diff --git a/src/app.html b/src/app.html index d14a963..42dc46b 100644 --- a/src/app.html +++ b/src/app.html @@ -3,10 +3,13 @@ + + + %sveltekit.head% 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-selector.svelte b/src/lib/modules/device/components/watch-selector.svelte index d117b50..b764c41 100644 --- a/src/lib/modules/device/components/watch-selector.svelte +++ b/src/lib/modules/device/components/watch-selector.svelte @@ -88,14 +88,20 @@ .pickable { cursor: pointer; - &:hover .face, &:focus-visible .face { outline-color: oklch(from var(--color-accent) l c h / 45%); } - &:hover .label, &:focus-visible .label { color: var(--color-text); } + @media (hover: hover) { + &:hover .face { + outline-color: oklch(from var(--color-accent) l c h / 45%); + } + &:hover .label { + color: var(--color-text); + } + } } .selected { .face { 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..6c088bf --- /dev/null +++ b/src/lib/modules/device/components/watch-sheet.svelte @@ -0,0 +1,165 @@ + + +{@render trigger({ open, toggle, connected: !!$bleInfo })} + + + + {#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/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/TreePanel.svelte b/src/lib/modules/editor/components/TreePanel.svelte index 46b2deb..1fcc022 100644 --- a/src/lib/modules/editor/components/TreePanel.svelte +++ b/src/lib/modules/editor/components/TreePanel.svelte @@ -535,8 +535,10 @@ text-align: start; cursor: pointer; - &: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%); + } } &.selected { background: oklch(from var(--color-accent) l c h / 12%); @@ -577,14 +579,26 @@ opacity: 0; color: oklch(from var(--color-text) l c h / 55%); - &:hover { - opacity: 1; + @media (hover: hover) { + &:hover { + opacity: 1; + } } } - :global(.flag.on), - .node-row:hover :global(.flag) { + :global(.flag.on) { opacity: 0.75; } + @media (hover: hover) { + .node-row:hover :global(.flag) { + opacity: 0.75; + } + } + /* no hover to reveal them with — flags stay visible on touch */ + @media (hover: none) { + :global(.flag) { + opacity: 0.75; + } + } .rename { flex: 1; min-width: 0; diff --git a/src/lib/modules/editor/components/props/frame-thumb.svelte b/src/lib/modules/editor/components/props/frame-thumb.svelte index 47f6ad6..c103b8a 100644 --- a/src/lib/modules/editor/components/props/frame-thumb.svelte +++ b/src/lib/modules/editor/components/props/frame-thumb.svelte @@ -98,8 +98,12 @@ top: auto; bottom: 0.125rem; } - .thumb-wrap:hover .dl-btn, .dl-btn:focus-visible { opacity: 1; } + @media (hover: hover) { + .thumb-wrap:hover .dl-btn { + opacity: 1; + } + } diff --git a/src/lib/modules/editor/components/props/frames.svelte b/src/lib/modules/editor/components/props/frames.svelte index d221439..64c2cc9 100644 --- a/src/lib/modules/editor/components/props/frames.svelte +++ b/src/lib/modules/editor/components/props/frames.svelte @@ -165,8 +165,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/components/props/geometry.svelte b/src/lib/modules/editor/components/props/geometry.svelte index c610b2b..3b42050 100644 --- a/src/lib/modules/editor/components/props/geometry.svelte +++ b/src/lib/modules/editor/components/props/geometry.svelte @@ -106,14 +106,14 @@ {/if} {#if placed && !frame}
- x set(layer.id, { x: num(v) } as Partial)} /> - y set(layer.id, { y: num(v) } as Partial)} @@ -122,8 +122,8 @@ {/if} {#if resSize}
- w resize(num(v), $lockAspect ? Math.round((num(v) * resSize.h) / resSize.w) : resSize.h)} /> - h {#if bareRing} - size setRingSize(num(v))} /> {/if} - stroke
- start setSpec({ start: num(v) })} /> - end - setSpec({ end: num(v) })} /> + setSpec({ end: num(v) })} + />
@@ -201,16 +205,32 @@ {/if} {#if frame}
- 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 @@ -232,14 +252,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 9574d5b..bff4058 100644 --- a/src/lib/modules/editor/components/props/source.svelte +++ b/src/lib/modules/editor/components/props/source.svelte @@ -182,9 +182,9 @@ {/if} {#if num_}
- digits - + (); +/** "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 2a0e05c..cd63079 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"; @@ -79,6 +80,7 @@ previewThumb, $rightPanel: rightPanel, rightPanelSet, + $loading: loading, } = editorModel; let canvas = $state(null); @@ -918,6 +920,11 @@ onpointermove={onMove} onpointerup={onUp} > + + {#if $loading} +
+ {/if}
{#if perf && $doc} @@ -1043,8 +1050,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); @@ -1115,11 +1124,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; @@ -1162,6 +1175,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); @@ -1182,6 +1196,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..4289b1b 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}
@@ -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/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..c7e3fca --- /dev/null +++ b/src/lib/modules/market/pages/watchface.svelte @@ -0,0 +1,264 @@ + + +{$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/lib/shared/components/adaptive-popover/adaptive-popover.svelte b/src/lib/shared/components/adaptive-popover/adaptive-popover.svelte new file mode 100644 index 0000000..a3f184b --- /dev/null +++ b/src/lib/shared/components/adaptive-popover/adaptive-popover.svelte @@ -0,0 +1,86 @@ + + +{#if desktop.current} + + + {#if title}

{title}

{/if} + {@render children?.()} +
+{:else} + + +
+ {#if title}

{title}

{/if} + +
+ {@render children?.()} +
+{/if} + + 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 024d3b1..62551d4 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/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/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?.()} +
+ + 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..b152593 --- /dev/null +++ b/src/lib/shared/components/sheet/sheet.svelte @@ -0,0 +1,175 @@ + + + + + { + armed = false; + travel = 0; + onClose?.(); + }} + oncancel={(e) => { + e.preventDefault(); /* let Esc slide it out too */ + dismiss(); + }} +> +
+ + +
+
+
+ + +
e.stopPropagation()}> +
+
+
{@render children?.({ travel })}
+
+
+
+
+
+ + 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 c6d7aaa..4c1224a 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"); @@ -12,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); @@ -19,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, 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 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 @@ + + +