+
+
+
+ Artifact directions — lab
+
+
+
+
+
+
+
+
diff --git a/ui/src/artifact-directions/api-stub.test.ts b/ui/src/artifact-directions/api-stub.test.ts
new file mode 100644
index 00000000000..c909a3a2988
--- /dev/null
+++ b/ui/src/artifact-directions/api-stub.test.ts
@@ -0,0 +1,45 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it } from "vitest";
+import { installApiStub, registerApiRoute } from "./api-stub";
+import { COMPANY } from "./fixtures";
+
+describe("installApiStub", () => {
+ const original = window.fetch;
+ const restores: Array<() => void> = [];
+
+ afterEach(() => {
+ while (restores.length) restores.pop()?.();
+ window.fetch = original;
+ });
+
+ it("answers /api/ from the fixtures and puts fetch back when restored", async () => {
+ restores.push(installApiStub());
+ expect(window.fetch).not.toBe(original);
+ const companies = await (await window.fetch("/api/companies")).json();
+ expect(companies).toEqual([COMPANY]);
+ restores.pop()?.();
+ expect(window.fetch).toBe(original);
+ });
+
+ it("serves a registered route and answers anything else under /api/ with an empty object", async () => {
+ registerApiRoute((pathname) => (pathname === "/api/lab/ping" ? { pong: true } : undefined));
+ restores.push(installApiStub());
+ expect(await (await window.fetch("/api/lab/ping")).json()).toEqual({ pong: true });
+ expect(await (await window.fetch("/api/lab/nothing", { method: "POST" })).json()).toEqual({});
+ });
+
+ it("keeps one stub across nested installs and restores on the last release", () => {
+ const first = installApiStub();
+ const stub = window.fetch;
+ const second = installApiStub();
+ restores.push(first, second);
+ expect(window.fetch).toBe(stub);
+ first();
+ expect(window.fetch).toBe(stub);
+ first(); // a second call of the same restore is a no-op
+ expect(window.fetch).toBe(stub);
+ second();
+ expect(window.fetch).toBe(original);
+ });
+});
diff --git a/ui/src/artifact-directions/api-stub.ts b/ui/src/artifact-directions/api-stub.ts
new file mode 100644
index 00000000000..143582d7de8
--- /dev/null
+++ b/ui/src/artifact-directions/api-stub.ts
@@ -0,0 +1,71 @@
+import { COMPANY } from "./fixtures";
+
+/**
+ * The lab's only "server". Every API call in the app goes through `fetch`, so
+ * standing in for `/api/` here is enough to render real data-fetching
+ * components with no backend and no module mocks. Nothing under `/api/` ever
+ * reaches the network; everything else (fonts, assets) passes through.
+ *
+ * A direction that renders a real component registers the routes it needs.
+ * `installApiStub` returns a restore, so a story can scope the stub to itself.
+ */
+
+/** Returns the JSON body for a path it owns, or `undefined` to pass. */
+export type ApiRoute = (pathname: string, method: string) => unknown | undefined;
+
+const routes: ApiRoute[] = [];
+
+export function registerApiRoute(route: ApiRoute): void {
+ routes.push(route);
+}
+
+const json = (body: unknown) =>
+ new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } });
+
+function respond(pathname: string, method: string): Response {
+ for (const route of routes) {
+ const body = route(pathname, method);
+ if (body !== undefined) return json(body);
+ }
+ if (pathname.endsWith("/auth/get-session")) {
+ return json({ session: { id: "lab-session", userId: "lab-user" }, user: { id: "lab-user", name: "Lab", email: "lab@acmehats.example" } });
+ }
+ if (pathname.endsWith("/companies")) return json([COMPANY]);
+ // An unhandled call answers empty rather than failing, so it is never the
+ // reason a frame looks broken; the debug line says what was asked for.
+ console.debug("[artifact-directions] unhandled", method, pathname);
+ return json({});
+}
+
+let holders = 0;
+let realFetch: typeof window.fetch | null = null;
+let stubFetch: typeof window.fetch | null = null;
+
+/**
+ * Stands in for `/api/` until the returned function is called. The lab page
+ * installs once for its lifetime; a Storybook story installs on mount and
+ * restores on unmount. Nested installs share one stub, and the last restore
+ * puts the real `fetch` back — unless something else replaced it meanwhile.
+ */
+export function installApiStub(): () => void {
+ if (holders === 0) {
+ const original = window.fetch;
+ const real = original.bind(window);
+ realFetch = original;
+ stubFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (!url.includes("/api/")) return real(input as RequestInfo, init);
+ const { pathname } = new URL(url, window.location.origin);
+ return respond(pathname, (init?.method ?? "GET").toUpperCase());
+ }) as typeof window.fetch;
+ window.fetch = stubFetch;
+ }
+ holders += 1;
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ holders -= 1;
+ if (holders === 0 && realFetch && window.fetch === stubFetch) window.fetch = realFetch;
+ };
+}
diff --git a/ui/src/artifact-directions/assets/acmehats-logo-lockup.svg b/ui/src/artifact-directions/assets/acmehats-logo-lockup.svg
new file mode 100644
index 00000000000..516c93529bf
--- /dev/null
+++ b/ui/src/artifact-directions/assets/acmehats-logo-lockup.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/avatar-ceo.svg b/ui/src/artifact-directions/assets/avatar-ceo.svg
new file mode 100644
index 00000000000..36fc3cc1ca6
--- /dev/null
+++ b/ui/src/artifact-directions/assets/avatar-ceo.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/avatar-designer.svg b/ui/src/artifact-directions/assets/avatar-designer.svg
new file mode 100644
index 00000000000..4309569f34d
--- /dev/null
+++ b/ui/src/artifact-directions/assets/avatar-designer.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/cap-reel-poster.svg b/ui/src/artifact-directions/assets/cap-reel-poster.svg
new file mode 100644
index 00000000000..25c4373d142
--- /dev/null
+++ b/ui/src/artifact-directions/assets/cap-reel-poster.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/cap-reel.webm b/ui/src/artifact-directions/assets/cap-reel.webm
new file mode 100644
index 00000000000..92335775b9b
Binary files /dev/null and b/ui/src/artifact-directions/assets/cap-reel.webm differ
diff --git a/ui/src/artifact-directions/assets/cap-shot-black.svg b/ui/src/artifact-directions/assets/cap-shot-black.svg
new file mode 100644
index 00000000000..8e413878bba
--- /dev/null
+++ b/ui/src/artifact-directions/assets/cap-shot-black.svg
@@ -0,0 +1,25 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/cap-shot-cobalt.svg b/ui/src/artifact-directions/assets/cap-shot-cobalt.svg
new file mode 100644
index 00000000000..fc0464add97
--- /dev/null
+++ b/ui/src/artifact-directions/assets/cap-shot-cobalt.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/cap-shot-forest.svg b/ui/src/artifact-directions/assets/cap-shot-forest.svg
new file mode 100644
index 00000000000..f9a58eed085
--- /dev/null
+++ b/ui/src/artifact-directions/assets/cap-shot-forest.svg
@@ -0,0 +1,14 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/contact-sheet-first-draft.svg b/ui/src/artifact-directions/assets/contact-sheet-first-draft.svg
new file mode 100644
index 00000000000..8c4cc0b97a3
--- /dev/null
+++ b/ui/src/artifact-directions/assets/contact-sheet-first-draft.svg
@@ -0,0 +1,56 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg b/ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg
new file mode 100644
index 00000000000..02612696198
--- /dev/null
+++ b/ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg
@@ -0,0 +1,65 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/contact-sheet-revised.svg b/ui/src/artifact-directions/assets/contact-sheet-revised.svg
new file mode 100644
index 00000000000..eae06750c9b
--- /dev/null
+++ b/ui/src/artifact-directions/assets/contact-sheet-revised.svg
@@ -0,0 +1,65 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/assets/storefront-preview.svg b/ui/src/artifact-directions/assets/storefront-preview.svg
new file mode 100644
index 00000000000..24da8345c5c
--- /dev/null
+++ b/ui/src/artifact-directions/assets/storefront-preview.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/ui/src/artifact-directions/directions/d1-solid/Counts.tsx b/ui/src/artifact-directions/directions/d1-solid/Counts.tsx
new file mode 100644
index 00000000000..56911f04ca8
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/Counts.tsx
@@ -0,0 +1,65 @@
+import { Fragment } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { MetaDot } from "./MetaLine";
+import { StateDot } from "./StateMarker";
+
+/**
+ * The mono count label, one grammar on every surface: a total in foreground,
+ * then any breakdown, then what is waiting — each waiting count behind its
+ * state dot, which is its separator. The caller sets the mono recipe
+ * (META_TEXT) and the muted colour for the fill it sits on.
+ *
+ * thread 5 attachments · 1 image · 4 files ● 5 to review
+ * panel 21 artifacts ● 9 to review
+ * gallery 6 stacks · 43 artifacts ● 12 to review ● 1 change requested
+ *
+ * Every item is unbreakable and the label wraps between items, so in a narrow
+ * tile or pane it takes a second line instead of running out of its slab.
+ */
+
+const reviewCount = (items: LabArtifact[]) => items.filter((a) => a.review === "needs_review").length;
+const changesCount = (items: LabArtifact[]) => items.filter((a) => a.review === "changes_requested").length;
+
+export function Counts({
+ lead,
+ parts = [],
+ of,
+ leadClassName = "text-foreground",
+}: {
+ /** "21 artifacts" */
+ lead: string;
+ /** Plain breakdown after the lead: "1 image", "4 files". */
+ parts?: string[];
+ /** The artifacts counted: what waits among them is appended behind its state dot. */
+ of?: LabArtifact[];
+ leadClassName?: string;
+}) {
+ const review = of ? reviewCount(of) : 0;
+ const changes = of ? changesCount(of) : 0;
+ return (
+
+
+ {lead}
+ {parts.map((part) => (
+
+
+ {part}
+
+ ))}
+
+ {review > 0 ? (
+
+
+ {review} to review
+
+ ) : null}
+ {changes > 0 ? (
+
+
+ {changes} {changes === 1 ? "change" : "changes"} requested
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx b/ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx
new file mode 100644
index 00000000000..27938a62f6d
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx
@@ -0,0 +1,204 @@
+import { Fragment } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { KindTag } from "./KindBlock";
+
+/**
+ * The preview for kinds whose preview is TEXT — task documents, Markdown, code
+ * and sheets — at panel and gallery scale: the artifact's own words set as
+ * real text on a SHEET, the lightest fill short of ink in both themes (paper
+ * on a mat), so content is the filled thing and never a recess.
+ *
+ * prose sans; the opening line and every heading in foreground medium,
+ * body in the sheet's text tone
+ * code the mono token, whitespace kept, a long line ends in an ellipsis
+ * sheet a table in the mono token, header row in foreground; whole columns
+ * only, as many as the sheet is wide enough for, the rest counted
+ * ("+2 cols")
+ *
+ * Three shapes:
+ * plate (default) a gallery preview: the kind tab in the bottom-left
+ * corner, prose at 13px, as many WHOLE lines as the plate's height
+ * has room for above the tab (--solid-sheet-fit) — at any column
+ * width, never a fixed count.
+ * cell a small cell of a stack mosaic: the same at 11px, 8px inset.
+ * drawer a panel card's preview: no tab (the card's kind block is right
+ * above it), height set by `lines`, 12px inset like the slab.
+ *
+ * The thread never uses this: at 72×44 a text kind is an ink plate.
+ * No fade at the bottom edge (no gradients in this direction).
+ */
+
+const CLAMP = { 3: "line-clamp-3", 6: "line-clamp-6" } as const;
+
+/** Whether an artifact's preview is its text (a picture always wins). */
+export const hasExcerpt = (a: LabArtifact) => Boolean(a.excerpt) && !a.thumbnailUrl;
+
+const words = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
+const paragraphs = (a: LabArtifact) => (a.body ?? a.excerpt ?? "").trim().split(/\n{2,}/);
+const HEADING = /^#{1,6}\s+/;
+
+/** Markdown to the text a sheet sets: bullets kept as bullets, link and emphasis syntax dropped. */
+const inline = (markdown: string) =>
+ markdown
+ .replace(/^\s*[-*]\s+/gm, "• ")
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
+ .replace(/[*_`]/g, "");
+
+/** Whether the text opens with the artifact's own title, which the surface already prints beside the sheet. */
+const opensWithTitle = (a: LabArtifact) => words(paragraphs(a)[0].replace(HEADING, "")) === words(a.title);
+
+interface Run {
+ text: string;
+ strong: boolean;
+ /** What separates it from the run before: a new line, or a space within the same paragraph. */
+ lead: "\n" | " ";
+}
+
+/**
+ * The prose as runs. The repeated title is left out; headings are strong; and
+ * the sheet always opens on a strong line — a short opening paragraph whole,
+ * a long one by its first sentence.
+ */
+function runs(a: LabArtifact): Run[] {
+ const all = paragraphs(a);
+ const blocks = (opensWithTitle(a) && all.length > 1 ? all.slice(1) : all).slice(0, 10);
+ const out: Run[] = blocks.map((block) => ({ text: inline(block.replace(HEADING, "")), strong: HEADING.test(block), lead: "\n" }));
+ const first = out[0];
+ if (!first || first.strong) return out;
+ const sentence = first.text.length > 90 ? /^(.+?[.!?])\s+([\s\S]+)$/.exec(first.text) : null;
+ if (!sentence) return [{ ...first, strong: true }, ...out.slice(1)];
+ return [{ text: sentence[1], strong: true, lead: "\n" }, { text: sentence[2], strong: false, lead: " " }, ...out.slice(1)];
+}
+
+const SHEET_TEXT = "text-(--solid-sheet-text)";
+/**
+ * Code and tables: 11px on 16px in a drawer or a mosaic cell; a size up on a
+ * tile's plate, which is read from further away (as prose is) — from 16rem of
+ * sheet, so the large cell of a mosaic keeps the smaller size and its columns.
+ */
+const MONO = "font-mono text-(length:--text-micro) leading-4";
+const MONO_PLATE = "font-mono text-(length:--text-micro) leading-4 @3xs:text-xs @3xs:leading-5";
+
+/**
+ * Columns are shown whole or not at all. How many is decided by the width of
+ * the sheet itself (its own container, inside its inset), so a table never
+ * runs out of its sheet: two, a third from 18rem, a fourth from 24rem, a fifth
+ * from 32rem. A mosaic cell shows the first column only. What is left out is
+ * counted in the header row.
+ */
+const COLUMN = ["", "", "hidden @2xs:table-cell", "hidden @sm:table-cell", "hidden @lg:table-cell"];
+const SHOWN = [
+ { columns: 2, when: "@2xs:hidden" },
+ { columns: 3, when: "hidden @2xs:table-cell @sm:hidden" },
+ { columns: 4, when: "hidden @sm:table-cell @lg:hidden" },
+ { columns: 5, when: "hidden @lg:table-cell" },
+];
+
+function Table({ csv, rows, single, mono, className }: { csv: string; rows: number; single: boolean; mono: string; className?: string }) {
+ const [head, ...body] = csv
+ .trim()
+ .split("\n")
+ .slice(0, rows)
+ .map((line) => line.split(",").slice(0, single ? 1 : COLUMN.length));
+ const total = csv.trim().split("\n")[0].split(",").length;
+ const notes = single ? [{ columns: 1, when: "" }] : SHOWN;
+ return (
+
+ );
+}
+
+export function ExcerptSheet({
+ artifact,
+ shape = "plate",
+ lines = 3,
+ className,
+}: {
+ artifact: LabArtifact;
+ shape?: "plate" | "cell" | "drawer";
+ /** A drawer's depth in whole lines of prose (code and tables get one more row: their lines are shorter). */
+ lines?: keyof typeof CLAMP;
+ /** Sizing. A plate or a cell fills what it is given; a drawer is as tall as its lines. */
+ className?: string;
+}) {
+ const text = artifact.excerpt ?? "";
+ const drawer = shape === "drawer";
+ const cell = shape === "cell";
+ const fit = drawer ? null : "max-h-(--solid-sheet-fit) overflow-hidden";
+ // A drawer shows a set number of rows; a fitted sheet is given more than it can hold and keeps whole ones.
+ const rows = drawer ? lines + 1 : 14;
+ const mono = shape === "plate" ? MONO_PLATE : MONO;
+ return (
+
+
+ );
+}
+
+/**
+ * The lab's one switch. Sectioning an opened stack by day is a change of
+ * structure riding on a visual direction; `?days=off` lists the stack as the
+ * product does today — one flat grid in the page's order — so the tiles can be
+ * judged like for like.
+ */
+const flat = () => typeof window !== "undefined" && new URLSearchParams(window.location.search).get("days") === "off";
+
+export function GalleryStack({ group }: { group: LabGroup }) {
+ if (group.artifacts.length === 0) {
+ return
No artifacts in this stack match the current filters.
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx b/ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx
new file mode 100644
index 00000000000..64d9fddd77b
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import { Play } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { ExcerptSheet } from "./ExcerptSheet";
+import { previewKind } from "./gallery-model";
+import { extent, fileName, metaLabel } from "./kinds";
+import { CornerTag, KindDock, KindTag, PlatePicture } from "./KindBlock";
+import { META_TEXT } from "./MetaLine";
+import { StateMarker } from "./StateMarker";
+
+/**
+ * GALLERY — the preview of one artifact. Every kind gets a preview that says
+ * something, and what fills it is the artifact's CONTENT, never its extension:
+ *
+ * picture the picture, full-bleed ink kind tab bottom-left
+ * text its own words on a sheet (ExcerptSheet) ink kind tab bottom-left
+ * facts a sheet that says what is known of a file the kind block docked bottom-left:
+ * with nothing to show: how much of it there the tab grown to a block, because
+ * is, at display size, and its filename here the kind is most of the story
+ *
+ * So ink is always the mark in the bottom-left corner and never the whole
+ * plate; a row of previews keeps one baseline of ink, and three decks in a row
+ * read as three decks. The other corner is the state: a bite the slab takes
+ * out of the bottom-right (StateMarker). A picture that fails to load becomes
+ * the facts sheet and says "No preview".
+ *
+ * `plate` is a tile's preview (18:11 at the width of its container) and the
+ * large cell of a stack mosaic; `cell` is a mosaic's small cell.
+ */
+
+function Facts({ artifact, cell, note }: { artifact: LabArtifact; cell: boolean; note?: string }) {
+ const fact = note ?? extent(artifact) ?? artifact.sizeLabel ?? metaLabel(artifact);
+ if (cell) {
+ return (
+
+
{fact}
+
+
+ );
+ }
+ return (
+
+
{fact}
+ {/* One line where the dock leaves room for one; two from 20rem of tile. */}
+
+ {fileName(artifact)}
+
+
+
+ );
+}
+
+export function GalleryPreview({
+ artifact,
+ scale = "plate",
+ state = true,
+ className,
+}: {
+ artifact: LabArtifact;
+ scale?: "plate" | "cell";
+ /** Show the state bite. Off inside a stack mosaic, where the stack's count line carries state. */
+ state?: boolean;
+ /** Sizing; the plate's 18:11 at the width of its container by default. */
+ className?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+ const kind = failed ? "facts" : previewKind(artifact);
+ const cell = scale === "cell";
+ return (
+
+ {kind === "text" ? (
+
+ ) : kind === "picture" ? (
+
+ setFailed(true)} />
+ {/* In a mosaic the tab is on the large cell only: three tabs on three photographs spend the signature where it says least. */}
+ {cell ? (
+ artifact.kind === "video" ? (
+
+
+
+ ) : null
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+ {state ? : null}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx b/ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx
new file mode 100644
index 00000000000..1e9d41de1da
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx
@@ -0,0 +1,85 @@
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { actionName, externalProps, type SolidAction } from "./actions";
+import { galleryActions } from "./gallery-model";
+import { GalleryPreview } from "./GalleryPreview";
+import { META_TEXT, MetaDot, MetaLine } from "./MetaLine";
+import { shortStamp } from "./panel-model";
+import { BUTTON, BUTTON_PRIMARY, COVER, ICON_BUTTON, SLAB, SLAB_HOVER } from "./slab";
+
+/**
+ * GALLERY — one artifact in an opened stack (or the ungrouped grid): a slab
+ * tile, preview first.
+ *
+ * ┌ preview, 18:11 ───────────────────────────────┐
+ * │ picture · words on a sheet · a file's facts │
+ * │ [KIND] ● Review │ ink bottom-left, state bite bottom-right
+ * └────────────────────────────────────────────────┘
+ * Title, the full width of the tile, up to two lines
+ * kind · size · rev · extent (mono)
+ * Sep 28, 11:07 AM · Author [⧉] [↓] [→] (stamp mono, author sans)
+ *
+ * The thread row's grammar turned on its end: block, title, mono meta, and
+ * the actions at the far end of the foot — where the thread row and the panel
+ * card end theirs. The whole tile is the [→] action's target (the owning
+ * task, at this artifact), which turns to ink when the tile is hovered. Open
+ * file in new tab and Download file are ghosts beside it, always visible, so
+ * they are reachable by touch and by keyboard and sit in no other link; each
+ * has its own glyph (a box with an arrow leaves the app, a tray downloads,
+ * the arrow goes to the task). In a tile under 18rem the actions take a row of
+ * their own.
+ */
+
+function Ghost({ action, artifact }: { action: SolidAction; artifact: LabArtifact }) {
+ const Icon = action.icon;
+ return (
+
+
+
+ );
+}
+
+export function GalleryTile({ artifact }: { artifact: LabArtifact }) {
+ const { primary, secondary } = galleryActions(artifact);
+ const PrimaryIcon = primary.icon;
+ return (
+
+
+
+
{artifact.title}
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx b/ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx
new file mode 100644
index 00000000000..2869452f0a2
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx
@@ -0,0 +1,166 @@
+import { useState, type ReactNode } from "react";
+import { Play } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { blockLabel, hasThumbnail, kindGlyph } from "./kinds";
+import { SLAB_RING } from "./slab";
+
+/**
+ * SIGNATURE 1 — the kind block: the one inverted (INK) fill in the direction.
+ *
+ * One landscape plate (18:11) with the kind's mono-caps label in its
+ * bottom-left corner and the kind's glyph in the opposite, top-right corner,
+ * so both corners of the plate are used and kinds differ by shape as well as
+ * by letters. It comes in three forms:
+ *
+ * KindBlock free-standing, the anchor of a row or a card:
+ * xs 56×36 attachments (label only) sm 72×44 thread row, panel card
+ * lg 128×96 a picture in the attachments block (4:3: a picture's plate, beside a narrow column of text)
+ * Where the artifact has a picture, the picture fills the plate
+ * and nothing is laid over it (a video keeps a play mark); a
+ * picture that fails to load falls back to the ink plate, so a
+ * broken thumbnail is never an empty box.
+ * KindTag the label alone, on an ink tab flush in the bottom-left corner
+ * of a preview that has content of its own (a picture, a sheet).
+ * KindDock the block docked in that same corner for a preview that has
+ * none — the tab grown to a block: 72×44 in a mosaic cell, 131×80
+ * on a tile's plate (72×44 while the tile is under 18rem).
+ *
+ * Tabs and docks size themselves against the gallery unit they sit in (an
+ * `@container/unit`: a tile, or a stack's face), never against the viewport.
+ */
+
+export type KindBlockSize = "xs" | "sm" | "lg";
+type FaceSize = KindBlockSize | "plate";
+
+const BOX: Record = {
+ xs: "h-9 w-14",
+ sm: "h-11 w-18",
+ lg: "h-24 w-32",
+ plate: "h-11 w-18 @2xs/unit:h-20 @2xs/unit:w-auto @2xs/unit:aspect-(--solid-plate-ratio)",
+};
+const PAD: Record = { xs: "p-1.5", sm: "px-2 py-1.5", lg: "p-3", plate: "px-2 py-1.5 @2xs/unit:p-3" };
+/** The glyph sits in the top-right corner, at the plate's own inset. xs has no room for one. */
+const GLYPH: Record = {
+ xs: null,
+ sm: "top-1.5 right-2 size-5",
+ lg: "top-3 right-3 size-7",
+ plate: "top-1.5 right-2 size-5 @2xs/unit:top-3 @2xs/unit:right-3 @2xs/unit:size-7",
+};
+const LABEL: Record = { xs: "text-xs", sm: "text-xs", lg: "text-lg", plate: "text-xs @2xs/unit:text-2xl" };
+
+/** Mono caps. Always followed by a size and then `leading-none`: tailwind-merge drops a leading that precedes a font size. */
+const MONO_CAPS = "font-mono font-semibold uppercase tracking-(--tracking-label)";
+
+const INK = "bg-(--solid-ink) text-(--solid-ink-foreground)";
+/** Ink laid over a preview is ringed with the slab fill (it follows the slab's steps), so it reads on a picture of any value. */
+const RING = SLAB_RING;
+
+function InkFace({ artifact, size }: { artifact: LabArtifact; size: FaceSize }) {
+ const Glyph = kindGlyph(artifact);
+ return (
+ <>
+ {GLYPH[size] ? : null}
+ {blockLabel(artifact)}
+ >
+ );
+}
+
+/** A picture filling its (relative, overflow-hidden) parent. A page starts at its top-left; a photograph is about its centre. */
+export function PlatePicture({ artifact, onError }: { artifact: LabArtifact; onError: () => void }) {
+ return (
+
+ );
+}
+
+const TAG: Record<"sm" | "md", string> = { sm: "px-1.5 pb-1.5 pt-1", md: "px-2 pb-2 pt-1.5" };
+const TAG_TEXT: Record<"sm" | "md", string> = { sm: "text-(length:--text-nano)", md: "text-(length:--text-micro)" };
+
+/** An ink tab in the bottom-left corner of a preview. The parent must be `relative overflow-hidden`. */
+export function CornerTag({ size = "md", className, children }: { size?: "sm" | "md"; className?: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The kind's label on a corner tab; a video's carries a play mark.
+ * `compact`: in a mosaic cell of a stack face narrower than 16rem, where there
+ * is no room for letters, the tab shows the kind's glyph instead.
+ */
+export function KindTag({ artifact, size = "md", compact = false }: { artifact: LabArtifact; size?: "sm" | "md"; compact?: boolean }) {
+ const Glyph = kindGlyph(artifact);
+ return (
+
+ {artifact.kind === "video" ? : null}
+ {compact ? : null}
+ {blockLabel(artifact)}
+
+ );
+}
+
+/**
+ * The kind block docked in the bottom-left corner of a sheet that has no
+ * content of its own to show. `compact` as for the tab: a glyph-only square
+ * where a mosaic cell has no room for the block.
+ */
+export function KindDock({ artifact, size = "plate", compact = false }: { artifact: LabArtifact; size?: "sm" | "plate"; compact?: boolean }) {
+ const Glyph = kindGlyph(artifact);
+ const dock = cn("absolute bottom-0 left-0 rounded-tr-md", INK, RING);
+ return (
+ <>
+ {compact ? (
+
+
+
+ ) : null}
+
+
+
+ >
+ );
+}
+
+export function KindBlock({
+ artifact,
+ size = "sm",
+ ink = false,
+ className,
+}: {
+ artifact: LabArtifact;
+ size?: KindBlockSize;
+ /** Always the ink plate, even when the artifact has a picture (a head that sits under the picture itself). */
+ ink?: boolean;
+ className?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+ const box = cn("relative shrink-0 overflow-hidden rounded-md", BOX[size], className);
+
+ if (hasThumbnail(artifact) && !failed && !ink) {
+ return (
+
+ setFailed(true)} />
+ {/* No kind tab on a picture this small: the meta line beside it says what it is. A video keeps its play mark. */}
+ {artifact.kind === "video" ? (
+
+
+
+ ) : null}
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx b/ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx
new file mode 100644
index 00000000000..6c7152a6473
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx
@@ -0,0 +1,70 @@
+import { Fragment } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { metaParts } from "./kinds";
+
+/**
+ * SIGNATURE 2 — the mono meta line: kind · size · rev · extent, in the
+ * monospace token, in that order on every surface.
+ */
+
+/** The mono recipe for every machine value in the set: meta lines, filenames, stamps, counts, identifiers. */
+export const META_TEXT = "font-mono text-(length:--text-micro) leading-4";
+
+/** A set-width separator: a mono " · " is three cells wide and leaves the line gappy. */
+export function MetaDot() {
+ return ·;
+}
+
+/** A host and path may break after a slash; nothing else in a part may break. */
+function breakable(part: string) {
+ if (!part.includes("/")) return part;
+ const pieces = part.split("/");
+ return pieces.map((piece, index) => (
+
+ {index > 0 ? : null}
+ {index < pieces.length - 1 ? `${piece}/` : piece}
+
+ ));
+}
+
+export function MetaLine({
+ artifact,
+ max,
+ wrap = false,
+ className,
+}: {
+ artifact: LabArtifact;
+ /** Keep only the first `max` parts when the surface is short of room. */
+ max?: number;
+ /**
+ * Let the line wrap (a panel card, a picture slab). It then breaks only
+ * between parts — never inside "90.1 KB" — and after a slash in a path.
+ * Off, the surface truncates it.
+ */
+ wrap?: boolean;
+ className?: string;
+}) {
+ const parts = max ? metaParts(artifact).slice(0, max) : metaParts(artifact);
+ return (
+
+ {parts.map((part, index) =>
+ wrap ? (
+ // The separator travels with the part before it, so a wrapped line never starts with a dot.
+
+
+ {breakable(part)}
+ {index < parts.length - 1 ? : null}
+
+ {index < parts.length - 1 ? : null}
+
+ ) : (
+
+ {index > 0 ? : null}
+ {part}
+
+ ),
+ )}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx
new file mode 100644
index 00000000000..766fcc5bfcc
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx
@@ -0,0 +1,77 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { Counts } from "./Counts";
+import { plural } from "./kinds";
+import { META_TEXT } from "./MetaLine";
+import { PanelCard } from "./PanelCard";
+import { panelSections, shortStamp, type PanelSection } from "./panel-model";
+
+/**
+ * PANEL — the scroll body of the task's Artifacts tab (434px pane, 402px of
+ * content).
+ *
+ * 21 artifacts ● 9 to review ← the shelf, counted
+ *
+ * Task documents ← a section: who or what
+ * 7 documents
+ * [card] [card] …
+ *
+ * Brand and Presentation Designer
+ * 5 artifacts ● 5 to review Sep 28, 10:52 AM ← counts · run start (mono)
+ * [card] [card] …
+ *
+ * Sections and their order are the product's — one per producing run, task
+ * documents on their own, as listed (panel-model.ts). The mono count label is
+ * the thread attachments label again: the count in foreground, the breakdown
+ * after it, a state dot where a state is counted.
+ *
+ * One column in the docked pane. A maximized pane sets the cards in 22rem
+ * columns, filled row by row so the listed order still reads across.
+ */
+
+function Section({ section, children }: { section: PanelSection; children: ReactNode }) {
+ const headingId = `solid-panel-section-${section.id}`;
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx
new file mode 100644
index 00000000000..0f29bb5855b
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import { MessageSquare } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { actionName, externalProps } from "./actions";
+import { ExcerptSheet, hasExcerpt } from "./ExcerptSheet";
+import { hasThumbnail, isWaiting, plural } from "./kinds";
+import { KindBlock } from "./KindBlock";
+import { META_TEXT, MetaDot, MetaLine } from "./MetaLine";
+import { panelActions } from "./panel-actions";
+import { stampUnder, type PanelSection } from "./panel-model";
+import { PanelLead } from "./PanelLead";
+import { PanelMenu } from "./PanelMenu";
+import { StateMarker } from "./StateMarker";
+import { BUTTON, BUTTON_HOVER, COUNT_BUTTON, FOCUS, primaryRest, SLAB } from "./slab";
+
+/**
+ * PANEL — one artifact on the task's shelf: a thread row with its drawer open.
+ *
+ * [kind block 72×44] Title, wraps a picture leads its card instead:
+ * Summary, wraps ┌ picture 16:9, kind tab ┐
+ * kind · size · rev · extent └────────────────────────┘
+ * filename.ext / content type Title …
+ * ┌ sheet: the excerpt as text ───────────────────┐ (only for text kinds)
+ * └────────────────────────────────────────────────┘
+ * ● Review 11:07 AM [💬 0] [⋯] [Read document ⌄]
+ *
+ * The head is the thread row at the same size. The foot is ONE row with the
+ * same three places on every card: the state (or nothing); who and when in
+ * mono (the author only where the section does not name them; the time alone
+ * where the section carries the day; it breaks between the two when short of
+ * room, never inside either); and at the far end the comments count (Markdown
+ * files), "More actions" (where there are any) and the primary — the thread's
+ * 136px block, in ink when the artifact waits for review.
+ *
+ * This is the reading surface, so the card is NOT a click target: text can be
+ * selected, tooltips show, and nothing downloads from a stray click. Only the
+ * controls act.
+ *
+ * Nothing in the head is clamped: titles, summaries, filenames and content
+ * types wrap. Under 20rem of card (a pane dragged narrow) the foot's two ends
+ * stack.
+ */
+
+/** Kinds whose card is the product's file card, which carries the content type. */
+const FILE_KINDS = new Set(["pdf", "slides", "sheet", "archive", "code", "file"]);
+
+export function PanelCard({ artifact, section }: { artifact: LabArtifact; section: PanelSection }) {
+ const [pictureFailed, setPictureFailed] = useState(false);
+ const { primary, more } = panelActions(artifact);
+ const PrimaryIcon = primary.icon;
+ const waiting = isWaiting(artifact);
+ const leads = hasThumbnail(artifact) && !pictureFailed;
+ const showAuthor = artifact.author !== section.agent;
+ const mono = cn(META_TEXT, "wrap-anywhere text-(--solid-meta)");
+
+ const button = {
+ "aria-label": actionName(primary, artifact),
+ // First in the DOM so it is first in the tab order; last in the row.
+ className: cn(BUTTON, primaryRest(waiting), BUTTON_HOVER, FOCUS, "order-last min-w-34 cursor-pointer justify-between px-2.5"),
+ };
+ const face = (
+ <>
+ {primary.label}
+
+ >
+ );
+
+ return (
+
+ {leads ? setPictureFailed(true)} /> : null}
+
+
+
+ {/* What waits for review is given room to be read here; the rest shows enough to be told apart. */}
+ {hasExcerpt(artifact) ? : null}
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx
new file mode 100644
index 00000000000..64efb44de94
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx
@@ -0,0 +1,59 @@
+import { useRef, useState } from "react";
+import type { LabArtifact } from "../../kit";
+import { KindTag, PlatePicture } from "./KindBlock";
+
+/**
+ * PANEL — what leads a card whose artifact is a picture: the picture itself,
+ * at 16:9 across the card, with the ink kind tab in its bottom-left corner —
+ * the same rule as the thread and the gallery (the picture is the plate and
+ * carries the tab), so nothing is represented twice.
+ *
+ * A video plays in place: its poster is the play button, and the product's
+ * controls take over once it has started. A picture that fails to load reports
+ * it, and the card falls back to the ink kind block.
+ */
+
+const LEAD = "relative aspect-(--solid-lead-ratio) w-full overflow-hidden rounded-md bg-(--solid-sheet)";
+
+function VideoLead({ artifact }: { artifact: LabArtifact }) {
+ const video = useRef(null);
+ const [started, setStarted] = useState(false);
+ return (
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx
new file mode 100644
index 00000000000..6fe8df278b2
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx
@@ -0,0 +1,47 @@
+import { Ellipsis } from "lucide-react";
+import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
+import type { LabArtifact } from "../../kit";
+import { externalProps, type SolidAction } from "./actions";
+import { ICON_BUTTON } from "./slab";
+
+/**
+ * PANEL — a card's "More actions": every way out that is not the primary, in
+ * one named control, so the foot is the same single row on every card. The
+ * list is the product's own menu (it opens in a portal, outside the kit's
+ * wrapper, in the app's popover chrome); each item is the action by name, not
+ * an unlabelled icon.
+ */
+export function PanelMenu({ actions, artifact }: { actions: SolidAction[]; artifact: LabArtifact }) {
+ if (actions.length === 0) return null;
+ return (
+
+
+
+
+
+ {actions.map((action) => {
+ const Icon = action.icon;
+ const face = (
+ <>
+
+ {action.label}
+ >
+ );
+ return (
+
+ {action.href ? (
+
+ {face}
+
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/StackTile.tsx b/ui/src/artifact-directions/directions/d1-solid/StackTile.tsx
new file mode 100644
index 00000000000..f943cf35fe6
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/StackTile.tsx
@@ -0,0 +1,129 @@
+import { ArrowRight } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { LabGroup } from "../../kit";
+import { Counts } from "./Counts";
+import { kindTally, pileDepth, stackPreviews } from "./gallery-model";
+import { GalleryPreview } from "./GalleryPreview";
+import { plural } from "./kinds";
+import { META_TEXT, MetaDot } from "./MetaLine";
+import { CORNER_NOTCH } from "./StateMarker";
+import { BUTTON, BUTTON_PRIMARY, COVER, SLAB, SLAB_HOVER } from "./slab";
+
+/**
+ * GALLERY — one task's stack.
+ *
+ * ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ pile: the slabs behind the face, each narrower
+ * ▁ MD 7 · PDF 4 · PPTX 4 · PNG 3 ▁▁▁ and one step nearer the canvas; the nearer one
+ * ┌──────────────────────┬─────────┐ says what is in the pile, on its edge
+ * │ │ 12 slides│
+ * │ picture ├─────────┤ mosaic: the newest three, each as its own preview;
+ * │ [PNG] │ [ZIP] +18│ the one with most to show is large
+ * └──────────────────────┴─────────┘
+ * ACM-3 Create visual directions and first acmehats brand deck
+ * 21 artifacts ● 9 to review [→]
+ * Updated Sep 28, 2026
+ *
+ * The pile is made of flat fills only. Its depth says how much is in it: no
+ * slab behind one artifact, one behind two or three, two behind more. A stack
+ * of one is therefore an artifact tile with a task's name on it — one preview
+ * at full size, a flat top — and cannot be mistaken for a pile.
+ *
+ * The whole object — face and pile — is the [→] action's target ("Open
+ * stack"). Hover or keyboard focus steps the face, turns the action to ink,
+ * and the pile rises 6px a slab (not under reduced motion).
+ *
+ * Pile geometry: 28px is kept above every face; the nearer slab shows 16px of
+ * itself 12px in from each side, the farther one 12px more, 32px in.
+ */
+
+const LAYER =
+ "pointer-events-none absolute h-10 overflow-hidden rounded-md transition-transform duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-reduce:transition-none";
+const RISE_1 = "motion-safe:group-hover/pile:-translate-y-1.5 motion-safe:group-has-[[data-primary]:focus-visible]/pile:-translate-y-1.5";
+const RISE_2 = "motion-safe:group-hover/pile:-translate-y-3 motion-safe:group-has-[[data-primary]:focus-visible]/pile:-translate-y-3";
+
+function Pile({ group, depth }: { group: LabGroup; depth: 0 | 1 | 2 }) {
+ if (depth === 0) return null;
+ return (
+ <>
+ {depth > 1 ? : null}
+
+ {/* The slab's visible edge is 16px: one line of mono caps, cut clean where the pile runs out of room. */}
+
+ {kindTally(group.artifacts).map((entry, index) => (
+
+ {index > 0 ? : null}
+ {entry}
+
+ ))}
+
+
+ >
+ );
+}
+
+/** The corner bite of the mosaic: how many artifacts it leaves out, in mono. */
+function MoreTab({ count }: { count: number }) {
+ return (
+
+ +{count}
+
+ );
+}
+
+function Mosaic({ group }: { group: LabGroup }) {
+ const [lead, ...rest] = stackPreviews(group);
+ if (!lead) return null;
+ if (rest.length === 0) return ;
+ const more = group.count - 1 - rest.length;
+ return (
+
+
+
+
+ {rest.map((artifact) => (
+
+
+
+ ))}
+ {more > 0 ? : null}
+
+ );
+}
+
+export function StackTile({ group }: { group: LabGroup }) {
+ const depth = pileDepth(group.count);
+ const count = plural(group.count, "artifact");
+ return (
+ // 28px above every face, so faces line up along a row whether or not a pile sits behind them.
+
+
+
+
+
+ {/* h2: on the stacks page a stack sits directly under the page title. */}
+
+ {group.taskIdentifier}
+ {group.title}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx b/ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx
new file mode 100644
index 00000000000..51927c7aa1f
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx
@@ -0,0 +1,75 @@
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { MOTION, SLAB_FILL, SLAB_JOINTS } from "./slab";
+
+/**
+ * The state marker: the only chroma in the direction, and only a 6px dot of
+ * it, followed by the production label in foreground text.
+ *
+ * On a slab it has no fill of its own — dot and label sit straight on the
+ * slab, so it can never be taken for the button next to it. On a gallery
+ * preview it is written in a BITE the slab takes out of the preview's
+ * bottom-right corner, the mirror of the ink kind tab in the bottom-left: what
+ * it is on the left, what it needs on the right.
+ *
+ * Nothing renders for an artifact with no pending state, as in the product.
+ */
+
+/** The one state vocabulary: label and dot fill per state. Counts.tsx counts in the same dots. */
+export const STATE = {
+ needs_review: { label: "Review", dot: "bg-(--solid-state-review)" },
+ changes_requested: { label: "Changes requested", dot: "bg-(--solid-state-changes)" },
+} as const;
+
+export function StateDot({ state, className }: { state: keyof typeof STATE; className?: string }) {
+ return ;
+}
+
+/**
+ * A bite out of a preview's bottom-right corner, in the slab's own fill (it
+ * follows the slab's hover). The state marker uses it in the gallery; so does
+ * a stack mosaic's "+18". The parent must be `relative overflow-hidden`.
+ *
+ * It is cut, not laid on: where the bite meets the preview's right and bottom
+ * edges, two 6px joints (its pseudo-elements) round the preview's new corners
+ * to the set's one radius — the slab painted into the corner outside a
+ * quarter circle (--solid-joint).
+ */
+export const CORNER_NOTCH = cn(
+ "absolute right-0 bottom-0 rounded-tl-md",
+ SLAB_FILL,
+ MOTION,
+ // The joint above the bite, on the preview's right edge; and the one beside it, on the bottom edge.
+ "before:absolute before:right-0 before:bottom-full before:size-1.5 before:rounded-br-md before:shadow-(--solid-joint)",
+ "after:absolute after:right-full after:bottom-0 after:size-1.5 after:rounded-br-md after:shadow-(--solid-joint)",
+ SLAB_JOINTS,
+);
+
+export function StateMarker({
+ review,
+ on = "slab",
+ collapse = false,
+ className,
+}: {
+ review: LabArtifact["review"];
+ /** `slab`: dot and label on the slab. `corner`: in a bite out of a preview's bottom-right corner. */
+ on?: "slab" | "corner";
+ /** Inside an `@container` narrower than 36rem, show only the dot (the label stays for screen readers). */
+ collapse?: boolean;
+ className?: string;
+}) {
+ if (!review) return null;
+ return (
+
+
+ {STATE[review].label}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx b/ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx
new file mode 100644
index 00000000000..3dc884b4223
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx
@@ -0,0 +1,102 @@
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { Counts } from "./Counts";
+import { fileName, hasThumbnail, plural } from "./kinds";
+import { KindBlock } from "./KindBlock";
+import { META_TEXT, MetaLine } from "./MetaLine";
+import { STATE, StateMarker } from "./StateMarker";
+import { FOCUS, SLAB, SLAB_HOVER } from "./slab";
+
+/**
+ * THREAD — the attachments block under an agent message.
+ *
+ * Today this is two unrelated blocks (an "Images · 1" thumbnail grid and a
+ * scrolling "Files · 4" strip of square-cornered tiles). Here it is one grid
+ * under one mono label that keeps both counts and says what waits:
+ *
+ * 5 attachments · 1 image · 4 files ● 5 to review
+ * ┌ picture 128×96 ┐ contact-sheet.png [PPTX] name.pptx [PDF] name.pdf
+ * │ │ Image · 422.9 KB Slides · 90.1 KB ● Review …
+ * │ │ [MD] deck-guide.md [ZIP] editing-source.zip
+ * └────────────────┘ ● Review Markdown · 5.9 KB ● Review …
+ *
+ * A file is a 52px slab: kind block, filename, mono meta, state. Anything with
+ * a picture takes two rows (112px) so the picture is shown at a size it can be
+ * read at, with nothing laid over it. The grid wraps, so no file is ever
+ * scrolled out of sight.
+ */
+
+function AttachmentSlab({ artifact }: { artifact: LabArtifact }) {
+ const name = fileName(artifact);
+ const picture = hasThumbnail(artifact);
+ const opensGallery = artifact.kind === "image" || artifact.kind === "video";
+ const shared = {
+ // The name replaces the slab's content for assistive tech, so the state is part of it.
+ "aria-label": [`Open ${name}`, artifact.review ? STATE[artifact.review].label : null].filter(Boolean).join(", "),
+ title: [name, artifact.title === name ? null : artifact.title].filter(Boolean).join(" — "),
+ className: cn(
+ "group/slab flex size-full min-w-0 cursor-pointer gap-2.5 p-2 pr-3 text-left focus-visible:bg-(--solid-slab-hover)",
+ picture ? "items-stretch" : "items-center",
+ SLAB,
+ SLAB_HOVER,
+ FOCUS,
+ ),
+ };
+ const face = picture ? (
+ <>
+
+
+ {name}
+
+ {/* The one place a state label may take two lines: the column beside a picture is narrow. */}
+
+
+ >
+ ) : (
+ <>
+
+
+ {name}
+
+ {/* A long state takes the size's place; the kind always stays. */}
+
+
+
+
+ >
+ );
+ return opensGallery ? (
+
+ ) : (
+
+ {face}
+
+ );
+}
+
+export function ThreadAttachments({ artifacts }: { artifacts: LabArtifact[] }) {
+ if (artifacts.length === 0) return null;
+ const images = artifacts.filter((a) => a.kind === "image");
+ const files = artifacts.filter((a) => a.kind !== "image");
+ const counts = [images.length > 0 ? plural(images.length, "image") : null, files.length > 0 ? plural(files.length, "file") : null].filter(
+ (count): count is string => Boolean(count),
+ );
+ // Whatever has a picture goes first, so the two-row slabs start the grid and the files pack beside them.
+ const ordered = [...artifacts.filter(hasThumbnail), ...artifacts.filter((a) => !hasThumbnail(a))];
+ return (
+
+
+
+
+
+ {ordered.map((artifact) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx b/ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx
new file mode 100644
index 00000000000..1b306462216
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx
@@ -0,0 +1,100 @@
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { actionName, downloadAction, externalProps, threadAction } from "./actions";
+import { extent, isWaiting, metaParts } from "./kinds";
+import { KindBlock } from "./KindBlock";
+import { MetaLine } from "./MetaLine";
+import { StateMarker } from "./StateMarker";
+import { BUTTON, BUTTON_PRIMARY, COVER, ICON_BUTTON, primaryRest, SLAB, SLAB_HOVER } from "./slab";
+
+/**
+ * THREAD — one work product between turns: a 60px slab.
+ *
+ * [kind block 72×44] Title ● Review [Open gallery ⤢]
+ * kind · size · rev · extent — summary
+ *
+ * One grammar for all ten kinds (today: three). The whole slab is the primary
+ * action's target; hovering it steps the fill and turns the action to ink. An
+ * artifact that waits for review has its action in ink already.
+ *
+ * The row gives way in order of what matters least (by its own width, inside its inset):
+ * from 48rem one-line title, summary on the meta line after a dash
+ * under 48rem two-line title, summary on its own line under the meta, two lines at most
+ * (a row with a summary grows to 76px; nothing is left to a tooltip)
+ * from 42rem every action is the same 136px block, as every row starts with the same 72px one
+ * under 36rem the state is its dot alone
+ * under 28rem the action is its icon alone
+ * and the text column never goes under 8rem, so the title is the last thing to yield.
+ */
+export function ThreadWorkProduct({ artifact }: { artifact: LabArtifact }) {
+ const action = threadAction(artifact);
+ const Icon = action.icon;
+ // A text-like file opens in the side panel, so Download becomes its own small target.
+ const download = action.id === "tab" ? downloadAction(artifact) : null;
+ const DownloadIcon = download?.icon;
+ // "Slides · 90.1 KB · 12 slides — 12 editable slides…" stutters: the summary keeps the figure, the meta lets it go.
+ const figure = extent(artifact)?.split(" ")[0];
+ const echoed = Boolean(figure && artifact.summary?.startsWith(`${figure} `));
+
+ const primary = {
+ "data-primary": true,
+ "aria-label": actionName(action, artifact),
+ title: actionName(action, artifact),
+ // First in the DOM so it is first in the tab order; last in the row.
+ className: cn(
+ BUTTON,
+ primaryRest(isWaiting(artifact)),
+ BUTTON_PRIMARY,
+ COVER,
+ "order-last cursor-pointer after:cursor-pointer @2xl:min-w-34 @2xl:justify-between @2xl:px-2.5",
+ ),
+ };
+ const face = (
+ <>
+ {action.label}
+
+ >
+ );
+
+ return (
+
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/actions.ts b/ui/src/artifact-directions/directions/d1-solid/actions.ts
new file mode 100644
index 00000000000..aa3b981c3e2
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/actions.ts
@@ -0,0 +1,42 @@
+import { Download, ExternalLink, Maximize2, PanelRightOpen, type LucideIcon } from "lucide-react";
+import type { LabArtifact } from "../../kit";
+import { isTaskDocument, isTextLike, slug } from "./kinds";
+
+/**
+ * The one thing you do with an artifact from the thread, in production
+ * wording. The lab's targets are inert hashes; `gallery` is a button because
+ * the product opens a modal from it.
+ *
+ * One glyph per destination on every surface, so no two controls on a unit
+ * look alike: ExternalLink leaves for a new browser tab, PanelRightOpen opens
+ * the side panel, Maximize2 opens the gallery, Download downloads, and
+ * ArrowRight (the gallery only) goes to the owning task.
+ */
+
+export interface SolidAction {
+ id: "document" | "gallery" | "preview" | "tab" | "download";
+ label: string;
+ icon: LucideIcon;
+ /** Absent for `gallery`, which is a button. */
+ href?: string;
+ external?: boolean;
+}
+
+export function downloadAction(a: LabArtifact): SolidAction {
+ return { id: "download", label: "Download", icon: Download, href: `#download-${a.id}` };
+}
+
+export function threadAction(a: LabArtifact): SolidAction {
+ if (isTaskDocument(a)) return { id: "document", label: "Open document", icon: PanelRightOpen, href: `#document-${slug(a)}` };
+ if (a.kind === "link") return { id: "preview", label: "Open preview", icon: ExternalLink, href: a.url ?? "#", external: true };
+ if (a.kind === "image" || a.kind === "video") return { id: "gallery", label: "Open gallery", icon: Maximize2 };
+ // Text-like files open in the side panel; Download stays reachable as its own target.
+ if (isTextLike(a)) return { id: "tab", label: "Open in tab", icon: PanelRightOpen, href: `#attachment-${a.id}` };
+ return downloadAction(a);
+}
+
+/** The accessible name of an action on a given artifact: "Download: Interface Craft revision — PDF". */
+export const actionName = (action: SolidAction, a: LabArtifact) => `${action.label}: ${a.title}`;
+
+/** Link attributes for an action that leaves for a new browser tab. */
+export const externalProps = (action: SolidAction) => (action.external ? ({ target: "_blank", rel: "noreferrer" } as const) : null);
diff --git a/ui/src/artifact-directions/directions/d1-solid/gallery-model.ts b/ui/src/artifact-directions/directions/d1-solid/gallery-model.ts
new file mode 100644
index 00000000000..313eaaca50b
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/gallery-model.ts
@@ -0,0 +1,107 @@
+import { ArrowRight, Download, ExternalLink } from "lucide-react";
+import type { LabArtifact, LabGroup } from "../../kit";
+import type { SolidAction } from "./actions";
+import { blockLabel, hasThumbnail, isTaskDocument, slug } from "./kinds";
+
+/**
+ * How the gallery reads its data: what a tile's preview is, which previews a
+ * stack shows, how an opened stack is sectioned, and what a tile offers.
+ */
+
+/**
+ * The three previews of the direction, by what the artifact has to show:
+ * picture image, video poster, link preview → the picture, kind tab in the corner
+ * text task document, Markdown, code, sheet → its own words on a sheet, kind tab in the corner
+ * facts PDF, slides, archive, any other file → a sheet that says what is known of it, the kind block docked in the corner
+ */
+export type PreviewKind = "picture" | "text" | "facts";
+
+export function previewKind(a: LabArtifact): PreviewKind {
+ if (hasThumbnail(a)) return "picture";
+ return a.excerpt ? "text" : "facts";
+}
+
+const VISUAL: Record = { picture: 0, text: 1, facts: 2 };
+
+/**
+ * A stack's mosaic: its three newest artifacts (what the API already sends as
+ * `previewArtifacts`), with the one that has most to show in the large cell —
+ * a picture before text before bare facts.
+ */
+export function stackPreviews(group: LabGroup): LabArtifact[] {
+ return group.artifacts
+ .slice(0, 3)
+ .map((artifact, index) => ({ artifact, index }))
+ .sort((a, b) => VISUAL[previewKind(a.artifact)] - VISUAL[previewKind(b.artifact)] || a.index - b.index)
+ .map((entry) => entry.artifact);
+}
+
+/** How many slabs sit behind a stack's face: none for one artifact, one for two or three, two for a real pile. */
+export const pileDepth = (count: number): 0 | 1 | 2 => (count <= 1 ? 0 : count <= 3 ? 1 : 2);
+
+/**
+ * What is in the pile, for the edge of the slab behind a stack's face: kinds
+ * by their block label, most numerous first ("PDF 4 · PPTX 4 · PNG 3").
+ * Task documents are counted together as DOCS — their block label is a revision.
+ */
+export function kindTally(artifacts: LabArtifact[]): string[] {
+ const counts = new Map();
+ for (const artifact of artifacts) {
+ const label = isTaskDocument(artifact) ? "DOCS" : blockLabel(artifact);
+ counts.set(label, (counts.get(label) ?? 0) + 1);
+ }
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([label, count]) => `${label} ${count}`);
+}
+
+export interface DaySection {
+ /** "2026-09-28" */
+ id: string;
+ /** "Monday, September 28" */
+ label: string;
+ items: LabArtifact[];
+}
+
+/** An opened stack by the day of the last edit, newest day first; inside a day the page's own order (newest first) is kept. */
+export function daySections(artifacts: LabArtifact[]): DaySection[] {
+ const byDay = new Map();
+ for (const artifact of artifacts) {
+ const id = artifact.updatedAt.slice(0, 10);
+ const section = byDay.get(id) ?? { id, label: artifact.dayLabel, items: [] };
+ section.items.push(artifact);
+ byDay.set(id, section);
+ }
+ return [...byDay.values()].sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
+}
+
+export interface GalleryActions {
+ /** The tile itself: the owning task, at this artifact. */
+ primary: SolidAction;
+ /** The product's two ways straight to the file, where it has them. */
+ secondary: SolidAction[];
+}
+
+/**
+ * What a gallery tile offers, in the product's wording. The tile is a link to
+ * the owning task at the artifact's anchor; "Open file in new tab" and
+ * "Download file" exist for anything file-backed (a task document has no file,
+ * a link only opens).
+ */
+export function galleryActions(a: LabArtifact): GalleryActions {
+ const primary: SolidAction = {
+ id: "document",
+ label: `Open in ${a.taskIdentifier}`,
+ icon: ArrowRight,
+ href: isTaskDocument(a) ? `#document-${slug(a)}` : `#work-product-${a.id}`,
+ };
+ if (isTaskDocument(a)) return { primary, secondary: [] };
+ if (a.kind === "link") {
+ return { primary, secondary: [{ id: "preview", label: "Open link in new tab", icon: ExternalLink, href: a.url ?? "#", external: true }] };
+ }
+ return {
+ primary,
+ secondary: [
+ { id: "preview", label: "Open file in new tab", icon: ExternalLink, href: `#file-${a.id}`, external: true },
+ { id: "download", label: "Download file", icon: Download, href: `#download-${a.id}` },
+ ],
+ };
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/index.tsx b/ui/src/artifact-directions/directions/d1-solid/index.tsx
new file mode 100644
index 00000000000..26e4e76e9e2
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/index.tsx
@@ -0,0 +1,142 @@
+import type { DirectionKit } from "../../kit";
+import { GalleryStack, GalleryStacks } from "./Gallery";
+import { PanelBody } from "./PanelBody";
+import { ThreadAttachments } from "./ThreadAttachments";
+import { ThreadWorkProduct } from "./ThreadWorkProduct";
+import "./tokens.css";
+
+/**
+ * DIRECTION 1 — SOLID. Flat, filled, mostly monochrome.
+ *
+ * The fix is fill, not line. Today an artifact is a 1px outline around the
+ * canvas colour; here it is a SLAB — a filled block a clear value step off the
+ * canvas, with no stroke, no shadow and no gradient — and what fills an
+ * artifact's preview is its CONTENT, never its file extension.
+ *
+ * FILLS (tokens.css; OKLCH lightness, light / dark; contrast against what it sits on)
+ * canvas 1.000 / 0.205 --background, untouched
+ * slab 0.900 / 0.325 the artifact (1.35 / 1.44 on the canvas; today's outline colour is 1.26 / 1.32)
+ * slab-hover 0.865 / 0.365 a slab that is a target, hovered or focused; slab-press 0.830 / 0.400 while pressed
+ * button 0.800 / 0.400 a resting button on a slab (1.39 / 1.35 on the slab)
+ * sheet 1.000 / 0.430 the artifact's own content — an excerpt, a file's facts: the lightest fill short of ink
+ * in both themes (paper on a mat); body text in --solid-sheet-text, lead lines in --foreground
+ * ink 0.205 / 0.800 the one inverted fill: the kind block and tab, and a primary action that is hovered or due
+ * pile-1/-2 0.920 / 0.290 and 0.945 / 0.265 the slabs behind a gallery stack: each one step NEARER the canvas, both themes
+ * Text on a slab: --foreground, and --solid-meta (0.44 / 0.78) for secondary; every pair ≥ 4.5:1 at rest and on hover.
+ *
+ * SIGNATURES — the same idea on thread, panel and gallery
+ * 1. KIND BLOCK (KindBlock.tsx, kinds.ts) — an INK plate: mono-caps label
+ * bottom-left, the kind's glyph top-right (20px in a thread row, so kinds
+ * differ by shape as well as letters). A file is labelled by its extension
+ * (PDF, PPTX, MD, PNG, ZIP, XLSX, WEBM, HTML, OTF; URL for a link); a task
+ * document, which has none, by its revision (REV 3) under a notebook glyph.
+ * KindBlock free-standing: xs 56×36 attachments (label only) · sm 72×44
+ * thread row and panel card · lg 128×96 a picture attachment.
+ * An artifact with a picture shows the picture in the plate,
+ * with nothing over it (a video keeps a play mark).
+ * KindTag the label on an ink tab in the bottom-left corner of a
+ * preview that has content of its own (a picture, a sheet).
+ * KindDock the block docked in that corner of a preview that has none
+ * (131×80 on a tile, 72×44 in a mosaic cell): the tab grown
+ * to a block. Ink is always the mark in the bottom-left
+ * corner — never the whole plate.
+ * 2. MONO META LINE (MetaLine.tsx, kinds.ts) — kind · size · rev · extent in
+ * font-mono 11px, never reordered: "PDF · 57.7 KB · 12 pages",
+ * "Document · rev 3", "Video · 3.4 MB · 0:06". Production wording; a
+ * surface may take a prefix (`max`), never reorder. Every other machine
+ * value (filenames, stamps, counts, content types) is the same META_TEXT.
+ * 3. ONE RADIUS, ONE RHYTHM (slab.ts) — rounded-md on every slab, plate,
+ * sheet, button, tab and bite; 8px inset around the kind block in the
+ * thread, 12px in the panel and the gallery; 12px from block to text; 8px
+ * between slabs, 24px gutters in the gallery.
+ * 4. MONO COUNT LABEL (Counts.tsx) — heads the thread's attachments, the
+ * panel's shelf and sections, and the gallery's grids, days and stacks:
+ * "21 artifacts ● 9 to review". Items are unbreakable; the label wraps
+ * between them.
+ *
+ * STATE (StateMarker.tsx) — the only chroma, and only a 6px dot of it: the
+ * product's status hue + the production label ("Review", "Changes requested")
+ * in foreground text, straight on the slab with no fill of its own, so it is
+ * never taken for a button. What makes "this needs you" unmistakable is the
+ * action beside it: the primary of an artifact that waits for review is INK AT
+ * REST (thread and panel). On a gallery preview the state is written in a BITE
+ * the slab takes out of the bottom-right corner (inverse radii at its joints),
+ * the mirror of the ink kind tab bottom-left; a mosaic's "+18" is the same bite.
+ *
+ * INTERACTION (slab.ts) — in the thread and the gallery the whole slab is its
+ * primary action's target (COVER). Hover or keyboard focus steps the slab and
+ * turns that action to ink; pressing steps it once more. A secondary control
+ * is an icon-only ghost that takes the ink itself and hands the primary back
+ * to the button fill while hovered. The panel card is a reading surface and is
+ * NOT a target: only its controls act. The primary is first in the tab order
+ * everywhere; icon-only controls carry a tooltip naming the action. One glyph
+ * per destination: a box-with-arrow leaves for a new tab, a tray downloads, a
+ * side-panel glyph opens the pane, the plain arrow goes to the owning task.
+ * focus-visible is a 2px ink outline 2px off the slab or the control. Colour
+ * transitions only (and the pile's rise); none under reduced motion.
+ *
+ * THREAD — compact and calm (ThreadWorkProduct, ThreadAttachments)
+ * Row 60px: [block 72×44] title / meta — summary … ● state [action 136px].
+ * Gives way by priority, by its own width: from 48rem a one-line title with
+ * the summary on the meta line; under that a two-line title and the summary
+ * on its own line (76px); under 36rem the state is its dot; under 28rem the
+ * action is its icon; the text column never goes under 8rem.
+ * Attachments: one count label ("5 attachments · 1 image · 4 files ● 5 to
+ * review") over a wrapping grid: a file is a 52px slab (block, filename, meta,
+ * state); anything with a picture takes two rows, the picture at 128×96.
+ *
+ * PANEL — a thread row with its drawer open (PanelBody, PanelCard, PanelLead,
+ * PanelMenu, panel-model.ts, panel-actions.ts)
+ * Shelf one count line, then the product's sections in the product's
+ * order: one per producing run (name, count line, run start), task
+ * documents on their own. Nothing is reordered.
+ * Card slab, 12px inset. A picture or a video LEADS its card at 16:9
+ * with the kind tab on it (a video plays in place); every other
+ * kind opens with [block 72×44] beside the text.
+ * head title, summary, meta line, filename, and the content type on
+ * file cards — all wrap, nothing is clamped.
+ * sheet text kinds only: the excerpt on a sheet, opening line and
+ * headings strong; six lines when it waits for review, three otherwise.
+ * foot one row, the same three places on every card: ● state · who and
+ * when (the time alone under a section dated the same day) · and
+ * at the far end [comments] [⋯ More actions] [primary 136px]. All
+ * secondaries are in the one menu, by name.
+ * Widths under 20rem of card the foot's two ends stack; a maximized pane
+ * sets the cards in 22rem columns, row by row.
+ *
+ * GALLERY — browsing: preview first (Gallery, StackTile, GalleryTile,
+ * GalleryPreview, ExcerptSheet, gallery-model.ts)
+ * Grid the product's: 1 / 2 / 3 columns at <768 / 768 / 1280px, 24px
+ * gutters, under one count line. An opened stack is sectioned by
+ * day of last edit: the day name is the page's one large line
+ * (text-lg semibold), its count line beneath. `?days=off` lists it
+ * as the product does today, in one flat grid.
+ * Preview one 18:11 plate per artifact, filled by what the artifact is:
+ * picture image, video poster, link the picture + ink kind tab
+ * text document, Markdown, code, its own words on a sheet, as many whole
+ * sheet lines as the plate holds + ink kind tab
+ * facts PDF, slides, archive, file a sheet: how much of it there is at display
+ * size ("12 slides"), its filename, and the
+ * kind block docked bottom-left
+ * Tile slab: preview / title across the full width (2 lines) / meta
+ * line and stamp · author, with [⧉] [↓] [→] at the far end of the
+ * foot. The tile is the [→] action's target (the owning task).
+ * Stack a PILE of flat fills behind the face: 0 / 1 / 2 slabs for 1 /
+ * 2–3 / more artifacts; the nearer one says what is in the pile on
+ * its edge ("DOCS 7 · PDF 3 · PPTX 3"); they rise 6px a slab on
+ * hover. On the face a MOSAIC of the three newest, each as its own
+ * preview, the one with most to show large, "+18" bitten into the
+ * corner. Then ACM-3 + title, and a two-line foot (counts, then
+ * Updated) with [→]. Face and pile are one target. A stack of one
+ * has no pile and one full preview.
+ */
+export const kit: DirectionKit = {
+ id: "d1-solid",
+ name: "1.Solid",
+ tagline: "Flat, filled, mostly monochrome",
+ ThreadWorkProduct,
+ ThreadAttachments,
+ PanelBody,
+ GalleryStacks,
+ GalleryStack,
+};
diff --git a/ui/src/artifact-directions/directions/d1-solid/kinds.ts b/ui/src/artifact-directions/directions/d1-solid/kinds.ts
new file mode 100644
index 00000000000..faae6384955
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/kinds.ts
@@ -0,0 +1,112 @@
+import { Archive, CodeXml, File, FilePenLine, FileText, Film, Globe, Image, NotebookText, Presentation, Table2, type LucideIcon } from "lucide-react";
+import { fileKindForName } from "@/components/task-chat/task-chat-attachments";
+import type { LabArtifact, LabKind } from "../../kit";
+
+/**
+ * The Solid vocabulary: for each of the ten kinds, the glyph that goes in the
+ * kind block, the mono-caps label under it, and the wording of the meta line.
+ * Every surface reads kinds from here so a PDF is the same PDF in the thread,
+ * the panel and the gallery.
+ *
+ * Glyphs are chosen to differ in shape at 20px, not only in their letters: a
+ * notebook (task document — kept and revised in the task), a page with a pen
+ * (Markdown — a source you edit), a lined page (PDF — the finished page), an
+ * easel, a grid, a box, brackets, a blank page, a globe, a framed picture, a
+ * film strip.
+ */
+
+interface KindSpec {
+ glyph: LucideIcon;
+ /** Kind-block label when the artifact has no extension to show. */
+ block: string;
+ /** Meta-line label when the product's extension mapper only says "File". */
+ meta: string;
+}
+
+export const KINDS: Record = {
+ document: { glyph: FilePenLine, block: "MD", meta: "Markdown" },
+ image: { glyph: Image, block: "IMG", meta: "Image" },
+ video: { glyph: Film, block: "VID", meta: "Video" },
+ pdf: { glyph: FileText, block: "PDF", meta: "PDF" },
+ slides: { glyph: Presentation, block: "PPT", meta: "Slides" },
+ sheet: { glyph: Table2, block: "XLS", meta: "Sheet" },
+ archive: { glyph: Archive, block: "ZIP", meta: "Archive" },
+ code: { glyph: CodeXml, block: "CODE", meta: "Code" },
+ file: { glyph: File, block: "FILE", meta: "File" },
+ link: { glyph: Globe, block: "URL", meta: "Link" },
+};
+
+/** A task document ("Document · rev N"), as opposed to a Markdown file an agent attached. */
+export const isTaskDocument = (a: LabArtifact) => a.kind === "document" && a.revision != null;
+
+/** A task document is a notebook: it is kept and revised in the task, not a file on disk. */
+export const kindGlyph = (a: LabArtifact): LucideIcon => (isTaskDocument(a) ? NotebookText : KINDS[a.kind].glyph);
+
+/**
+ * The mono-caps label in the kind block. A file is labelled by its extension
+ * (PDF, PPTX, MD, PNG, ZIP, CSV, WEBM); a task document has none, and is
+ * labelled by what tells one from the next — its revision (REV 3).
+ */
+export function blockLabel(a: LabArtifact): string {
+ if (a.kind === "link") return KINDS.link.block;
+ if (isTaskDocument(a)) return `REV ${a.revision}`;
+ const ext = a.ext?.replace(/[^a-z0-9]/gi, "");
+ return ext ? ext.slice(0, 4).toUpperCase() : KINDS[a.kind].block;
+}
+
+/** The first word of the meta line, in production wording where production has one. */
+export function metaLabel(a: LabArtifact): string {
+ if (isTaskDocument(a)) return "Document";
+ if (a.kind === "link") return KINDS.link.meta;
+ if (a.kind === "image" || a.kind === "video") return KINDS[a.kind].meta;
+ const shipped = a.filename ? fileKindForName(a.filename).label : "File";
+ return shipped === "File" ? KINDS[a.kind].meta : shipped;
+}
+
+export const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`;
+
+/** The key a document or file is addressed by in the lab's inert hashes. */
+export const slug = (a: LabArtifact) => (a.filename ?? a.id).replace(/\.md$/, "");
+
+function host(url: string | undefined): string | null {
+ if (!url) return null;
+ try {
+ const parsed = new URL(url);
+ return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
+ } catch {
+ return url;
+ }
+}
+
+/** How much of it there is: pages, slides, a duration, or where a link points. */
+export function extent(a: LabArtifact): string | null {
+ if (a.kind === "video") return a.durationLabel ?? null;
+ if (a.kind === "link") return host(a.url);
+ if (!a.pageCount) return null;
+ return plural(a.pageCount, a.kind === "slides" ? "slide" : "page");
+}
+
+/**
+ * The meta line's parts, in one grammar everywhere: kind · size · rev · extent
+ * (pages, slides, duration or host). A surface may take a prefix of this list
+ * when it is short of room, never reorder it.
+ */
+export function metaParts(a: LabArtifact): string[] {
+ return [metaLabel(a), a.sizeLabel, a.revision != null ? `rev ${a.revision}` : null, extent(a)].filter((part): part is string => Boolean(part));
+}
+
+/** What the product can open in the side panel instead of downloading (`isTextAttachment`). */
+export function isTextLike(a: LabArtifact): boolean {
+ if (a.kind === "link" || isTaskDocument(a)) return false;
+ const type = a.contentType ?? "";
+ return type.startsWith("text/") || type === "application/json" || /\.(md|txt|log|csv|json|ya?ml|html|css)$/i.test(a.filename ?? "");
+}
+
+/** Whether the artifact has a picture to put where the kind block would go. */
+export const hasThumbnail = (a: LabArtifact) => Boolean(a.thumbnailUrl);
+
+/** The name a file is listed under when the surface shows files rather than titles. A link has no file, so it keeps its title. */
+export const fileName = (a: LabArtifact) => a.filename ?? a.title;
+
+/** Whether anything waits on the reader: the artifact's primary action is then ink at rest. */
+export const isWaiting = (a: LabArtifact) => a.review === "needs_review";
diff --git a/ui/src/artifact-directions/directions/d1-solid/panel-actions.ts b/ui/src/artifact-directions/directions/d1-solid/panel-actions.ts
new file mode 100644
index 00000000000..46d55a2f66e
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/panel-actions.ts
@@ -0,0 +1,54 @@
+import { ChevronDown, Download, ExternalLink, Maximize2, PanelRightOpen, Table2 } from "lucide-react";
+import type { LabArtifact } from "../../kit";
+import type { SolidAction } from "./actions";
+import { isTaskDocument, isTextLike, slug } from "./kinds";
+
+/**
+ * What a panel card offers, in the panel's production wording. The primary is
+ * the same deed the thread row offers for that artifact, under the name the
+ * panel gives it:
+ *
+ * thread panel
+ * Open document Read document task documents (a side-panel tab) and Markdown files (expands in place)
+ * Open gallery View image / Open video
+ * Open preview Open link
+ * Open in tab Open in tab other text-like files
+ * Download Download file everything else
+ *
+ * Everything else the product puts on the card is listed, by name, in the
+ * card's one "More actions" menu: Open in tab, Open raw, Open file, Download,
+ * Preview data. The comments count of a Markdown file is the card's own chip,
+ * not listed here.
+ */
+
+export interface PanelActions {
+ primary: SolidAction;
+ more: SolidAction[];
+}
+
+export function panelActions(a: LabArtifact): PanelActions {
+ const download = (label: string): SolidAction => ({ id: "download", label, icon: Download, href: `#download-${a.id}` });
+ const openFile: SolidAction = { id: "preview", label: "Open file", icon: ExternalLink, href: `#file-${a.id}`, external: true };
+ const openTab: SolidAction = { id: "tab", label: "Open in tab", icon: PanelRightOpen, href: `#attachment-${a.id}` };
+
+ if (isTaskDocument(a)) {
+ return { primary: { id: "document", label: "Read document", icon: PanelRightOpen, href: `#document-${slug(a)}` }, more: [] };
+ }
+ if (a.kind === "link") {
+ return { primary: { id: "preview", label: "Open link", icon: ExternalLink, href: a.url ?? "#", external: true }, more: [] };
+ }
+ if (a.kind === "image") return { primary: { id: "gallery", label: "View image", icon: Maximize2 }, more: [] };
+ if (a.kind === "video") return { primary: { id: "gallery", label: "Open video", icon: Maximize2 }, more: [] };
+ if (a.kind === "document") {
+ // A Markdown file an agent attached: reads in place, with the product's three ways out.
+ return {
+ primary: { id: "document", label: "Read document", icon: ChevronDown },
+ more: [openTab, { id: "preview", label: "Open raw", icon: ExternalLink, href: `#raw-${a.id}`, external: true }, download("Download")],
+ };
+ }
+ if (isTextLike(a)) {
+ const data: SolidAction[] = /\.csv$/i.test(a.filename ?? "") ? [{ id: "tab", label: "Preview data", icon: Table2 }] : [];
+ return { primary: openTab, more: [...data, openFile, download("Download file")] };
+ }
+ return { primary: download("Download file"), more: [openFile] };
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/panel-model.ts b/ui/src/artifact-directions/directions/d1-solid/panel-model.ts
new file mode 100644
index 00000000000..5f21c57174a
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/panel-model.ts
@@ -0,0 +1,63 @@
+import type { LabArtifact } from "../../kit";
+import { isTaskDocument } from "./kinds";
+
+/**
+ * How the panel is sectioned and ordered: exactly as the product does it.
+ *
+ * Work products are grouped by the run that produced them (agent + run start);
+ * task documents, which no run produced, are a section of their own. Sections
+ * and the cards inside them keep the order the product lists them in — Solid
+ * changes how an artifact looks, not where it is found.
+ */
+
+export interface PanelSection {
+ id: string;
+ /** The producing agent, or a name for what was not produced by a run. */
+ title: string;
+ /** The agent every artifact in the section shares, when it is a run. A card repeats an author only when it differs. */
+ agent?: string;
+ /** "Sep 28, 2026, 10:52 AM" — a run's start. */
+ startedLabel?: string;
+ startedAt?: string;
+ /** "artifact", or "document" when the section holds task documents only. */
+ noun: string;
+ items: LabArtifact[];
+}
+
+export function panelSections(artifacts: LabArtifact[]): PanelSection[] {
+ // A Map keeps insertion order: sections appear where their first artifact does, cards as listed.
+ const byRun = new Map();
+ for (const artifact of artifacts) {
+ const key = artifact.run?.id ?? "";
+ byRun.set(key, [...(byRun.get(key) ?? []), artifact]);
+ }
+ return [...byRun.entries()].map(([runId, items]): PanelSection => {
+ const run = items[0].run;
+ const documentsOnly = items.every(isTaskDocument);
+ return {
+ id: runId || "loose",
+ title: run ? run.agent : documentsOnly ? "Task documents" : "Other artifacts",
+ agent: run?.agent,
+ startedLabel: run?.startedLabel,
+ startedAt: run?.startedAt,
+ noun: !run && documentsOnly ? "document" : "artifact",
+ items,
+ };
+ });
+}
+
+/**
+ * A timestamp for a narrow row: the product's label without the year when it
+ * is this year ("Sep 28, 11:07 AM"). The full label stays in the tooltip.
+ */
+export const shortStamp = (label: string) => label.replace(`, ${new Date().getFullYear()}`, "");
+
+const day = (label: string) => label.slice(0, label.lastIndexOf(", "));
+
+/**
+ * A card's stamp under a section header that already carries a date: the time
+ * alone when the day is the section's ("11:07 AM"), the short stamp otherwise.
+ */
+export function stampUnder(label: string, sectionLabel: string | undefined): string {
+ return sectionLabel && day(label) === day(sectionLabel) ? label.slice(label.lastIndexOf(", ") + 2) : shortStamp(label);
+}
diff --git a/ui/src/artifact-directions/directions/d1-solid/slab.ts b/ui/src/artifact-directions/directions/d1-solid/slab.ts
new file mode 100644
index 00000000000..b4b8cfb084c
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/slab.ts
@@ -0,0 +1,73 @@
+/**
+ * SIGNATURE 3 — the slab: one clear fill step off the canvas, one radius
+ * (`rounded-md`, used by every slab, block, sheet, button and notch in the
+ * set), no stroke, no shadow.
+ *
+ * Fill logic, shared by all three surfaces (values in tokens.css):
+ * slab the artifact itself
+ * slab-hover a slab that is a target, hovered or holding keyboard focus; slab-press while it is pressed
+ * button a resting button on a slab: a step past the slab, away from the canvas
+ * sheet the artifact's own content (an excerpt, a file's facts): the lightest fill short of ink
+ * ink the kind block and tab; the primary action of a hovered slab, and of an artifact that waits for review
+ */
+
+export const MOTION = "transition-colors duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-reduce:transition-none";
+
+/** A resting slab. Add `group/slab` + SLAB_HOVER when the whole slab is a target. */
+export const SLAB = `rounded-md bg-(--solid-slab) ${MOTION}`;
+/** A slab that is a target steps one fill when hovered or when its primary holds keyboard focus, and one more while pressed. */
+export const SLAB_HOVER =
+ "hover:bg-(--solid-slab-hover) has-[[data-primary]:focus-visible]:bg-(--solid-slab-hover) active:bg-(--solid-slab-press)";
+
+/**
+ * For anything laid over a preview that must stay the colour of the slab under
+ * it (a corner bite, the ring round an ink tab): it takes the slab's fill and
+ * follows the same three steps.
+ */
+export const SLAB_FILL =
+ "bg-(--solid-slab) group-hover/slab:bg-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:bg-(--solid-slab-hover) group-active/slab:bg-(--solid-slab-press)";
+export const SLAB_RING =
+ "ring-2 ring-(--solid-slab) group-hover/slab:ring-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:ring-(--solid-slab-hover) group-active/slab:ring-(--solid-slab-press)";
+/** The same for `currentColor` on the two pseudo-elements of a corner bite (its joints, StateMarker.tsx). */
+export const SLAB_JOINTS =
+ "before:text-(--solid-slab) after:text-(--solid-slab) group-hover/slab:before:text-(--solid-slab-hover) group-hover/slab:after:text-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:before:text-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:after:text-(--solid-slab-hover) group-active/slab:before:text-(--solid-slab-press) group-active/slab:after:text-(--solid-slab-press)";
+
+/** Keyboard focus: a 2px ink outline, 2px off the slab so it reads against both the slab and the canvas. */
+export const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--solid-ink)";
+
+/**
+ * For the one element that makes a whole slab clickable: its `::after` covers
+ * the slab (which must be `relative`) and carries the focus outline.
+ */
+export const COVER =
+ "after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-(--solid-ink)";
+
+/** A filled button on a slab. */
+export const BUTTON = `inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-md bg-(--solid-button) px-2 text-xs font-medium whitespace-nowrap text-foreground ${MOTION}`;
+
+/** Ink at rest: the primary of an artifact that waits for review, so "this needs you" reads without a hover. */
+const BUTTON_INK = "bg-(--solid-ink) text-(--solid-ink-foreground)";
+
+/** The button's own hover and press, for a button that is not a slab's cover. */
+export const BUTTON_HOVER = "hover:bg-(--solid-ink) hover:text-(--solid-ink-foreground) active:bg-(--solid-ink) active:text-(--solid-ink-foreground)";
+
+/**
+ * The primary action of a `group/slab` (mark it `data-primary`): turns to ink
+ * when the slab is hovered or the action holds keyboard focus, and goes back
+ * to the button fill while the pointer is on a secondary control
+ * (`data-secondary`), which takes the ink instead.
+ */
+export const BUTTON_PRIMARY =
+ "group-hover/slab:bg-(--solid-ink) group-hover/slab:text-(--solid-ink-foreground) focus-visible:bg-(--solid-ink) focus-visible:text-(--solid-ink-foreground) group-has-[[data-secondary]:hover]/slab:bg-(--solid-button) group-has-[[data-secondary]:hover]/slab:text-foreground";
+
+/** The primary's two looks at rest. */
+export const primaryRest = (waiting: boolean) => (waiting ? BUTTON_INK : null);
+
+/** A secondary control on a slab: no fill at rest, ink on hover. Sits above the cover. Mark it `data-secondary`. */
+const GHOST = `relative z-10 inline-flex h-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-(--solid-meta) ${MOTION} ${BUTTON_HOVER} ${FOCUS}`;
+
+/** The icon-only ghost: 28px square. */
+export const ICON_BUTTON = `${GHOST} w-7`;
+
+/** A ghost that carries a short mono value beside its icon (the panel's comments count). */
+export const COUNT_BUTTON = `${GHOST} gap-1 px-1.5 font-mono text-(length:--text-micro) leading-none`;
diff --git a/ui/src/artifact-directions/directions/d1-solid/tokens.css b/ui/src/artifact-directions/directions/d1-solid/tokens.css
new file mode 100644
index 00000000000..2f96ef2a9b8
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d1-solid/tokens.css
@@ -0,0 +1,111 @@
+/*
+ Direction 1 — SOLID. Custom properties, scoped to the wrapper every frame puts
+ around a kit slot. Everything here is achromatic: the only chroma in the
+ direction is the product's own status vocabulary (see StateMarker.tsx).
+
+ The fill ladder, by value only (OKLCH lightness; canvas is --background).
+ Each step is named for what it is, and each is a visible step from the one
+ it sits on (contrast in brackets, Y = L³ for greys):
+
+ light canvas 1.000 → slab 0.900 [1.35] → slab-hover 0.865 → slab-press 0.830
+ button 0.800 [1.39 on slab] sheet 1.000 [1.35 on slab]
+ dark canvas 0.205 → slab 0.325 [1.44] → slab-hover 0.365 → slab-press 0.400
+ button 0.400 [1.35 on slab] sheet 0.430 [1.54 on slab]
+
+ SHEET is where an artifact's own content is set (an excerpt, a file's facts):
+ the lightest fill short of ink in both themes — paper on a mat.
+
+ A gallery pile steps the other way: what is behind a stack's face is nearer
+ the canvas, in both themes.
+
+ light slab 0.900 → pile-1 0.920 → pile-2 0.945
+ dark slab 0.325 → pile-1 0.290 → pile-2 0.265
+
+ and one inverted fill, INK, for the kind block, the kind tab, and the primary
+ action of a hovered slab or of an artifact that waits for review:
+
+ light ink 0.205 / on-ink 0.985
+ dark ink 0.800 / on-ink 0.205 (silver, not white: it must not out-shout the prose)
+
+ Text contrast, all ≥ 4.5:1:
+ --solid-meta on slab light 5.8 dark 6.2
+ --solid-meta on slab-hover light 5.2 dark 5.3
+ --solid-meta on pile-1 light 6.1 dark 7.1
+ --solid-sheet-text on sheet light 10.4 dark 6.4
+ --foreground on sheet light 19.3 dark 7.8
+ --foreground on button light 10.3 dark 8.8
+ --solid-ink-foreground on ink light 17.2 dark 9.6
+*/
+
+[data-direction="d1-solid"] {
+ /* Fills */
+ --solid-slab: oklch(0.9 0 0);
+ --solid-slab-hover: oklch(0.865 0 0);
+ --solid-slab-press: oklch(0.83 0 0);
+ --solid-button: oklch(0.8 0 0);
+ --solid-sheet: oklch(1 0 0);
+ --solid-ink: oklch(0.205 0 0);
+ /* A gallery pile: the slabs behind a stack's face, each one step nearer the canvas. */
+ --solid-pile-1: oklch(0.92 0 0);
+ --solid-pile-2: oklch(0.945 0 0);
+
+ /* Text on those fills */
+ --solid-ink-foreground: oklch(0.985 0 0);
+ /* Secondary text on a slab or a pile. --muted-foreground is 3.4:1 on the light slab. */
+ --solid-meta: oklch(0.44 0 0);
+ /* Body text on a sheet: a step under --foreground, well over the meta tone. */
+ --solid-sheet-text: oklch(0.37 0 0);
+
+ /* State dots: the product's status hues. */
+ --solid-state-review: var(--status-task-icon-in_review);
+ --solid-state-changes: var(--status-task-icon-blocked);
+
+ /* Geometry */
+ /* The kind block and every gallery preview: one landscape plate (72×44 in a thread row). */
+ --solid-plate-ratio: 18 / 11;
+ /* A picture or a video leading a panel card. */
+ --solid-lead-ratio: 16 / 9;
+ /* The attachments block: as many equal slabs as fit, never narrower than 16rem; a file is one 52px row, a picture two. */
+ --solid-attach-cols: repeat(auto-fill, minmax(16rem, 1fr));
+ --solid-attach-row: 3.25rem;
+ /* The panel's cards: one column in the docked pane; a maximized pane sets as many 22rem columns as fit, row by row. */
+ --solid-panel-cols: repeat(auto-fill, minmax(min(22rem, 100%), 1fr));
+ /*
+ Text on a gallery sheet: as many WHOLE lines as the sheet has room for above
+ the band kept clear for the kind tab. Resolved where it is used, against
+ the sheet as a size container: 100cqh is the sheet inside its own inset, so
+ taking 1rem more off leaves a band of that inset + 1rem under the text
+ (32px on a plate, 24px in a mosaic cell).
+ */
+ --solid-sheet-fit: round(down, calc(100cqh - 1rem), 1lh);
+ /*
+ A joint of a corner bite: a 6px box with one rounded corner, whose shadow
+ (its own shape grown by 3px and moved 3px right and down, so it starts at
+ the box's own top-left) paints only what lies outside the quarter circle.
+ The colour is the box's currentColor — the slab's fill, at any step.
+ */
+ --solid-joint: 0.1875rem 0.1875rem 0 0.1875rem currentColor;
+}
+
+.dark [data-direction="d1-solid"] {
+ --solid-slab: oklch(0.325 0 0);
+ --solid-slab-hover: oklch(0.365 0 0);
+ --solid-slab-press: oklch(0.4 0 0);
+ --solid-button: oklch(0.4 0 0);
+ --solid-sheet: oklch(0.43 0 0);
+ --solid-ink: oklch(0.8 0 0);
+ --solid-pile-1: oklch(0.29 0 0);
+ --solid-pile-2: oklch(0.265 0 0);
+
+ --solid-ink-foreground: oklch(0.205 0 0);
+ --solid-meta: oklch(0.78 0 0);
+ --solid-sheet-text: oklch(0.92 0 0);
+
+ /* The product's blocked red (#dc2626) is 2.6:1 on the dark slab; a 6px dot that stands for a state needs 3:1. */
+ --solid-state-changes: oklch(0.704 0.191 22.216);
+}
+
+/* A gallery sheet is a size container, so its text can be fitted to whole lines of its own height (--solid-sheet-fit). */
+[data-direction="d1-solid"] [data-solid-sheet="fit"] {
+ container-type: size;
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/Content.tsx b/ui/src/artifact-directions/directions/d2-tint/Content.tsx
new file mode 100644
index 00000000000..6b5ae59fabb
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/Content.tsx
@@ -0,0 +1,138 @@
+import { Fragment, useLayoutEffect, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { documentExcerpt, sheetPreview } from "./excerpt";
+
+/**
+ * What is INSIDE a text-like artifact, as bare content: the caller supplies
+ * the sheet it sits on (`FoldedSheet`) and the type size. The panel, the
+ * gallery tile and a folder's face set the same three things at three sizes.
+ *
+ * ProseExcerpt a document's opening in two tiers: at most one heading on
+ * its own line (foreground, medium), body lines in secondary
+ * ink, list items each on a line behind a dash. One clamped
+ * block, so it always ends on a whole line with an ellipsis.
+ * SheetTable header + first rows, monospace; the first column truncates,
+ * value columns keep their width; columns that do not fit the
+ * measured width are counted, never clipped
+ * CodeLines the first lines, monospace, each cut with an ellipsis
+ */
+
+export function ProseExcerpt({ artifact, maxChars, className }: { artifact: LabArtifact; maxChars?: number; className?: string }) {
+ const lines = documentExcerpt(artifact, maxChars);
+ if (lines.length === 0) return null;
+ return (
+ // Inline content with explicit breaks, so the line clamp counts every line of every tier.
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/FileName.tsx b/ui/src/artifact-directions/directions/d2-tint/FileName.tsx
new file mode 100644
index 00000000000..51f55071ae8
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/FileName.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@/lib/utils";
+
+/**
+ * A filename is a machine value: monospace. When it does not fit, the stem
+ * truncates and the extension never does — two files that share a long stem
+ * (`acmehats-interface-craft.pptx` / `.pdf`) stay tellable apart.
+ *
+ * `mono={false}` is for a human title standing in where a file has no name.
+ */
+export function FileName({ name, mono = true, className }: { name: string; mono?: boolean; className?: string }) {
+ const dot = mono ? name.lastIndexOf(".") : -1;
+ const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
+ return (
+
+ {stem}
+ {ext ? {ext} : null}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx
new file mode 100644
index 00000000000..f85455df36e
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx
@@ -0,0 +1,125 @@
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { CodeLines, ProseExcerpt, SheetTable } from "./Content";
+import { FileName } from "./FileName";
+import { FoldedPreview, FoldedSheet, KindWell } from "./Lead";
+import { hasTextPreview, headlineFact, linkLabel } from "./kinds";
+
+/**
+ * GALLERY — the preview of one artifact, the largest object a tile has, by
+ * the one rule (Lead.tsx). Never an empty box:
+ *
+ * picture image, video poster, link screenshot — fold lg, extension tab,
+ * play mark and duration on a video
+ * sheet document, Markdown, sheet, code — its own text at full width on
+ * a neutral sheet in the same frame (fold lg, tab). Clamped to
+ * whole lines for the tile's width, so nothing is sliced.
+ * named PDF, slides, archive, generic file, or a picture that failed —
+ * the lg glyph on its kind well with what is known beside it: the
+ * countable fact as a headline ("12 slides", else the size), what
+ * the agent said is inside, and the filename. This replaces
+ * today's empty box that says FILE.
+ *
+ * The box is 16:9 and the tile is its `@container`. `depth="short"` is the
+ * shallower box of a single-artifact stack, whose height the caller sets.
+ */
+
+type PreviewKind = "picture" | "sheet" | "named";
+type PreviewDepth = "tile" | "short";
+
+/** Which preview an artifact gets, and the handler that turns a failed picture into a named one. */
+export function usePreviewKind(artifact: LabArtifact): [PreviewKind, () => void] {
+ const [failed, setFailed] = useState(false);
+ const kind: PreviewKind = artifact.thumbnailUrl && !failed ? "picture" : hasTextPreview(artifact) ? "sheet" : "named";
+ return [kind, () => setFailed(true)];
+}
+
+const BOX = "aspect-video w-full";
+const TEXT = "text-(length:--text-compact) leading-5";
+const MONO = "text-(length:--text-micro) leading-5";
+// Whole lines that fit a 16:9 sheet, by the tile's width: 20px lines under a 24px head, above the tab.
+const CLAMP: Record = { tile: "line-clamp-3 @3xs:line-clamp-4 @2xs:line-clamp-5 @xs:line-clamp-6 @sm:line-clamp-8", short: "line-clamp-4" };
+const CODE_STEP = ["", "", "", "hidden @3xs:block", "hidden @2xs:block", "hidden @xs:block", "hidden @sm:block", "hidden @sm:block"];
+const CODE_LINES: Record = { tile: CODE_STEP.length, short: 4 };
+
+/** Data rows that fit a 16:9 sheet whose text column is `width` wide: its lines, less the header and the "+N more" note. */
+function rowsThatFit(width: number): number {
+ const SIDE = 28; // the sheet's two side insets
+ const HEAD_AND_TAB = 44; // top inset + the tab's height
+ const lines = Math.floor((((width + SIDE) * 9) / 16 - HEAD_AND_TAB) / 20);
+ return Math.max(1, lines - 2);
+}
+
+function Sheet({ artifact, depth, className }: { artifact: LabArtifact; depth: PreviewDepth; className?: string }) {
+ return (
+
+ {artifact.kind === "document" ? (
+
+ ) : artifact.kind === "sheet" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function Named({ artifact, depth, className }: { artifact: LabArtifact; depth: PreviewDepth; className?: string }) {
+ const headline = headlineFact(artifact);
+ const address = linkLabel(artifact.url);
+ const words = Boolean(headline ?? artifact.summary ?? artifact.filename ?? address);
+ const small = "relative z-10 mt-1.5 cursor-text select-text text-(length:--text-micro) leading-4 text-(--tint-ink-2)";
+ return (
+
+ {words ? (
+
+ {/* A count is a machine value: the mono voice, at headline size. */}
+ {headline ?
+ ) : null}
+
+ );
+}
+
+const DURATION = "absolute bottom-2 right-2 rounded-(--rad-4) bg-(--tint-scrim) px-1.5 py-1 font-mono text-(length:--text-micro) leading-none text-(--tint-on-kind)";
+
+export function GalleryPreview({
+ artifact,
+ kind,
+ onError,
+ depth = "tile",
+ className,
+}: {
+ artifact: LabArtifact;
+ kind: PreviewKind;
+ onError: () => void;
+ depth?: PreviewDepth;
+ className?: string;
+}) {
+ if (kind === "sheet") return ;
+ if (kind === "named") return ;
+ return (
+
+ {artifact.kind === "video" && artifact.durationLabel ? {artifact.durationLabel} : null}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx
new file mode 100644
index 00000000000..22dc179ebfa
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx
@@ -0,0 +1,66 @@
+import { useId } from "react";
+import type { LabGroup } from "../../kit";
+import { GalleryTile } from "./GalleryTile";
+import { KindTally } from "./KindTally";
+import { MetaLine } from "./MetaLine";
+import { ReviewTally } from "./StateMark";
+import { artifactCount, stackSections, type StackSection } from "./gallery-model";
+import { countReview } from "./panel-model";
+
+/**
+ * GALLERY — one opened stack: everything below the "← All stacks" row.
+ *
+ * caption what the folder's pocket said, as one line of type on the
+ * page: the kinds spelled out with their mini glyphs, how much
+ * is waiting, the count and the updated date. A caption, not a
+ * control — it has no fill, so it cannot be mistaken for a second
+ * row of the page's own filter tabs right above it.
+ * sections the artifacts by the day of their last edit, newest first; a
+ * day that cannot fill a row shares its heading with its quiet
+ * neighbours (gallery-model.ts), so headings do not strand tiles
+ * tiles GalleryTile.tsx, three to a row on the page's grid
+ */
+
+function Caption({ group }: { group: LabGroup }) {
+ return (
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx
new file mode 100644
index 00000000000..c7dac4b47ca
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx
@@ -0,0 +1,22 @@
+import type { LabGroup } from "../../kit";
+import { StackFolder } from "./StackFolder";
+import { StackSingle } from "./StackSingle";
+
+/**
+ * GALLERY — the grid of task stacks under the page toolbar.
+ *
+ * The page's own grid (1 / 2 / 3 columns, 24px gap). A stack of two or more
+ * is a folder; a stack of one is the artifact itself. Every unit in a row
+ * takes the row's height, so the pockets and the single tiles end level.
+ */
+export function GalleryStacks({ groups }: { groups: LabGroup[] }) {
+ if (groups.length === 0) {
+ // The shipped empty state's copy.
+ return
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx
new file mode 100644
index 00000000000..d5121fb1485
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx
@@ -0,0 +1,70 @@
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { GalleryPreview, usePreviewKind } from "./GalleryPreview";
+import { MetaLine } from "./MetaLine";
+import { StateMark } from "./StateMark";
+import { SLAB, SecondaryAction, TitleAction } from "./UnitAction";
+import { galleryAction, gallerySecondary } from "./actions";
+import { headlineFact, metaParts, shortDateTime, timeOnly } from "./kinds";
+
+/**
+ * GALLERY — one artifact in an opened stack.
+ *
+ * The panel card made preview-first for every kind: the same filled slab with
+ * the same 8px inset, the lead across the whole width, then three lines —
+ *
+ * ┌───────────── preview, 16:9 ──────────────┐
+ * │ folded picture · text sheet · kind well │
+ * └──────────────────────────────────────────┘
+ * Title one line
+ * kind · size · rev · fact ○ Review the mono meta line
+ * Author · 11:07 AM ↗ ↓ icon actions, always visible
+ *
+ * The title is the link (stretched over the tile); it goes to the artifact in
+ * its task, as today's whole-card link does. "Open file in new tab" and
+ * "Download file" are their own targets, visible at rest and reachable by
+ * keyboard — today they exist only on hover.
+ *
+ * A fact the preview already states (a video's duration chip, a kind well's
+ * headline) is not repeated on the meta line. The author truncates before the
+ * time does. Under a day heading the tile shows the time; under a heading
+ * that spans days (`dated`) it shows the date too.
+ */
+export function GalleryTile({ artifact, dated = false }: { artifact: LabArtifact; dated?: boolean }) {
+ const [kind, onError] = usePreviewKind(artifact);
+ const secondary = gallerySecondary(artifact);
+ const stated = kind === "named" ? headlineFact(artifact) : kind === "picture" ? artifact.durationLabel : undefined;
+ return (
+
+
+
+
+
+ {artifact.title}
+
+
+ {/* Every tile keeps the height of a state mark here, so a row of tiles stays level whether or not one is waiting. */}
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx b/ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx
new file mode 100644
index 00000000000..3975c8f9bcb
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx
@@ -0,0 +1,127 @@
+import { cn } from "@/lib/utils";
+import type { LabKind } from "../../kit";
+import { Pictogram } from "./Pictogram";
+
+/**
+ * SIGNATURE ELEMENT 1 — the kind glyph.
+ *
+ * A rounded tile with a folded top-right corner (a flat, lighter facet of the
+ * same hue — never a shadow), a pictogram top-left and the real extension in
+ * bold along the bottom. Solid `--kind` fill, flat. It is the one place a
+ * unit carries its hue at full strength.
+ *
+ * mini 16×18 pictogram only — index chips, kind tallies
+ * xs 34×38 thread: attachment slabs, and row leads on their tinted well
+ * sm 40×46 spare (specimen)
+ * md 56×64 panel leads on their tinted well; narrow gallery tiles
+ * lg 96×110 gallery tiles with nothing to show
+ * tuck 96×190 standing in a folder (StackFolder.tsx): the same top — fold,
+ * pictogram, label — on a tile tall enough that its bottom
+ * edge is always behind the pocket
+ *
+ * Without a `label` (a task document: it has no extension) the pictogram
+ * stands alone, larger and centred — the mini glyph's composition at size.
+ *
+ * The ancestor that sets `data-kind` supplies `--kind`, `--kind-facet` and
+ * `--kind-ink` (tokens.css); pass `kind` here only for the pictogram.
+ */
+
+export type GlyphSize = "mini" | "xs" | "sm" | "md" | "lg" | "tuck";
+
+interface Placement {
+ x: number;
+ y: number;
+ size: number;
+}
+
+interface Spec {
+ w: number;
+ h: number;
+ /** Corner radius and fold size, in the tile's own px. */
+ r: number;
+ fold: number;
+ picto: Placement;
+ /** The pictogram when there is no label to share the tile with. */
+ solo: Placement;
+ label: { size: number; baseline: number; inset: number } | null;
+ box: string;
+}
+
+const MINI: Placement = { x: 3.2, y: 5.6, size: 9.6 };
+const SPEC: Record = {
+ mini: { w: 16, h: 18, r: 3.5, fold: 5, picto: MINI, solo: MINI, label: null, box: "w-4 h-4.5" },
+ xs: { w: 34, h: 38, r: 6, fold: 9.5, picto: { x: 4.5, y: 4.5, size: 10 }, solo: { x: 7.5, y: 12, size: 19 }, label: { size: 10, baseline: 32.2, inset: 3 }, box: "w-8.5 h-9.5" },
+ sm: { w: 40, h: 46, r: 7, fold: 11.5, picto: { x: 5.5, y: 5.5, size: 14 }, solo: { x: 9, y: 14.5, size: 22 }, label: { size: 10.5, baseline: 39.2, inset: 4 }, box: "w-10 h-11.5" },
+ md: { w: 56, h: 64, r: 9.5, fold: 16, picto: { x: 8, y: 8, size: 18 }, solo: { x: 12.5, y: 20, size: 31 }, label: { size: 14, baseline: 54.4, inset: 6 }, box: "w-14 h-16" },
+ lg: { w: 96, h: 110, r: 16, fold: 27, picto: { x: 14, y: 14, size: 30 }, solo: { x: 22, y: 35, size: 52 }, label: { size: 23.5, baseline: 93.5, inset: 10 }, box: "w-24 h-27.5" },
+ tuck: { w: 96, h: 190, r: 16, fold: 27, picto: { x: 13, y: 13, size: 26 }, solo: { x: 24, y: 30, size: 48 }, label: { size: 22, baseline: 68, inset: 10 }, box: "w-24 h-47.5" },
+};
+
+/** The tile with its top-right corner cut on the diagonal. */
+function tilePath({ w, h, r, fold }: Spec): string {
+ const k = r * 0.45; // softens the two ends of the cut
+ return [
+ `M${r} 0`,
+ `H${w - fold - k}`,
+ `Q${w - fold} 0 ${w - fold + k * 0.7} ${k * 0.7}`,
+ `L${w - k * 0.7} ${fold - k * 0.7}`,
+ `Q${w} ${fold} ${w} ${fold + k}`,
+ `V${h - r}`,
+ `Q${w} ${h} ${w - r} ${h}`,
+ `H${r}`,
+ `Q0 ${h} 0 ${h - r}`,
+ `V${r}`,
+ `Q0 0 ${r} 0Z`,
+ ].join("");
+}
+
+/** The folded flap: the cut-off triangle laid back onto the tile, inner corner rounded. */
+function facetPath({ w, r, fold }: Spec): string {
+ const k = r * 0.45;
+ const c = Math.min(r * 0.7, fold * 0.4);
+ return [
+ `M${w - fold} ${k * 0.6}`,
+ `Q${w - fold} 0 ${w - fold + k * 0.7} ${k * 0.7}`,
+ `L${w - k * 0.7} ${fold - k * 0.7}`,
+ `Q${w} ${fold} ${w - k * 0.6} ${fold}`,
+ `H${w - fold + c}`,
+ `Q${w - fold} ${fold} ${w - fold} ${fold - c}Z`,
+ ].join("");
+}
+
+// Inter Bold capitals, as a fraction of the font size — only to decide when a label must be squeezed.
+const WIDE: Record = { W: 0.99, M: 0.9, O: 0.78, Q: 0.78, G: 0.76, C: 0.73, D: 0.74, H: 0.74, N: 0.75, U: 0.73, I: 0.29, J: 0.56, L: 0.59, F: 0.61, E: 0.63, T: 0.64 };
+const labelWidth = (label: string, size: number) => [...label].reduce((sum, ch) => sum + (WIDE[ch] ?? 0.68), 0) * size;
+
+export function KindGlyph({ kind, label, size = "sm", className }: { kind: LabKind; label?: string; size?: GlyphSize; className?: string }) {
+ const spec = SPEC[size];
+ const text = spec.label && label ? label : null;
+ const room = spec.label ? spec.w - spec.label.inset * 2 : 0;
+ const squeeze = text && spec.label && labelWidth(text, spec.label.size) > room;
+ return (
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/KindTally.tsx b/ui/src/artifact-directions/directions/d2-tint/KindTally.tsx
new file mode 100644
index 00000000000..b3c3a894a36
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/KindTally.tsx
@@ -0,0 +1,31 @@
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { KindGlyph } from "./KindGlyph";
+import { kindNoun, kindNounInline, tallyKinds } from "./panel-model";
+
+/**
+ * What is in a pile, as a CAPTION: one entry per kind present, in the
+ * vocabulary's order — the mini glyph and a mono count. Never a control: it
+ * has no fill and no button shape (the panel's index, which filters, is built
+ * from buttons — PanelIndex.tsx).
+ *
+ * `named` spells each kind out ("10 documents"), where there is a line to
+ * itself — the caption of an opened stack.
+ */
+export function KindTally({ artifacts, named = false, className }: { artifacts: LabArtifact[]; named?: boolean; className?: string }) {
+ const kinds = tallyKinds(artifacts);
+ if (kinds.length === 0) return null;
+ return (
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/Lead.tsx b/ui/src/artifact-directions/directions/d2-tint/Lead.tsx
new file mode 100644
index 00000000000..bb1d3cd962d
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/Lead.tsx
@@ -0,0 +1,146 @@
+import { useState, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { KindGlyph, type GlyphSize } from "./KindGlyph";
+import { glyphLabel } from "./kinds";
+
+/**
+ * THE ONE RULE — the lead of every unit, on every surface, is one of two things.
+ *
+ * SHOWN the artifact's own content wearing the kind's two marks: the top-
+ * right corner folded back as a solid kind-hue flap, and the
+ * extension as a hue tab on the bottom-left edge. A picture
+ * (`FoldedPreview`) or its text on a neutral sheet (`FoldedSheet`).
+ * NAMED when there is nothing to show, or no room to show it: the kind
+ * glyph centred on a well filled with the kind's quiet hue (`KindWell`).
+ *
+ * So colour is the fold and tab on real content, or the glyph and its well —
+ * never both, and nowhere else on the unit at rest. A missing or failed
+ * picture falls back to NAMED, so the placeholder is never an empty box.
+ */
+
+type FoldSize = "sm" | "md" | "lg";
+/** Where the extension tab sits. `top` is for a folder, where the bottom edge is tucked behind the pocket. */
+type TabEdge = "bottom" | "top" | "none";
+
+// The line-height sits AFTER the size in `tab`: tailwind-merge drops a leading that precedes a font size.
+// `sheet` pads a text sheet so its first line clears the fold; `room` is the extra a tab needs on its edge.
+const FOLD: Record }> = {
+ sm: { clip: "var(--tint-clip-fold-sm)", flap: "size-(--tint-fold-sm)", tab: "px-1 py-0.5 text-(length:--text-nano) leading-none", play: "top-(--tint-play-y-sm) size-5", sheet: "px-2 pt-2.5 pb-2", room: { bottom: "pb-5", top: "pt-5" } },
+ md: { clip: "var(--tint-clip-fold-md)", flap: "size-(--tint-fold-md)", tab: "px-1.5 py-1 text-(length:--text-micro) leading-none", play: "top-1/2 size-9", sheet: "px-3 pt-4 pb-3", room: { bottom: "pb-7", top: "pt-7" } },
+ lg: { clip: "var(--tint-clip-fold-lg)", flap: "size-(--tint-fold-lg)", tab: "px-2 py-1 text-xs leading-none", play: "top-1/2 size-11", sheet: "px-3.5 pt-6 pb-3.5", room: { bottom: "pb-8", top: "pt-8" } },
+};
+
+const TAB_EDGE = { bottom: "bottom-0 rounded-tr-(--rad-4)", top: "top-0 rounded-br-(--rad-4)" } as const;
+
+/** The folded flap: the cut-off corner laid back, inner corner rounded. Square, scales with its box. */
+function FoldFlap({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function Tab({ artifact, fold, edge }: { artifact: LabArtifact; fold: FoldSize; edge: TabEdge }) {
+ const label = glyphLabel(artifact);
+ if (!label || edge === "none") return null;
+ return {label};
+}
+
+/** The play mark on a video poster: white on a neutral scrim, never the kind hue. */
+export function PlayMark({ fold, className }: { fold: FoldSize; className?: string }) {
+ return (
+
+
+
+ );
+}
+
+interface FrameProps {
+ artifact: LabArtifact;
+ fold: FoldSize;
+ tab?: TabEdge;
+ className?: string;
+ children?: ReactNode;
+}
+
+/**
+ * SHOWN, a picture. The caller sets the box (`className`) and picks the fold;
+ * the image fills the box and can never set its height. `fit="contain"` shows
+ * the whole picture on the neutral well (the panel, where it is judged).
+ * Children render over the image (a duration, a play button).
+ */
+export function FoldedPreview({ artifact, fold, tab = "bottom", fit = "cover", play = true, className, onError, children }: FrameProps & { fit?: "cover" | "contain"; play?: boolean; onError?: () => void }) {
+ // A cropped photograph keeps its middle; a cropped screenshot keeps its top-left, where a page starts.
+ const crop = artifact.kind === "link" ? "object-cover object-left-top" : "object-cover";
+ return (
+
+
+ {artifact.kind === "video" && play ? : null}
+ {children}
+
+
+
+ );
+}
+
+/**
+ * SHOWN, text. A neutral sheet (a hole back to the page fill) holding the
+ * artifact's own words at full width, in the frame a picture wears. Children
+ * are the content (Content.tsx); anything that does not fit is clipped by the
+ * sheet, so the caller clamps to whole lines.
+ */
+export function FoldedSheet({ artifact, fold, tab = "bottom", className, children }: FrameProps) {
+ const spec = FOLD[fold];
+ const tabbed = tab !== "none" && glyphLabel(artifact) ? spec.room[tab] : null;
+ return (
+
+ {children}
+
+
+
+ );
+}
+
+/** NAMED. The glyph centred on its kind well; children sit beside it (a headline, a summary). */
+export function KindWell({
+ artifact,
+ size = "md",
+ className,
+ glyphClassName,
+ children,
+}: {
+ artifact: LabArtifact;
+ size?: GlyphSize;
+ className?: string;
+ /** Overrides the glyph's box (the SVG scales), e.g. a container-query step down in a narrow unit. */
+ glyphClassName?: string;
+ children?: ReactNode;
+}) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+// 72×46: the one lead box of a thread row, so every title starts on the same edge.
+const ROW_BOX = "h-11.5 w-18 shrink-0";
+
+/** The lead of a thread row: a picture when there is one; otherwise the xs glyph on its well (no room to show text here). */
+export function Lead({ artifact }: { artifact: LabArtifact }) {
+ const [failed, setFailed] = useState(false);
+ if (!artifact.thumbnailUrl || failed) return ;
+ return setFailed(true)} />;
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx b/ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx
new file mode 100644
index 00000000000..69c150591f8
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx
@@ -0,0 +1,29 @@
+import { Fragment } from "react";
+import { cn } from "@/lib/utils";
+
+/**
+ * SIGNATURE ELEMENT 2 — the mono meta line.
+ *
+ * One grammar on every surface — kind · size · rev — set in the monospace
+ * token because every part of it is a machine value. Never coloured: the hue
+ * belongs to the lead.
+ *
+ * One line with an ellipsis by default. `wrap` lets it break between parts
+ * instead, where the product's rule is that facts wrap rather than disappear
+ * (a narrow pane, a narrow folder).
+ */
+export function MetaLine({ parts, wrap = false, className }: { parts: string[]; wrap?: boolean; className?: string }) {
+ return (
+
+ {parts.map((part, index) => (
+
+ {/* A full monospace " · " is three cells wide; the dot gets half-cell margins instead. */}
+ {index > 0 ? · : null}
+ {/* When wrapping, a part never breaks inside itself; the line breaks after a dot. */}
+ {index > 0 && wrap ? : null}
+ {part}
+
+ ))}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx
new file mode 100644
index 00000000000..4cdcdd35181
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx
@@ -0,0 +1,41 @@
+import { useMemo, useState } from "react";
+import type { LabArtifact } from "../../kit";
+import { PanelIndex } from "./PanelIndex";
+import { PanelSection } from "./PanelSection";
+import { buildSections, type PanelFilter } from "./panel-model";
+
+/**
+ * PANEL — the whole scroll body of the task pane's Artifacts tab.
+ *
+ * index one row of filter buttons: All · Review · the kinds with two or
+ * more · "+N" (PanelIndex.tsx)
+ * sections producing runs and the task's documents, in the product's
+ * order — newest first — each saying how many of its items are
+ * waiting for review (panel-model.ts)
+ * cards one filled slab per artifact (PanelCard.tsx)
+ *
+ * A card is as tall as what it has to show: 136–208px for a file (the tall
+ * ones carry a three-line MIME type), 226–264 for a document, about 435 for a
+ * 4:3 picture — against today's 390–500 for every kind. The 21-artifact task
+ * is 5,400px of cards against today's 8,300.
+ * The filter is local state; in the product the index would stay pinned while
+ * the list scrolls.
+ */
+export function PanelBody({ artifacts }: { artifacts: LabArtifact[] }) {
+ const [filter, setFilter] = useState("all");
+ const sections = useMemo(() => buildSections(artifacts, filter), [artifacts, filter]);
+
+ if (artifacts.length === 0) {
+ // The shipped empty state, word for word.
+ return
No artifacts yet. Work products, documents, and agent-produced files will appear here.
;
+ }
+
+ return (
+
+
+ {sections.map((section) => (
+
+ ))}
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx
new file mode 100644
index 00000000000..52c02a12ab2
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx
@@ -0,0 +1,120 @@
+import { Fragment, useState } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { FileName } from "./FileName";
+import { MetaLine } from "./MetaLine";
+import { PanelNamed, PanelPicture, PanelSheet, panelLeadKind } from "./PanelLead";
+import { StateMark } from "./StateMark";
+import { ButtonAction, SLAB, SecondaryAction } from "./UnitAction";
+import { panelAction, panelSecondary } from "./actions";
+import { kindLabel, linkLabel, metaParts, shortDateTime } from "./kinds";
+
+/**
+ * PANEL — one artifact on the task's shelf. One skeleton for every kind; the
+ * lead follows the one rule (PanelLead.tsx):
+ *
+ * SHOWN (picture, or text on a sheet) NAMED (nothing to show)
+ * ┌──────── lead, full width ────────┐ ┌ well ┐ title
+ * └──────────────────────────────────┘ │ glyph│ kind · size · fact ○ Review
+ * title │ │ summary
+ * kind · size · rev · fact ○ Review └──────┘ filename · MIME type
+ * summary · filename
+ * author · updated icon actions [Primary] (the same footer on both)
+ *
+ * Everything the shipped card prints is printed here — nothing lives only in
+ * a tooltip. Filename, address and MIME type sit above the card's stretched
+ * action so they can be selected and copied. Hover is neutral (the hue is
+ * spent on the lead): the slab steps up and the primary button inverts.
+ */
+
+// Machine values a reviewer may want to copy: above the stretched link, selectable.
+const COPYABLE = "relative z-10 cursor-text select-text";
+const SMALL = "mt-1 text-(length:--text-micro) leading-4 text-(--tint-ink-2)";
+
+/** The product prints the MIME type on file cards — not on documents, pictures or links. */
+const showsMime = (a: LabArtifact) => Boolean(a.contentType) && a.kind !== "document" && a.kind !== "image" && a.kind !== "video" && a.kind !== "link";
+
+/** A long MIME type wraps after its own separators, not in the middle of a word — and still copies as the plain string. */
+function breakable(value: string) {
+ return value.split(/(?<=[/.+-])/).map((part, index) => (
+
+ {index > 0 ? : null}
+ {part}
+
+ ));
+}
+
+function Byline({ artifact, grouped }: { artifact: LabArtifact; grouped: boolean }) {
+ return (
+
+ {/* Inside a run section the agent is the section's heading; only the edit time is the card's own. */}
+ {grouped ? null : {artifact.author}}
+
+
+ );
+}
+
+export function PanelCard({ artifact, grouped }: { artifact: LabArtifact; grouped: boolean }) {
+ const [failed, setFailed] = useState(false);
+ const lead = panelLeadKind(artifact, failed);
+ const shown = lead !== "named";
+ const primary = panelAction(artifact);
+ const secondary = panelSecondary(artifact);
+ // A link's address is too long for the meta line at this width; it takes the filename's place.
+ const address = artifact.kind === "link" ? linkLabel(artifact.url) : undefined;
+ const meta = address ? [kindLabel(artifact)] : metaParts(artifact, { facts: true });
+ const { filename } = artifact;
+ return (
+
+ {/* `mt-auto`: in a wide pane the cards sit two or three to a row and share its height. */}
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx
new file mode 100644
index 00000000000..6e62dfbf1ec
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx
@@ -0,0 +1,99 @@
+import { useState, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact, LabKind } from "../../kit";
+import { KindGlyph } from "./KindGlyph";
+import { StateDot } from "./StateMark";
+import { FOCUS_RING, MOTION } from "./UnitAction";
+import { countReview, kindNoun, tallyKinds, type KindTally, type PanelFilter } from "./panel-model";
+
+/**
+ * PANEL — the shelf's index: a FILTER, and built to look like one. A row of
+ * buttons — All, then what is waiting for review (the ring the cards use),
+ * then the kinds that hold two or more artifacts (mini glyph + count), then
+ * one "+N" button that opens the rest. Held to one row at the pane's width,
+ * pressed or not; in a pane dragged narrow every kind folds into the "+N".
+ *
+ * One button is pressed at a time, and pressed is unmistakable without hue:
+ * the foreground inversion the primary button uses, with the kind's name
+ * spelled out ("Images 3"). The only colour in the row is the glyphs.
+ *
+ * (The same mini glyph and count, without the button, is a CAPTION on the
+ * Artifacts page — KindTally.tsx.)
+ */
+
+const CHIP = cn("inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium leading-none", MOTION, FOCUS_RING);
+const REST = "bg-(--tint-slab) text-(--tint-ink-2) hover:bg-(--tint-slab-raised) hover:text-foreground active:bg-(--tint-button)";
+const PRESSED = "bg-foreground text-background active:bg-foreground/85";
+
+function Chip({ pressed, onPress, name, kind, className, children }: { pressed?: boolean; onPress: () => void; name: string; kind?: LabKind; className?: string; children: ReactNode }) {
+ return (
+ // The accessible name starts with the words on the button, so speech input can find it.
+
+ );
+}
+
+// What one row holds at the pane's 434px: five kind chips at rest, three while one of them is spelled out.
+const FRONT = 5;
+const FRONT_PRESSED = 3;
+
+/**
+ * The kinds that get a chip in the row. Kinds with two or more lead; singles
+ * wait behind "+N", and so does the tail of the row while a pressed chip needs
+ * the room for its name. The pressed kind is always in the row.
+ */
+function frontKinds(kinds: KindTally[], filter: PanelFilter, open: boolean): KindTally[] {
+ if (open) return kinds;
+ const pressed = kinds.find((t) => t.kind === filter);
+ const room = pressed ? FRONT_PRESSED : FRONT;
+ const lead = kinds.filter((t) => t.count > 1 && t !== pressed).slice(0, pressed ? room - 1 : room);
+ return kinds.filter((t) => t === pressed || lead.includes(t));
+}
+
+const Count = ({ value }: { value: number }) => {value};
+
+export function PanelIndex({ artifacts, filter, onFilter }: { artifacts: LabArtifact[]; filter: PanelFilter; onFilter: (filter: PanelFilter) => void }) {
+ const [open, setOpen] = useState(false);
+ const review = countReview(artifacts);
+ const kinds = tallyKinds(artifacts);
+ if (kinds.length < 2 && review === 0) return null;
+ const toggle = (next: PanelFilter) => onFilter(filter === next ? "all" : next);
+ const shown = frontKinds(kinds, filter, open);
+ const behind = kinds.length - shown.length;
+ return (
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx
new file mode 100644
index 00000000000..6fa386d0780
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx
@@ -0,0 +1,79 @@
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import type { LabArtifact } from "../../kit";
+import { CodeLines, ProseExcerpt, SheetTable } from "./Content";
+import { FoldedPreview, FoldedSheet, KindWell, PlayMark } from "./Lead";
+import { MOTION } from "./UnitAction";
+import { hasTextPreview } from "./kinds";
+
+/**
+ * PANEL — the lead of a card, by the one rule (Lead.tsx):
+ *
+ * picture image, video poster, link screenshot — the full width of the
+ * card, the whole picture (never cropped: this is where it is
+ * judged), fold lg and tab. A video plays in place: the poster is
+ * a real button, and pressing it swaps in the player with controls.
+ * sheet document, Markdown, sheet, code — the artifact's own text at full
+ * width on a neutral sheet, fold md and tab: five lines of a
+ * document, header + three rows of a sheet, five lines of code.
+ * named PDF, slides, archive, generic file, a picture that failed — the md
+ * glyph on its kind well, 112px wide, as tall as the words beside it.
+ */
+
+type PanelLeadKind = "picture" | "sheet" | "named";
+
+export function panelLeadKind(a: LabArtifact, failed: boolean): PanelLeadKind {
+ if (a.thumbnailUrl && !failed) return "picture";
+ return hasTextPreview(a) ? "sheet" : "named";
+}
+
+/** The picture's own shape, held between 4:3 and 16:9; anything outside that is shown whole on the neutral well. */
+function pictureBox(aspect: number | undefined): string {
+ if (aspect == null || aspect >= 1.7) return "aspect-video";
+ return aspect >= 1.45 ? "aspect-3/2" : "aspect-4/3";
+}
+
+const PLAY_RING = "focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-(--tint-on-kind)";
+const DURATION = "absolute bottom-2 right-2 rounded-(--rad-4) bg-(--tint-scrim) px-1.5 py-1 font-mono text-(length:--text-micro) leading-none text-(--tint-on-kind)";
+
+export function PanelPicture({ artifact, onError }: { artifact: LabArtifact; onError: () => void }) {
+ const [playing, setPlaying] = useState(false);
+ const box = cn("w-full", pictureBox(artifact.aspect));
+ const video = artifact.kind === "video";
+ if (video && playing && artifact.videoUrl) {
+ // Above the card's stretched action, so the player's own controls work.
+ return ;
+ }
+ return (
+
+ {video ? (
+
+ ) : null}
+
+ );
+}
+
+const MONO = "text-(length:--text-micro) leading-4.5";
+
+export function PanelSheet({ artifact }: { artifact: LabArtifact }) {
+ return (
+
+ {artifact.kind === "document" ? (
+
+ ) : artifact.kind === "sheet" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+/** 112px wide (80 in a pane dragged under about 23rem), and as tall as the card's words, so no card is taller than what it says. */
+export function PanelNamed({ artifact }: { artifact: LabArtifact }) {
+ return ;
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelSection.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelSection.tsx
new file mode 100644
index 00000000000..02ae976d234
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/PanelSection.tsx
@@ -0,0 +1,42 @@
+import { useId } from "react";
+import { MetaLine } from "./MetaLine";
+import { PanelCard } from "./PanelCard";
+import { ReviewTally } from "./StateMark";
+import { shortDateTime } from "./kinds";
+import type { PanelSection as Section } from "./panel-model";
+
+/**
+ * PANEL — one section of the shelf: a producing run (agent + run start), or
+ * the task's documents. The heading is type on the pane, not a card: the
+ * agent in the sans, the machine line under it in the mono token, and — when
+ * the section holds something waiting — the review tally at the right end,
+ * using the same ring the cards' state marks use.
+ *
+ * The machine line wraps between its parts in a narrow pane (the run's start
+ * is what tells two same-day runs apart, so it is never cut).
+ */
+export function PanelSection({ section }: { section: Section }) {
+ const count = section.artifacts.length;
+ const headingId = useId();
+ // A run's start keeps its year (the product prints it); the cards under it drop theirs while it is the current one.
+ const when = section.run ? `Run ${section.run.startedLabel}` : `Latest ${shortDateTime(section.latest.updatedLabel, section.latest.updatedAt)}`;
+ return (
+
+
+
+
+ {section.title}
+
+
+
+
+
+ {/* One column in the docked pane; two and three when the pane is maximized (the product keeps one, stretched). */}
+
+
+ );
+}
diff --git a/ui/src/artifact-directions/directions/d2-tint/Pictogram.tsx b/ui/src/artifact-directions/directions/d2-tint/Pictogram.tsx
new file mode 100644
index 00000000000..8823c1ee9f4
--- /dev/null
+++ b/ui/src/artifact-directions/directions/d2-tint/Pictogram.tsx
@@ -0,0 +1,98 @@
+import type { ReactNode } from "react";
+import type { LabKind } from "../../kit";
+
+/**
+ * The ten white pictograms that sit on a kind glyph, drawn on a 24-unit grid
+ * as solid shapes (the reference's weight), not hairline icons — they have to
+ * read at 10px in the attachments strip. `currentColor` is the ink; anything
+ * knocked out of a solid shape is filled with the unit's `--kind`.
+ */
+
+const ROUND = { stroke: "currentColor", strokeLinejoin: "round", strokeLinecap: "round" } as const;
+const CUT = "fill-(--kind)";
+const CUT_STROKE = "fill-none stroke-(--kind)";
+
+const SHAPES: Record = {
+ // Running text: two full lines and a short last one.
+ document: (
+ <>
+
+
+
+ >
+ ),
+ // The reference's PDF mark: a short heading over a long line.
+ pdf: (
+ <>
+
+
+ >
+ ),
+ // A slide on its stand.
+ slides: (
+ <>
+
+
+
+ >
+ ),
+ // Four cells.
+ sheet: (
+ <>
+
+
+
+
+ >
+ ),
+ // Sun over two peaks.
+ image: (
+ <>
+
+
+ >
+ ),
+ // Play.
+ video: ,
+ // A box with its lid and a pull slot.
+ archive: (
+ <>
+
+
+
+ >
+ ),
+ // Angle brackets.
+ code: (
+
+
+
+
+ ),
+ // The generic file: the product's paperclip.
+ file: (
+
+ ),
+ // A globe: a solid disc with its meridian and equator knocked out.
+ link: (
+ <>
+
+
+
+ >
+ ),
+};
+
+/** A pictogram placed inside a parent `