From 37f4483fbb03944a5d1548a73017133bd8fc3708 Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Sun, 9 Aug 2026 21:58:03 +0300 Subject: [PATCH 01/23] Give a watchface its own page, and install it from there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The market card used to be a door into the editor: opening one meant parsing a megabyte of .bin before anything showed up, and flashing it to the watch meant going through a document you never intended to edit. /market/[id] shows the face instead — drawn for real by the editor's renderer against the wall clock, not as a still preview — and installs it straight from the bytes. The editor is one button among several now, and it opens at once and loads behind its own spinner. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny --- src/lib/modules/editor/model/io.model.ts | 25 +- src/lib/modules/editor/pages/editor.svelte | 15 + .../market/components/live-dial/index.ts | 1 + .../components/live-dial/live-dial.svelte | 47 ++++ .../watchface-card/watchface-card.svelte | 2 +- src/lib/modules/market/model/market.api.ts | 6 + src/lib/modules/market/model/market.model.ts | 154 +++++++++-- src/lib/modules/market/pages/index.ts | 1 + src/lib/modules/market/pages/market.svelte | 30 +- src/lib/modules/market/pages/my.svelte | 2 +- src/lib/modules/market/pages/watchface.svelte | 260 ++++++++++++++++++ src/routes/(app)/market/[id]/+page.svelte | 5 + 12 files changed, 516 insertions(+), 32 deletions(-) create mode 100644 src/lib/modules/market/components/live-dial/index.ts create mode 100644 src/lib/modules/market/components/live-dial/live-dial.svelte create mode 100644 src/lib/modules/market/pages/watchface.svelte create mode 100644 src/routes/(app)/market/[id]/+page.svelte diff --git a/src/lib/modules/editor/model/io.model.ts b/src/lib/modules/editor/model/io.model.ts index 3c28778..30ec2d2 100644 --- a/src/lib/modules/editor/model/io.model.ts +++ b/src/lib/modules/editor/model/io.model.ts @@ -1,7 +1,8 @@ // Getting documents in and out: opening a .bin, starting a blank face, importing a Facer or // WatchMaker export, and building the bytes back. The assets are re-encoded on the way out only // (flushAssets), so everything the editor did to the pixels stays non-destructive until export. -import { attach, createEffect, createEvent, createStore, sample } from "effector"; +import { attach, combine, createEffect, createEvent, createStore, sample } from "effector"; +import { reset } from "patronum"; import { buildBin, parseBin } from "../core/format"; import { fromLegacy, toLegacy, type Doc, type ImageId } from "../core/document/doc"; import { saveNames, withNames } from "../core/document/names"; @@ -32,9 +33,16 @@ export const exportBin = createEvent(); /** Fired on any successful load — pages that need to react (navigate once the face is ready) * subscribe, others ignore it. */ export const loadDone = createEvent<{ doc: Doc; label: string }>(); +/** "Bytes are on their way" — a marketplace open fetches the .bin before it can call + * loadRequested, and the editor is already on screen by then. Without this the page looked + * idle for the whole download. */ +export const bytesAwaited = createEvent(); // ---- stores ---- export const $faceKey = createStore(null); +// bytesAwaited until the load they belong to settles — or until the fetch that promised them +// fails, which the caller reports by way of its own error +const $awaitingBytes = createStore(false); // ---- effects ---- const loadBufferFx = createEffect( @@ -48,7 +56,12 @@ const loadBufferFx = createEffect( const saveNamesFx = createEffect(({ key, doc }: { key: string; doc: Doc }) => saveNames(key, doc)); -export const $loading = loadBufferFx.pending; +/** Covers the whole wait, not just the parse: the fetch that precedes it counts too. */ +export const $loading = combine( + loadBufferFx.pending, + $awaitingBytes, + (parsing, awaiting) => parsing || awaiting, +); const newFaceFx = createEffect(async (name: string = "Custom") => { const doc = blankDoc( @@ -160,6 +173,14 @@ const exportBinFx = attach({ }); // ---- business logic ---- +sample({ + clock: bytesAwaited, + fn: () => true, + target: $awaitingBytes, +}); +// the load itself takes over from here; errored covers the fetch that never got to call it +reset({ clock: [loadRequested, errored], target: $awaitingBytes }); + sample({ clock: loadRequested, target: loadBufferFx, diff --git a/src/lib/modules/editor/pages/editor.svelte b/src/lib/modules/editor/pages/editor.svelte index 9a6fe03..b492c4c 100644 --- a/src/lib/modules/editor/pages/editor.svelte +++ b/src/lib/modules/editor/pages/editor.svelte @@ -2,6 +2,7 @@ import { Button } from "$lib/shared/components/button"; import { Tabs } from "$lib/shared/components/tabs"; import { Dialog } from "$lib/shared/components/dialog"; + import { Loader } from "$lib/shared/components/loader"; import { Icon } from "$lib/shared/components/icon"; import { authModel } from "$lib/modules/auth/model"; import { marketModel } from "$lib/modules/market/model"; @@ -78,6 +79,7 @@ previewThumb, $rightPanel: rightPanel, rightPanelSet, + $loading: loading, } = editorModel; let canvas = $state(null); @@ -904,6 +906,11 @@ onpointermove={onMove} onpointerup={onUp} > + + {#if $loading} +
+ {/if} {#if perf && $doc} @@ -1148,6 +1155,7 @@ padding: 1.5rem; } .canvas-frame { + position: relative; /* the loading overlay sits on the dial */ aspect-ratio: 1; width: min(70vh, 90%, 35rem); min-width: var(--canvas-min); @@ -1168,6 +1176,13 @@ height: 100%; touch-action: none; } + .canvas-loading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: oklch(1 0 0 / 70%); + } /* dev only — same overlay treatment as .hint, pinned to the opposite corner */ .fps { position: absolute; diff --git a/src/lib/modules/market/components/live-dial/index.ts b/src/lib/modules/market/components/live-dial/index.ts new file mode 100644 index 0000000..4b6c8b4 --- /dev/null +++ b/src/lib/modules/market/components/live-dial/index.ts @@ -0,0 +1 @@ +export { default as LiveDial } from "./live-dial.svelte"; diff --git a/src/lib/modules/market/components/live-dial/live-dial.svelte b/src/lib/modules/market/components/live-dial/live-dial.svelte new file mode 100644 index 0000000..9d41f01 --- /dev/null +++ b/src/lib/modules/market/components/live-dial/live-dial.svelte @@ -0,0 +1,47 @@ + + + + + diff --git a/src/lib/modules/market/components/watchface-card/watchface-card.svelte b/src/lib/modules/market/components/watchface-card/watchface-card.svelte index 6985628..32e25cc 100644 --- a/src/lib/modules/market/components/watchface-card/watchface-card.svelte +++ b/src/lib/modules/market/components/watchface-card/watchface-card.svelte @@ -37,7 +37,7 @@
-
+
{wf.name}
diff --git a/src/lib/modules/market/model/market.api.ts b/src/lib/modules/market/model/market.api.ts index 48bb3ab..ac5f2e4 100644 --- a/src/lib/modules/market/model/market.api.ts +++ b/src/lib/modules/market/model/market.api.ts @@ -20,6 +20,12 @@ export const loadMarketFx = createEffect(async () => { return { items, likes }; }); +// one record for its own page (/market/[id]) — a deep link has no catalog list to read from, +// and $items only ever holds published faces +export const loadWfFx = createEffect((id: string) => + pb.collection("watchfaces").getOne(id, { expand: "owner" }), +); + export const loadMyFx = createEffect((userId: string) => pb.collection("watchfaces").getFullList({ sort: "-updated", filter: `owner = '${userId}'` }), ); diff --git a/src/lib/modules/market/model/market.model.ts b/src/lib/modules/market/model/market.model.ts index c2ff406..f447ffe 100644 --- a/src/lib/modules/market/model/market.model.ts +++ b/src/lib/modules/market/model/market.model.ts @@ -7,6 +7,13 @@ import { fileUrl } from "$lib/shared/api"; import { authModel } from "$lib/modules/auth/model"; import { bleModel } from "$lib/modules/device/model"; import { editorModel } from "$lib/modules/editor/model"; +// The watchface page draws the real dial rather than the still preview, so it needs the editor's +// domain layer (the .bin reader and the renderer's inputs) — not its model, which is the editor's +// own session. Everything the renderer itself needs stays in the component that draws. +import { parseBin } from "$lib/modules/editor/core/format"; +import { fromLegacy, type Doc } from "$lib/modules/editor/core/document/doc"; +import { decodeAssets } from "$lib/modules/editor/core/render/pixels"; +import type { ImageStore } from "$lib/modules/editor/core/render/canvas"; import * as marketApi from "./market.api"; export type { SavePayload } from "./market.api"; @@ -33,9 +40,6 @@ export const $foreignWf = combine( $openedWf, (loaded, opened) => Boolean(loaded) && !opened, ); -// editorModel.loadDone also fires for unrelated loads (drag-drop import on /editor) — only -// navigate when the load we're waiting on is specifically the one editRequested started -const $awaitingEdit = createStore(false); // editor.svelte's "Save" and PublishDialog's "Publish" both hit saveFx but need different // done/error handling (Publish also navigates + closes the dialog) and are mounted on the same // page at the same time — a shared done/err reaction would make one react to the other's call, @@ -46,6 +50,21 @@ export const $publishDialogOpen = createStore(false); // marketLoadRequested fires on every market.svelte mount — load the catalog once per session, // a page revisit reuses $items; reloading needs a full page refresh (or removeFx's own reload below) const $marketRequestedOnce = createStore(false); +// the watchface open on its own page (/market/[id]) — fetched by id, not read out of $items +export const $wf = createStore(null); +export const $wfLoading = marketApi.loadWfFx.pending; +// the one being flashed straight from that page, without the editor — the downloads bump +// below reads it first, $loadedWf only ever knows what the editor has open +const $installingWf = createStore(null); +export const $installed = createStore(false); +/** The parsed face behind that page: what the canvas draws, ticking against the real clock. */ +export interface LiveFace { + doc: Doc; + store: ImageStore; +} +export const $live = createStore(null); +// the same bytes an install sends — fetched once, whichever happens first +const $bin = createStore(null); export const $likes = createStore([]); export const $items = createStore([]); export const $myItems = createStore([]); @@ -59,9 +78,12 @@ export const publishToggleRequested = createEvent(); export const openedWfSet = createEvent(); // fired by the component on New / drag-drop import — the loaded face has no backing record export const faceDetached = createEvent(); -// "open in editor" from a market/my card: fetch the .bin, hand it to the editor model, then -// navigate once it's actually loaded — used by both pages (market.svelte, my.svelte) +// "open in editor" from a market/my card or the watchface page: go to the editor at once and +// fetch the .bin behind it, handing the bytes to the editor model when they land export const editRequested = createEvent(); +// the watchface page: load the record by id, and flash it to the watch without the editor +export const wfLoadRequested = createEvent(); +export const installRequested = createEvent(); export const saveDraftRequested = createEvent(); export const publishRequested = createEvent(); export const publishDialogOpened = createEvent(); @@ -78,6 +100,33 @@ const openInEditorFx = createEffect(async (wf: RecordModel) => { return { wf, buf }; }); const navigateToEditorFx = createEffect(() => goto("/editor")); +const binOfFx = createEffect( + async (wf: RecordModel) => new Uint8Array(await (await fetch(fileUrl(wf, "bin"))).arrayBuffer()), +); +// the still preview is a fallback, not the plan: parse the file and hand the renderer the same +// two things the editor gives it — the document and its decoded pixels +const liveFx = createEffect(async (bin: Uint8Array): Promise => { + const { doc } = fromLegacy(parseBin(bin)); + + return { doc, store: { assets: doc.images, cache: await decodeAssets(doc.images) } }; +}); +// install from the watchface page: the .bin is all the watch needs, the editor never enters it +const fetchBinFx = attach({ + source: $bin, + async effect(cached, wf: RecordModel) { + return { + bin: cached ?? (await binOfFx(wf)), + // the watch reports ids only — the market preview is what makes it recognisable later + preview: fileUrl(wf, "preview"), + key: wf.id, + }; + }, +}); +export const $installing = combine( + fetchBinFx.pending, + bleModel.$flashing, + (fetching, flashing) => fetching || flashing, +); // resolves openedId from $openedWf so the api layer doesn't need to know about model state const saveFx = attach({ source: $openedWf, @@ -145,12 +194,75 @@ sample({ sample({ clock: faceDetached, fn: () => null, - target: [$openedWf, $loadedWf, editorModel.faceKeySet], + target: [$openedWf, $loadedWf, $installingWf, editorModel.faceKeySet], +}); + +sample({ + clock: wfLoadRequested, + target: marketApi.loadWfFx, +}); +sample({ + clock: marketApi.loadWfFx.doneData, + target: $wf, +}); +// Coming from a list, the record is already in hand — show it (preview, name, author) on the +// spot and let the fetch below refresh it. A deep link finds nothing cached and falls back to +// the skeleton. Also what clears the previous face, so no reset() for $wf. +sample({ + clock: wfLoadRequested, + source: { items: $items, mine: $myItems }, + fn: ({ items, mine }, id) => + items.find((i) => i.id === id) ?? mine.find((i) => i.id === id) ?? null, + target: $wf, +}); +// a different face on screen than the one that was just installed +reset({ clock: wfLoadRequested, target: [$installed, $live, $bin] }); + +// the page draws the dial for real: fetch the file, parse it, decode its pixels +sample({ + clock: marketApi.loadWfFx.doneData, + target: binOfFx, +}); +sample({ + clock: binOfFx.doneData, + target: [$bin, liveFx], +}); +sample({ + clock: liveFx.doneData, + target: $live, +}); +// a file we can't parse or decode isn't worth an error banner — the still preview stays up, +// and everything else on the page (install included) works off the bytes regardless + +sample({ + clock: installRequested, + target: [fetchBinFx, $installingWf], +}); +sample({ + clock: fetchBinFx.doneData, + target: bleModel.flashRequested, +}); +sample({ + clock: bleModel.flashDone, + source: $installingWf, + filter: Boolean, + fn: () => true, + target: $installed, +}); +reset({ clock: installRequested, target: $installed }); +// whichever of the two acted last owns the flash: opening a face in the editor hands the +// downloads bump back to $loadedWf +sample({ + clock: openInEditorFx.doneData, + fn: () => null, + target: $installingWf, }); +// Open the editor first and let it show its own loading state: the .bin is a megabyte over the +// network, and waiting for it on the page the user just left off reads as a frozen site. sample({ clock: editRequested, - target: openInEditorFx, + target: [openInEditorFx, navigateToEditorFx, editorModel.bytesAwaited], }); sample({ clock: openInEditorFx.failData, @@ -176,23 +288,6 @@ sample({ target: editorModel.loadRequested, }); -sample({ - clock: openInEditorFx.doneData, - fn: () => true, - target: $awaitingEdit, -}); -sample({ - clock: editorModel.loadDone, - source: $awaitingEdit, - filter: Boolean, - target: navigateToEditorFx, -}); -sample({ - clock: editorModel.loadDone, - fn: () => false, - target: $awaitingEdit, -}); - sample({ clock: saveFx.doneData, target: openedWfSet, @@ -351,6 +446,8 @@ sample({ clock: [ marketApi.loadMarketFx.failData, marketApi.loadMyFx.failData, + marketApi.loadWfFx.failData, + fetchBinFx.failData, toggleLikeFx.failData, marketApi.removeFx.failData, marketApi.togglePublishFx.failData, @@ -366,7 +463,7 @@ sample({ // downloads counter also bumps on a successful flash to the watch — no auth check, own or not sample({ clock: bleModel.flashDone, - source: $loadedWf, + source: combine($installingWf, $loadedWf, (installing, loaded) => installing || loaded), filter: Boolean, fn: (wf) => wf.id, target: marketApi.bumpDownloadsFx, @@ -383,6 +480,13 @@ sample({ fn: (list, { params: wfId }) => bumpDownloads(list, wfId), target: $myItems, }); +sample({ + clock: marketApi.bumpDownloadsFx.done, + source: $wf, + filter: (wf, { params: wfId }) => wf?.id === wfId, + fn: (wf, _p) => ({ ...wf!, downloads: (wf!.downloads || 0) + 1 }), + target: $wf, +}); // any successful load clears the banner — otherwise a one-off failure (or a request the SDK // auto-cancelled) stayed on screen for the rest of the session, /my never reset it at all diff --git a/src/lib/modules/market/pages/index.ts b/src/lib/modules/market/pages/index.ts index 57c10d5..525670e 100644 --- a/src/lib/modules/market/pages/index.ts +++ b/src/lib/modules/market/pages/index.ts @@ -1,2 +1,3 @@ export { default as MarketPage } from "./market.svelte"; export { default as MyPage } from "./my.svelte"; +export { default as WatchfacePage } from "./watchface.svelte"; diff --git a/src/lib/modules/market/pages/market.svelte b/src/lib/modules/market/pages/market.svelte index cef0f6f..503c888 100644 --- a/src/lib/modules/market/pages/market.svelte +++ b/src/lib/modules/market/pages/market.svelte @@ -4,6 +4,7 @@ import { Skeleton } from "$lib/shared/components/skeleton"; import { Icon } from "$lib/shared/components/icon"; import type { RecordModel } from "pocketbase"; + import { goto } from "$app/navigation"; import { authModel } from "$lib/modules/auth/model"; import { marketModel } from "../model"; import { WatchfaceCard } from "../components/watchface-card"; @@ -17,7 +18,6 @@ marketLoadRequested, likeToggleRequested, removeRequested, - editRequested, } = marketModel; marketLoadRequested(); @@ -113,7 +113,7 @@ liked={!!myLike(wf.id)} canLike={!!$user} canRemove={$user?.id === wf.owner} - onOpen={() => editRequested(wf)} + onOpen={() => goto(`/market/${wf.id}`)} onLike={() => $user && likeToggleRequested({ wf, userId: $user.id })} onRemove={() => remove(wf)} /> @@ -167,12 +167,31 @@ .sort { width: 8.125rem; } + /* a phone can't fit both fixed widths, and wrapping left the search pinned right on a row + of its own — let it take whatever the sort doesn't */ + @media (max-width: 767px) { + .toolbar { + flex-wrap: nowrap; + gap: 0.5rem; + padding: 0.5rem; + } + .search { + flex: 1; + width: auto; + margin-inline-start: 0; + } + .sort { + width: 7.5rem; + flex: none; + } + } main { overflow-y: auto; } .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(11.875rem, 1fr)); + /* the min() keeps two columns on a phone, where a fixed 8.875rem would drop to one */ + grid-template-columns: repeat(auto-fill, minmax(min(8.875rem, 50%), 1fr)); align-items: start; gap: 1rem; padding: 1rem; @@ -181,6 +200,11 @@ min-height: auto; height: 100%; } + + @media (max-width: 767px) { + gap: 0.5rem; + padding: 0.5rem; + } } .skeleton-card { display: flex; diff --git a/src/lib/modules/market/pages/my.svelte b/src/lib/modules/market/pages/my.svelte index 5c27497..983fe44 100644 --- a/src/lib/modules/market/pages/my.svelte +++ b/src/lib/modules/market/pages/my.svelte @@ -46,7 +46,7 @@ {#each $myItems as wf (wf.id)}
-
diff --git a/src/lib/modules/market/pages/watchface.svelte b/src/lib/modules/market/pages/watchface.svelte new file mode 100644 index 0000000..c34cb24 --- /dev/null +++ b/src/lib/modules/market/pages/watchface.svelte @@ -0,0 +1,260 @@ + + +{$wf?.name || "Watchface"} — FMC Watchfaces + +
+ ← Market + + {#if $marketErr}

{$marketErr}

{/if} + + {#if $wf} +
+
+ + {#if $live} + + {:else} + {$wf.name} + {/if} +
+ +
+
+

{$wf.name}

+ {#if $wf.type}{$wf.type}{/if} +
+ by {$wf.expand?.owner?.name || "—"} + + {#if $wf.description}

{$wf.description}

{/if} + +
+ {#if !$bleInfo} + + {:else if $installed} + + + {:else} + + {/if} +
+ {#if $bleStatus}{$bleStatus}{/if} + +
+ + + + + + {$wf.downloads || 0} + + + + +
+
+
+ {:else if $wfLoading} +
+ +
+ + +
+
+ {/if} +
+ + diff --git a/src/routes/(app)/market/[id]/+page.svelte b/src/routes/(app)/market/[id]/+page.svelte new file mode 100644 index 0000000..a4f7a12 --- /dev/null +++ b/src/routes/(app)/market/[id]/+page.svelte @@ -0,0 +1,5 @@ + + + From 158ab17a033b06c4f57da087aa7e0ca8b9a71b11 Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Sun, 9 Aug 2026 21:58:03 +0300 Subject: [PATCH 02/23] Read body text in Inter --font-mono was an alias for --font-family, so the whole UI was Geist Mono. The places that ask for mono (sim panel, shortcuts, frame numbers) mean it; the rest just inherited it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny --- src/app.html | 2 +- src/lib/styles/global.css | 3 ++- src/lib/styles/tokens.css | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app.html b/src/app.html index d14a963..0b6ae20 100644 --- a/src/app.html +++ b/src/app.html @@ -6,7 +6,7 @@ %sveltekit.head% diff --git a/src/lib/styles/global.css b/src/lib/styles/global.css index c6d7aaa..2968c8a 100644 --- a/src/lib/styles/global.css +++ b/src/lib/styles/global.css @@ -1,6 +1,7 @@ /* Minimal global base — replaces what Tailwind preflight / app.css @layer base used to do. */ -/* Geist Mono self-hosted (OFL); Instrument Serif comes from Google Fonts in app.html. */ +/* Geist Mono self-hosted (OFL), used only where --font-mono is asked for; Inter (body) and + Instrument Serif (headings) come from Google Fonts in app.html. */ @font-face { font-family: "Geist Mono"; src: url(/fonts/geistmono.ttf) format("truetype"); diff --git a/src/lib/styles/tokens.css b/src/lib/styles/tokens.css index 7717428..b6e4f1f 100644 --- a/src/lib/styles/tokens.css +++ b/src/lib/styles/tokens.css @@ -16,9 +16,11 @@ --border-radius: 0.5rem; - --font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --font-family: "Inter", system-ui, -apple-system, sans-serif; --font-display: "Instrument Serif", Georgia, serif; - --font-mono: var(--font-family); + /* its own stack now that the body text isn't mono — the places that ask for --font-mono + (sim panel, shortcuts, frame numbers) mean it literally */ + --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; /* spring easing shared by all micro-interactions (from friendzone) */ --spring-transition: 0.5s From 0d690dba546d35bedf7b478fb9a02cfd4c6af2ce Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Sun, 9 Aug 2026 22:11:26 +0300 Subject: [PATCH 03/23] Add a bottom sheet that dismisses by scrolling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay is a scroll container with two snap points — an empty screen, then the sheet — so swiping it down is ordinary scrolling with native inertia, and arriving back at the top is what closes it. No pointer handlers, no drag maths. hosts it, so the top layer, Esc and the focus trap come for free. The trick is lifted from friendzone's drawer; the keyboard-offset half of it isn't here, since nothing in this app types into a sheet yet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny --- src/lib/shared/components/sheet/index.ts | 1 + src/lib/shared/components/sheet/sheet.svelte | 179 +++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 src/lib/shared/components/sheet/index.ts create mode 100644 src/lib/shared/components/sheet/sheet.svelte diff --git a/src/lib/shared/components/sheet/index.ts b/src/lib/shared/components/sheet/index.ts new file mode 100644 index 0000000..06abf6d --- /dev/null +++ b/src/lib/shared/components/sheet/index.ts @@ -0,0 +1 @@ +export { default as Sheet } from "./sheet.svelte"; diff --git a/src/lib/shared/components/sheet/sheet.svelte b/src/lib/shared/components/sheet/sheet.svelte new file mode 100644 index 0000000..7fdcf46 --- /dev/null +++ b/src/lib/shared/components/sheet/sheet.svelte @@ -0,0 +1,179 @@ + + + { + armed = false; + progress = 0; + onClose?.(); + }} + oncancel={(e) => { + e.preventDefault(); /* let Esc slide it out too */ + dismiss(); + }} +> +
+ + + +
+
+
+ + +
e.stopPropagation()}> +
+
+ {#if title}

{title}

{/if} + +
+
{@render children?.()}
+
+
+
+
+ + From 8d9bb8523907cd9953061255b9dcb665c9d1ac6d Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Sun, 9 Aug 2026 22:17:31 +0300 Subject: [PATCH 04/23] Show the watch panel as a sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was a popover anchored to its trigger, which meant two placements, a click-outside listener and an Escape listener — all so the same panel could hang off the header on a desktop and off the tab bar on a phone. As a sheet it is one shape in both places, and already knows how to do the rest. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01U7TLWwHXLcXB4tjPhc44ny --- .../device/components/watch-popover.svelte | 201 ------------------ .../device/components/watch-sheet.svelte | 153 +++++++++++++ .../components/app-header/app-header.svelte | 6 +- src/lib/shared/components/bottom-nav.svelte | 34 ++- 4 files changed, 168 insertions(+), 226 deletions(-) delete mode 100644 src/lib/modules/device/components/watch-popover.svelte create mode 100644 src/lib/modules/device/components/watch-sheet.svelte diff --git a/src/lib/modules/device/components/watch-popover.svelte b/src/lib/modules/device/components/watch-popover.svelte deleted file mode 100644 index d1ea18c..0000000 --- a/src/lib/modules/device/components/watch-popover.svelte +++ /dev/null @@ -1,201 +0,0 @@ - - - { - if (open && root && !root.contains(e.target as Node)) open = false; - }} - onkeydown={(e) => { - if (open && e.key === "Escape") open = false; - }} -/> - -
- {@render trigger({ - open, - toggle: () => (open = !open), - connected: !!$bleInfo, - })} - {#if open} -
- - {#if !$bleInfo} -
-
-

Connect your watch

-

CMF Watch Pro 2 over Web Bluetooth.

-
-
- - - - - {#if $bleStatus}{$bleStatus}{/if} -
-
- {:else} -
-
-

CMF Watch Pro 2

-

{$bleStatus}

-
-
-

- - Battery: {$bleInfo.battery ?? "?"}% -

-

- Firmware: {$bleInfo.firmware ?? "?"} -

-

- Serial: {$bleInfo.serial ?? "?"} -

-
-
- - {#if $dials} -
-
-

- Watchfaces on the watch ({$dials.length}/{WF_CAPACITY}) -

-
-
- {#each $dials as id, i (id)} - - {:else} - the watch reported none - {/each} -
-
- {/if} - {/if} -
- {/if} -
- - diff --git a/src/lib/modules/device/components/watch-sheet.svelte b/src/lib/modules/device/components/watch-sheet.svelte new file mode 100644 index 0000000..a6774fe --- /dev/null +++ b/src/lib/modules/device/components/watch-sheet.svelte @@ -0,0 +1,153 @@ + + +{@render trigger({ + open, + toggle: () => (open = !open), + connected: !!$bleInfo, +})} + + (open = false)} +> + + {#if !$bleInfo} +
+

CMF Watch Pro 2 over Web Bluetooth.

+
+ + + + + {#if $bleStatus}{$bleStatus}{/if} +
+
+ {:else} +
+

{$bleStatus}

+
+

+ + Battery: {$bleInfo.battery ?? "?"}% +

+

+ Firmware: {$bleInfo.firmware ?? "?"} +

+

+ Serial: {$bleInfo.serial ?? "?"} +

+
+
+ + {#if $dials} +
+

+ Watchfaces on the watch ({$dials.length}/{WF_CAPACITY}) +

+
+ {#each $dials as id, i (id)} + + {:else} + the watch reported none + {/each} +
+
+ {/if} + {/if} +
+ + diff --git a/src/lib/shared/components/app-header/app-header.svelte b/src/lib/shared/components/app-header/app-header.svelte index 024d3b1..b2db2e3 100644 --- a/src/lib/shared/components/app-header/app-header.svelte +++ b/src/lib/shared/components/app-header/app-header.svelte @@ -2,7 +2,7 @@ import { page } from "$app/state"; import { Icon } from "$lib/shared/components/icon"; import { authModel } from "$lib/modules/auth/model"; - import WatchPopover from "$lib/modules/device/components/watch-popover.svelte"; + import WatchSheet from "$lib/modules/device/components/watch-sheet.svelte"; import { Avatar } from "$lib/shared/components/avatar"; import { Menu, MenuItem } from "$lib/shared/components/menu"; @@ -22,14 +22,14 @@ {item.title} {/each} - + {#snippet trigger({ open, toggle, connected })} {/snippet} - +
import { page } from "$app/state"; import { authModel } from "$lib/modules/auth/model"; - import WatchPopover from "$lib/modules/device/components/watch-popover.svelte"; + import WatchSheet from "$lib/modules/device/components/watch-sheet.svelte"; import { Icon, type IconName } from "$lib/shared/components/icon"; const { $user: user } = authModel; @@ -20,19 +20,17 @@ {item.title} {/each} -
- - {#snippet trigger({ open, toggle, connected })} - - {/snippet} - -
+ + {#snippet trigger({ open, toggle, connected })} + + {/snippet} + diff --git a/src/lib/shared/components/adaptive-popover/index.ts b/src/lib/shared/components/adaptive-popover/index.ts new file mode 100644 index 0000000..2e024d9 --- /dev/null +++ b/src/lib/shared/components/adaptive-popover/index.ts @@ -0,0 +1 @@ +export { default as AdaptivePopover } from "./adaptive-popover.svelte"; diff --git a/src/lib/shared/components/app-header/app-header.svelte b/src/lib/shared/components/app-header/app-header.svelte index b2db2e3..ee1dfb2 100644 --- a/src/lib/shared/components/app-header/app-header.svelte +++ b/src/lib/shared/components/app-header/app-header.svelte @@ -104,6 +104,8 @@ position: relative; display: inline-flex; align-items: center; + /* the watch popover (watch-sheet.svelte) anchors here */ + anchor-name: --watch-trigger; } .dot { position: absolute; diff --git a/src/lib/shared/components/popover/index.ts b/src/lib/shared/components/popover/index.ts new file mode 100644 index 0000000..d7423ea --- /dev/null +++ b/src/lib/shared/components/popover/index.ts @@ -0,0 +1 @@ +export { default as Popover } from "./popover.svelte"; diff --git a/src/lib/shared/components/popover/popover.svelte b/src/lib/shared/components/popover/popover.svelte new file mode 100644 index 0000000..4bb301a --- /dev/null +++ b/src/lib/shared/components/popover/popover.svelte @@ -0,0 +1,92 @@ + + +
e.newState === "closed" && onClose?.()} +> + + {@render children?.()} +
+ + From b018680b9b94f8acc76ec5f14d3ce597a65feffd Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Thu, 13 Aug 2026 23:08:44 +0300 Subject: [PATCH 21/23] Move inspector field labels inside the box, and join button groups into pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Input grows optional label/unit props rendered inside the field's own box ([x 183], [100 %]), Figma-inspector style: the box carries the chrome, focus rides :focus-within, and number spinners are hidden — arrow keys still step. Geometry and source drop their outside field-labels for it. The align button groups become a single recessed pill with equal segments, the active one raised and accent-tinted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax --- .../editor/components/PropsPanel.svelte | 28 ++++++- .../editor/components/props/geometry.svelte | 24 +++--- .../editor/components/props/source.svelte | 4 +- src/lib/shared/components/input/input.svelte | 83 ++++++++++++++----- 4 files changed, 96 insertions(+), 43 deletions(-) diff --git a/src/lib/modules/editor/components/PropsPanel.svelte b/src/lib/modules/editor/components/PropsPanel.svelte index 7ba1d31..ea07bc0 100644 --- a/src/lib/modules/editor/components/PropsPanel.svelte +++ b/src/lib/modules/editor/components/PropsPanel.svelte @@ -95,9 +95,27 @@ width: 4rem; flex-shrink: 0; } + /* a segmented pill: one recessed track, equal segments, the active one raised */ .btn-group { display: flex; - gap: 0.25rem; + flex: 1; + gap: 0.125rem; + padding: 0.125rem; + background: oklch(from var(--color-text) l c h / 5%); + border-radius: var(--border-radius); + + .icon-btn { + flex: 1; + width: auto; + height: 1.625rem; + border: none; + border-radius: calc(var(--border-radius) - 0.125rem); + + &.on { + background: oklch(from var(--color-text) l c h / 10%); + color: var(--color-accent); + } + } } .grow { flex: 1; @@ -115,9 +133,11 @@ cursor: pointer; transition: background-color 0.15s ease; - &:hover { - background: oklch(from var(--color-text) l c h / 6%); - color: var(--color-text); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 6%); + color: var(--color-text); + } } &.on { border-color: var(--color-accent); diff --git a/src/lib/modules/editor/components/props/geometry.svelte b/src/lib/modules/editor/components/props/geometry.svelte index 9871ae9..3e6f827 100644 --- a/src/lib/modules/editor/components/props/geometry.svelte +++ b/src/lib/modules/editor/components/props/geometry.svelte @@ -90,14 +90,14 @@ {/if} {#if placed && !frame}
- x set(layer.id, { x: num(v) } as Partial)} /> - y set(layer.id, { y: num(v) } as Partial)} @@ -106,8 +106,8 @@ {/if} {#if resSize}
- w resize(num(v), $lockAspect ? Math.round((num(v) * resSize.h) / resSize.w) : resSize.h)} /> - h - x - setFrame({ x: num(v) })} /> - y - setFrame({ y: num(v) })} /> + setFrame({ x: num(v) })} /> + setFrame({ y: num(v) })} />
- w - setFrame({ w: num(v) })} /> - h - setFrame({ h: num(v) })} /> + setFrame({ w: num(v) })} /> + setFrame({ h: num(v) })} />
align @@ -182,14 +178,14 @@ {/if} {#if hand}
- pivot x set(layer.id, { pivotX: num(v) } as Partial)} /> - y set(layer.id, { pivotY: num(v) } as Partial)} diff --git a/src/lib/modules/editor/components/props/source.svelte b/src/lib/modules/editor/components/props/source.svelte index 4af9a49..5f4939f 100644 --- a/src/lib/modules/editor/components/props/source.svelte +++ b/src/lib/modules/editor/components/props/source.svelte @@ -171,9 +171,9 @@ {/if} {#if num_}
- digits - + void; // native `change` — fires on blur/Enter/stepper, for edits too expensive to run per keystroke onChange?: (value: string) => void; @@ -29,51 +33,84 @@ min, max, step, + label, + unit, onInput, onChange, }: Props = $props(); - onInput?.(value)} - onchange={() => onChange?.(value)} -/> + + From 9ee0a534cf2621ec549c83045ec56ebdb1e58296 Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Thu, 13 Aug 2026 23:08:51 +0300 Subject: [PATCH 22/23] Sweep the mobile-web papercuts Global: kill the tap-highlight flash, touch-action: manipulation on controls, user-select: none on buttons, overscroll-behavior-y: none on html (an SPA has no pull-to-refresh to offer), theme-color metas per scheme, and 16px fields on coarse pointers so iOS stops zooming into focused inputs. Every :hover moves under @media (hover: hover) so a tap doesn't leave a stuck hover state; combined hover/focus-visible selectors are split so keyboard focus keeps working everywhere. The layer-tree flags inverted instead: they only appeared on row hover, so on touch they are simply always visible. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax --- src/app.html | 3 ++ .../device/components/watch-selector.svelte | 10 +++++-- .../editor/components/TreePanel.svelte | 26 ++++++++++++---- .../components/props/frame-thumb.svelte | 6 +++- .../editor/components/props/frames.svelte | 6 ++-- src/lib/modules/editor/pages/editor.svelte | 12 ++++++-- .../watchface-card/watchface-card.svelte | 6 ++-- src/lib/modules/market/pages/watchface.svelte | 12 +++++--- .../adaptive-popover/adaptive-popover.svelte | 6 ++-- .../components/app-header/app-header.svelte | 14 +++++---- .../shared/components/button/button.svelte | 12 +++++--- .../shared/components/dialog/dialog.svelte | 6 ++-- .../shared/components/menu/menu-item.svelte | 6 ++-- .../shared/components/slider/slider.svelte | 10 +++++-- src/lib/styles/global.css | 30 +++++++++++++++++++ 15 files changed, 128 insertions(+), 37 deletions(-) diff --git a/src/app.html b/src/app.html index 0b6ae20..42dc46b 100644 --- a/src/app.html +++ b/src/app.html @@ -3,6 +3,9 @@ + + + diff --git a/src/lib/modules/editor/components/props/frames.svelte b/src/lib/modules/editor/components/props/frames.svelte index 24c17ba..0b4f073 100644 --- a/src/lib/modules/editor/components/props/frames.svelte +++ b/src/lib/modules/editor/components/props/frames.svelte @@ -137,8 +137,10 @@ padding: 0.125rem 0.25rem; cursor: grab; - &:hover { - background: oklch(from var(--color-text) l c h / 6%); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 6%); + } } &.dragging { opacity: 0.4; diff --git a/src/lib/modules/editor/pages/editor.svelte b/src/lib/modules/editor/pages/editor.svelte index b492c4c..37d2233 100644 --- a/src/lib/modules/editor/pages/editor.svelte +++ b/src/lib/modules/editor/pages/editor.svelte @@ -1036,8 +1036,10 @@ text-overflow: ellipsis; white-space: nowrap; - &:hover { - border-color: oklch(from var(--color-text) l c h / 12%); + @media (hover: hover) { + &:hover { + border-color: oklch(from var(--color-text) l c h / 12%); + } } &:focus { border-color: var(--color-accent); @@ -1108,11 +1110,15 @@ touch-action: none; transition: background-color 0.15s ease; - &:hover, &:focus-visible, &.active { background: oklch(from var(--color-accent) l c h / 35%); } + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-accent) l c h / 35%); + } + } } .gutter-tree { inset-inline-end: -0.25rem; diff --git a/src/lib/modules/market/components/watchface-card/watchface-card.svelte b/src/lib/modules/market/components/watchface-card/watchface-card.svelte index 32e25cc..4289b1b 100644 --- a/src/lib/modules/market/components/watchface-card/watchface-card.svelte +++ b/src/lib/modules/market/components/watchface-card/watchface-card.svelte @@ -164,8 +164,10 @@ font-size: 0.75rem; transition: background-color 0.15s ease; - &:hover { - background-color: oklch(from var(--color-text) l c h / 20%); + @media (hover: hover) { + &:hover { + background-color: oklch(from var(--color-text) l c h / 20%); + } } } diff --git a/src/lib/modules/market/pages/watchface.svelte b/src/lib/modules/market/pages/watchface.svelte index c34cb24..c7e3fca 100644 --- a/src/lib/modules/market/pages/watchface.svelte +++ b/src/lib/modules/market/pages/watchface.svelte @@ -148,8 +148,10 @@ text-decoration: none; color: oklch(from var(--color-text) l c h / 55%); - &:hover { - color: var(--color-text); + @media (hover: hover) { + &:hover { + color: var(--color-text); + } } } .error { @@ -237,8 +239,10 @@ text-decoration: none; font-size: 0.75rem; - &:hover { - background-color: oklch(from var(--color-text) l c h / 20%); + @media (hover: hover) { + &:hover { + background-color: oklch(from var(--color-text) l c h / 20%); + } } } @media (max-width: 767px) { diff --git a/src/lib/shared/components/adaptive-popover/adaptive-popover.svelte b/src/lib/shared/components/adaptive-popover/adaptive-popover.svelte index b62da2c..a3f184b 100644 --- a/src/lib/shared/components/adaptive-popover/adaptive-popover.svelte +++ b/src/lib/shared/components/adaptive-popover/adaptive-popover.svelte @@ -77,8 +77,10 @@ color: oklch(from var(--color-text) l c h / 55%); cursor: pointer; - &:hover { - background: oklch(from var(--color-text) l c h / 8%); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 8%); + } } } diff --git a/src/lib/shared/components/app-header/app-header.svelte b/src/lib/shared/components/app-header/app-header.svelte index ee1dfb2..62551d4 100644 --- a/src/lib/shared/components/app-header/app-header.svelte +++ b/src/lib/shared/components/app-header/app-header.svelte @@ -91,8 +91,10 @@ border-radius: calc(var(--border-radius) / 1.5); color: oklch(from var(--color-text) l c h / 60%); - &:hover { - background: oklch(from var(--color-text) l c h / 6%); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 6%); + } } &.active { color: var(--color-text); @@ -131,9 +133,11 @@ border-radius: 50%; color: oklch(from var(--color-text) l c h / 55%); - &:hover { - background: oklch(from var(--color-text) l c h / 6%); - color: var(--color-text); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 6%); + color: var(--color-text); + } } } .avatar-btn { diff --git a/src/lib/shared/components/button/button.svelte b/src/lib/shared/components/button/button.svelte index 8a7e18b..b151560 100644 --- a/src/lib/shared/components/button/button.svelte +++ b/src/lib/shared/components/button/button.svelte @@ -40,8 +40,10 @@ transform var(--spring-transition), background-color 0.15s ease; - &:hover:not(:disabled) { - background-color: oklch(from var(--color, var(--color-text)) l c h / 20%); + @media (hover: hover) { + &:hover:not(:disabled) { + background-color: oklch(from var(--color, var(--color-text)) l c h / 20%); + } } &:active:not(:disabled) { transform: scale(0.97); @@ -63,8 +65,10 @@ .kind__primary { background-color: var(--color-accent); color: var(--color-background); - &:hover:not(:disabled) { - background-color: oklch(from var(--color-accent) calc(l * 1.08) c h); + @media (hover: hover) { + &:hover:not(:disabled) { + background-color: oklch(from var(--color-accent) calc(l * 1.08) c h); + } } } .kind__secondary { diff --git a/src/lib/shared/components/dialog/dialog.svelte b/src/lib/shared/components/dialog/dialog.svelte index bdcb800..26db9f1 100644 --- a/src/lib/shared/components/dialog/dialog.svelte +++ b/src/lib/shared/components/dialog/dialog.svelte @@ -109,8 +109,10 @@ color: oklch(from var(--color-text) l c h / 55%); padding: 0.25rem; border-radius: 0.375rem; - &:hover { - background: oklch(from var(--color-text) l c h / 8%); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 8%); + } } } .body { diff --git a/src/lib/shared/components/menu/menu-item.svelte b/src/lib/shared/components/menu/menu-item.svelte index cb93716..390282a 100644 --- a/src/lib/shared/components/menu/menu-item.svelte +++ b/src/lib/shared/components/menu/menu-item.svelte @@ -35,8 +35,10 @@ border-radius: calc(var(--border-radius) - 0.25rem); text-align: start; - &:hover { - background: oklch(from var(--color-text) l c h / 6%); + @media (hover: hover) { + &:hover { + background: oklch(from var(--color-text) l c h / 6%); + } } } .danger { diff --git a/src/lib/shared/components/slider/slider.svelte b/src/lib/shared/components/slider/slider.svelte index d21b134..ca38c4d 100644 --- a/src/lib/shared/components/slider/slider.svelte +++ b/src/lib/shared/components/slider/slider.svelte @@ -105,14 +105,20 @@ box-shadow: 0 1px 4px oklch(from var(--color-text) l c h / 45%); transition: transform var(--spring-transition, 0.15s ease); } - input:hover::-webkit-slider-thumb, input:active::-webkit-slider-thumb { transform: scale(1.15); } - input:hover::-moz-range-thumb, input:active::-moz-range-thumb { transform: scale(1.15); } + @media (hover: hover) { + input:hover::-webkit-slider-thumb { + transform: scale(1.15); + } + input:hover::-moz-range-thumb { + transform: scale(1.15); + } + } input:focus-visible { outline: none; } diff --git a/src/lib/styles/global.css b/src/lib/styles/global.css index 2968c8a..4c1224a 100644 --- a/src/lib/styles/global.css +++ b/src/lib/styles/global.css @@ -13,6 +13,12 @@ box-sizing: border-box; } +html { + /* an SPA has no pull-to-refresh to offer — a swipe past the top reloading the whole app + mid-edit is pure loss */ + overscroll-behavior-y: none; +} + body { margin: 0; background: var(--color-background); @@ -20,6 +26,30 @@ body { font-family: var(--font-family); /* type runs a notch below the 1rem sizing grid — see tokens.css */ font-size: 0.875rem; + -webkit-tap-highlight-color: transparent; /* the gray tap flash on mobile */ +} + +/* touch papercuts: no double-tap-zoom delay on controls, no long-press text selection on + buttons — both keep taps feeling immediate */ +button, +a, +input, +select, +label { + touch-action: manipulation; +} +button { + user-select: none; +} + +/* iOS zooms the page into any focused field whose font-size is under 16px; our fields run + 12–14px by design. One hardware-constraint override beats retuning every component. */ +@media (pointer: coarse) { + input, + select, + textarea { + font-size: 16px !important; + } } h1, From 7177614fefdcbeab41bbd49eb54d38f0b1105670 Mon Sep 17 00:00:00 2001 From: Egor Kolesnikov Date: Thu, 13 Aug 2026 23:15:24 +0300 Subject: [PATCH 23/23] Run oxfmt over the merge Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013uXvqNHddM9rCCR3NK7Yax --- .../device/components/watch-sheet.svelte | 6 +++- .../editor/components/props/geometry.svelte | 28 ++++++++++++++++--- src/lib/shared/components/sheet/sheet.svelte | 10 ++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/lib/modules/device/components/watch-sheet.svelte b/src/lib/modules/device/components/watch-sheet.svelte index 0f58ad5..6c088bf 100644 --- a/src/lib/modules/device/components/watch-sheet.svelte +++ b/src/lib/modules/device/components/watch-sheet.svelte @@ -94,7 +94,11 @@
{#each $dials as id, i (id)} - + {:else} the watch reported none {/each} diff --git a/src/lib/modules/editor/components/props/geometry.svelte b/src/lib/modules/editor/components/props/geometry.svelte index 8ac6f75..3b42050 100644 --- a/src/lib/modules/editor/components/props/geometry.svelte +++ b/src/lib/modules/editor/components/props/geometry.svelte @@ -205,12 +205,32 @@ {/if} {#if frame}
- setFrame({ x: num(v) })} /> - setFrame({ y: num(v) })} /> + setFrame({ x: num(v) })} + /> + setFrame({ y: num(v) })} + />
- setFrame({ w: num(v) })} /> - setFrame({ h: num(v) })} /> + setFrame({ w: num(v) })} + /> + setFrame({ h: num(v) })} + />
align diff --git a/src/lib/shared/components/sheet/sheet.svelte b/src/lib/shared/components/sheet/sheet.svelte index fd9cbce..b152593 100644 --- a/src/lib/shared/components/sheet/sheet.svelte +++ b/src/lib/shared/components/sheet/sheet.svelte @@ -62,12 +62,7 @@
-
+
@@ -161,8 +156,7 @@ position: absolute; inset: 0 0 -50svh; z-index: -1; - border-radius: calc(3 * var(--border-radius, 0.5rem)) - calc(3 * var(--border-radius, 0.5rem)) 0 0; + border-radius: calc(3 * var(--border-radius, 0.5rem)) calc(3 * var(--border-radius, 0.5rem)) 0 0; background: var(--color-background, Canvas); } .grabber {