summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/features/workspaces/logic/tile-layout.test.ts53
-rw-r--r--src/features/workspaces/logic/tile-layout.ts55
-rw-r--r--src/features/workspaces/logic/view-model.test.ts28
-rw-r--r--src/features/workspaces/logic/view-model.ts34
-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
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}