summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 16:12:58 +0900
committerAdam Malczewski <[email protected]>2026-09-07 16:12:58 +0900
commit83fb6e3ce7f80a9c287764df26b6245e0945a941 (patch)
treeafe8a0046b40a45a85f3bc56a4bcd4c93a6f36c7 /src/features/workspaces/ui
parent754450b539818f9ad94c9586888973b0edd74b68 (diff)
downloaddispatch-web-83fb6e3ce7f80a9c287764df26b6245e0945a941.tar.gz
dispatch-web-83fb6e3ce7f80a9c287764df26b6245e0945a941.zip
feat(workspaces): rework tile-grid visuals — Start-screen polish
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.
Diffstat (limited to 'src/features/workspaces/ui')
-rw-r--r--src/features/workspaces/ui/HomeHeader.svelte9
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.svelte41
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.svelte74
-rw-r--r--src/features/workspaces/ui/WorkspaceTile.test.ts15
-rw-r--r--src/features/workspaces/ui/WorkspacesHome.svelte16
5 files changed, 119 insertions, 36 deletions
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 @@
}
</script>
-<header class="flex shrink-0 items-center justify-between gap-2 px-6 py-4">
- <h1 class="text-3xl font-black tracking-tight">Dispatch</h1>
+<header class="flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12">
+ <div class="flex items-baseline gap-3">
+ <h1 class="text-4xl font-black tracking-tight">Dispatch</h1>
+ <span class="hidden text-sm font-medium tracking-widest uppercase opacity-40 sm:inline">
+ Workspaces
+ </span>
+ </div>
<div class="flex items-center gap-2" class:gap-1={compact}>
<!-- New workspace -->
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<TileTone, string> = {
+ 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 @@
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<div
data-tile-id={placement.id}
- class={`flex select-none flex-col overflow-hidden rounded-box border transition-colors ${
- selectedId === placement.id ? "border-primary/50 ring-2 ring-primary" : ""
+ class={`flex select-none flex-col overflow-hidden rounded-box border shadow-md transition-[transform,box-shadow] duration-150 hover:-translate-y-0.5 hover:shadow-xl ${
+ TONE_CLASSES[tileTone(placement.id)]
} ${
- ws.starred
- ? "border-primary/40 bg-primary/20"
- : "border-base-content/10 bg-base-200"
+ selectedId === placement.id
+ ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100"
+ : "border-transparent"
} ${phase.kind === "dragging" && phase.id === placement.id ? "opacity-40" : ""}`}
style={`${wrapperStyle(placement)} scroll-snap-align: start;`}
role="button"
@@ -277,17 +293,22 @@
</div>
{#if canPan}
- <!-- Pan arrow + thin scroll-position indicator (wide geometry only). -->
+ <!-- Pan arrow + thin scroll-position indicator (wide geometry only).
+ Both sit INSIDE the canvas' outer margin. -->
<button
type="button"
- class="btn btn-circle btn-sm absolute right-3 bottom-3 z-10 border-base-content/20 bg-base-100/80 shadow-lg"
+ class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl"
+ style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`}
aria-label="Pan right"
title="Pan one screen sideways"
onclick={pan}
>
→
</button>
- <div class="absolute inset-x-0 bottom-0 z-10 h-0.5 bg-base-content/10">
+ <div
+ class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10"
+ style={`margin: 0 ${geometry.margin}px;`}
+ >
<div
class="h-full bg-primary transition-[width] duration-100"
style={`width: ${Math.round(scrollProgress * 100)}%;`}
@@ -299,7 +320,7 @@
{#if phase.kind === "dragging"}
<!-- The drag ghost follows the pointer (transient; never a data change). -->
<div
- class="pointer-events-none fixed z-50 rounded-box border border-primary bg-primary/30 px-3 py-2 text-sm font-bold shadow-2xl"
+ class="pointer-events-none fixed z-50 rounded-box border border-base-content/25 bg-base-100/95 px-3 py-2 text-sm font-bold shadow-2xl"
style={`left: ${phase.x + 10}px; top: ${phase.y + 10}px;`}
>
{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.",
+ );
</script>
-<div data-tile-face={faceKey} class="flex h-full w-full flex-col gap-1 p-3">
+<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}>
+ <!-- The slug label at the very top — the Win8 tile's small "app" caption. -->
+ <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase">
+ <span class="truncate opacity-70">/{ws.id}</span>
+ {#if active}
+ <span
+ class="loading loading-dots loading-xs shrink-0"
+ aria-label="Workspace has active conversations"
+ title="A conversation in this workspace is generating"></span>
+ {/if}
+ </div>
+
+ {#if ws.starred}
+ <!-- Starred is a badge, not a tint — the tone stays the tile's identity. -->
+ <span
+ class="absolute top-2.5 right-3 text-lg leading-none opacity-90"
+ aria-label="Starred workspace"
+ title="Starred — priority in the agent queue">★</span
+ >
+ {/if}
+
{#if editing}
<input
bind:this={titleInput}
bind:value={titleDraft}
- class="input input-bordered input-sm"
+ class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50"
aria-label="Workspace title"
onkeydown={(e) => {
if (e.key === "Enter") void saveTitle();
@@ -81,29 +120,30 @@
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
<span
- class="line-clamp-2 cursor-default text-lg leading-tight font-bold"
+ class={`mt-1 cursor-default font-bold ${titleClass}`}
title="Double-click to rename"
ondblclick={startEdit}>{ws.title}</span
>
{/if}
- <span class="font-mono text-xs opacity-50">/{ws.id}</span>
-
{#if titleError}
- <p class="text-xs text-error">{titleError}</p>
+ <p class="mt-1 text-xs font-semibold">{titleError}</p>
{/if}
- <div class="mt-auto flex items-center gap-2 text-xs opacity-80">
- {#if active}
- <span
- class="loading loading-dots loading-xs shrink-0 text-primary"
- aria-label="Workspace has active conversations"
- title="A conversation in this workspace is generating"></span>
- {/if}
- <span>
- {ws.conversationCount}
- {ws.conversationCount === 1 ? "conversation" : "conversations"}
+ <!-- The stat line pinned to the tile's bottom edge, like a live tile's
+ status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). -->
+ <div
+ class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap"
+ >
+ <span class="flex min-w-0 items-center gap-1.5">
+ <span aria-hidden="true" class="shrink-0 opacity-70">◱</span>
+ <span class="truncate">
+ {ws.conversationCount}
+ {countLabel}
+ </span>
+ </span>
+ <span class="ml-auto shrink-0 font-mono opacity-70">
+ {relativeTime(ws.lastActivityAt, Date.now())}
</span>
- <span class="ml-auto font-mono opacity-70">{relativeTime(ws.lastActivityAt, Date.now())}</span>
</div>
</div>
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 @@
<div bind:this={regionEl} class="min-h-0 w-full flex-1">
{#if store.loading && store.list.length === 0}
- <div class="flex h-32 items-center justify-center">
- <span class="loading loading-spinner loading-sm opacity-60"></span>
+ <div class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-lg opacity-60"></span>
</div>
{:else if store.list.length === 0}
- <p class="py-8 text-center text-sm opacity-60">
- No workspaces yet. Create one with <strong>New workspace</strong> above, or visit
- <code>/your-name</code> in the URL.
- </p>
+ <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
+ <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p>
+ <p class="max-w-md text-sm opacity-60">
+ Create one with <strong>New workspace</strong> above, or visit
+ <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first
+ tile drops onto the canvas ready to rename.
+ </p>
+ </div>
{:else}
<HomeTileGrid
{geometry}