From 83fb6e3ce7f80a9c287764df26b6245e0945a941 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 7 Sep 2026 16:12:58 +0900 Subject: feat(workspaces): rework tile-grid visuals — Start-screen polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home canvas now reads as a real Windows 8 Start screen instead of a cramped, edge-hugging list: - Geometry: the wide unit is DERIVED from the canvas height (4 rows fill it: unit=height/5 clamped to 112-184px, gap=unit/9, margin=unit/3 clamped 24-64px) — tiles are no longer tiny, and the tile field always carries a proportional outer margin (never compressed against the left edge). Tall mode keeps fluid columns with a taller 148px row + 12px gap. - Tiles take a per-workspace SATURATED tone (new pure tileTone(id): FNV-1a hash → one of the six theme-semantic colors) with tone-content text — the Win8 colored-tile identity. Starred is now a ★ badge (a tile keeps its own color), not a gray-tint override. - The tile face is the Win8 layout: small uppercase slug caption at the top, a bold span-aware title (lg → 3xl across 1×1 → 4×2), and a single-line stat row pinned to the bottom (count compacts to 'conv.' on 1×1 tiles, truncation-safe so narrow tiles never clip). - Selection is a high-contrast offset ring (visible on any tile color); tiles lift on hover; the pan arrow is a solid elevated circle. - Header gains a 'WORKSPACES' kicker; empty/loading states are centered and styled for the Start-screen canvas. Persisted data (order + spans only) and all interaction semantics (drag/FLIP, long-press, action bar, resize picker, create popover) are untouched. Tests: geometry derivation/clamping, tileTone determinism, wide-vs-compact count label — 1664 green. --- src/features/workspaces/ui/HomeHeader.svelte | 9 ++- src/features/workspaces/ui/HomeTileGrid.svelte | 41 +++++++++---- src/features/workspaces/ui/WorkspaceTile.svelte | 74 ++++++++++++++++++------ src/features/workspaces/ui/WorkspaceTile.test.ts | 15 ++++- src/features/workspaces/ui/WorkspacesHome.svelte | 16 +++-- 5 files changed, 119 insertions(+), 36 deletions(-) (limited to 'src/features/workspaces/ui') diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte index 01ac63f..be630d2 100644 --- a/src/features/workspaces/ui/HomeHeader.svelte +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -65,8 +65,13 @@ } -
-

Dispatch

+
+
+

Dispatch

+ +
diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte index e29b9b1..ceae133 100644 --- a/src/features/workspaces/ui/HomeTileGrid.svelte +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -2,9 +2,25 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller"; import type { GridGeometry, Placement, Span } from "../logic/tile-layout"; + import { tileTone, type TileTone } from "../logic/view-model"; import type { WorkspaceStore } from "../store.svelte"; import WorkspaceTile from "./WorkspaceTile.svelte"; + /** + * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see + * the literal `bg-*`/`text-*-content` names to emit them). A tile is its + * tone's SATURATED color with the tone's `-content` text, like Win8's + * colored tiles on the dark Start canvas. + */ + const TONE_CLASSES: Record = { + primary: "bg-primary text-primary-content border-primary", + secondary: "bg-secondary text-secondary-content border-secondary", + accent: "bg-accent text-accent-content border-accent", + info: "bg-info text-info-content border-info", + success: "bg-success text-success-content border-success", + warning: "bg-warning text-warning-content border-warning", + }; + let { geometry, placements, @@ -222,7 +238,7 @@ class="h-full overscroll-contain" class:overflow-x-auto={geometry.mode === "wide"} class:overflow-y-auto={geometry.mode === "tall"} - style={`scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`} + style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`} onscroll={updateScroll} onwheel={handleWheel} > @@ -233,12 +249,12 @@
{#if canPan} - + -
+
{workspacesById.get(phase.id)?.title ?? phase.id} diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte index 4059690..3894b26 100644 --- a/src/features/workspaces/ui/WorkspaceTile.svelte +++ b/src/features/workspaces/ui/WorkspaceTile.svelte @@ -63,14 +63,53 @@ // The face's semantic key (tests read it). const faceKey = $derived(`tile-${placement.id}`); + + // Span-aware face: bigger footprints get more air + bigger type (all styles + // static so Tailwind sees them). + const wide = $derived(placement.colSpan > 1); + const tall = $derived(placement.rowSpan > 1); + const facePadding = $derived(wide || tall ? "p-5" : "p-4"); + const titleClass = $derived( + wide && tall + ? "text-3xl leading-tight line-clamp-3" + : wide + ? "text-2xl leading-snug line-clamp-2" + : tall + ? "text-xl leading-snug line-clamp-4" + : "text-lg leading-snug line-clamp-3", + ); + /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */ + const countLabel = $derived( + ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.", + ); -
+
+ +
+ /{ws.id} + {#if active} + + {/if} +
+ + {#if ws.starred} + + ★ + {/if} + {#if editing} { if (e.key === "Enter") void saveTitle(); @@ -81,29 +120,30 @@ {:else} {ws.title} {/if} - /{ws.id} - {#if titleError} -

{titleError}

+

{titleError}

{/if} -
- {#if active} - - {/if} - - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} + +
+ + + + {ws.conversationCount} + {countLabel} + + + + {relativeTime(ws.lastActivityAt, Date.now())} - {relativeTime(ws.lastActivityAt, Date.now())}
diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts index 918aa1c..b76f709 100644 --- a/src/features/workspaces/ui/WorkspaceTile.test.ts +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -48,10 +48,23 @@ describe("WorkspaceTile", () => { }); expect(screen.getByText("My Workspace")).toBeInTheDocument(); expect(screen.getByText("/my-ws")).toBeInTheDocument(); - expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out). + expect(screen.getByText(/3 conv\./)).toBeInTheDocument(); expect(screen.getByText("1m")).toBeInTheDocument(); }); + it("spells out 'conversations' on a wide tile", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement({ colSpan: 2, rowSpan: 1 }), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + }); + it("shows the active indicator only when the active port says so", async () => { const { container, rerender } = render(WorkspaceTile, { props: { diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index 276a811..cbf762f 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -248,14 +248,18 @@
{#if store.loading && store.list.length === 0} -
- +
+
{:else if store.list.length === 0} -

- No workspaces yet. Create one with New workspace above, or visit - /your-name in the URL. -

+
+

No workspaces yet

+

+ Create one with New workspace above, or visit + /your-name in the URL — the first + tile drops onto the canvas ready to rename. +

+
{:else}