feat(ui): add the artifact directions lab with the MVP V1 kit (#17213)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents produce artifacts: documents, files, pictures, video and links. The app shows them in the task thread, in the right panel's Artifacts tab and on the Artifacts page > - The three surfaces grew one at a time. Their artifact presentation is dated and does not match > - A redesign needs a side-by-side comparison of several directions on the real chrome, in both themes, with hover states, before any product code changes > - This pull request adds a backend-free design lab under `ui/src/artifact-directions`: the shipped presentation as the baseline, four exploratory directions, and the MVP V1 kit drawn from the PCLP-Core mocks > - The benefit is a shareable, capturable reference for the artifact redesign that touches no product code path ## Linked Issues or Issue Description No public issue exists. This section follows the feature request template. **Subsystem affected** The web UI (`ui/`): the task thread, the right panel's Artifacts tab and the Artifacts page. The change is a design lab beside the app, not a change to those surfaces. **Problem or motivation** The three artifact surfaces are being redesigned. Designers must compare candidate directions on the real chrome, in dark and light, with hover states, and share a link. No harness for that exists. Figma mocks do not show the real chrome or the themes. **Proposed solution** A separate Vite entry (`ui/artifact-directions.html`) renders static mocks of the three surfaces. A "kit" supplies the artifact components for each surface. Kits: Today (the shipped components fed with fixtures), four directions (1.Solid, 2.Tint, 3.Tactile, 4.Hybrid) and 5.MVP V1 built from the Figma mocks. A toolbar switches kit, surface, theme and hover animation. The state lives in the URL, so any view can be linked and captured. **Alternatives considered** Figma mocks only: no real chrome, no themes, no hover. Storybook stories only: no page-level composition and no shareable static build. The lab keeps two Storybook stories as a secondary view. **Roadmap alignment** The lab supports the "Artifacts & Work Products" theme in `ROADMAP.md`. It is a design reference. It is not a product feature and it does not ship in the app bundle. **Additional context** The lab builds with its own config (`ui/vite.artifact-directions.config.mjs`). The app's `vite build` does not include it. A build of this branch is live at https://paperclip-artifact-directions.vercel.app. That preview also carries two surfaces that are not in this pull request (a media preview overlay and a panel closeup); they are parked on a separate branch. ## What Changed - Add the lab entry, its Vite config and the shell: `ui/artifact-directions.html`, `ui/vite.artifact-directions.config.mjs`, `ui/src/artifact-directions/` (static frames for the thread, the panel and the Artifacts page, a `fetch` stub for the lab's own page, fixtures, the kit contract, URL state, a sticky two-row toolbar). - Add the Today kit: the shipped artifact components, fed with the lab's fixtures. - Add four directions, each with its own `tokens.css` scoped to `[data-direction]`: 1.Solid, 2.Tint, 3.Tactile, 4.Hybrid. - Add the 5.MVP V1 kit from the PCLP-Core mocks: Artifacts page stacks and single tiles, the right panel's row list, thread rows grouped inline when one message produced several, a hover animation (the backing pages turn 5°, the artwork rises 6px), light-mode values, Paper Mono for the task pill. - Set Paper Mono for every monospace run in the lab (`lab.css`), from the face and `--font-paper-mono` that `index.css` declares. - Add two Storybook stories under `design-proposals` (`artifact-directions-compare`, `artifact-directions-hybrid`), annotated for the Storybook catalog as proposed prototypes on fixtures, and a capture script (`shot.cjs`). - Add two `.claude/launch.json` entries: the lab's dev server and its static preview. - Add Vitest coverage for the lab's logic: URL state, the API stub, the kit's kind treatment, type labels and media kinds, its date labels, print fitting and the thread's inline grouping (six files). ## Verification - Run `cd ui && npx vitest run src/artifact-directions`. The six lab test files pass (31 tests). - Run `cd ui && npx tsc -p tsconfig.json --noEmit`. The output is clean apart from the five pre-existing `PluginOrganizationSwitcher` errors. - Run `node scripts/check-token-gates.mjs`, `node scripts/sync-agent-palette-tokens.mjs --check` and `node scripts/sync-cliplab-character.mjs --check`. All gates are clean. The lab directory is outside the token-gated paths; its tokens are lab-local. - Start the lab: `cd ui && npx vite --config vite.artifact-directions.config.mjs --port 5180`. Open `http://localhost:5180/`. The page lands on 5.MVP V1's Overview in dark mode with animation on. Switch kit, surface and theme in the toolbar; the URL follows. - Open `http://localhost:5180/?d=d5-v1-mvp&s=gallery` and hover a stack: the two backing pages turn and the first page rises. Open `?s=thread` and `?s=panel` for the rows. - Build the lab: `cd ui && ARTIFACT_DIRECTIONS_OUT_DIR=/tmp/artifact-lab npx vite build --config vite.artifact-directions.config.mjs`. - Headless checks ran against the dev server during the work: toolbar labels, defaults and keys; thread and panel row geometry (58px rows, 8px and 12px gaps); the hover animation (5° turn, 6px rise, one easing curve); pill fills per theme; Paper Mono resolution on every monospace run. The measured values are in the kit's doc comments. - Run `node scripts/storybook-catalog.mjs --check` (the check `ui typecheck` runs first): the catalog accepts both stories. - Not run: the repo-wide `pnpm -r typecheck` (it needs the Rust toolchain for `paperclip-runner`) and `pnpm build`. The change adds files under `ui/src/artifact-directions` and `ui/storybook/stories/design-proposals` and edits `.claude/launch.json`; it changes no shipped module. CI runs the full Vitest suite. ## Risks - Low risk for the product. The app bundle does not include the lab. The lab's `fetch` stub installs only on the lab's own page. - Storybook gains two stories; the Storybook build includes them. ## Model Used - Claude Fable 5.1 by Anthropic, model ID `claude-fable-5-1`, used through Claude Code in the desktop app. Extended reasoning was on. Capabilities used: tool use (shell, file edits, a headless Playwright browser for measurements and captures, the Figma MCP server to read the mocks) and image input for mock screenshots. The context window is the Claude Code session's; its exact size is not surfaced to the session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above (none found for "artifact directions lab", "artifacts redesign", "artifacts tab design") - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (the lab's Vitest files, the UI typecheck and the token gates; CI runs the full suite) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (no product behaviour or command changed; the lab documents itself in `kit.ts` and the frames) - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
No files matched your search
@@ -23,6 +23,24 @@
|
||||
"cd /Users/tonio/Projects/paperclip-cloud/.worktrees/deploy-stack-master/web && npx vite --port 4173 --strictPort"
|
||||
],
|
||||
"port": 4173
|
||||
},
|
||||
{
|
||||
"name": "artifact-directions-dev",
|
||||
"runtimeExecutable": "/bin/sh",
|
||||
"runtimeArgs": [
|
||||
"-c",
|
||||
"cd ui && npx vite --config vite.artifact-directions.config.mjs --port 5180 --strictPort"
|
||||
],
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "artifact-directions",
|
||||
"runtimeExecutable": "/bin/sh",
|
||||
"runtimeArgs": [
|
||||
"-c",
|
||||
"cd ui && export ARTIFACT_DIRECTIONS_OUT_DIR=node_modules/.artifact-directions-dist && npx vite build --config vite.artifact-directions.config.mjs && npx vite preview --config vite.artifact-directions.config.mjs --port 5190 --strictPort"
|
||||
],
|
||||
"port": 5190
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#18181b" />
|
||||
<title>Artifact directions — lab</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<style>
|
||||
/*
|
||||
Unlayered, for the same reason `wizard-preview.html` carries the same
|
||||
rule: index.css pins `html, body` to viewport height with
|
||||
`overflow: hidden` inside @layer base, which is right for the app shell
|
||||
and would stop this page growing to the height of its frames — a
|
||||
full-page capture would then stop at the viewport.
|
||||
|
||||
Only <html> scrolls. A body that is itself a scroll container (overflow
|
||||
auto) is the nearest scrollport of everything inside it, so the sticky
|
||||
lab toolbar would stick to the body's own box — as tall as the page —
|
||||
and never stay in view.
|
||||
*/
|
||||
html {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
body {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/artifact-directions/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 800" width="1600" height="800"><rect width="1600" height="800" fill="#1f4d3a"/><rect x="40" y="40" width="1520" height="720" fill="none" stroke="#f3ecd9" stroke-opacity="0.25" stroke-width="2"/>
|
||||
<g transform="translate(300 250) scale(2.1)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#e4dbc4"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#f3ecd9"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(31,77,58,0.45)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(31,77,58,0.45)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#f3ecd9" stroke="rgba(31,77,58,0.45)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#c8553d" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="740" y="440" font-family="Georgia, 'Times New Roman', serif" font-size="150" fill="#f3ecd9">acmehats</text>
|
||||
<text x="746" y="520" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" letter-spacing="10" fill="#f3ecd9" opacity="0.7">CUSTOM CAPS · EST. 2026</text></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#7eb6e3"/><stop offset="1" stop-color="#ee79a1"/></linearGradient></defs>
|
||||
<path d="M14 88 V50 C14 26 29 10 48 10 C67 10 82 26 82 50 V88 C82 90 80 91 78 90 L70 84 L62 90 C61 91 59 91 58 90 L48 84 L38 90 C37 91 35 91 34 90 L26 84 L18 90 C16 91 14 90 14 88 Z" fill="url(#g)"/>
|
||||
<circle cx="38" cy="46" r="4.5" fill="#1b1b1b"/><circle cx="58" cy="46" r="4.5" fill="#1b1b1b"/><path d="M40 58 Q48 65 56 58" fill="none" stroke="#1b1b1b" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3e6c4"/><stop offset="1" stop-color="#e3a21a"/></linearGradient></defs>
|
||||
<path d="M14 88 V50 C14 26 29 10 48 10 C67 10 82 26 82 50 V88 C82 90 80 91 78 90 L70 84 L62 90 C61 91 59 91 58 90 L48 84 L38 90 C37 91 35 91 34 90 L26 84 L18 90 C16 91 14 90 14 88 Z" fill="url(#g)"/>
|
||||
<circle cx="38" cy="46" r="4.5" fill="#1b1b1b"/><circle cx="58" cy="46" r="4.5" fill="#1b1b1b"/><path d="M40 58 Q48 65 56 58" fill="none" stroke="#1b1b1b" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900"><defs><radialGradient id="sp" cx="0.5" cy="0.38" r="0.6"><stop offset="0" stop-color="#5a3a22"/><stop offset="0.55" stop-color="#22160f"/><stop offset="1" stop-color="#0b0807"/></radialGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="18"/></filter></defs>
|
||||
<rect width="1600" height="900" fill="url(#sp)"/>
|
||||
<ellipse cx="800" cy="676" rx="470" ry="62" fill="#2b1c12"/><ellipse cx="800" cy="664" rx="470" ry="62" fill="#3c2819"/><ellipse cx="800" cy="664" rx="400" ry="50" fill="none" stroke="#6b4a2f" stroke-width="2" opacity="0.6"/>
|
||||
<ellipse cx="810" cy="662" rx="330" ry="26" fill="#000" opacity="0.55" filter="url(#sh)"/>
|
||||
<g transform="translate(420 250) scale(3.9)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#c44f1d"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#e2632d"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#e2632d" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f6ead8" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="70" y="820" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="34" font-weight="700" fill="#f6ead8" opacity="0.85" letter-spacing="3">ACMEHATS — SIGNAL ORANGE, 360°</text></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1250" width="1000" height="1250"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d98f76"/><stop offset="1" stop-color="#c97458"/></linearGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="20"/></filter></defs>
|
||||
<rect width="1000" height="1250" fill="url(#bg)"/><rect x="0" y="880" width="1000" height="370" fill="#b9634a"/>
|
||||
<rect x="150" y="820" width="700" height="90" rx="6" fill="#efe3d3"/><rect x="150" y="900" width="700" height="14" fill="#d5c4ae"/>
|
||||
<ellipse cx="510" cy="826" rx="330" ry="24" fill="#3a1a10" opacity="0.45" filter="url(#sh)"/>
|
||||
<g transform="translate(180 420) scale(3.5)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#0c0c0c"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#151515"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#151515" stroke="rgba(255,255,255,0.2)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#efe3d3" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<g transform="translate(801 190) scale(-2.9 2.9)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#e2d5c2"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#f1e7d8"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.2)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.2)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#f1e7d8" stroke="rgba(0,0,0,0.2)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#151515" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="76" y="1150" font-family="Georgia, 'Times New Roman', serif" font-size="64" fill="#fbeee2">Your place. Your cap.</text></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 1200" width="1200" height="1200"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f7cf2f"/><stop offset="1" stop-color="#f0b91a"/></linearGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="26"/></filter></defs>
|
||||
<rect width="1200" height="1200" fill="url(#bg)"/><circle cx="600" cy="560" r="400" fill="#f9dc5a" opacity="0.65"/>
|
||||
<ellipse cx="610" cy="818" rx="400" ry="34" fill="#7a5200" opacity="0.4" filter="url(#sh)"/>
|
||||
<g transform="translate(1007 330) scale(-4.3 4.3)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1836a3"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f44c8"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f44c8" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f7cf2f" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<text x="80" y="1110" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="46" font-weight="800" fill="#1836a3" letter-spacing="2">THE SHARED MARK</text></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 1000" width="1500" height="1000"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#efe5d2"/><stop offset="1" stop-color="#e2d3b8"/></linearGradient><linearGradient id="fl" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#d9c8a8"/><stop offset="1" stop-color="#cdb992"/></linearGradient><filter id="sh" x="-30%" y="-80%" width="160%" height="260%"><feGaussianBlur stdDeviation="22"/></filter></defs>
|
||||
<rect width="1500" height="1000" fill="url(#bg)"/><path d="M0 640 C400 600 1100 600 1500 640 L1500 1000 L0 1000 Z" fill="url(#fl)"/>
|
||||
<ellipse cx="760" cy="742" rx="430" ry="38" fill="#3b2f1c" opacity="0.38" filter="url(#sh)"/>
|
||||
<g transform="translate(330 250) scale(4.4)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1d4333"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#24523f"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.16)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.16)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#24523f" stroke="rgba(255,255,255,0.16)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f1e7d2" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="96" y="96" width="150" height="150" rx="10" fill="#f8f1e2" opacity="0.6"/><text x="126" y="200" font-family="Georgia, 'Times New Roman', serif" font-size="96" fill="#24523f">a</text></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,56 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1080" width="1440" height="1080"><rect width="1440" height="1080" fill="#d9d2c0"/><g transform="translate(0 0)"><rect width="476" height="265.5" fill="#1f4d3a"/><text x="26" y="62" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">Your place.</text><text x="26" y="95.6" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">Your people.</text><text x="26" y="129.2" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">Your cap.</text><rect x="26" y="150" width="150" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="26" y="159" width="138" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="26" y="168" width="93" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/>
|
||||
<rect x="330" y="46" width="96" height="96" fill="#f6f0e1"/><text x="360" y="112" font-family="Georgia, 'Times New Roman', serif" font-size="56" font-weight="400" fill="#1f4d3a">a</text>
|
||||
<rect x="26" y="219.5" width="424" height="1.5" fill="#f3ecd9" opacity="0.35"/><rect x="26" y="231.5" width="300" height="3" rx="1" fill="#f3ecd9" opacity="0.4"/><rect x="26" y="239.5" width="186" height="3" rx="1" fill="#f3ecd9" opacity="0.4"/></g>
|
||||
<g transform="translate(482 0)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Build an identity before making promises.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<g transform="translate(22 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Opening</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(170 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Hypothesis</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(318 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Still open</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g>
|
||||
<rect x="22" y="160" width="432" height="1" fill="#1f3a2e" opacity="0.3"/><rect x="22" y="176" width="390" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="185" width="358.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="194" width="327.59999999999997" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="203" width="241.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(964 0)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Specificity is the creative opening.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Customers</text><rect x="120" y="80" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="88" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="22" y="112" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Category</text><rect x="120" y="106" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="114" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="22" y="138" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Evidence</text><rect x="120" y="132" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="140" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="22" y="164" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#9a3b28">Advantage</text><rect x="120" y="158" width="300" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="120" y="166" width="186" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="22" y="196" width="432" height="26" fill="#1f4d3a"/><rect x="34" y="207" width="186" height="4" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(0 271.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Start with the people behind the counter.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="190" height="132" fill="#1f4d3a"/><text x="36" y="98" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Independent cafés</text><text x="36" y="114.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">& hospitality</text><rect x="36" y="132" width="150" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="141" width="138" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="150" width="126" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="159" width="150" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="36" y="168" width="93" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/>
|
||||
<text x="232" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Ownership pride</text><rect x="232" y="94" width="200" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="102" width="184" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="110" width="124" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="232" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Corporate teams</text><rect x="232" y="138" width="200" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="146" width="184" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="154" width="124" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><text x="232" y="172" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#9a3b28">Clubs and makers</text><rect x="232" y="182" width="200" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="190" width="184" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="232" y="198" width="124" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(482 271.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Make the place recognizable.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/><rect x="22" y="70" width="400" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="79" width="248" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<g transform="translate(22 100)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Opening</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(236 100)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Recognition</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(22 158)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Practicality</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(236 158)"><rect width="12" height="12" fill="#1f4d3a"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">Belonging</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(964 271.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Belonging is the ambition. Proof comes next.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">For independent places,</text><text x="22" y="101.68" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">acmehats imagines custom caps</text><text x="22" y="117.36" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">that carry the character of the</text><text x="22" y="133.04000000000002" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#1f3a2e">place and its people.</text><rect x="22" y="170" width="240" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="179" width="220.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="188" width="148.8" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="296" y="70" width="158" height="132" fill="#1f4d3a"/><rect x="310" y="86" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="94" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="114" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="122" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="142" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="150" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="170" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="310" y="178" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.6"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(0 543)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Start with the character of a place.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<text x="22" y="84" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#1f3a2e">The name. The people. The familiar details.</text><text x="22" y="98.56" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#1f3a2e">A cap can become a small expression</text><text x="22" y="113.12" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#1f3a2e">of that shared identity.</text>
|
||||
<g transform="translate(22 150)"><rect width="134" height="18" fill="#1f4d3a"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(168 150)"><rect width="134" height="18" fill="#1f4d3a"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><g transform="translate(314 150)"><rect width="134" height="18" fill="#1f4d3a"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f3ecd9" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(482 543)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">The Local Signature</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="240" height="112" fill="#1f4d3a"/><text x="46" y="124" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f3ecd9">acmehats</text><rect x="46" y="142" width="140" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="46" y="150" width="86.8" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/>
|
||||
<text x="280" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#1f3a2e">Familiar. Assured.</text><text x="280" y="97.44" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#1f3a2e">Observant.</text><rect x="280" y="116" width="150" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="124" width="138" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="132" width="126" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="140" width="150" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="148" width="138" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="156" width="126" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="280" y="164" width="93" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="22" y="192" width="80" height="6" fill="#c8553d"/><rect x="108" y="192" width="80" height="6" fill="#1f4d3a"/><rect x="194" y="192" width="68" height="6" fill="#1f3a2e" opacity="0.4"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(964 543)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Let the customer's name lead.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/>
|
||||
<g transform="translate(40 64) scale(1.1)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1f4d3a"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f4d3a"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f4d3a" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f6f0e1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="286" y="70" width="168" height="126" fill="#1f4d3a"/><text x="300" y="100" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Your place.</text><text x="300" y="116.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Your people.</text><text x="300" y="133.6" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f3ecd9">Your cap.</text><rect x="300" y="160" width="130" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="300" y="168" width="119.60000000000001" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="300" y="176" width="80.6" height="3" rx="1" fill="#f3ecd9" opacity="0.5"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g>
|
||||
<g transform="translate(0 814.5)"><rect width="476" height="265.5" fill="#1f44c8"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">The Shared Mark</text>
|
||||
<rect x="22" y="56" width="170" height="126" fill="#f4c81f"/><text x="38" y="108" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#1a2f8f">acme</text><text x="38" y="141.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#1a2f8f">hats</text>
|
||||
<text x="210" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">Warm. Bold.</text><text x="210" y="99.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">Participatory.</text><rect x="210" y="124" width="220" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="133" width="202.4" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="142" width="184.79999999999998" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="151" width="220" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="160" width="202.4" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="210" y="169" width="136.4" height="3" rx="1" fill="#ffffff" opacity="0.6"/>
|
||||
<rect x="22" y="237.5" width="432" height="1.5" fill="#ffffff" opacity="0.4"/></g>
|
||||
<g transform="translate(482 814.5)"><rect width="476" height="265.5" fill="#1f44c8"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff">A shared identity with more energy.</text>
|
||||
<rect x="22" y="56" width="130" height="126" fill="#f4c81f"/><rect x="34" y="76" width="100" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="86" width="92" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="96" width="84" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="106" width="100" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="116" width="92" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/><rect x="34" y="126" width="62" height="4" rx="1" fill="#1a2f8f" opacity="0.75"/>
|
||||
<rect x="166" y="56" width="150" height="126" fill="#f6f0e1"/><g transform="translate(184 78) scale(0.6)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1f44c8"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f44c8"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f44c8" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f4c81f" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="330" y="56" width="124" height="126" fill="#f4c81f"/><text x="342" y="92" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#1a2f8f">Your place.</text><text x="342" y="106.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#1a2f8f">Your people.</text><text x="342" y="121.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#1a2f8f">Your cap.</text></g>
|
||||
<g transform="translate(964 814.5)"><rect width="476" height="265.5" fill="#f6f0e1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#1f3a2e">Choose The Local Signature to develop.</text><rect x="22" y="52" width="432" height="1.5" fill="#1f3a2e" opacity="0.5"/><rect x="22" y="70" width="420" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="79" width="386.40000000000003" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="88" width="260.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/>
|
||||
<rect x="22" y="108" width="206" height="78" fill="#1f4d3a"/><text x="34" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#f3ecd9">A / Recommended</text><rect x="34" y="140" width="170" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="34" y="149" width="156.4" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="34" y="158" width="142.79999999999998" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/><rect x="34" y="167" width="105.4" height="3" rx="1" fill="#f3ecd9" opacity="0.55"/>
|
||||
<rect x="242" y="108" width="206" height="78" fill="none" stroke="#1f3a2e" stroke-width="1.5"/><text x="254" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#1f3a2e">B / Alternative</text><rect x="254" y="140" width="170" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="254" y="149" width="156.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="254" y="158" width="142.79999999999998" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="254" y="167" width="105.4" height="3" rx="1" fill="#1f3a2e" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#1f3a2e" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#1f3a2e" opacity="0.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,65 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1080" width="1440" height="1080"><rect width="1440" height="1080" fill="#d4d4d4"/><g transform="translate(0 0)"><rect width="476" height="265.5" fill="#0c0c0c"/><text x="26" y="62" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Your place.</text><text x="26" y="95.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Your people.</text><text x="26" y="129.2" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="700" fill="#ffffff">Your cap.</text><rect x="26" y="150" width="150" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="26" y="159" width="138" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="26" y="168" width="93" height="3" rx="1" fill="#ffffff" opacity="0.5"/>
|
||||
<g transform="translate(290 60) scale(0.82)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#f4f4f4"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#f4f4f4"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#f4f4f4" stroke="rgba(0,0,0,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#0c0c0c" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="26" y="219.5" width="424" height="1.5" fill="#ffffff" opacity="0.35"/><rect x="26" y="231.5" width="300" height="3" rx="1" fill="#ffffff" opacity="0.4"/><rect x="26" y="239.5" width="186" height="3" rx="1" fill="#ffffff" opacity="0.4"/></g>
|
||||
<g transform="translate(482 0)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Build an identity before making promises.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<g transform="translate(22 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Opening</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(170 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Hypothesis</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(318 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Still open</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g>
|
||||
<rect x="22" y="160" width="432" height="1" fill="#111111" opacity="0.3"/><rect x="22" y="176" width="390" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="185" width="358.8" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="194" width="327.59999999999997" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="203" width="241.8" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(964 0)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Specificity is the creative opening.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Customers</text><rect x="120" y="80" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="88" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="22" y="112" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Category</text><rect x="120" y="106" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="114" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="22" y="138" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Evidence</text><rect x="120" y="132" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="140" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="22" y="164" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#111111">Advantage</text><rect x="120" y="158" width="300" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="120" y="166" width="186" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="22" y="196" width="432" height="26" fill="#0c0c0c"/><rect x="34" y="207" width="186" height="4" rx="1" fill="#ffffff" opacity="0.8"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(0 271.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Start with the people behind the counter.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="190" height="132" fill="#0c0c0c"/><text x="36" y="98" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Independent cafés</text><text x="36" y="114.8" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">& hospitality</text><rect x="36" y="132" width="150" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="141" width="138" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="150" width="126" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="159" width="150" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="36" y="168" width="93" height="3" rx="1" fill="#ffffff" opacity="0.55"/>
|
||||
<text x="232" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Ownership pride</text><rect x="232" y="94" width="200" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="102" width="184" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="110" width="124" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="232" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Corporate teams</text><rect x="232" y="138" width="200" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="146" width="184" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="154" width="124" height="3" rx="1" fill="#111111" opacity="0.55"/><text x="232" y="172" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Clubs and makers</text><rect x="232" y="182" width="200" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="190" width="184" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="232" y="198" width="124" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(482 271.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Make the place recognizable.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/><rect x="22" y="70" width="400" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="79" width="248" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<g transform="translate(22 100)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Opening</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(236 100)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Recognition</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(22 158)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Practicality</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(236 158)"><rect width="12" height="12" fill="#0c0c0c"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">Belonging</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(964 271.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Belonging is the ambition. Proof comes next.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">For independent places,</text><text x="22" y="101.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">acmehats imagines custom caps</text><text x="22" y="117.36" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">that carry the character of the</text><text x="22" y="133.04000000000002" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="400" fill="#111111">place and its people.</text><rect x="22" y="170" width="240" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="179" width="220.8" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="188" width="148.8" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="296" y="70" width="158" height="132" fill="#0c0c0c"/><rect x="310" y="86" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="94" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="114" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="122" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="142" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="150" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="170" width="130" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="310" y="178" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.6"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(0 543)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Start with the character of a place.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<text x="22" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="400" fill="#111111">The name. The people. The familiar details.</text><text x="22" y="98.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="400" fill="#111111">A cap can become a small expression</text><text x="22" y="113.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="400" fill="#111111">of that shared identity.</text>
|
||||
<g transform="translate(22 150)"><rect width="134" height="18" fill="#0c0c0c"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#ffffff" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(168 150)"><rect width="134" height="18" fill="#0c0c0c"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#ffffff" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><g transform="translate(314 150)"><rect width="134" height="18" fill="#0c0c0c"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#ffffff" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#111111" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(482 543)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">The Local Signature</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="240" height="112" fill="#0c0c0c"/><text x="46" y="124" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="700" fill="#ffffff">acme hats</text><rect x="46" y="142" width="140" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="46" y="150" width="86.8" height="3" rx="1" fill="#ffffff" opacity="0.5"/>
|
||||
<text x="280" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#111111">Familiar. Assured.</text><text x="280" y="97.44" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#111111">Observant.</text><rect x="280" y="116" width="150" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="124" width="138" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="132" width="126" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="140" width="150" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="148" width="138" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="156" width="126" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="280" y="164" width="93" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="22" y="192" width="80" height="6" fill="#111111"/><rect x="108" y="192" width="80" height="6" fill="#0c0c0c"/><rect x="194" y="192" width="68" height="6" fill="#111111" opacity="0.4"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(964 543)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Let the customer's name lead.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/>
|
||||
<g transform="translate(40 64) scale(1.1)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#111111"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#111111"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.28)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#111111" stroke="rgba(255,255,255,0.28)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#ffffff" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="286" y="70" width="168" height="126" fill="#0c0c0c"/><text x="300" y="100" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Your place.</text><text x="300" y="116.8" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Your people.</text><text x="300" y="133.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="15" font-weight="700" fill="#ffffff">Your cap.</text><rect x="300" y="160" width="130" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="300" y="168" width="119.60000000000001" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="300" y="176" width="80.6" height="3" rx="1" fill="#ffffff" opacity="0.5"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(0 814.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">The Shared Mark</text>
|
||||
<rect x="22" y="56" width="170" height="126" fill="#0c0c0c"/><text x="38" y="108" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#ffffff">acme</text><text x="38" y="141.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#ffffff">hats</text>
|
||||
<text x="210" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">Warm. Bold.</text><text x="210" y="99.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">Participatory.</text><rect x="210" y="124" width="220" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="133" width="202.4" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="142" width="184.79999999999998" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="151" width="220" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="160" width="202.4" height="3" rx="1" fill="#111111" opacity="0.6"/><rect x="210" y="169" width="136.4" height="3" rx="1" fill="#111111" opacity="0.6"/>
|
||||
<rect x="22" y="237.5" width="432" height="1.5" fill="#111111" opacity="0.4"/></g>
|
||||
<g transform="translate(482 814.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#111111">A shared identity with more energy.</text>
|
||||
<rect x="22" y="56" width="130" height="126" fill="#0c0c0c"/><rect x="34" y="76" width="100" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="86" width="92" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="96" width="84" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="106" width="100" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="116" width="92" height="4" rx="1" fill="#ffffff" opacity="0.75"/><rect x="34" y="126" width="62" height="4" rx="1" fill="#ffffff" opacity="0.75"/>
|
||||
<rect x="166" y="56" width="150" height="126" fill="#ececec"/><g transform="translate(184 78) scale(0.6)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#111111"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#111111"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#111111" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#0c0c0c" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="330" y="56" width="124" height="126" fill="#0c0c0c"/><text x="342" y="92" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff">Your place.</text><text x="342" y="106.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff">Your people.</text><text x="342" y="121.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff">Your cap.</text></g>
|
||||
<g transform="translate(964 814.5)"><rect width="476" height="265.5" fill="#ffffff"/><text x="22" y="40" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="19" font-weight="700" fill="#111111">Choose The Local Signature to develop.</text><rect x="22" y="52" width="432" height="1.5" fill="#111111" opacity="0.5"/><rect x="22" y="70" width="420" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="79" width="386.40000000000003" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="88" width="260.4" height="3" rx="1" fill="#111111" opacity="0.55"/>
|
||||
<rect x="22" y="108" width="206" height="78" fill="#0c0c0c"/><text x="34" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#ffffff">A / Recommended</text><rect x="34" y="140" width="170" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="34" y="149" width="156.4" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="34" y="158" width="142.79999999999998" height="3" rx="1" fill="#ffffff" opacity="0.55"/><rect x="34" y="167" width="105.4" height="3" rx="1" fill="#ffffff" opacity="0.55"/>
|
||||
<rect x="242" y="108" width="206" height="78" fill="none" stroke="#111111" stroke-width="1.5"/><text x="254" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#111111">B / Alternative</text><rect x="254" y="140" width="170" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="254" y="149" width="156.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="254" y="158" width="142.79999999999998" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="254" y="167" width="105.4" height="3" rx="1" fill="#111111" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#111111" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#111111" opacity="0.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,65 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 1080" width="1440" height="1080"><rect width="1440" height="1080" fill="#d8d3c7"/><g transform="translate(0 0)"><rect width="476" height="265.5" fill="#23372e"/><text x="26" y="62" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">Your place.</text><text x="26" y="95.6" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">Your people.</text><text x="26" y="129.2" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">Your cap.</text><rect x="26" y="150" width="150" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="26" y="159" width="138" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="26" y="168" width="93" height="3" rx="1" fill="#f5efe2" opacity="0.5"/>
|
||||
<g transform="translate(290 60) scale(0.82)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#d9622b"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#d9622b"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(0,0,0,0.25)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#d9622b" stroke="rgba(0,0,0,0.25)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f5efe2" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="26" y="219.5" width="424" height="1.5" fill="#f5efe2" opacity="0.35"/><rect x="26" y="231.5" width="300" height="3" rx="1" fill="#f5efe2" opacity="0.4"/><rect x="26" y="239.5" width="186" height="3" rx="1" fill="#f5efe2" opacity="0.4"/></g>
|
||||
<g transform="translate(482 0)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Build an identity before making promises.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<g transform="translate(22 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Opening</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(170 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Hypothesis</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(318 74)"><text x="0" y="12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Still open</text><rect x="0" y="24" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="33" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="42" width="107.52" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="51" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="60" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="69" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g>
|
||||
<rect x="22" y="160" width="432" height="1" fill="#22302a" opacity="0.3"/><rect x="22" y="176" width="390" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="185" width="358.8" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="194" width="327.59999999999997" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="203" width="241.8" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(964 0)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Specificity is the creative opening.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Customers</text><rect x="120" y="80" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="88" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="22" y="112" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Category</text><rect x="120" y="106" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="114" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="22" y="138" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Evidence</text><rect x="120" y="132" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="140" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="22" y="164" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="10" font-weight="700" fill="#b24a18">Advantage</text><rect x="120" y="158" width="300" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="120" y="166" width="186" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="22" y="196" width="432" height="26" fill="#23372e"/><rect x="34" y="207" width="186" height="4" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(0 271.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Start with the people behind the counter.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="190" height="132" fill="#23372e"/><text x="36" y="98" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Independent cafés</text><text x="36" y="114.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">& hospitality</text><rect x="36" y="132" width="150" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="141" width="138" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="150" width="126" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="159" width="150" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="36" y="168" width="93" height="3" rx="1" fill="#f5efe2" opacity="0.55"/>
|
||||
<text x="232" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Ownership pride</text><rect x="232" y="94" width="200" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="102" width="184" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="110" width="124" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="232" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Corporate teams</text><rect x="232" y="138" width="200" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="146" width="184" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="154" width="124" height="3" rx="1" fill="#22302a" opacity="0.55"/><text x="232" y="172" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#b24a18">Clubs and makers</text><rect x="232" y="182" width="200" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="190" width="184" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="232" y="198" width="124" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(482 271.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Make the place recognizable.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/><rect x="22" y="70" width="400" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="79" width="248" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<g transform="translate(22 100)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Opening</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(236 100)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Recognition</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(22 158)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Practicality</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(236 158)"><rect width="12" height="12" fill="#23372e"/><text x="20" y="11" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">Belonging</text><rect x="20" y="22" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="30" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="20" y="38" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(964 271.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Belonging is the ambition. Proof comes next.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<text x="22" y="86" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">For independent places,</text><text x="22" y="101.68" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">acmehats imagines custom caps</text><text x="22" y="117.36" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">that carry the character of the</text><text x="22" y="133.04000000000002" font-family="Georgia, 'Times New Roman', serif" font-size="14" font-weight="400" fill="#22302a">place and its people.</text><rect x="22" y="170" width="240" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="179" width="220.8" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="188" width="148.8" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="296" y="70" width="158" height="132" fill="#23372e"/><rect x="310" y="86" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="94" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="114" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="122" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="142" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="150" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="170" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="310" y="178" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.6"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(0 543)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Start with the character of a place.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<text x="22" y="84" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#22302a">The name. The people. The familiar details.</text><text x="22" y="98.56" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#22302a">A cap can become a small expression</text><text x="22" y="113.12" font-family="Georgia, 'Times New Roman', serif" font-size="13" font-weight="400" fill="#22302a">of that shared identity.</text>
|
||||
<g transform="translate(22 150)"><rect width="134" height="18" fill="#23372e"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(168 150)"><rect width="134" height="18" fill="#23372e"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><g transform="translate(314 150)"><rect width="134" height="18" fill="#23372e"/><rect x="8" y="8" width="55.8" height="3" rx="1" fill="#f5efe2" opacity="0.8"/><rect x="0" y="28" width="128" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="36" width="117.76" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="0" y="44" width="79.36" height="3" rx="1" fill="#22302a" opacity="0.55"/></g><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(482 543)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">The Local Signature</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<rect x="22" y="70" width="240" height="112" fill="#23372e"/><text x="46" y="124" font-family="Georgia, 'Times New Roman', serif" font-size="30" font-weight="400" fill="#f5efe2">acmehats</text><rect x="46" y="142" width="140" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="46" y="150" width="86.8" height="3" rx="1" fill="#f5efe2" opacity="0.5"/>
|
||||
<text x="280" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#22302a">Familiar. Assured.</text><text x="280" y="97.44" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="12" font-weight="700" fill="#22302a">Observant.</text><rect x="280" y="116" width="150" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="124" width="138" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="132" width="126" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="140" width="150" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="148" width="138" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="156" width="126" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="280" y="164" width="93" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="22" y="192" width="80" height="6" fill="#d9622b"/><rect x="108" y="192" width="80" height="6" fill="#23372e"/><rect x="194" y="192" width="68" height="6" fill="#22302a" opacity="0.4"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(964 543)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Let the customer's name lead.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/>
|
||||
<g transform="translate(40 64) scale(1.1)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.12)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#23372e"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#23372e"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.22)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#23372e" stroke="rgba(255,255,255,0.22)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#fbf8f1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="286" y="70" width="168" height="126" fill="#23372e"/><text x="300" y="100" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Your place.</text><text x="300" y="116.8" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Your people.</text><text x="300" y="133.6" font-family="Georgia, 'Times New Roman', serif" font-size="15" font-weight="400" fill="#f5efe2">Your cap.</text><rect x="300" y="160" width="130" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="300" y="168" width="119.60000000000001" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="300" y="176" width="80.6" height="3" rx="1" fill="#f5efe2" opacity="0.5"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g>
|
||||
<g transform="translate(0 814.5)"><rect width="476" height="265.5" fill="#d9622b"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">The Shared Mark</text>
|
||||
<rect x="22" y="56" width="170" height="126" fill="#fbf8f1"/><text x="38" y="108" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#23372e">acme</text><text x="38" y="141.6" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="30" font-weight="800" fill="#23372e">hats</text>
|
||||
<text x="210" y="84" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">Warm. Bold.</text><text x="210" y="99.68" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">Participatory.</text><rect x="210" y="124" width="220" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="133" width="202.4" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="142" width="184.79999999999998" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="151" width="220" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="160" width="202.4" height="3" rx="1" fill="#fff7ee" opacity="0.6"/><rect x="210" y="169" width="136.4" height="3" rx="1" fill="#fff7ee" opacity="0.6"/>
|
||||
<rect x="22" y="237.5" width="432" height="1.5" fill="#fff7ee" opacity="0.4"/></g>
|
||||
<g transform="translate(482 814.5)"><rect width="476" height="265.5" fill="#d9622b"/><text x="22" y="34" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="14" font-weight="700" fill="#fff7ee">A shared identity with more energy.</text>
|
||||
<rect x="22" y="56" width="130" height="126" fill="#fbf8f1"/><rect x="34" y="76" width="100" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="86" width="92" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="96" width="84" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="106" width="100" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="116" width="92" height="4" rx="1" fill="#23372e" opacity="0.75"/><rect x="34" y="126" width="62" height="4" rx="1" fill="#23372e" opacity="0.75"/>
|
||||
<rect x="166" y="56" width="150" height="126" fill="#23372e"/><g transform="translate(184 78) scale(0.6)">
|
||||
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#d9622b"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#d9622b"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.3)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#d9622b" stroke="rgba(255,255,255,0.3)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#fbf8f1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="330" y="56" width="124" height="126" fill="#fbf8f1"/><text x="342" y="92" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#23372e">Your place.</text><text x="342" y="106.56" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#23372e">Your people.</text><text x="342" y="121.12" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="13" font-weight="700" fill="#23372e">Your cap.</text></g>
|
||||
<g transform="translate(964 814.5)"><rect width="476" height="265.5" fill="#fbf8f1"/><text x="22" y="40" font-family="Georgia, 'Times New Roman', serif" font-size="19" font-weight="400" fill="#22302a">Choose The Local Signature to develop.</text><rect x="22" y="52" width="432" height="1.5" fill="#22302a" opacity="0.5"/><rect x="22" y="70" width="420" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="79" width="386.40000000000003" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="88" width="260.4" height="3" rx="1" fill="#22302a" opacity="0.55"/>
|
||||
<rect x="22" y="108" width="206" height="78" fill="#23372e"/><text x="34" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#f5efe2">A / Recommended</text><rect x="34" y="140" width="170" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="34" y="149" width="156.4" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="34" y="158" width="142.79999999999998" height="3" rx="1" fill="#f5efe2" opacity="0.55"/><rect x="34" y="167" width="105.4" height="3" rx="1" fill="#f5efe2" opacity="0.55"/>
|
||||
<rect x="242" y="108" width="206" height="78" fill="none" stroke="#22302a" stroke-width="1.5"/><text x="254" y="128" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="11" font-weight="700" fill="#22302a">B / Alternative</text><rect x="254" y="140" width="170" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="254" y="149" width="156.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="254" y="158" width="142.79999999999998" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="254" y="167" width="105.4" height="3" rx="1" fill="#22302a" opacity="0.55"/><rect x="22" y="245.5" width="60" height="3" fill="#22302a" opacity="0.4"/><rect x="416" y="245.5" width="38" height="3" fill="#22302a" opacity="0.4"/></g></svg>
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900"><rect width="1600" height="900" fill="#f6f0e1"/><rect width="1600" height="84" fill="#1f4d3a"/><text x="60" y="56" font-family="Georgia, 'Times New Roman', serif" font-size="38" fill="#f3ecd9">acmehats</text>
|
||||
<rect x="1040" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/><rect x="1170" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/><rect x="1300" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/><rect x="1430" y="38" width="90" height="8" rx="4" fill="#f3ecd9" opacity="0.6"/>
|
||||
<text x="80" y="250" font-family="Georgia, 'Times New Roman', serif" font-size="84" font-weight="400" fill="#1f3a2e">Caps that carry</text><text x="80" y="344.08000000000004" font-family="Georgia, 'Times New Roman', serif" font-size="84" font-weight="400" fill="#1f3a2e">your place.</text><rect x="84" y="400" width="520" height="8" rx="1" fill="#1f3a2e" opacity="0.4"/><rect x="84" y="422" width="478.40000000000003" height="8" rx="1" fill="#1f3a2e" opacity="0.4"/><rect x="84" y="444" width="322.4" height="8" rx="1" fill="#1f3a2e" opacity="0.4"/>
|
||||
<rect x="84" y="500" width="260" height="70" rx="35" fill="#1f4d3a"/><text x="130" y="546" font-family="Helvetica Neue, Helvetica, Arial, sans-serif" font-size="28" font-weight="700" fill="#f3ecd9">Design yours</text>
|
||||
<rect x="860" y="160" width="660" height="560" rx="24" fill="#e7dcc4"/><g transform="translate(930 250) scale(2.9)">
|
||||
<ellipse cx="100" cy="118" rx="92" ry="7" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C2 115 8 118 16 116 C44 108 74 104 108 103 Z" fill="#1f4d3a"/>
|
||||
<path d="M56 90 C34 90 10 99 4 110 C22 100 44 97 62 96 Z" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M48 98 C46 52 76 22 116 22 C156 22 184 52 186 98 C140 104 94 104 48 98 Z" fill="#1f4d3a"/>
|
||||
<path d="M116 22 C100 44 95 70 95 101 M116 22 C140 44 150 70 151 101" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="1.6"/>
|
||||
<path d="M48 98 C94 104 140 104 186 98" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="2.4"/>
|
||||
<circle cx="116" cy="21" r="4.5" fill="#1f4d3a" stroke="rgba(255,255,255,0.2)" stroke-width="1"/>
|
||||
<rect x="60" y="60" width="30" height="19" rx="2.5" fill="#f6f0e1" transform="rotate(-6 75 70)"/>
|
||||
</g>
|
||||
<rect x="84" y="760" width="220" height="90" rx="12" fill="#ffffff"/><circle cx="130" cy="805" r="22" fill="#1f4d3a"/><rect x="166" y="796" width="110" height="10" rx="5" fill="#1f3a2e" opacity="0.4"/><rect x="334" y="760" width="220" height="90" rx="12" fill="#ffffff"/><circle cx="380" cy="805" r="22" fill="#1f44c8"/><rect x="416" y="796" width="110" height="10" rx="5" fill="#1f3a2e" opacity="0.4"/><rect x="584" y="760" width="220" height="90" rx="12" fill="#ffffff"/><circle cx="630" cy="805" r="22" fill="#c8553d"/><rect x="666" y="796" width="110" height="10" rx="5" fill="#1f3a2e" opacity="0.4"/></svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
@@ -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 (
|
||||
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-2.5">
|
||||
<span className="whitespace-nowrap">
|
||||
<span className={leadClassName}>{lead}</span>
|
||||
{parts.map((part) => (
|
||||
<Fragment key={part}>
|
||||
<MetaDot />
|
||||
{part}
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
{review > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<StateDot state="needs_review" />
|
||||
{review} to review
|
||||
</span>
|
||||
) : null}
|
||||
{changes > 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 whitespace-nowrap">
|
||||
<StateDot state="changes_requested" />
|
||||
{changes} {changes === 1 ? "change" : "changes"} requested
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={cn(mono, SHEET_TEXT, "overflow-hidden", className)}>
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
{head.map((cell, index) => (
|
||||
<th key={index} scope="col" className={cn("truncate pr-2 text-left font-medium text-foreground", COLUMN[index])}>
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
{notes.map((note) =>
|
||||
total > note.columns ? (
|
||||
<th key={note.columns} className={cn("w-7 text-right font-normal whitespace-nowrap @2xs:w-14", note.when)}>
|
||||
+{total - note.columns}
|
||||
<span className="hidden @2xs:inline"> {total - note.columns === 1 ? "col" : "cols"}</span>
|
||||
</th>
|
||||
) : null,
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{body.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{row.map((cell, column) => (
|
||||
<td key={column} className={cn("truncate pr-2", COLUMN[column])}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
aria-hidden
|
||||
data-solid="excerpt-sheet"
|
||||
data-solid-sheet={drawer ? undefined : "fit"}
|
||||
// A drawer is a container too (by width only), so its table can count its columns the same way.
|
||||
className={cn("relative overflow-hidden rounded-md bg-(--solid-sheet)", drawer ? "@container px-3 py-2.5" : cell ? "p-2" : "p-4", className ?? "w-full")}
|
||||
>
|
||||
{artifact.kind === "sheet" ? (
|
||||
<Table csv={text} rows={rows} single={cell} mono={mono} className={fit ?? undefined} />
|
||||
) : artifact.kind === "code" ? (
|
||||
<div className={cn(mono, SHEET_TEXT, fit)}>
|
||||
{text
|
||||
.split("\n")
|
||||
.slice(0, rows)
|
||||
.map((line, index) => (
|
||||
<span key={index} className="block overflow-hidden text-ellipsis whitespace-pre">
|
||||
{line || " "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// The size comes first: tailwind-merge drops a `leading-*` that precedes a font size.
|
||||
<div
|
||||
className={cn(
|
||||
drawer ? "text-xs leading-5" : cell ? "text-(length:--text-micro) leading-4" : "text-(length:--text-compact) leading-5",
|
||||
"wrap-break-word whitespace-pre-line",
|
||||
SHEET_TEXT,
|
||||
drawer ? CLAMP[lines] : fit,
|
||||
)}
|
||||
>
|
||||
{runs(artifact).map((run, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? run.lead : null}
|
||||
{run.strong ? <span className="font-medium text-foreground">{run.text}</span> : run.text}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{drawer ? null : <KindTag artifact={artifact} size={cell ? "sm" : "md"} compact={cell} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabGroup } from "../../kit";
|
||||
import { Counts } from "./Counts";
|
||||
import { daySections, type DaySection } from "./gallery-model";
|
||||
import { GalleryTile } from "./GalleryTile";
|
||||
import { plural } from "./kinds";
|
||||
import { META_TEXT } from "./MetaLine";
|
||||
import { StackTile } from "./StackTile";
|
||||
|
||||
/**
|
||||
* GALLERY — the company Artifacts page, below its toolbar.
|
||||
*
|
||||
* The product's grid (1 / 2 / 3 columns at <768 / 768 / 1280px, 24px gutters
|
||||
* both ways, inside the page's 1152px column), with the direction's mono
|
||||
* count label above it — the line that heads the thread's attachments and the
|
||||
* panel's shelf.
|
||||
*
|
||||
* stacks 6 stacks · 43 artifacts ● 12 to review ● 1 change requested
|
||||
* [stack] [stack] [stack] …
|
||||
*
|
||||
* stack 21 artifacts ● 9 to review
|
||||
*
|
||||
* Monday, September 28 ← a day: the page's one large line
|
||||
* 17 artifacts ● 9 to review
|
||||
* [tile] [tile] [tile] …
|
||||
*
|
||||
* Saturday, September 26
|
||||
* 2 artifacts
|
||||
* [tile] [tile]
|
||||
*
|
||||
* (`?days=off`: the same tiles in the product's flat grid, without the days.)
|
||||
*/
|
||||
|
||||
const GRID = "grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3";
|
||||
const COUNT_LINE = cn(META_TEXT, "px-1 text-muted-foreground");
|
||||
|
||||
export function GalleryStacks({ groups }: { groups: LabGroup[] }) {
|
||||
if (groups.length === 0) return <p className="px-1 py-16 text-center text-sm font-medium text-foreground">No artifact stacks yet.</p>;
|
||||
const all = groups.flatMap((group) => group.artifacts);
|
||||
const total = groups.reduce((sum, group) => sum + group.count, 0);
|
||||
return (
|
||||
<div data-solid="gallery-stacks" className="flex flex-col gap-2">
|
||||
<p className={COUNT_LINE}>
|
||||
<Counts lead={plural(groups.length, "stack")} parts={[plural(total, "artifact")]} of={all} />
|
||||
</p>
|
||||
{/* Each tile keeps 28px above its face for the pile; the 24px row gap leaves a risen pile clear of the row above. */}
|
||||
<ul className={GRID}>
|
||||
{groups.map((group) => (
|
||||
<li key={group.id} className="min-w-0">
|
||||
<StackTile group={group} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Day({ id, section }: { id: string; section: DaySection }) {
|
||||
return (
|
||||
<section data-solid="gallery-day" aria-labelledby={id} className="flex flex-col gap-4">
|
||||
<header className="flex flex-col gap-0.5 px-1">
|
||||
<h2 id={id} className="text-lg font-semibold text-foreground">
|
||||
{section.label}
|
||||
</h2>
|
||||
<p className={cn(META_TEXT, "text-muted-foreground")}>
|
||||
<Counts lead={plural(section.items.length, "artifact")} of={section.items} />
|
||||
</p>
|
||||
</header>
|
||||
<ul className={GRID}>
|
||||
{section.items.map((artifact) => (
|
||||
<li key={artifact.id} className="min-w-0">
|
||||
<GalleryTile artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <p className="px-1 py-16 text-center text-sm font-medium text-foreground">No artifacts in this stack match the current filters.</p>;
|
||||
}
|
||||
return (
|
||||
<div data-solid="gallery-stack" className="flex flex-col gap-6">
|
||||
<p className={COUNT_LINE}>
|
||||
<Counts lead={plural(group.count, "artifact")} of={group.artifacts} />
|
||||
</p>
|
||||
{flat() ? (
|
||||
<ul className={GRID}>
|
||||
{group.artifacts.map((artifact) => (
|
||||
<li key={artifact.id} className="min-w-0">
|
||||
<GalleryTile artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex flex-col gap-10">
|
||||
{daySections(group.artifacts).map((section) => (
|
||||
<Day key={section.id} id={`solid-gallery-day-${group.id}-${section.id}`} section={section} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div aria-hidden className="relative size-full overflow-hidden rounded-md bg-(--solid-sheet) p-2">
|
||||
<p className={cn(META_TEXT, "truncate font-medium text-foreground")}>{fact}</p>
|
||||
<KindDock artifact={artifact} size="sm" compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div aria-hidden className="relative size-full overflow-hidden rounded-md bg-(--solid-sheet) p-3 @2xs/unit:p-4">
|
||||
<p className="truncate text-xl font-medium tracking-tight text-foreground @2xs/unit:text-3xl">{fact}</p>
|
||||
{/* One line where the dock leaves room for one; two from 20rem of tile. */}
|
||||
<p className="mt-0.5 truncate text-sm font-medium text-(--solid-sheet-text) @xs/unit:line-clamp-2 @xs/unit:wrap-anywhere @xs/unit:whitespace-normal">
|
||||
{fileName(artifact)}
|
||||
</p>
|
||||
<KindDock artifact={artifact} size="plate" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
data-solid="gallery-preview"
|
||||
data-preview={kind}
|
||||
className={cn("relative overflow-hidden rounded-md", className ?? "aspect-(--solid-plate-ratio) w-full")}
|
||||
>
|
||||
{kind === "text" ? (
|
||||
<ExcerptSheet artifact={artifact} shape={cell ? "cell" : "plate"} className="size-full" />
|
||||
) : kind === "picture" ? (
|
||||
<span aria-hidden className="absolute inset-0 bg-(--solid-sheet)">
|
||||
<PlatePicture artifact={artifact} onError={() => 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" ? (
|
||||
<CornerTag size="sm">
|
||||
<Play className="size-2.5" fill="currentColor" />
|
||||
</CornerTag>
|
||||
) : null
|
||||
) : (
|
||||
<KindTag artifact={artifact} />
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<Facts artifact={artifact} cell={cell} note={failed ? "No preview" : undefined} />
|
||||
)}
|
||||
{state ? <StateMarker review={artifact.review} on="corner" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<a data-secondary href={action.href} {...externalProps(action)} aria-label={actionName(action, artifact)} title={action.label} className={ICON_BUTTON}>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryTile({ artifact }: { artifact: LabArtifact }) {
|
||||
const { primary, secondary } = galleryActions(artifact);
|
||||
const PrimaryIcon = primary.icon;
|
||||
return (
|
||||
<article
|
||||
data-solid="gallery-tile"
|
||||
data-kind={artifact.kind}
|
||||
className={cn("group/slab @container/unit relative flex h-full min-w-0 flex-col gap-2.5 p-3", SLAB, SLAB_HOVER)}
|
||||
>
|
||||
<GalleryPreview artifact={artifact} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="line-clamp-2 text-sm font-medium wrap-break-word text-foreground">{artifact.title}</h3>
|
||||
<footer className="mt-auto flex flex-wrap items-end justify-end gap-x-2 gap-y-1.5">
|
||||
<p className="flex min-w-0 grow basis-40 flex-col">
|
||||
<MetaLine artifact={artifact} className="truncate" />
|
||||
<span className={cn(META_TEXT, "flex min-w-0 text-(--solid-meta)")}>
|
||||
<time dateTime={artifact.updatedAt} className="shrink-0">
|
||||
<span className="sr-only">Last edited </span>
|
||||
{shortStamp(artifact.updatedLabel)}
|
||||
</time>
|
||||
<MetaDot />
|
||||
<span className="min-w-0 truncate font-sans">{artifact.author}</span>
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* First in the DOM so it is first in the tab order; last in the row. */}
|
||||
<a
|
||||
data-primary
|
||||
href={primary.href}
|
||||
aria-label={actionName(primary, artifact)}
|
||||
title={[actionName(primary, artifact), artifact.summary, `Last edited ${artifact.updatedLabel} · ${artifact.author}`].filter(Boolean).join("\n")}
|
||||
className={cn(BUTTON, BUTTON_PRIMARY, COVER, "order-last w-7 cursor-pointer px-0 after:cursor-pointer")}
|
||||
>
|
||||
<PrimaryIcon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
{secondary.map((action) => (
|
||||
<Ghost key={action.label} action={action} artifact={artifact} />
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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<FaceSize, string> = {
|
||||
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<FaceSize, string> = { 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<FaceSize, string | null> = {
|
||||
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<FaceSize, string> = { 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] ? <Glyph className={cn("absolute", GLYPH[size])} /> : null}
|
||||
<span className={cn(MONO_CAPS, LABEL[size], "leading-none")}>{blockLabel(artifact)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<img
|
||||
src={artifact.thumbnailUrl}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={onError}
|
||||
className={cn("absolute inset-0 size-full object-cover", artifact.kind === "link" ? "object-left-top" : null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={cn("absolute bottom-0 left-0 flex items-center gap-1 rounded-tr-md", INK, RING, TAG[size], MONO_CAPS, TAG_TEXT[size], "leading-none", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<CornerTag size={size}>
|
||||
{artifact.kind === "video" ? <Play className="size-2" fill="currentColor" /> : null}
|
||||
{compact ? <Glyph className="size-3 @3xs/unit:hidden" /> : null}
|
||||
<span className={compact ? "hidden @3xs/unit:inline" : undefined}>{blockLabel(artifact)}</span>
|
||||
</CornerTag>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? (
|
||||
<span data-kind-block="dock" className={cn(dock, "grid size-7 place-items-center @3xs/unit:hidden")}>
|
||||
<Glyph className="size-4" />
|
||||
</span>
|
||||
) : null}
|
||||
<span data-kind-block="dock" className={cn(dock, "items-end", compact ? "hidden @3xs/unit:flex" : "flex", BOX[size], PAD[size])}>
|
||||
<InkFace artifact={artifact} size={size} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span aria-hidden className={cn(box, "block bg-(--solid-sheet)")} data-kind-block="picture">
|
||||
<PlatePicture artifact={artifact} onError={() => 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" ? (
|
||||
<CornerTag size="sm">
|
||||
<Play className="size-2.5" fill="currentColor" />
|
||||
</CornerTag>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span aria-hidden className={cn(box, "flex items-end", INK, PAD[size])} data-kind-block="ink">
|
||||
<InkFace artifact={artifact} size={size} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 <span className="px-1">·</span>;
|
||||
}
|
||||
|
||||
/** 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) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? <wbr /> : null}
|
||||
{index < pieces.length - 1 ? `${piece}/` : piece}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className={cn(META_TEXT, "text-(--solid-meta)", className)}>
|
||||
{parts.map((part, index) =>
|
||||
wrap ? (
|
||||
// The separator travels with the part before it, so a wrapped line never starts with a dot.
|
||||
<Fragment key={part}>
|
||||
<span className={part.includes("/") ? "wrap-anywhere" : "whitespace-nowrap"}>
|
||||
{breakable(part)}
|
||||
{index < parts.length - 1 ? <MetaDot /> : null}
|
||||
</span>
|
||||
{index < parts.length - 1 ? <wbr /> : null}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment key={part}>
|
||||
{index > 0 ? <MetaDot /> : null}
|
||||
{part}
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<section data-solid="panel-section" aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||
<header className="flex flex-col px-1">
|
||||
<h3 id={headingId} className="truncate text-xs font-medium text-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className={cn(META_TEXT, "flex flex-wrap items-baseline justify-between gap-x-3 text-muted-foreground")}>
|
||||
<Counts lead={plural(section.items.length, section.noun)} of={section.items} />
|
||||
{section.startedLabel ? (
|
||||
<time dateTime={section.startedAt} title={section.startedLabel} className="shrink-0">
|
||||
{shortStamp(section.startedLabel)}
|
||||
</time>
|
||||
) : null}
|
||||
</p>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBody({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
if (artifacts.length === 0) {
|
||||
return <p className="px-1 py-6 text-sm text-muted-foreground">No artifacts yet. Work products, documents, and agent-produced files will appear here.</p>;
|
||||
}
|
||||
return (
|
||||
<div data-solid="panel-body" className="flex flex-col gap-5">
|
||||
<p className={cn(META_TEXT, "px-1 text-muted-foreground")}>
|
||||
<Counts lead={plural(artifacts.length, "artifact")} of={artifacts} />
|
||||
</p>
|
||||
{panelSections(artifacts).map((section) => (
|
||||
<Section key={section.id} section={section}>
|
||||
<ul className="grid grid-cols-(--solid-panel-cols) items-start gap-2">
|
||||
{section.items.map((artifact) => (
|
||||
<li key={artifact.id} className="min-w-0">
|
||||
<PanelCard artifact={artifact} section={section} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<LabArtifact["kind"]>(["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}
|
||||
<PrimaryIcon aria-hidden className="size-3.5" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article data-solid="panel-card" data-kind={artifact.kind} className={cn("flex min-w-0 flex-col gap-2.5 p-3", SLAB)}>
|
||||
{leads ? <PanelLead artifact={artifact} onPictureError={() => setPictureFailed(true)} /> : null}
|
||||
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{leads ? null : <KindBlock artifact={artifact} size="sm" ink />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-medium wrap-break-word text-foreground">{artifact.title}</h4>
|
||||
{artifact.summary ? <p className="mt-0.5 text-xs wrap-break-word text-(--solid-meta)">{artifact.summary}</p> : null}
|
||||
<p className="mt-1 flex flex-col">
|
||||
<MetaLine artifact={artifact} wrap />
|
||||
{artifact.filename ? <span className={mono}>{artifact.filename}</span> : null}
|
||||
{FILE_KINDS.has(artifact.kind) && artifact.contentType ? <span className={mono}>{artifact.contentType}</span> : null}
|
||||
{hasThumbnail(artifact) && pictureFailed ? <span className={mono}>No preview available</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What waits for review is given room to be read here; the rest shows enough to be told apart. */}
|
||||
{hasExcerpt(artifact) ? <ExcerptSheet artifact={artifact} shape="drawer" lines={waiting ? 6 : 3} /> : null}
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
{/* State, then who and when. The line breaks between the two when the row is short of room, never inside either. */}
|
||||
<div className="flex min-w-0 grow basis-28 flex-wrap items-center gap-x-2.5 gap-y-0.5">
|
||||
<StateMarker review={artifact.review} />
|
||||
<p className={cn(META_TEXT, "min-w-0 text-(--solid-meta)")}>
|
||||
{showAuthor ? (
|
||||
<span className="inline-block max-w-full truncate align-bottom font-sans">
|
||||
{artifact.author}
|
||||
<MetaDot />
|
||||
</span>
|
||||
) : (
|
||||
<span className="sr-only">{artifact.author}, </span>
|
||||
)}
|
||||
<wbr />
|
||||
<time dateTime={artifact.updatedAt} title={artifact.updatedLabel} className="whitespace-nowrap">
|
||||
{stampUnder(artifact.updatedLabel, section.startedLabel)}
|
||||
</time>
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{primary.href ? (
|
||||
<a href={primary.href} {...externalProps(primary)} {...button}>
|
||||
{face}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" {...button}>
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
{artifact.commentCount != null ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${plural(artifact.commentCount, "comment")}: ${artifact.title}`}
|
||||
title={plural(artifact.commentCount, "comment")}
|
||||
className={cn(COUNT_BUTTON, artifact.commentCount > 0 ? "text-foreground" : null)}
|
||||
>
|
||||
<MessageSquare aria-hidden className="size-3.5" />
|
||||
{artifact.commentCount}
|
||||
</button>
|
||||
) : null}
|
||||
<PanelMenu actions={more} artifact={artifact} />
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLVideoElement>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
return (
|
||||
<div data-solid="panel-lead" data-lead="video" className={LEAD}>
|
||||
<video
|
||||
ref={video}
|
||||
src={artifact.videoUrl}
|
||||
poster={artifact.thumbnailUrl}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
controls={started}
|
||||
aria-label={artifact.title}
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
{started ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Play video: ${artifact.title}`}
|
||||
title="Play video"
|
||||
onClick={() => {
|
||||
setStarted(true);
|
||||
void video.current?.play();
|
||||
}}
|
||||
className="absolute inset-0 cursor-pointer rounded-md focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-(--solid-ink)"
|
||||
>
|
||||
<KindTag artifact={artifact} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelLead({ artifact, onPictureError }: { artifact: LabArtifact; onPictureError: () => void }) {
|
||||
if (artifact.kind === "video" && artifact.videoUrl) return <VideoLead artifact={artifact} />;
|
||||
return (
|
||||
<div aria-hidden data-solid="panel-lead" data-lead="picture" className={LEAD}>
|
||||
<PlatePicture artifact={artifact} onError={onPictureError} />
|
||||
<KindTag artifact={artifact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger data-secondary aria-label={`More actions: ${artifact.title}`} title="More actions" className={ICON_BUTTON}>
|
||||
<Ellipsis aria-hidden className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
const face = (
|
||||
<>
|
||||
<Icon aria-hidden />
|
||||
{action.label}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem key={action.label} asChild>
|
||||
{action.href ? (
|
||||
<a href={action.href} {...externalProps(action)}>
|
||||
{face}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" className="w-full">
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <span aria-hidden data-solid="pile-layer" className={cn(LAYER, RISE_2, "inset-x-8 top-0 bg-(--solid-pile-2)")} /> : null}
|
||||
<span aria-hidden data-solid="pile-layer" className={cn(LAYER, RISE_1, "inset-x-3 top-3 bg-(--solid-pile-1)")}>
|
||||
{/* The slab's visible edge is 16px: one line of mono caps, cut clean where the pile runs out of room. */}
|
||||
<span className="block h-4 truncate px-3 font-mono text-(length:--text-nano) leading-4 font-medium tracking-(--tracking-label) text-(--solid-meta) uppercase">
|
||||
{kindTally(group.artifacts).map((entry, index) => (
|
||||
<span key={entry}>
|
||||
{index > 0 ? <MetaDot /> : null}
|
||||
{entry}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The corner bite of the mosaic: how many artifacts it leaves out, in mono. */
|
||||
function MoreTab({ count }: { count: number }) {
|
||||
return (
|
||||
<span aria-hidden className={cn(META_TEXT, CORNER_NOTCH, "px-2 pt-1 pb-0.5 font-medium text-foreground")}>
|
||||
+{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Mosaic({ group }: { group: LabGroup }) {
|
||||
const [lead, ...rest] = stackPreviews(group);
|
||||
if (!lead) return null;
|
||||
if (rest.length === 0) return <GalleryPreview artifact={lead} state={false} />;
|
||||
const more = group.count - 1 - rest.length;
|
||||
return (
|
||||
<div data-solid="stack-mosaic" className="relative grid aspect-(--solid-plate-ratio) w-full grid-cols-3 grid-rows-2 gap-1 overflow-hidden rounded-md">
|
||||
<div className="col-span-2 row-span-2 min-h-0 min-w-0">
|
||||
<GalleryPreview artifact={lead} state={false} className="size-full" />
|
||||
</div>
|
||||
{rest.map((artifact) => (
|
||||
<div key={artifact.id} className={cn("min-h-0 min-w-0", rest.length === 1 ? "row-span-2" : null)}>
|
||||
<GalleryPreview artifact={artifact} scale="cell" state={false} className="size-full" />
|
||||
</div>
|
||||
))}
|
||||
{more > 0 ? <MoreTab count={more} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
<article data-solid="stack-tile" data-count={group.count} data-pile={depth} className="group/pile relative flex h-full min-w-0 flex-col pt-7">
|
||||
<Pile group={group} depth={depth} />
|
||||
<div className={cn("group/slab @container/unit relative flex min-w-0 flex-1 flex-col gap-2.5 p-3", SLAB, SLAB_HOVER)}>
|
||||
<Mosaic group={group} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
{/* h2: on the stacks page a stack sits directly under the page title. */}
|
||||
<h2 className="line-clamp-2 text-sm font-medium wrap-break-word text-foreground">
|
||||
<span className={cn(META_TEXT, "mr-2 font-normal text-(--solid-meta)")}>{group.taskIdentifier}</span>
|
||||
{group.title}
|
||||
</h2>
|
||||
<footer className="mt-auto flex items-end gap-2">
|
||||
{/* Always two lines — what is in it, then when — so the feet of a row line up; a long count wraps inside its own line. */}
|
||||
<p className={cn(META_TEXT, "flex min-w-0 flex-1 flex-col text-(--solid-meta)")}>
|
||||
<Counts lead={count} of={group.artifacts} />
|
||||
<span className="truncate">Updated {group.updatedLabel}</span>
|
||||
</p>
|
||||
<a
|
||||
data-primary
|
||||
href={`#stack-${group.taskIdentifier}`}
|
||||
aria-label={`Open stack ${group.taskIdentifier}: ${group.title}, ${count}`}
|
||||
title={`Open stack ${group.taskIdentifier}: ${group.title}\n${count} · Updated ${group.updatedLabel}`}
|
||||
// The cover reaches up over the pile, so the whole object is one target.
|
||||
className={cn(BUTTON, BUTTON_PRIMARY, COVER, "w-7 cursor-pointer px-0 after:-top-7 after:cursor-pointer")}
|
||||
>
|
||||
<ArrowRight aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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 <span aria-hidden className={cn("inline-block size-1.5 shrink-0 rounded-full", STATE[state].dot, className)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span
|
||||
data-state={review}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 text-(length:--text-micro) font-medium leading-none whitespace-nowrap text-foreground",
|
||||
on === "corner" ? cn(CORNER_NOTCH, "h-6 px-2") : null,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<StateDot state={review} />
|
||||
<span className={collapse ? "sr-only @xl:not-sr-only" : undefined}>{STATE[review].label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<>
|
||||
<KindBlock artifact={artifact} size="lg" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
||||
<span className="line-clamp-2 shrink-0 text-xs font-medium wrap-anywhere text-foreground">{name}</span>
|
||||
<MetaLine artifact={artifact} max={2} wrap className="line-clamp-2 shrink-0" />
|
||||
{/* The one place a state label may take two lines: the column beside a picture is narrow. */}
|
||||
<StateMarker review={artifact.review} className="mt-auto items-baseline leading-4 whitespace-normal" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<KindBlock artifact={artifact} size="xs" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-foreground">{name}</span>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{/* A long state takes the size's place; the kind always stays. */}
|
||||
<MetaLine artifact={artifact} max={artifact.review === "changes_requested" ? 1 : 2} className="min-w-0 flex-1 truncate" />
|
||||
<StateMarker review={artifact.review} />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return opensGallery ? (
|
||||
<button type="button" {...shared}>
|
||||
{face}
|
||||
</button>
|
||||
) : (
|
||||
<a href={artifact.kind === "link" ? (artifact.url ?? "#") : `#attachment-${artifact.id}`} {...(artifact.kind === "link" ? { target: "_blank", rel: "noreferrer" } : null)} {...shared}>
|
||||
{face}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section data-solid="thread-attachments" aria-label="Attachments" className="flex w-full flex-col gap-2 py-1">
|
||||
<p className={cn(META_TEXT, "px-1 text-muted-foreground")}>
|
||||
<Counts lead={plural(artifacts.length, "attachment")} parts={counts} of={artifacts} />
|
||||
</p>
|
||||
<ul className="grid grid-flow-row-dense auto-rows-(--solid-attach-row) grid-cols-(--solid-attach-cols) gap-2">
|
||||
{ordered.map((artifact) => (
|
||||
<li key={artifact.id} className={cn("min-w-0", hasThumbnail(artifact) ? "row-span-2" : null)}>
|
||||
<AttachmentSlab artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
<span className="hidden @md:inline">{action.label}</span>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
data-solid="thread-row"
|
||||
data-kind={artifact.kind}
|
||||
className={cn("group/slab @container relative flex min-w-0 items-center gap-3 p-2 pr-3", SLAB, SLAB_HOVER)}
|
||||
>
|
||||
<KindBlock artifact={artifact} size="sm" />
|
||||
<div className="min-w-32 flex-1">
|
||||
<p className="line-clamp-2 text-sm font-medium wrap-break-word text-foreground @3xl:line-clamp-1">{artifact.title}</p>
|
||||
<p className="mt-0.5 flex min-w-0 items-baseline">
|
||||
<MetaLine artifact={artifact} max={echoed ? metaParts(artifact).length - 1 : undefined} className="min-w-0 truncate @3xl:shrink-0" />
|
||||
{artifact.summary ? (
|
||||
<span className="hidden min-w-0 truncate text-xs text-(--solid-meta) @3xl:inline">
|
||||
<span aria-hidden className="px-2">
|
||||
—
|
||||
</span>
|
||||
{artifact.summary}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{artifact.summary ? <p className="mt-0.5 line-clamp-2 text-xs text-(--solid-meta) @3xl:hidden">{artifact.summary}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{action.href ? (
|
||||
<a href={action.href} {...externalProps(action)} {...primary}>
|
||||
{face}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" {...primary}>
|
||||
{face}
|
||||
</button>
|
||||
)}
|
||||
<StateMarker review={artifact.review} collapse />
|
||||
{download && DownloadIcon ? (
|
||||
<a data-secondary href={download.href} aria-label={actionName(download, artifact)} title={download.label} className={ICON_BUTTON}>
|
||||
<DownloadIcon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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<PreviewKind, number> = { 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<string, number>();
|
||||
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<string, DaySection>();
|
||||
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}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<LabKind, KindSpec> = {
|
||||
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";
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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<string, LabArtifact[]>();
|
||||
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);
|
||||
}
|
||||
@@ -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`;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
<p data-excerpt className={cn("wrap-anywhere text-(--tint-ink-2)", className)}>
|
||||
{lines.map((line, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? <br /> : null}
|
||||
{line.heading ? <span className="font-medium text-foreground">{line.text}</span> : line.item ? <>– {line.text}</> : line.text}
|
||||
</Fragment>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** How many monospace cells fit across an element, re-measured when it resizes. */
|
||||
function useCells<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [fit, setFit] = useState<{ cells: number; width: number } | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const probe = document.createElement("span");
|
||||
probe.textContent = "0000000000";
|
||||
probe.className = "invisible absolute whitespace-pre";
|
||||
el.append(probe);
|
||||
const cell = probe.getBoundingClientRect().width / 10;
|
||||
probe.remove();
|
||||
if (cell > 0) setFit({ cells: Math.floor(el.clientWidth / cell), width: el.clientWidth });
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
return [ref, fit] as const;
|
||||
}
|
||||
|
||||
const FIRST_CELL = "w-full max-w-0 truncate pr-2";
|
||||
const VALUE_CELL = "whitespace-nowrap pl-3 text-right";
|
||||
|
||||
export function SheetTable({
|
||||
artifact,
|
||||
rows,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
/** Rows to show: a number, or a function of the measured width in px (a fixed-height sheet shows fewer when it is narrow). */
|
||||
rows: number | ((width: number) => number);
|
||||
/** Type size and leading of the table. */
|
||||
className?: string;
|
||||
}) {
|
||||
const [ref, fit] = useCells<HTMLDivElement>();
|
||||
const maxRows = typeof rows === "number" ? rows : rows(fit?.width ?? 0);
|
||||
// Before the first measurement only the name column is laid out, so nothing can spill for a frame.
|
||||
const preview = sheetPreview(artifact, maxRows, fit?.cells ?? 0);
|
||||
if (!preview) return null;
|
||||
const more = [
|
||||
preview.more > 0 ? `+${preview.more} more row${preview.more === 1 ? "" : "s"}` : null,
|
||||
preview.moreColumns > 0 ? `+${preview.moreColumns} more column${preview.moreColumns === 1 ? "" : "s"}` : null,
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<div ref={ref} data-excerpt className={cn("relative min-w-0 overflow-hidden font-mono", className)}>
|
||||
<table className="w-full border-collapse text-left">
|
||||
<thead>
|
||||
<tr className="text-(--tint-ink-2)">
|
||||
{preview.header.map((cell, index) => (
|
||||
<th key={index} scope="col" className={cn("font-normal", index === 0 ? FIRST_CELL : VALUE_CELL)}>
|
||||
{cell}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground">
|
||||
{preview.rows.map((row, r) => (
|
||||
<tr key={r}>
|
||||
{row.map((cell, index) => (
|
||||
<td key={index} className={index === 0 ? FIRST_CELL : VALUE_CELL}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{more.length > 0 ? <p className="truncate text-(--tint-ink-2)">{more.join(" · ")}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeLines({
|
||||
artifact,
|
||||
lines,
|
||||
step,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
lines: number;
|
||||
/** Per-line classes, by index — a fixed-height sheet hides its last lines in a narrow unit instead of slicing one. */
|
||||
step?: readonly string[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (!artifact.excerpt) return null;
|
||||
return (
|
||||
<pre data-excerpt className={cn("overflow-hidden font-mono text-(--tint-ink-2)", className)}>
|
||||
{artifact.excerpt
|
||||
.split("\n")
|
||||
.slice(0, lines)
|
||||
.map((line, index) => (
|
||||
<span key={index} className={cn("block overflow-hidden text-ellipsis whitespace-pre", step?.[index])}>
|
||||
{line || " "}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span data-filename className={cn("flex min-w-0", mono ? "font-mono" : null, className)}>
|
||||
<span className="truncate">{stem}</span>
|
||||
{ext ? <span className="shrink-0">{ext}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<PreviewDepth, string> = { 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<PreviewDepth, number> = { 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 (
|
||||
<FoldedSheet artifact={artifact} fold="lg" className={cn(BOX, className)}>
|
||||
{artifact.kind === "document" ? (
|
||||
<ProseExcerpt artifact={artifact} maxChars={depth === "tile" ? 320 : 160} className={cn(TEXT, CLAMP[depth])} />
|
||||
) : artifact.kind === "sheet" ? (
|
||||
<SheetTable artifact={artifact} rows={depth === "tile" ? rowsThatFit : 2} className={MONO} />
|
||||
) : (
|
||||
<CodeLines artifact={artifact} lines={CODE_LINES[depth]} step={depth === "tile" ? CODE_STEP : undefined} className={MONO} />
|
||||
)}
|
||||
</FoldedSheet>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<KindWell
|
||||
artifact={artifact}
|
||||
size={depth === "tile" ? "lg" : "md"}
|
||||
// Below a 20rem tile the glyph steps down to md, so the words keep their room.
|
||||
glyphClassName={depth === "tile" ? "@max-xs:h-16 @max-xs:w-14" : undefined}
|
||||
className={cn(BOX, "gap-4 bg-(--kind-well-lg) px-5 @max-xs:gap-3 @max-xs:px-3.5", words ? "justify-start" : null, className)}
|
||||
>
|
||||
{words ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* A count is a machine value: the mono voice, at headline size. */}
|
||||
{headline ? <p data-headline className={cn("truncate font-mono font-medium tracking-tight text-foreground", depth === "tile" ? "text-lg leading-6 @max-xs:text-sm @max-xs:leading-5" : "text-sm leading-5")}>{headline}</p> : null}
|
||||
{artifact.summary ? (
|
||||
<p data-summary className={cn("mt-0.5 wrap-anywhere text-sm leading-5 text-foreground", depth === "tile" ? "line-clamp-2" : "line-clamp-1")}>
|
||||
{artifact.summary}
|
||||
</p>
|
||||
) : null}
|
||||
{artifact.filename ? (
|
||||
<FileName name={artifact.filename} className={small} />
|
||||
) : address ? (
|
||||
<span data-address className={cn("block truncate font-mono", small)}>
|
||||
{address}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</KindWell>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Sheet artifact={artifact} depth={depth} className={className} />;
|
||||
if (kind === "named") return <Named artifact={artifact} depth={depth} className={className} />;
|
||||
return (
|
||||
<FoldedPreview artifact={artifact} fold="lg" className={cn(BOX, className)} onError={onError}>
|
||||
{artifact.kind === "video" && artifact.durationLabel ? <span className={DURATION}>{artifact.durationLabel}</span> : null}
|
||||
</FoldedPreview>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div data-stack-caption className="flex min-w-0 flex-wrap items-center gap-x-5 gap-y-2 px-1">
|
||||
<KindTally artifacts={group.artifacts} named />
|
||||
<ReviewTally count={countReview(group.artifacts)} />
|
||||
<MetaLine wrap parts={[artifactCount(group.count), `Updated ${group.updatedLabel}`]} className="ml-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ section }: { section: StackSection }) {
|
||||
const headingId = useId();
|
||||
return (
|
||||
<section data-section={section.spansDays ? "days" : "day"} aria-labelledby={headingId} className="flex flex-col gap-3">
|
||||
<header className="flex min-w-0 items-baseline gap-2.5 px-1">
|
||||
<h3 id={headingId} className="truncate text-sm font-medium leading-5 text-foreground">
|
||||
{section.label}
|
||||
</h3>
|
||||
<span className="shrink-0 font-mono text-(length:--text-micro) leading-5 text-(--tint-ink-2)">{artifactCount(section.artifacts.length)}</span>
|
||||
</header>
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{section.artifacts.map((artifact) => (
|
||||
<GalleryTile key={artifact.id} artifact={artifact} dated={section.spansDays} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryStack({ group }: { group: LabGroup }) {
|
||||
if (group.artifacts.length === 0) {
|
||||
// The shipped empty state's copy.
|
||||
return <p className="py-16 text-center text-sm font-medium text-foreground">No artifacts in this stack match the current filters.</p>;
|
||||
}
|
||||
return (
|
||||
<div data-surface="gallery-stack" className="flex flex-col gap-6">
|
||||
<Caption group={group} />
|
||||
{stackSections(group.artifacts).map((section) => (
|
||||
<Section key={section.id} section={section} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <p className="py-16 text-center text-sm font-medium text-foreground">No artifact stacks yet.</p>;
|
||||
}
|
||||
return (
|
||||
<ul data-surface="gallery-stacks" className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map((group) => (group.count > 1 ? <StackFolder key={group.id} group={group} /> : <StackSingle key={group.id} group={group} />))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<li data-kind={artifact.kind} data-unit="gallery-tile" className={cn("group/unit @container relative flex min-w-0 flex-col p-2", SLAB)}>
|
||||
<GalleryPreview artifact={artifact} kind={kind} onError={onError} />
|
||||
<div className="flex min-w-0 flex-col px-1.5 pt-2.5">
|
||||
<h4 className="truncate text-sm font-medium leading-5 text-foreground">
|
||||
<TitleAction action={galleryAction(artifact)} stretch="after:rounded-lg" title={artifact.title}>
|
||||
{artifact.title}
|
||||
</TitleAction>
|
||||
</h4>
|
||||
{/* Every tile keeps the height of a state mark here, so a row of tiles stays level whether or not one is waiting. */}
|
||||
<div className="mt-0.5 flex h-5.5 min-w-0 items-center gap-2">
|
||||
<MetaLine parts={metaParts(artifact, { facts: true, omit: stated })} className="min-w-0 flex-1" />
|
||||
<StateMark review={artifact.review} />
|
||||
</div>
|
||||
<footer className="mt-1 flex h-7 min-w-0 items-center gap-2">
|
||||
<p data-byline className="flex min-w-0 flex-1 items-baseline text-xs leading-4 text-(--tint-ink-2)">
|
||||
<span className="truncate">{artifact.author}</span>
|
||||
<span className="mx-(--tint-meta-gap) shrink-0">·</span>
|
||||
<time dateTime={artifact.updatedAt} className="shrink-0 font-mono text-(length:--text-micro)">
|
||||
{dated ? shortDateTime(artifact.updatedLabel, artifact.updatedAt) : timeOnly(artifact.updatedLabel)}
|
||||
</time>
|
||||
</p>
|
||||
{secondary.length > 0 ? (
|
||||
<div className="-mr-1.5 flex shrink-0 items-center">
|
||||
{secondary.map((action, index) => (
|
||||
<SecondaryAction key={`${action.intent}-${index}`} action={action} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</footer>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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<GlyphSize, Spec> = {
|
||||
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<string, number> = { 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 (
|
||||
<svg
|
||||
viewBox={`0 0 ${spec.w} ${spec.h}`}
|
||||
aria-hidden
|
||||
focusable="false"
|
||||
data-glyph={size}
|
||||
className={cn("block shrink-0 text-(--kind-ink)", spec.box, className)}
|
||||
>
|
||||
<path d={tilePath(spec)} className="fill-(--kind)" />
|
||||
<path d={facetPath(spec)} className="fill-(--kind-facet)" />
|
||||
<Pictogram kind={kind} {...(text ? spec.picto : spec.solo)} />
|
||||
{text && spec.label ? (
|
||||
<text
|
||||
x={spec.w / 2}
|
||||
y={spec.label.baseline}
|
||||
textAnchor="middle"
|
||||
fontSize={spec.label.size}
|
||||
fill="currentColor"
|
||||
className="font-sans font-bold"
|
||||
{...(squeeze ? { textLength: room, lengthAdjust: "spacingAndGlyphs" as const } : null)}
|
||||
>
|
||||
{text}
|
||||
</text>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ul data-tally-kinds aria-label="Contents by kind" className={cn("flex min-w-0 flex-wrap items-center gap-y-1", named ? "gap-x-3.5" : "gap-x-2.5", className)}>
|
||||
{kinds.map(({ kind, count }) => (
|
||||
<li key={kind} data-kind={kind} className="flex shrink-0 items-center gap-1">
|
||||
<KindGlyph kind={kind} size="mini" />
|
||||
<span className="font-mono text-(length:--text-micro) leading-none text-(--tint-ink-2)">
|
||||
{count}
|
||||
{named ? <span className="font-sans text-xs leading-none"> {kindNounInline(kind, count)}</span> : <span className="sr-only"> {kindNoun(kind, count)}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -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<FoldSize, { clip: string; flap: string; tab: string; play: string; sheet: string; room: Record<"bottom" | "top", string> }> = {
|
||||
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 (
|
||||
<svg viewBox="0 0 12 12" aria-hidden focusable="false" className={cn("pointer-events-none absolute right-0 top-0", className)}>
|
||||
<path d="M0 0 12 12H3.2Q0 12 0 8.8Z" className="fill-(--kind)" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ artifact, fold, edge }: { artifact: LabArtifact; fold: FoldSize; edge: TabEdge }) {
|
||||
const label = glyphLabel(artifact);
|
||||
if (!label || edge === "none") return null;
|
||||
return <span className={cn("pointer-events-none absolute left-0 bg-(--kind) font-sans font-bold text-(--kind-ink)", TAB_EDGE[edge], FOLD[fold].tab)}>{label}</span>;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"absolute left-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-(--tint-scrim) text-(--tint-on-kind)",
|
||||
FOLD[fold].play,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="size-1/2 translate-x-(--tint-play-nudge)">
|
||||
<path d="M7 4.6 19.4 12 7 19.4Z" fill="currentColor" stroke="currentColor" strokeWidth="3.4" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span data-lead="preview" className={cn("relative block shrink-0 overflow-hidden rounded-(--tint-radius-glyph) bg-(--tint-well)", className)} style={{ clipPath: FOLD[fold].clip }}>
|
||||
<img src={artifact.thumbnailUrl} alt="" loading="lazy" onError={onError} className={cn("absolute inset-0 size-full", fit === "cover" ? crop : "object-contain")} />
|
||||
{artifact.kind === "video" && play ? <PlayMark fold={fold} /> : null}
|
||||
{children}
|
||||
<FoldFlap className={FOLD[fold].flap} />
|
||||
<Tab artifact={artifact} fold={fold} edge={tab} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div data-lead="sheet" className={cn("relative min-w-0 overflow-hidden rounded-(--tint-radius-glyph) bg-(--tint-well)", spec.sheet, tabbed, className)} style={{ clipPath: spec.clip }}>
|
||||
{children}
|
||||
<FoldFlap className={spec.flap} />
|
||||
<Tab artifact={artifact} fold={fold} edge={tab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div data-lead="well" className={cn("flex min-w-0 items-center justify-center gap-3 rounded-(--tint-radius-glyph) bg-(--kind-well)", className)}>
|
||||
<KindGlyph kind={artifact.kind} label={glyphLabel(artifact)} size={size} className={glyphClassName} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 <KindWell artifact={artifact} size="xs" className={ROW_BOX} />;
|
||||
return <FoldedPreview artifact={artifact} fold="sm" className={ROW_BOX} onError={() => setFailed(true)} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<span data-meta className={cn("block font-mono text-(length:--text-micro) leading-4 text-(--tint-ink-2)", wrap ? "min-w-0" : "truncate", className)}>
|
||||
{parts.map((part, index) => (
|
||||
<Fragment key={index}>
|
||||
{/* A full monospace " · " is three cells wide; the dot gets half-cell margins instead. */}
|
||||
{index > 0 ? <span className="mx-(--tint-meta-gap)">·</span> : null}
|
||||
{/* When wrapping, a part never breaks inside itself; the line breaks after a dot. */}
|
||||
{index > 0 && wrap ? <wbr /> : null}
|
||||
<span className={wrap ? "whitespace-nowrap" : undefined}>{part}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<PanelFilter>("all");
|
||||
const sections = useMemo(() => buildSections(artifacts, filter), [artifacts, filter]);
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
// The shipped empty state, word for word.
|
||||
return <p className="px-1 py-6 text-sm text-(--tint-ink-2)">No artifacts yet. Work products, documents, and agent-produced files will appear here.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-surface="panel" className="@container/panel flex flex-col gap-5">
|
||||
<PanelIndex artifacts={artifacts} filter={filter} onFilter={setFilter} />
|
||||
{sections.map((section) => (
|
||||
<PanelSection key={section.id} section={section} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? <wbr /> : null}
|
||||
{part}
|
||||
</Fragment>
|
||||
));
|
||||
}
|
||||
|
||||
function Byline({ artifact, grouped }: { artifact: LabArtifact; grouped: boolean }) {
|
||||
return (
|
||||
<p data-byline className="flex min-w-0 flex-auto flex-col text-xs leading-4 text-(--tint-ink-2)">
|
||||
{/* Inside a run section the agent is the section's heading; only the edit time is the card's own. */}
|
||||
{grouped ? null : <span className="truncate">{artifact.author}</span>}
|
||||
<time dateTime={artifact.updatedAt} className="truncate font-mono text-(length:--text-micro)">
|
||||
{shortDateTime(artifact.updatedLabel, artifact.updatedAt)}
|
||||
</time>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<li data-kind={artifact.kind} data-unit="panel-card" data-lead-kind={lead} className={cn("group/unit @container relative flex min-w-0 flex-col gap-2 p-2", SLAB)}>
|
||||
{lead === "picture" ? <PanelPicture artifact={artifact} onError={() => setFailed(true)} /> : lead === "sheet" ? <PanelSheet artifact={artifact} /> : null}
|
||||
<div className={cn("flex min-w-0 gap-3", shown ? "px-1.5" : null)}>
|
||||
{lead === "named" ? <PanelNamed artifact={artifact} /> : null}
|
||||
<div className="min-w-0 flex-1 py-0.5">
|
||||
{/* Long titles wrap rather than truncate, as the shipped card's do; a third line in a narrow pane. */}
|
||||
<h4 className="line-clamp-2 wrap-anywhere text-sm font-medium leading-5 text-foreground @max-xs:line-clamp-3">{artifact.title}</h4>
|
||||
{/* The state mark shares the meta line when both fit and drops under it when they do not; neither truncates the other. */}
|
||||
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<MetaLine parts={meta} wrap className="max-w-full" />
|
||||
<StateMark review={artifact.review} className="ml-auto" />
|
||||
</div>
|
||||
{artifact.summary ? (
|
||||
<p data-summary className="mt-1 line-clamp-2 wrap-anywhere text-xs leading-4 text-foreground">
|
||||
{artifact.summary}
|
||||
</p>
|
||||
) : null}
|
||||
{filename ? (
|
||||
<FileName name={filename} className={cn(SMALL, COPYABLE)} />
|
||||
) : address ? (
|
||||
<span data-address className={cn("block truncate font-mono", SMALL, COPYABLE)}>
|
||||
{address}
|
||||
</span>
|
||||
) : null}
|
||||
{showsMime(artifact) ? (
|
||||
<span data-mime className={cn("block wrap-anywhere font-mono", SMALL, "mt-0", COPYABLE)}>
|
||||
{breakable(artifact.contentType ?? "")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* `mt-auto`: in a wide pane the cards sit two or three to a row and share its height. */}
|
||||
<footer className={cn("mt-auto flex min-w-0 flex-wrap items-center gap-2", shown ? "pl-1.5" : "pl-1")}>
|
||||
<Byline artifact={artifact} grouped={grouped} />
|
||||
{/* If the byline and the actions do not fit one row (a narrow pane), the actions wrap under it, still at the right. */}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
{secondary.map((action) => (
|
||||
<SecondaryAction
|
||||
key={action.intent}
|
||||
action={action}
|
||||
count={action.intent === "comments" ? artifact.commentCount : undefined}
|
||||
// A Markdown file carries four of these beside its button; 26px each keeps the row on one line at 434px.
|
||||
className={action.intent === "comments" ? undefined : "w-6.5"}
|
||||
/>
|
||||
))}
|
||||
<ButtonAction action={primary} stretch="after:rounded-lg" className={secondary.length > 0 ? "ml-1" : undefined} />
|
||||
</div>
|
||||
</footer>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
<button type="button" aria-pressed={pressed} aria-label={name} data-kind={kind} data-chip onClick={onPress} className={cn(CHIP, pressed ? PRESSED : REST, className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 }) => <span className="font-mono text-(length:--text-micro) leading-none">{value}</span>;
|
||||
|
||||
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 (
|
||||
<div role="group" aria-label="Filter artifacts" data-index className="flex flex-wrap items-center gap-1">
|
||||
<Chip pressed={filter === "all"} onPress={() => onFilter("all")} name={`All ${artifacts.length} artifacts`}>
|
||||
All <Count value={artifacts.length} />
|
||||
</Chip>
|
||||
{review > 0 ? (
|
||||
<Chip pressed={filter === "review"} onPress={() => toggle("review")} name={`Review ${review} waiting`}>
|
||||
<StateDot review="needs_review" />
|
||||
Review <Count value={review} />
|
||||
</Chip>
|
||||
) : null}
|
||||
{shown.map(({ kind, count }) => {
|
||||
const pressed = filter === kind;
|
||||
return (
|
||||
// In a narrow pane only the pressed kind and an opened row keep their chips.
|
||||
<Chip key={kind} kind={kind} pressed={pressed} onPress={() => toggle(kind)} name={`${kindNoun(kind, count)} ${count}`} className={cn("gap-1 px-1.5", pressed || open ? null : "@max-xs/panel:hidden")}>
|
||||
<KindGlyph kind={kind} size="mini" />
|
||||
{pressed ? kindNoun(kind, count) : null} <Count value={count} />
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
{open ? null : (
|
||||
<>
|
||||
{behind > 0 ? (
|
||||
<Chip onPress={() => setOpen(true)} name={`+${behind} more kinds`} className="@max-xs/panel:hidden">
|
||||
+{behind}
|
||||
</Chip>
|
||||
) : null}
|
||||
<Chip onPress={() => setOpen(true)} name={`+${kinds.length} kinds`} className="hidden @max-xs/panel:inline-flex">
|
||||
+{kinds.length} kinds
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <video data-lead="player" src={artifact.videoUrl} poster={artifact.thumbnailUrl} controls autoPlay playsInline className={cn("relative z-10 rounded-(--tint-radius-glyph) bg-(--tint-well) object-contain", box)} />;
|
||||
}
|
||||
return (
|
||||
<FoldedPreview artifact={artifact} fold="lg" fit="contain" play={false} className={cn(box, video ? "z-10" : null)} onError={onError}>
|
||||
{video ? (
|
||||
<button type="button" aria-label={`Play video: ${artifact.title}`} onClick={() => setPlaying(true)} className={cn("group/play absolute inset-0 cursor-pointer", PLAY_RING)}>
|
||||
{/* The disc is the button's visible state: it darkens under the pointer and grows while pressed. */}
|
||||
<PlayMark fold="lg" className={cn("group-hover/play:bg-(--tint-scrim-strong) group-active/play:scale-95", MOTION)} />
|
||||
{artifact.durationLabel ? <span className={DURATION}>{artifact.durationLabel}</span> : null}
|
||||
</button>
|
||||
) : null}
|
||||
</FoldedPreview>
|
||||
);
|
||||
}
|
||||
|
||||
const MONO = "text-(length:--text-micro) leading-4.5";
|
||||
|
||||
export function PanelSheet({ artifact }: { artifact: LabArtifact }) {
|
||||
return (
|
||||
<FoldedSheet artifact={artifact} fold="md">
|
||||
{artifact.kind === "document" ? (
|
||||
<ProseExcerpt artifact={artifact} maxChars={260} className="line-clamp-5 text-xs leading-4.5" />
|
||||
) : artifact.kind === "sheet" ? (
|
||||
<SheetTable artifact={artifact} rows={3} className={MONO} />
|
||||
) : (
|
||||
<CodeLines artifact={artifact} lines={5} className={MONO} />
|
||||
)}
|
||||
</FoldedSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 <KindWell artifact={artifact} size="md" className="min-h-21 w-28 shrink-0 self-stretch @max-xs:min-h-15 @max-xs:w-20" glyphClassName="@max-xs:h-11.5 @max-xs:w-10" />;
|
||||
}
|
||||
@@ -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 data-section={section.run ? "run" : "documents"} aria-labelledby={headingId} className="flex flex-col gap-2">
|
||||
<header className="flex min-w-0 items-start gap-3 px-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 id={headingId} className="wrap-anywhere text-xs font-medium leading-4 text-foreground">
|
||||
{section.title}
|
||||
</h3>
|
||||
<MetaLine wrap parts={[when, `${count} ${count === 1 ? "artifact" : "artifacts"}`]} />
|
||||
</div>
|
||||
<ReviewTally count={section.reviewCount} className="mt-2" />
|
||||
</header>
|
||||
{/* One column in the docked pane; two and three when the pane is maximized (the product keeps one, stretched). */}
|
||||
<ul className="grid grid-cols-1 gap-2 @3xl/panel:grid-cols-2 @6xl/panel:grid-cols-3">
|
||||
{section.artifacts.map((artifact) => (
|
||||
<PanelCard key={artifact.id} artifact={artifact} grouped={Boolean(section.run)} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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<LabKind, ReactNode> = {
|
||||
// Running text: two full lines and a short last one.
|
||||
document: (
|
||||
<>
|
||||
<rect x="2" y="3.5" width="20" height="3.6" rx="1.8" />
|
||||
<rect x="2" y="10.2" width="20" height="3.6" rx="1.8" />
|
||||
<rect x="2" y="16.9" width="12" height="3.6" rx="1.8" />
|
||||
</>
|
||||
),
|
||||
// The reference's PDF mark: a short heading over a long line.
|
||||
pdf: (
|
||||
<>
|
||||
<rect x="2" y="5.5" width="11" height="4" rx="2" />
|
||||
<rect x="2" y="14" width="20" height="4" rx="2" />
|
||||
</>
|
||||
),
|
||||
// A slide on its stand.
|
||||
slides: (
|
||||
<>
|
||||
<rect x="1.5" y="3" width="21" height="13.5" rx="2.6" />
|
||||
<rect x="10.4" y="16" width="3.2" height="4" />
|
||||
<rect x="6.5" y="19.2" width="11" height="2.8" rx="1.4" />
|
||||
</>
|
||||
),
|
||||
// Four cells.
|
||||
sheet: (
|
||||
<>
|
||||
<rect x="2" y="2" width="9" height="9" rx="1.8" />
|
||||
<rect x="13" y="2" width="9" height="9" rx="1.8" />
|
||||
<rect x="2" y="13" width="9" height="9" rx="1.8" />
|
||||
<rect x="13" y="13" width="9" height="9" rx="1.8" />
|
||||
</>
|
||||
),
|
||||
// Sun over two peaks.
|
||||
image: (
|
||||
<>
|
||||
<circle cx="6.2" cy="5.6" r="2.9" />
|
||||
<path d="M2 20.5 9.4 9.6l4 5.9 2.3-3.2 6.3 8.2Z" strokeWidth="1.6" {...ROUND} />
|
||||
</>
|
||||
),
|
||||
// Play.
|
||||
video: <path d="M7 4.6 19.4 12 7 19.4Z" strokeWidth="3.4" {...ROUND} />,
|
||||
// A box with its lid and a pull slot.
|
||||
archive: (
|
||||
<>
|
||||
<rect x="1.5" y="3" width="21" height="5.6" rx="1.8" />
|
||||
<path d="M3.4 10.2h17.2v8.4a2.4 2.4 0 0 1-2.4 2.4H5.8a2.4 2.4 0 0 1-2.4-2.4Z" />
|
||||
<rect x="8.8" y="12.8" width="6.4" height="2.8" rx="1.4" className={CUT} />
|
||||
</>
|
||||
),
|
||||
// Angle brackets.
|
||||
code: (
|
||||
<g fill="none" strokeWidth="3.2" {...ROUND}>
|
||||
<path d="M8.6 5.6 2.6 12l6 6.4" />
|
||||
<path d="M15.4 5.6l6 6.4-6 6.4" />
|
||||
</g>
|
||||
),
|
||||
// The generic file: the product's paperclip.
|
||||
file: (
|
||||
<path
|
||||
d="m20.6 11.3-8.4 8.4a5.5 5.5 0 0 1-7.8-7.8l8.2-8.2a3.7 3.7 0 0 1 5.2 5.2l-8.2 8.2a1.85 1.85 0 0 1-2.6-2.6l7.6-7.6"
|
||||
fill="none"
|
||||
strokeWidth="2.6"
|
||||
{...ROUND}
|
||||
/>
|
||||
),
|
||||
// A globe: a solid disc with its meridian and equator knocked out.
|
||||
link: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10.4" />
|
||||
<ellipse cx="12" cy="12" rx="4.4" ry="10.4" strokeWidth="1.9" className={CUT_STROKE} />
|
||||
<path d="M1.6 12h20.8" strokeWidth="1.9" className={CUT_STROKE} />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
/** A pictogram placed inside a parent `<svg>`: `size` and the offset are in that SVG's units. */
|
||||
export function Pictogram({ kind, x, y, size }: { kind: LabKind; x: number; y: number; size: number }) {
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) scale(${size / 24})`} fill="currentColor">
|
||||
{SHAPES[kind]}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeLines, ProseExcerpt } from "./Content";
|
||||
import { KindGlyph } from "./KindGlyph";
|
||||
import { FoldedPreview, FoldedSheet } from "./Lead";
|
||||
import type { StackFace as Face } from "./gallery-model";
|
||||
import { glyphLabel } from "./kinds";
|
||||
|
||||
/**
|
||||
* GALLERY — one thing standing in a folder, by the one rule (Lead.tsx):
|
||||
*
|
||||
* picture the real picture with its kind-hue folded corner (no tab: the
|
||||
* edge it sits on is behind the pocket)
|
||||
* sheet a neutral sheet with the artifact's first lines — prose for a
|
||||
* document, monospace for a sheet or code — the folded corner, and
|
||||
* the extension tab moved to the TOP edge, where it can be seen
|
||||
* glyph the tuck glyph, for a kind with nothing to show; it stands a
|
||||
* little lower than content does, so full colour is the smaller
|
||||
* part of what a folder shows
|
||||
*
|
||||
* Every face is taller than the folder's mouth, so its bottom edge is always
|
||||
* behind the pocket: that clip is all "tucked in" needs.
|
||||
*/
|
||||
|
||||
const SPAN: Record<number, string> = { 3: "col-span-3", 4: "col-span-4", 5: "col-span-5", 6: "col-span-6" };
|
||||
// Each face stands at its own height, like files that were not squared up.
|
||||
const STAGGER = ["translate-y-0", "translate-y-1.5", "translate-y-0.5", "translate-y-2"];
|
||||
const TEXT = "text-(length:--text-nano) leading-3.5";
|
||||
|
||||
export function StackFace({ face, index, onFail }: { face: Face; index: number; onFail: (id: string) => void }) {
|
||||
const { artifact, kind, span } = face;
|
||||
return (
|
||||
<div data-kind={artifact.kind} data-face={kind} className={cn("min-h-0 min-w-0", SPAN[span], STAGGER[index % STAGGER.length], kind === "glyph" ? "pt-4" : null)}>
|
||||
{kind === "picture" ? (
|
||||
<FoldedPreview artifact={artifact} fold="md" tab="none" className="size-full rounded-b-none" onError={() => onFail(artifact.id)} />
|
||||
) : kind === "sheet" ? (
|
||||
<FoldedSheet artifact={artifact} fold="sm" tab="top" className="h-full rounded-b-none">
|
||||
{artifact.kind === "document" ? <ProseExcerpt artifact={artifact} maxChars={260} className={TEXT} /> : <CodeLines artifact={artifact} lines={10} className={TEXT} />}
|
||||
</FoldedSheet>
|
||||
) : (
|
||||
<KindGlyph kind={artifact.kind} label={glyphLabel(artifact)} size="tuck" className="h-auto w-full" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { LabGroup } from "../../kit";
|
||||
import { KindTally } from "./KindTally";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { StackFace } from "./StackFace";
|
||||
import { ReviewTally } from "./StateMark";
|
||||
import { MOTION, TitleAction } from "./UnitAction";
|
||||
import { stackAction } from "./actions";
|
||||
import { artifactCount, stackFaces } from "./gallery-model";
|
||||
import { countReview } from "./panel-model";
|
||||
|
||||
/**
|
||||
* GALLERY — a stack of two or more artifacts: a flat folder.
|
||||
*
|
||||
* ┌ ACM-3 ╲___________________________ tab + back panel --tint-folder-back
|
||||
* │ ▙ZIP ▙PPTX ┌─ picture ─◥ │ what is inside, standing in it
|
||||
* ┌┴────────────────────────────────────┴┐
|
||||
* │ Task title, two lines at most │ pocket --tint-slab
|
||||
* │ 21 artifacts · Updated Sep 28, 2026 │
|
||||
* │ ▙10 ▙3 ▙3 ▙3 ▙2 ○ 9 to review│
|
||||
* └──────────────────────────────────────┘
|
||||
*
|
||||
* Two neutral fills, no shadow, no gradient: the pocket is the slab every
|
||||
* card is made of, and the back panel is one neutral step further from the
|
||||
* page. A folder has no hue of its own; what colours it is its contents
|
||||
* (StackFace.tsx) — pictures and sheets of text first, a full-colour glyph
|
||||
* only for a kind with nothing to show — and the same kinds counted on the
|
||||
* pocket as a caption.
|
||||
*
|
||||
* The mouth is 5:2 of the folder's width and takes whatever height the row
|
||||
* has to spare, so the pocket is exactly as tall as its three lines.
|
||||
*
|
||||
* The task identifier is the label on the tab. The folder's body is one link
|
||||
* (the title, stretched; the tab is a second, pointer-only way in). On hover
|
||||
* the title underlines and the contents rise 12px out of the folder — unless
|
||||
* motion is reduced; the pocket keeps its fill, so it never merges with the
|
||||
* back panel.
|
||||
*/
|
||||
export function StackFolder({ group }: { group: LabGroup }) {
|
||||
const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const faces = useMemo(() => stackFaces(group, failed), [group, failed]);
|
||||
const action = stackAction(group);
|
||||
return (
|
||||
<li data-unit="stack-folder" data-count={group.count} className="group/unit relative flex min-w-0 flex-col">
|
||||
<div className="flex h-6 shrink-0">
|
||||
{/* Pointer-only: the title below is the folder's link for the keyboard and for assistive technology. */}
|
||||
<a href={action.href} tabIndex={-1} aria-hidden className="flex items-center rounded-tl-xl bg-(--tint-folder-back) pl-3.5 pr-1 font-mono text-(length:--text-micro) font-medium leading-none text-foreground">
|
||||
{group.taskIdentifier}
|
||||
</a>
|
||||
{/* The tab's shoulder: one curve from the tab's top down to the back panel's. */}
|
||||
<svg viewBox="0 0 28 24" aria-hidden focusable="false" className="h-6 w-7 shrink-0">
|
||||
<path d="M0 0C14 0 14 24 28 24H0Z" className="fill-(--tint-folder-back)" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col rounded-b-xl rounded-tr-xl bg-(--tint-folder-back)">
|
||||
{/* The mouth clips at the pocket's edge. Its contents rest 12px down and rise on hover. */}
|
||||
<div aria-hidden data-mouth className="relative mx-4 mt-1 aspect-5/2 flex-auto overflow-hidden">
|
||||
<div className="absolute inset-x-0 -bottom-2 top-0 grid translate-y-3 grid-cols-12 grid-rows-1 gap-2 transition-transform duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-safe:group-hover/unit:translate-y-0 motion-safe:group-focus-within/unit:translate-y-0 motion-reduce:transition-none">
|
||||
{faces.map((face, index) => (
|
||||
<StackFace key={face.artifact.id} face={face} index={index} onFail={(id) => setFailed((prev) => new Set(prev).add(id))} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex flex-col rounded-b-xl rounded-t-lg bg-(--tint-slab) p-3.5 group-active/unit:bg-(--tint-slab-raised) ${MOTION}`}>
|
||||
<h3 className="line-clamp-2 wrap-anywhere text-sm font-medium leading-5 text-foreground">
|
||||
<TitleAction action={action} stretch="after:top-6 after:rounded-xl after:rounded-tl-none" title={`${group.taskIdentifier} ${group.title}`}>
|
||||
{group.title}
|
||||
</TitleAction>
|
||||
</h3>
|
||||
<MetaLine wrap parts={[artifactCount(group.count), `Updated ${group.updatedLabel}`]} className="mt-1" />
|
||||
{/* Directly under the meta line: a row's spare height goes to the mouth, not to a gap in the pocket. */}
|
||||
<div className="flex min-w-0 items-center gap-3 pt-2.5">
|
||||
<KindTally artifacts={group.artifacts} className="flex-1" />
|
||||
<ReviewTally count={countReview(group.artifacts)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabGroup } from "../../kit";
|
||||
import { GalleryPreview, usePreviewKind } from "./GalleryPreview";
|
||||
import { KindGlyph } from "./KindGlyph";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { StateMark } from "./StateMark";
|
||||
import { SLAB, TitleAction } from "./UnitAction";
|
||||
import { stackAction } from "./actions";
|
||||
import { artifactCount } from "./gallery-model";
|
||||
|
||||
/**
|
||||
* GALLERY — a stack that holds one artifact is not a folder. It is that
|
||||
* artifact, lying on the page as the same slab an opened stack's tiles are:
|
||||
* its preview, then the task it belongs to where a folder has its pocket.
|
||||
*
|
||||
* ┌──────── the artifact's preview ────────┐
|
||||
* └────────────────────────────────────────┘
|
||||
* ACM-2 Task title identifier + title, two lines at most
|
||||
* 1 artifact · Updated Sep 25, 2026
|
||||
* ▙ The artifact's own title ○ Review
|
||||
*
|
||||
* No tab, no back panel, nothing standing behind anything: a pile is a
|
||||
* folder, one thing is a tile. It still opens the stack, like a folder.
|
||||
*
|
||||
* The preview box takes the height the folders beside it give the row (never
|
||||
* less than 136px), and the picture fills that box — it can never set it, so
|
||||
* the row does not move when an image arrives.
|
||||
*/
|
||||
export function StackSingle({ group }: { group: LabGroup }) {
|
||||
const artifact = group.artifacts[0];
|
||||
const [kind, onError] = usePreviewKind(artifact);
|
||||
return (
|
||||
<li data-kind={artifact.kind} data-unit="stack-single" data-count={group.count} className={cn("group/unit @container relative flex min-w-0 flex-col p-2", SLAB)}>
|
||||
<div className="relative min-h-34 flex-1">
|
||||
<GalleryPreview artifact={artifact} kind={kind} onError={onError} depth="short" className="absolute inset-0 aspect-auto h-full" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col px-1.5 pb-1.5 pt-2.5">
|
||||
{/* Two lines at most, like a folder's pocket; the identifier a folder wears on its tab leads the first. */}
|
||||
<h3 className="line-clamp-2 wrap-anywhere text-sm font-medium leading-5 text-foreground">
|
||||
<span className="mr-2 whitespace-nowrap font-mono text-(length:--text-micro) leading-none">{group.taskIdentifier}</span>
|
||||
<TitleAction action={stackAction(group)} stretch="after:rounded-lg" title={`${group.taskIdentifier} ${group.title}`}>
|
||||
{group.title}
|
||||
</TitleAction>
|
||||
</h3>
|
||||
<MetaLine wrap parts={[artifactCount(group.count), `Updated ${group.updatedLabel}`]} className="mt-1" />
|
||||
<div className="flex min-w-0 items-center gap-1.5 pt-2.5">
|
||||
<KindGlyph kind={artifact.kind} size="mini" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs leading-4 text-(--tint-ink-2)">{artifact.title}</span>
|
||||
<StateMark review={artifact.review} />
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
|
||||
/**
|
||||
* SIGNATURE ELEMENT 3 — the state mark.
|
||||
*
|
||||
* State never gets a coloured block. The chip is a hole punched in the slab
|
||||
* back to the page (`--tint-well`) with foreground text; the status hue lives
|
||||
* only in an 8px marker, and the marker's SHAPE carries the state too —
|
||||
*
|
||||
* needs_review open ring "waiting on you" --status-task-icon-in_review
|
||||
* changes_requested solid dot "sent back" --status-task-icon-blocked
|
||||
*
|
||||
* So a kind is a solid shape on the left and a state is a ring on the right:
|
||||
* different shape, different mass, opposite ends of the unit. The hues are
|
||||
* the app's status tokens, unchanged — and no kind uses violet, so the Review
|
||||
* ring is the only violet on any surface.
|
||||
*
|
||||
* Sizes: sm (22px, 10px text) wherever it rides a meta line; md (24px, 12px
|
||||
* text) where it stands alone. The label truncates before the pill can leave
|
||||
* its unit (a 260px pane). `StateDot` is the marker alone: section tallies
|
||||
* and the Review chip in the panel.
|
||||
*/
|
||||
|
||||
const STATES = {
|
||||
needs_review: { label: "Review", marker: "border-2 border-(--status-task-icon-in_review)" },
|
||||
changes_requested: { label: "Changes requested", marker: "bg-(--status-task-icon-blocked)" },
|
||||
} as const;
|
||||
|
||||
// Line-height after the size: tailwind-merge drops a leading that precedes a font size.
|
||||
const SIZES = {
|
||||
sm: "gap-1.5 py-1.5 pl-2 pr-2.5 text-(length:--text-nano) leading-none",
|
||||
md: "gap-1.5 py-1.5 pl-2 pr-2.5 text-xs leading-none",
|
||||
} as const;
|
||||
|
||||
export function StateMark({
|
||||
review,
|
||||
size = "sm",
|
||||
className,
|
||||
}: {
|
||||
review: LabArtifact["review"];
|
||||
size?: keyof typeof SIZES;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!review) return null;
|
||||
const state = STATES[review];
|
||||
return (
|
||||
<span
|
||||
data-state={review}
|
||||
className={cn("inline-flex min-w-0 max-w-full shrink-0 items-center rounded-full bg-(--tint-well) font-medium text-foreground", SIZES[size], className)}
|
||||
>
|
||||
<span aria-hidden className={cn("size-2 shrink-0 rounded-full", state.marker)} />
|
||||
<span className="truncate">{state.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The marker alone, for places too small for the word (the caller supplies the accessible text). */
|
||||
export function StateDot({ review, className }: { review: LabArtifact["review"]; className?: string }) {
|
||||
if (!review) return null;
|
||||
return <span aria-hidden data-state={review} className={cn("block size-2 shrink-0 rounded-full", STATES[review].marker, className)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* "N to review": the ring and a count, for a heading that holds several
|
||||
* artifacts — a panel section, a folder's pocket, an opened stack's caption.
|
||||
*/
|
||||
export function ReviewTally({ count, className }: { count: number; className?: string }) {
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<p data-tally className={cn("flex shrink-0 items-center gap-1.5 text-xs font-medium leading-4 text-foreground", className)}>
|
||||
<StateDot review="needs_review" />
|
||||
<span>
|
||||
<span className="font-mono text-(length:--text-micro)">{count}</span> to review
|
||||
</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { FileName } from "./FileName";
|
||||
import { KindGlyph } from "./KindGlyph";
|
||||
import { FoldedPreview, KindWell } from "./Lead";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { FOCUS_RING, MOTION } from "./UnitAction";
|
||||
import { glyphLabel, metaParts } from "./kinds";
|
||||
|
||||
/**
|
||||
* THREAD — the attachments block under an agent message.
|
||||
*
|
||||
* One even grid instead of today's "Images" grid plus a clipped "Files"
|
||||
* strip, so nothing runs off the right edge:
|
||||
*
|
||||
* picture a cell two rows tall — a 144×96 folded preview (fold md, tab,
|
||||
* play mark on a video) with the filename and meta line beside it
|
||||
* file a 50px slab — the xs glyph, mono filename, meta line
|
||||
*
|
||||
* Pictures lead, as the product's Images block does, and the label keeps its
|
||||
* two counts ("1 image · 4 files"). Past twelve attachments the block shows
|
||||
* eleven and a "Show all N" slab. The whole cell opens the attachment (one
|
||||
* stretched anchor per cell); hover is the kind wash, as on a thread row.
|
||||
*/
|
||||
|
||||
const CELL = `group/unit relative flex min-w-0 items-center rounded-lg bg-(--tint-slab) hover:bg-(--kind-wash) active:bg-(--kind-wash-pressed) ${MOTION}`;
|
||||
const CAP = 12;
|
||||
// 144×96: two 50px rows and the gap between them, less the cell's inset.
|
||||
const PICTURE_BOX = "h-24 w-36 shrink-0";
|
||||
|
||||
function Words({ artifact, tall }: { artifact: LabArtifact; tall: boolean }) {
|
||||
const name = artifact.filename ?? artifact.title;
|
||||
const mono = artifact.filename ? "font-mono tracking-tight" : undefined;
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* A filename is a machine value: monospace. On a file slab the stem truncates and the extension never does; beside a picture it has two lines. */}
|
||||
<p className="min-w-0 text-xs font-medium leading-4 text-foreground">
|
||||
{tall ? <span className={cn("line-clamp-2 wrap-anywhere", mono)}>{name}</span> : <FileName name={name} mono={Boolean(artifact.filename)} className={artifact.filename ? "tracking-tight" : undefined} />}
|
||||
</p>
|
||||
<MetaLine wrap={tall} parts={metaParts(artifact, { address: false })} className="mt-0.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ artifact }: { artifact: LabArtifact }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const name = artifact.filename ?? artifact.title;
|
||||
const picture = Boolean(artifact.thumbnailUrl);
|
||||
return (
|
||||
<li data-kind={artifact.kind} data-unit="thread-attachment" data-cell={picture ? "picture" : "file"} className={`${CELL} ${picture ? "row-span-2 gap-3 p-1.5 pr-3" : "gap-2.5 p-1.5 pr-3"}`}>
|
||||
{!picture ? (
|
||||
<KindGlyph kind={artifact.kind} label={glyphLabel(artifact)} size="xs" />
|
||||
) : failed ? (
|
||||
<KindWell artifact={artifact} size="md" className={PICTURE_BOX} />
|
||||
) : (
|
||||
<FoldedPreview artifact={artifact} fold="md" className={PICTURE_BOX} onError={() => setFailed(true)} />
|
||||
)}
|
||||
<Words artifact={artifact} tall={picture} />
|
||||
<a href={`#attachment-${artifact.id}`} aria-label={`Open ${name}`} title={name} className={`absolute inset-0 rounded-lg ${FOCUS_RING}`} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const count = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
||||
|
||||
export function ThreadAttachments({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
const [all, setAll] = useState(false);
|
||||
if (artifacts.length === 0) return null;
|
||||
// Pictures first, as the product's Images block leads its Files block.
|
||||
const pictures = artifacts.filter((a) => a.thumbnailUrl);
|
||||
const files = artifacts.filter((a) => !a.thumbnailUrl);
|
||||
const ordered = [...pictures, ...files];
|
||||
const capped = !all && ordered.length > CAP;
|
||||
const shown = capped ? ordered.slice(0, CAP - 1) : ordered;
|
||||
return (
|
||||
<section aria-label={`Attachments, ${artifacts.length}`} className="@container flex w-full flex-col gap-2 pb-1 pt-0.5">
|
||||
<p className="text-xs leading-4 text-(--tint-ink-2)">
|
||||
Attachments · <span className="font-mono text-(length:--text-micro)">{artifacts.length}</span>
|
||||
{pictures.length > 0 && files.length > 0 ? (
|
||||
<span className="ml-2 font-mono text-(length:--text-micro)">
|
||||
{count(pictures.length, "image")}
|
||||
<span className="mx-(--tint-meta-gap)">·</span>
|
||||
{count(files.length, "file")}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{/* Even columns: one in a sliver, two beside the open pane, three in the full column. Files fill in around the taller pictures. */}
|
||||
<ul className="grid grid-flow-row-dense grid-cols-1 gap-2 @md:grid-cols-2 @3xl:grid-cols-3">
|
||||
{shown.map((artifact) => (
|
||||
<Cell key={artifact.id} artifact={artifact} />
|
||||
))}
|
||||
{capped ? (
|
||||
<li className="flex min-h-12.5 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
data-show-all
|
||||
onClick={() => setAll(true)}
|
||||
className={`flex min-w-0 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg bg-(--tint-slab) px-3 text-xs font-medium text-foreground hover:bg-(--tint-slab-raised) active:bg-(--tint-button) ${MOTION} ${FOCUS_RING}`}
|
||||
>
|
||||
Show all <span className="font-mono text-(length:--text-micro)">{ordered.length}</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { Lead } from "./Lead";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { StateMark } from "./StateMark";
|
||||
import { MOTION, PrimaryAction, SecondaryAction } from "./UnitAction";
|
||||
import { downloadAction, threadAction } from "./actions";
|
||||
import { linkLabel, metaParts } from "./kinds";
|
||||
|
||||
/**
|
||||
* THREAD — one work product between turns.
|
||||
*
|
||||
* A single-row slab, 62px, the same for every kind:
|
||||
*
|
||||
* [ lead 72×46 ] Title ○ Review Action ↗
|
||||
* kind · size · rev │ summary
|
||||
*
|
||||
* The lead is one box for every row (Lead.tsx), so titles share one edge down
|
||||
* the conversation; the action sits in a fixed slot, so state marks share one
|
||||
* edge too. A link's address and the summary each truncate on their own, so
|
||||
* nothing can run under the action.
|
||||
*
|
||||
* Below a 32rem row the slab becomes three lines instead of losing its words:
|
||||
* the state mark drops to the right end of the meta line and the summary takes
|
||||
* up to two lines of its own, as the shipped row does.
|
||||
*
|
||||
* Hover is the row's one extra use of its hue: the slab takes the kind wash
|
||||
* (and one step more while pressed).
|
||||
*/
|
||||
export function ThreadWorkProduct({ artifact }: { artifact: LabArtifact }) {
|
||||
const action = threadAction(artifact);
|
||||
// A text-like file opens in the side panel; Download stays a separate target, as it does today.
|
||||
const secondary = action.intent === "tab" ? downloadAction(artifact) : null;
|
||||
const address = artifact.kind === "link" ? linkLabel(artifact.url) : undefined;
|
||||
const { summary } = artifact;
|
||||
return (
|
||||
<article
|
||||
data-kind={artifact.kind}
|
||||
data-unit="thread-row"
|
||||
className={`group/unit @container relative flex min-w-0 items-center gap-3 rounded-lg bg-(--tint-slab) p-2 pr-3.5 hover:bg-(--kind-wash) active:bg-(--kind-wash-pressed) ${MOTION}`}
|
||||
>
|
||||
<Lead artifact={artifact} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium leading-5 text-foreground">{artifact.title}</p>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2">
|
||||
{/* The kind word never gives way to an address; in a narrow row the rest of the line may. */}
|
||||
<MetaLine parts={metaParts(artifact, { address: false })} className={address ? "shrink-0" : "max-w-full @lg:shrink-0"} />
|
||||
{address ? (
|
||||
// The address truncates on its own, and sits above the stretched link so it can be selected and copied.
|
||||
<span data-address className="relative z-10 -ml-2 min-w-0 cursor-text select-text truncate font-mono text-(length:--text-micro) leading-4 text-(--tint-ink-2) @lg:max-w-1/2">
|
||||
<span className="mx-(--tint-meta-gap)">·</span>
|
||||
{address}
|
||||
</span>
|
||||
) : null}
|
||||
{summary ? (
|
||||
<>
|
||||
<span aria-hidden className="hidden h-2.5 w-px shrink-0 bg-(--tint-ink-2)/35 @lg:block" />
|
||||
<span data-summary className="hidden min-w-8 flex-1 truncate text-xs leading-4 text-(--tint-ink-2) @lg:block">
|
||||
{summary}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<StateMark review={artifact.review} className="ml-auto @lg:hidden" />
|
||||
</div>
|
||||
{summary ? (
|
||||
<p data-summary className="mt-1 line-clamp-2 wrap-anywhere text-xs leading-4 text-(--tint-ink-2) @lg:hidden">
|
||||
{summary}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<StateMark review={artifact.review} className="hidden @lg:inline-flex" />
|
||||
{/* One slot for every action, so the state marks beside it line up down the thread. */}
|
||||
<div className="flex items-center justify-end gap-3 @lg:w-30">
|
||||
<PrimaryAction action={action} stretch="after:rounded-lg" title={summary ? `${artifact.title} — ${summary}` : artifact.title} />
|
||||
{secondary ? <SecondaryAction action={secondary} className="-mx-1.5" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, Download, ExternalLink, FileCode2, FolderOpen, Maximize2, MessageSquare, PanelRight, Play, Table2, type LucideIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ActionIntent, UnitAction } from "./actions";
|
||||
|
||||
/**
|
||||
* Actions are real anchors with accessible names. A unit has ONE primary
|
||||
* action whose hit area is stretched over the whole slab by an `::after`
|
||||
* overlay (so nothing interactive is nested), and any secondary actions sit
|
||||
* above that overlay as their own targets.
|
||||
*
|
||||
* PrimaryAction quiet text + icon — the thread, where the row must stay calm
|
||||
* ButtonAction a filled neutral button — the panel, where actions are visible
|
||||
* TitleAction the unit's own title as the link — the gallery, where a tile
|
||||
* is browsed by its preview and a label would only be more text
|
||||
* SecondaryAction an icon target (with a count, for comments); its label is
|
||||
* the app's tooltip, so it also appears on keyboard focus
|
||||
*
|
||||
* At rest an action is neutral; it never takes the kind hue. Every action has
|
||||
* a pressed step (`active:`), one notch past its hover.
|
||||
*/
|
||||
|
||||
const ICON: Record<ActionIntent, LucideIcon> = {
|
||||
document: ArrowUpRight,
|
||||
gallery: Maximize2,
|
||||
preview: ExternalLink,
|
||||
tab: PanelRight,
|
||||
download: Download,
|
||||
comments: MessageSquare,
|
||||
raw: FileCode2,
|
||||
data: Table2,
|
||||
play: Play,
|
||||
task: ArrowUpRight,
|
||||
stack: FolderOpen,
|
||||
};
|
||||
|
||||
/**
|
||||
* Keyboard focus: a 2px foreground ring, offset so it reads on a slab and on the page, in both themes.
|
||||
* No `outline-none` here — in Tailwind v4 it pins the outline style to none and the ring never appears.
|
||||
*/
|
||||
export const FOCUS_RING = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground";
|
||||
const FOCUS_RING_STRETCHED =
|
||||
"outline-none focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-foreground";
|
||||
export const MOTION = "transition-colors duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-reduce:transition-none";
|
||||
|
||||
/** A neutral slab that is one unit: steps up on hover, and again while pressed. */
|
||||
export const SLAB = cn("rounded-lg bg-(--tint-slab) hover:bg-(--tint-slab-raised) active:bg-(--tint-button)", MOTION);
|
||||
|
||||
/**
|
||||
* The primary action as quiet text + icon. `stretch` names the radius class of
|
||||
* the slab it should cover (e.g. "after:rounded-lg"). The label hides below a
|
||||
* 24rem unit (the unit must be an `@container`), as the shipped rows do.
|
||||
* `title` is the unit's full text, for a row whose lines truncate.
|
||||
*/
|
||||
export function PrimaryAction({ action, stretch, title, className }: { action: UnitAction; stretch: string; title?: string; className?: string }) {
|
||||
const Icon = ICON[action.intent];
|
||||
return (
|
||||
<a
|
||||
href={action.href}
|
||||
aria-label={action.name}
|
||||
title={title}
|
||||
data-action={action.intent}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 text-xs font-medium text-(--tint-ink-2) after:absolute after:inset-0 group-hover/unit:text-foreground focus-visible:text-foreground",
|
||||
MOTION,
|
||||
stretch,
|
||||
FOCUS_RING_STRETCHED,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="hidden @sm:inline">{action.label}</span>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary action as a filled button (panel cards). Still stretched over
|
||||
* its unit, so pointing anywhere on the card that is not another target
|
||||
* hovers this anchor and inverts the button to the foreground — the card
|
||||
* shows what a click will do. The label hides below an 18rem unit; the icon
|
||||
* and the accessible name stay.
|
||||
*/
|
||||
export function ButtonAction({ action, stretch, className }: { action: UnitAction; stretch: string; className?: string }) {
|
||||
const Icon = ICON[action.intent];
|
||||
return (
|
||||
<a
|
||||
href={action.href}
|
||||
aria-label={action.name}
|
||||
data-action={action.intent}
|
||||
className={cn(
|
||||
"inline-flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-(--tint-button) px-2 text-xs font-medium text-foreground after:absolute after:inset-0 hover:bg-foreground hover:text-background focus-visible:bg-foreground focus-visible:text-background active:bg-foreground/85",
|
||||
MOTION,
|
||||
stretch,
|
||||
FOCUS_RING_STRETCHED,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="hidden @2xs:inline">{action.label}</span>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary action as the unit's title (gallery tiles, folders). The title
|
||||
* text is the anchor and its hit area is the whole unit; the accessible name
|
||||
* says where the click goes ("Open in ACM-3: …", "Open stack ACM-3: …").
|
||||
* It adds no colour and no type of its own — the heading around it sets those.
|
||||
*/
|
||||
export function TitleAction({ action, stretch, title, children }: { action: UnitAction; stretch: string; title?: string; children: ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={action.href}
|
||||
aria-label={action.name}
|
||||
title={title}
|
||||
data-action={action.intent}
|
||||
// Pointing anywhere on the unit that is not another target hovers this anchor: the title underlines, so the unit shows what a click opens.
|
||||
className={cn("underline-offset-4 after:absolute after:inset-0 hover:underline focus-visible:underline", stretch, FOCUS_RING_STRETCHED)}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** A secondary action: an icon target above the stretched primary. `count` turns it into a chip (comments). */
|
||||
export function SecondaryAction({ action, count, className }: { action: UnitAction; count?: number; className?: string }) {
|
||||
const Icon = ICON[action.intent];
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<a
|
||||
href={action.href}
|
||||
aria-label={action.name}
|
||||
data-action={action.intent}
|
||||
className={cn(
|
||||
"relative z-10 inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md text-(--tint-ink-2) hover:bg-(--tint-well) hover:text-foreground active:bg-(--tint-button)",
|
||||
count == null ? "w-7" : "px-1.5",
|
||||
MOTION,
|
||||
FOCUS_RING,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
{count == null ? null : <span className="font-mono text-(length:--text-micro) leading-none">{count}</span>}
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { isMarkdownFile, isTaskDocument, isTextLike } from "./kinds";
|
||||
|
||||
/**
|
||||
* What you can do with an artifact on each surface, in the product's wording.
|
||||
* A unit has one primary action (its whole slab) and, where the product has
|
||||
* them, secondary ones as separate targets.
|
||||
*/
|
||||
|
||||
export type ActionIntent = "document" | "gallery" | "preview" | "tab" | "download" | "comments" | "raw" | "data" | "play" | "task" | "stack";
|
||||
|
||||
export interface UnitAction {
|
||||
intent: ActionIntent;
|
||||
/** Visible label, production wording. */
|
||||
label: string;
|
||||
/** Accessible name: "Download: <title>". */
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const anchor = (prefix: string, a: LabArtifact) => `#${prefix}-${a.id}`;
|
||||
|
||||
/** THREAD — the one thing you do with it, reachable from anywhere on the row. */
|
||||
export function threadAction(a: LabArtifact): UnitAction {
|
||||
if (isTaskDocument(a)) return { intent: "document", label: "Open document", name: `Open document: ${a.title}`, href: anchor("document", a) };
|
||||
if (a.kind === "image" || a.kind === "video") return { intent: "gallery", label: "Open gallery", name: `Open gallery: ${a.title}`, href: anchor("gallery", a) };
|
||||
if (a.kind === "link") return { intent: "preview", label: "Open preview", name: `Open preview: ${a.title}`, href: a.url ?? anchor("preview", a) };
|
||||
if (isTextLike(a)) return { intent: "tab", label: "Open in tab", name: `Open in tab: ${a.title}`, href: anchor("file", a) };
|
||||
return downloadAction(a);
|
||||
}
|
||||
|
||||
export function downloadAction(a: LabArtifact): UnitAction {
|
||||
return { intent: "download", label: "Download", name: `Download: ${a.title}`, href: anchor("download", a) };
|
||||
}
|
||||
|
||||
/** PANEL — the product's button wording there: Read document, View image, Open video, Open link, Open file. */
|
||||
export function panelAction(a: LabArtifact): UnitAction {
|
||||
if (a.kind === "document") return { intent: "document", label: "Read document", name: `Read document: ${a.title}`, href: anchor("document", a) };
|
||||
if (a.kind === "image") return { intent: "gallery", label: "View image", name: `View image: ${a.title}`, href: anchor("gallery", a) };
|
||||
if (a.kind === "video") return { intent: "gallery", label: "Open video", name: `Open video: ${a.title}`, href: anchor("gallery", a) };
|
||||
if (a.kind === "link") return { intent: "preview", label: "Open link", name: `Open link: ${a.title}`, href: a.url ?? anchor("preview", a) };
|
||||
// "Open file" leaves the app for a new browser tab, as the shipped button does.
|
||||
return { intent: "preview", label: "Open file", name: `Open file: ${a.title}`, href: anchor("file", a) };
|
||||
}
|
||||
|
||||
/** PANEL — the second button on file cards. */
|
||||
function panelDownload(a: LabArtifact): UnitAction {
|
||||
return { intent: "download", label: "Download file", name: `Download file: ${a.title}`, href: anchor("download", a) };
|
||||
}
|
||||
|
||||
/**
|
||||
* PANEL — everything else a card offers today, as icon targets beside the
|
||||
* primary: the comments chip, Open in tab and Open raw on a Markdown file;
|
||||
* View data on a sheet; Open in tab on any text-like file; Download file on
|
||||
* every file. Task documents, images, videos and links have none.
|
||||
*/
|
||||
export function panelSecondary(a: LabArtifact): UnitAction[] {
|
||||
if (isTaskDocument(a) || a.kind === "image" || a.kind === "video" || a.kind === "link") return [];
|
||||
const actions: UnitAction[] = [];
|
||||
if (isMarkdownFile(a) && a.commentCount != null) {
|
||||
const n = a.commentCount;
|
||||
actions.push({ intent: "comments", label: `${n} comment${n === 1 ? "" : "s"}`, name: `${n} comment${n === 1 ? "" : "s"}: ${a.title}`, href: anchor("comments", a) });
|
||||
}
|
||||
if (a.kind === "sheet" && a.excerpt) actions.push({ intent: "data", label: "View data", name: `View data: ${a.title}`, href: anchor("data", a) });
|
||||
if (isTextLike(a)) actions.push({ intent: "tab", label: "Open in tab", name: `Open in tab: ${a.title}`, href: anchor("file", a) });
|
||||
if (isMarkdownFile(a)) actions.push({ intent: "raw", label: "Open raw", name: `Open raw: ${a.title}`, href: anchor("raw", a) });
|
||||
actions.push(panelDownload(a));
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* GALLERY — a tile is a link to the artifact where it lives: the owning task,
|
||||
* at the artifact's anchor (today's whole-card link).
|
||||
*/
|
||||
export function galleryAction(a: LabArtifact): UnitAction {
|
||||
return { intent: "task", label: `Open in ${a.taskIdentifier}`, name: `Open in ${a.taskIdentifier}: ${a.title}`, href: `#${a.taskIdentifier}-${a.id}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* GALLERY — the two icon targets a tile has today, in the product's wording:
|
||||
* "Open file in new tab" and "Download file" for anything file-backed. A link
|
||||
* opens its address; a task document has neither (it has no file).
|
||||
*/
|
||||
export function gallerySecondary(a: LabArtifact): UnitAction[] {
|
||||
if (isTaskDocument(a)) return [];
|
||||
if (a.kind === "link") return [{ intent: "preview", label: "Open link in new tab", name: `Open link in new tab: ${a.title}`, href: a.url ?? anchor("preview", a) }];
|
||||
return [
|
||||
{ intent: "preview", label: "Open file in new tab", name: `Open file in new tab: ${a.title}`, href: anchor("file", a) },
|
||||
panelDownload(a),
|
||||
];
|
||||
}
|
||||
|
||||
/** GALLERY — a folder, or a stack of one, opens its stack. */
|
||||
export function stackAction(group: { taskIdentifier: string; title: string; count: number }): UnitAction {
|
||||
const count = `${group.count} ${group.count === 1 ? "artifact" : "artifacts"}`;
|
||||
return { intent: "stack", label: "Open stack", name: `Open stack ${group.taskIdentifier}: ${group.title}, ${count}`, href: `#stack-${group.taskIdentifier}` };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { LabArtifact } from "../../kit";
|
||||
|
||||
/**
|
||||
* What a text-like artifact shows of itself: a document's opening as two
|
||||
* tiers of text, and a delimited file as a header with its first rows.
|
||||
*/
|
||||
|
||||
interface ExcerptLine {
|
||||
text: string;
|
||||
/** A heading inside the document: its own line, foreground medium. At most one per excerpt. */
|
||||
heading: boolean;
|
||||
/** A list item: its own line behind a dash. */
|
||||
item: boolean;
|
||||
}
|
||||
|
||||
const same = (a: string, b: string | undefined) => Boolean(b) && a.trim().toLowerCase() === b?.trim().toLowerCase();
|
||||
/** "Revision 3 • 28 September 2026 • 12 slides • 16:9" — a document's own metadata line, which the meta line already covers. */
|
||||
const isMetadataLine = (line: string) => line.length < 120 && line.split(/\s[•·|]\s/).length >= 3;
|
||||
|
||||
/** Characters of text above which a heading is no longer started (about two lines in the pane, three on a tile). */
|
||||
const HEADING_AFTER = 110;
|
||||
|
||||
/**
|
||||
* The opening of a document as lines, not as one flattened run:
|
||||
* - the opening heading is dropped (it restates the title right beside it),
|
||||
* and so are a leading metadata line and a heading that repeats the summary;
|
||||
* - at most ONE heading is kept, on its own line; the excerpt ends before a second;
|
||||
* - a heading is only kept while the text above it is short (`HEADING_AFTER`),
|
||||
* so a clamp cannot leave it stranded as the last visible line;
|
||||
* - every paragraph and every list item is its own line.
|
||||
*/
|
||||
export function documentExcerpt(a: LabArtifact, maxChars = 600): ExcerptLine[] {
|
||||
const source = a.body ?? a.excerpt ?? "";
|
||||
const lines: ExcerptLine[] = [];
|
||||
let used = 0;
|
||||
let first = true;
|
||||
let headings = 0;
|
||||
for (const raw of source.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const heading = /^#{1,6}\s+(.*)$/.exec(line);
|
||||
const item = /^(?:[-*•]|\d+\.)\s+(.*)$/.exec(line);
|
||||
const opening = first;
|
||||
first = false;
|
||||
if (heading && (opening || same(heading[1], a.summary) || same(heading[1], a.title))) continue;
|
||||
if (!heading && !item && lines.length === 0 && isMetadataLine(line)) continue;
|
||||
if (heading && ((headings += 1) > 1 || used > HEADING_AFTER)) break;
|
||||
const text = (heading?.[1] ?? item?.[1] ?? line).replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*_`]/g, "");
|
||||
lines.push({ text, heading: Boolean(heading), item: Boolean(item) });
|
||||
used += text.length;
|
||||
if (used >= maxChars) break;
|
||||
}
|
||||
// A heading with nothing under it says nothing.
|
||||
while (lines.length > 0 && lines[lines.length - 1].heading) lines.pop();
|
||||
return lines;
|
||||
}
|
||||
|
||||
interface SheetPreview {
|
||||
header: string[];
|
||||
rows: string[][];
|
||||
/** Rows and columns that exist but are not shown: counted under the table, never clipped. */
|
||||
more: number;
|
||||
moreColumns: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A delimited excerpt as a header and its first rows. `maxChars` is the width
|
||||
* the caller has, in monospace cells: columns are kept from the left while
|
||||
* they fit, and the rest are counted. The first column may truncate (it is
|
||||
* usually a name), so it is costed at twelve cells at most.
|
||||
*/
|
||||
export function sheetPreview(a: LabArtifact, maxRows = 3, maxChars = Number.POSITIVE_INFINITY): SheetPreview | null {
|
||||
if (!a.excerpt) return null;
|
||||
const lines = a.excerpt.trim().split("\n").map((line) => line.split(","));
|
||||
if (lines.length < 2) return null;
|
||||
const [header, ...rows] = lines;
|
||||
let columns = 0;
|
||||
let used = 0;
|
||||
for (let c = 0; c < header.length; c += 1) {
|
||||
const widest = Math.max(...lines.map((line) => (line[c] ?? "").length));
|
||||
const cost = (c === 0 ? Math.min(widest, 12) : widest) + (c === 0 ? 0 : 2);
|
||||
if (c > 0 && used + cost > maxChars) break;
|
||||
used += cost;
|
||||
columns += 1;
|
||||
}
|
||||
return {
|
||||
header: header.slice(0, columns),
|
||||
rows: rows.slice(0, maxRows).map((row) => row.slice(0, columns)),
|
||||
more: Math.max(0, rows.length - maxRows),
|
||||
moreColumns: header.length - columns,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { LabArtifact, LabGroup, LabKind } from "../../kit";
|
||||
import { hasTextPreview } from "./kinds";
|
||||
|
||||
/**
|
||||
* GALLERY — what a folder shows of its contents, and how an opened stack is
|
||||
* sectioned.
|
||||
*/
|
||||
|
||||
/** By the one rule: a picture or a text sheet is SHOWN; anything else stands in the folder as its glyph. */
|
||||
type FaceKind = "picture" | "sheet" | "glyph";
|
||||
|
||||
/** One thing standing in a folder. `span` is its width in twelfths of the folder's mouth. */
|
||||
export interface StackFace {
|
||||
artifact: LabArtifact;
|
||||
kind: FaceKind;
|
||||
span: number;
|
||||
}
|
||||
|
||||
const MOUTH = 12;
|
||||
/** A glyph takes a quarter of the mouth, a sheet a third (anything narrower cannot hold a line of text), a picture five twelfths. */
|
||||
const COST: Record<FaceKind, number> = { picture: 5, sheet: 4, glyph: 3 };
|
||||
/** Spare room goes to what there is to look at: sheets first, then pictures, up to half the mouth each. */
|
||||
const WIDEST = 6;
|
||||
|
||||
/**
|
||||
* The faces of a stack, newest first. One artifact per KIND goes in before
|
||||
* any kind repeats, so what stands in the folder says what is inside it
|
||||
* rather than what happened to be edited last (today the face is the single
|
||||
* newest artifact — for ACM-3 an empty "FILE" box in front of twenty other
|
||||
* things). Room that is left over widens the sheets and pictures. `failed`
|
||||
* holds pictures that did not load: they stand as glyphs, and the room they
|
||||
* give back is filled again.
|
||||
*/
|
||||
export function stackFaces(group: LabGroup, failed: ReadonlySet<string> = new Set()): StackFace[] {
|
||||
const faceKind = (a: LabArtifact): FaceKind => (a.thumbnailUrl && !failed.has(a.id) ? "picture" : hasTextPreview(a) ? "sheet" : "glyph");
|
||||
const faces: StackFace[] = [];
|
||||
const kinds = new Set<LabKind>();
|
||||
const taken = new Set<string>();
|
||||
let used = 0;
|
||||
const take = (artifact: LabArtifact) => {
|
||||
const kind = faceKind(artifact);
|
||||
if (used + COST[kind] > MOUTH) return;
|
||||
faces.push({ artifact, kind, span: COST[kind] });
|
||||
taken.add(artifact.id);
|
||||
kinds.add(artifact.kind);
|
||||
used += COST[kind];
|
||||
};
|
||||
for (const artifact of group.artifacts) {
|
||||
if (!kinds.has(artifact.kind)) take(artifact);
|
||||
}
|
||||
for (const artifact of group.artifacts) {
|
||||
if (used >= MOUTH) break;
|
||||
if (!taken.has(artifact.id)) take(artifact);
|
||||
}
|
||||
for (const kind of ["sheet", "picture"] as const) {
|
||||
const wider = faces.filter((face) => face.kind === kind);
|
||||
for (let i = 0; used < MOUTH && wider.some((face) => face.span < WIDEST); i += 1) {
|
||||
const face = wider[i % wider.length];
|
||||
if (face.span >= WIDEST) continue;
|
||||
face.span += 1;
|
||||
used += 1;
|
||||
}
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
export interface StackSection {
|
||||
/** "2026-09-28", or "2026-09-25_2026-09-26" for a span of days. */
|
||||
id: string;
|
||||
/** "Monday, September 28" — or "September 25 – 26" for a span. */
|
||||
label: string;
|
||||
artifacts: LabArtifact[];
|
||||
/** A span of days: its tiles carry their own date, not only the time. */
|
||||
spansDays: boolean;
|
||||
}
|
||||
|
||||
const sameYear = (iso: string) => iso.slice(0, 4) === String(new Date().getFullYear());
|
||||
const monthDay = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "long", day: "numeric" });
|
||||
|
||||
function spanLabel(newest: string, oldest: string): string {
|
||||
const [from, to] = [monthDay(oldest), monthDay(newest)];
|
||||
const sameMonth = oldest.slice(0, 7) === newest.slice(0, 7);
|
||||
return `${from} – ${sameMonth ? to.split(" ").pop() : to}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An opened stack by the day of the last edit, newest first — but a heading
|
||||
* has to earn its row. A day that fills at least one row of the grid gets its
|
||||
* own heading; quieter days next to each other share one ("September 25 – 26")
|
||||
* instead of each leaving a tile or two alone in a row. A date outside the
|
||||
* current year says its year.
|
||||
*/
|
||||
export function stackSections(artifacts: LabArtifact[], columns = 3): StackSection[] {
|
||||
const days = new Map<string, LabArtifact[]>();
|
||||
for (const artifact of artifacts) {
|
||||
const id = artifact.updatedAt.slice(0, 10);
|
||||
days.set(id, [...(days.get(id) ?? []), artifact]);
|
||||
}
|
||||
const ordered = [...days.entries()].sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0));
|
||||
const year = (id: string) => (sameYear(id) ? "" : `, ${id.slice(0, 4)}`);
|
||||
const sections: StackSection[] = [];
|
||||
let quiet: [string, LabArtifact[]][] = [];
|
||||
const flush = () => {
|
||||
if (quiet.length === 1) {
|
||||
const [id, items] = quiet[0];
|
||||
sections.push({ id, label: `${items[0].dayLabel}${year(id)}`, artifacts: items, spansDays: false });
|
||||
} else if (quiet.length > 1) {
|
||||
const [newest, oldest] = [quiet[0][0], quiet[quiet.length - 1][0]];
|
||||
sections.push({ id: `${oldest}_${newest}`, label: `${spanLabel(newest, oldest)}${year(newest)}`, artifacts: quiet.flatMap(([, items]) => items), spansDays: true });
|
||||
}
|
||||
quiet = [];
|
||||
};
|
||||
for (const [id, items] of ordered) {
|
||||
if (items.length < columns) {
|
||||
quiet.push([id, items]);
|
||||
continue;
|
||||
}
|
||||
flush();
|
||||
sections.push({ id, label: `${items[0].dayLabel}${year(id)}`, artifacts: items, spansDays: false });
|
||||
}
|
||||
flush();
|
||||
return sections;
|
||||
}
|
||||
|
||||
export const artifactCount = (count: number) => `${count} ${count === 1 ? "artifact" : "artifacts"}`;
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { DirectionKit } from "../../kit";
|
||||
import { GalleryStack } from "./GalleryStack";
|
||||
import { GalleryStacks } from "./GalleryStacks";
|
||||
import { PanelBody } from "./PanelBody";
|
||||
import { ThreadAttachments } from "./ThreadAttachments";
|
||||
import { ThreadWorkProduct } from "./ThreadWorkProduct";
|
||||
import "./tokens.css";
|
||||
|
||||
/**
|
||||
* 2 · TINT — filled cards with colour used sparingly.
|
||||
*
|
||||
* A sibling of Solid: filled neutral slabs, no outlines, no shadows, no
|
||||
* gradients, crisp edges. The difference is that every artifact KIND owns one
|
||||
* hue, and one rule decides where that hue may live.
|
||||
*
|
||||
* THE ONE RULE (Lead.tsx) — the lead of every unit, on every surface, is
|
||||
* 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 real
|
||||
* extension as a hue tab on its edge. Either a picture
|
||||
* (`FoldedPreview` — image, video poster, link screenshot) or its
|
||||
* text on a neutral sheet (`FoldedSheet` — a document's opening, a
|
||||
* sheet's rows, a file's code).
|
||||
* 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`).
|
||||
* Colour is the fold and tab on real content, or the glyph and its well —
|
||||
* never both, and nowhere else on a unit at rest: no coloured border, stripe,
|
||||
* title, action or chip. A failed or missing picture falls back to NAMED.
|
||||
*
|
||||
* SIGNATURE ELEMENTS — what carries across thread, panel and gallery
|
||||
* 1. The kind glyph (KindGlyph.tsx): a rounded tile with a folded top-right
|
||||
* corner (a flat lighter facet, never a shadow), a pictogram top-left
|
||||
* (Pictogram.tsx, ten of them) and the REAL extension in bold — PDF, PPTX,
|
||||
* XLSX, MD, PNG, WEBM, ZIP, HTML, OTF; URL for a link. A task document has
|
||||
* no extension, so its glyph is the pictogram alone, larger: it can no
|
||||
* longer be mistaken for a .doc, or for the .md file next to it.
|
||||
* Sizes: mini 16×18 (pictogram only — index chips, kind tallies), xs 34×38
|
||||
* (thread), md 56×64 (panel), lg 96×110 (gallery), tuck (standing in a folder).
|
||||
* 2. The fold and tab on real content (Lead.tsx), folds sm / md / lg — the
|
||||
* same frame for a picture and for a sheet of text. Video adds a white
|
||||
* play mark on a neutral scrim.
|
||||
* 3. The mono meta line (MetaLine.tsx): kind · size · rev (· fact) — one
|
||||
* grammar everywhere, monospace, never coloured. Wording is the product's
|
||||
* (kinds.ts). It wraps between parts where the product wraps.
|
||||
* 4. The state mark (StateMark.tsx): a pill punched back to the page fill
|
||||
* with an 8px marker in the app's status hue — an open RING for Review, a
|
||||
* solid DOT for Changes requested; "N to review" (`ReviewTally`) where a
|
||||
* heading holds several artifacts. No kind uses violet, so the Review
|
||||
* ring is the only violet on any surface.
|
||||
* 5. The filled slab: `--tint-slab`, `rounded-lg`, no border, no shadow.
|
||||
*
|
||||
* KIND → HUE (tokens.css, OKLCH, tuned per theme) — nine hues for ten kinds
|
||||
* document blue · pdf red · slides orange · sheet green · image + video one
|
||||
* media magenta (told apart by pictogram, extension and play mark) · archive
|
||||
* a true amber, the one hue that carries dark ink · code teal · link sky ·
|
||||
* file a near-neutral grey. A unit sets `data-kind` ONCE on its root;
|
||||
* descendants read `--kind`, `--kind-ink`, `--kind-facet`, `--kind-well`
|
||||
* (the quiet fill: its own token per hue and theme, so red stays red and
|
||||
* amber stays amber), `--kind-well-lg` (tile-sized: less chroma in light)
|
||||
* and `--kind-wash` / `--kind-wash-pressed` (a hovered / pressed thread slab).
|
||||
*
|
||||
* MATERIALS
|
||||
* `--tint-slab` (7.5% / 9% foreground over the page, 1.2:1): every card.
|
||||
* `--tint-slab-raised`: the neutral hover of a unit. `--tint-button`: a
|
||||
* filled neutral button, and the pressed step of a slab. `--tint-well` (= the
|
||||
* page fill): holes in a slab — state pills, and the sheet text is set on.
|
||||
* `--tint-ink-2`: secondary text, ≥ 4.5:1 on every fill and every kind well.
|
||||
* `--tint-folder-back`: the one fill a folder adds. Previews use
|
||||
* `--tint-radius-glyph`.
|
||||
*
|
||||
* WHO SPEAKS IN WHICH VOICE
|
||||
* Human words (title, summary, a document's own text) are sans; machine
|
||||
* values (meta line, filename, address, MIME type, timestamps, counts, sheet
|
||||
* and code previews) are the mono token in secondary ink. Text on a slab or
|
||||
* a kind well is ABOUT the artifact; text on a neutral sheet IS the artifact
|
||||
* (Content.tsx: at most one heading on its own line, then body lines; list
|
||||
* items each on a line; the document's own metadata line and a heading that
|
||||
* repeats the summary are left out).
|
||||
*
|
||||
* STATES (UnitAction.tsx)
|
||||
* rest — neutral slab, the lead carries the colour, action neutral;
|
||||
* hover — thread: the slab takes the kind wash (the row's one extra use of
|
||||
* its hue) and the action takes the foreground; panel: slab steps to
|
||||
* raised and the primary button inverts to the foreground; gallery: slab
|
||||
* steps to raised and the title — which is the link — underlines; a
|
||||
* folder's contents rise 12px out of it while its pocket keeps its fill;
|
||||
* pressed — one step past hover on every unit and every target;
|
||||
* focus-visible — 2px foreground outline, 2px offset, around the whole slab
|
||||
* (the primary action is stretched over it; every other target has its
|
||||
* own ring, and icon targets show their label in the app's tooltip);
|
||||
* review — the state mark at the right end of the meta line (in a wide
|
||||
* thread row: in its own column, so marks line up down the thread);
|
||||
* long text — titles truncate in a thread row and on a tile, clamp at two
|
||||
* lines in the panel and on a folder (three in a narrow pane), and break
|
||||
* anywhere so an unbroken filename still ends in an ellipsis; filenames
|
||||
* keep their extension; a link's address truncates on its own;
|
||||
* one artifact versus a pile — a stack of one is a tile, never a folder.
|
||||
* Nothing is tooltip-only: MIME type, filename and times are printed, and a
|
||||
* date outside the current year says its year. Filenames, addresses and MIME
|
||||
* types sit above the stretched link, so they can be selected and copied.
|
||||
* Motion: colour, plus the rise of a hovered folder's contents;
|
||||
* `--motion-duration-fast`, none under reduced motion.
|
||||
*
|
||||
* THREAD
|
||||
* ThreadWorkProduct — a 62px single-row slab for every kind: one 72×46 lead
|
||||
* box (a folded picture, or the xs glyph on its kind well — no room to show
|
||||
* text here), so every title starts on one edge · title over the meta line
|
||||
* (summary rides it after a hairline) · state mark · the one action in a
|
||||
* fixed slot. Below a 32rem row it becomes three lines: the state mark joins
|
||||
* the meta line and the summary gets up to two lines of its own.
|
||||
* ThreadAttachments — "Attachments · N" with its image and file counts, over
|
||||
* one even grid: a picture is a cell two rows tall with a 144×96 folded
|
||||
* preview, a file is a 50px slab with the xs glyph; pictures lead; past
|
||||
* twelve the block ends in "Show all N". 1 / 2 / 3 columns by container.
|
||||
*
|
||||
* PANEL — PanelBody.tsx
|
||||
* index (PanelIndex.tsx) — one row of filter BUTTONS: All · Review (the
|
||||
* ring) · the kinds with two or more (mini glyph + count) · "+N". Pressed
|
||||
* is the foreground inversion and spells the kind out.
|
||||
* sections (PanelSection.tsx, panel-model.ts) — producing runs and the
|
||||
* task's documents in the product's order, newest first; a section says
|
||||
* how many of its items are waiting, nothing is re-sorted.
|
||||
* card (PanelCard.tsx, PanelLead.tsx) — one skeleton, the lead by the rule:
|
||||
* picture full width, the whole picture at its own shape (4:3 to 16:9),
|
||||
* fold lg; a video's poster is a play button that swaps in the
|
||||
* real player
|
||||
* sheet full width: five lines of a document, header + three rows of
|
||||
* a sheet (only the columns that fit, the rest counted), five
|
||||
* lines of code; fold md
|
||||
* named the md glyph on a 112px kind well beside the words
|
||||
* then title · meta line with facts + state mark · summary · filename or
|
||||
* address · MIME type (file cards) · footer: author over edit time (time
|
||||
* only inside a run section), icon actions, the filled primary button.
|
||||
*
|
||||
* GALLERY — GalleryStacks.tsx, GalleryStack.tsx
|
||||
* stacks — a stack of two or more is a FLAT FOLDER (StackFolder.tsx): the
|
||||
* task identifier on the tab, a back panel, a mouth of contents, and the
|
||||
* pocket in front with the title, "N artifacts · Updated <date>", the
|
||||
* kinds counted as a caption (KindTally.tsx) and the review tally. What
|
||||
* stands in the mouth (StackFace.tsx, gallery-model.ts) is one artifact
|
||||
* per kind before any kind repeats, each by the rule: a picture, a sheet
|
||||
* of its first lines (tab on its top edge), or — only for a kind with
|
||||
* nothing to show — the full-colour glyph, standing a little lower. A
|
||||
* folder has no hue of its own. A stack of ONE is not a folder
|
||||
* (StackSingle.tsx): it is that artifact's preview on a slab.
|
||||
* opened stack — a caption (the pocket laid flat, as type on the page), then
|
||||
* the artifacts by day of last edit; days too quiet to fill a row share
|
||||
* one heading ("September 25 – 26") and their tiles carry the date.
|
||||
* tile (GalleryTile.tsx) — a 16:9 preview by the rule (GalleryPreview.tsx:
|
||||
* folded picture · sheet of text clamped to whole lines · kind well with
|
||||
* the lg glyph, the countable fact as a headline, the summary and the
|
||||
* filename), then title (the link) · meta line + state mark · author ·
|
||||
* time with "Open file in new tab" and "Download file" visible at rest.
|
||||
*
|
||||
* `specimen.html` in this directory shows every primitive at every size for
|
||||
* all ten kinds (dev server: /src/artifact-directions/directions/d2-tint/specimen.html).
|
||||
* It is a builder's aid; nothing in the kit imports it.
|
||||
*/
|
||||
export const kit: DirectionKit = {
|
||||
id: "d2-tint",
|
||||
name: "2.Tint",
|
||||
tagline: "Filled cards with colour used sparingly",
|
||||
ThreadWorkProduct,
|
||||
ThreadAttachments,
|
||||
PanelBody,
|
||||
GalleryStacks,
|
||||
GalleryStack,
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { fileKindForName } from "@/components/task-chat/task-chat-attachments";
|
||||
import type { LabArtifact, LabKind } from "../../kit";
|
||||
|
||||
/**
|
||||
* The words of the set. One place decides what a kind is called and what its
|
||||
* glyph says, so the three surfaces cannot drift (the actions are in
|
||||
* actions.ts). Labels reuse the product's own mapper (`fileKindForName`)
|
||||
* wherever it has an answer: PDF, Markdown, ZIP, Sheet, CSV, HTML, File.
|
||||
*/
|
||||
|
||||
/** 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 Markdown file an agent attached: a `document` the product reviews inline, with comments. */
|
||||
export const isMarkdownFile = (a: LabArtifact) => a.kind === "document" && a.revision == null;
|
||||
|
||||
const GLYPH_FALLBACK: Record<LabKind, string> = {
|
||||
document: "MD",
|
||||
image: "IMG",
|
||||
video: "VID",
|
||||
pdf: "PDF",
|
||||
slides: "PPT",
|
||||
sheet: "XLS",
|
||||
archive: "ZIP",
|
||||
code: "CODE",
|
||||
file: "FILE",
|
||||
link: "URL",
|
||||
};
|
||||
|
||||
/**
|
||||
* The bold word on the glyph and on a preview's tab: always the real
|
||||
* extension, four characters at most (URL for a link). A task document has
|
||||
* none — it is the product's own document, not a file — so its glyph carries
|
||||
* the pictogram alone, larger, and "Document · rev N" stays on the meta line.
|
||||
*/
|
||||
export function glyphLabel(a: LabArtifact): string | undefined {
|
||||
if (isTaskDocument(a)) return undefined;
|
||||
if (a.kind === "link") return "URL";
|
||||
const ext = a.ext ?? a.filename?.split(".").pop();
|
||||
return ext && ext.length <= 4 ? ext.toUpperCase() : GLYPH_FALLBACK[a.kind];
|
||||
}
|
||||
|
||||
/** What a kind is called when the filename cannot say (no filename, or an extension the product does not know). */
|
||||
const KIND_NOUN: Partial<Record<LabKind, string>> = {
|
||||
document: "Markdown",
|
||||
pdf: "PDF",
|
||||
// The product has no entry for .pptx and calls it "File"; the kind is known here.
|
||||
slides: "Slides",
|
||||
sheet: "Sheet",
|
||||
archive: "Archive",
|
||||
code: "Code",
|
||||
};
|
||||
|
||||
/** The first word of the meta line, in the product's wording. */
|
||||
export function kindLabel(a: LabArtifact): string {
|
||||
if (isTaskDocument(a)) return "Document";
|
||||
if (a.kind === "image") return "Image";
|
||||
if (a.kind === "video") return "Video";
|
||||
if (a.kind === "link") return "Link preview";
|
||||
if (a.kind === "slides") return "Slides";
|
||||
const byName = a.filename ? fileKindForName(a.filename).label : "File";
|
||||
return byName === "File" ? (KIND_NOUN[a.kind] ?? "File") : byName;
|
||||
}
|
||||
|
||||
/** A link's address without the scheme: "preview.acmehats.example/storefront". */
|
||||
export function linkLabel(url: string | undefined): string | undefined {
|
||||
if (!url) return undefined;
|
||||
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? "" : "s"}`;
|
||||
|
||||
/** Data rows and columns of a delimited excerpt (the header is not a row). */
|
||||
function sheetShape(a: LabArtifact): { rows: number; columns: number } | undefined {
|
||||
if (a.kind !== "sheet" || !a.excerpt) return undefined;
|
||||
const lines = a.excerpt.trim().split("\n");
|
||||
return { rows: Math.max(0, lines.length - 1), columns: lines[0].split(",").length };
|
||||
}
|
||||
|
||||
/** "12 pages", "12 slides", "0:06", "4 rows" — the countable fact a kind has, if any. */
|
||||
function kindFact(a: LabArtifact): string | undefined {
|
||||
if (a.kind === "video") return a.durationLabel;
|
||||
const shape = sheetShape(a);
|
||||
if (shape) return plural(shape.rows, "row");
|
||||
if (a.pageCount == null) return undefined;
|
||||
return plural(a.pageCount, a.kind === "slides" ? "slide" : "page");
|
||||
}
|
||||
|
||||
/** The one fact a preview with nothing to show can lead with: the count, or failing that the size. */
|
||||
export const headlineFact = (a: LabArtifact) => kindFact(a) ?? a.sizeLabel;
|
||||
|
||||
interface MetaOptions {
|
||||
/** Append the countable fact — and a sheet's columns, as the product's card does. Panel and gallery have room for it. */
|
||||
facts?: boolean;
|
||||
/** A part already shown right beside the line (a duration chip, a well's headline), so it is not said twice. */
|
||||
omit?: string;
|
||||
/** A link's address is set by the caller where it has to truncate on its own. */
|
||||
address?: boolean;
|
||||
}
|
||||
|
||||
/** The mono meta line, one grammar everywhere: kind · size · rev (· fact). */
|
||||
export function metaParts(a: LabArtifact, { facts = false, omit, address = true }: MetaOptions = {}): string[] {
|
||||
const parts = [kindLabel(a)];
|
||||
if (a.kind === "link") {
|
||||
const link = address ? linkLabel(a.url) : undefined;
|
||||
if (link) parts.push(link);
|
||||
} else if (a.sizeLabel) {
|
||||
parts.push(a.sizeLabel);
|
||||
}
|
||||
if (a.revision != null) parts.push(`rev ${a.revision}`);
|
||||
const fact = facts || a.kind === "video" ? kindFact(a) : undefined;
|
||||
if (fact) parts.push(fact);
|
||||
const shape = facts ? sheetShape(a) : undefined;
|
||||
if (shape) parts.push(plural(shape.columns, "column"));
|
||||
return omit ? parts.filter((part) => part !== omit) : parts;
|
||||
}
|
||||
|
||||
/** Files the product opens in the side panel on a plain click (lib/issue-attachments.ts `isTextAttachment`). */
|
||||
export function isTextLike(a: LabArtifact): boolean {
|
||||
if (isTaskDocument(a) || a.kind === "link") return false;
|
||||
const type = (a.contentType ?? "").toLowerCase();
|
||||
if (type.startsWith("text/") || /^application\/(json|xml|javascript|x-yaml|yaml)$/.test(type)) return true;
|
||||
return /\.(md|markdown|txt|log|csv|json|ya?ml|toml|ini|sh|tsx?|jsx?|py|css|html)$/i.test(a.filename ?? "");
|
||||
}
|
||||
|
||||
/** Kinds whose preview is their own text: a document's opening, a sheet's rows, a file's code. */
|
||||
export function hasTextPreview(a: LabArtifact): boolean {
|
||||
if (a.kind === "document") return Boolean(a.body ?? a.excerpt);
|
||||
if (a.kind === "sheet" || a.kind === "code") return Boolean(a.excerpt);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The timestamp of the current year reads without its year; an older one keeps it. `label` is "Sep 28, 2026, 11:07 AM". */
|
||||
export function shortDateTime(label: string, iso: string): string {
|
||||
return iso.slice(0, 4) === String(new Date().getFullYear()) ? label.replace(/, \d{4}(?=,)/, "") : label;
|
||||
}
|
||||
|
||||
/** "Sep 28, 2026, 11:07 AM" → "11:07 AM". */
|
||||
export const timeOnly = (label: string) => label.split(", ").pop() ?? label;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { LAB_KINDS, type LabArtifact, type LabKind, type LabRun } from "../../kit";
|
||||
import { isTaskDocument } from "./kinds";
|
||||
|
||||
/**
|
||||
* PANEL — what goes where on the task's shelf.
|
||||
*
|
||||
* The product's own order, unchanged: artifacts are grouped by producing run
|
||||
* (agent + run start) with everything that has no run in one group, groups
|
||||
* are newest first, and inside a group the order is the one given. Review is
|
||||
* expressed without re-sorting: a section says how many of its items are
|
||||
* waiting, and the index has a Review filter.
|
||||
*/
|
||||
|
||||
export type PanelFilter = "all" | "review" | LabKind;
|
||||
|
||||
export interface PanelSection {
|
||||
id: string;
|
||||
/** The agent for a run; "Task documents" (or the wider label) for the rest. */
|
||||
title: string;
|
||||
run?: LabRun;
|
||||
artifacts: LabArtifact[];
|
||||
/** Items in `needs_review`. */
|
||||
reviewCount: number;
|
||||
/** The newest edit in the section, for a section with no run. */
|
||||
latest: LabArtifact;
|
||||
}
|
||||
|
||||
const needsReview = (a: LabArtifact) => a.review === "needs_review";
|
||||
|
||||
function matchesFilter(a: LabArtifact, filter: PanelFilter): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "review") return needsReview(a);
|
||||
return a.kind === filter;
|
||||
}
|
||||
|
||||
export function buildSections(artifacts: LabArtifact[], filter: PanelFilter = "all"): PanelSection[] {
|
||||
const groups = new Map<string, LabArtifact[]>();
|
||||
for (const artifact of artifacts) {
|
||||
if (!matchesFilter(artifact, filter)) continue;
|
||||
const key = artifact.run?.id ?? "ungrouped";
|
||||
groups.set(key, [...(groups.get(key) ?? []), artifact]);
|
||||
}
|
||||
const sections = [...groups.entries()].map(([id, items]): PanelSection & { at: string } => {
|
||||
const run = items[0].run;
|
||||
const latest = items.reduce((newest, a) => (a.updatedAt > newest.updatedAt ? a : newest), items[0]);
|
||||
return {
|
||||
id,
|
||||
title: run ? run.agent : items.every(isTaskDocument) ? "Task documents" : "Documents and links",
|
||||
run,
|
||||
artifacts: items,
|
||||
reviewCount: items.filter(needsReview).length,
|
||||
latest,
|
||||
// The product's group order: a run by its start, the rest by their newest row.
|
||||
at: run ? run.startedAt : latest.updatedAt,
|
||||
};
|
||||
});
|
||||
sections.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
|
||||
return sections;
|
||||
}
|
||||
|
||||
export interface KindTally {
|
||||
kind: LabKind;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** One entry per kind present, in the vocabulary's order. */
|
||||
export function tallyKinds(artifacts: LabArtifact[]): KindTally[] {
|
||||
return LAB_KINDS.map((kind) => ({ kind, count: artifacts.filter((a) => a.kind === kind).length })).filter((t) => t.count > 0);
|
||||
}
|
||||
|
||||
export const countReview = (artifacts: LabArtifact[]) => artifacts.filter(needsReview).length;
|
||||
|
||||
const PLURAL: Record<LabKind, [string, string]> = {
|
||||
document: ["Document", "Documents"],
|
||||
image: ["Image", "Images"],
|
||||
video: ["Video", "Videos"],
|
||||
pdf: ["PDF", "PDFs"],
|
||||
slides: ["Slide deck", "Slide decks"],
|
||||
sheet: ["Sheet", "Sheets"],
|
||||
archive: ["Archive", "Archives"],
|
||||
code: ["Code file", "Code files"],
|
||||
file: ["File", "Files"],
|
||||
link: ["Link", "Links"],
|
||||
};
|
||||
|
||||
export const kindNoun = (kind: LabKind, count: number) => PLURAL[kind][count === 1 ? 0 : 1];
|
||||
|
||||
/** The same noun inside a sentence: "documents", "slide decks" — and still "PDFs". */
|
||||
export function kindNounInline(kind: LabKind, count: number): string {
|
||||
const noun = kindNoun(kind, count);
|
||||
return kind === "pdf" ? noun : noun.toLowerCase();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark" style="color-scheme: dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>2 · Tint — primitives</title>
|
||||
<!--
|
||||
The direction's primitives on one page, outside the lab's frames:
|
||||
/src/artifact-directions/directions/d2-tint/specimen.html[?theme=light]
|
||||
A builder's aid only; nothing in the lab imports it.
|
||||
-->
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./specimen.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,139 @@
|
||||
import { StrictMode, type ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "../../../index.css";
|
||||
import "./tokens.css";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { KIND_SPECIMENS, STACK_SPECIMENS } from "../../fixtures";
|
||||
import { LAB_KINDS, type LabArtifact } from "../../kit";
|
||||
import { GalleryPreview, usePreviewKind } from "./GalleryPreview";
|
||||
import { GalleryStacks } from "./GalleryStacks";
|
||||
import { KindGlyph, type GlyphSize } from "./KindGlyph";
|
||||
import { FoldedPreview, KindWell, Lead } from "./Lead";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { StateMark } from "./StateMark";
|
||||
import { glyphLabel, metaParts } from "./kinds";
|
||||
|
||||
/**
|
||||
* Builder's aid: every primitive of the direction at every size, for all ten
|
||||
* kinds. Served by the lab's dev server at
|
||||
* /src/artifact-directions/directions/d2-tint/specimen.html — not part of the kit.
|
||||
*/
|
||||
|
||||
const light = new URLSearchParams(window.location.search).get("theme") === "light";
|
||||
document.documentElement.classList.toggle("dark", !light);
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
|
||||
const SIZES: GlyphSize[] = ["mini", "xs", "sm", "md", "lg", "tuck"];
|
||||
const byKind = LAB_KINDS.map((kind) => KIND_SPECIMENS.find((a) => a.kind === kind)).filter((a): a is LabArtifact => Boolean(a));
|
||||
const markdown = KIND_SPECIMENS.find((a) => a.kind === "document" && a.revision == null);
|
||||
const glyphs = markdown ? [...byKind, markdown] : byKind;
|
||||
const previews = KIND_SPECIMENS.filter((a) => a.thumbnailUrl);
|
||||
|
||||
function Block({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="font-mono text-(length:--text-nano) uppercase tracking-(--tracking-label) text-muted-foreground">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ artifact }: { artifact: LabArtifact }) {
|
||||
const [kind, onError] = usePreviewKind(artifact);
|
||||
return (
|
||||
<div data-kind={artifact.kind} className="@container flex flex-col gap-2 rounded-lg bg-(--tint-slab) p-2">
|
||||
<GalleryPreview artifact={artifact} kind={kind} onError={onError} />
|
||||
<MetaLine parts={metaParts(artifact, { facts: true })} className="px-1.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Specimen() {
|
||||
return (
|
||||
<main data-direction="d2-tint" className="flex min-h-screen flex-col gap-10 bg-background p-8 text-foreground">
|
||||
<Block title="Kind glyph — mini · xs · sm · md · lg · tuck (the folder face), on the page and on a slab. A task document has no extension: pictogram alone.">
|
||||
<div className="flex flex-col gap-4">
|
||||
{[false, true].map((onSlab) => (
|
||||
<div key={String(onSlab)} className={onSlab ? "flex flex-wrap gap-6 rounded-lg bg-(--tint-slab) p-4" : "flex flex-wrap gap-6 p-4"}>
|
||||
{glyphs.map((a) => (
|
||||
<div key={a.id} data-kind={a.kind} className="flex items-end gap-2">
|
||||
{SIZES.map((size) => (
|
||||
<KindGlyph key={size} kind={a.kind} label={glyphLabel(a)} size={size} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block title="Lead — the thread row's one box (72×46): a picture, or the xs glyph on its kind well">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg bg-(--tint-slab) p-4">
|
||||
{glyphs.map((a) => (
|
||||
<div key={a.id} data-kind={a.kind}>
|
||||
<Lead artifact={a} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block title="Folded preview — sm · md · lg">
|
||||
<div className="flex flex-wrap items-end gap-6 rounded-lg bg-(--tint-slab) p-4">
|
||||
{previews.map((a) => (
|
||||
<div key={a.id} data-kind={a.kind} className="flex items-end gap-3">
|
||||
<FoldedPreview artifact={a} fold="sm" className="h-11.5 w-18" />
|
||||
<FoldedPreview artifact={a} fold="md" className="h-24 w-36" />
|
||||
<FoldedPreview artifact={a} fold="lg" className="h-40 w-64" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block title="Kind well — md glyph on the kind's quiet fill, all ten kinds side by side">
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{byKind.map((a) => (
|
||||
<div key={a.id} data-kind={a.kind} className="flex flex-col gap-2 rounded-lg bg-(--tint-slab) p-2">
|
||||
<KindWell artifact={a} size="md" className="h-24" />
|
||||
<MetaLine parts={metaParts(a, { facts: true })} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block title="Gallery preview — picture, text sheet (neutral) or kind well (tinted), one per kind">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{glyphs.map((a) => (
|
||||
<Preview key={a.id} artifact={a} />
|
||||
))}
|
||||
</div>
|
||||
</Block>
|
||||
|
||||
<Block title="Stack — a single artifact, three, and a pile; kind tally and review tally">
|
||||
<GalleryStacks groups={STACK_SPECIMENS} />
|
||||
</Block>
|
||||
|
||||
<Block title="State mark — sm · md, on a slab and on the page">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-(--tint-slab) p-3">
|
||||
<StateMark review="needs_review" />
|
||||
<StateMark review="changes_requested" />
|
||||
<StateMark review="needs_review" size="md" />
|
||||
<StateMark review="changes_requested" size="md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
<StateMark review="needs_review" className="bg-(--tint-slab)" />
|
||||
<StateMark review="changes_requested" size="md" className="bg-(--tint-slab)" />
|
||||
</div>
|
||||
</div>
|
||||
</Block>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<TooltipProvider>
|
||||
<Specimen />
|
||||
</TooltipProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
2 · Tint — custom properties, scoped to the wrapper every frame puts around a
|
||||
kit slot. Structure (page, text, radius, type, motion) comes from the app's
|
||||
token layer; only what that layer lacks is minted here. Colours are OKLCH,
|
||||
as in the app's own layer.
|
||||
|
||||
Four groups:
|
||||
1. neutral materials — the slab ladder, derived from the semantic tokens
|
||||
2. kind hues — ONE hue per artifact kind (nine hues for ten kinds: image
|
||||
and video share the media hue), tuned per theme so the label on the
|
||||
glyph holds 4.5:1 (white everywhere; near-black on amber)
|
||||
3. kind wells — the same hue as a quiet fill, set per theme at one
|
||||
lightness and as much chroma as the hue allows, so red stays red and
|
||||
amber stays amber (an alpha mix over the page turned them all brown)
|
||||
4. geometry the Tailwind scale has no step for (folded-corner clips)
|
||||
*/
|
||||
|
||||
[data-direction="d2-tint"] {
|
||||
/* 1 · Neutral materials. A slab is a filled card: no stroke, no shadow. 1.20:1 on the page. */
|
||||
--tint-slab: color-mix(in oklab, var(--foreground) 7.5%, var(--background));
|
||||
/* One step up, still neutral: the hover of a unit whose hue is already spent. */
|
||||
--tint-slab-raised: color-mix(in oklab, var(--foreground) 11.5%, var(--background));
|
||||
/* A filled neutral button on a slab; also the pressed step of a neutral slab. */
|
||||
--tint-button: color-mix(in oklab, var(--foreground) 16%, var(--background));
|
||||
/* A hole punched in a slab back to the page: state pills, and the sheet a text preview is set on. */
|
||||
--tint-well: var(--background);
|
||||
/* Secondary text: at least 4.5:1 on every fill above and on every kind well. */
|
||||
--tint-ink-2: color-mix(in oklab, var(--foreground) 64%, var(--background));
|
||||
/* Pictogram and extension label on a kind hue; the dark one is for amber, the one light hue. */
|
||||
--tint-on-kind: oklch(1 0 0);
|
||||
--tint-on-kind-dark: oklch(0.22 0.04 80);
|
||||
|
||||
/* 2 · Kind hues (light). Violet is left out on purpose: it is the app's Review hue. */
|
||||
--tint-kind-document: oklch(0.528 0.207 262.2);
|
||||
--tint-kind-pdf: oklch(0.556 0.209 22.9);
|
||||
--tint-kind-slides: oklch(0.56 0.175 40.3);
|
||||
--tint-kind-sheet: oklch(0.518 0.132 152.2);
|
||||
--tint-kind-media: oklch(0.555 0.219 352.9);
|
||||
--tint-kind-archive: oklch(0.74 0.156 75.1);
|
||||
--tint-kind-code: oklch(0.516 0.087 204.2);
|
||||
--tint-kind-link: oklch(0.52 0.121 242.6);
|
||||
--tint-kind-file: oklch(0.519 0.017 260.7);
|
||||
|
||||
/* 3 · Kind wells (light): L 0.93, the slab's own lightness, told from it by hue. PDF and slides are pulled apart (rose / apricot). */
|
||||
--tint-well-document: oklch(0.931 0.033 261);
|
||||
--tint-well-pdf: oklch(0.93 0.035 15.7);
|
||||
--tint-well-slides: oklch(0.931 0.043 59.4);
|
||||
--tint-well-sheet: oklch(0.93 0.044 152.4);
|
||||
--tint-well-media: oklch(0.929 0.041 352.9);
|
||||
--tint-well-archive: oklch(0.93 0.044 83.9);
|
||||
--tint-well-code: oklch(0.93 0.045 205.1);
|
||||
--tint-well-link: oklch(0.929 0.037 244.4);
|
||||
--tint-well-file: oklch(0.93 0.009 258.3);
|
||||
|
||||
/*
|
||||
A tile-sized well (gallery) keeps its lightness and hue but gives up part of
|
||||
its chroma to a neutral of the same lightness: a large area reads stronger.
|
||||
*/
|
||||
--tint-well-neutral: oklch(0.93 0 0);
|
||||
--tint-well-lg-share: 70%;
|
||||
|
||||
/* How much white the folded corner's facet takes; how much hue a hovered and a pressed thread slab take. */
|
||||
--tint-facet-lift: 30%;
|
||||
--tint-wash-hover: 9%;
|
||||
--tint-wash-pressed: 15%;
|
||||
|
||||
/* 4 · Geometry. */
|
||||
/* The dot in the mono meta line gets half-cell margins (a full " · " is three cells). */
|
||||
--tint-meta-gap: 0.5ch;
|
||||
/* Optical centring of a play triangle in its disc; on a small poster the disc sits clear of the extension tab. */
|
||||
--tint-play-nudge: 4%;
|
||||
--tint-play-y-sm: 42%;
|
||||
/* Radius of a glyph-sized object (the sm glyph's own corner), and the fold of a preview at each size. */
|
||||
--tint-radius-glyph: 0.4375rem;
|
||||
--tint-fold-sm: 0.75rem;
|
||||
--tint-fold-md: 1.125rem;
|
||||
--tint-fold-lg: 1.75rem;
|
||||
/* The top-right corner cut on the diagonal, one clip per fold size. */
|
||||
--tint-clip-fold-sm: polygon(0 0, calc(100% - var(--tint-fold-sm)) 0, 100% var(--tint-fold-sm), 100% 100%, 0 100%);
|
||||
--tint-clip-fold-md: polygon(0 0, calc(100% - var(--tint-fold-md)) 0, 100% var(--tint-fold-md), 100% 100%, 0 100%);
|
||||
--tint-clip-fold-lg: polygon(0 0, calc(100% - var(--tint-fold-lg)) 0, 100% var(--tint-fold-lg), 100% 100%, 0 100%);
|
||||
/* A neutral scrim behind the play mark on a video poster. */
|
||||
--tint-scrim: color-mix(in oklab, black 55%, transparent);
|
||||
--tint-scrim-strong: color-mix(in oklab, black 75%, transparent);
|
||||
|
||||
/*
|
||||
The flat folder (Artifacts page stacks). No shadow and no gradient: the
|
||||
pocket in front is the ordinary slab, and the back panel with its tab is
|
||||
the one fill a folder adds — a clear neutral step further from the page
|
||||
than the slab, in both themes.
|
||||
*/
|
||||
--tint-folder-back: color-mix(in oklab, var(--foreground) 16%, var(--background));
|
||||
}
|
||||
|
||||
.dark [data-direction="d2-tint"] {
|
||||
--tint-slab: color-mix(in oklab, var(--foreground) 9%, var(--background));
|
||||
--tint-slab-raised: color-mix(in oklab, var(--foreground) 14%, var(--background));
|
||||
--tint-button: color-mix(in oklab, var(--foreground) 20%, var(--background));
|
||||
--tint-ink-2: color-mix(in oklab, var(--foreground) 72%, var(--background));
|
||||
|
||||
/* Kind hues (dark): as bright as the white label allows; amber is a true amber with dark ink. */
|
||||
--tint-kind-document: oklch(0.569 0.21 262.3);
|
||||
--tint-kind-pdf: oklch(0.587 0.217 22.6);
|
||||
--tint-kind-slides: oklch(0.579 0.177 41.4);
|
||||
--tint-kind-sheet: oklch(0.548 0.14 152.4);
|
||||
--tint-kind-media: oklch(0.586 0.227 351.9);
|
||||
--tint-kind-archive: oklch(0.801 0.165 80.1);
|
||||
--tint-kind-code: oklch(0.548 0.092 204.5);
|
||||
--tint-kind-link: oklch(0.554 0.129 243.6);
|
||||
--tint-kind-file: oklch(0.564 0.016 258.4);
|
||||
|
||||
/* Kind wells (dark): the page's own lightness (L 0.21), so a kind well is the neutral well — a hole back to the page — with the kind's hue. About 1.2:1 against the slab; the glyph on it holds 3.6:1 or better. */
|
||||
--tint-well-document: oklch(0.209 0.066 262.8);
|
||||
--tint-well-pdf: oklch(0.211 0.065 16);
|
||||
--tint-well-slides: oklch(0.21 0.051 57.2);
|
||||
--tint-well-sheet: oklch(0.21 0.055 152.5);
|
||||
--tint-well-media: oklch(0.209 0.065 352.4);
|
||||
--tint-well-archive: oklch(0.209 0.043 84.6);
|
||||
--tint-well-code: oklch(0.21 0.036 204.3);
|
||||
--tint-well-link: oklch(0.21 0.052 244);
|
||||
--tint-well-file: oklch(0.211 0.012 254.1);
|
||||
/* A dark colour needs all its chroma to stay a colour: tile-sized wells give none up here. */
|
||||
--tint-well-neutral: oklch(0.21 0 0);
|
||||
--tint-well-lg-share: 100%;
|
||||
|
||||
--tint-facet-lift: 26%;
|
||||
--tint-wash-hover: 11%;
|
||||
--tint-wash-pressed: 18%;
|
||||
|
||||
--tint-folder-back: color-mix(in oklab, var(--foreground) 19%, var(--background));
|
||||
}
|
||||
|
||||
/* Derived per unit: label ink, the fold facet, and the hover and pressed fills of a thread slab. */
|
||||
[data-direction="d2-tint"] [data-kind] {
|
||||
--kind-ink: var(--tint-on-kind);
|
||||
--kind-facet: color-mix(in oklab, var(--kind) calc(100% - var(--tint-facet-lift)), white);
|
||||
--kind-wash: color-mix(in oklab, var(--kind) var(--tint-wash-hover), var(--tint-slab));
|
||||
--kind-wash-pressed: color-mix(in oklab, var(--kind) var(--tint-wash-pressed), var(--tint-slab));
|
||||
--kind-well-lg: color-mix(in oklab, var(--kind-well) var(--tint-well-lg-share), var(--tint-well-neutral));
|
||||
}
|
||||
|
||||
/* A unit names its kind once; everything inside reads `--kind` (the hue) and `--kind-well` (its quiet fill). */
|
||||
[data-direction="d2-tint"] [data-kind="document"] { --kind: var(--tint-kind-document); --kind-well: var(--tint-well-document); }
|
||||
[data-direction="d2-tint"] [data-kind="pdf"] { --kind: var(--tint-kind-pdf); --kind-well: var(--tint-well-pdf); }
|
||||
[data-direction="d2-tint"] [data-kind="slides"] { --kind: var(--tint-kind-slides); --kind-well: var(--tint-well-slides); }
|
||||
[data-direction="d2-tint"] [data-kind="sheet"] { --kind: var(--tint-kind-sheet); --kind-well: var(--tint-well-sheet); }
|
||||
[data-direction="d2-tint"] [data-kind="image"],
|
||||
[data-direction="d2-tint"] [data-kind="video"] { --kind: var(--tint-kind-media); --kind-well: var(--tint-well-media); }
|
||||
[data-direction="d2-tint"] [data-kind="archive"] { --kind: var(--tint-kind-archive); --kind-well: var(--tint-well-archive); --kind-ink: var(--tint-on-kind-dark); }
|
||||
[data-direction="d2-tint"] [data-kind="code"] { --kind: var(--tint-kind-code); --kind-well: var(--tint-well-code); }
|
||||
[data-direction="d2-tint"] [data-kind="link"] { --kind: var(--tint-kind-link); --kind-well: var(--tint-well-link); }
|
||||
[data-direction="d2-tint"] [data-kind="file"] { --kind: var(--tint-kind-file); --kind-well: var(--tint-well-file); }
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Download, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { timeOf } from "./gallery-model";
|
||||
import { REVIEW_LABEL, RING, RING_AFTER, RING_ON, galleryActions, taskHref } from "./kind";
|
||||
import { LEDGE, STAND, STAND_CLIP } from "./ledge";
|
||||
import { ArtifactObject } from "./objects/ArtifactObject";
|
||||
|
||||
/**
|
||||
* GALLERY — one artifact in an opened stack: the object, large, standing on
|
||||
* the row's ledge with its foot behind the lip, and its caption under the
|
||||
* ledge — the title (two lines reserved, so the lines under it align along a
|
||||
* row) with Open and Download at the end of its first line, then when and
|
||||
* who, the author whole. The kind is the object's own shape and marking, so
|
||||
* the caption does not repeat it.
|
||||
*
|
||||
* The title is the link to the artifact in its task. Its hit area and its
|
||||
* focus ring cover the object and the caption — not the empty space above a
|
||||
* short object: the item tells CSS how tall its own object is (`--tac-obj-h`).
|
||||
* Open and Download are separate targets, always visible, never inside the
|
||||
* link.
|
||||
*
|
||||
* Two grid rows per item, shared with the row through subgrid: objects stand
|
||||
* on one ledge however tall they are, and captions start on one line.
|
||||
*/
|
||||
|
||||
const ICON_ACTION = cn(
|
||||
"relative z-10 inline-flex size-6 cursor-pointer items-center justify-center rounded-md text-(--tac-meta) transition-colors hover:bg-(--tac-tray-press) hover:text-foreground",
|
||||
RING,
|
||||
RING_ON.ground,
|
||||
);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/** The shape an object is cut from, and so which of the size's footprints is its height. */
|
||||
function footprint(a: LabArtifact): { box?: string; height: string } {
|
||||
switch (a.kind) {
|
||||
case "image":
|
||||
case "video": {
|
||||
const aspect = clamp(a.aspect ?? 4 / 3, 0.75, 1.9);
|
||||
return { height: aspect < 1.15 ? "var(--tac-print-hs)" : `calc(var(--tac-print-ws) / ${aspect})` };
|
||||
}
|
||||
case "slides":
|
||||
return { box: "slide", height: "var(--tac-slide-h)" };
|
||||
case "sheet":
|
||||
return { box: "ledger", height: "var(--tac-ledger-h)" };
|
||||
case "archive":
|
||||
return { box: "box", height: "var(--tac-box-h)" };
|
||||
case "link":
|
||||
return { box: "card", height: "var(--tac-card-h)" };
|
||||
default:
|
||||
return { box: "page", height: "var(--tac-page-h)" };
|
||||
}
|
||||
}
|
||||
|
||||
export function GalleryItem({ artifact }: { artifact: LabArtifact }) {
|
||||
const actions = galleryActions(artifact);
|
||||
const edited = `Last edited ${artifact.updatedLabel}`;
|
||||
const { box, height } = footprint(artifact);
|
||||
return (
|
||||
// The size attributes are the object's own, so the item resolves the same footprint the object does.
|
||||
<li
|
||||
data-tac-item={artifact.kind}
|
||||
data-tac-size="gl"
|
||||
data-tac-box={box}
|
||||
style={{ "--tac-obj-h": height } as CSSProperties}
|
||||
className="group/object row-span-2 grid min-w-0 grid-rows-subgrid gap-y-0"
|
||||
>
|
||||
<span className={cn("flex min-w-0 items-end", LEDGE)}>
|
||||
<span className="flex" style={STAND_CLIP}>
|
||||
<span className={cn("flex", STAND)}>
|
||||
<ArtifactObject artifact={artifact} size="gl" flag="top" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<div className="relative flex min-w-0 max-w-(--tac-gl-cap) flex-col gap-0.5 pt-(--tac-gl-gap)">
|
||||
<div className="flex items-start gap-1">
|
||||
<h4 className="min-h-10 min-w-0 flex-1 text-sm font-medium leading-5 text-foreground">
|
||||
<a
|
||||
href={taskHref(artifact)}
|
||||
title={`${artifact.title}\n${edited} · ${artifact.author}`}
|
||||
className={cn(
|
||||
"line-clamp-2 wrap-anywhere decoration-(--tac-meta) underline-offset-4 after:absolute after:-inset-x-(--tac-gl-ring) after:-bottom-(--tac-gl-ring) after:top-(--tac-gl-reach) after:rounded-lg hover:underline focus-visible:after:ring-offset-(--tac-ground)",
|
||||
RING_AFTER,
|
||||
)}
|
||||
>
|
||||
{artifact.title}
|
||||
{artifact.review ? <span className="sr-only"> — {REVIEW_LABEL[artifact.review]}</span> : null}
|
||||
</a>
|
||||
</h4>
|
||||
{actions.length > 0 ? (
|
||||
<span className="-mr-1 -mt-0.5 flex shrink-0 items-center">
|
||||
{actions.map((action) => (
|
||||
<a key={action.label} href={action.href} aria-label={`${action.label}: ${artifact.title}`} title={action.label} className={ICON_ACTION}>
|
||||
{action.icon === "download" ? <Download className="size-3.5" aria-hidden /> : <ExternalLink className="size-3.5" aria-hidden />}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* When and who, across the whole caption; a long name wraps rather than hides. */}
|
||||
<p className="wrap-anywhere text-(length:--text-micro) leading-4 text-(--tac-meta)">
|
||||
<time dateTime={artifact.updatedAt} className="whitespace-nowrap font-mono">
|
||||
<span className="sr-only">Last edited {artifact.dayLabel}, </span>
|
||||
{timeOf(artifact)}
|
||||
</time>
|
||||
<span aria-hidden className="px-1 opacity-70">
|
||||
·
|
||||
</span>
|
||||
{artifact.author}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { LabGroup } from "../../kit";
|
||||
import { countLabel, daysOf, reviewCountOf } from "./gallery-model";
|
||||
import { GalleryItem } from "./GalleryItem";
|
||||
import { KindTally } from "./KindTally";
|
||||
import { Folder } from "./objects/Folder";
|
||||
import { StateFlag } from "./StateFlag";
|
||||
|
||||
/**
|
||||
* GALLERY — an opened stack: the folder's contents taken out and stood up by
|
||||
* day.
|
||||
*
|
||||
* First the folder itself — the same one as on the shelf, now open and
|
||||
* empty, its tab still the task identifier and its "Review · N" flag still
|
||||
* on the pocket's edge — with the task's title as the page's heading beside
|
||||
* it, then the count, the date and the tally by kind. Then the artifacts by
|
||||
* the day they were last edited, newest day first: each day under a heading
|
||||
* with its count and a tick ruler down the left margin, on a mat in the
|
||||
* ground tone, the objects in rows on ledges with their captions under
|
||||
* (`GalleryItem`). No tiles: the paper is the tile, the mat is the ground
|
||||
* every surface shares.
|
||||
*/
|
||||
|
||||
function Summary({ group }: { group: LabGroup }) {
|
||||
const review = reviewCountOf(group.artifacts);
|
||||
return (
|
||||
<header className="flex items-end gap-5">
|
||||
{/* A little room each side: the open front is wider at its top than the board behind it. */}
|
||||
<span className="block w-52 shrink-0 px-2">
|
||||
<Folder
|
||||
size="hero"
|
||||
open
|
||||
tab={<span className="truncate pl-3 font-mono text-(length:--text-micro) font-medium tracking-wide text-(--tac-folder-ink)">{group.taskIdentifier}</span>}
|
||||
flag={review > 0 ? <StateFlag review="needs_review" count={review} /> : null}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1.5 pb-1.5">
|
||||
<h2 className="line-clamp-2 wrap-anywhere text-xl font-semibold leading-7 text-foreground" title={group.title}>
|
||||
{group.title}
|
||||
</h2>
|
||||
<p className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-sm text-(--tac-meta)">
|
||||
<span className="font-medium text-foreground">
|
||||
<span className="font-mono text-(length:--tac-mono-scale)">{group.count}</span> {countLabel(group.count)}
|
||||
</span>
|
||||
<span>
|
||||
Updated <span className="font-mono text-(length:--tac-mono-scale)">{group.updatedLabel}</span>
|
||||
</span>
|
||||
{review > 0 ? <span className="sr-only">{review} to review</span> : null}
|
||||
</p>
|
||||
<KindTally artifacts={group.artifacts} className="flex flex-wrap items-baseline gap-y-0.5 text-xs leading-4 text-(--tac-meta)" />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryStack({ group }: { group: LabGroup }) {
|
||||
const days = daysOf(group.artifacts);
|
||||
if (group.artifacts.length === 0) return <p className="py-16 text-center text-sm font-medium text-foreground">No artifacts in this stack match the current filters.</p>;
|
||||
return (
|
||||
<div className="flex flex-col gap-7 pt-1">
|
||||
<Summary group={group} />
|
||||
{days.map((day) => (
|
||||
// `day` is the container a day's objects measure themselves against (tokens-gallery.css).
|
||||
<section key={day.day} aria-label={`${day.label}, ${day.items.length} ${countLabel(day.items.length)}`} className="@container/day relative flex flex-col gap-3.5 pl-(--tac-ruler-gutter)">
|
||||
{/* The ruler: a long tick at the day, short ones down its rows. */}
|
||||
<span aria-hidden className="absolute bottom-0 left-0 top-2.5 block w-(--tac-ruler-w) bg-(image:--tac-ruler)" />
|
||||
<span aria-hidden className="absolute left-0 top-2.5 block h-px w-(--tac-ruler-major) bg-(--tac-rule-strong)" />
|
||||
<h3 className="flex items-baseline gap-2.5 text-sm font-semibold leading-5 text-foreground">
|
||||
{day.label}
|
||||
<span className="text-xs font-normal text-(--tac-meta)">
|
||||
<span className="font-mono text-(length:--tac-mono-scale)">{day.items.length}</span> {countLabel(day.items.length)}
|
||||
</span>
|
||||
</h3>
|
||||
{/* The mat. It clips on the x axis: each item's ledge runs out to its edges. */}
|
||||
<ul
|
||||
data-tac-mat
|
||||
className="grid grid-cols-(--tac-gl-cols) gap-x-(--tac-mat-gap) gap-y-6 overflow-x-clip rounded-xl bg-(--tac-ground) bg-(image:--tac-ground-grain) px-(--tac-mat-pad) pb-4 pt-7 shadow-(--tac-tray-rim)"
|
||||
>
|
||||
{day.items.map((artifact) => (
|
||||
<GalleryItem key={artifact.id} artifact={artifact} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact, LabGroup } from "../../kit";
|
||||
import { countLabel, peekOf, pileOf, reviewCountOf, standsTall, widthOf } from "./gallery-model";
|
||||
import { RING, RING_ON, metaParts } from "./kind";
|
||||
import { KindTally } from "./KindTally";
|
||||
import { LEDGE, LIP, LIP_CLIP, STAND, STAND_CLIP } from "./ledge";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { ArtifactObject } from "./objects/ArtifactObject";
|
||||
import { Folder } from "./objects/Folder";
|
||||
import { StateFlag } from "./StateFlag";
|
||||
|
||||
/**
|
||||
* GALLERY — the shelf: one folder per task.
|
||||
*
|
||||
* A stack of several artifacts is a folder with its real contents standing
|
||||
* in it — the sheets and prints are the stack's own artifacts, picked so a
|
||||
* mixed stack shows its mix — and separate sheet tops behind them when it
|
||||
* holds more than it shows (one, two, or four for a full folder). The
|
||||
* pocket's tab is labelled with the task identifier; the pocket carries the
|
||||
* title, a one-line tally by kind, the count and the date, and a
|
||||
* "Review · N" flag stands on its top edge when anything inside waits for
|
||||
* review. Hovering lifts the contents out and fans them.
|
||||
*
|
||||
* A stack of one is not a folder: it is the object itself, stood on a mat in
|
||||
* the ground tone with the same lines beside it (a sheet, its foot behind
|
||||
* the mat's lip, as in a thread row) or under it (anything wider than tall,
|
||||
* on a ledge, as in an opened stack).
|
||||
*/
|
||||
|
||||
const SLOT_LETTERS = "abcd";
|
||||
|
||||
const SLOT =
|
||||
"absolute bottom-0 left-(--tac-slot-left) block origin-bottom -translate-x-1/2 translate-y-(--tac-slot-y) rotate-(--tac-fan) transition-(--tac-tp-fan) duration-(--motion-duration-base) ease-(--motion-ease-out-expo) motion-safe:group-hover/object:translate-y-(--tac-slot-y-open) motion-safe:group-hover/object:rotate-(--tac-fan-open) motion-safe:group-focus-visible/object:translate-y-(--tac-slot-y-open) motion-safe:group-focus-visible/object:rotate-(--tac-fan-open)";
|
||||
|
||||
/** The stack's own artifacts, standing in the pocket. Each says how wide it is, so its slot can keep it inside the folder's sides. */
|
||||
function FolderContents({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
return (
|
||||
<>
|
||||
{artifacts.map((artifact, index) => (
|
||||
<span
|
||||
key={artifact.id}
|
||||
data-tac-slot={`${artifacts.length}${SLOT_LETTERS[index]}`}
|
||||
data-tac-deep={standsTall(artifact) ? "" : undefined}
|
||||
className={SLOT}
|
||||
style={{ "--tac-slot-w": `${widthOf(artifact)}rem` } as CSSProperties}
|
||||
>
|
||||
<ArtifactObject artifact={artifact} size="fd" flag="nub" repose={2} />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const FOCUS = cn(RING, RING_ON.canvas, "focus-visible:ring-offset-4");
|
||||
|
||||
function Count({ group, className }: { group: LabGroup; className?: string }) {
|
||||
return (
|
||||
<span className={className}>
|
||||
<span className="whitespace-nowrap">
|
||||
<span className="font-mono text-(length:--tac-mono-scale)">{group.count}</span> {countLabel(group.count)}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">
|
||||
Updated <span className="font-mono text-(length:--tac-mono-scale)">{group.updatedLabel}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const stackLabel = (group: LabGroup, review: number) =>
|
||||
`Open stack ${group.taskIdentifier} ${group.title}: ${group.count} ${countLabel(group.count)}, updated ${group.updatedLabel}${review > 0 ? `, ${review} to review` : ""}`;
|
||||
|
||||
function StackFolder({ group }: { group: LabGroup }) {
|
||||
const peek = peekOf(group);
|
||||
const review = reviewCountOf(group.artifacts);
|
||||
return (
|
||||
<a href={`#stack-${group.taskIdentifier}`} aria-label={stackLabel(group, review)} data-tac-stack="many" className={cn("group/object block rounded-2xl", FOCUS)}>
|
||||
<Folder
|
||||
size="shelf"
|
||||
pile={pileOf(group.count, peek.length)}
|
||||
contents={<FolderContents artifacts={peek} />}
|
||||
tab={<span className="truncate pl-3.5 font-mono text-(length:--text-micro) font-medium tracking-wide text-(--tac-folder-ink)">{group.taskIdentifier}</span>}
|
||||
flag={review > 0 ? <StateFlag review="needs_review" count={review} /> : null}
|
||||
>
|
||||
<span className="flex min-h-0 flex-1 flex-col px-3.5 pb-3 pt-2.5">
|
||||
<span className="line-clamp-2 wrap-anywhere text-base font-semibold leading-5.5 text-(--tac-folder-ink)" title={group.title}>
|
||||
{group.title}
|
||||
</span>
|
||||
<KindTally artifacts={group.artifacts} className="block truncate pt-1 text-xs leading-4 text-(--tac-folder-meta)" />
|
||||
<Count group={group} className="mt-auto flex items-baseline justify-between gap-3 pt-1 text-xs leading-4 text-(--tac-folder-meta)" />
|
||||
</span>
|
||||
</Folder>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const ID = "shrink-0 font-mono text-(length:--text-micro) font-medium tracking-wide text-(--tac-meta)";
|
||||
const MAT = "absolute inset-x-0 bottom-0 top-(--tac-mat-top) block rounded-xl bg-(--tac-ground) bg-(image:--tac-ground-grain) shadow-(--tac-tray-rim) transition-colors duration-(--motion-duration-fast) group-hover/object:bg-(--tac-tray-hover)";
|
||||
|
||||
/**
|
||||
* One artifact is not a stack: the object stands on a mat by itself, labelled like a folder's pocket. A sheet stands
|
||||
* at the mat's left with its foot behind the lip and its head proud of the mat's top edge, its lines beside it;
|
||||
* anything wider than tall stands on a ledge with its lines under, the identifier leading the title as it does in
|
||||
* the product.
|
||||
*/
|
||||
function StackLone({ group }: { group: LabGroup }) {
|
||||
const artifact = group.artifacts[0];
|
||||
const review = reviewCountOf(group.artifacts);
|
||||
const tall = standsTall(artifact);
|
||||
return (
|
||||
<a
|
||||
href={`#stack-${group.taskIdentifier}`}
|
||||
aria-label={stackLabel(group, review)}
|
||||
data-tac-stack="one"
|
||||
data-tac-folder="shelf"
|
||||
className={cn("group/object @container/lone relative flex h-(--tac-fol-h) rounded-xl", tall ? "items-end" : "flex-col justify-end overflow-x-clip px-4", FOCUS)}
|
||||
>
|
||||
<span aria-hidden className={MAT} />
|
||||
{tall ? (
|
||||
<>
|
||||
<span className="relative z-10 mb-(--tac-row-foot) ml-5 flex shrink-0 @max-2xs/lone:ml-3" style={STAND_CLIP}>
|
||||
<ArtifactObject artifact={artifact} size="gl" flag="top" />
|
||||
</span>
|
||||
<span aria-hidden className={cn(LIP_CLIP, "top-(--tac-mat-top) rounded-xl")}>
|
||||
<span className={LIP} />
|
||||
</span>
|
||||
{/* In a compact cell (under 18rem) the lines have about 5rem: smaller type, and the date may wrap. */}
|
||||
<span className="relative ml-5 mr-4 flex min-w-0 flex-1 flex-col gap-1 pb-5 @max-2xs/lone:ml-3 @max-2xs/lone:mr-3 @max-2xs/lone:pb-4">
|
||||
<span className={ID}>{group.taskIdentifier}</span>
|
||||
<span className="line-clamp-4 wrap-anywhere text-base font-semibold leading-5.5 text-foreground @max-2xs/lone:text-sm @max-2xs/lone:leading-5" title={group.title}>
|
||||
{group.title}
|
||||
</span>
|
||||
<MetaLine parts={metaParts(artifact)} className="text-xs leading-4 text-(--tac-meta)" />
|
||||
<Count group={group} className="flex flex-col pt-2 text-xs leading-4 text-(--tac-meta) @max-2xs/lone:*:whitespace-normal" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className={cn("block", LEDGE)}>
|
||||
<span className="flex items-end" style={STAND_CLIP}>
|
||||
<span className={cn("flex", STAND)}>
|
||||
<ArtifactObject artifact={artifact} size="gl" flag="top" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="relative flex min-w-0 flex-col gap-0.5 pb-3 pt-(--tac-gl-gap)">
|
||||
<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className={ID}>{group.taskIdentifier}</span>
|
||||
<span className="truncate text-sm font-semibold leading-5 text-foreground" title={group.title}>
|
||||
{group.title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-3 text-xs leading-4 text-(--tac-meta)">
|
||||
<MetaLine parts={metaParts(artifact).slice(0, 2)} />
|
||||
<Count group={group} className="flex items-baseline gap-3" />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function GalleryStacks({ groups }: { groups: LabGroup[] }) {
|
||||
if (groups.length === 0) return <p className="py-16 text-center text-sm font-medium text-foreground">No artifact stacks yet.</p>;
|
||||
return (
|
||||
// The shelf, measured: under 40rem it keeps two columns of compact folders (tokens-gallery.css).
|
||||
<div className="@container/shelf">
|
||||
<ul data-tac-shelf className="grid grid-cols-(--tac-shelf-cols) gap-x-6 gap-y-10 pt-1">
|
||||
{groups.map((group) => (
|
||||
<li key={group.id} className="min-w-0">
|
||||
{group.count > 1 ? <StackFolder group={group} /> : <StackLone group={group} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Fragment } from "react";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { tallyOf } from "./gallery-model";
|
||||
|
||||
/**
|
||||
* GALLERY — what a stack holds, by kind: "Document 7 · Slides 3 · Image 3 …",
|
||||
* largest group first, counts in mono. On a folder's pocket (one line) and
|
||||
* under an opened stack's title (wrapping). Colour, size and wrapping come
|
||||
* from the caller.
|
||||
*/
|
||||
export function KindTally({ artifacts, className }: { artifacts: readonly LabArtifact[]; className?: string }) {
|
||||
const tally = tallyOf(artifacts);
|
||||
return (
|
||||
<span className={className} title={tally.map((entry) => `${entry.label} ${entry.count}`).join(" · ")}>
|
||||
{tally.map((entry, index) => (
|
||||
<Fragment key={entry.label}>
|
||||
{index > 0 ? (
|
||||
<span aria-hidden className="px-1.5 opacity-70">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<span className="whitespace-nowrap">
|
||||
{entry.label} <span className="font-mono text-(length:--tac-mono-scale)">{entry.count}</span>
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Fragment } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MetaPart } from "./kind";
|
||||
|
||||
/**
|
||||
* "PDF · 57.7 KB · 12 pages": the kind in the UI face, machine values (sizes,
|
||||
* revisions, counts, hosts) in mono, separated by a middle dot. Colour and
|
||||
* size come from the caller.
|
||||
*/
|
||||
export function MetaLine({ parts, className }: { parts: MetaPart[]; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex min-w-0 items-baseline whitespace-nowrap", className)}>
|
||||
{parts.map((part, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 ? (
|
||||
<span aria-hidden className="px-1 opacity-70">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
{/* Mono runs a size smaller (0.92em) so it sits level with the UI face beside it. */}
|
||||
<span className={cn(index === parts.length - 1 && "truncate")}>
|
||||
{part.lead ? `${part.lead} ` : null}
|
||||
<span className={cn(part.mono && "font-mono text-(length:--tac-mono-scale)")}>{part.text}</span>
|
||||
{part.unit ? ` ${part.unit}` : null}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Download, ExternalLink, Maximize2, MessageSquare, PanelRight, type LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { RING, RING_ON, panelActions, panelIconActions, type ActionIcon, type ArtifactAction } from "./kind";
|
||||
|
||||
/**
|
||||
* PANEL — what one does with an artifact, as ordinary buttons under its
|
||||
* caption: the product's wording (Read document, View image, Open file,
|
||||
* Download file …), the first one outlined and the rest quiet. A Markdown
|
||||
* file keeps its comment count and its three small targets (Open in tab,
|
||||
* Open raw, Download) as icon buttons at the end of the row.
|
||||
*
|
||||
* The first action also answers a click on an object in a slot: its hit area
|
||||
* is stretched over the slot beside the caption.
|
||||
*
|
||||
* Focus: the repo Button's ring is half-transparent, under 2:1 on the desk.
|
||||
* Here every button takes the kit's ring instead — solid, with a gap in the
|
||||
* desk's colour.
|
||||
*/
|
||||
|
||||
const ICON: Record<ActionIcon, LucideIcon> = { open: PanelRight, gallery: Maximize2, download: Download, external: ExternalLink };
|
||||
|
||||
/** Covers the slot (the row is the positioned ancestor), so the object is the first action's target too. */
|
||||
const OVER_SLOT = "@3xs/desk:after:absolute @3xs/desk:after:inset-y-0 @3xs/desk:after:left-0 @3xs/desk:after:w-(--tac-stage-w) @3xs/desk:after:cursor-pointer";
|
||||
|
||||
/** The kit's focus ring on a repo Button, in place of its own. */
|
||||
export const BUTTON_RING = cn("focus-visible:border-transparent", RING, RING_ON.desk);
|
||||
|
||||
function TextAction({ action, title, primary, overSlot }: { action: ArtifactAction; title: string; primary: boolean; overSlot: boolean }) {
|
||||
const label = `${action.label}: ${title}`;
|
||||
const className = cn("h-7 px-2.5", BUTTON_RING, primary ? overSlot && OVER_SLOT : "relative text-(--tac-meta) hover:text-foreground");
|
||||
const content = (
|
||||
<>
|
||||
{action.label}
|
||||
{action.icon === "external" ? <ExternalLink aria-hidden /> : null}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<Button asChild={Boolean(action.href)} type={action.href ? undefined : "button"} variant={primary ? "outline" : "ghost"} size="xs" className={className} aria-label={label}>
|
||||
{action.href ? <a href={action.href}>{content}</a> : content}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAction({ action, title }: { action: ArtifactAction; title: string }) {
|
||||
const Icon = ICON[action.icon];
|
||||
return (
|
||||
<Button asChild variant="ghost" size="icon-xs" className={cn("relative text-(--tac-meta) hover:text-foreground", BUTTON_RING)}>
|
||||
<a href={action.href} aria-label={`${action.label}: ${title}`} title={action.label}>
|
||||
<Icon aria-hidden />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelActions({ artifact, overSlot }: { artifact: LabArtifact; /** The object lies in a slot beside the caption (not across the desk). */ overSlot: boolean }) {
|
||||
const [primary, ...rest] = panelActions(artifact);
|
||||
const icons = panelIconActions(artifact);
|
||||
const comments = artifact.commentCount;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-1 gap-y-0.5 pt-1.5">
|
||||
<TextAction action={primary} title={artifact.title} primary overSlot={overSlot} />
|
||||
{rest.map((action) => (
|
||||
<TextAction key={action.label} action={action} title={artifact.title} primary={false} overSlot={false} />
|
||||
))}
|
||||
{comments != null || icons.length > 0 ? (
|
||||
<span className="ml-auto flex items-center">
|
||||
{comments != null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className={cn("relative px-1.5 text-(--tac-meta) hover:text-foreground", BUTTON_RING)}
|
||||
aria-label={`${comments} ${comments === 1 ? "comment" : "comments"}: ${artifact.title}`}
|
||||
>
|
||||
<MessageSquare aria-hidden />
|
||||
<span className="font-mono">{comments}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{icons.map((action) => (
|
||||
<IconAction key={action.label} action={action} title={artifact.title} />
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { REVIEW_VAR } from "./kind";
|
||||
import { ArtifactObject } from "./objects/ArtifactObject";
|
||||
import { onlyToReview, sectionsOf } from "./panel-order";
|
||||
import { PanelDivider } from "./PanelDivider";
|
||||
import { BUTTON_RING } from "./PanelActions";
|
||||
import { PanelItem } from "./PanelItem";
|
||||
|
||||
/**
|
||||
* PANEL — the task's shelf: everything the task has produced, laid out on
|
||||
* the desk.
|
||||
*
|
||||
* One column of objects with captions (`PanelItem`), sectioned by the run
|
||||
* that produced them behind a tabbed divider (`PanelDivider`); what has no
|
||||
* run — task documents, links — is one more section. Newest first, as in the
|
||||
* product. A two-way switch at the top narrows the desk to what is flagged
|
||||
* for review. Each section is the `desk` container its rows measure
|
||||
* themselves against.
|
||||
*/
|
||||
|
||||
type Filter = "all" | "review";
|
||||
|
||||
const FLAG = { "--tac-flag": REVIEW_VAR.needs_review, clipPath: "var(--tac-clip-flag)" } as CSSProperties;
|
||||
|
||||
function FilterButton({ pressed, onPress, count, children }: { pressed: boolean; onPress: () => void; count: number; children: ReactNode }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
aria-pressed={pressed}
|
||||
onClick={onPress}
|
||||
className={cn("h-7 gap-1.5 px-2 text-(--tac-meta) aria-pressed:bg-(--tac-key) aria-pressed:text-foreground aria-pressed:shadow-(--tac-key-edge)", BUTTON_RING)}
|
||||
>
|
||||
{children}
|
||||
<span className="font-mono text-(length:--text-micro)">{count}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const BLANK: LabArtifact = { id: "empty", kind: "file", title: "", author: "", updatedAt: "", updatedLabel: "", dayLabel: "", taskIdentifier: "", taskTitle: "" };
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-1 py-6">
|
||||
<ArtifactObject artifact={BLANK} size="sm" flag="none" repose={2} />
|
||||
<p className="text-sm text-(--tac-meta)">No artifacts yet. Work products, documents, and agent-produced files will appear here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBody({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
const [filter, setFilter] = useState<Filter>("all");
|
||||
const sections = useMemo(() => sectionsOf(artifacts), [artifacts]);
|
||||
const toReview = sections.reduce((sum, section) => sum + section.reviewCount, 0);
|
||||
const shown = filter === "review" && toReview > 0 ? onlyToReview(sections) : sections;
|
||||
|
||||
if (artifacts.length === 0) return <Empty />;
|
||||
return (
|
||||
<div data-tac-desk className="-m-4 flex flex-col gap-7 bg-(--tac-desk) bg-(image:--tac-ground-grain) p-4 pb-6 shadow-(--tac-desk-rim)">
|
||||
{toReview > 0 ? (
|
||||
<div role="group" aria-label="Show" className="-mb-2 flex items-center gap-1">
|
||||
<FilterButton pressed={filter === "all"} onPress={() => setFilter("all")} count={artifacts.length}>
|
||||
All
|
||||
</FilterButton>
|
||||
<FilterButton pressed={filter === "review"} onPress={() => setFilter("review")} count={toReview}>
|
||||
<span aria-hidden className="block h-2.5 w-3.5 bg-(--tac-flag)" style={FLAG} />
|
||||
To review
|
||||
</FilterButton>
|
||||
</div>
|
||||
) : null}
|
||||
{shown.map((section) => (
|
||||
<section key={section.id} aria-label={section.run ? `${section.label}, run started ${section.run.startedLabel}` : section.label} className="@container/desk flex flex-col">
|
||||
<PanelDivider section={section} />
|
||||
<ul className="flex flex-col">
|
||||
{section.items.map((artifact) => (
|
||||
<PanelItem key={artifact.id} artifact={artifact} showAuthor={!section.run} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { countLabel } from "./gallery-model";
|
||||
import type { PanelSection } from "./panel-order";
|
||||
import { StateFlag } from "./StateFlag";
|
||||
|
||||
/**
|
||||
* PANEL — the divider that introduces a run: folder board with a tab, as in
|
||||
* a file drawer. The tab names the agent and counts what the run left. If
|
||||
* anything under it waits for review, a flag with the number stands on the
|
||||
* far end of the board's edge — clear of the tab's label and of the flag on
|
||||
* the first object below. The run's start time is written under the board,
|
||||
* at every pane width.
|
||||
*/
|
||||
|
||||
const SHADOW = { filter: "var(--tac-divider-shadow)" } as const;
|
||||
|
||||
export function PanelDivider({ section }: { section: PanelSection }) {
|
||||
return (
|
||||
<header className="flex flex-col gap-1.5">
|
||||
<div className="relative flex items-end justify-between gap-3 pb-(--tac-divider-bar)">
|
||||
<span className="relative flex min-w-0 max-w-(--tac-divider-max)" style={SHADOW}>
|
||||
<h3
|
||||
className="flex h-(--tac-divider-h) min-w-0 items-center gap-2 rounded-tl-md bg-(--tac-folder) bg-(image:--tac-board-grain) pl-2.5 pr-0.5 text-xs font-medium text-(--tac-folder-ink)"
|
||||
title={section.run ? `${section.label} · run started ${section.run.startedLabel}` : section.label}
|
||||
>
|
||||
<span className="truncate">{section.label}</span>
|
||||
<span className="font-mono text-(length:--text-micro) font-normal text-(--tac-folder-meta)">
|
||||
{section.items.length}
|
||||
<span className="sr-only"> {countLabel(section.items.length)}</span>
|
||||
</span>
|
||||
</h3>
|
||||
{/* The tab's shoulder. */}
|
||||
<svg aria-hidden viewBox="0 0 16 26" preserveAspectRatio="none" fill="currentColor" className="h-(--tac-divider-h) w-4 shrink-0 text-(--tac-folder)">
|
||||
<path d="M0 0C8 0 8 26 16 26H0z" />
|
||||
</svg>
|
||||
</span>
|
||||
{/* Stuck to the board's edge by its foot, the rest standing proud. */}
|
||||
{section.reviewCount > 0 ? <StateFlag review="needs_review" count={section.reviewCount} className="relative z-10 -mb-(--tac-divider-bar) mr-2 shrink-0" /> : null}
|
||||
{/* The board's top edge, running the width of the pane. */}
|
||||
<span aria-hidden className="absolute inset-x-0 bottom-0 block h-(--tac-divider-bar) rounded-r-full bg-(--tac-folder)" />
|
||||
</div>
|
||||
{section.run ? (
|
||||
<p className="pl-0.5 text-(length:--text-micro) leading-4 text-(--tac-meta)">
|
||||
Run started{" "}
|
||||
<time dateTime={section.run.startedAt} className="font-mono">
|
||||
{section.run.startedLabel}
|
||||
</time>
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { REVIEW_LABEL, excerptLead, isTaskDocument, metaParts, panelActions, splitName } from "./kind";
|
||||
import { LIP } from "./ledge";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { ArtifactObject, isPortraitKind, liesAcross } from "./objects/ArtifactObject";
|
||||
import { PanelActions } from "./PanelActions";
|
||||
|
||||
/**
|
||||
* PANEL — one artifact on the desk. Two ways of lying there:
|
||||
*
|
||||
* in a slot paper and things (`pn`). The object is at the left with the
|
||||
* caption beside it. A sheet is wide enough to read — headline
|
||||
* about 13px, first lines about 10px — and its foot slides
|
||||
* under the rail that ends the row; slides, ledgers, boxes and
|
||||
* address cards lie whole above it. Below a 16rem body the
|
||||
* caption goes under and the slot takes the whole width.
|
||||
* across pictures (`pw`): an image, a video — playable in place, its
|
||||
* own controls — or a link with a preview. As wide as the
|
||||
* desk, the caption under it.
|
||||
*
|
||||
* The review flag stands on the object's top edge. No card: the caption is
|
||||
* plain UI type — title, kind and machine values, the summary (a document
|
||||
* without one gets its own first lines), the filename and, for a file, its
|
||||
* content type, who and when — with the actions as ordinary buttons. Every
|
||||
* row ends in the rail, the same lip a thread row's tray has.
|
||||
*/
|
||||
|
||||
const SLOT = { clipPath: "var(--tac-clip-slot)" } as const;
|
||||
/** A sheet's slot takes the sheet's own size attributes, so it can measure itself against the page (tokens-panel.css). */
|
||||
const SHEET_SLOT = { "data-tac-size": "pn", "data-tac-box": "page" } as const;
|
||||
|
||||
/** Kinds whose card in the product carries a content-type line. */
|
||||
const FILE_KINDS = new Set(["pdf", "slides", "sheet", "archive", "code", "file"]);
|
||||
|
||||
function Slot({ artifact }: { artifact: LabArtifact }) {
|
||||
const sheet = isPortraitKind(artifact.kind);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"relative block w-full shrink-0 @3xs/desk:w-(--tac-stage-w)",
|
||||
sheet ? "h-(--tac-stage-min) @3xs/desk:h-auto @3xs/desk:min-h-(--tac-stage-min)" : "pb-1 @3xs/desk:min-h-(--tac-stage-min) @3xs/desk:pb-5",
|
||||
)}
|
||||
style={sheet ? SLOT : undefined}
|
||||
{...(sheet ? SHEET_SLOT : null)}
|
||||
>
|
||||
<span className={cn("flex justify-center", sheet && "absolute inset-x-0 top-(--tac-slot-top)")}>
|
||||
<ArtifactObject artifact={artifact} size="pn" flag="top" />
|
||||
</span>
|
||||
{/* Stacked, the slot ends in its own rail; beside the caption, the row's rail does it. */}
|
||||
{sheet ? <span aria-hidden className={cn(LIP, "@3xs/desk:hidden")} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture across the desk. It answers a click as the first action does; the keyboard and assistive technology use
|
||||
* that action's button in the caption, so this copy of the target is out of both. A video is not a copy of anything:
|
||||
* it is the player.
|
||||
*/
|
||||
function Across({ artifact }: { artifact: LabArtifact }) {
|
||||
const holder = "block w-full max-w-(--tac-wide-max) px-1.5";
|
||||
if (artifact.kind === "video") {
|
||||
return (
|
||||
<span className={holder}>
|
||||
<ArtifactObject artifact={artifact} size="pw" flag="top" player />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const object = <ArtifactObject artifact={artifact} size="pw" flag="top" />;
|
||||
const [action] = panelActions(artifact);
|
||||
return action.href ? (
|
||||
<a href={action.href} tabIndex={-1} aria-hidden className={cn(holder, "cursor-pointer")}>
|
||||
{object}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" tabIndex={-1} aria-hidden className={cn(holder, "cursor-pointer text-left")}>
|
||||
{object}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-baseline">
|
||||
<span aria-hidden className="w-3 shrink-0 text-center">
|
||||
·
|
||||
</span>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelItem({ artifact, showAuthor }: { artifact: LabArtifact; /** False under a run's divider, which already names the agent. */ showAuthor: boolean }) {
|
||||
const [base, ext] = splitName(artifact);
|
||||
const name = artifact.kind === "link" ? (artifact.url ?? base) : `${base}${ext}`;
|
||||
const across = liesAcross(artifact);
|
||||
// A document with no summary of its own is previewed by its opening, in type one can read.
|
||||
const lead = artifact.summary ?? (artifact.kind === "document" ? excerptLead(artifact) : "");
|
||||
const mime = FILE_KINDS.has(artifact.kind) || (artifact.kind === "document" && !isTaskDocument(artifact)) ? artifact.contentType : undefined;
|
||||
return (
|
||||
<li data-tac-item={artifact.kind} className={cn("group/object relative flex flex-col gap-y-3 pt-4.5", across ? "items-start" : "gap-x-(--tac-slot-gap) @3xs/desk:flex-row")}>
|
||||
{across ? <Across artifact={artifact} /> : <Slot artifact={artifact} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 self-stretch pb-4.5">
|
||||
<h4 className="line-clamp-3 wrap-anywhere text-sm font-medium leading-5 text-foreground" title={artifact.title}>
|
||||
{artifact.title}
|
||||
{/* Across the desk the object, flag included, is a hidden copy of the first action. */}
|
||||
{across && artifact.kind !== "video" && artifact.review ? <span className="sr-only"> — {REVIEW_LABEL[artifact.review]}</span> : null}
|
||||
</h4>
|
||||
<MetaLine parts={metaParts(artifact)} className="text-xs leading-4 text-(--tac-meta)" />
|
||||
{lead ? (
|
||||
<p className="line-clamp-2 wrap-anywhere pt-0.5 text-xs leading-4 text-(--tac-meta)" title={artifact.summary}>
|
||||
{lead}
|
||||
</p>
|
||||
) : null}
|
||||
{/* The filename, whole, and a file's content type under it: both wrap rather than hide. */}
|
||||
<p className="wrap-anywhere pt-1 font-mono text-(length:--text-micro) leading-4 text-(--tac-meta)">
|
||||
{name}
|
||||
{mime ? <span className="block opacity-85">{mime}</span> : null}
|
||||
</p>
|
||||
{/* Who and when. Each part leads with its dot; the first dot on a line falls in the clipped margin, so a wrapped line never starts or ends with one. */}
|
||||
<p className="overflow-hidden text-(length:--text-micro) leading-4 text-(--tac-meta)">
|
||||
<span className="-ml-3 flex flex-wrap items-baseline">
|
||||
{showAuthor ? (
|
||||
<Dot>
|
||||
<span className="truncate">{artifact.author}</span>
|
||||
</Dot>
|
||||
) : null}
|
||||
<Dot>
|
||||
<time dateTime={artifact.updatedAt} title={`${artifact.author} · ${artifact.updatedLabel}`} className="font-mono">
|
||||
{artifact.updatedLabel}
|
||||
</time>
|
||||
</Dot>
|
||||
</span>
|
||||
</p>
|
||||
<PanelActions artifact={artifact} overSlot={!across} />
|
||||
</div>
|
||||
{/* The rail that ends the row; a sheet's foot is behind it. */}
|
||||
<span aria-hidden className={LIP} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { REVIEW_LABEL, REVIEW_VAR } from "./kind";
|
||||
|
||||
/**
|
||||
* State is a physical marker: a sticky flag in the status colour, stuck to an
|
||||
* edge and standing a little proud of it. The same flag on every surface —
|
||||
* `StateFlag` carries the word, `StateNub` is the same flag seen end-on, for
|
||||
* objects too small to carry type. Both have a 1px film edge (`--tac-flag-key`)
|
||||
* cut to the same shape, so the mark holds its outline on a dark tray and on
|
||||
* kraft, where the status colour alone is under 3:1.
|
||||
*/
|
||||
|
||||
export type Review = "needs_review" | "changes_requested";
|
||||
|
||||
const flag = (review: Review, clip: string) => ({ "--tac-flag": REVIEW_VAR[review], clipPath: clip }) as CSSProperties;
|
||||
const SHADOW = { filter: "var(--tac-flag-shadow)" } as const;
|
||||
const KEY_FLAG = { clipPath: "var(--tac-clip-flag)" } as const;
|
||||
const KEY_NUB = { clipPath: "var(--tac-clip-nub)" } as const;
|
||||
|
||||
export function StateFlag({ review, count, className }: { review: Review; /** On a divider: how many things under it carry this flag. */ count?: number; className?: string }) {
|
||||
return (
|
||||
<span className={cn("inline-block rotate-(--tac-tilt-flag)", className)} style={SHADOW} data-tac-flag={review}>
|
||||
<span className="block bg-(--tac-flag-key) p-px" style={KEY_FLAG}>
|
||||
<span
|
||||
className="relative flex h-(--tac-flag-h) items-center whitespace-nowrap bg-(--tac-flag) pl-3 pr-3.5 text-(length:--text-nano) font-semibold leading-none text-(--tac-flag-ink)"
|
||||
style={flag(review, "var(--tac-clip-flag)")}
|
||||
>
|
||||
{/* The gummed end. */}
|
||||
<span aria-hidden className="absolute inset-y-0 left-0 block w-1.5 bg-(--tac-flag-glue)" />
|
||||
{REVIEW_LABEL[review]}
|
||||
{count != null ? (
|
||||
<>
|
||||
<span aria-hidden className="px-1 opacity-70">
|
||||
·
|
||||
</span>
|
||||
<span className="font-mono">{count}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The unlabelled flag: stands up from the top edge of a small object. Decorative; say the state in text nearby. */
|
||||
export function StateNub({ review, className }: { review: Review; className?: string }) {
|
||||
return (
|
||||
<span aria-hidden className={cn("absolute -top-(--tac-nub-rise) block", className ?? "left-1/5")} style={SHADOW} data-tac-flag={review}>
|
||||
<span className="block bg-(--tac-flag-key) p-px" style={KEY_NUB}>
|
||||
<span className="block h-(--tac-nub-h) w-(--tac-nub-w) bg-(--tac-flag)" style={flag(review, "var(--tac-clip-nub)")} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { REVIEW_LABEL, RING, RING_ON, kindLabel, metaParts, splitName } from "./kind";
|
||||
import { LEDGE, STAND, STAND_CLIP } from "./ledge";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { ArtifactObject } from "./objects/ArtifactObject";
|
||||
import { StateFlag } from "./StateFlag";
|
||||
|
||||
/**
|
||||
* THREAD — what an agent attached to a message, stood on one tray: small
|
||||
* objects on a ledge, feet behind its lip, each with a caption under the
|
||||
* ledge (the filename in mono, whole, then kind and size).
|
||||
* Images come first, as little prints; past four, the fourth place is a pile
|
||||
* marked "+N".
|
||||
*
|
||||
* Up to four items the tray hugs them; from five it takes the same measure
|
||||
* as a work-product row and lays them on an even grid, wrapping rather than
|
||||
* scrolling, so every attachment stays reachable — each row of the grid
|
||||
* stands on its own run of the ledge. Each item is one target: images and
|
||||
* video open the gallery (button), everything else opens the file (link).
|
||||
*
|
||||
* Review state: a nub on each object that carries it, and — as on anything
|
||||
* that groups — one worded flag with the count on the tray's top edge.
|
||||
*/
|
||||
|
||||
const MAX_PRINTS = 4;
|
||||
/** From this many places the tray stops hugging its contents and becomes a full-measure grid. */
|
||||
const FULL_TRAY = 5;
|
||||
/**
|
||||
* A filename is shown whole, on up to three lines, breaking at its hyphens where it has them. About three lines of
|
||||
* the caption's mono fit one place on the tray; a longer name loses its middle, never its extension.
|
||||
*/
|
||||
const NAME_FITS = 44;
|
||||
|
||||
const ITEM = cn("group/object relative flex w-full cursor-pointer flex-col items-center gap-3.5 rounded-md px-0.5 pb-1", RING, RING_ON.ground);
|
||||
|
||||
const fitName = (base: string, ext: string) => (base.length + ext.length > NAME_FITS ? `${base.slice(0, NAME_FITS - ext.length - 1)}…${ext}` : `${base}${ext}`);
|
||||
|
||||
/** One place on the ledge: the object stands in it, foot behind the lip. */
|
||||
function Place({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className={cn("block h-22 w-full", LEDGE)}>
|
||||
<span className="flex size-full items-end justify-center" style={STAND_CLIP}>
|
||||
<span className={cn("flex", STAND)}>{children}</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeskItem({ artifact }: { artifact: LabArtifact }) {
|
||||
const [base, ext] = splitName(artifact);
|
||||
const media = artifact.kind === "image" || artifact.kind === "video";
|
||||
const name = `${base}${ext}`;
|
||||
// The flag on the object is decorative, so the state is said in the item's name.
|
||||
const label = `Open ${name}${artifact.review ? ` — ${REVIEW_LABEL[artifact.review]}` : ""}`;
|
||||
const content = (
|
||||
<>
|
||||
<Place>
|
||||
<ArtifactObject artifact={artifact} size="sm" />
|
||||
</Place>
|
||||
<span className="flex w-full min-w-0 flex-col items-center gap-0.5 text-center">
|
||||
<span className="line-clamp-3 max-w-full wrap-anywhere font-mono text-(length:--text-micro) leading-4 tracking-tight text-foreground" title={name}>
|
||||
{fitName(base, ext)}
|
||||
</span>
|
||||
{/* Kind and size only: page counts and durations stay with the row and the panel. */}
|
||||
<MetaLine parts={artifact.kind === "link" ? [{ text: kindLabel(artifact) }] : metaParts(artifact).slice(0, 2)} className="max-w-full text-(length:--text-micro) leading-4 text-(--tac-meta)" />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return media ? (
|
||||
<button type="button" aria-label={label} className={ITEM}>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<a href={`#attachment-${artifact.id}`} aria-label={label} className={ITEM}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** The pile that stands for the prints that did not fit. */
|
||||
function MorePrints({ count }: { count: number }) {
|
||||
return (
|
||||
<button type="button" aria-label={`Open ${count} more images`} className={ITEM}>
|
||||
<Place>
|
||||
<span data-tac-size="sm" className="relative block aspect-4/3 w-(--tac-print-ws) filter-(--tac-filter-rest)">
|
||||
<span className="absolute inset-0 block rotate-(--tac-tilt-d) rounded-(--tac-print-r) bg-(--tac-paper-back)" />
|
||||
<span className="absolute inset-0 block rotate-(--tac-tilt-a) rounded-(--tac-print-r) bg-(--tac-paper-back)" />
|
||||
<span className="absolute inset-0 flex items-center justify-center rounded-(--tac-print-r) bg-(--tac-print) bg-(image:--tac-grain) font-mono text-sm font-semibold text-(--tac-ink)">
|
||||
+{count}
|
||||
</span>
|
||||
</span>
|
||||
</Place>
|
||||
<span className="text-(length:--text-micro) leading-4 text-(--tac-meta)">more images</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Count({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<span>
|
||||
{label} · <span className="font-mono">{count}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadAttachments({ artifacts }: { artifacts: LabArtifact[] }) {
|
||||
const images = artifacts.filter((a) => a.kind === "image");
|
||||
const files = artifacts.filter((a) => a.kind !== "image");
|
||||
const prints = images.length > MAX_PRINTS ? images.slice(0, MAX_PRINTS - 1) : images;
|
||||
const hidden = images.length - prints.length;
|
||||
const toReview = artifacts.filter((a) => a.review === "needs_review").length;
|
||||
if (artifacts.length === 0) return null;
|
||||
const full = prints.length + (hidden > 0 ? 1 : 0) + files.length >= FULL_TRAY;
|
||||
const place = full ? "min-w-0" : "w-(--tac-desk-cell)";
|
||||
return (
|
||||
// The column, measured: like a row's tray, the full tray keeps 40rem in a wide column and fills a narrow one.
|
||||
<div className="@container/col w-full">
|
||||
<section aria-label="Attachments" className={cn("relative flex flex-col gap-2 pt-1", full ? "w-full max-w-(--tac-tray-max) @max-3xl/col:max-w-none" : "w-fit max-w-full")}>
|
||||
<p className="flex gap-3 px-1 text-xs text-muted-foreground">
|
||||
{images.length > 0 ? <Count label="Images" count={images.length} /> : null}
|
||||
{files.length > 0 ? <Count label="Files" count={files.length} /> : null}
|
||||
</p>
|
||||
<div className="relative">
|
||||
{/* Clips on the x axis: each place's ledge runs out to the tray's edges. */}
|
||||
<ul
|
||||
className={cn(
|
||||
"items-start gap-x-3 gap-y-5 overflow-x-clip rounded-xl bg-(--tac-tray) bg-(image:--tac-ground-grain) px-3 pb-2.5 pt-5 shadow-(--tac-tray-rim)",
|
||||
full ? "grid grid-cols-(--tac-desk-cols)" : "flex flex-wrap",
|
||||
)}
|
||||
>
|
||||
{prints.map((artifact) => (
|
||||
<li key={artifact.id} className={place}>
|
||||
<DeskItem artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
{hidden > 0 ? (
|
||||
<li className={place}>
|
||||
<MorePrints count={hidden} />
|
||||
</li>
|
||||
) : null}
|
||||
{files.map((artifact) => (
|
||||
<li key={artifact.id} className={place}>
|
||||
<DeskItem artifact={artifact} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{toReview > 0 ? <StateFlag review="needs_review" count={toReview} className="pointer-events-none absolute -top-2 right-4 z-10" /> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Download, ExternalLink, Maximize2, PanelRight, type LucideIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
import { RING, RING_AFTER, RING_ON, metaParts, primaryAction, secondaryAction, type ActionIcon, type ArtifactAction } from "./kind";
|
||||
import { LIP, LIP_CLIP, STAND_CLIP } from "./ledge";
|
||||
import { MetaLine } from "./MetaLine";
|
||||
import { ArtifactObject } from "./objects/ArtifactObject";
|
||||
import { StateFlag } from "./StateFlag";
|
||||
|
||||
/**
|
||||
* THREAD — one work product between turns: a shallow pocket with the artifact
|
||||
* standing in it.
|
||||
*
|
||||
* The tray is a filled, softly inset slab with a front lip across its bottom
|
||||
* edge. The artifact stands at the left as a small object, its foot behind
|
||||
* the lip; a sheet is taller than the tray and stands 10px proud of its top
|
||||
* edge, a print or a slide stands inside it. Beside it, ordinary UI type:
|
||||
* title, kind and machine values, the summary (two lines at most), and the
|
||||
* one action. The whole tray is the primary action's hit area; a text-like
|
||||
* file keeps Download as its own small target. The review flag is stuck over
|
||||
* the tray's top edge.
|
||||
*
|
||||
* In a wide column the tray keeps its own measure (40rem); in a column under
|
||||
* 48rem — the thread beside an open pane — it runs the full width, so its
|
||||
* right edge meets the message actions above it.
|
||||
*/
|
||||
|
||||
const ICON: Record<ActionIcon, LucideIcon> = { open: PanelRight, gallery: Maximize2, download: Download, external: ExternalLink };
|
||||
|
||||
function RowAction({ action, title, primary }: { action: ArtifactAction; title: string; primary: boolean }) {
|
||||
const Icon = ICON[action.icon];
|
||||
const className = cn(
|
||||
"inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md text-xs font-medium text-foreground",
|
||||
primary
|
||||
? // The label drops below a 24rem tray, as in the product; the hit area is the tray itself, and so is the focus ring.
|
||||
cn("px-1.5 after:absolute after:inset-0 after:cursor-pointer after:rounded-xl focus-visible:after:ring-offset-background", RING_AFTER)
|
||||
: cn("relative z-10 w-7 justify-center text-(--tac-meta) transition-colors hover:bg-(--tac-tray-press) hover:text-foreground", RING, RING_ON.ground),
|
||||
);
|
||||
const label = `${action.label}: ${title}`;
|
||||
const content = (
|
||||
<>
|
||||
{primary ? <span className="hidden @sm:inline">{action.label}</span> : null}
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</>
|
||||
);
|
||||
return action.href ? (
|
||||
<a href={action.href} aria-label={label} title={primary ? undefined : action.label} className={className}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<button type="button" aria-label={label} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThreadWorkProduct({ artifact }: { artifact: LabArtifact }) {
|
||||
const secondary = secondaryAction(artifact);
|
||||
return (
|
||||
// The column, measured: the tray answers its width, not the viewport's.
|
||||
<div className="@container/col w-full">
|
||||
<article
|
||||
aria-label={artifact.title}
|
||||
data-tac-row={artifact.kind}
|
||||
className="group/object @container relative flex min-h-18 w-full max-w-(--tac-tray-max) items-center gap-3 rounded-xl bg-(--tac-tray) bg-(image:--tac-ground-grain) pb-(--tac-row-pb) pl-2.5 pr-2 pt-1.5 shadow-(--tac-tray-rim) transition-(--tac-tp-lift) duration-(--motion-duration-fast) ease-(--motion-ease-standard) hover:bg-(--tac-tray-hover) active:bg-(--tac-tray-press) @max-3xl/col:max-w-none"
|
||||
>
|
||||
{/* The object's place in the row; the object itself hangs from the tray's bottom edge, whatever the row's height. */}
|
||||
<span aria-hidden className="block w-21 shrink-0" />
|
||||
{/* Above the action's hit-area overlay, so the focus ring passes behind the standing sheet; clicks fall through. */}
|
||||
<span className="pointer-events-none absolute bottom-(--tac-row-foot) left-2.5 z-10 flex w-21 justify-center" style={STAND_CLIP}>
|
||||
<ArtifactObject artifact={artifact} size="xs" flag="none" />
|
||||
</span>
|
||||
<span aria-hidden className={cn(LIP_CLIP, "rounded-xl")}>
|
||||
<span className={LIP} />
|
||||
</span>
|
||||
{/* In a tray narrow enough that the title runs to its right end, a flagged row's text starts below the flag. */}
|
||||
<div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", artifact.review && "@max-sm:pt-2.5")}>
|
||||
<span className="line-clamp-1 wrap-anywhere text-sm font-medium text-foreground @max-sm:line-clamp-2" title={artifact.title}>
|
||||
{artifact.title}
|
||||
</span>
|
||||
<MetaLine parts={metaParts(artifact)} className="text-xs text-(--tac-meta)" />
|
||||
{artifact.summary ? (
|
||||
<span className="line-clamp-2 wrap-anywhere text-xs text-(--tac-meta)" title={artifact.summary}>
|
||||
{artifact.summary}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Above the hit-area overlay too; clicks fall through. */}
|
||||
{artifact.review ? <StateFlag review={artifact.review} className="pointer-events-none absolute -top-2 right-4 z-10" /> : null}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{secondary ? <RowAction action={secondary} title={artifact.title} primary={false} /> : null}
|
||||
<RowAction action={primaryAction(artifact)} title={artifact.title} primary />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { LabArtifact, LabGroup, LabKind } from "../../kit";
|
||||
import { kindLabel } from "./kind";
|
||||
|
||||
/**
|
||||
* What the gallery derives from a stack: which of its artifacts stand in the
|
||||
* folder, how thick the pile behind them is, what kinds it holds, and its
|
||||
* artifacts by day.
|
||||
*/
|
||||
|
||||
/** The kinds that say most about a stack come first; a box or a blank sheet only when nothing else is left. */
|
||||
const PEEK_RANK: readonly LabKind[] = ["image", "video", "document", "slides", "pdf", "sheet", "code", "link"];
|
||||
const DULL: ReadonlySet<LabKind> = new Set(["file", "archive"]);
|
||||
|
||||
/** Objects that stand taller than wide in a folder. */
|
||||
export const standsTall = (a: LabArtifact) =>
|
||||
a.kind === "document" || a.kind === "pdf" || a.kind === "code" || a.kind === "file" || ((a.kind === "image" || a.kind === "video") && (a.aspect ?? 4 / 3) < 1.15);
|
||||
|
||||
/** How wide an `fd` object is, in rem (the `fd` table in tokens-gallery.css): the shelf keeps each one inside its folder's sides by it. */
|
||||
export function widthOf(a: LabArtifact): number {
|
||||
switch (a.kind) {
|
||||
case "image":
|
||||
case "video": {
|
||||
const aspect = Math.min(1.9, Math.max(0.75, a.aspect ?? 4 / 3));
|
||||
return aspect < 1.15 ? 7.75 * aspect : 9.5;
|
||||
}
|
||||
case "slides":
|
||||
case "link":
|
||||
return 8.8;
|
||||
case "sheet":
|
||||
return 8.4;
|
||||
case "archive":
|
||||
return 7.125;
|
||||
default:
|
||||
return 7.5;
|
||||
}
|
||||
}
|
||||
|
||||
/** A folder is 21.5rem inside its edges; contents may overlap by about a third. */
|
||||
const POCKET_BUDGET = 29.5;
|
||||
const MAX_PEEK = 4;
|
||||
|
||||
/**
|
||||
* The artifacts that stand in a stack's folder: one of each telling kind
|
||||
* first (so a mixed stack shows its mix), then the newest of what is left (so
|
||||
* a stack of photos shows photos), as many as fit the pocket — three or four.
|
||||
* Tall sheets and wide prints alternate so neither hides the other.
|
||||
*/
|
||||
export function peekOf(group: LabGroup): LabArtifact[] {
|
||||
const picked: LabArtifact[] = [];
|
||||
let width = 0;
|
||||
const take = (a: LabArtifact | undefined) => {
|
||||
if (!a || picked.includes(a) || picked.length >= MAX_PEEK || width + widthOf(a) > POCKET_BUDGET) return;
|
||||
picked.push(a);
|
||||
width += widthOf(a);
|
||||
};
|
||||
for (const kind of PEEK_RANK) take(group.artifacts.find((a) => a.kind === kind));
|
||||
for (const a of group.artifacts) if (!DULL.has(a.kind)) take(a);
|
||||
for (const a of group.artifacts) take(a);
|
||||
|
||||
const tall = picked.filter(standsTall);
|
||||
const wide = picked.filter((a) => !standsTall(a));
|
||||
const arranged: LabArtifact[] = [];
|
||||
while (tall.length > 0 || wide.length > 0) {
|
||||
const next = tall.shift();
|
||||
if (next) arranged.push(next);
|
||||
const other = wide.shift();
|
||||
if (other) arranged.push(other);
|
||||
}
|
||||
return arranged;
|
||||
}
|
||||
|
||||
/** How much more a folder holds than it shows, as a pile level: 0 nothing, 1 a little (one sheet top behind the contents), 2 some (two), 3 a lot (four, and a deeper mouth). */
|
||||
export function pileOf(count: number, shown: number): 0 | 1 | 2 | 3 {
|
||||
const hidden = count - shown;
|
||||
if (hidden <= 0) return 0;
|
||||
if (hidden < 4) return 1;
|
||||
if (hidden < 12) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
export const reviewCountOf = (artifacts: readonly LabArtifact[]) => artifacts.filter((a) => a.review === "needs_review").length;
|
||||
|
||||
export const countLabel = (count: number) => (count === 1 ? "artifact" : "artifacts");
|
||||
|
||||
/** "Document 7 · Slides 3 · PDF 3 …": what a stack holds, largest group first, in the product's kind wording. */
|
||||
export function tallyOf(artifacts: readonly LabArtifact[]): { label: string; count: number }[] {
|
||||
const counts = new Map<string, number>();
|
||||
for (const a of artifacts) counts.set(kindLabel(a), (counts.get(kindLabel(a)) ?? 0) + 1);
|
||||
return [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export interface DayGroup {
|
||||
/** "2026-09-28" */
|
||||
day: string;
|
||||
/** "Monday, September 28, 2026" */
|
||||
label: string;
|
||||
items: LabArtifact[];
|
||||
}
|
||||
|
||||
/** A stack's artifacts by the day they were last edited: newest day first, newest first within a day. */
|
||||
export function daysOf(artifacts: readonly LabArtifact[]): DayGroup[] {
|
||||
const days = new Map<string, DayGroup>();
|
||||
for (const a of [...artifacts].sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))) {
|
||||
const day = a.updatedAt.slice(0, 10);
|
||||
// The year is part of the heading: a stack outlives the year it was started in.
|
||||
const group = days.get(day) ?? { day, label: `${a.dayLabel}, ${day.slice(0, 4)}`, items: [] };
|
||||
group.items.push(a);
|
||||
days.set(day, group);
|
||||
}
|
||||
return [...days.values()].sort((a, b) => (a.day < b.day ? 1 : -1));
|
||||
}
|
||||
|
||||
/** "11:07 AM" from "Sep 28, 2026, 11:07 AM". */
|
||||
export function timeOf(a: LabArtifact): string {
|
||||
const cut = a.updatedLabel.lastIndexOf(", ");
|
||||
return cut >= 0 ? a.updatedLabel.slice(cut + 2) : a.updatedLabel;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { DirectionKit } from "../../kit";
|
||||
import { GalleryStack } from "./GalleryStack";
|
||||
import { GalleryStacks } from "./GalleryStacks";
|
||||
import { PanelBody } from "./PanelBody";
|
||||
import { ThreadAttachments } from "./ThreadAttachments";
|
||||
import { ThreadWorkProduct } from "./ThreadWorkProduct";
|
||||
import "./tokens.css";
|
||||
import "./tokens-sizes.css";
|
||||
import "./tokens-panel.css";
|
||||
import "./tokens-folder.css";
|
||||
import "./tokens-gallery.css";
|
||||
|
||||
/**
|
||||
* 3 · TACTILE — paper, prints and folders.
|
||||
*
|
||||
* Artifacts are objects, not cards: a document is a sheet of paper with its
|
||||
* real opening set on it, an image is a photo print, a group is a folder with
|
||||
* its own sheets and prints tucked in. What stops them getting lost is that
|
||||
* they are the lightest thing on a filled ground — the same warm ground on
|
||||
* every surface — and that they stand in things: a tray, a slot, a ledge, a
|
||||
* folder.
|
||||
*
|
||||
* SIGNATURE — the same on every surface
|
||||
* 1. The object (`objects/ArtifactObject`): one drawing per kind, authored
|
||||
* once at design size and scaled, so the sheet in the thread IS the sheet
|
||||
* in the panel and in the folder. Sheets are dog-eared (top right) and
|
||||
* carry the artifact's real opening in serif, fading out toward the foot;
|
||||
* images are white-bordered prints.
|
||||
* 2. The mark that says what it is. A file carries its extension on a
|
||||
* printed tab stuck to its left edge (`ExtTab`; above the top edge on a
|
||||
* slide) — PDF red, PPTX orange, XLSX/CSV green, HTML/JSON pale blue,
|
||||
* MD/OTF graphite, ZIP on the box's own label. A task document is not a
|
||||
* file: it carries its revision as a rubber stamp on the paper (`Stamp`,
|
||||
* "REV 3"), and its captions drop the `.md`.
|
||||
* 3. The sticky flag: review state as a flag in the status colour, tilted
|
||||
* 2°, gummed end lighter, swallow-tailed, with a 1px film edge, always
|
||||
* standing proud of a top edge. `StateFlag` carries the word, and a count
|
||||
* on anything that groups (the attachments tray, a run's divider, a
|
||||
* folder's pocket); `StateNub` is the same flag end-on for objects too
|
||||
* small for type. Review = --status-task-in_review · Changes requested =
|
||||
* --status-task-blocked. Nothing else changes colour for state.
|
||||
* 4. Repose and light: every object lies 1–2.5° off square (stable per id;
|
||||
* under 1° when it lies across a whole pane) under one top-left light —
|
||||
* hairline + contact + ambient shadow as a `filter`, so it follows the
|
||||
* silhouette.
|
||||
* 5. The ground (`--tac-ground`): one warm filled tone, with a faint grain,
|
||||
* under the paper everywhere — the thread's trays, the panel's desk, the
|
||||
* mats under an opened stack's rows and under a stack of one. In light it
|
||||
* is deep enough that white paper holds its edge on it (about 1.2:1
|
||||
* before the shadow).
|
||||
* 6. The lip (`ledge.ts`): one physical edge — a strip of the ground's
|
||||
* material, lit along the top, throwing a crisp shadow on what stands
|
||||
* behind it. An object's foot is always a few px behind it. It is the
|
||||
* front of a thread row's tray, the rail a sheet slides under in the
|
||||
* panel, and the front of the ledge a row of objects stands on (thread
|
||||
* attachments, opened stack).
|
||||
* 7. Folder board (`--tac-folder*`, graphite in dark, manila in light,
|
||||
* visibly grained) with a labelled tab and a curved shoulder, for
|
||||
* whatever groups artifacts: the panel's run dividers, the gallery's
|
||||
* stacks, the opened stack's own folder.
|
||||
*
|
||||
* KINDS (all in objects/, all through `ArtifactObject`)
|
||||
* document typeset sheet; REV stamp if a task document image photo print
|
||||
* pdf sheet, red spine, second page under video print + sprocket strip, play disc, duration tab
|
||||
* slides 16:9 title slide: accent rule, the real archive kraft box: lid, hand-hole, paper label, fibre
|
||||
* title large, "1 / N" footer (or the cover) code blueprint-blue sheet, numbered gutter, mono
|
||||
* sheet punched ledger, green rules, real rows link pale blue address card: the host in a white
|
||||
* file blank sheet, name / type / size typed on bar, then its preview picture or its path
|
||||
*
|
||||
* SURFACES
|
||||
* thread `ThreadWorkProduct`: a 72px pocket — a filled tray with a front
|
||||
* lip. The object (`xs`) stands at its left with its foot behind
|
||||
* the lip; a sheet is taller than the tray and stands 10px proud of
|
||||
* its top edge. Beside it: title, kind and values, summary (two
|
||||
* lines at most), one action; the worded flag over the tray's
|
||||
* top-right edge. The tray keeps a 40rem measure in a wide column
|
||||
* and runs the full width of a column under 48rem (the thread
|
||||
* beside an open pane).
|
||||
* `ThreadAttachments`: one tray; `sm` objects stand on a ledge
|
||||
* across it with captions under the ledge (filename whole, in mono;
|
||||
* kind and size). Nubs on the objects, one "Review · N" flag on the
|
||||
* tray's top edge.
|
||||
* panel `PanelBody`: the pane's body is the desk; there are no cards. A
|
||||
* two-way switch (All / To review), then sections newest first, as
|
||||
* in the product, each behind a `PanelDivider` (folder-board tab
|
||||
* naming the agent and the count, a "Review · N" flag standing on
|
||||
* the far end of the board, the run's start time under it).
|
||||
* `PanelItem`, two ways of lying there. Paper and things stand in a
|
||||
* slot at the left (`pn`): a sheet is set to be read — headline
|
||||
* about 13px, first lines about 10px — and its foot slides under
|
||||
* the rail that ends the row. Pictures lie across the whole desk
|
||||
* (`pw`): an image, a video that plays in place, a link with its
|
||||
* preview. Plain caption with ordinary buttons: title, kind and
|
||||
* values, summary or the document's own first lines, filename and
|
||||
* content type, who and when.
|
||||
* gallery `GalleryStacks`: the shelf. A stack is a `Folder` — back board,
|
||||
* the stack's own artifacts standing in it as `fd` objects (one of
|
||||
* each telling kind first; kept inside the folder's sides; flags as
|
||||
* nubs), separate sheet tops behind them when it holds more than it
|
||||
* shows (one, two, or four and a deeper mouth for a full one), and
|
||||
* a pocket whose tab is the task identifier and whose face carries
|
||||
* the title, a tally by kind, the count and the date, with a
|
||||
* "Review · N" flag on its top edge. Hover lifts the contents out
|
||||
* and fans them. A stack of one is the object itself (`gl`) on a
|
||||
* mat with a lip, its lines beside it — a large thread row.
|
||||
* `GalleryStack`: the folder opened. The same folder, open and
|
||||
* empty, with the task's title as the heading beside it, the count,
|
||||
* the date and the tally. Then the artifacts by day — heading with
|
||||
* the year and a count, a tick ruler down the margin — on mats, in
|
||||
* rows on ledges: `gl` objects (a sheet 178px wide; everything
|
||||
* wider than tall sized to the grid cell, 244px) with the caption
|
||||
* under the ledge (title, Open / Download at its end; time and
|
||||
* author, whole). No tiles.
|
||||
*
|
||||
* MATERIALS (all --tac-*; light first, `.dark` overrides)
|
||||
* tokens.css paper, ink, prints, kind colours, flag, ground, lip, focus ring, folder board, grain, light, repose
|
||||
* tokens-sizes.css the size table (`xs`, `sm`) and what every size derives from --tac-k
|
||||
* tokens-panel.css desk, slot, divider, the `pn` and `pw` sizes, narrow-pane steps
|
||||
* tokens-folder.css the folder: face, pile, sizes, the slots its contents stand in
|
||||
* tokens-gallery.css shelf and mat grids, the `fd` and `gl` sizes, narrow-page steps
|
||||
* Grain is three strengths: barely there on paper and prints, visible on
|
||||
* folder board, kraft (with a fibre) and the lip, between the two on the
|
||||
* ground, where UI type is set. Serif (--tac-font-serif) on paper only;
|
||||
* machine values are mono.
|
||||
*
|
||||
* SIZES — `size` on an object sets `data-tac-size`, which sets --tac-k and
|
||||
* retunes the type so a small page shows fewer, larger words
|
||||
* xs 0.258 62×80 sheet · 80px print thread row
|
||||
* sm 0.27 65×84 sheet · 108px print thread attachments
|
||||
* fd 0.5 120×155 sheet · 152px print standing in a folder
|
||||
* pn 0.61 146×189 sheet panel, in a slot (every shape ≈146px wide)
|
||||
* pw — as wide as the desk panel, across: prints, the playable video, a link's card
|
||||
* gl 0.74 178×229 sheet · 244px print opened stack, stack of one
|
||||
* Every size but `sm` scales each shape separately. --tac-fit shrinks a
|
||||
* whole size inside a container query, never elsewhere.
|
||||
*
|
||||
* STATES
|
||||
* hover / focus object lifts 2px, ambient shadow doubles (an ancestor
|
||||
* carries `group/object`); the tray or mat steps one
|
||||
* tone; a folder's contents rise 12px and fan.
|
||||
* pressed the object goes back down, the tray a tone darker.
|
||||
* focus-visible a 2px ring at 72% of the foreground with a 2px gap in
|
||||
* the colour of what the control lies on: round the
|
||||
* tray (behind a sheet standing proud of it), on each
|
||||
* panel button, round the folder, round an opened
|
||||
* stack's object and caption (not the space above it).
|
||||
* review the flag; nothing else changes colour.
|
||||
* no / failed thumbnail a print is left undeveloped (grey emulsion, struck
|
||||
* picture mark); a link's card drops the picture.
|
||||
* long titles wrap anywhere and clamp with an ellipsis in the UI
|
||||
* (thread 1 line, 2 in a narrow tray; panel 3; pocket
|
||||
* and gallery caption 2); on paper they wrap and fade.
|
||||
* one vs many a folder with contents and a pile, or the lone object.
|
||||
* narrow thread: full-width trays, the action's label drops
|
||||
* under 24rem. panel: slot beside caption down to a
|
||||
* 290px pane, then stacked. gallery: opened stack 4 /
|
||||
* 3 / 2 columns at 1440 / 1024 / 768; the shelf keeps
|
||||
* two compact folders across at 768.
|
||||
* reduced motion nothing translates or rotates; shadow and tone still answer.
|
||||
*/
|
||||
export const kit: DirectionKit = {
|
||||
id: "d3-tactile",
|
||||
name: "3.Tactile",
|
||||
tagline: "Paper, prints and folders",
|
||||
ThreadWorkProduct,
|
||||
ThreadAttachments,
|
||||
PanelBody,
|
||||
GalleryStacks,
|
||||
GalleryStack,
|
||||
};
|
||||
@@ -0,0 +1,230 @@
|
||||
import { fileKindForName } from "@/components/task-chat/task-chat-attachments";
|
||||
import type { LabArtifact } from "../../kit";
|
||||
|
||||
/**
|
||||
* What an artifact is called and what one does with it — the wording every
|
||||
* surface of this direction shares. Labels come from the product's own
|
||||
* extension mapper wherever it has one (PDF, Markdown, ZIP, Sheet, HTML, File).
|
||||
*/
|
||||
|
||||
/** The colour family of an object's extension tab. */
|
||||
export type TabTone = "pdf" | "slides" | "sheet" | "code" | "note";
|
||||
|
||||
/** One part of a meta line; `mono` marks a machine value, `lead` / `unit` are the words around it ("rev", "pages"). */
|
||||
export interface MetaPart {
|
||||
text: string;
|
||||
mono?: boolean;
|
||||
lead?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export type ActionIcon = "open" | "gallery" | "download" | "external";
|
||||
|
||||
export interface ArtifactAction {
|
||||
/** Visible label, production wording. */
|
||||
label: string;
|
||||
icon: ActionIcon;
|
||||
/** Where an anchor would go; buttons (gallery) have none. */
|
||||
href?: string;
|
||||
}
|
||||
|
||||
/** A task document, as opposed to a Markdown file an agent attached. */
|
||||
export const isTaskDocument = (a: LabArtifact) => a.kind === "document" && a.revision != null;
|
||||
|
||||
const TEXT_LIKE = new Set(["md", "txt", "csv", "tsv", "json", "yaml", "yml", "xml", "html", "css", "js", "ts", "tsx", "sql", "log"]);
|
||||
export const isTextLike = (a: LabArtifact) => !isTaskDocument(a) && TEXT_LIKE.has((a.ext ?? "").toLowerCase());
|
||||
|
||||
export function hostOf(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function pathOf(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
try {
|
||||
return new URL(url).pathname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** "Document", "Image", "PDF", "Markdown", "Slides", "Sheet", "ZIP", "HTML", "File", "Link". */
|
||||
export function kindLabel(a: LabArtifact): string {
|
||||
if (isTaskDocument(a)) return "Document";
|
||||
switch (a.kind) {
|
||||
case "image":
|
||||
return "Image";
|
||||
case "video":
|
||||
return "Video";
|
||||
case "link":
|
||||
return "Link";
|
||||
case "slides":
|
||||
// The product's mapper has no entry for .pptx and says "File"; the object is a slide, so the word is too.
|
||||
return "Slides";
|
||||
default:
|
||||
return fileKindForName(a.filename ?? "").label;
|
||||
}
|
||||
}
|
||||
|
||||
/** The extension as it is printed on an object's tab: "PDF", "PPTX", "MD". */
|
||||
export function tabLabel(a: LabArtifact): string | null {
|
||||
if (isTaskDocument(a) || a.kind === "image" || a.kind === "video" || a.kind === "link") return null;
|
||||
const ext = (a.ext ?? "").toUpperCase();
|
||||
return ext ? ext.slice(0, 4) : null;
|
||||
}
|
||||
|
||||
export function tabTone(a: LabArtifact): TabTone {
|
||||
switch (a.kind) {
|
||||
case "pdf":
|
||||
return "pdf";
|
||||
case "slides":
|
||||
return "slides";
|
||||
case "sheet":
|
||||
return "sheet";
|
||||
case "code":
|
||||
return "code";
|
||||
default:
|
||||
return "note";
|
||||
}
|
||||
}
|
||||
|
||||
/** The second line of a row or caption: kind, then the machine values. */
|
||||
export function metaParts(a: LabArtifact): MetaPart[] {
|
||||
const parts: MetaPart[] = [{ text: kindLabel(a) }];
|
||||
if (isTaskDocument(a)) {
|
||||
parts.push({ lead: "rev", text: String(a.revision), mono: true });
|
||||
return parts;
|
||||
}
|
||||
if (a.kind === "link") {
|
||||
parts.push({ text: hostOf(a.url), mono: true });
|
||||
return parts;
|
||||
}
|
||||
if (a.sizeLabel) parts.push({ text: a.sizeLabel, mono: true });
|
||||
if (a.kind === "video" && a.durationLabel) parts.push({ text: a.durationLabel, mono: true });
|
||||
if (a.pageCount && a.pageCount > 1) parts.push({ text: String(a.pageCount), mono: true, unit: a.kind === "slides" ? "slides" : "pages" });
|
||||
return parts;
|
||||
}
|
||||
|
||||
const anchor = (a: LabArtifact) => (isTaskDocument(a) ? `#document-${(a.filename ?? a.id).replace(/\.md$/, "")}` : `#work-product-${a.id}`);
|
||||
|
||||
/** GALLERY — where an artifact's tile goes: the owning task, at the artifact (ArtifactCard.tsx:203). */
|
||||
export const taskHref = anchor;
|
||||
|
||||
/**
|
||||
* GALLERY — the two quiet targets beside a tile's caption (ArtifactCard.tsx:188-229). Task documents have neither
|
||||
* path in the product; a link opens itself.
|
||||
*/
|
||||
export function galleryActions(a: LabArtifact): ArtifactAction[] {
|
||||
if (isTaskDocument(a)) return [];
|
||||
if (a.kind === "link") return [{ label: "Open link in new tab", icon: "external", href: anchor(a) }];
|
||||
return [
|
||||
{ label: "Open file in new tab", icon: "external", href: anchor(a) },
|
||||
{ label: "Download file", icon: "download", href: anchor(a) },
|
||||
];
|
||||
}
|
||||
|
||||
/** THREAD — the one thing to do with it (RichWorkProductCard.tsx:148-191). */
|
||||
export function primaryAction(a: LabArtifact): ArtifactAction {
|
||||
if (isTaskDocument(a)) return { label: "Open document", icon: "open", href: anchor(a) };
|
||||
if (a.kind === "image" || a.kind === "video") return { label: "Open gallery", icon: "gallery" };
|
||||
if (a.kind === "link") return { label: "Open preview", icon: "external", href: anchor(a) };
|
||||
if (isTextLike(a)) return { label: "Open in tab", icon: "open", href: anchor(a) };
|
||||
return { label: "Download", icon: "download", href: anchor(a) };
|
||||
}
|
||||
|
||||
/** THREAD — text-like files open in the side panel and keep Download as its own target. */
|
||||
export function secondaryAction(a: LabArtifact): ArtifactAction | null {
|
||||
return isTextLike(a) ? { label: "Download", icon: "download", href: anchor(a) } : null;
|
||||
}
|
||||
|
||||
/** PANEL — the wording of the pane's buttons (RichArtifactCards.tsx). */
|
||||
export function panelActions(a: LabArtifact): ArtifactAction[] {
|
||||
if (a.kind === "document") return [{ label: "Read document", icon: "open", href: anchor(a) }];
|
||||
if (a.kind === "image") return [{ label: "View image", icon: "gallery" }];
|
||||
if (a.kind === "video") return [{ label: "Open video", icon: "gallery" }];
|
||||
if (a.kind === "link") return [{ label: "Open link", icon: "external", href: anchor(a) }];
|
||||
return [
|
||||
{ label: "Open file", icon: "external", href: anchor(a) },
|
||||
{ label: "Download file", icon: "download", href: anchor(a) },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* PANEL — the quieter targets beside the buttons, icon only (IssuePropertiesArtifactsTab.tsx:237-265):
|
||||
* a Markdown file keeps Open in tab, Open raw and Download; any other text-like file keeps Open in tab.
|
||||
*/
|
||||
export function panelIconActions(a: LabArtifact): ArtifactAction[] {
|
||||
if (isTaskDocument(a) || !isTextLike(a)) return [];
|
||||
const inTab: ArtifactAction = { label: "Open in tab", icon: "open", href: anchor(a) };
|
||||
if (a.kind !== "document") return [inTab];
|
||||
return [inTab, { label: "Open raw", icon: "external", href: anchor(a) }, { label: "Download", icon: "download", href: anchor(a) }];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caption calls the thing when it has to be one machine string: the filename, a link's host, else the title.
|
||||
* A task document is not a file — its name is its key, without the `.md` the store appends — so an extension always
|
||||
* means a file, on the caption as on the object's tab.
|
||||
*/
|
||||
export function splitName(a: LabArtifact): [base: string, ext: string] {
|
||||
const name = a.filename ?? (a.kind === "link" ? hostOf(a.url) : a.title);
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (!a.filename || dot <= 0) return [name, ""];
|
||||
return [name.slice(0, dot), isTaskDocument(a) ? "" : name.slice(dot)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus: a strong ring (`--tac-ring`, 72% of the foreground) with a 2px gap; add `RING_ON.*` for the gap's colour —
|
||||
* that of whatever the control lies on. On a pseudo-element hit area use `RING_AFTER`.
|
||||
*/
|
||||
export const RING = "outline-none focus-visible:ring-2 focus-visible:ring-(--tac-ring) focus-visible:ring-offset-2";
|
||||
export const RING_AFTER = "outline-none focus-visible:after:ring-2 focus-visible:after:ring-(--tac-ring) focus-visible:after:ring-offset-2";
|
||||
export const RING_ON = {
|
||||
canvas: "focus-visible:ring-offset-background",
|
||||
ground: "focus-visible:ring-offset-(--tac-ground)",
|
||||
desk: "focus-visible:ring-offset-(--tac-desk)",
|
||||
} as const;
|
||||
|
||||
export const REVIEW_LABEL = { needs_review: "Review", changes_requested: "Changes requested" } as const;
|
||||
export const REVIEW_VAR = {
|
||||
needs_review: "var(--status-task-in_review)",
|
||||
changes_requested: "var(--status-task-blocked)",
|
||||
} as const;
|
||||
|
||||
/** A stable 0-3 from an id, for the angle an object lies at. */
|
||||
export function reposeOf(id: string): 0 | 1 | 2 | 3 {
|
||||
let h = 0;
|
||||
for (const char of id) h = (h * 31 + char.charCodeAt(0)) >>> 0;
|
||||
return (h % 4) as 0 | 1 | 2 | 3;
|
||||
}
|
||||
|
||||
/** A plain-text excerpt as title + blocks; a block is a heading, a bullet or a paragraph. */
|
||||
export interface ExcerptBlock {
|
||||
type: "heading" | "bullet" | "para";
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** The first running text of a document after its title, as one string: the panel's two-line excerpt in the UI face. */
|
||||
export function excerptLead(a: LabArtifact): string {
|
||||
const { blocks } = excerptBlocks(a);
|
||||
return blocks
|
||||
.filter((block) => block.type !== "heading")
|
||||
.slice(0, 3)
|
||||
.map((block) => block.text)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function excerptBlocks(a: LabArtifact): { title: string; blocks: ExcerptBlock[] } {
|
||||
const chunks = (a.excerpt ?? "").split(/\n+/).map((line) => line.trim()).filter(Boolean);
|
||||
if (chunks.length === 0) return { title: a.title, blocks: [] };
|
||||
const [title, ...rest] = chunks;
|
||||
const blocks = rest.slice(0, 14).map<ExcerptBlock>((text) => {
|
||||
if (text.startsWith("• ")) return { type: "bullet", text: text.slice(2) };
|
||||
if (text.length < 44 && !/[.:;,]$/.test(text)) return { type: "heading", text };
|
||||
return { type: "para", text };
|
||||
});
|
||||
return { title, blocks };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* The lip — the one physical edge every surface shares (tokens.css, "the lip").
|
||||
*
|
||||
* A strip of the ground's own material, lit along its top, throwing a crisp
|
||||
* shadow on whatever stands behind it. An object's foot is always a few px
|
||||
* behind it (`STAND`), so a tilted corner never floats.
|
||||
*
|
||||
* LIP the front of a tray, across its bottom edge: a thread row, a stack
|
||||
* of one, the panel's rail. `LIP_CLIP` is the box to put it in when
|
||||
* the tray has round corners (with the tray's own radius): the tray
|
||||
* itself cannot clip, because what stands in it stands proud of it.
|
||||
* LEDGE the same strip under one object's holder, running out sideways to
|
||||
* the edges of the tray it is on — which must clip it on the x axis
|
||||
* (`overflow-x-clip`). Every holder in a row draws the same line, so
|
||||
* a row of objects stands on one continuous ledge however the row
|
||||
* wraps: the attachments tray, an opened stack's rows.
|
||||
*/
|
||||
export const LIP = "pointer-events-none absolute inset-x-0 bottom-0 z-20 block h-(--tac-lip-h) bg-(--tac-ledge) bg-(image:--tac-board-grain) shadow-(--tac-ledge-edge)";
|
||||
|
||||
export const LIP_CLIP = "pointer-events-none absolute inset-0 z-20 block overflow-hidden";
|
||||
|
||||
export const LEDGE =
|
||||
"relative after:pointer-events-none after:absolute after:-inset-x-(--tac-run) after:top-full after:z-20 after:block after:h-(--tac-lip-h) after:bg-(--tac-ledge) after:bg-(image:--tac-board-grain) after:shadow-(--tac-shelf-edge)";
|
||||
|
||||
/** On whatever stands behind a lip or on a ledge: its foot goes down behind the strip. */
|
||||
export const STAND = "translate-y-(--tac-lip-tuck)";
|
||||
|
||||
/** Cuts a standing object and its shadow at the foot of its holder (inline `style`), so neither shows below the lip. */
|
||||
export const STAND_CLIP = { clipPath: "var(--tac-clip-stand)" } as const;
|
||||
@@ -0,0 +1,109 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact, LabKind } from "../../../kit";
|
||||
import { reposeOf } from "../kind";
|
||||
import { StateFlag, StateNub } from "../StateFlag";
|
||||
import { LedgerSheet, SlideSheet } from "./Formats";
|
||||
import type { ObjectSize } from "./Paper";
|
||||
import { Print } from "./Print";
|
||||
import { CodeSheet, DocumentSheet } from "./Sheets";
|
||||
import { ArchiveBox, LinkCard, LinkCardWide } from "./Things";
|
||||
|
||||
/**
|
||||
* The one entry point: an artifact as the object it is, at a size, lying at a
|
||||
* slight angle.
|
||||
*
|
||||
* document typeset sheet, dog-eared image photo print
|
||||
* pdf sheet, red spine + PDF tab video print with film strip + play disc
|
||||
* slides 16:9 title slide, orange tab archive kraft box, labelled
|
||||
* sheet punched ledger paper, green code blueprint-blue sheet, mono
|
||||
* file blank dog-eared sheet link pale blue address card
|
||||
*
|
||||
* The object lifts when an ancestor carrying `group/object` is hovered or has
|
||||
* focus within, and is pressed back down while that ancestor is active; under
|
||||
* reduced motion it stays put and only the shadow changes.
|
||||
*/
|
||||
|
||||
const TILT = ["rotate-(--tac-tilt-a)", "rotate-(--tac-tilt-b)", "rotate-(--tac-tilt-c)", "rotate-(--tac-tilt-d)"] as const;
|
||||
/** Across a whole pane the same few px of skew is a much smaller angle. */
|
||||
const TILT_WIDE = ["rotate-(--tac-tilt-wide-a)", "rotate-(--tac-tilt-wide-b)"] as const;
|
||||
|
||||
const LIFT =
|
||||
"transition-(--tac-tp-lift) duration-(--motion-duration-fast) ease-(--motion-ease-out-expo) motion-safe:group-hover/object:translate-y-(--tac-lift) motion-safe:group-focus-within/object:translate-y-(--tac-lift) motion-safe:group-active/object:translate-y-0";
|
||||
|
||||
/**
|
||||
* Where a worded flag stands on the top edge, and how much of it is stuck to the face. The usual place is a fifth
|
||||
* along; a slide's index tab is already there, and a ledger's column heads sit right at its top edge, so its flag
|
||||
* only nips it.
|
||||
*/
|
||||
const TOP_FLAG_DEFAULT = "left-1/5 -mb-(--tac-flag-tuck)";
|
||||
const TOP_FLAG_AT: Partial<Record<LabKind, string>> = {
|
||||
slides: "left-2/7 -mb-(--tac-flag-tuck)",
|
||||
sheet: "left-1/5 -mb-(--tac-flag-nip)",
|
||||
};
|
||||
|
||||
/** Where the unlabelled flag stands: past a slide's index tab; over a ledger's punched margin, clear of its column heads. */
|
||||
const NUB_AT: Partial<Record<LabKind, string>> = { slides: "left-3/5", sheet: "left-0" };
|
||||
|
||||
/** Objects taller than wide; a surface may let these stand proud of what they rest on. */
|
||||
export const isPortraitKind = (kind: LabKind) => kind === "document" || kind === "pdf" || kind === "code" || kind === "file";
|
||||
|
||||
/** PANEL — what has a picture to show lies across the desk (`pw`) instead of in a slot: images, video, a link with a preview. */
|
||||
export const liesAcross = (a: LabArtifact) => a.kind === "image" || a.kind === "video" || (a.kind === "link" && Boolean(a.thumbnailUrl));
|
||||
|
||||
function objectFor(artifact: LabArtifact, size: ObjectSize, player: boolean) {
|
||||
switch (artifact.kind) {
|
||||
case "image":
|
||||
case "video":
|
||||
return <Print artifact={artifact} size={size} player={player} />;
|
||||
case "slides":
|
||||
return <SlideSheet artifact={artifact} size={size} />;
|
||||
case "sheet":
|
||||
return <LedgerSheet artifact={artifact} size={size} />;
|
||||
case "code":
|
||||
return <CodeSheet artifact={artifact} size={size} />;
|
||||
case "archive":
|
||||
return <ArchiveBox artifact={artifact} size={size} />;
|
||||
case "link":
|
||||
return size === "pw" ? <LinkCardWide artifact={artifact} /> : <LinkCard artifact={artifact} size={size} />;
|
||||
default:
|
||||
return <DocumentSheet artifact={artifact} size={size} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtifactObject({
|
||||
artifact,
|
||||
size,
|
||||
flag = "nub",
|
||||
repose,
|
||||
player = false,
|
||||
className,
|
||||
}: {
|
||||
artifact: LabArtifact;
|
||||
size: ObjectSize;
|
||||
/**
|
||||
* How the review state is marked on the object: end-on on the top edge (`nub`: thread attachments, folder
|
||||
* contents), with its word standing in the same place (`top`: panel, opened stack, a stack of one), or not here
|
||||
* (`none`: the thread row, whose tray carries the flag).
|
||||
*/
|
||||
flag?: "nub" | "top" | "none";
|
||||
/** 0-3: which of the four resting angles. Defaults to one derived from the id. */
|
||||
repose?: 0 | 1 | 2 | 3;
|
||||
/** PANEL — a video is the playable thing, with its own controls, and is not hidden from assistive technology. */
|
||||
player?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const review = artifact.review ?? null;
|
||||
const angle = repose ?? reposeOf(artifact.id);
|
||||
const wide = size === "pw";
|
||||
const live = player && artifact.kind === "video" && Boolean(artifact.videoUrl);
|
||||
return (
|
||||
<span className={cn("relative shrink-0", wide ? "flex w-full" : "inline-flex", wide ? TILT_WIDE[angle % 2] : TILT[angle], LIFT, className)} data-tac-object={artifact.kind}>
|
||||
<span aria-hidden={live ? undefined : true} className={cn("block", wide && "w-full")}>
|
||||
{objectFor(artifact, size, player)}
|
||||
</span>
|
||||
{review && flag === "nub" ? <StateNub review={review} className={NUB_AT[artifact.kind]} /> : null}
|
||||
{/* The same place as the nub, with the word: stuck by its lower edge, the rest standing proud of the top. */}
|
||||
{review && flag === "top" ? <StateFlag review={review} className={cn("absolute bottom-full", TOP_FLAG_AT[artifact.kind] ?? TOP_FLAG_DEFAULT)} /> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* A folder: a group of artifacts as the thing that holds them.
|
||||
*
|
||||
* back board a step darker than the front, standing behind everything
|
||||
* pile separate blank sheet tops behind the contents, staggered and
|
||||
* each with its own edge, when the folder holds more than it
|
||||
* shows: one, two, or four for a full one (whose mouth is also
|
||||
* in deeper shade)
|
||||
* contents whatever the caller tucks in; it stands in front of the board
|
||||
* and goes down behind the pocket. Above the pocket's mouth it
|
||||
* may lean out over the folder's sides; below it, it is cut to
|
||||
* the pocket's walls
|
||||
* pocket the front: a label tab on its top-left with a curved
|
||||
* shoulder (the same tab the panel's dividers have), then the
|
||||
* body, which carries type; a flag stands on its top edge
|
||||
*
|
||||
* Graphite in the dark theme so the paper in it stays the brightest thing;
|
||||
* manila in the light theme. One shadow follows the whole silhouette,
|
||||
* contents included, and widens while an ancestor carrying `group/object` is
|
||||
* hovered or focused.
|
||||
*
|
||||
* Sizes come from `data-tac-folder` (tokens-folder.css): `shelf` fills its
|
||||
* grid cell; `hero` is the opened stack's own folder. `open` lets the front
|
||||
* fall toward the reader, as a folder lies once its contents are out.
|
||||
*/
|
||||
|
||||
export type FolderSize = "shelf" | "hero";
|
||||
|
||||
const SHADOW =
|
||||
"filter-(--tac-fol-shadow) transition-(--tac-tp-fan) duration-(--motion-duration-fast) ease-(--motion-ease-out-expo) group-hover/object:filter-(--tac-fol-shadow-lift) group-focus-visible/object:filter-(--tac-fol-shadow-lift)";
|
||||
|
||||
/** Blank sheet tops standing behind the contents: there is more in here than is shown. */
|
||||
const PILE = [
|
||||
"left-1/20 w-2/5 h-(--tac-pile-a-h) rotate-(--tac-pile-a-tilt)",
|
||||
"left-11/20 w-9/25 h-(--tac-pile-b-h) rotate-(--tac-pile-b-tilt)",
|
||||
"left-3/10 w-2/5 h-(--tac-pile-c-h) rotate-(--tac-pile-c-tilt)",
|
||||
"left-7/10 w-1/4 h-(--tac-pile-d-h) rotate-(--tac-pile-d-tilt)",
|
||||
] as const;
|
||||
/** How many of them, by the `pile` level. */
|
||||
const PILE_SHEETS = [0, 1, 2, 4] as const;
|
||||
|
||||
const CONTENTS = { clipPath: "var(--tac-clip-contents)" } as const;
|
||||
|
||||
export function Folder({
|
||||
size,
|
||||
tab,
|
||||
contents,
|
||||
pile = 0,
|
||||
flag,
|
||||
open = false,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
size: FolderSize;
|
||||
/** What is written on the label tab. */
|
||||
tab?: ReactNode;
|
||||
/** Objects standing in the folder, each positioned by the caller inside the area above the pocket. */
|
||||
contents?: ReactNode;
|
||||
/** 0-3: how much more the folder holds than it shows (none, a little, some, a lot). */
|
||||
pile?: 0 | 1 | 2 | 3;
|
||||
/** A flag stood on the pocket's top edge, toward its right end. */
|
||||
flag?: ReactNode;
|
||||
/** Opened and emptied: the front leans out toward the reader. */
|
||||
open?: boolean;
|
||||
/** What is printed on the pocket. */
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span data-tac-folder={size} data-tac-full={pile === 3 ? "" : undefined} className={cn("relative block h-(--tac-fol-h) select-none", SHADOW, className)}>
|
||||
<span aria-hidden className="absolute inset-x-0 bottom-0 top-(--tac-fol-rise) block rounded-(--tac-fol-r) bg-(--tac-folder-back) bg-(image:--tac-board-grain) shadow-(--tac-folder-edge)" />
|
||||
<span className="absolute inset-x-(--tac-fol-inset) bottom-(--tac-fol-pocket) top-0 block" style={CONTENTS}>
|
||||
{PILE.slice(0, PILE_SHEETS[pile]).map((place) => (
|
||||
<span key={place} aria-hidden className={cn("absolute bottom-0 block origin-bottom rounded-t-(--tac-pile-r) bg-(--tac-pile) bg-(image:--tac-grain) shadow-(--tac-pile-edge)", place)} />
|
||||
))}
|
||||
{contents}
|
||||
</span>
|
||||
{/* The pocket's mouth: whatever goes in is in its shade for the last few px. */}
|
||||
<span aria-hidden className="absolute inset-x-0 bottom-(--tac-fol-pocket) block h-(--tac-fol-mouth-h) bg-(image:--tac-fol-mouth)" />
|
||||
<span className={cn("absolute inset-x-0 bottom-0 flex flex-col", open && "origin-bottom transform-(--tac-fol-open)")}>
|
||||
{/* Over the body's top highlight by a pixel, so tab and body are one piece of board. */}
|
||||
<span className="relative z-10 -mb-px flex h-(--tac-fol-tab-h)">
|
||||
<span className="flex w-(--tac-fol-tab-w) min-w-0 items-center rounded-tl-(--tac-fol-r) bg-(--tac-folder) shadow-(--tac-folder-tab-edge)">{tab}</span>
|
||||
<svg aria-hidden viewBox="0 0 28 24" preserveAspectRatio="none" className="h-full w-(--tac-fol-shoulder) shrink-0">
|
||||
<path d="M0 0C14 0 14 24 28 24H0z" className="fill-(--tac-folder)" />
|
||||
<path d="M0 0.5C14 0.5 14 24 28 24" fill="none" strokeWidth={1} vectorEffect="non-scaling-stroke" className="stroke-(--tac-folder-lip)" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="relative flex h-(--tac-fol-pocket) flex-col rounded-b-(--tac-fol-r) rounded-tr-(--tac-fol-r) bg-(--tac-folder) bg-(image:--tac-folder-face) shadow-(--tac-folder-edge)">
|
||||
{children}
|
||||
</span>
|
||||
{/* Stuck to the pocket's top edge by its foot; it leans with the front when the folder is open. */}
|
||||
{flag ? <span className="absolute bottom-(--tac-fol-pocket) right-(--tac-fol-flag-x) z-10 -mb-(--tac-flag-tuck) flex">{flag}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../../kit";
|
||||
import { tabLabel } from "../kind";
|
||||
import { ExtTab, Greek, Paper, Scaled, type ObjectSize } from "./Paper";
|
||||
|
||||
/** Landscape sheets: ledger paper (spreadsheets) and the slide. */
|
||||
|
||||
const LEDGER_ROWS = 12;
|
||||
const LEDGER_COLS = 3;
|
||||
|
||||
/**
|
||||
* Ledger paper: punched down the left margin, a green head band, green rules,
|
||||
* and the sheet's real first rows in mono. No dog-ear — it came off a pad.
|
||||
*/
|
||||
export function LedgerSheet({ artifact, size }: { artifact: LabArtifact; size: ObjectSize }) {
|
||||
const tab = tabLabel(artifact);
|
||||
const rows = (artifact.excerpt ?? "")
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => line.split(","));
|
||||
return (
|
||||
<Scaled size={size} box="ledger">
|
||||
<Paper ear={false} mask="var(--tac-mask-punch)" className="text-(length:--tac-type-body)">
|
||||
<span className="absolute inset-y-0 left-(--tac-ledger-margin) right-0 block border-l-(length:--tac-rule-w) border-(--tac-ledger-rule) font-mono leading-none text-(--tac-ink)">
|
||||
{Array.from({ length: LEDGER_ROWS }, (_, row) => (
|
||||
<span
|
||||
key={row}
|
||||
className={cn(
|
||||
"grid h-(--tac-ledger-row) grid-cols-3 border-b-(length:--tac-rule-w) border-(--tac-ledger-rule)",
|
||||
row === 0 && "bg-(--tac-ledger-head) font-bold text-(--tac-ledger-ink)",
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: LEDGER_COLS }, (_, col) => (
|
||||
<span
|
||||
key={col}
|
||||
className="flex items-center overflow-hidden whitespace-nowrap border-r-(length:--tac-rule-w) border-(--tac-ledger-rule) px-(--tac-gap-para) last:border-r-0"
|
||||
>
|
||||
{rows[row]?.[col] ?? ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</Paper>
|
||||
{tab ? <ExtTab label={tab} tone="sheet" /> : null}
|
||||
</Scaled>
|
||||
);
|
||||
}
|
||||
|
||||
const FADE_END = { maskImage: "var(--tac-fade-end)" } as const;
|
||||
const FOOTER_LINE = ["w-full"] as const;
|
||||
|
||||
/**
|
||||
* A 16:9 sheet: the deck's cover. With a first-slide thumbnail it is that
|
||||
* picture; without one (the usual case — a .pptx carries no preview) it is a
|
||||
* title slide set from what is known: a short accent rule in the tab's
|
||||
* orange, the real title large across the whole width, and a footer rule
|
||||
* with "1 / N". The title wraps and dissolves at the foot of its box —
|
||||
* printed paper is never ellipsized. A second slide lies under it when the
|
||||
* deck has more than one.
|
||||
*/
|
||||
export function SlideSheet({ artifact, size }: { artifact: LabArtifact; size: ObjectSize }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const tab = tabLabel(artifact);
|
||||
const cover = artifact.thumbnailUrl && !failed ? artifact.thumbnailUrl : null;
|
||||
const pages = artifact.pageCount ?? 1;
|
||||
return (
|
||||
<Scaled size={size} box="slide">
|
||||
<Paper ear={false} back={pages > 1}>
|
||||
{cover ? (
|
||||
<img src={cover} alt="" draggable={false} className="absolute inset-0 block size-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<span className="absolute inset-0 flex flex-col p-(--tac-page-pad) font-sans text-(--tac-ink)">
|
||||
<span className="block text-(length:--tac-type-body)">
|
||||
<span className="block h-(--tac-bar-h) w-1/8 rounded-full bg-(--tac-tab-slides)" />
|
||||
</span>
|
||||
<span
|
||||
className="mt-(--tac-gap-para) block min-h-0 flex-1 overflow-hidden break-words text-(length:--tac-type-title) font-bold leading-(--tac-lead-title) tracking-tight"
|
||||
style={FADE_END}
|
||||
>
|
||||
{artifact.title}
|
||||
</span>
|
||||
<span className="flex items-center justify-between gap-(--tac-page-pad) border-t-(length:--tac-rule-w) border-(--tac-ink-faint) pt-(--tac-gap-para) font-mono text-(length:--tac-type-body) leading-none text-(--tac-ink-soft)">
|
||||
<Greek lines={FOOTER_LINE} className="w-1/4" />
|
||||
{pages > 1 ? <span className="shrink-0">1 / {pages}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Paper>
|
||||
{/* A slide is too short to give up a corner, so its tab stands above the top edge like an index tab. */}
|
||||
{tab ? <ExtTab label={tab} tone="slides" place="top" /> : null}
|
||||
</Scaled>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TabTone } from "../kind";
|
||||
|
||||
/**
|
||||
* The paper every sheet-like object is cut from.
|
||||
*
|
||||
* `Scaled` is the miniature: its children are laid out once at design size
|
||||
* (a 15rem-wide page) and scaled by `--tac-k`, which `data-tac-size` sets in
|
||||
* tokens.css. The wrapper is the real, scaled footprint and carries the
|
||||
* two-layer shadow as a `filter`, so the shadow follows the silhouette — the
|
||||
* cut corner, a tab, a second sheet — rather than a box.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `xs` stands in a thread row's tray and `sm` on the attachments tray (tokens-sizes.css). `pn` is the panel's size for
|
||||
* what lies in a slot, every shape about 9.5rem wide; `pw` is a print lying across the whole desk, as wide as its
|
||||
* holder (tokens-panel.css). `fd` is an object standing in a folder and `gl` one stood on an opened stack's ledge
|
||||
* (tokens-gallery.css). Every size but `sm` scales each shape separately.
|
||||
*/
|
||||
export type ObjectSize = "xs" | "sm" | "fd" | "pn" | "pw" | "gl";
|
||||
|
||||
export type ScaledBox = "page" | "slide" | "ledger" | "box" | "card";
|
||||
|
||||
const FOOTPRINT: Record<ScaledBox, string> = {
|
||||
page: "w-(--tac-page-w) h-(--tac-page-h)",
|
||||
slide: "w-(--tac-slide-w) h-(--tac-slide-h)",
|
||||
ledger: "w-(--tac-ledger-w) h-(--tac-ledger-h)",
|
||||
box: "w-(--tac-box-w) h-(--tac-box-h)",
|
||||
card: "w-(--tac-card-w) h-(--tac-card-h)",
|
||||
};
|
||||
|
||||
/** The same boxes at design size (k = 1). */
|
||||
const DESIGN: Record<ScaledBox, string> = {
|
||||
page: "w-60 h-77.5",
|
||||
slide: "w-80 h-45",
|
||||
ledger: "w-70 h-56",
|
||||
box: "w-75 h-62",
|
||||
card: "w-80 h-44",
|
||||
};
|
||||
|
||||
/** Shadow at rest, a wider one while the object's `group/object` ancestor is hovered or focused, back to rest while it is pressed. */
|
||||
export const OBJECT_SHADOW =
|
||||
"filter-(--tac-filter-rest) transition-(--tac-tp-lift) duration-(--motion-duration-fast) ease-(--motion-ease-out-expo) group-hover/object:filter-(--tac-filter-lift) group-focus-within/object:filter-(--tac-filter-lift) group-active/object:filter-(--tac-filter-rest)";
|
||||
|
||||
export function Scaled({ size, box, className, children }: { size: ObjectSize; box: ScaledBox; className?: string; children: ReactNode }) {
|
||||
return (
|
||||
// `data-tac-box` lets a size give each shape its own scale (the panel fits them all to one width).
|
||||
<span data-tac-size={size} data-tac-box={box} className={cn("relative block shrink-0 select-none", FOOTPRINT[box], OBJECT_SHADOW, className)}>
|
||||
<span className={cn("absolute left-0 top-0 block origin-top-left scale-(--tac-ks)", DESIGN[box])}>{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type PaperTone = "paper" | "carbon";
|
||||
|
||||
const SURFACE: Record<PaperTone, string> = {
|
||||
paper: "bg-(--tac-paper) bg-(image:--tac-grain) shadow-(--tac-paper-edge)",
|
||||
carbon: "bg-(--tac-carbon) bg-(image:--tac-grain-light) shadow-(--tac-carbon-edge)",
|
||||
};
|
||||
const FOLD: Record<PaperTone, string> = { paper: "bg-(image:--tac-fold)", carbon: "bg-(image:--tac-carbon-fold)" };
|
||||
|
||||
/**
|
||||
* One sheet filling its design box. `ear` cuts the top-right corner and lays
|
||||
* the fold over it; `back` slips a second sheet underneath (more than one page).
|
||||
*/
|
||||
export function Paper({
|
||||
tone = "paper",
|
||||
ear = true,
|
||||
back = false,
|
||||
mask,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
tone?: PaperTone;
|
||||
ear?: boolean;
|
||||
back?: boolean;
|
||||
/** A `var(--tac-mask-…)` reference: holes punched through the sheet. */
|
||||
mask?: string;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const cut: CSSProperties = {
|
||||
...(ear ? { clipPath: "var(--tac-clip-ear)" } : null),
|
||||
...(mask ? { maskImage: mask, maskComposite: "intersect" } : null),
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{back ? (
|
||||
<span className="absolute inset-0 block translate-x-(--tac-back-dx) translate-y-(--tac-back-dy) rotate-(--tac-tilt-back) rounded-(--tac-page-r) bg-(--tac-paper-back) shadow-(--tac-paper-edge)" />
|
||||
) : null}
|
||||
<span className={cn("absolute inset-0 block overflow-hidden rounded-(--tac-page-r)", SURFACE[tone], className)} style={cut}>
|
||||
{children}
|
||||
</span>
|
||||
{ear ? (
|
||||
<span className="absolute right-0 top-0 block size-(--tac-ear)" style={{ filter: "var(--tac-fold-shadow)" }}>
|
||||
<span className={cn("block size-full", FOLD[tone])} style={{ clipPath: "var(--tac-clip-fold)" }} />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const TAB: Record<TabTone, string> = {
|
||||
pdf: "bg-(--tac-tab-pdf) text-(--tac-tab-ink)",
|
||||
slides: "bg-(--tac-tab-slides) text-(--tac-tab-ink-dark)",
|
||||
sheet: "bg-(--tac-tab-sheet) text-(--tac-tab-ink)",
|
||||
code: "bg-(--tac-tab-code) text-(--tac-tab-ink-code)",
|
||||
note: "bg-(--tac-tab-note) text-(--tac-tab-ink)",
|
||||
};
|
||||
|
||||
const TAB_PLACE = {
|
||||
/** Wrapping the lower-left edge. */
|
||||
spine: "bottom-(--tac-tab-rise) -left-(--tac-tab-out)",
|
||||
/** An index tab standing above the top edge — for objects too short to give up a corner. */
|
||||
top: "-top-(--tac-tab-up) left-(--tac-tab-rise)",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The extension, printed on a label stuck to the object's edge. Its type is
|
||||
* retuned per size (`--tac-type-tab`) so it stays about 9px tall on screen
|
||||
* from the thread row to the gallery.
|
||||
*/
|
||||
export function ExtTab({
|
||||
label,
|
||||
tone,
|
||||
place = "spine",
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
tone: TabTone;
|
||||
place?: keyof typeof TAB_PLACE;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute block rounded-(--tac-tab-r) px-(--tac-tab-px) py-(--tac-tab-py) font-sans text-(length:--tac-type-tab) font-bold leading-none tracking-wide shadow-(--tac-tab-shadow)",
|
||||
TAB_PLACE[place],
|
||||
TAB[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A rubber stamp on the paper itself — a task document's revision, the one thing a file does not have. Inked, a few
|
||||
* degrees off, low on the right of the sheet; a file-backed artifact carries an extension tab on its left edge instead.
|
||||
*/
|
||||
export function Stamp({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<span className="absolute bottom-(--tac-stamp-rise) right-(--tac-page-pad) block rotate-(--tac-stamp-tilt) rounded-(--tac-tab-r) border-(length:--tac-stamp-edge) border-(--tac-stamp) px-(--tac-tab-px) py-(--tac-tab-py) font-mono text-(length:--tac-type-tab) font-bold uppercase leading-none tracking-wide text-(--tac-stamp)">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A coloured binding strip down the left edge of a sheet. */
|
||||
export function Spine({ tone }: { tone: TabTone }) {
|
||||
return <span className={cn("absolute inset-y-0 left-0 block w-(--tac-spine-w) text-(length:--tac-type-tab)", TAB[tone])} />;
|
||||
}
|
||||
|
||||
/** Greeked text: lines standing in for content the artifact does not expose. */
|
||||
export function Greek({ lines, className }: { lines: readonly string[]; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex flex-col gap-(--tac-bar-gap)", className)}>
|
||||
{lines.map((width, index) => (
|
||||
<span key={index} className={cn("block h-(--tac-bar-h) rounded-full bg-(--tac-ink-faint)", width)} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { ImageOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../../kit";
|
||||
import { OBJECT_SHADOW, type ObjectSize } from "./Paper";
|
||||
|
||||
/**
|
||||
* A photo print: the real thumbnail inside a white border, corners barely
|
||||
* rounded. Not scaled like the sheets — it takes its long side from
|
||||
* `--tac-print-ws` (landscape) or `--tac-print-hs` (portrait) and its shape
|
||||
* from the image. At `pw` (the panel) the long side is the holder's width.
|
||||
*
|
||||
* A video is the same print with film cues: a sprocket strip down the left
|
||||
* edge of the frame, a play disc, and (from `sm` up, except tucked in a
|
||||
* folder) a duration tab. `player` swaps the still for the playable video
|
||||
* itself, its own controls in place of the play disc — the caller must not
|
||||
* hide that print from assistive technology.
|
||||
*
|
||||
* A missing or failed thumbnail leaves an undeveloped print: grey emulsion
|
||||
* with a struck-through picture mark.
|
||||
*/
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const SPROCKETS = [0, 1, 2, 3, 4];
|
||||
const SQUARE = 1.15;
|
||||
|
||||
function FilmStrip() {
|
||||
return (
|
||||
<span aria-hidden className="flex w-(--tac-film-w) shrink-0 flex-col items-center justify-evenly bg-(--tac-film)">
|
||||
{SPROCKETS.map((hole) => (
|
||||
<span key={hole} className="block aspect-4/5 w-1/2 rounded-(--tac-print-r) bg-(--tac-film-ink)" />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Print({ artifact, size, player = false }: { artifact: LabArtifact; size: ObjectSize; /** PANEL — a video plays in place. */ player?: boolean }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const aspect = clamp(artifact.aspect ?? 4 / 3, 0.75, 1.9);
|
||||
const video = artifact.kind === "video";
|
||||
const blank = failed || !artifact.thumbnailUrl;
|
||||
const plays = player && video && Boolean(artifact.videoUrl);
|
||||
// A square print is sized by its height too: by its width it would stand taller than a sheet.
|
||||
const tall = aspect < SQUARE;
|
||||
return (
|
||||
<span
|
||||
data-tac-size={size}
|
||||
className={cn(
|
||||
"relative block shrink-0 select-none rounded-(--tac-print-r) bg-(--tac-print) bg-(image:--tac-grain) p-(--tac-print-edge)",
|
||||
tall ? "h-(--tac-print-hs)" : "w-(--tac-print-ws)",
|
||||
OBJECT_SHADOW,
|
||||
)}
|
||||
style={{ aspectRatio: aspect }}
|
||||
>
|
||||
<span className="relative flex size-full overflow-hidden bg-(--tac-emulsion)">
|
||||
{video ? <FilmStrip /> : null}
|
||||
<span className="relative block min-w-0 flex-1">
|
||||
{plays ? (
|
||||
<video
|
||||
src={artifact.videoUrl}
|
||||
poster={artifact.thumbnailUrl}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-label={`Video: ${artifact.title}`}
|
||||
className="absolute inset-0 block size-full bg-(--tac-film) object-cover"
|
||||
/>
|
||||
) : blank ? (
|
||||
<span className="flex size-full items-center justify-center text-(--tac-emulsion-ink)" data-tac-blank>
|
||||
<ImageOff className="h-2/5 max-h-7 w-auto" strokeWidth={1.5} aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<img src={artifact.thumbnailUrl} alt="" draggable={false} className="absolute inset-0 block size-full object-cover" onError={() => setFailed(true)} />
|
||||
)}
|
||||
{video && !plays ? (
|
||||
<>
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="flex size-(--tac-play) items-center justify-center rounded-full bg-(--tac-film-ink) text-(--tac-film) shadow-(--tac-tab-shadow)">
|
||||
<svg viewBox="0 0 10 10" className="size-2/5 translate-x-px" fill="currentColor">
|
||||
<path d="M2.2 1 8.8 5 2.2 9z" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
{/* Not in a folder: the pocket would cut it in half. */}
|
||||
{size !== "xs" && size !== "fd" && artifact.durationLabel ? (
|
||||
<span className="absolute bottom-1 right-1 block rounded-(--tac-print-r) bg-(--tac-film) px-1 py-0.5 font-mono text-(length:--text-nano) leading-none text-(--tac-film-ink)">
|
||||
{artifact.durationLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<span aria-hidden className="pointer-events-none absolute inset-0 block shadow-(--tac-emulsion-edge)" />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../../kit";
|
||||
import { excerptBlocks, isTaskDocument, tabLabel, tabTone } from "../kind";
|
||||
import { ExtTab, Greek, Paper, Scaled, Spine, Stamp, type ObjectSize } from "./Paper";
|
||||
|
||||
/**
|
||||
* Portrait sheets: the typeset page (document, Markdown, PDF, blank file) and
|
||||
* the carbon sheet (code). Everything in here is laid out at design size and
|
||||
* scaled by `Scaled`.
|
||||
*/
|
||||
|
||||
const FADE = { maskImage: "var(--tac-fade-bottom)" } as const;
|
||||
|
||||
const GREEK_A = ["w-full", "w-11/12", "w-full", "w-4/5"] as const;
|
||||
const GREEK_B = ["w-full", "w-10/12", "w-full", "w-11/12", "w-3/5"] as const;
|
||||
const GREEK_C = ["w-full", "w-11/12", "w-2/3"] as const;
|
||||
|
||||
/**
|
||||
* A sheet of paper with a dog-eared corner and the artifact's real opening
|
||||
* set on it in serif: the first line as the title, the rest lighter, fading
|
||||
* toward the bottom edge. A PDF has no text to show, so its title stands
|
||||
* over greeked lines behind a red spine; a generic file is a blank sheet
|
||||
* with only its name, type and size typed at the head (nothing at all at the
|
||||
* two smallest sizes). A file carries its extension on a tab; a task document
|
||||
* has no file, so it carries its revision as a rubber stamp instead.
|
||||
*/
|
||||
export function DocumentSheet({ artifact, size }: { artifact: LabArtifact; size: ObjectSize }) {
|
||||
const tab = tabLabel(artifact);
|
||||
const pdf = artifact.kind === "pdf";
|
||||
const blank = artifact.kind === "file";
|
||||
const small = size === "xs" || size === "sm";
|
||||
const { title, blocks } = excerptBlocks(artifact);
|
||||
return (
|
||||
<Scaled size={size} box="page">
|
||||
<Paper back={(artifact.pageCount ?? 1) > 1}>
|
||||
{pdf ? <Spine tone="pdf" /> : null}
|
||||
{blank ? (
|
||||
// Nothing to preview: the sheet stays blank but for what is known about the file, typed at its head.
|
||||
small ? null : (
|
||||
<span className="absolute inset-0 block break-all px-(--tac-page-pad) pt-(--tac-page-pad) font-mono text-(length:--tac-type-body) leading-(--tac-lead-body) text-(--tac-ink-soft)">
|
||||
<span className="float-right block h-(--tac-ear-clear) w-(--tac-ear-clear)" />
|
||||
<span className="block font-semibold text-(--tac-ink)">{artifact.filename ?? artifact.title}</span>
|
||||
{artifact.contentType ? <span className="block">{artifact.contentType}</span> : null}
|
||||
{artifact.sizeLabel ? <span className="block">{artifact.sizeLabel}</span> : null}
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="absolute inset-0 block break-words px-(--tac-page-pad) pt-(--tac-page-pad) font-(family-name:--tac-font-serif) text-(--tac-ink)" style={FADE}>
|
||||
{/* Keeps the first lines clear of the folded corner. */}
|
||||
<span className="float-right block h-(--tac-ear-clear) w-(--tac-ear-clear)" />
|
||||
<span className="block text-(length:--tac-type-title) font-semibold leading-(--tac-lead-title) tracking-tight">{title}</span>
|
||||
<span className="mt-(--tac-gap-block) block text-(length:--tac-type-body) leading-(--tac-lead-body) text-(--tac-ink-soft)">
|
||||
{blocks.length > 0 ? (
|
||||
blocks.map((block, index) =>
|
||||
block.type === "heading" ? (
|
||||
<span key={index} className="mt-(--tac-gap-block) block font-semibold text-(--tac-ink)">
|
||||
{block.text}
|
||||
</span>
|
||||
) : block.type === "bullet" ? (
|
||||
<span key={index} className="mt-(--tac-gap-para) flex gap-(--tac-gap-para)">
|
||||
<span>•</span>
|
||||
<span>{block.text}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span key={index} className="mt-(--tac-gap-para) block first:mt-0">
|
||||
{block.text}
|
||||
</span>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{artifact.summary ? <span className="block italic">{artifact.summary}</span> : null}
|
||||
<Greek lines={GREEK_A} className="mt-(--tac-gap-block)" />
|
||||
<Greek lines={GREEK_B} className="mt-(--tac-gap-block)" />
|
||||
<Greek lines={GREEK_C} className="mt-(--tac-gap-block)" />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{isTaskDocument(artifact) ? <Stamp>rev {artifact.revision}</Stamp> : null}
|
||||
</Paper>
|
||||
{tab ? <ExtTab label={tab} tone={tabTone(artifact)} /> : null}
|
||||
</Scaled>
|
||||
);
|
||||
}
|
||||
|
||||
function codeTint(line: string): string {
|
||||
const text = line.trim();
|
||||
if (text.startsWith("<!--") || text.startsWith("//") || text.startsWith("/*") || text.startsWith("#")) return "text-(--tac-code-note)";
|
||||
if (text.startsWith("<") || text.startsWith('"')) return "text-(--tac-code-tag)";
|
||||
return "text-(--tac-code-ink)";
|
||||
}
|
||||
|
||||
/**
|
||||
* A dark carbon sheet with a numbered gutter and the file's real first lines
|
||||
* in mono. Dark in both themes: it is the one sheet that is not paper-white.
|
||||
*/
|
||||
export function CodeSheet({ artifact, size }: { artifact: LabArtifact; size: ObjectSize }) {
|
||||
const tab = tabLabel(artifact);
|
||||
const lines = (artifact.excerpt ?? "").split("\n").slice(0, 28);
|
||||
return (
|
||||
<Scaled size={size} box="page">
|
||||
<Paper tone="carbon">
|
||||
<span className="absolute inset-y-0 left-0 block w-(--tac-gutter) bg-(--tac-carbon-gutter) text-(length:--tac-type-body)" />
|
||||
<span className="absolute inset-0 block pt-(--tac-page-pad) font-mono text-(length:--tac-type-body) leading-(--tac-lead-body)" style={FADE}>
|
||||
{lines.map((line, index) => (
|
||||
<span key={index} className="flex">
|
||||
<span className="block w-(--tac-gutter) shrink-0 pr-(--tac-gap-para) text-right text-(--tac-code-dim)">{index + 1}</span>
|
||||
<span className={cn("block whitespace-pre pl-(--tac-gap-para)", codeTint(line))}>{line || " "}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</Paper>
|
||||
{tab ? <ExtTab label={tab} tone="code" /> : null}
|
||||
</Scaled>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LabArtifact } from "../../../kit";
|
||||
import { hostOf, pathOf, tabLabel } from "../kind";
|
||||
import { Greek, OBJECT_SHADOW, Scaled, type ObjectSize } from "./Paper";
|
||||
|
||||
/** The two objects that are not sheets: the archive box and the link's address card. */
|
||||
|
||||
/**
|
||||
* A kraft archive box seen from the front: a lid a little wider than the body,
|
||||
* a hand-hole, and a paper label carrying the extension. The board has a
|
||||
* horizontal fibre as well as the speck.
|
||||
*/
|
||||
export function ArchiveBox({ artifact, size }: { artifact: LabArtifact; size: ObjectSize }) {
|
||||
const label = tabLabel(artifact) ?? "ZIP";
|
||||
return (
|
||||
<Scaled size={size} box="box">
|
||||
<span className="absolute inset-x-1/25 bottom-0 top-1/6 block overflow-hidden rounded-b-(--tac-page-r) bg-(--tac-kraft) bg-(image:--tac-kraft-grain) shadow-(--tac-kraft-edge)">
|
||||
{/* The lid's shadow on the body. */}
|
||||
<span className="absolute inset-x-0 top-0 block h-1/5 bg-(image:--tac-kraft-shade)" />
|
||||
<span className="absolute left-1/2 top-3/10 block h-1/8 w-3/10 -translate-x-1/2 rounded-full bg-(--tac-kraft-dark) shadow-(--tac-kraft-hole)" />
|
||||
<span className="absolute bottom-1/10 left-1/2 block -translate-x-1/2 rounded-(--tac-tab-r) bg-(--tac-paper) px-(--tac-tab-px) py-(--tac-tab-py) font-mono text-(length:--tac-type-tab) font-bold leading-none tracking-wide text-(--tac-ink) shadow-(--tac-tab-shadow)">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute inset-x-0 top-0 block h-1/5 rounded-(--tac-page-r) bg-(--tac-kraft-lid) bg-(image:--tac-kraft-grain) shadow-(--tac-kraft-edge)" />
|
||||
</Scaled>
|
||||
);
|
||||
}
|
||||
|
||||
const CARD_LINES = ["w-4/5", "w-1/2"] as const;
|
||||
const BAR_LINE = ["w-3/5"] as const;
|
||||
|
||||
function Arrow({ className }: { className: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 17 17 7M8.5 7H17v8.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link's address card: pale blue stock with rounder corners than paper (it
|
||||
* is not a sheet, and manila is the folders'), and the address printed in a
|
||||
* white bar across its head — a dot, the whole host in mono, wrapping rather
|
||||
* than cut, and an out-arrow. Under the bar, the link's preview picture when
|
||||
* it has one that loads; otherwise its path over a couple of greeked lines.
|
||||
* At the two thread sizes the address is a bar, like every other line there.
|
||||
*/
|
||||
export function LinkCard({ artifact, size }: { artifact: LabArtifact; size: ObjectSize }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const small = size === "xs" || size === "sm";
|
||||
const picture = artifact.thumbnailUrl && !small && !failed ? artifact.thumbnailUrl : null;
|
||||
return (
|
||||
<Scaled size={size} box="card">
|
||||
<span className="absolute inset-0 flex flex-col gap-(--tac-gap-block) overflow-hidden rounded-(--tac-card-r) bg-(--tac-card) bg-(image:--tac-grain) p-(--tac-page-pad) text-(length:--tac-type-body) shadow-(--tac-card-edge)">
|
||||
<span className="flex shrink-0 items-center gap-(--tac-gap-para) rounded-(--tac-card-r) bg-(--tac-card-bar) px-(--tac-gap-block) py-(--tac-gap-para) font-mono font-bold leading-(--tac-lead-title) text-(--tac-card-ink) shadow-(--tac-card-bar-edge)">
|
||||
<span className="block size-(--tac-gap-block) shrink-0 rounded-full bg-(--tac-card-dot)" />
|
||||
{small ? <Greek lines={BAR_LINE} className="min-w-0 flex-1 py-(--tac-bar-h)" /> : <span className="min-w-0 flex-1 break-all">{hostOf(artifact.url)}</span>}
|
||||
<Arrow className="w-(--tac-glyph) shrink-0 text-(--tac-card-soft)" />
|
||||
</span>
|
||||
{picture ? (
|
||||
<img
|
||||
src={picture}
|
||||
alt=""
|
||||
draggable={false}
|
||||
className="block min-h-0 w-full flex-1 rounded-(--tac-page-r) object-cover object-top shadow-(--tac-emulsion-edge)"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="flex min-h-0 flex-1 flex-col gap-(--tac-gap-block) overflow-hidden px-(--tac-gap-para) font-mono leading-(--tac-lead-title) text-(--tac-card-soft)">
|
||||
{small ? null : <span className="block break-all">{pathOf(artifact.url)}</span>}
|
||||
<Greek lines={CARD_LINES} className="opacity-70" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Scaled>
|
||||
);
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
||||
|
||||
/**
|
||||
* PANEL — the same card lying across the desk, as wide as its holder: the
|
||||
* address bar in UI-size mono (host and path, wrapping), then the preview
|
||||
* picture at its own shape. Not scaled; it takes the `pw` size's edge and
|
||||
* radius. A picture that fails to load is dropped and the card closes up.
|
||||
*/
|
||||
export function LinkCardWide({ artifact }: { artifact: LabArtifact }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const picture = artifact.thumbnailUrl && !failed ? artifact.thumbnailUrl : null;
|
||||
return (
|
||||
<span
|
||||
data-tac-size="pw"
|
||||
className={cn(
|
||||
"relative flex w-full select-none flex-col gap-(--tac-print-edge) rounded-(--tac-card-r) bg-(--tac-card) bg-(image:--tac-grain) p-(--tac-print-edge) shadow-(--tac-card-edge)",
|
||||
OBJECT_SHADOW,
|
||||
)}
|
||||
>
|
||||
<span className="flex items-start gap-2 rounded-(--tac-card-r) bg-(--tac-card-bar) px-2.5 py-1.5 font-mono text-(length:--text-micro) font-semibold leading-4 text-(--tac-card-ink) shadow-(--tac-card-bar-edge)">
|
||||
<span className="mt-1 block size-2 shrink-0 rounded-full bg-(--tac-card-dot)" />
|
||||
<span className="min-w-0 flex-1 break-all">
|
||||
{hostOf(artifact.url)}
|
||||
<span className="font-normal text-(--tac-card-soft)">{pathOf(artifact.url)}</span>
|
||||
</span>
|
||||
<Arrow className="mt-0.5 size-3 shrink-0 text-(--tac-card-soft)" />
|
||||
</span>
|
||||
{picture ? (
|
||||
<span className="relative block w-full overflow-hidden rounded-(--tac-print-r) bg-(--tac-emulsion)" style={{ aspectRatio: clamp(artifact.aspect ?? 16 / 9, 1.2, 2.2) }}>
|
||||
<img src={picture} alt="" draggable={false} className="block size-full object-cover object-top" onError={() => setFailed(true)} />
|
||||
<span className="absolute inset-0 block shadow-(--tac-emulsion-edge)" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { LabArtifact, LabRun } from "../../kit";
|
||||
import { isTaskDocument } from "./kind";
|
||||
|
||||
/**
|
||||
* How the panel is sectioned and ordered — as in the product.
|
||||
*
|
||||
* Artifacts are grouped by the run that produced them (agent + run start),
|
||||
* and whatever has no run — task documents, loose attachments, links — forms
|
||||
* one more group. Sections are newest first, and so are the items in each.
|
||||
* What waits for review is not moved to the front: each section's divider
|
||||
* counts it, and the switch above the desk narrows the desk to it.
|
||||
*/
|
||||
|
||||
export interface PanelSection {
|
||||
id: string;
|
||||
/** Null for the group that has no run. */
|
||||
run: LabRun | null;
|
||||
/** The divider's label: the agent, or what the unowned group holds. */
|
||||
label: string;
|
||||
items: LabArtifact[];
|
||||
/** How many items carry the Review flag. */
|
||||
reviewCount: number;
|
||||
}
|
||||
|
||||
const newestFirst = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
|
||||
|
||||
export function sectionsOf(artifacts: LabArtifact[]): PanelSection[] {
|
||||
const byRun = new Map<string, { run: LabRun | null; items: LabArtifact[] }>();
|
||||
for (const artifact of artifacts) {
|
||||
const key = artifact.run?.id ?? "other";
|
||||
const group = byRun.get(key) ?? { run: artifact.run ?? null, items: [] };
|
||||
group.items.push(artifact);
|
||||
byRun.set(key, group);
|
||||
}
|
||||
|
||||
const sections = [...byRun.entries()].map(([id, { run, items }]) => {
|
||||
const sorted = [...items].sort((a, b) => newestFirst(a.updatedAt, b.updatedAt));
|
||||
return {
|
||||
id,
|
||||
run,
|
||||
label: run ? run.agent : items.every(isTaskDocument) ? "Task documents" : "Documents and links",
|
||||
items: sorted,
|
||||
reviewCount: items.filter((a) => a.review === "needs_review").length,
|
||||
// The product dates the unowned group by its newest row.
|
||||
at: run ? run.startedAt : sorted[0].updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return sections.sort((a, b) => newestFirst(a.at, b.at)).map(({ at: _at, ...section }) => section);
|
||||
}
|
||||
|
||||
/** The same sections, holding only what carries the Review flag. */
|
||||
export function onlyToReview(sections: PanelSection[]): PanelSection[] {
|
||||
return sections
|
||||
.map((section) => ({ ...section, items: section.items.filter((a) => a.review === "needs_review") }))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
3 · Tactile — the folder's custom properties (the board's colours are in
|
||||
tokens.css, the shelf and the opened stack in tokens-gallery.css).
|
||||
|
||||
A stack is a FOLDER: a back board, the stack's real contents tucked in and
|
||||
standing out of the top, more sheet tops behind them when it holds more than
|
||||
it shows, and a front pocket with a label tab (the task identifier) that
|
||||
carries the title, what it holds, the count and the date.
|
||||
*/
|
||||
|
||||
[data-direction="d3-tactile"] {
|
||||
/* ---- the folder's front: a soft sheen over the board, then the grain ---- */
|
||||
--tac-folder-face: linear-gradient(to bottom, oklch(1 0 0 / 0.42), oklch(1 0 0 / 0) 42%, oklch(0.3 0.04 70 / 0.06)), var(--tac-board-grain);
|
||||
--tac-folder-lip: oklch(1 0 0 / 0.85); /* the lit edge along the tab's shoulder */
|
||||
--tac-folder-tab-edge: inset 0 1px 0 oklch(1 0 0 / 0.7); /* the tab: lit along the top only, so it runs into its shoulder without a seam */
|
||||
--tac-fol-mouth: linear-gradient(to bottom, oklch(0.25 0.03 75 / 0), oklch(0.25 0.03 75 / 0.24)); /* shade where things go into the pocket */
|
||||
--tac-fol-shadow: drop-shadow(0 0 0.5px var(--tac-shadow-hairline)) drop-shadow(0 1px 1.5px var(--tac-shadow-contact)) drop-shadow(2px 9px 14px var(--tac-shadow-ambient));
|
||||
--tac-fol-shadow-lift: drop-shadow(0 0 0.5px var(--tac-shadow-hairline)) drop-shadow(0 1px 1.5px var(--tac-shadow-contact)) drop-shadow(3px 16px 24px var(--tac-shadow-ambient))
|
||||
drop-shadow(2px 9px 14px var(--tac-shadow-ambient));
|
||||
--tac-fol-lift: 0.75rem; /* how far the contents rise out of a hovered folder */
|
||||
--tac-fol-open: perspective(34rem) rotateX(-22deg); /* an opened folder's front, fallen toward the reader */
|
||||
--tac-tp-fan: translate, rotate, filter;
|
||||
|
||||
/* ---- more sheets than the folder shows: separate sheet tops behind the contents,
|
||||
staggered, each with its own edge. One, two, or — from twelve hidden — four. */
|
||||
--tac-pile: oklch(0.962 0.006 88);
|
||||
--tac-pile-edge: inset 0 1px 0 oklch(1 0 0 / 0.95), 0 0 0 1px oklch(0.3 0.02 80 / 0.1), 0 -1px 3px oklch(0.25 0.03 75 / 0.22);
|
||||
--tac-pile-r: 0.25rem;
|
||||
--tac-pile-a-h: 7.375rem;
|
||||
--tac-pile-a-tilt: -2.6deg;
|
||||
--tac-pile-b-h: 7.875rem;
|
||||
--tac-pile-b-tilt: 2deg;
|
||||
--tac-pile-c-h: 8.625rem; /* the two a full folder adds stand highest and lowest */
|
||||
--tac-pile-c-tilt: 0.8deg;
|
||||
--tac-pile-d-h: 6.875rem;
|
||||
--tac-pile-d-tilt: 3.6deg;
|
||||
}
|
||||
|
||||
.dark [data-direction="d3-tactile"] {
|
||||
--tac-folder-face: linear-gradient(to bottom, oklch(1 0 0 / 0.07), oklch(1 0 0 / 0) 45%, oklch(0 0 0 / 0.1)), var(--tac-board-grain);
|
||||
--tac-folder-lip: oklch(1 0 0 / 0.16);
|
||||
--tac-folder-tab-edge: inset 0 1px 0 oklch(1 0 0 / 0.12);
|
||||
--tac-fol-mouth: linear-gradient(to bottom, oklch(0 0 0 / 0), oklch(0 0 0 / 0.45));
|
||||
--tac-pile: oklch(0.87 0.01 88);
|
||||
--tac-pile-edge: inset 0 1px 0 oklch(1 0 0 / 0.6), 0 0 0 1px oklch(0 0 0 / 0.25), 0 -1px 4px oklch(0 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/* ---- folder sizes ----------------------------------------------------------
|
||||
shelf a stack on the Artifacts page: as wide as its grid cell
|
||||
hero the opened stack's own folder, beside its title: opened and empty
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-folder="shelf"] {
|
||||
--tac-fol-h: 15.75rem; /* the whole object, contents included */
|
||||
--tac-fol-rise: 2.5rem; /* how far the tallest contents stand above the back board */
|
||||
--tac-fol-pocket: 6.75rem;
|
||||
--tac-fol-tab-h: 1.5rem;
|
||||
--tac-fol-tab-w: 34%;
|
||||
--tac-fol-shoulder: 1.75rem;
|
||||
--tac-fol-r: 0.875rem;
|
||||
--tac-fol-inset: 0.75rem;
|
||||
--tac-fol-mouth-h: 0.875rem;
|
||||
--tac-fol-flag-x: 1.25rem;
|
||||
}
|
||||
/*
|
||||
Contents may lean out over the folder's sides above the pocket's mouth;
|
||||
below it nothing shows outside the pocket's walls. Declared on the folder,
|
||||
where --tac-fol-inset is.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-folder] {
|
||||
--tac-clip-contents: polygon(-50% -100%, 150% -100%, 150% 100%, calc(100% + var(--tac-fol-inset) - 2px) 100%, calc(100% + var(--tac-fol-inset) - 2px) 300%, calc(2px - var(--tac-fol-inset)) 300%, calc(2px - var(--tac-fol-inset)) 100%, -50% 100%);
|
||||
}
|
||||
/* A full folder: its mouth is in deeper shade. */
|
||||
[data-direction="d3-tactile"] [data-tac-folder="shelf"][data-tac-full] {
|
||||
--tac-fol-mouth-h: 1.375rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-folder="hero"] {
|
||||
--tac-fol-h: 9.25rem;
|
||||
--tac-fol-rise: 0.75rem;
|
||||
--tac-fol-pocket: 5rem;
|
||||
--tac-fol-tab-h: 1.375rem;
|
||||
--tac-fol-tab-w: 44%;
|
||||
--tac-fol-shoulder: 1.5rem;
|
||||
--tac-fol-r: 0.75rem;
|
||||
--tac-fol-inset: 0.625rem;
|
||||
--tac-fol-mouth-h: 2.25rem; /* nothing in it but shade */
|
||||
--tac-fol-flag-x: 1rem;
|
||||
}
|
||||
|
||||
/* ---- where each of a folder's contents stands -----------------------------
|
||||
`data-tac-slot` is "<how many><which>": 3b is the middle of three. A slot
|
||||
sets where along the pocket the object stands, the angle it leans at, the
|
||||
wider angle it fans to when the folder is hovered, and a few px of
|
||||
unevenness. `data-tac-deep` marks a portrait object, which sits deeper.
|
||||
|
||||
--tac-slot-w is the object's own width (set inline, in rem, by the shelf):
|
||||
wherever the slot asks to stand, the object's sides stay --tac-slot-keep
|
||||
inside the folder's edges.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-slot] {
|
||||
--tac-slot-tuck: 1rem;
|
||||
--tac-slot-dy: 0rem;
|
||||
--tac-slot-keep: 0.625rem;
|
||||
--tac-slot-y: calc(var(--tac-slot-tuck) + var(--tac-slot-dy));
|
||||
--tac-slot-y-open: calc(var(--tac-slot-tuck) + var(--tac-slot-dy) - var(--tac-fol-lift));
|
||||
--tac-slot-half: calc(var(--tac-slot-w) * var(--tac-fit, 1) / 2 - var(--tac-fol-inset) + var(--tac-slot-keep));
|
||||
--tac-slot-left: clamp(var(--tac-slot-half), var(--tac-slot-x), calc(100% - var(--tac-slot-half)));
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-slot][data-tac-deep] {
|
||||
--tac-slot-tuck: 1.6875rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-slot="1a"] { --tac-slot-x: 50%; --tac-fan: -2deg; --tac-fan-open: -3deg; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="2a"] { --tac-slot-x: 28%; --tac-fan: -4deg; --tac-fan-open: -7deg; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="2b"] { --tac-slot-x: 71%; --tac-fan: 3.5deg; --tac-fan-open: 6.5deg; --tac-slot-dy: 0.375rem; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="3a"] { --tac-slot-x: 19%; --tac-fan: -5deg; --tac-fan-open: -9deg; --tac-slot-dy: 0.25rem; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="3b"] { --tac-slot-x: 50%; --tac-fan: 1.5deg; --tac-fan-open: 2deg; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="3c"] { --tac-slot-x: 81%; --tac-fan: 4deg; --tac-fan-open: 8deg; --tac-slot-dy: 0.5rem; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="4a"] { --tac-slot-x: 15.5%; --tac-fan: -6deg; --tac-fan-open: -10deg; --tac-slot-dy: 0.375rem; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="4b"] { --tac-slot-x: 38.5%; --tac-fan: 2deg; --tac-fan-open: 3.5deg; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="4c"] { --tac-slot-x: 61.5%; --tac-fan: -2deg; --tac-fan-open: -3.5deg; --tac-slot-dy: 0.5rem; }
|
||||
[data-direction="d3-tactile"] [data-tac-slot="4d"] { --tac-slot-x: 84.5%; --tac-fan: 4deg; --tac-fan-open: 8deg; --tac-slot-dy: 0.125rem; }
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
3 · Tactile — the gallery's custom properties (materials are in tokens.css,
|
||||
the folder in tokens-folder.css, the panel's in tokens-panel.css).
|
||||
|
||||
The Artifacts page is a shelf of folders. Opened, a folder's contents stand
|
||||
by day on mats in the ground tone, each row on a ledge with its captions
|
||||
under the ledge.
|
||||
*/
|
||||
|
||||
[data-direction="d3-tactile"] {
|
||||
/* ---- the shelf: three folders across the page's 72rem, never narrower than their contents */
|
||||
--tac-shelf-cols: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
--tac-mat-top: 2rem; /* a stack of one: its mat starts this far down, so a sheet stands proud of it as in a thread row */
|
||||
|
||||
/* ---- the opened stack: day groups of objects on a mat ------------------- */
|
||||
--tac-gl-cols: repeat(auto-fill, minmax(min(100%, 15.5rem), 1fr)); /* four across the page's 72rem */
|
||||
--tac-mat-pad: 1.25rem; /* the mat's side padding, and… */
|
||||
--tac-mat-gap: 1.5rem; /* …the gap between its columns */
|
||||
--tac-gl-ring: 0.625rem; /* the focus ring stands this far off an item… */
|
||||
--tac-gl-ring-top: 1rem; /* …and further above it, clear of a flag on the object's top edge */
|
||||
--tac-gl-gap: calc(var(--tac-lip-h) + 0.5rem); /* ledge to caption */
|
||||
--tac-gl-cap: 16.5rem; /* a caption is no wider than the widest object, so its actions stay under the object */
|
||||
--tac-rule: oklch(0.3 0.02 80 / 0.16); /* the day ruler's ticks */
|
||||
--tac-rule-strong: oklch(0.3 0.02 80 / 0.5);
|
||||
--tac-ruler: repeating-linear-gradient(to bottom, var(--tac-rule) 0, var(--tac-rule) 1px, transparent 1px, transparent 0.75rem);
|
||||
--tac-ruler-w: 0.4375rem;
|
||||
--tac-ruler-major: 0.875rem;
|
||||
--tac-ruler-gutter: 1.75rem;
|
||||
}
|
||||
|
||||
.dark [data-direction="d3-tactile"] {
|
||||
--tac-rule: oklch(1 0 0 / 0.14);
|
||||
--tac-rule-strong: oklch(1 0 0 / 0.5);
|
||||
}
|
||||
|
||||
/*
|
||||
An item on a mat says how tall its own object is (--tac-obj-h, inline), so
|
||||
its focus ring and hit area can reach from the caption's top up over the
|
||||
object and no further. Declared on the item: a custom property resolves the
|
||||
var()s inside it where it is declared, not where it is used.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-mat] > [data-tac-item] {
|
||||
--tac-gl-reach: calc(var(--tac-lip-tuck) - var(--tac-obj-h) - var(--tac-gl-ring-top));
|
||||
}
|
||||
|
||||
/*
|
||||
`fd` — folder contents. Every shape comes to roughly 7–9.5rem wide, small
|
||||
enough for three or four to stand in a folder and large enough that a
|
||||
sheet's headline (about 10px on screen) and a print's picture are the real
|
||||
ones. Extension tabs and stamps ride high, clear of the pocket's mouth.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-size="fd"] {
|
||||
--tac-k: 0.5; /* page: 7.5rem × 9.7rem */
|
||||
--tac-ear: 2.125rem;
|
||||
--tac-page-pad: 1.375rem;
|
||||
--tac-page-r: 0.375rem;
|
||||
--tac-type-title: 1.28125rem;
|
||||
--tac-type-body: 0.8125rem;
|
||||
--tac-type-tab: 1.125rem;
|
||||
--tac-tab-rise: 4.6em;
|
||||
--tac-print-w: 9.5rem;
|
||||
--tac-print-h: 7.75rem;
|
||||
--tac-print-edge: 0.3125rem;
|
||||
--tac-play: 1.75rem;
|
||||
--tac-shadow-near: 0 1px 1.5px;
|
||||
--tac-shadow-far: 1px 5px 8px;
|
||||
--tac-back-dx: 3%;
|
||||
--tac-tilt-back: 2deg;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="fd"][data-tac-box="slide"] {
|
||||
--tac-k: 0.44;
|
||||
--tac-page-r: 0.4375rem;
|
||||
--tac-type-title: 1.6875rem;
|
||||
--tac-type-body: 0.875rem;
|
||||
--tac-type-tab: 1.25rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="fd"][data-tac-box="ledger"] {
|
||||
--tac-k: 0.48;
|
||||
--tac-type-body: 0.875rem;
|
||||
--tac-tab-rise: 3.4em;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="fd"][data-tac-box="box"] {
|
||||
--tac-k: 0.38;
|
||||
--tac-type-tab: 1.4375rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="fd"][data-tac-box="card"] {
|
||||
--tac-k: 0.44;
|
||||
--tac-page-pad: 1.125rem;
|
||||
--tac-type-body: 1.0625rem;
|
||||
}
|
||||
|
||||
/*
|
||||
`gl` — an artifact in the opened stack (and a stack of one on the shelf).
|
||||
The largest the objects get in a grid: a sheet is 11.1rem × 14.3rem with its
|
||||
headline at about 12.5px and its first lines at about 8px. Everything wider
|
||||
than tall is sized to the grid cell instead — 15.25rem across — so a print
|
||||
stands nearly as tall as a sheet.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-size="gl"] {
|
||||
--tac-k: 0.74;
|
||||
--tac-ear: 1.9375rem;
|
||||
--tac-page-pad: 1.4375rem;
|
||||
--tac-page-r: 0.25rem;
|
||||
--tac-type-title: 1.0625rem;
|
||||
--tac-type-body: 0.6875rem;
|
||||
--tac-type-tab: 0.875rem;
|
||||
--tac-print-w: 15.25rem;
|
||||
--tac-print-h: 13.5rem;
|
||||
--tac-print-edge: 0.5625rem;
|
||||
--tac-play: 2.75rem;
|
||||
--tac-shadow-near: 0 1px 2px;
|
||||
--tac-shadow-far: 2px 8px 14px;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="gl"][data-tac-box="slide"] {
|
||||
--tac-k: 0.7625;
|
||||
--tac-type-title: 1.5rem;
|
||||
--tac-type-body: 0.75rem;
|
||||
--tac-type-tab: 0.84375rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="gl"][data-tac-box="ledger"] {
|
||||
--tac-k: 0.87;
|
||||
--tac-type-body: 0.75rem;
|
||||
--tac-type-tab: 0.75rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="gl"][data-tac-box="box"] {
|
||||
--tac-k: 0.72;
|
||||
--tac-type-tab: 0.875rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="gl"][data-tac-box="card"] {
|
||||
--tac-k: 0.7625;
|
||||
--tac-page-pad: 1.125rem;
|
||||
--tac-type-body: 0.8125rem;
|
||||
}
|
||||
|
||||
/*
|
||||
Narrow pages. `day` is one day's section of an opened stack: under 46rem its
|
||||
objects step down so three columns hold at a 1024 viewport and two at 768.
|
||||
`shelf` is the stacks grid: between 28.5rem and 40rem it keeps two columns
|
||||
of compact folders rather than one very wide one, and under 19rem a single
|
||||
compact one.
|
||||
*/
|
||||
@container day (width < 46rem) {
|
||||
[data-direction="d3-tactile"] [data-tac-mat] {
|
||||
--tac-gl-cols: repeat(auto-fill, minmax(min(100%, 12.25rem), 1fr));
|
||||
--tac-gl-cap: 13.5rem;
|
||||
--tac-mat-pad: 0.875rem;
|
||||
--tac-mat-gap: 1rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="gl"] {
|
||||
--tac-fit: 0.79;
|
||||
}
|
||||
}
|
||||
@container shelf (width < 40rem) {
|
||||
[data-direction="d3-tactile"] [data-tac-shelf] {
|
||||
--tac-shelf-cols: repeat(auto-fill, minmax(min(100%, 13.5rem), 1fr));
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-folder="shelf"] {
|
||||
--tac-fol-h: 12.75rem;
|
||||
--tac-fol-rise: 2rem;
|
||||
--tac-fol-lift: 0.5rem;
|
||||
--tac-pile-a-h: 5.375rem;
|
||||
--tac-pile-b-h: 5.75rem;
|
||||
--tac-pile-c-h: 6.25rem;
|
||||
--tac-pile-d-h: 5rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-slot] {
|
||||
--tac-fit: 0.72;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-stack="one"] [data-tac-size="gl"] {
|
||||
--tac-fit: 0.6;
|
||||
}
|
||||
}
|
||||
@container shelf (width < 28.5rem) and (width >= 19rem) {
|
||||
[data-direction="d3-tactile"] [data-tac-shelf] {
|
||||
--tac-shelf-cols: repeat(auto-fill, minmax(min(100%, 19rem), 1fr));
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-folder="shelf"] {
|
||||
--tac-fol-h: 15.75rem;
|
||||
--tac-fol-rise: 2.5rem;
|
||||
--tac-fol-lift: 0.75rem;
|
||||
--tac-pile-a-h: 7.375rem;
|
||||
--tac-pile-b-h: 7.875rem;
|
||||
--tac-pile-c-h: 8.625rem;
|
||||
--tac-pile-d-h: 6.875rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-slot],
|
||||
[data-direction="d3-tactile"] [data-tac-stack="one"] [data-tac-size="gl"] {
|
||||
--tac-fit: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
3 · Tactile — the panel's custom properties (the materials are in tokens.css).
|
||||
|
||||
The panel is the desk: no cards. Paper stands in a slot — open on three
|
||||
sides, its foot sliding under the rail that ends the row — with a plain
|
||||
caption beside it. A picture lies across the whole desk with its caption
|
||||
under it. A run of work is introduced by a divider: folder board with a tab.
|
||||
*/
|
||||
|
||||
[data-direction="d3-tactile"] {
|
||||
/* ---- the desk: the pane's body, in the ground tone every surface shares ---- */
|
||||
--tac-desk: var(--tac-ground);
|
||||
--tac-desk-rim: inset 0 2px 3px -1px oklch(0.3 0.02 80 / 0.14), inset 0 1px 0 oklch(0.3 0.02 80 / 0.09);
|
||||
|
||||
/* ---- the slot paper stands in ----------------------------------------- */
|
||||
--tac-stage-w: 9.75rem; /* every `pn` object is at most this wide */
|
||||
--tac-stage-min: 8.5rem;
|
||||
--tac-slot-gap: 1rem; /* stage to caption */
|
||||
--tac-clip-slot: inset(-1.5rem -1.5rem 0 -1.5rem); /* open above and to the sides, cut at the rail */
|
||||
--tac-wide-max: 27rem; /* a picture lying across the desk stops growing here, in a maximised pane */
|
||||
|
||||
/* ---- a flag standing on a top edge (objects, dividers) ----------------- */
|
||||
--tac-flag-tuck: 0.4375rem; /* how much of the flag is stuck to the face */
|
||||
--tac-flag-nip: 0.1875rem; /* …where the face has something printed right at its top edge */
|
||||
|
||||
/* ---- the switch above the desk: the pressed key ------------------------ */
|
||||
--tac-key: oklch(1 0 0);
|
||||
--tac-key-edge: 0 1px 2px oklch(0.3 0.02 80 / 0.16), 0 0 0 1px oklch(0.3 0.02 80 / 0.09);
|
||||
|
||||
/* ---- the divider that introduces a run --------------------------------- */
|
||||
--tac-divider-h: 1.625rem;
|
||||
--tac-divider-bar: 0.1875rem;
|
||||
--tac-divider-shadow: drop-shadow(0 1px 1px oklch(0.3 0.03 75 / 0.22));
|
||||
--tac-divider-max: 17rem; /* the tab leaves room for the flag at the far end of its bar */
|
||||
}
|
||||
|
||||
.dark [data-direction="d3-tactile"] {
|
||||
--tac-desk: oklch(0.185 0.003 85);
|
||||
--tac-key: oklch(0.285 0.004 85);
|
||||
--tac-key-edge: 0 1px 2px oklch(0 0 0 / 0.4), inset 0 1px 0 oklch(1 0 0 / 0.06);
|
||||
--tac-desk-rim: inset 0 2px 4px -1px oklch(0 0 0 / 0.45), inset 0 1px 0 oklch(0 0 0 / 0.35);
|
||||
--tac-divider-shadow: drop-shadow(0 1px 1px oklch(0 0 0 / 0.5));
|
||||
}
|
||||
/* The dark desk is a step under the pane, where the tray is a step over the canvas: its rail is cut from the darker stuff. */
|
||||
.dark [data-direction="d3-tactile"] [data-tac-desk] {
|
||||
--tac-ledge: oklch(0.262 0.004 85);
|
||||
}
|
||||
|
||||
/*
|
||||
`pn` — the panel's size for what stands in a slot. Unlike the thread sizes
|
||||
it gives each shape its own scale, so a sheet, a slide, a ledger, a box and
|
||||
a card all come to about the stage's width. A sheet is set to be read
|
||||
here: its headline is about 13px on screen and its first lines about 10px,
|
||||
and the text stops fading out just above the rail.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"] {
|
||||
--tac-k: 0.61; /* page: 9.15rem × 11.8rem, of which the slot shows the top two thirds */
|
||||
--tac-ear: 1.9375rem;
|
||||
--tac-page-pad: 1.25rem;
|
||||
--tac-page-r: 0.3125rem;
|
||||
--tac-type-title: 1.3125rem;
|
||||
--tac-type-body: 1.03125rem;
|
||||
--tac-type-tab: 0.9375rem;
|
||||
--tac-lead-body: 1.42;
|
||||
--tac-print-w: 9.25rem;
|
||||
--tac-print-h: 8.25rem;
|
||||
--tac-print-edge: 0.375rem;
|
||||
--tac-play: 2rem;
|
||||
--tac-shadow-near: 0 1px 2px;
|
||||
--tac-shadow-far: 2px 7px 12px;
|
||||
/* A second sheet shows less of itself here: the caption starts a rem to the right. */
|
||||
--tac-back-dx: 2.5%;
|
||||
--tac-tilt-back: 2deg;
|
||||
}
|
||||
/*
|
||||
Also set on the slot of a sheet, not only on the sheet: a sheet's foot is
|
||||
always under the rail (in a row taller than the sheet it slides down to stay
|
||||
there), and its extension tab or stamp moves up into view.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"][data-tac-box="page"] {
|
||||
--tac-tab-rise: 7.75rem;
|
||||
--tac-tab-out: 1.2em; /* …and hangs further off the edge, clear of the lines it would cover here */
|
||||
--tac-slot-top: max(0rem, calc(100% - var(--tac-page-h) + 1.25rem));
|
||||
--tac-fade-bottom: linear-gradient(to bottom, black 0%, black 50%, transparent 70%);
|
||||
--tac-stamp-rise: 4rem; /* on the blank foot of the sheet, above the rail */
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"][data-tac-box="slide"] {
|
||||
--tac-k: 0.46;
|
||||
--tac-page-pad: 1.375rem;
|
||||
--tac-page-r: 0.375rem;
|
||||
--tac-type-title: 1.6875rem;
|
||||
--tac-type-body: 0.875rem;
|
||||
--tac-type-tab: 1.21875rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"][data-tac-box="ledger"] {
|
||||
--tac-k: 0.53;
|
||||
--tac-page-r: 0.3125rem;
|
||||
--tac-type-body: 0.875rem;
|
||||
--tac-type-tab: 1.0625rem;
|
||||
}
|
||||
/* A box is smaller than the paper beside it: it holds things, it is not the thing to read. */
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"][data-tac-box="box"] {
|
||||
--tac-k: 0.44;
|
||||
--tac-page-r: 0.4375rem;
|
||||
--tac-type-tab: 1.28125rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"][data-tac-box="card"] {
|
||||
--tac-k: 0.46;
|
||||
--tac-page-pad: 1.125rem;
|
||||
--tac-page-r: 0.375rem;
|
||||
--tac-type-body: 1rem;
|
||||
}
|
||||
|
||||
/*
|
||||
`pw` — a picture lying across the desk: a print (image, playable video) or a
|
||||
link's address card with its preview. As wide as its holder; nothing is
|
||||
scaled, so the address is set in real UI sizes.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-size="pw"] {
|
||||
--tac-print-w: 100%;
|
||||
--tac-print-h: min(17rem, 100cqw); /* a square or upright picture is held to this height, so it does not take the pane over */
|
||||
--tac-print-edge: 0.5rem;
|
||||
--tac-page-r: 0.25rem;
|
||||
--tac-play: 2.75rem;
|
||||
--tac-shadow-near: 0 1px 2px;
|
||||
--tac-shadow-far: 2px 9px 16px;
|
||||
}
|
||||
|
||||
/*
|
||||
A pane dragged narrow. The slot stays beside the caption down to a 16rem
|
||||
body (a 290px pane), with a smaller stage from 22rem down; under 16rem the
|
||||
caption goes below and the slot takes the whole width (PanelItem). `desk`
|
||||
is each section.
|
||||
*/
|
||||
@container desk (width < 22rem) {
|
||||
[data-direction="d3-tactile"] [data-tac-item] {
|
||||
--tac-stage-w: 7rem;
|
||||
--tac-stage-min: 6.25rem;
|
||||
--tac-slot-gap: 0.75rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"] {
|
||||
--tac-fit: 0.72;
|
||||
}
|
||||
}
|
||||
@container desk (width < 16rem) {
|
||||
[data-direction="d3-tactile"] [data-tac-item] {
|
||||
--tac-stage-min: 7.5rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="pn"] {
|
||||
--tac-fit: 0.86;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
3 · Tactile — object sizes.
|
||||
|
||||
A paper object is authored once at "design" size (a 15rem-wide page) and
|
||||
scaled by --tac-k; the type on it is retuned per size so a page shows fewer,
|
||||
larger words when it is small. Prints are not scaled: they take
|
||||
--tac-print-w / --tac-print-h (the long side, landscape / portrait).
|
||||
|
||||
xs thread row sm thread attachments (here)
|
||||
pn panel, in a slot pw panel, across the desk (tokens-panel.css)
|
||||
fd folder contents gl opened stack, stack of one (tokens-gallery.css)
|
||||
|
||||
--tac-fit (default 1) shrinks a whole size where its container is narrow; it
|
||||
is only ever set inside a container query, on the object itself.
|
||||
*/
|
||||
[data-direction="d3-tactile"] [data-tac-size="xs"] {
|
||||
--tac-k: 0.258; /* page: 62 × 80px — tall enough to stand 10px proud of a 72px tray with its foot behind the lip */
|
||||
--tac-ear: 2.5rem;
|
||||
--tac-page-pad: 1.5rem;
|
||||
--tac-page-r: 0.5625rem;
|
||||
--tac-type-title: 1.8125rem;
|
||||
--tac-type-body: 1rem;
|
||||
--tac-type-tab: 2.25rem;
|
||||
--tac-print-w: 5rem;
|
||||
--tac-print-h: 3.875rem;
|
||||
--tac-print-edge: 0.21875rem;
|
||||
--tac-play: 1.25rem;
|
||||
--tac-shadow-near: 0 0.5px 0.75px;
|
||||
--tac-shadow-far: 1px 2px 3px;
|
||||
}
|
||||
/* The landscape shapes come to the print's width, not the page's scale. */
|
||||
[data-direction="d3-tactile"] [data-tac-size="xs"][data-tac-box="slide"] {
|
||||
--tac-k: 0.25;
|
||||
--tac-type-title: 2rem;
|
||||
--tac-type-tab: 2.25rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="xs"][data-tac-box="ledger"] {
|
||||
--tac-k: 0.27;
|
||||
--tac-type-tab: 2.125rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="xs"][data-tac-box="box"] {
|
||||
--tac-k: 0.25;
|
||||
--tac-type-tab: 2.25rem;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="xs"][data-tac-box="card"] {
|
||||
--tac-k: 0.25;
|
||||
}
|
||||
[data-direction="d3-tactile"] [data-tac-size="sm"] {
|
||||
--tac-k: 0.27;
|
||||
--tac-ear: 2.75rem;
|
||||
--tac-page-pad: 1.5rem;
|
||||
--tac-page-r: 0.5625rem;
|
||||
--tac-type-title: 1.4375rem;
|
||||
--tac-type-body: 0.8125rem;
|
||||
--tac-type-tab: 2.125rem;
|
||||
--tac-print-w: 6.75rem;
|
||||
--tac-print-h: 5.25rem;
|
||||
--tac-print-edge: 0.25rem;
|
||||
--tac-play: 1.5rem;
|
||||
--tac-shadow-near: 0 1px 1px;
|
||||
--tac-shadow-far: 1px 4px 6px;
|
||||
}
|
||||
/* Derived on the same element as --tac-k, so each object resolves its own. */
|
||||
[data-direction="d3-tactile"] [data-tac-size] {
|
||||
--tac-ks: calc(var(--tac-k) * var(--tac-fit, 1));
|
||||
--tac-page-w: calc(15rem * var(--tac-ks));
|
||||
--tac-page-h: calc(19.375rem * var(--tac-ks));
|
||||
--tac-slide-w: calc(20rem * var(--tac-ks));
|
||||
--tac-slide-h: calc(11.25rem * var(--tac-ks));
|
||||
--tac-ledger-w: calc(17.5rem * var(--tac-ks));
|
||||
--tac-ledger-h: calc(14rem * var(--tac-ks));
|
||||
--tac-box-w: calc(18.75rem * var(--tac-ks));
|
||||
--tac-box-h: calc(15.5rem * var(--tac-ks));
|
||||
--tac-card-w: calc(20rem * var(--tac-ks));
|
||||
--tac-card-h: calc(11rem * var(--tac-ks));
|
||||
--tac-print-ws: calc(var(--tac-print-w) * var(--tac-fit, 1));
|
||||
--tac-print-hs: calc(var(--tac-print-h) * var(--tac-fit, 1));
|
||||
|
||||
--tac-clip-ear: polygon(0 0, calc(100% - var(--tac-ear)) 0, 100% var(--tac-ear), 100% 100%, 0 100%);
|
||||
--tac-clip-fold: polygon(0 0, 100% 100%, 0 100%);
|
||||
--tac-ear-clear: calc(var(--tac-ear) - var(--tac-page-pad) + 0.25rem);
|
||||
--tac-film-w: calc(var(--tac-print-edge) * 2.6);
|
||||
--tac-card-r: calc(var(--tac-page-r) * 2.4); /* an address card's corners are rounder than paper's */
|
||||
--tac-stamp-rise: var(--tac-tab-rise); /* a stamp sits level with a file's extension tab, unless a size says otherwise */
|
||||
|
||||
--tac-filter-rest: drop-shadow(0 0 0.5px var(--tac-shadow-hairline)) drop-shadow(var(--tac-shadow-near) var(--tac-shadow-contact))
|
||||
drop-shadow(var(--tac-shadow-far) var(--tac-shadow-ambient));
|
||||
--tac-filter-lift: drop-shadow(0 0 0.5px var(--tac-shadow-hairline)) drop-shadow(var(--tac-shadow-near) var(--tac-shadow-contact))
|
||||
drop-shadow(var(--tac-shadow-far) var(--tac-shadow-ambient)) drop-shadow(var(--tac-shadow-far) var(--tac-shadow-ambient));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
3 · Tactile — custom properties, scoped to the wrapper every frame puts
|
||||
around a kit slot. Light values first, dark overrides below. The object-size
|
||||
table is in tokens-sizes.css.
|
||||
|
||||
Naming: --tac-<role>. Materials are complete colours. Nothing here is
|
||||
consumed outside directions/d3-tactile.
|
||||
*/
|
||||
|
||||
[data-direction="d3-tactile"] {
|
||||
/* ---- type on paper --------------------------------------------------- */
|
||||
--tac-font-serif: "Iowan Old Style", "Charter", "Bitstream Charter", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
|
||||
|
||||
/* ---- paper ----------------------------------------------------------- */
|
||||
--tac-paper: oklch(0.992 0.004 90);
|
||||
--tac-paper-back: oklch(0.945 0.007 88); /* a second sheet lying under the first */
|
||||
--tac-fold: linear-gradient(225deg, oklch(0.84 0.01 88) 0%, oklch(0.9 0.008 88) 48%, oklch(0.975 0.005 90) 100%);
|
||||
--tac-fold-shadow: drop-shadow(-1px 1px 1.5px oklch(0.25 0.02 80 / 0.28));
|
||||
--tac-ink: oklch(0.24 0.012 70);
|
||||
--tac-ink-soft: oklch(0.44 0.012 70);
|
||||
--tac-ink-faint: oklch(0.82 0.008 85); /* greeked lines, rules */
|
||||
--tac-paper-edge: inset 0 1px 0 oklch(1 0 0 / 0.95), inset 0 0 0 1px oklch(0.3 0.02 80 / 0.07);
|
||||
--tac-fade-bottom: linear-gradient(to bottom, black 0%, black 52%, transparent 94%);
|
||||
/* Setting the page: em-based, so they follow the per-size type below. */
|
||||
--tac-lead-title: 1.14;
|
||||
--tac-lead-body: 1.5;
|
||||
--tac-gap-block: 0.8em;
|
||||
--tac-gap-para: 0.55em;
|
||||
--tac-bar-h: 0.5em;
|
||||
--tac-bar-gap: 0.64em;
|
||||
--tac-back-dx: 4%;
|
||||
--tac-back-dy: 2.5%;
|
||||
--tac-rule-w: 0.1em;
|
||||
--tac-glyph: 1.15em; /* a printed mark the size of the type beside it */
|
||||
--tac-gutter: 2.3em; /* the code sheet's line-number column */
|
||||
--tac-ledger-margin: 2em;
|
||||
--tac-ledger-row: 2.1em;
|
||||
/* Holes through the paper: binder punches down a ledger's margin. */
|
||||
--tac-mask-punch: radial-gradient(circle at 1em 20%, transparent 0.4em, black 0.46em), radial-gradient(circle at 1em 50%, transparent 0.4em, black 0.46em),
|
||||
radial-gradient(circle at 1em 80%, transparent 0.4em, black 0.46em);
|
||||
--tac-fade-end: linear-gradient(to bottom, black 0%, black 68%, transparent 100%); /* a title that runs past its box dissolves; nothing on paper is ellipsized */
|
||||
|
||||
/* ---- prints ---------------------------------------------------------- */
|
||||
--tac-print: oklch(0.998 0.002 90);
|
||||
--tac-emulsion: oklch(0.88 0.006 85); /* an undeveloped print: missing or failed thumbnail */
|
||||
--tac-emulsion-ink: oklch(0.47 0.008 85);
|
||||
--tac-emulsion-edge: inset 0 0 0 1px oklch(0.2 0.01 80 / 0.14);
|
||||
--tac-film: oklch(0.2 0.008 80);
|
||||
--tac-film-ink: oklch(0.97 0.004 90);
|
||||
--tac-print-r: 0.09375rem;
|
||||
|
||||
/* ---- kind markings --------------------------------------------------- */
|
||||
--tac-tab-pdf: oklch(0.55 0.2 27);
|
||||
--tac-tab-slides: oklch(0.74 0.16 62);
|
||||
--tac-tab-sheet: oklch(0.52 0.13 155);
|
||||
--tac-tab-code: oklch(0.9 0.06 230); /* pale, so it reads on the blue sheet */
|
||||
--tac-tab-ink-code: oklch(0.27 0.07 258);
|
||||
--tac-tab-note: oklch(0.4 0.012 260); /* Markdown and anything without a hue of its own */
|
||||
--tac-tab-ink: oklch(0.99 0.003 90);
|
||||
--tac-tab-ink-dark: oklch(0.22 0.03 60); /* on the slides tab, the one light tab */
|
||||
--tac-tab-shadow: 0 1px 1px oklch(0.2 0.02 60 / 0.35);
|
||||
/* The tab's own box, in ems of --tac-type-tab. */
|
||||
--tac-tab-px: 0.36em;
|
||||
--tac-tab-py: 0.26em;
|
||||
--tac-tab-r: 0.14em;
|
||||
--tac-tab-out: 0.32em;
|
||||
--tac-tab-rise: 0.6em;
|
||||
--tac-tab-up: 0.82em; /* an index tab standing above a top edge (slides) */
|
||||
--tac-spine-w: 0.17em;
|
||||
|
||||
--tac-stamp: oklch(0.43 0.07 262 / 0.82); /* the "rev N" rubber stamp on a task document: stamp-pad blue-black */
|
||||
--tac-stamp-edge: 0.13em;
|
||||
--tac-stamp-tilt: -5deg;
|
||||
--tac-ledger-rule: oklch(0.72 0.09 155);
|
||||
--tac-ledger-head: oklch(0.93 0.045 155);
|
||||
--tac-ledger-ink: oklch(0.36 0.09 155);
|
||||
--tac-carbon: oklch(0.34 0.075 258); /* the code sheet: blueprint blue, never paper-white */
|
||||
--tac-carbon-gutter: oklch(0.29 0.07 258);
|
||||
--tac-carbon-fold: linear-gradient(225deg, oklch(0.26 0.06 258) 0%, oklch(0.42 0.08 258) 55%, oklch(0.52 0.085 258) 100%);
|
||||
--tac-carbon-edge: inset 0 1px 0 oklch(1 0 0 / 0.22), inset 0 0 0 1px oklch(1 0 0 / 0.06);
|
||||
--tac-code-ink: oklch(0.95 0.015 250);
|
||||
--tac-code-dim: oklch(0.7 0.05 255);
|
||||
--tac-code-tag: oklch(0.88 0.09 205);
|
||||
--tac-code-note: oklch(0.88 0.1 150);
|
||||
--tac-kraft: oklch(0.72 0.075 72);
|
||||
--tac-kraft-lid: oklch(0.65 0.078 68);
|
||||
--tac-kraft-dark: oklch(0.38 0.05 60);
|
||||
--tac-kraft-edge: inset 0 1px 0 oklch(1 0 0 / 0.28), inset 0 -1px 0 oklch(0.2 0.03 60 / 0.25);
|
||||
--tac-kraft-shade: linear-gradient(to bottom, oklch(0.2 0.03 60 / 0.32), oklch(0.2 0.03 60 / 0));
|
||||
--tac-kraft-hole: inset 0 2px 3px oklch(0 0 0 / 0.45);
|
||||
/* A link is an address card: pale blue stock (manila is for folders), the address printed in a white bar. */
|
||||
--tac-card: oklch(0.95 0.026 236);
|
||||
--tac-card-bar: oklch(0.995 0.003 236);
|
||||
--tac-card-bar-edge: inset 0 1px 1.5px oklch(0.3 0.05 250 / 0.22), 0 1px 0 oklch(1 0 0 / 0.8);
|
||||
--tac-card-ink: oklch(0.3 0.06 255);
|
||||
--tac-card-soft: oklch(0.5 0.05 250);
|
||||
--tac-card-dot: oklch(0.62 0.13 250);
|
||||
--tac-card-edge: inset 0 1px 0 oklch(1 0 0 / 0.9), inset 0 0 0 1px oklch(0.3 0.06 250 / 0.1);
|
||||
|
||||
/* ---- state: a sticky flag in the status colour ----------------------- */
|
||||
--tac-flag-ink: oklch(0.995 0 0);
|
||||
--tac-flag-glue: oklch(1 0 0 / 0.3);
|
||||
--tac-flag-key: oklch(1 0 0 / 0.7); /* a 1px film edge round the flag, so its shape holds on dark trays and on kraft */
|
||||
--tac-flag-shadow: drop-shadow(0 1px 1px oklch(0.2 0.02 80 / 0.3));
|
||||
--tac-clip-flag: polygon(0 0, 100% 0, calc(100% - 0.3125rem) 50%, 100% 100%, 0 100%);
|
||||
--tac-clip-nub: polygon(0 0, 100% 0, 100% 100%, 50% calc(100% - 0.1875rem), 0 100%);
|
||||
--tac-flag-h: 1.125rem;
|
||||
--tac-nub-w: 0.4375rem;
|
||||
--tac-nub-h: 0.9375rem;
|
||||
--tac-nub-rise: 0.375rem; /* how far a flag stands proud of the edge it is stuck to */
|
||||
|
||||
/* ---- the ground: what paper lies on, the same warm tone on every surface -----
|
||||
thread: the tray · panel: the desk · gallery: the mat under an opened stack's
|
||||
rows and under a stack of one. Deep enough in light that white paper holds
|
||||
its edge on it (about 1.2:1 before the shadow). */
|
||||
--tac-ground: oklch(0.925 0.009 85);
|
||||
--tac-tray: var(--tac-ground);
|
||||
--tac-tray-hover: oklch(0.905 0.01 85);
|
||||
--tac-tray-press: oklch(0.885 0.011 85);
|
||||
--tac-tray-rim: inset 0 2px 3px oklch(0.3 0.02 80 / 0.14), inset 0 0 0 1px oklch(0.3 0.02 80 / 0.1), 0 1px 0 oklch(1 0 0 / 1);
|
||||
--tac-tray-max: 40rem; /* a tray's own measure in a wide column; in a column under 48rem it runs the full width */
|
||||
--tac-desk-cell: 7.75rem; /* one place on the attachments tray */
|
||||
--tac-desk-cols: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
--tac-meta: oklch(0.45 0.008 85); /* secondary text on the ground: 5.9:1 */
|
||||
|
||||
/* ---- the lip: one physical edge on every surface ---------------------------
|
||||
A strip of the ground's own material, lit along the top, throwing a crisp
|
||||
shadow on whatever stands behind it. thread: the front of the tray, hiding
|
||||
an object's foot · panel: the rail a sheet slides under · gallery and
|
||||
attachments: the front of the ledge a row of objects stands on. */
|
||||
--tac-ledge: oklch(0.955 0.007 85);
|
||||
--tac-ledge-edge: inset 0 1px 0 oklch(1 0 0 / 0.95), inset 0 -1px 0 oklch(0.3 0.02 80 / 0.1), 0 -1px 0 oklch(0.3 0.02 80 / 0.2), 0 -3px 3px -1px oklch(0.3 0.02 80 / 0.24);
|
||||
--tac-ledge-under: 0 2px 3px -1px oklch(0.3 0.02 80 / 0.22); /* added under a ledge that stands out from the mat */
|
||||
--tac-shelf-edge: var(--tac-ledge-edge), var(--tac-ledge-under);
|
||||
--tac-lip-h: 0.375rem;
|
||||
--tac-lip-tuck: 0.25rem; /* how much of an object's foot is behind the lip */
|
||||
--tac-row-pb: calc(var(--tac-lip-h) + 0.25rem); /* a thread row's text clears the lip */
|
||||
--tac-row-foot: calc(var(--tac-lip-h) - var(--tac-lip-tuck)); /* …and its object stands this far above the tray's bottom edge */
|
||||
--tac-clip-stand: inset(-4rem -2rem 0 -2rem); /* a standing object's shadow stops at the lip too */
|
||||
--tac-run: 100vw; /* a ledge runs past its own item to the edges of the tray, which clips it */
|
||||
|
||||
/* ---- focus: a strong ring with a gap in the colour of what the control lies on (set by the caller) */
|
||||
--tac-ring: color-mix(in oklab, var(--foreground) 72%, transparent);
|
||||
--tac-mono-scale: 0.92em; /* mono beside the UI face, optically level */
|
||||
|
||||
/* ---- folder board: the panel's divider tabs, the gallery's folders ---- */
|
||||
--tac-folder: oklch(0.878 0.042 84);
|
||||
--tac-folder-back: oklch(0.8 0.048 82);
|
||||
--tac-folder-ink: oklch(0.27 0.03 70);
|
||||
--tac-folder-meta: oklch(0.4 0.03 72);
|
||||
--tac-folder-edge: inset 0 1px 0 oklch(1 0 0 / 0.7), inset 0 0 0 1px oklch(0.3 0.03 70 / 0.08);
|
||||
|
||||
/* ---- grain: feTurbulence, baked faint on paper, twice as strong on board ----
|
||||
--tac-grain / -light paper and prints: barely there, so type stays crisp
|
||||
--tac-board-grain folder board and the lip (dark specks in light, light specks in dark)
|
||||
--tac-ground-grain tray, desk, mat: between the two, since UI type is set on it
|
||||
--tac-kraft-grain the box: the same speck plus a horizontal fibre */
|
||||
--tac-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.16 0 0 0 0 0.1 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)' opacity='0.1'/%3E%3C/svg%3E");
|
||||
--tac-grain-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)' opacity='0.07'/%3E%3C/svg%3E");
|
||||
|
||||
--tac-board-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.15 0 0 0 0 0.08 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)' opacity='0.24'/%3E%3C/svg%3E");
|
||||
--tac-ground-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.2 0 0 0 0 0.15 0 0 0 0 0.08 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)' opacity='0.13'/%3E%3C/svg%3E");
|
||||
--tac-kraft-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='120'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.012 0.55' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.25 0 0 0 0 0.16 0 0 0 0 0.06 0 0 0 1.1 -0.1'/%3E%3C/filter%3E%3Crect width='240' height='120' filter='url(%23f)' opacity='0.34'/%3E%3C/svg%3E"), var(--tac-board-grain);
|
||||
|
||||
/* ---- light: one source, top-left -------------------------------------- */
|
||||
--tac-shadow-contact: oklch(0.3 0.03 75 / 0.36);
|
||||
--tac-shadow-ambient: oklch(0.3 0.03 75 / 0.24);
|
||||
--tac-shadow-hairline: oklch(0.3 0.03 75 / 0.38);
|
||||
|
||||
/* ---- repose: nothing lies perfectly square ---------------------------- */
|
||||
--tac-tilt-a: -2.5deg;
|
||||
--tac-tilt-b: 1.5deg;
|
||||
--tac-tilt-c: -1deg;
|
||||
--tac-tilt-d: 2.5deg;
|
||||
/* Something lying across a whole pane is tilted by the same few px at its corners, so by a smaller angle. */
|
||||
--tac-tilt-wide-a: -0.8deg;
|
||||
--tac-tilt-wide-b: 0.6deg;
|
||||
--tac-tilt-flag: 2deg;
|
||||
--tac-tilt-back: 3deg;
|
||||
--tac-lift: -0.125rem;
|
||||
--tac-tp-lift: translate, rotate, filter, background-color;
|
||||
}
|
||||
|
||||
.dark [data-direction="d3-tactile"] {
|
||||
/* Paper comes a step off white so it does not glare on the dark canvas. */
|
||||
--tac-paper: oklch(0.94 0.009 90);
|
||||
--tac-paper-back: oklch(0.86 0.01 88);
|
||||
--tac-fold: linear-gradient(225deg, oklch(0.72 0.012 88) 0%, oklch(0.83 0.01 88) 48%, oklch(0.92 0.008 90) 100%);
|
||||
--tac-fold-shadow: drop-shadow(-1px 1px 1.5px oklch(0.15 0.02 80 / 0.4));
|
||||
--tac-ink-faint: oklch(0.76 0.01 85);
|
||||
--tac-paper-edge: inset 0 1px 0 oklch(1 0 0 / 0.75), inset 0 0 0 1px oklch(0.2 0.02 80 / 0.06);
|
||||
|
||||
--tac-print: oklch(0.955 0.006 90);
|
||||
--tac-emulsion: oklch(0.8 0.008 85);
|
||||
--tac-emulsion-ink: oklch(0.38 0.008 85);
|
||||
--tac-film: oklch(0.16 0.006 80);
|
||||
|
||||
--tac-tab-pdf: oklch(0.56 0.2 27);
|
||||
--tac-tab-note: oklch(0.36 0.012 260);
|
||||
--tac-carbon: oklch(0.44 0.095 258);
|
||||
--tac-carbon-gutter: oklch(0.38 0.09 258);
|
||||
--tac-carbon-fold: linear-gradient(225deg, oklch(0.3 0.07 258) 0%, oklch(0.48 0.09 258) 55%, oklch(0.58 0.09 258) 100%);
|
||||
--tac-carbon-edge: inset 0 1px 0 oklch(1 0 0 / 0.3), inset 0 0 0 1px oklch(1 0 0 / 0.08);
|
||||
--tac-kraft: oklch(0.68 0.075 72);
|
||||
--tac-kraft-lid: oklch(0.6 0.078 68);
|
||||
--tac-card: oklch(0.9 0.032 236);
|
||||
--tac-card-bar: oklch(0.975 0.006 236);
|
||||
--tac-stamp: oklch(0.4 0.075 262 / 0.85);
|
||||
|
||||
--tac-flag-key: oklch(1 0 0 / 0.62);
|
||||
|
||||
--tac-ground: oklch(0.268 0.005 85);
|
||||
--tac-tray-hover: oklch(0.305 0.006 85);
|
||||
--tac-tray-press: oklch(0.245 0.005 85);
|
||||
--tac-ledge: oklch(0.335 0.006 85);
|
||||
--tac-ledge-edge: inset 0 1px 0 oklch(1 0 0 / 0.16), inset 0 -1px 0 oklch(0 0 0 / 0.3), 0 -1px 0 oklch(0 0 0 / 0.55), 0 -3px 3px -1px oklch(0 0 0 / 0.5);
|
||||
--tac-ledge-under: 0 2px 3px -1px oklch(0 0 0 / 0.5);
|
||||
--tac-board-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)' opacity='0.15'/%3E%3C/svg%3E");
|
||||
--tac-ground-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)' opacity='0.07'/%3E%3C/svg%3E");
|
||||
--tac-tray-rim: inset 0 2px 4px oklch(0 0 0 / 0.5), inset 0 0 0 1px oklch(0 0 0 / 0.3), inset 0 -1px 0 oklch(1 0 0 / 0.035), 0 1px 0 oklch(1 0 0 / 0.08);
|
||||
--tac-meta: oklch(0.74 0.006 85);
|
||||
|
||||
/* Graphite, a few steps off the canvas, so the bright paper stays the hero. */
|
||||
--tac-folder: oklch(0.335 0.006 265);
|
||||
--tac-folder-back: oklch(0.275 0.006 265);
|
||||
--tac-folder-ink: oklch(0.96 0.004 90);
|
||||
--tac-folder-meta: oklch(0.76 0.006 265);
|
||||
--tac-folder-edge: inset 0 1px 0 oklch(1 0 0 / 0.12), inset 0 0 0 1px oklch(1 0 0 / 0.04);
|
||||
|
||||
--tac-shadow-contact: oklch(0 0 0 / 0.55);
|
||||
--tac-shadow-ambient: oklch(0 0 0 / 0.42);
|
||||
--tac-shadow-hairline: oklch(0 0 0 / 0);
|
||||
}
|
||||