diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 16:12:58 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 16:12:58 +0900 |
| commit | 83fb6e3ce7f80a9c287764df26b6245e0945a941 (patch) | |
| tree | afe8a0046b40a45a85f3bc56a4bcd4c93a6f36c7 /src/features | |
| parent | 754450b539818f9ad94c9586888973b0edd74b68 (diff) | |
| download | dispatch-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')
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.test.ts | 53 | ||||
| -rw-r--r-- | src/features/workspaces/logic/tile-layout.ts | 55 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 28 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 34 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeHeader.svelte | 9 | ||||
| -rw-r--r-- | src/features/workspaces/ui/HomeTileGrid.svelte | 41 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceTile.svelte | 74 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspaceTile.test.ts | 15 | ||||
| -rw-r--r-- | src/features/workspaces/ui/WorkspacesHome.svelte | 16 |
9 files changed, 276 insertions, 49 deletions
diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts index f2fbc5b..be61da4 100644 --- a/src/features/workspaces/logic/tile-layout.test.ts +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -52,6 +52,44 @@ describe("geometryFor", () => { it("resolves degenerate (unmeasured) sizes to wide", () => { expect(geometryFor(0, 0).mode).toBe("wide"); }); + + it("derives the wide unit from the canvas height so the 4 rows fill it", () => { + // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles, + // NOT the old fixed 120px (the "squares are so small" complaint). + const geometry = geometryFor(1440, 800); + const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin; + expect(geometry.unit.height).toBeGreaterThanOrEqual(140); + expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8); + expect(geometry.unit.width).toBe(geometry.unit.height); + }); + + it("clamps the wide unit to its band on very short and very tall canvases", () => { + const short = geometryFor(1200, 300); + const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height + expect(short.unit.height).toBe(112); + expect(huge.unit.height).toBe(184); + // The margin never collapses (the "no margins" complaint) nor dominates. + expect(short.margin).toBeGreaterThanOrEqual(24); + expect(huge.margin).toBeLessThanOrEqual(64); + }); + + it("always carries a positive margin (the tile field never hugs the edge)", () => { + const sizes: [number, number][] = [ + [1920, 1080], + [390, 844], + [0, 0], + [1200, 300], + ]; + for (const [w, h] of sizes) { + expect(geometryFor(w, h).margin).toBeGreaterThan(0); + } + }); + + it("keeps the tall unit fixed with a gap (its columns are fluid)", () => { + expect(TALL.unit.width).toBe(0); + expect(TALL.unit.height).toBeGreaterThanOrEqual(120); + expect(TALL.unit.gap).toBeGreaterThan(8); + }); }); describe("clampSpan", () => { @@ -429,12 +467,15 @@ describe("geometry-agnostic persistence", () => { describe("type surface", () => { it("exposes the geometry fields the picker and grid need", () => { + // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum, + // so the gap is 12px and the margin 37px (never the old fixed 8px gap). const geometry: GridGeometry = geometryFor(1000, 500); - expect([geometry.mode, geometry.flowRows, geometry.flowCols, geometry.unit.gap]).toEqual([ - "wide", - 4, - 0, - 8, - ]); + expect([ + geometry.mode, + geometry.flowRows, + geometry.flowCols, + geometry.unit.gap, + geometry.margin, + ]).toEqual(["wide", 4, 0, 12, 37]); }); }); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts index 17923c0..4d81a0c 100644 --- a/src/features/workspaces/logic/tile-layout.ts +++ b/src/features/workspaces/logic/tile-layout.ts @@ -46,6 +46,13 @@ export interface GridGeometry { /** Maximum vertical span a tile may occupy in this geometry. */ readonly maxRowSpan: number; readonly unit: TileUnitSize; + /** + * The breathing room around the whole tile field — the grid never renders + * flush against the canvas edge. In wide mode the scroller pads all four + * sides by this amount (the pan arrow/scroll indicator offset inward by it + * too); in tall mode it insets the two fluid columns horizontally. + */ + readonly margin: number; } /** One persisted tile: which workspace it pins, how big it wants to be, and its manual position in the order. */ @@ -82,14 +89,41 @@ export interface Span { /** The viewport assumed before the canvas has been measured (a landscape desktop). */ export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const; -const WIDE_UNIT: TileUnitSize = { width: 128, height: 120, gap: 8 }; -const TALL_UNIT: TileUnitSize = { width: 0, height: 120, gap: 8 }; +/** + * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4 + * fixed rows (the Start-screen feel), but never shrink below a legible small + * tile nor grow past a comfortable large one. + */ +const WIDE_UNIT_MIN = 112; +const WIDE_UNIT_MAX = 184; + +/** The wide canvas' outer margin band (px) — proportional to the unit size. */ +const WIDE_MARGIN_MIN = 24; +const WIDE_MARGIN_MAX = 64; + +/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */ +const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 }; +const TALL_MARGIN = 16; + +/** Round to whole pixels (fractional track sizes blur tile edges). */ +function px(value: number): number { + return Math.round(value); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} /** * Resolve the grid geometry from the tile canvas' size. Landscape/desktop → - * WIDE (4 fixed ~120px rows, column-wise flow, 4×2 span cap); portrait/mobile - * → TALL (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes - * (not yet measured) resolve to wide. Pure: numbers in, geometry out. + * WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL + * (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet + * measured) resolve to wide. + * + * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill + * it (unit + margin + gap all proportional, clamped to their bands); the tall + * unit is fixed (its columns are fluid instead). Pure: numbers in, geometry + * out. */ export function geometryFor(width: number, height: number): GridGeometry { const tall = width > 0 && height > 0 && height > width; @@ -101,15 +135,24 @@ export function geometryFor(width: number, height: number): GridGeometry { maxColSpan: 2, maxRowSpan: 2, unit: TALL_UNIT, + margin: TALL_MARGIN, }; } + // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin = + // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) → + // the band's middle, not a tiny tile. + const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2; + const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX)); + const gap = Math.max(10, px(unit / 9)); + const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX)); return { mode: "wide", flowRows: 4, flowCols: 0, maxColSpan: 4, maxRowSpan: 2, - unit: WIDE_UNIT, + unit: { width: unit, height: unit, gap }, + margin, }; } diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 44d2f31..d283481 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,32 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; +import { applyStarred, pageTitle, relativeTime, sortWorkspaces, tileTone } from "./view-model"; + +describe("tileTone", () => { + const TONES = ["primary", "secondary", "accent", "info", "success", "warning"]; + + it("is deterministic — the same id always gets the same tone", () => { + expect(tileTone("my-workspace")).toBe(tileTone("my-workspace")); + }); + + it("only returns known tones", () => { + for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) { + expect(TONES).toContain(tileTone(id)); + } + }); + + it("spreads distinct ids across more than one tone", () => { + const tones = new Set( + ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone), + ); + expect(tones.size).toBeGreaterThan(1); + }); + + it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => { + // Not a correctness requirement — just documents the hash reads raw chars. + expect(typeof tileTone("ABC")).toBe("string"); + }); +}); describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index b994b7c..0b9f239 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import type { Route } from "./route"; /** + * A home tile's color tone. Windows 8's Start screen gave every tile its own + * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of + * the theme's six semantic tones, so the same workspace keeps its color across + * sessions and themes (the tone name maps to a DaisyUI color, which adapts to + * the active theme). Starred stays a separate visual concern (a badge), NOT a + * tone — any tile may be starred regardless of its color. + */ +export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning"; + +/** The tones a tile may take, in hash order. */ +const TILE_TONES: readonly TileTone[] = [ + "primary", + "secondary", + "accent", + "info", + "success", + "warning", +]; + +/** + * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id) + * and uniformly distributed across the six tones — pure: id in, tone out. + */ +export function tileTone(id: string): TileTone { + let hash = 0x811c9dc5; + for (let index = 0; index < id.length; index++) { + hash ^= id.charCodeAt(index); + // FNV prime (16777619), kept in 32-bit unsigned range. + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return TILE_TONES[hash % TILE_TONES.length] ?? "primary"; +} + +/** * The browser tab / page (`document.title`) text for a route. The home route * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}", * using the workspace's display title and falling back to the URL slug (`id`) 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} |
