From def4726bea0e3b340457100038d80ca55c2b7d4f Mon Sep 17 00:00:00 2001 From: Tonio Date: Thu, 22 Oct 2026 10:38:12 -0700 Subject: [PATCH] feat(ui): add the artifact directions lab with the MVP V1 kit (#17213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .claude/launch.json | 18 ++ ui/artifact-directions.html | 38 +++ ui/src/artifact-directions/api-stub.test.ts | 45 +++ ui/src/artifact-directions/api-stub.ts | 71 ++++ .../assets/acmehats-logo-lockup.svg | 13 + .../artifact-directions/assets/avatar-ceo.svg | 3 + .../assets/avatar-designer.svg | 3 + .../assets/cap-reel-poster.svg | 15 + .../artifact-directions/assets/cap-reel.webm | Bin 0 -> 3429 bytes .../assets/cap-shot-black.svg | 25 ++ .../assets/cap-shot-cobalt.svg | 14 + .../assets/cap-shot-forest.svg | 14 + .../assets/contact-sheet-first-draft.svg | 56 ++++ .../assets/contact-sheet-interface-craft.svg | 65 ++++ .../assets/contact-sheet-revised.svg | 65 ++++ .../assets/storefront-preview.svg | 15 + .../directions/d1-solid/Counts.tsx | 65 ++++ .../directions/d1-solid/ExcerptSheet.tsx | 204 ++++++++++++ .../directions/d1-solid/Gallery.tsx | 114 +++++++ .../directions/d1-solid/GalleryPreview.tsx | 98 ++++++ .../directions/d1-solid/GalleryTile.tsx | 85 +++++ .../directions/d1-solid/KindBlock.tsx | 166 ++++++++++ .../directions/d1-solid/MetaLine.tsx | 70 ++++ .../directions/d1-solid/PanelBody.tsx | 77 +++++ .../directions/d1-solid/PanelCard.tsx | 135 ++++++++ .../directions/d1-solid/PanelLead.tsx | 59 ++++ .../directions/d1-solid/PanelMenu.tsx | 47 +++ .../directions/d1-solid/StackTile.tsx | 129 ++++++++ .../directions/d1-solid/StateMarker.tsx | 75 +++++ .../directions/d1-solid/ThreadAttachments.tsx | 102 ++++++ .../directions/d1-solid/ThreadWorkProduct.tsx | 100 ++++++ .../directions/d1-solid/actions.ts | 42 +++ .../directions/d1-solid/gallery-model.ts | 107 +++++++ .../directions/d1-solid/index.tsx | 142 ++++++++ .../directions/d1-solid/kinds.ts | 112 +++++++ .../directions/d1-solid/panel-actions.ts | 54 ++++ .../directions/d1-solid/panel-model.ts | 63 ++++ .../directions/d1-solid/slab.ts | 73 +++++ .../directions/d1-solid/tokens.css | 111 +++++++ .../directions/d2-tint/Content.tsx | 138 ++++++++ .../directions/d2-tint/FileName.tsx | 19 ++ .../directions/d2-tint/GalleryPreview.tsx | 125 ++++++++ .../directions/d2-tint/GalleryStack.tsx | 66 ++++ .../directions/d2-tint/GalleryStacks.tsx | 22 ++ .../directions/d2-tint/GalleryTile.tsx | 70 ++++ .../directions/d2-tint/KindGlyph.tsx | 127 ++++++++ .../directions/d2-tint/KindTally.tsx | 31 ++ .../directions/d2-tint/Lead.tsx | 146 +++++++++ .../directions/d2-tint/MetaLine.tsx | 29 ++ .../directions/d2-tint/PanelBody.tsx | 41 +++ .../directions/d2-tint/PanelCard.tsx | 120 +++++++ .../directions/d2-tint/PanelIndex.tsx | 99 ++++++ .../directions/d2-tint/PanelLead.tsx | 79 +++++ .../directions/d2-tint/PanelSection.tsx | 42 +++ .../directions/d2-tint/Pictogram.tsx | 98 ++++++ .../directions/d2-tint/StackFace.tsx | 44 +++ .../directions/d2-tint/StackFolder.tsx | 80 +++++ .../directions/d2-tint/StackSingle.tsx | 54 ++++ .../directions/d2-tint/StateMark.tsx | 78 +++++ .../directions/d2-tint/ThreadAttachments.tsx | 108 +++++++ .../directions/d2-tint/ThreadWorkProduct.tsx | 80 +++++ .../directions/d2-tint/UnitAction.tsx | 152 +++++++++ .../directions/d2-tint/actions.ts | 97 ++++++ .../directions/d2-tint/excerpt.ts | 92 ++++++ .../directions/d2-tint/gallery-model.ts | 125 ++++++++ .../directions/d2-tint/index.tsx | 167 ++++++++++ .../directions/d2-tint/kinds.ts | 139 ++++++++ .../directions/d2-tint/panel-model.ts | 92 ++++++ .../directions/d2-tint/specimen.html | 25 ++ .../directions/d2-tint/specimen.tsx | 139 ++++++++ .../directions/d2-tint/tokens.css | 152 +++++++++ .../directions/d3-tactile/GalleryItem.tsx | 116 +++++++ .../directions/d3-tactile/GalleryStack.tsx | 86 +++++ .../directions/d3-tactile/GalleryStacks.tsx | 178 ++++++++++ .../directions/d3-tactile/KindTally.tsx | 29 ++ .../directions/d3-tactile/MetaLine.tsx | 30 ++ .../directions/d3-tactile/PanelActions.tsx | 88 +++++ .../directions/d3-tactile/PanelBody.tsx | 87 +++++ .../directions/d3-tactile/PanelDivider.tsx | 51 +++ .../directions/d3-tactile/PanelItem.tsx | 143 +++++++++ .../directions/d3-tactile/StateFlag.tsx | 55 ++++ .../d3-tactile/ThreadAttachments.tsx | 154 +++++++++ .../d3-tactile/ThreadWorkProduct.tsx | 97 ++++++ .../directions/d3-tactile/gallery-model.ts | 117 +++++++ .../directions/d3-tactile/index.tsx | 170 ++++++++++ .../directions/d3-tactile/kind.ts | 230 +++++++++++++ .../directions/d3-tactile/ledge.ts | 29 ++ .../d3-tactile/objects/ArtifactObject.tsx | 109 +++++++ .../directions/d3-tactile/objects/Folder.tsx | 100 ++++++ .../directions/d3-tactile/objects/Formats.tsx | 95 ++++++ .../directions/d3-tactile/objects/Paper.tsx | 175 ++++++++++ .../directions/d3-tactile/objects/Print.tsx | 98 ++++++ .../directions/d3-tactile/objects/Sheets.tsx | 118 +++++++ .../directions/d3-tactile/objects/Things.tsx | 116 +++++++ .../directions/d3-tactile/panel-order.ts | 57 ++++ .../directions/d3-tactile/tokens-folder.css | 120 +++++++ .../directions/d3-tactile/tokens-gallery.css | 190 +++++++++++ .../directions/d3-tactile/tokens-panel.css | 152 +++++++++ .../directions/d3-tactile/tokens-sizes.css | 90 ++++++ .../directions/d3-tactile/tokens.css | 233 ++++++++++++++ .../directions/d4-hybrid/Character.tsx | 39 +++ .../directions/d4-hybrid/Content.tsx | 138 ++++++++ .../directions/d4-hybrid/CornerLabel.tsx | 44 +++ .../directions/d4-hybrid/FileName.tsx | 19 ++ .../directions/d4-hybrid/GalleryPreview.tsx | 116 +++++++ .../directions/d4-hybrid/GalleryStack.tsx | 66 ++++ .../directions/d4-hybrid/GalleryStacks.tsx | 22 ++ .../directions/d4-hybrid/GalleryTile.tsx | 71 ++++ .../directions/d4-hybrid/KindGlyph.tsx | 127 ++++++++ .../directions/d4-hybrid/KindTally.tsx | 31 ++ .../directions/d4-hybrid/Lead.tsx | 114 +++++++ .../directions/d4-hybrid/MetaLine.tsx | 29 ++ .../directions/d4-hybrid/PanelBody.tsx | 42 +++ .../directions/d4-hybrid/PanelCard.tsx | 121 +++++++ .../directions/d4-hybrid/PanelIndex.tsx | 99 ++++++ .../directions/d4-hybrid/PanelLead.tsx | 151 +++++++++ .../directions/d4-hybrid/PanelSection.tsx | 42 +++ .../directions/d4-hybrid/PaperSheet.tsx | 162 ++++++++++ .../directions/d4-hybrid/Pictogram.tsx | 98 ++++++ .../directions/d4-hybrid/StackFace.tsx | 46 +++ .../directions/d4-hybrid/StackFolder.tsx | 87 +++++ .../directions/d4-hybrid/StackSingle.tsx | 73 +++++ .../directions/d4-hybrid/StateMark.tsx | 78 +++++ .../d4-hybrid/ThreadAttachments.tsx | 112 +++++++ .../d4-hybrid/ThreadWorkProduct.tsx | 84 +++++ .../directions/d4-hybrid/UnitAction.tsx | 157 +++++++++ .../directions/d4-hybrid/actions.ts | 97 ++++++ .../directions/d4-hybrid/excerpt.ts | 92 ++++++ .../directions/d4-hybrid/gallery-model.ts | 125 ++++++++ .../directions/d4-hybrid/index.tsx | 123 +++++++ .../directions/d4-hybrid/kinds.ts | 140 ++++++++ .../directions/d4-hybrid/panel-model.ts | 92 ++++++ .../directions/d4-hybrid/paper-text.ts | 71 ++++ .../directions/d4-hybrid/tokens.css | 202 ++++++++++++ .../directions/d5-v1-mvp/Front.tsx | 36 +++ .../directions/d5-v1-mvp/GalleryView.tsx | 123 +++++++ .../directions/d5-v1-mvp/MediaPrint.test.ts | 23 ++ .../directions/d5-v1-mvp/MediaPrint.tsx | 57 ++++ .../directions/d5-v1-mvp/PanelBody.tsx | 21 ++ .../directions/d5-v1-mvp/PanelRow.tsx | 119 +++++++ .../directions/d5-v1-mvp/Sheet.tsx | 128 ++++++++ .../directions/d5-v1-mvp/Stack.tsx | 121 +++++++ .../directions/d5-v1-mvp/Thread.test.tsx | 59 ++++ .../directions/d5-v1-mvp/Thread.tsx | 32 ++ .../directions/d5-v1-mvp/Tile.tsx | 80 +++++ .../directions/d5-v1-mvp/backing-short.svg | 6 + .../directions/d5-v1-mvp/backing-tall-a.svg | 6 + .../directions/d5-v1-mvp/backing-tall-b.svg | 6 + .../directions/d5-v1-mvp/index.tsx | 49 +++ .../directions/d5-v1-mvp/kinds.test.ts | 67 ++++ .../directions/d5-v1-mvp/kinds.ts | 97 ++++++ .../directions/d5-v1-mvp/panel-doc-corner.svg | 3 + .../directions/d5-v1-mvp/panel-doc.svg | 3 + .../d5-v1-mvp/panel-glyph-audio.svg | 7 + .../d5-v1-mvp/panel-glyph-slides.svg | 7 + .../directions/d5-v1-mvp/panel-play.svg | 5 + .../directions/d5-v1-mvp/sheet-preview.svg | 3 + .../directions/d5-v1-mvp/test-artifact.ts | 16 + .../directions/d5-v1-mvp/tokens.css | 131 ++++++++ .../directions/d5-v1-mvp/when.test.ts | 28 ++ .../directions/d5-v1-mvp/when.ts | 31 ++ .../directions/today/Gallery.tsx | 34 ++ .../directions/today/PanelBody.tsx | 20 ++ .../directions/today/ThreadAttachments.tsx | 96 ++++++ .../directions/today/ThreadWorkProduct.tsx | 31 ++ .../directions/today/adapters.ts | 204 ++++++++++++ .../directions/today/index.tsx | 17 + .../directions/today/panel-api.ts | 90 ++++++ .../artifact-directions/fixture-documents.ts | 254 +++++++++++++++ ui/src/artifact-directions/fixtures.ts | 303 ++++++++++++++++++ .../artifact-directions/frames/AppShell.tsx | 181 +++++++++++ ui/src/artifact-directions/frames/Board.tsx | 132 ++++++++ .../frames/GalleryFrame.tsx | 118 +++++++ .../artifact-directions/frames/KindsSheet.tsx | 102 ++++++ .../artifact-directions/frames/LabToolbar.tsx | 104 ++++++ .../artifact-directions/frames/PanelFrame.tsx | 71 ++++ ui/src/artifact-directions/frames/Slot.tsx | 14 + .../frames/SurfaceView.tsx | 31 ++ .../frames/ThreadFrame.tsx | 176 ++++++++++ .../frames/thread-parts.tsx | 117 +++++++ ui/src/artifact-directions/kit.ts | 176 ++++++++++ ui/src/artifact-directions/lab-state.test.ts | 54 ++++ ui/src/artifact-directions/lab-state.ts | 57 ++++ ui/src/artifact-directions/lab.css | 10 + ui/src/artifact-directions/main.tsx | 198 ++++++++++++ ui/src/artifact-directions/shot.cjs | 263 +++++++++++++++ .../artifact-directions-compare.stories.tsx | 79 +++++ .../artifact-directions-hybrid.stories.tsx | 162 ++++++++++ ui/vite.artifact-directions.config.mjs | 129 ++++++++ 189 files changed, 16476 insertions(+) create mode 100644 ui/artifact-directions.html create mode 100644 ui/src/artifact-directions/api-stub.test.ts create mode 100644 ui/src/artifact-directions/api-stub.ts create mode 100644 ui/src/artifact-directions/assets/acmehats-logo-lockup.svg create mode 100644 ui/src/artifact-directions/assets/avatar-ceo.svg create mode 100644 ui/src/artifact-directions/assets/avatar-designer.svg create mode 100644 ui/src/artifact-directions/assets/cap-reel-poster.svg create mode 100644 ui/src/artifact-directions/assets/cap-reel.webm create mode 100644 ui/src/artifact-directions/assets/cap-shot-black.svg create mode 100644 ui/src/artifact-directions/assets/cap-shot-cobalt.svg create mode 100644 ui/src/artifact-directions/assets/cap-shot-forest.svg create mode 100644 ui/src/artifact-directions/assets/contact-sheet-first-draft.svg create mode 100644 ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg create mode 100644 ui/src/artifact-directions/assets/contact-sheet-revised.svg create mode 100644 ui/src/artifact-directions/assets/storefront-preview.svg create mode 100644 ui/src/artifact-directions/directions/d1-solid/Counts.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/Gallery.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/StackTile.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/actions.ts create mode 100644 ui/src/artifact-directions/directions/d1-solid/gallery-model.ts create mode 100644 ui/src/artifact-directions/directions/d1-solid/index.tsx create mode 100644 ui/src/artifact-directions/directions/d1-solid/kinds.ts create mode 100644 ui/src/artifact-directions/directions/d1-solid/panel-actions.ts create mode 100644 ui/src/artifact-directions/directions/d1-solid/panel-model.ts create mode 100644 ui/src/artifact-directions/directions/d1-solid/slab.ts create mode 100644 ui/src/artifact-directions/directions/d1-solid/tokens.css create mode 100644 ui/src/artifact-directions/directions/d2-tint/Content.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/FileName.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/KindTally.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/Lead.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/PanelSection.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/Pictogram.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/StackFace.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/StackFolder.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/StackSingle.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/StateMark.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/ThreadAttachments.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/ThreadWorkProduct.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/UnitAction.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/actions.ts create mode 100644 ui/src/artifact-directions/directions/d2-tint/excerpt.ts create mode 100644 ui/src/artifact-directions/directions/d2-tint/gallery-model.ts create mode 100644 ui/src/artifact-directions/directions/d2-tint/index.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/kinds.ts create mode 100644 ui/src/artifact-directions/directions/d2-tint/panel-model.ts create mode 100644 ui/src/artifact-directions/directions/d2-tint/specimen.html create mode 100644 ui/src/artifact-directions/directions/d2-tint/specimen.tsx create mode 100644 ui/src/artifact-directions/directions/d2-tint/tokens.css create mode 100644 ui/src/artifact-directions/directions/d3-tactile/GalleryItem.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/GalleryStack.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/GalleryStacks.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/KindTally.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/MetaLine.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/PanelActions.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/PanelBody.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/PanelDivider.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/PanelItem.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/StateFlag.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/ThreadAttachments.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/ThreadWorkProduct.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/gallery-model.ts create mode 100644 ui/src/artifact-directions/directions/d3-tactile/index.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/kind.ts create mode 100644 ui/src/artifact-directions/directions/d3-tactile/ledge.ts create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/ArtifactObject.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/Folder.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/Formats.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/Paper.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/Print.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/Sheets.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/objects/Things.tsx create mode 100644 ui/src/artifact-directions/directions/d3-tactile/panel-order.ts create mode 100644 ui/src/artifact-directions/directions/d3-tactile/tokens-folder.css create mode 100644 ui/src/artifact-directions/directions/d3-tactile/tokens-gallery.css create mode 100644 ui/src/artifact-directions/directions/d3-tactile/tokens-panel.css create mode 100644 ui/src/artifact-directions/directions/d3-tactile/tokens-sizes.css create mode 100644 ui/src/artifact-directions/directions/d3-tactile/tokens.css create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/Character.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/Content.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/CornerLabel.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/FileName.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/GalleryPreview.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/GalleryStack.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/GalleryStacks.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/GalleryTile.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/KindGlyph.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/KindTally.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/Lead.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/MetaLine.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/PanelBody.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/PanelCard.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/PanelIndex.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/PanelLead.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/PanelSection.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/PaperSheet.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/Pictogram.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/StackFace.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/StackFolder.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/StackSingle.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/StateMark.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/ThreadAttachments.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/ThreadWorkProduct.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/UnitAction.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/actions.ts create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/excerpt.ts create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/gallery-model.ts create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/index.tsx create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/kinds.ts create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/panel-model.ts create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/paper-text.ts create mode 100644 ui/src/artifact-directions/directions/d4-hybrid/tokens.css create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/Front.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/GalleryView.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/MediaPrint.test.ts create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/MediaPrint.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/PanelBody.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/PanelRow.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/Sheet.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/Stack.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/Thread.test.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/Thread.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/Tile.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/backing-short.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/backing-tall-a.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/backing-tall-b.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/index.tsx create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/kinds.test.ts create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/kinds.ts create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/panel-doc-corner.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/panel-doc.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/panel-glyph-audio.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/panel-glyph-slides.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/panel-play.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/sheet-preview.svg create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/test-artifact.ts create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/tokens.css create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/when.test.ts create mode 100644 ui/src/artifact-directions/directions/d5-v1-mvp/when.ts create mode 100644 ui/src/artifact-directions/directions/today/Gallery.tsx create mode 100644 ui/src/artifact-directions/directions/today/PanelBody.tsx create mode 100644 ui/src/artifact-directions/directions/today/ThreadAttachments.tsx create mode 100644 ui/src/artifact-directions/directions/today/ThreadWorkProduct.tsx create mode 100644 ui/src/artifact-directions/directions/today/adapters.ts create mode 100644 ui/src/artifact-directions/directions/today/index.tsx create mode 100644 ui/src/artifact-directions/directions/today/panel-api.ts create mode 100644 ui/src/artifact-directions/fixture-documents.ts create mode 100644 ui/src/artifact-directions/fixtures.ts create mode 100644 ui/src/artifact-directions/frames/AppShell.tsx create mode 100644 ui/src/artifact-directions/frames/Board.tsx create mode 100644 ui/src/artifact-directions/frames/GalleryFrame.tsx create mode 100644 ui/src/artifact-directions/frames/KindsSheet.tsx create mode 100644 ui/src/artifact-directions/frames/LabToolbar.tsx create mode 100644 ui/src/artifact-directions/frames/PanelFrame.tsx create mode 100644 ui/src/artifact-directions/frames/Slot.tsx create mode 100644 ui/src/artifact-directions/frames/SurfaceView.tsx create mode 100644 ui/src/artifact-directions/frames/ThreadFrame.tsx create mode 100644 ui/src/artifact-directions/frames/thread-parts.tsx create mode 100644 ui/src/artifact-directions/kit.ts create mode 100644 ui/src/artifact-directions/lab-state.test.ts create mode 100644 ui/src/artifact-directions/lab-state.ts create mode 100644 ui/src/artifact-directions/lab.css create mode 100644 ui/src/artifact-directions/main.tsx create mode 100644 ui/src/artifact-directions/shot.cjs create mode 100644 ui/storybook/stories/design-proposals/artifact-directions-compare.stories.tsx create mode 100644 ui/storybook/stories/design-proposals/artifact-directions-hybrid.stories.tsx create mode 100644 ui/vite.artifact-directions.config.mjs diff --git a/.claude/launch.json b/.claude/launch.json index 219f5be924d..416679e1caa 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -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 } ] } diff --git a/ui/artifact-directions.html b/ui/artifact-directions.html new file mode 100644 index 00000000000..ef159c5bad3 --- /dev/null +++ b/ui/artifact-directions.html @@ -0,0 +1,38 @@ + + + + + + + Artifact directions — lab + + + + +
+ + + diff --git a/ui/src/artifact-directions/api-stub.test.ts b/ui/src/artifact-directions/api-stub.test.ts new file mode 100644 index 00000000000..c909a3a2988 --- /dev/null +++ b/ui/src/artifact-directions/api-stub.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it } from "vitest"; +import { installApiStub, registerApiRoute } from "./api-stub"; +import { COMPANY } from "./fixtures"; + +describe("installApiStub", () => { + const original = window.fetch; + const restores: Array<() => void> = []; + + afterEach(() => { + while (restores.length) restores.pop()?.(); + window.fetch = original; + }); + + it("answers /api/ from the fixtures and puts fetch back when restored", async () => { + restores.push(installApiStub()); + expect(window.fetch).not.toBe(original); + const companies = await (await window.fetch("/api/companies")).json(); + expect(companies).toEqual([COMPANY]); + restores.pop()?.(); + expect(window.fetch).toBe(original); + }); + + it("serves a registered route and answers anything else under /api/ with an empty object", async () => { + registerApiRoute((pathname) => (pathname === "/api/lab/ping" ? { pong: true } : undefined)); + restores.push(installApiStub()); + expect(await (await window.fetch("/api/lab/ping")).json()).toEqual({ pong: true }); + expect(await (await window.fetch("/api/lab/nothing", { method: "POST" })).json()).toEqual({}); + }); + + it("keeps one stub across nested installs and restores on the last release", () => { + const first = installApiStub(); + const stub = window.fetch; + const second = installApiStub(); + restores.push(first, second); + expect(window.fetch).toBe(stub); + first(); + expect(window.fetch).toBe(stub); + first(); // a second call of the same restore is a no-op + expect(window.fetch).toBe(stub); + second(); + expect(window.fetch).toBe(original); + }); +}); diff --git a/ui/src/artifact-directions/api-stub.ts b/ui/src/artifact-directions/api-stub.ts new file mode 100644 index 00000000000..143582d7de8 --- /dev/null +++ b/ui/src/artifact-directions/api-stub.ts @@ -0,0 +1,71 @@ +import { COMPANY } from "./fixtures"; + +/** + * The lab's only "server". Every API call in the app goes through `fetch`, so + * standing in for `/api/` here is enough to render real data-fetching + * components with no backend and no module mocks. Nothing under `/api/` ever + * reaches the network; everything else (fonts, assets) passes through. + * + * A direction that renders a real component registers the routes it needs. + * `installApiStub` returns a restore, so a story can scope the stub to itself. + */ + +/** Returns the JSON body for a path it owns, or `undefined` to pass. */ +export type ApiRoute = (pathname: string, method: string) => unknown | undefined; + +const routes: ApiRoute[] = []; + +export function registerApiRoute(route: ApiRoute): void { + routes.push(route); +} + +const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + +function respond(pathname: string, method: string): Response { + for (const route of routes) { + const body = route(pathname, method); + if (body !== undefined) return json(body); + } + if (pathname.endsWith("/auth/get-session")) { + return json({ session: { id: "lab-session", userId: "lab-user" }, user: { id: "lab-user", name: "Lab", email: "lab@acmehats.example" } }); + } + if (pathname.endsWith("/companies")) return json([COMPANY]); + // An unhandled call answers empty rather than failing, so it is never the + // reason a frame looks broken; the debug line says what was asked for. + console.debug("[artifact-directions] unhandled", method, pathname); + return json({}); +} + +let holders = 0; +let realFetch: typeof window.fetch | null = null; +let stubFetch: typeof window.fetch | null = null; + +/** + * Stands in for `/api/` until the returned function is called. The lab page + * installs once for its lifetime; a Storybook story installs on mount and + * restores on unmount. Nested installs share one stub, and the last restore + * puts the real `fetch` back — unless something else replaced it meanwhile. + */ +export function installApiStub(): () => void { + if (holders === 0) { + const original = window.fetch; + const real = original.bind(window); + realFetch = original; + stubFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (!url.includes("/api/")) return real(input as RequestInfo, init); + const { pathname } = new URL(url, window.location.origin); + return respond(pathname, (init?.method ?? "GET").toUpperCase()); + }) as typeof window.fetch; + window.fetch = stubFetch; + } + holders += 1; + let released = false; + return () => { + if (released) return; + released = true; + holders -= 1; + if (holders === 0 && realFetch && window.fetch === stubFetch) window.fetch = realFetch; + }; +} diff --git a/ui/src/artifact-directions/assets/acmehats-logo-lockup.svg b/ui/src/artifact-directions/assets/acmehats-logo-lockup.svg new file mode 100644 index 00000000000..516c93529bf --- /dev/null +++ b/ui/src/artifact-directions/assets/acmehats-logo-lockup.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + acmehats + CUSTOM CAPS · EST. 2026 \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/avatar-ceo.svg b/ui/src/artifact-directions/assets/avatar-ceo.svg new file mode 100644 index 00000000000..36fc3cc1ca6 --- /dev/null +++ b/ui/src/artifact-directions/assets/avatar-ceo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/avatar-designer.svg b/ui/src/artifact-directions/assets/avatar-designer.svg new file mode 100644 index 00000000000..4309569f34d --- /dev/null +++ b/ui/src/artifact-directions/assets/avatar-designer.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/cap-reel-poster.svg b/ui/src/artifact-directions/assets/cap-reel-poster.svg new file mode 100644 index 00000000000..25c4373d142 --- /dev/null +++ b/ui/src/artifact-directions/assets/cap-reel-poster.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + ACMEHATS — SIGNAL ORANGE, 360° \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/cap-reel.webm b/ui/src/artifact-directions/assets/cap-reel.webm new file mode 100644 index 0000000000000000000000000000000000000000..92335775b9b6b5486f757033e7c6aa49cf3d81af GIT binary patch literal 3429 zcmcJSc~DbV6vl4?WPyYbsFf;~pjg*R07b2~O(Lif0!n}uTmY%07LZ6-)T+r%kg&Lc zQW!-IM8pLVP(;K93#lMgz^O`62e6K`)>;J=mlpa$UgX6P-e1iGX5Qq>?|kRG_nmh$ z#;aX@h?fEhyguZw8=CWaAcZGSPFk}%hNpx?-Zn_I7s^BgpncJ2F$MeM&kL$EgeQ_m z@sC9ci($U*>cfDNDCDYDP`w@b%r>A_BrKK_Ch0C6gw@E0a=POa9R~yt;{K0+T36*$ z@02MRJdQxN!zVm3(!*_rEAnQ$eZ@7r_DoCVREh^(XzgDsLEUlhK!QY*8n$)j^Ea;> zOY$HXUy-LsSocjT1S#8sLW7phRY1ao-xSH_eYjDdSkY@& zCyF4HGb-3bAZ$@vN% znD@--to^0;Be zi>l&TO(_cl)&T zd-9gkPlKw`$%Vq29mfMptK&->1)AiWzm)gGKcws3R@AzG;{9kNeQ|nso%^&~hx+Eg zyo{hf1JZs~)$tk!%;S=Q^h&eb);l|FcBbE5ey^V(^+It9>8y~sF_=b=J~F<$+ee zMy&-;^rdXvo(IzcUANNc&9sfjOf+@c2EMR`F)2)NqOdND!ckW$+z?EFN!NOwN(%DEW|(Z%iR2 ze*A$Y8p;@*L#zNLa53>|Y2M7ZkT0`8sB+PzCWk^~?;M}N-JmH*DldG$#hUW8( zNtC3HLuCw>lC57C^IfP2yK)#z0W^PLj3Fnrg{WO)to0O&S*Qlno&|=Yf);7U6s)2E zTHG^sVpvBFNiN0|q@{xcbrCiky%~}M5xti+w$x!rx^0Z1O5cVHv)^w18C{72NS-o` zA%hL6g5)j6By252(TplgMnjn}GNS-el7@5Q+F$SB{jIL+!;o^#m_n5fto5Wvx;uC1 zv!RYdRhXL$d!rUW>Uv|2;ccjb)H+1-e!{W+6>5sUj^$`$3bu|4T1KG?Y%Z9QQ2;G3 z88gUYQFem44Z21uXvIV|*zJpm#^~*N9@oUKr@O$Idy!bz!K`DkgS_3Nm`|eAu9q zo@H`>*zCh){Dmq^=E2lXcMdY$F*Dn+7v^46VbTY)3y)!|o#!KFYP%Dc(TV57PCv>` z_F|WcGKSAXd*b76>XO^t)jswCo+A)|=SY9JFrBXcaDjcg8s<;%#q%+Ev=}j6{mH_# v58~nxlR@&(A1Fp(poa<5JJyUhVlUbsI+@kGPD4 + + + + + + + + + + + + + + + + + + + + + + + + Your place. Your cap. \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/cap-shot-cobalt.svg b/ui/src/artifact-directions/assets/cap-shot-cobalt.svg new file mode 100644 index 00000000000..fc0464add97 --- /dev/null +++ b/ui/src/artifact-directions/assets/cap-shot-cobalt.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + THE SHARED MARK \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/cap-shot-forest.svg b/ui/src/artifact-directions/assets/cap-shot-forest.svg new file mode 100644 index 00000000000..f9a58eed085 --- /dev/null +++ b/ui/src/artifact-directions/assets/cap-shot-forest.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + a \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/contact-sheet-first-draft.svg b/ui/src/artifact-directions/assets/contact-sheet-first-draft.svg new file mode 100644 index 00000000000..8c4cc0b97a3 --- /dev/null +++ b/ui/src/artifact-directions/assets/contact-sheet-first-draft.svg @@ -0,0 +1,56 @@ +Your place.Your people.Your cap. + a + +Build an identity before making promises. + OpeningHypothesisStill open + +Specificity is the creative opening. + CustomersCategoryEvidenceAdvantage + +Start with the people behind the counter. + Independent cafés& hospitality + Ownership prideCorporate teamsClubs and makers +Make the place recognizable. + OpeningRecognitionPracticalityBelonging +Belonging is the ambition. Proof comes next. + For independent places,acmehats imagines custom capsthat carry the character of theplace and its people. + +Start with the character of a place. + The name. The people. The familiar details.A cap can become a small expressionof that shared identity. + +The Local Signature + acmehats + Familiar. Assured.Observant. + +Let the customer's name lead. + + + + + + + + + + + Your place.Your people.Your cap. +The Shared Mark + acmehats + Warm. Bold.Participatory. + +A shared identity with more energy. + + + + + + + + + + + + Your place.Your people.Your cap. +Choose The Local Signature to develop. + A / Recommended + B / Alternative \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg b/ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg new file mode 100644 index 00000000000..02612696198 --- /dev/null +++ b/ui/src/artifact-directions/assets/contact-sheet-interface-craft.svg @@ -0,0 +1,65 @@ +Your place.Your people.Your cap. + + + + + + + + + + + +Build an identity before making promises. + OpeningHypothesisStill open + +Specificity is the creative opening. + CustomersCategoryEvidenceAdvantage + +Start with the people behind the counter. + Independent cafés& hospitality + Ownership prideCorporate teamsClubs and makers +Make the place recognizable. + OpeningRecognitionPracticalityBelonging +Belonging is the ambition. Proof comes next. + For independent places,acmehats imagines custom capsthat carry the character of theplace and its people. + +Start with the character of a place. + The name. The people. The familiar details.A cap can become a small expressionof that shared identity. + +The Local Signature + acme hats + Familiar. Assured.Observant. + +Let the customer's name lead. + + + + + + + + + + + Your place.Your people.Your cap. +The Shared Mark + acmehats + Warm. Bold.Participatory. + +A shared identity with more energy. + + + + + + + + + + + + Your place.Your people.Your cap. +Choose The Local Signature to develop. + A / Recommended + B / Alternative \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/contact-sheet-revised.svg b/ui/src/artifact-directions/assets/contact-sheet-revised.svg new file mode 100644 index 00000000000..eae06750c9b --- /dev/null +++ b/ui/src/artifact-directions/assets/contact-sheet-revised.svg @@ -0,0 +1,65 @@ +Your place.Your people.Your cap. + + + + + + + + + + + +Build an identity before making promises. + OpeningHypothesisStill open + +Specificity is the creative opening. + CustomersCategoryEvidenceAdvantage + +Start with the people behind the counter. + Independent cafés& hospitality + Ownership prideCorporate teamsClubs and makers +Make the place recognizable. + OpeningRecognitionPracticalityBelonging +Belonging is the ambition. Proof comes next. + For independent places,acmehats imagines custom capsthat carry the character of theplace and its people. + +Start with the character of a place. + The name. The people. The familiar details.A cap can become a small expressionof that shared identity. + +The Local Signature + acmehats + Familiar. Assured.Observant. + +Let the customer's name lead. + + + + + + + + + + + Your place.Your people.Your cap. +The Shared Mark + acmehats + Warm. Bold.Participatory. + +A shared identity with more energy. + + + + + + + + + + + + Your place.Your people.Your cap. +Choose The Local Signature to develop. + A / Recommended + B / Alternative \ No newline at end of file diff --git a/ui/src/artifact-directions/assets/storefront-preview.svg b/ui/src/artifact-directions/assets/storefront-preview.svg new file mode 100644 index 00000000000..24da8345c5c --- /dev/null +++ b/ui/src/artifact-directions/assets/storefront-preview.svg @@ -0,0 +1,15 @@ +acmehats + + Caps that carryyour place. + Design yours + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/artifact-directions/directions/d1-solid/Counts.tsx b/ui/src/artifact-directions/directions/d1-solid/Counts.tsx new file mode 100644 index 00000000000..56911f04ca8 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/Counts.tsx @@ -0,0 +1,65 @@ +import { Fragment } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { MetaDot } from "./MetaLine"; +import { StateDot } from "./StateMarker"; + +/** + * The mono count label, one grammar on every surface: a total in foreground, + * then any breakdown, then what is waiting — each waiting count behind its + * state dot, which is its separator. The caller sets the mono recipe + * (META_TEXT) and the muted colour for the fill it sits on. + * + * thread 5 attachments · 1 image · 4 files ● 5 to review + * panel 21 artifacts ● 9 to review + * gallery 6 stacks · 43 artifacts ● 12 to review ● 1 change requested + * + * Every item is unbreakable and the label wraps between items, so in a narrow + * tile or pane it takes a second line instead of running out of its slab. + */ + +const reviewCount = (items: LabArtifact[]) => items.filter((a) => a.review === "needs_review").length; +const changesCount = (items: LabArtifact[]) => items.filter((a) => a.review === "changes_requested").length; + +export function Counts({ + lead, + parts = [], + of, + leadClassName = "text-foreground", +}: { + /** "21 artifacts" */ + lead: string; + /** Plain breakdown after the lead: "1 image", "4 files". */ + parts?: string[]; + /** The artifacts counted: what waits among them is appended behind its state dot. */ + of?: LabArtifact[]; + leadClassName?: string; +}) { + const review = of ? reviewCount(of) : 0; + const changes = of ? changesCount(of) : 0; + return ( + + + {lead} + {parts.map((part) => ( + + + {part} + + ))} + + {review > 0 ? ( + + + {review} to review + + ) : null} + {changes > 0 ? ( + + + {changes} {changes === 1 ? "change" : "changes"} requested + + ) : null} + + ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx b/ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx new file mode 100644 index 00000000000..27938a62f6d --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/ExcerptSheet.tsx @@ -0,0 +1,204 @@ +import { Fragment } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { KindTag } from "./KindBlock"; + +/** + * The preview for kinds whose preview is TEXT — task documents, Markdown, code + * and sheets — at panel and gallery scale: the artifact's own words set as + * real text on a SHEET, the lightest fill short of ink in both themes (paper + * on a mat), so content is the filled thing and never a recess. + * + * prose sans; the opening line and every heading in foreground medium, + * body in the sheet's text tone + * code the mono token, whitespace kept, a long line ends in an ellipsis + * sheet a table in the mono token, header row in foreground; whole columns + * only, as many as the sheet is wide enough for, the rest counted + * ("+2 cols") + * + * Three shapes: + * plate (default) a gallery preview: the kind tab in the bottom-left + * corner, prose at 13px, as many WHOLE lines as the plate's height + * has room for above the tab (--solid-sheet-fit) — at any column + * width, never a fixed count. + * cell a small cell of a stack mosaic: the same at 11px, 8px inset. + * drawer a panel card's preview: no tab (the card's kind block is right + * above it), height set by `lines`, 12px inset like the slab. + * + * The thread never uses this: at 72×44 a text kind is an ink plate. + * No fade at the bottom edge (no gradients in this direction). + */ + +const CLAMP = { 3: "line-clamp-3", 6: "line-clamp-6" } as const; + +/** Whether an artifact's preview is its text (a picture always wins). */ +export const hasExcerpt = (a: LabArtifact) => Boolean(a.excerpt) && !a.thumbnailUrl; + +const words = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +const paragraphs = (a: LabArtifact) => (a.body ?? a.excerpt ?? "").trim().split(/\n{2,}/); +const HEADING = /^#{1,6}\s+/; + +/** Markdown to the text a sheet sets: bullets kept as bullets, link and emphasis syntax dropped. */ +const inline = (markdown: string) => + markdown + .replace(/^\s*[-*]\s+/gm, "• ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[*_`]/g, ""); + +/** Whether the text opens with the artifact's own title, which the surface already prints beside the sheet. */ +const opensWithTitle = (a: LabArtifact) => words(paragraphs(a)[0].replace(HEADING, "")) === words(a.title); + +interface Run { + text: string; + strong: boolean; + /** What separates it from the run before: a new line, or a space within the same paragraph. */ + lead: "\n" | " "; +} + +/** + * The prose as runs. The repeated title is left out; headings are strong; and + * the sheet always opens on a strong line — a short opening paragraph whole, + * a long one by its first sentence. + */ +function runs(a: LabArtifact): Run[] { + const all = paragraphs(a); + const blocks = (opensWithTitle(a) && all.length > 1 ? all.slice(1) : all).slice(0, 10); + const out: Run[] = blocks.map((block) => ({ text: inline(block.replace(HEADING, "")), strong: HEADING.test(block), lead: "\n" })); + const first = out[0]; + if (!first || first.strong) return out; + const sentence = first.text.length > 90 ? /^(.+?[.!?])\s+([\s\S]+)$/.exec(first.text) : null; + if (!sentence) return [{ ...first, strong: true }, ...out.slice(1)]; + return [{ text: sentence[1], strong: true, lead: "\n" }, { text: sentence[2], strong: false, lead: " " }, ...out.slice(1)]; +} + +const SHEET_TEXT = "text-(--solid-sheet-text)"; +/** + * Code and tables: 11px on 16px in a drawer or a mosaic cell; a size up on a + * tile's plate, which is read from further away (as prose is) — from 16rem of + * sheet, so the large cell of a mosaic keeps the smaller size and its columns. + */ +const MONO = "font-mono text-(length:--text-micro) leading-4"; +const MONO_PLATE = "font-mono text-(length:--text-micro) leading-4 @3xs:text-xs @3xs:leading-5"; + +/** + * Columns are shown whole or not at all. How many is decided by the width of + * the sheet itself (its own container, inside its inset), so a table never + * runs out of its sheet: two, a third from 18rem, a fourth from 24rem, a fifth + * from 32rem. A mosaic cell shows the first column only. What is left out is + * counted in the header row. + */ +const COLUMN = ["", "", "hidden @2xs:table-cell", "hidden @sm:table-cell", "hidden @lg:table-cell"]; +const SHOWN = [ + { columns: 2, when: "@2xs:hidden" }, + { columns: 3, when: "hidden @2xs:table-cell @sm:hidden" }, + { columns: 4, when: "hidden @sm:table-cell @lg:hidden" }, + { columns: 5, when: "hidden @lg:table-cell" }, +]; + +function Table({ csv, rows, single, mono, className }: { csv: string; rows: number; single: boolean; mono: string; className?: string }) { + const [head, ...body] = csv + .trim() + .split("\n") + .slice(0, rows) + .map((line) => line.split(",").slice(0, single ? 1 : COLUMN.length)); + const total = csv.trim().split("\n")[0].split(",").length; + const notes = single ? [{ columns: 1, when: "" }] : SHOWN; + return ( +
+ + + + {head.map((cell, index) => ( + + ))} + {notes.map((note) => + total > note.columns ? ( + + ) : null, + )} + + + + {body.map((row, index) => ( + + {row.map((cell, column) => ( + + ))} + + ))} + +
+ {cell} + + +{total - note.columns} + {total - note.columns === 1 ? "col" : "cols"} +
+ {cell} +
+
+ ); +} + +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 ( +
+ {artifact.kind === "sheet" ? ( + + ) : artifact.kind === "code" ? ( +
+ {text + .split("\n") + .slice(0, rows) + .map((line, index) => ( + + {line || " "} + + ))} +
+ ) : ( + // The size comes first: tailwind-merge drops a `leading-*` that precedes a font size. +
+ {runs(artifact).map((run, index) => ( + + {index > 0 ? run.lead : null} + {run.strong ? {run.text} : run.text} + + ))} +
+ )} + {drawer ? null : } + + ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/Gallery.tsx b/ui/src/artifact-directions/directions/d1-solid/Gallery.tsx new file mode 100644 index 00000000000..8408ab99677 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/Gallery.tsx @@ -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

No artifact stacks yet.

; + const all = groups.flatMap((group) => group.artifacts); + const total = groups.reduce((sum, group) => sum + group.count, 0); + return ( +
+

+ +

+ {/* Each tile keeps 28px above its face for the pile; the 24px row gap leaves a risen pile clear of the row above. */} +
    + {groups.map((group) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function Day({ id, section }: { id: string; section: DaySection }) { + return ( +
+
+

+ {section.label} +

+

+ +

+
+
    + {section.items.map((artifact) => ( +
  • + +
  • + ))} +
+
+ ); +} + +/** + * The lab's one switch. Sectioning an opened stack by day is a change of + * structure riding on a visual direction; `?days=off` lists the stack as the + * product does today — one flat grid in the page's order — so the tiles can be + * judged like for like. + */ +const flat = () => typeof window !== "undefined" && new URLSearchParams(window.location.search).get("days") === "off"; + +export function GalleryStack({ group }: { group: LabGroup }) { + if (group.artifacts.length === 0) { + return

No artifacts in this stack match the current filters.

; + } + return ( +
+

+ +

+ {flat() ? ( +
    + {group.artifacts.map((artifact) => ( +
  • + +
  • + ))} +
+ ) : ( +
+ {daySections(group.artifacts).map((section) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx b/ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx new file mode 100644 index 00000000000..64d9fddd77b --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/GalleryPreview.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { Play } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { ExcerptSheet } from "./ExcerptSheet"; +import { previewKind } from "./gallery-model"; +import { extent, fileName, metaLabel } from "./kinds"; +import { CornerTag, KindDock, KindTag, PlatePicture } from "./KindBlock"; +import { META_TEXT } from "./MetaLine"; +import { StateMarker } from "./StateMarker"; + +/** + * GALLERY — the preview of one artifact. Every kind gets a preview that says + * something, and what fills it is the artifact's CONTENT, never its extension: + * + * picture the picture, full-bleed ink kind tab bottom-left + * text its own words on a sheet (ExcerptSheet) ink kind tab bottom-left + * facts a sheet that says what is known of a file the kind block docked bottom-left: + * with nothing to show: how much of it there the tab grown to a block, because + * is, at display size, and its filename here the kind is most of the story + * + * So ink is always the mark in the bottom-left corner and never the whole + * plate; a row of previews keeps one baseline of ink, and three decks in a row + * read as three decks. The other corner is the state: a bite the slab takes + * out of the bottom-right (StateMarker). A picture that fails to load becomes + * the facts sheet and says "No preview". + * + * `plate` is a tile's preview (18:11 at the width of its container) and the + * large cell of a stack mosaic; `cell` is a mosaic's small cell. + */ + +function Facts({ artifact, cell, note }: { artifact: LabArtifact; cell: boolean; note?: string }) { + const fact = note ?? extent(artifact) ?? artifact.sizeLabel ?? metaLabel(artifact); + if (cell) { + return ( +
+

{fact}

+ +
+ ); + } + return ( +
+

{fact}

+ {/* One line where the dock leaves room for one; two from 20rem of tile. */} +

+ {fileName(artifact)} +

+ +
+ ); +} + +export function GalleryPreview({ + artifact, + scale = "plate", + state = true, + className, +}: { + artifact: LabArtifact; + scale?: "plate" | "cell"; + /** Show the state bite. Off inside a stack mosaic, where the stack's count line carries state. */ + state?: boolean; + /** Sizing; the plate's 18:11 at the width of its container by default. */ + className?: string; +}) { + const [failed, setFailed] = useState(false); + const kind = failed ? "facts" : previewKind(artifact); + const cell = scale === "cell"; + return ( +
+ {kind === "text" ? ( + + ) : kind === "picture" ? ( + + setFailed(true)} /> + {/* In a mosaic the tab is on the large cell only: three tabs on three photographs spend the signature where it says least. */} + {cell ? ( + artifact.kind === "video" ? ( + + + + ) : null + ) : ( + + )} + + ) : ( + + )} + {state ? : null} +
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx b/ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx new file mode 100644 index 00000000000..1e9d41de1da --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/GalleryTile.tsx @@ -0,0 +1,85 @@ +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { actionName, externalProps, type SolidAction } from "./actions"; +import { galleryActions } from "./gallery-model"; +import { GalleryPreview } from "./GalleryPreview"; +import { META_TEXT, MetaDot, MetaLine } from "./MetaLine"; +import { shortStamp } from "./panel-model"; +import { BUTTON, BUTTON_PRIMARY, COVER, ICON_BUTTON, SLAB, SLAB_HOVER } from "./slab"; + +/** + * GALLERY — one artifact in an opened stack (or the ungrouped grid): a slab + * tile, preview first. + * + * ┌ preview, 18:11 ───────────────────────────────┐ + * │ picture · words on a sheet · a file's facts │ + * │ [KIND] ● Review │ ink bottom-left, state bite bottom-right + * └────────────────────────────────────────────────┘ + * Title, the full width of the tile, up to two lines + * kind · size · rev · extent (mono) + * Sep 28, 11:07 AM · Author [⧉] [↓] [→] (stamp mono, author sans) + * + * The thread row's grammar turned on its end: block, title, mono meta, and + * the actions at the far end of the foot — where the thread row and the panel + * card end theirs. The whole tile is the [→] action's target (the owning + * task, at this artifact), which turns to ink when the tile is hovered. Open + * file in new tab and Download file are ghosts beside it, always visible, so + * they are reachable by touch and by keyboard and sit in no other link; each + * has its own glyph (a box with an arrow leaves the app, a tray downloads, + * the arrow goes to the task). In a tile under 18rem the actions take a row of + * their own. + */ + +function Ghost({ action, artifact }: { action: SolidAction; artifact: LabArtifact }) { + const Icon = action.icon; + return ( + + + + ); +} + +export function GalleryTile({ artifact }: { artifact: LabArtifact }) { + const { primary, secondary } = galleryActions(artifact); + const PrimaryIcon = primary.icon; + return ( +
+ +
+

{artifact.title}

+
+

+ + + + + {artifact.author} + +

+
+ {/* First in the DOM so it is first in the tab order; last in the row. */} + + + + {secondary.map((action) => ( + + ))} +
+
+
+
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx b/ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx new file mode 100644 index 00000000000..2869452f0a2 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/KindBlock.tsx @@ -0,0 +1,166 @@ +import { useState, type ReactNode } from "react"; +import { Play } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { blockLabel, hasThumbnail, kindGlyph } from "./kinds"; +import { SLAB_RING } from "./slab"; + +/** + * SIGNATURE 1 — the kind block: the one inverted (INK) fill in the direction. + * + * One landscape plate (18:11) with the kind's mono-caps label in its + * bottom-left corner and the kind's glyph in the opposite, top-right corner, + * so both corners of the plate are used and kinds differ by shape as well as + * by letters. It comes in three forms: + * + * KindBlock free-standing, the anchor of a row or a card: + * xs 56×36 attachments (label only) sm 72×44 thread row, panel card + * lg 128×96 a picture in the attachments block (4:3: a picture's plate, beside a narrow column of text) + * Where the artifact has a picture, the picture fills the plate + * and nothing is laid over it (a video keeps a play mark); a + * picture that fails to load falls back to the ink plate, so a + * broken thumbnail is never an empty box. + * KindTag the label alone, on an ink tab flush in the bottom-left corner + * of a preview that has content of its own (a picture, a sheet). + * KindDock the block docked in that same corner for a preview that has + * none — the tab grown to a block: 72×44 in a mosaic cell, 131×80 + * on a tile's plate (72×44 while the tile is under 18rem). + * + * Tabs and docks size themselves against the gallery unit they sit in (an + * `@container/unit`: a tile, or a stack's face), never against the viewport. + */ + +export type KindBlockSize = "xs" | "sm" | "lg"; +type FaceSize = KindBlockSize | "plate"; + +const BOX: Record = { + xs: "h-9 w-14", + sm: "h-11 w-18", + lg: "h-24 w-32", + plate: "h-11 w-18 @2xs/unit:h-20 @2xs/unit:w-auto @2xs/unit:aspect-(--solid-plate-ratio)", +}; +const PAD: Record = { xs: "p-1.5", sm: "px-2 py-1.5", lg: "p-3", plate: "px-2 py-1.5 @2xs/unit:p-3" }; +/** The glyph sits in the top-right corner, at the plate's own inset. xs has no room for one. */ +const GLYPH: Record = { + xs: null, + sm: "top-1.5 right-2 size-5", + lg: "top-3 right-3 size-7", + plate: "top-1.5 right-2 size-5 @2xs/unit:top-3 @2xs/unit:right-3 @2xs/unit:size-7", +}; +const LABEL: Record = { xs: "text-xs", sm: "text-xs", lg: "text-lg", plate: "text-xs @2xs/unit:text-2xl" }; + +/** Mono caps. Always followed by a size and then `leading-none`: tailwind-merge drops a leading that precedes a font size. */ +const MONO_CAPS = "font-mono font-semibold uppercase tracking-(--tracking-label)"; + +const INK = "bg-(--solid-ink) text-(--solid-ink-foreground)"; +/** Ink laid over a preview is ringed with the slab fill (it follows the slab's steps), so it reads on a picture of any value. */ +const RING = SLAB_RING; + +function InkFace({ artifact, size }: { artifact: LabArtifact; size: FaceSize }) { + const Glyph = kindGlyph(artifact); + return ( + <> + {GLYPH[size] ? : null} + {blockLabel(artifact)} + + ); +} + +/** A picture filling its (relative, overflow-hidden) parent. A page starts at its top-left; a photograph is about its centre. */ +export function PlatePicture({ artifact, onError }: { artifact: LabArtifact; onError: () => void }) { + return ( + + ); +} + +const TAG: Record<"sm" | "md", string> = { sm: "px-1.5 pb-1.5 pt-1", md: "px-2 pb-2 pt-1.5" }; +const TAG_TEXT: Record<"sm" | "md", string> = { sm: "text-(length:--text-nano)", md: "text-(length:--text-micro)" }; + +/** An ink tab in the bottom-left corner of a preview. The parent must be `relative overflow-hidden`. */ +export function CornerTag({ size = "md", className, children }: { size?: "sm" | "md"; className?: string; children: ReactNode }) { + return ( + + {children} + + ); +} + +/** + * The kind's label on a corner tab; a video's carries a play mark. + * `compact`: in a mosaic cell of a stack face narrower than 16rem, where there + * is no room for letters, the tab shows the kind's glyph instead. + */ +export function KindTag({ artifact, size = "md", compact = false }: { artifact: LabArtifact; size?: "sm" | "md"; compact?: boolean }) { + const Glyph = kindGlyph(artifact); + return ( + + {artifact.kind === "video" ? : null} + {compact ? : null} + {blockLabel(artifact)} + + ); +} + +/** + * The kind block docked in the bottom-left corner of a sheet that has no + * content of its own to show. `compact` as for the tab: a glyph-only square + * where a mosaic cell has no room for the block. + */ +export function KindDock({ artifact, size = "plate", compact = false }: { artifact: LabArtifact; size?: "sm" | "plate"; compact?: boolean }) { + const Glyph = kindGlyph(artifact); + const dock = cn("absolute bottom-0 left-0 rounded-tr-md", INK, RING); + return ( + <> + {compact ? ( + + + + ) : null} + + + + + ); +} + +export function KindBlock({ + artifact, + size = "sm", + ink = false, + className, +}: { + artifact: LabArtifact; + size?: KindBlockSize; + /** Always the ink plate, even when the artifact has a picture (a head that sits under the picture itself). */ + ink?: boolean; + className?: string; +}) { + const [failed, setFailed] = useState(false); + const box = cn("relative shrink-0 overflow-hidden rounded-md", BOX[size], className); + + if (hasThumbnail(artifact) && !failed && !ink) { + return ( + + setFailed(true)} /> + {/* No kind tab on a picture this small: the meta line beside it says what it is. A video keeps its play mark. */} + {artifact.kind === "video" ? ( + + + + ) : null} + + ); + } + + return ( + + + + ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx b/ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx new file mode 100644 index 00000000000..6c7152a6473 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/MetaLine.tsx @@ -0,0 +1,70 @@ +import { Fragment } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { metaParts } from "./kinds"; + +/** + * SIGNATURE 2 — the mono meta line: kind · size · rev · extent, in the + * monospace token, in that order on every surface. + */ + +/** The mono recipe for every machine value in the set: meta lines, filenames, stamps, counts, identifiers. */ +export const META_TEXT = "font-mono text-(length:--text-micro) leading-4"; + +/** A set-width separator: a mono " · " is three cells wide and leaves the line gappy. */ +export function MetaDot() { + return ·; +} + +/** A host and path may break after a slash; nothing else in a part may break. */ +function breakable(part: string) { + if (!part.includes("/")) return part; + const pieces = part.split("/"); + return pieces.map((piece, index) => ( + + {index > 0 ? : null} + {index < pieces.length - 1 ? `${piece}/` : piece} + + )); +} + +export function MetaLine({ + artifact, + max, + wrap = false, + className, +}: { + artifact: LabArtifact; + /** Keep only the first `max` parts when the surface is short of room. */ + max?: number; + /** + * Let the line wrap (a panel card, a picture slab). It then breaks only + * between parts — never inside "90.1 KB" — and after a slash in a path. + * Off, the surface truncates it. + */ + wrap?: boolean; + className?: string; +}) { + const parts = max ? metaParts(artifact).slice(0, max) : metaParts(artifact); + return ( + + {parts.map((part, index) => + wrap ? ( + // The separator travels with the part before it, so a wrapped line never starts with a dot. + + + {breakable(part)} + {index < parts.length - 1 ? : null} + + {index < parts.length - 1 ? : null} + + ) : ( + + {index > 0 ? : null} + {part} + + ), + )} + + ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx new file mode 100644 index 00000000000..766fcc5bfcc --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/PanelBody.tsx @@ -0,0 +1,77 @@ +import type { ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { Counts } from "./Counts"; +import { plural } from "./kinds"; +import { META_TEXT } from "./MetaLine"; +import { PanelCard } from "./PanelCard"; +import { panelSections, shortStamp, type PanelSection } from "./panel-model"; + +/** + * PANEL — the scroll body of the task's Artifacts tab (434px pane, 402px of + * content). + * + * 21 artifacts ● 9 to review ← the shelf, counted + * + * Task documents ← a section: who or what + * 7 documents + * [card] [card] … + * + * Brand and Presentation Designer + * 5 artifacts ● 5 to review Sep 28, 10:52 AM ← counts · run start (mono) + * [card] [card] … + * + * Sections and their order are the product's — one per producing run, task + * documents on their own, as listed (panel-model.ts). The mono count label is + * the thread attachments label again: the count in foreground, the breakdown + * after it, a state dot where a state is counted. + * + * One column in the docked pane. A maximized pane sets the cards in 22rem + * columns, filled row by row so the listed order still reads across. + */ + +function Section({ section, children }: { section: PanelSection; children: ReactNode }) { + const headingId = `solid-panel-section-${section.id}`; + return ( +
+
+

+ {section.title} +

+

+ + {section.startedLabel ? ( + + ) : null} +

+
+ {children} +
+ ); +} + +export function PanelBody({ artifacts }: { artifacts: LabArtifact[] }) { + if (artifacts.length === 0) { + return

No artifacts yet. Work products, documents, and agent-produced files will appear here.

; + } + return ( +
+

+ +

+ {panelSections(artifacts).map((section) => ( +
+
    + {section.items.map((artifact) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx new file mode 100644 index 00000000000..0f29bb5855b --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/PanelCard.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { MessageSquare } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { actionName, externalProps } from "./actions"; +import { ExcerptSheet, hasExcerpt } from "./ExcerptSheet"; +import { hasThumbnail, isWaiting, plural } from "./kinds"; +import { KindBlock } from "./KindBlock"; +import { META_TEXT, MetaDot, MetaLine } from "./MetaLine"; +import { panelActions } from "./panel-actions"; +import { stampUnder, type PanelSection } from "./panel-model"; +import { PanelLead } from "./PanelLead"; +import { PanelMenu } from "./PanelMenu"; +import { StateMarker } from "./StateMarker"; +import { BUTTON, BUTTON_HOVER, COUNT_BUTTON, FOCUS, primaryRest, SLAB } from "./slab"; + +/** + * PANEL — one artifact on the task's shelf: a thread row with its drawer open. + * + * [kind block 72×44] Title, wraps a picture leads its card instead: + * Summary, wraps ┌ picture 16:9, kind tab ┐ + * kind · size · rev · extent └────────────────────────┘ + * filename.ext / content type Title … + * ┌ sheet: the excerpt as text ───────────────────┐ (only for text kinds) + * └────────────────────────────────────────────────┘ + * ● Review 11:07 AM [💬 0] [⋯] [Read document ⌄] + * + * The head is the thread row at the same size. The foot is ONE row with the + * same three places on every card: the state (or nothing); who and when in + * mono (the author only where the section does not name them; the time alone + * where the section carries the day; it breaks between the two when short of + * room, never inside either); and at the far end the comments count (Markdown + * files), "More actions" (where there are any) and the primary — the thread's + * 136px block, in ink when the artifact waits for review. + * + * This is the reading surface, so the card is NOT a click target: text can be + * selected, tooltips show, and nothing downloads from a stray click. Only the + * controls act. + * + * Nothing in the head is clamped: titles, summaries, filenames and content + * types wrap. Under 20rem of card (a pane dragged narrow) the foot's two ends + * stack. + */ + +/** Kinds whose card is the product's file card, which carries the content type. */ +const FILE_KINDS = new Set(["pdf", "slides", "sheet", "archive", "code", "file"]); + +export function PanelCard({ artifact, section }: { artifact: LabArtifact; section: PanelSection }) { + const [pictureFailed, setPictureFailed] = useState(false); + const { primary, more } = panelActions(artifact); + const PrimaryIcon = primary.icon; + const waiting = isWaiting(artifact); + const leads = hasThumbnail(artifact) && !pictureFailed; + const showAuthor = artifact.author !== section.agent; + const mono = cn(META_TEXT, "wrap-anywhere text-(--solid-meta)"); + + const button = { + "aria-label": actionName(primary, artifact), + // First in the DOM so it is first in the tab order; last in the row. + className: cn(BUTTON, primaryRest(waiting), BUTTON_HOVER, FOCUS, "order-last min-w-34 cursor-pointer justify-between px-2.5"), + }; + const face = ( + <> + {primary.label} + + + ); + + return ( +
+ {leads ? setPictureFailed(true)} /> : null} + +
+ {leads ? null : } +
+

{artifact.title}

+ {artifact.summary ?

{artifact.summary}

: null} +

+ + {artifact.filename ? {artifact.filename} : null} + {FILE_KINDS.has(artifact.kind) && artifact.contentType ? {artifact.contentType} : null} + {hasThumbnail(artifact) && pictureFailed ? No preview available : null} +

+
+
+ + {/* What waits for review is given room to be read here; the rest shows enough to be told apart. */} + {hasExcerpt(artifact) ? : null} + +
+ {/* State, then who and when. The line breaks between the two when the row is short of room, never inside either. */} +
+ +

+ {showAuthor ? ( + + {artifact.author} + + + ) : ( + {artifact.author}, + )} + + +

+
+
+ {primary.href ? ( + + {face} + + ) : ( + + )} + {artifact.commentCount != null ? ( + + ) : null} + +
+
+
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx new file mode 100644 index 00000000000..64efb44de94 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/PanelLead.tsx @@ -0,0 +1,59 @@ +import { useRef, useState } from "react"; +import type { LabArtifact } from "../../kit"; +import { KindTag, PlatePicture } from "./KindBlock"; + +/** + * PANEL — what leads a card whose artifact is a picture: the picture itself, + * at 16:9 across the card, with the ink kind tab in its bottom-left corner — + * the same rule as the thread and the gallery (the picture is the plate and + * carries the tab), so nothing is represented twice. + * + * A video plays in place: its poster is the play button, and the product's + * controls take over once it has started. A picture that fails to load reports + * it, and the card falls back to the ink kind block. + */ + +const LEAD = "relative aspect-(--solid-lead-ratio) w-full overflow-hidden rounded-md bg-(--solid-sheet)"; + +function VideoLead({ artifact }: { artifact: LabArtifact }) { + const video = useRef(null); + const [started, setStarted] = useState(false); + return ( +
+
+ ); +} + +export function PanelLead({ artifact, onPictureError }: { artifact: LabArtifact; onPictureError: () => void }) { + if (artifact.kind === "video" && artifact.videoUrl) return ; + return ( +
+ + +
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx b/ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx new file mode 100644 index 00000000000..6fe8df278b2 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/PanelMenu.tsx @@ -0,0 +1,47 @@ +import { Ellipsis } from "lucide-react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import type { LabArtifact } from "../../kit"; +import { externalProps, type SolidAction } from "./actions"; +import { ICON_BUTTON } from "./slab"; + +/** + * PANEL — a card's "More actions": every way out that is not the primary, in + * one named control, so the foot is the same single row on every card. The + * list is the product's own menu (it opens in a portal, outside the kit's + * wrapper, in the app's popover chrome); each item is the action by name, not + * an unlabelled icon. + */ +export function PanelMenu({ actions, artifact }: { actions: SolidAction[]; artifact: LabArtifact }) { + if (actions.length === 0) return null; + return ( + + + + + + {actions.map((action) => { + const Icon = action.icon; + const face = ( + <> + + {action.label} + + ); + return ( + + {action.href ? ( + + {face} + + ) : ( + + )} + + ); + })} + + + ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/StackTile.tsx b/ui/src/artifact-directions/directions/d1-solid/StackTile.tsx new file mode 100644 index 00000000000..f943cf35fe6 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/StackTile.tsx @@ -0,0 +1,129 @@ +import { ArrowRight } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { LabGroup } from "../../kit"; +import { Counts } from "./Counts"; +import { kindTally, pileDepth, stackPreviews } from "./gallery-model"; +import { GalleryPreview } from "./GalleryPreview"; +import { plural } from "./kinds"; +import { META_TEXT, MetaDot } from "./MetaLine"; +import { CORNER_NOTCH } from "./StateMarker"; +import { BUTTON, BUTTON_PRIMARY, COVER, SLAB, SLAB_HOVER } from "./slab"; + +/** + * GALLERY — one task's stack. + * + * ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ pile: the slabs behind the face, each narrower + * ▁ MD 7 · PDF 4 · PPTX 4 · PNG 3 ▁▁▁ and one step nearer the canvas; the nearer one + * ┌──────────────────────┬─────────┐ says what is in the pile, on its edge + * │ │ 12 slides│ + * │ picture ├─────────┤ mosaic: the newest three, each as its own preview; + * │ [PNG] │ [ZIP] +18│ the one with most to show is large + * └──────────────────────┴─────────┘ + * ACM-3 Create visual directions and first acmehats brand deck + * 21 artifacts ● 9 to review [→] + * Updated Sep 28, 2026 + * + * The pile is made of flat fills only. Its depth says how much is in it: no + * slab behind one artifact, one behind two or three, two behind more. A stack + * of one is therefore an artifact tile with a task's name on it — one preview + * at full size, a flat top — and cannot be mistaken for a pile. + * + * The whole object — face and pile — is the [→] action's target ("Open + * stack"). Hover or keyboard focus steps the face, turns the action to ink, + * and the pile rises 6px a slab (not under reduced motion). + * + * Pile geometry: 28px is kept above every face; the nearer slab shows 16px of + * itself 12px in from each side, the farther one 12px more, 32px in. + */ + +const LAYER = + "pointer-events-none absolute h-10 overflow-hidden rounded-md transition-transform duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-reduce:transition-none"; +const RISE_1 = "motion-safe:group-hover/pile:-translate-y-1.5 motion-safe:group-has-[[data-primary]:focus-visible]/pile:-translate-y-1.5"; +const RISE_2 = "motion-safe:group-hover/pile:-translate-y-3 motion-safe:group-has-[[data-primary]:focus-visible]/pile:-translate-y-3"; + +function Pile({ group, depth }: { group: LabGroup; depth: 0 | 1 | 2 }) { + if (depth === 0) return null; + return ( + <> + {depth > 1 ? : null} + + {/* The slab's visible edge is 16px: one line of mono caps, cut clean where the pile runs out of room. */} + + {kindTally(group.artifacts).map((entry, index) => ( + + {index > 0 ? : null} + {entry} + + ))} + + + + ); +} + +/** The corner bite of the mosaic: how many artifacts it leaves out, in mono. */ +function MoreTab({ count }: { count: number }) { + return ( + + +{count} + + ); +} + +function Mosaic({ group }: { group: LabGroup }) { + const [lead, ...rest] = stackPreviews(group); + if (!lead) return null; + if (rest.length === 0) return ; + const more = group.count - 1 - rest.length; + return ( +
+
+ +
+ {rest.map((artifact) => ( +
+ +
+ ))} + {more > 0 ? : null} +
+ ); +} + +export function StackTile({ group }: { group: LabGroup }) { + const depth = pileDepth(group.count); + const count = plural(group.count, "artifact"); + return ( + // 28px above every face, so faces line up along a row whether or not a pile sits behind them. +
+ +
+ +
+ {/* h2: on the stacks page a stack sits directly under the page title. */} +

+ {group.taskIdentifier} + {group.title} +

+
+ {/* 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. */} +

+ + Updated {group.updatedLabel} +

+ + + +
+
+
+
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx b/ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx new file mode 100644 index 00000000000..51927c7aa1f --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/StateMarker.tsx @@ -0,0 +1,75 @@ +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { MOTION, SLAB_FILL, SLAB_JOINTS } from "./slab"; + +/** + * The state marker: the only chroma in the direction, and only a 6px dot of + * it, followed by the production label in foreground text. + * + * On a slab it has no fill of its own — dot and label sit straight on the + * slab, so it can never be taken for the button next to it. On a gallery + * preview it is written in a BITE the slab takes out of the preview's + * bottom-right corner, the mirror of the ink kind tab in the bottom-left: what + * it is on the left, what it needs on the right. + * + * Nothing renders for an artifact with no pending state, as in the product. + */ + +/** The one state vocabulary: label and dot fill per state. Counts.tsx counts in the same dots. */ +export const STATE = { + needs_review: { label: "Review", dot: "bg-(--solid-state-review)" }, + changes_requested: { label: "Changes requested", dot: "bg-(--solid-state-changes)" }, +} as const; + +export function StateDot({ state, className }: { state: keyof typeof STATE; className?: string }) { + return ; +} + +/** + * A bite out of a preview's bottom-right corner, in the slab's own fill (it + * follows the slab's hover). The state marker uses it in the gallery; so does + * a stack mosaic's "+18". The parent must be `relative overflow-hidden`. + * + * It is cut, not laid on: where the bite meets the preview's right and bottom + * edges, two 6px joints (its pseudo-elements) round the preview's new corners + * to the set's one radius — the slab painted into the corner outside a + * quarter circle (--solid-joint). + */ +export const CORNER_NOTCH = cn( + "absolute right-0 bottom-0 rounded-tl-md", + SLAB_FILL, + MOTION, + // The joint above the bite, on the preview's right edge; and the one beside it, on the bottom edge. + "before:absolute before:right-0 before:bottom-full before:size-1.5 before:rounded-br-md before:shadow-(--solid-joint)", + "after:absolute after:right-full after:bottom-0 after:size-1.5 after:rounded-br-md after:shadow-(--solid-joint)", + SLAB_JOINTS, +); + +export function StateMarker({ + review, + on = "slab", + collapse = false, + className, +}: { + review: LabArtifact["review"]; + /** `slab`: dot and label on the slab. `corner`: in a bite out of a preview's bottom-right corner. */ + on?: "slab" | "corner"; + /** Inside an `@container` narrower than 36rem, show only the dot (the label stays for screen readers). */ + collapse?: boolean; + className?: string; +}) { + if (!review) return null; + return ( + + + {STATE[review].label} + + ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx b/ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx new file mode 100644 index 00000000000..3dc884b4223 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/ThreadAttachments.tsx @@ -0,0 +1,102 @@ +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { Counts } from "./Counts"; +import { fileName, hasThumbnail, plural } from "./kinds"; +import { KindBlock } from "./KindBlock"; +import { META_TEXT, MetaLine } from "./MetaLine"; +import { STATE, StateMarker } from "./StateMarker"; +import { FOCUS, SLAB, SLAB_HOVER } from "./slab"; + +/** + * THREAD — the attachments block under an agent message. + * + * Today this is two unrelated blocks (an "Images · 1" thumbnail grid and a + * scrolling "Files · 4" strip of square-cornered tiles). Here it is one grid + * under one mono label that keeps both counts and says what waits: + * + * 5 attachments · 1 image · 4 files ● 5 to review + * ┌ picture 128×96 ┐ contact-sheet.png [PPTX] name.pptx [PDF] name.pdf + * │ │ Image · 422.9 KB Slides · 90.1 KB ● Review … + * │ │ [MD] deck-guide.md [ZIP] editing-source.zip + * └────────────────┘ ● Review Markdown · 5.9 KB ● Review … + * + * A file is a 52px slab: kind block, filename, mono meta, state. Anything with + * a picture takes two rows (112px) so the picture is shown at a size it can be + * read at, with nothing laid over it. The grid wraps, so no file is ever + * scrolled out of sight. + */ + +function AttachmentSlab({ artifact }: { artifact: LabArtifact }) { + const name = fileName(artifact); + const picture = hasThumbnail(artifact); + const opensGallery = artifact.kind === "image" || artifact.kind === "video"; + const shared = { + // The name replaces the slab's content for assistive tech, so the state is part of it. + "aria-label": [`Open ${name}`, artifact.review ? STATE[artifact.review].label : null].filter(Boolean).join(", "), + title: [name, artifact.title === name ? null : artifact.title].filter(Boolean).join(" — "), + className: cn( + "group/slab flex size-full min-w-0 cursor-pointer gap-2.5 p-2 pr-3 text-left focus-visible:bg-(--solid-slab-hover)", + picture ? "items-stretch" : "items-center", + SLAB, + SLAB_HOVER, + FOCUS, + ), + }; + const face = picture ? ( + <> + + + {name} + + {/* The one place a state label may take two lines: the column beside a picture is narrow. */} + + + + ) : ( + <> + + + {name} + + {/* A long state takes the size's place; the kind always stays. */} + + + + + + ); + return opensGallery ? ( + + ) : ( + + {face} + + ); +} + +export function ThreadAttachments({ artifacts }: { artifacts: LabArtifact[] }) { + if (artifacts.length === 0) return null; + const images = artifacts.filter((a) => a.kind === "image"); + const files = artifacts.filter((a) => a.kind !== "image"); + const counts = [images.length > 0 ? plural(images.length, "image") : null, files.length > 0 ? plural(files.length, "file") : null].filter( + (count): count is string => Boolean(count), + ); + // Whatever has a picture goes first, so the two-row slabs start the grid and the files pack beside them. + const ordered = [...artifacts.filter(hasThumbnail), ...artifacts.filter((a) => !hasThumbnail(a))]; + return ( +
+

+ +

+
    + {ordered.map((artifact) => ( +
  • + +
  • + ))} +
+
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx b/ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx new file mode 100644 index 00000000000..1b306462216 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/ThreadWorkProduct.tsx @@ -0,0 +1,100 @@ +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { actionName, downloadAction, externalProps, threadAction } from "./actions"; +import { extent, isWaiting, metaParts } from "./kinds"; +import { KindBlock } from "./KindBlock"; +import { MetaLine } from "./MetaLine"; +import { StateMarker } from "./StateMarker"; +import { BUTTON, BUTTON_PRIMARY, COVER, ICON_BUTTON, primaryRest, SLAB, SLAB_HOVER } from "./slab"; + +/** + * THREAD — one work product between turns: a 60px slab. + * + * [kind block 72×44] Title ● Review [Open gallery ⤢] + * kind · size · rev · extent — summary + * + * One grammar for all ten kinds (today: three). The whole slab is the primary + * action's target; hovering it steps the fill and turns the action to ink. An + * artifact that waits for review has its action in ink already. + * + * The row gives way in order of what matters least (by its own width, inside its inset): + * from 48rem one-line title, summary on the meta line after a dash + * under 48rem two-line title, summary on its own line under the meta, two lines at most + * (a row with a summary grows to 76px; nothing is left to a tooltip) + * from 42rem every action is the same 136px block, as every row starts with the same 72px one + * under 36rem the state is its dot alone + * under 28rem the action is its icon alone + * and the text column never goes under 8rem, so the title is the last thing to yield. + */ +export function ThreadWorkProduct({ artifact }: { artifact: LabArtifact }) { + const action = threadAction(artifact); + const Icon = action.icon; + // A text-like file opens in the side panel, so Download becomes its own small target. + const download = action.id === "tab" ? downloadAction(artifact) : null; + const DownloadIcon = download?.icon; + // "Slides · 90.1 KB · 12 slides — 12 editable slides…" stutters: the summary keeps the figure, the meta lets it go. + const figure = extent(artifact)?.split(" ")[0]; + const echoed = Boolean(figure && artifact.summary?.startsWith(`${figure} `)); + + const primary = { + "data-primary": true, + "aria-label": actionName(action, artifact), + title: actionName(action, artifact), + // First in the DOM so it is first in the tab order; last in the row. + className: cn( + BUTTON, + primaryRest(isWaiting(artifact)), + BUTTON_PRIMARY, + COVER, + "order-last cursor-pointer after:cursor-pointer @2xl:min-w-34 @2xl:justify-between @2xl:px-2.5", + ), + }; + const face = ( + <> + {action.label} + + + ); + + return ( +
+ +
+

{artifact.title}

+

+ + {artifact.summary ? ( + + + — + + {artifact.summary} + + ) : null} +

+ {artifact.summary ?

{artifact.summary}

: null} +
+
+ {action.href ? ( + + {face} + + ) : ( + + )} + + {download && DownloadIcon ? ( + + + + ) : null} +
+
+ ); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/actions.ts b/ui/src/artifact-directions/directions/d1-solid/actions.ts new file mode 100644 index 00000000000..aa3b981c3e2 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/actions.ts @@ -0,0 +1,42 @@ +import { Download, ExternalLink, Maximize2, PanelRightOpen, type LucideIcon } from "lucide-react"; +import type { LabArtifact } from "../../kit"; +import { isTaskDocument, isTextLike, slug } from "./kinds"; + +/** + * The one thing you do with an artifact from the thread, in production + * wording. The lab's targets are inert hashes; `gallery` is a button because + * the product opens a modal from it. + * + * One glyph per destination on every surface, so no two controls on a unit + * look alike: ExternalLink leaves for a new browser tab, PanelRightOpen opens + * the side panel, Maximize2 opens the gallery, Download downloads, and + * ArrowRight (the gallery only) goes to the owning task. + */ + +export interface SolidAction { + id: "document" | "gallery" | "preview" | "tab" | "download"; + label: string; + icon: LucideIcon; + /** Absent for `gallery`, which is a button. */ + href?: string; + external?: boolean; +} + +export function downloadAction(a: LabArtifact): SolidAction { + return { id: "download", label: "Download", icon: Download, href: `#download-${a.id}` }; +} + +export function threadAction(a: LabArtifact): SolidAction { + if (isTaskDocument(a)) return { id: "document", label: "Open document", icon: PanelRightOpen, href: `#document-${slug(a)}` }; + if (a.kind === "link") return { id: "preview", label: "Open preview", icon: ExternalLink, href: a.url ?? "#", external: true }; + if (a.kind === "image" || a.kind === "video") return { id: "gallery", label: "Open gallery", icon: Maximize2 }; + // Text-like files open in the side panel; Download stays reachable as its own target. + if (isTextLike(a)) return { id: "tab", label: "Open in tab", icon: PanelRightOpen, href: `#attachment-${a.id}` }; + return downloadAction(a); +} + +/** The accessible name of an action on a given artifact: "Download: Interface Craft revision — PDF". */ +export const actionName = (action: SolidAction, a: LabArtifact) => `${action.label}: ${a.title}`; + +/** Link attributes for an action that leaves for a new browser tab. */ +export const externalProps = (action: SolidAction) => (action.external ? ({ target: "_blank", rel: "noreferrer" } as const) : null); diff --git a/ui/src/artifact-directions/directions/d1-solid/gallery-model.ts b/ui/src/artifact-directions/directions/d1-solid/gallery-model.ts new file mode 100644 index 00000000000..313eaaca50b --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/gallery-model.ts @@ -0,0 +1,107 @@ +import { ArrowRight, Download, ExternalLink } from "lucide-react"; +import type { LabArtifact, LabGroup } from "../../kit"; +import type { SolidAction } from "./actions"; +import { blockLabel, hasThumbnail, isTaskDocument, slug } from "./kinds"; + +/** + * How the gallery reads its data: what a tile's preview is, which previews a + * stack shows, how an opened stack is sectioned, and what a tile offers. + */ + +/** + * The three previews of the direction, by what the artifact has to show: + * picture image, video poster, link preview → the picture, kind tab in the corner + * text task document, Markdown, code, sheet → its own words on a sheet, kind tab in the corner + * facts PDF, slides, archive, any other file → a sheet that says what is known of it, the kind block docked in the corner + */ +export type PreviewKind = "picture" | "text" | "facts"; + +export function previewKind(a: LabArtifact): PreviewKind { + if (hasThumbnail(a)) return "picture"; + return a.excerpt ? "text" : "facts"; +} + +const VISUAL: Record = { picture: 0, text: 1, facts: 2 }; + +/** + * A stack's mosaic: its three newest artifacts (what the API already sends as + * `previewArtifacts`), with the one that has most to show in the large cell — + * a picture before text before bare facts. + */ +export function stackPreviews(group: LabGroup): LabArtifact[] { + return group.artifacts + .slice(0, 3) + .map((artifact, index) => ({ artifact, index })) + .sort((a, b) => VISUAL[previewKind(a.artifact)] - VISUAL[previewKind(b.artifact)] || a.index - b.index) + .map((entry) => entry.artifact); +} + +/** How many slabs sit behind a stack's face: none for one artifact, one for two or three, two for a real pile. */ +export const pileDepth = (count: number): 0 | 1 | 2 => (count <= 1 ? 0 : count <= 3 ? 1 : 2); + +/** + * What is in the pile, for the edge of the slab behind a stack's face: kinds + * by their block label, most numerous first ("PDF 4 · PPTX 4 · PNG 3"). + * Task documents are counted together as DOCS — their block label is a revision. + */ +export function kindTally(artifacts: LabArtifact[]): string[] { + const counts = new Map(); + for (const artifact of artifacts) { + const label = isTaskDocument(artifact) ? "DOCS" : blockLabel(artifact); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([label, count]) => `${label} ${count}`); +} + +export interface DaySection { + /** "2026-09-28" */ + id: string; + /** "Monday, September 28" */ + label: string; + items: LabArtifact[]; +} + +/** An opened stack by the day of the last edit, newest day first; inside a day the page's own order (newest first) is kept. */ +export function daySections(artifacts: LabArtifact[]): DaySection[] { + const byDay = new Map(); + for (const artifact of artifacts) { + const id = artifact.updatedAt.slice(0, 10); + const section = byDay.get(id) ?? { id, label: artifact.dayLabel, items: [] }; + section.items.push(artifact); + byDay.set(id, section); + } + return [...byDay.values()].sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0)); +} + +export interface GalleryActions { + /** The tile itself: the owning task, at this artifact. */ + primary: SolidAction; + /** The product's two ways straight to the file, where it has them. */ + secondary: SolidAction[]; +} + +/** + * What a gallery tile offers, in the product's wording. The tile is a link to + * the owning task at the artifact's anchor; "Open file in new tab" and + * "Download file" exist for anything file-backed (a task document has no file, + * a link only opens). + */ +export function galleryActions(a: LabArtifact): GalleryActions { + const primary: SolidAction = { + id: "document", + label: `Open in ${a.taskIdentifier}`, + icon: ArrowRight, + href: isTaskDocument(a) ? `#document-${slug(a)}` : `#work-product-${a.id}`, + }; + if (isTaskDocument(a)) return { primary, secondary: [] }; + if (a.kind === "link") { + return { primary, secondary: [{ id: "preview", label: "Open link in new tab", icon: ExternalLink, href: a.url ?? "#", external: true }] }; + } + return { + primary, + secondary: [ + { id: "preview", label: "Open file in new tab", icon: ExternalLink, href: `#file-${a.id}`, external: true }, + { id: "download", label: "Download file", icon: Download, href: `#download-${a.id}` }, + ], + }; +} diff --git a/ui/src/artifact-directions/directions/d1-solid/index.tsx b/ui/src/artifact-directions/directions/d1-solid/index.tsx new file mode 100644 index 00000000000..26e4e76e9e2 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/index.tsx @@ -0,0 +1,142 @@ +import type { DirectionKit } from "../../kit"; +import { GalleryStack, GalleryStacks } from "./Gallery"; +import { PanelBody } from "./PanelBody"; +import { ThreadAttachments } from "./ThreadAttachments"; +import { ThreadWorkProduct } from "./ThreadWorkProduct"; +import "./tokens.css"; + +/** + * DIRECTION 1 — SOLID. Flat, filled, mostly monochrome. + * + * The fix is fill, not line. Today an artifact is a 1px outline around the + * canvas colour; here it is a SLAB — a filled block a clear value step off the + * canvas, with no stroke, no shadow and no gradient — and what fills an + * artifact's preview is its CONTENT, never its file extension. + * + * FILLS (tokens.css; OKLCH lightness, light / dark; contrast against what it sits on) + * canvas 1.000 / 0.205 --background, untouched + * slab 0.900 / 0.325 the artifact (1.35 / 1.44 on the canvas; today's outline colour is 1.26 / 1.32) + * slab-hover 0.865 / 0.365 a slab that is a target, hovered or focused; slab-press 0.830 / 0.400 while pressed + * button 0.800 / 0.400 a resting button on a slab (1.39 / 1.35 on the slab) + * sheet 1.000 / 0.430 the artifact's own content — an excerpt, a file's facts: the lightest fill short of ink + * in both themes (paper on a mat); body text in --solid-sheet-text, lead lines in --foreground + * ink 0.205 / 0.800 the one inverted fill: the kind block and tab, and a primary action that is hovered or due + * pile-1/-2 0.920 / 0.290 and 0.945 / 0.265 the slabs behind a gallery stack: each one step NEARER the canvas, both themes + * Text on a slab: --foreground, and --solid-meta (0.44 / 0.78) for secondary; every pair ≥ 4.5:1 at rest and on hover. + * + * SIGNATURES — the same idea on thread, panel and gallery + * 1. KIND BLOCK (KindBlock.tsx, kinds.ts) — an INK plate: mono-caps label + * bottom-left, the kind's glyph top-right (20px in a thread row, so kinds + * differ by shape as well as letters). A file is labelled by its extension + * (PDF, PPTX, MD, PNG, ZIP, XLSX, WEBM, HTML, OTF; URL for a link); a task + * document, which has none, by its revision (REV 3) under a notebook glyph. + * KindBlock free-standing: xs 56×36 attachments (label only) · sm 72×44 + * thread row and panel card · lg 128×96 a picture attachment. + * An artifact with a picture shows the picture in the plate, + * with nothing over it (a video keeps a play mark). + * KindTag the label on an ink tab in the bottom-left corner of a + * preview that has content of its own (a picture, a sheet). + * KindDock the block docked in that corner of a preview that has none + * (131×80 on a tile, 72×44 in a mosaic cell): the tab grown + * to a block. Ink is always the mark in the bottom-left + * corner — never the whole plate. + * 2. MONO META LINE (MetaLine.tsx, kinds.ts) — kind · size · rev · extent in + * font-mono 11px, never reordered: "PDF · 57.7 KB · 12 pages", + * "Document · rev 3", "Video · 3.4 MB · 0:06". Production wording; a + * surface may take a prefix (`max`), never reorder. Every other machine + * value (filenames, stamps, counts, content types) is the same META_TEXT. + * 3. ONE RADIUS, ONE RHYTHM (slab.ts) — rounded-md on every slab, plate, + * sheet, button, tab and bite; 8px inset around the kind block in the + * thread, 12px in the panel and the gallery; 12px from block to text; 8px + * between slabs, 24px gutters in the gallery. + * 4. MONO COUNT LABEL (Counts.tsx) — heads the thread's attachments, the + * panel's shelf and sections, and the gallery's grids, days and stacks: + * "21 artifacts ● 9 to review". Items are unbreakable; the label wraps + * between them. + * + * STATE (StateMarker.tsx) — the only chroma, and only a 6px dot of it: the + * product's status hue + the production label ("Review", "Changes requested") + * in foreground text, straight on the slab with no fill of its own, so it is + * never taken for a button. What makes "this needs you" unmistakable is the + * action beside it: the primary of an artifact that waits for review is INK AT + * REST (thread and panel). On a gallery preview the state is written in a BITE + * the slab takes out of the bottom-right corner (inverse radii at its joints), + * the mirror of the ink kind tab bottom-left; a mosaic's "+18" is the same bite. + * + * INTERACTION (slab.ts) — in the thread and the gallery the whole slab is its + * primary action's target (COVER). Hover or keyboard focus steps the slab and + * turns that action to ink; pressing steps it once more. A secondary control + * is an icon-only ghost that takes the ink itself and hands the primary back + * to the button fill while hovered. The panel card is a reading surface and is + * NOT a target: only its controls act. The primary is first in the tab order + * everywhere; icon-only controls carry a tooltip naming the action. One glyph + * per destination: a box-with-arrow leaves for a new tab, a tray downloads, a + * side-panel glyph opens the pane, the plain arrow goes to the owning task. + * focus-visible is a 2px ink outline 2px off the slab or the control. Colour + * transitions only (and the pile's rise); none under reduced motion. + * + * THREAD — compact and calm (ThreadWorkProduct, ThreadAttachments) + * Row 60px: [block 72×44] title / meta — summary … ● state [action 136px]. + * Gives way by priority, by its own width: from 48rem a one-line title with + * the summary on the meta line; under that a two-line title and the summary + * on its own line (76px); under 36rem the state is its dot; under 28rem the + * action is its icon; the text column never goes under 8rem. + * Attachments: one count label ("5 attachments · 1 image · 4 files ● 5 to + * review") over a wrapping grid: a file is a 52px slab (block, filename, meta, + * state); anything with a picture takes two rows, the picture at 128×96. + * + * PANEL — a thread row with its drawer open (PanelBody, PanelCard, PanelLead, + * PanelMenu, panel-model.ts, panel-actions.ts) + * Shelf one count line, then the product's sections in the product's + * order: one per producing run (name, count line, run start), task + * documents on their own. Nothing is reordered. + * Card slab, 12px inset. A picture or a video LEADS its card at 16:9 + * with the kind tab on it (a video plays in place); every other + * kind opens with [block 72×44] beside the text. + * head title, summary, meta line, filename, and the content type on + * file cards — all wrap, nothing is clamped. + * sheet text kinds only: the excerpt on a sheet, opening line and + * headings strong; six lines when it waits for review, three otherwise. + * foot one row, the same three places on every card: ● state · who and + * when (the time alone under a section dated the same day) · and + * at the far end [comments] [⋯ More actions] [primary 136px]. All + * secondaries are in the one menu, by name. + * Widths under 20rem of card the foot's two ends stack; a maximized pane + * sets the cards in 22rem columns, row by row. + * + * GALLERY — browsing: preview first (Gallery, StackTile, GalleryTile, + * GalleryPreview, ExcerptSheet, gallery-model.ts) + * Grid the product's: 1 / 2 / 3 columns at <768 / 768 / 1280px, 24px + * gutters, under one count line. An opened stack is sectioned by + * day of last edit: the day name is the page's one large line + * (text-lg semibold), its count line beneath. `?days=off` lists it + * as the product does today, in one flat grid. + * Preview one 18:11 plate per artifact, filled by what the artifact is: + * picture image, video poster, link the picture + ink kind tab + * text document, Markdown, code, its own words on a sheet, as many whole + * sheet lines as the plate holds + ink kind tab + * facts PDF, slides, archive, file a sheet: how much of it there is at display + * size ("12 slides"), its filename, and the + * kind block docked bottom-left + * Tile slab: preview / title across the full width (2 lines) / meta + * line and stamp · author, with [⧉] [↓] [→] at the far end of the + * foot. The tile is the [→] action's target (the owning task). + * Stack a PILE of flat fills behind the face: 0 / 1 / 2 slabs for 1 / + * 2–3 / more artifacts; the nearer one says what is in the pile on + * its edge ("DOCS 7 · PDF 3 · PPTX 3"); they rise 6px a slab on + * hover. On the face a MOSAIC of the three newest, each as its own + * preview, the one with most to show large, "+18" bitten into the + * corner. Then ACM-3 + title, and a two-line foot (counts, then + * Updated) with [→]. Face and pile are one target. A stack of one + * has no pile and one full preview. + */ +export const kit: DirectionKit = { + id: "d1-solid", + name: "1.Solid", + tagline: "Flat, filled, mostly monochrome", + ThreadWorkProduct, + ThreadAttachments, + PanelBody, + GalleryStacks, + GalleryStack, +}; diff --git a/ui/src/artifact-directions/directions/d1-solid/kinds.ts b/ui/src/artifact-directions/directions/d1-solid/kinds.ts new file mode 100644 index 00000000000..faae6384955 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/kinds.ts @@ -0,0 +1,112 @@ +import { Archive, CodeXml, File, FilePenLine, FileText, Film, Globe, Image, NotebookText, Presentation, Table2, type LucideIcon } from "lucide-react"; +import { fileKindForName } from "@/components/task-chat/task-chat-attachments"; +import type { LabArtifact, LabKind } from "../../kit"; + +/** + * The Solid vocabulary: for each of the ten kinds, the glyph that goes in the + * kind block, the mono-caps label under it, and the wording of the meta line. + * Every surface reads kinds from here so a PDF is the same PDF in the thread, + * the panel and the gallery. + * + * Glyphs are chosen to differ in shape at 20px, not only in their letters: a + * notebook (task document — kept and revised in the task), a page with a pen + * (Markdown — a source you edit), a lined page (PDF — the finished page), an + * easel, a grid, a box, brackets, a blank page, a globe, a framed picture, a + * film strip. + */ + +interface KindSpec { + glyph: LucideIcon; + /** Kind-block label when the artifact has no extension to show. */ + block: string; + /** Meta-line label when the product's extension mapper only says "File". */ + meta: string; +} + +export const KINDS: Record = { + document: { glyph: FilePenLine, block: "MD", meta: "Markdown" }, + image: { glyph: Image, block: "IMG", meta: "Image" }, + video: { glyph: Film, block: "VID", meta: "Video" }, + pdf: { glyph: FileText, block: "PDF", meta: "PDF" }, + slides: { glyph: Presentation, block: "PPT", meta: "Slides" }, + sheet: { glyph: Table2, block: "XLS", meta: "Sheet" }, + archive: { glyph: Archive, block: "ZIP", meta: "Archive" }, + code: { glyph: CodeXml, block: "CODE", meta: "Code" }, + file: { glyph: File, block: "FILE", meta: "File" }, + link: { glyph: Globe, block: "URL", meta: "Link" }, +}; + +/** A task document ("Document · rev N"), as opposed to a Markdown file an agent attached. */ +export const isTaskDocument = (a: LabArtifact) => a.kind === "document" && a.revision != null; + +/** A task document is a notebook: it is kept and revised in the task, not a file on disk. */ +export const kindGlyph = (a: LabArtifact): LucideIcon => (isTaskDocument(a) ? NotebookText : KINDS[a.kind].glyph); + +/** + * The mono-caps label in the kind block. A file is labelled by its extension + * (PDF, PPTX, MD, PNG, ZIP, CSV, WEBM); a task document has none, and is + * labelled by what tells one from the next — its revision (REV 3). + */ +export function blockLabel(a: LabArtifact): string { + if (a.kind === "link") return KINDS.link.block; + if (isTaskDocument(a)) return `REV ${a.revision}`; + const ext = a.ext?.replace(/[^a-z0-9]/gi, ""); + return ext ? ext.slice(0, 4).toUpperCase() : KINDS[a.kind].block; +} + +/** The first word of the meta line, in production wording where production has one. */ +export function metaLabel(a: LabArtifact): string { + if (isTaskDocument(a)) return "Document"; + if (a.kind === "link") return KINDS.link.meta; + if (a.kind === "image" || a.kind === "video") return KINDS[a.kind].meta; + const shipped = a.filename ? fileKindForName(a.filename).label : "File"; + return shipped === "File" ? KINDS[a.kind].meta : shipped; +} + +export const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? "" : "s"}`; + +/** The key a document or file is addressed by in the lab's inert hashes. */ +export const slug = (a: LabArtifact) => (a.filename ?? a.id).replace(/\.md$/, ""); + +function host(url: string | undefined): string | null { + if (!url) return null; + try { + const parsed = new URL(url); + return `${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`; + } catch { + return url; + } +} + +/** How much of it there is: pages, slides, a duration, or where a link points. */ +export function extent(a: LabArtifact): string | null { + if (a.kind === "video") return a.durationLabel ?? null; + if (a.kind === "link") return host(a.url); + if (!a.pageCount) return null; + return plural(a.pageCount, a.kind === "slides" ? "slide" : "page"); +} + +/** + * The meta line's parts, in one grammar everywhere: kind · size · rev · extent + * (pages, slides, duration or host). A surface may take a prefix of this list + * when it is short of room, never reorder it. + */ +export function metaParts(a: LabArtifact): string[] { + return [metaLabel(a), a.sizeLabel, a.revision != null ? `rev ${a.revision}` : null, extent(a)].filter((part): part is string => Boolean(part)); +} + +/** What the product can open in the side panel instead of downloading (`isTextAttachment`). */ +export function isTextLike(a: LabArtifact): boolean { + if (a.kind === "link" || isTaskDocument(a)) return false; + const type = a.contentType ?? ""; + return type.startsWith("text/") || type === "application/json" || /\.(md|txt|log|csv|json|ya?ml|html|css)$/i.test(a.filename ?? ""); +} + +/** Whether the artifact has a picture to put where the kind block would go. */ +export const hasThumbnail = (a: LabArtifact) => Boolean(a.thumbnailUrl); + +/** The name a file is listed under when the surface shows files rather than titles. A link has no file, so it keeps its title. */ +export const fileName = (a: LabArtifact) => a.filename ?? a.title; + +/** Whether anything waits on the reader: the artifact's primary action is then ink at rest. */ +export const isWaiting = (a: LabArtifact) => a.review === "needs_review"; diff --git a/ui/src/artifact-directions/directions/d1-solid/panel-actions.ts b/ui/src/artifact-directions/directions/d1-solid/panel-actions.ts new file mode 100644 index 00000000000..46d55a2f66e --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/panel-actions.ts @@ -0,0 +1,54 @@ +import { ChevronDown, Download, ExternalLink, Maximize2, PanelRightOpen, Table2 } from "lucide-react"; +import type { LabArtifact } from "../../kit"; +import type { SolidAction } from "./actions"; +import { isTaskDocument, isTextLike, slug } from "./kinds"; + +/** + * What a panel card offers, in the panel's production wording. The primary is + * the same deed the thread row offers for that artifact, under the name the + * panel gives it: + * + * thread panel + * Open document Read document task documents (a side-panel tab) and Markdown files (expands in place) + * Open gallery View image / Open video + * Open preview Open link + * Open in tab Open in tab other text-like files + * Download Download file everything else + * + * Everything else the product puts on the card is listed, by name, in the + * card's one "More actions" menu: Open in tab, Open raw, Open file, Download, + * Preview data. The comments count of a Markdown file is the card's own chip, + * not listed here. + */ + +export interface PanelActions { + primary: SolidAction; + more: SolidAction[]; +} + +export function panelActions(a: LabArtifact): PanelActions { + const download = (label: string): SolidAction => ({ id: "download", label, icon: Download, href: `#download-${a.id}` }); + const openFile: SolidAction = { id: "preview", label: "Open file", icon: ExternalLink, href: `#file-${a.id}`, external: true }; + const openTab: SolidAction = { id: "tab", label: "Open in tab", icon: PanelRightOpen, href: `#attachment-${a.id}` }; + + if (isTaskDocument(a)) { + return { primary: { id: "document", label: "Read document", icon: PanelRightOpen, href: `#document-${slug(a)}` }, more: [] }; + } + if (a.kind === "link") { + return { primary: { id: "preview", label: "Open link", icon: ExternalLink, href: a.url ?? "#", external: true }, more: [] }; + } + if (a.kind === "image") return { primary: { id: "gallery", label: "View image", icon: Maximize2 }, more: [] }; + if (a.kind === "video") return { primary: { id: "gallery", label: "Open video", icon: Maximize2 }, more: [] }; + if (a.kind === "document") { + // A Markdown file an agent attached: reads in place, with the product's three ways out. + return { + primary: { id: "document", label: "Read document", icon: ChevronDown }, + more: [openTab, { id: "preview", label: "Open raw", icon: ExternalLink, href: `#raw-${a.id}`, external: true }, download("Download")], + }; + } + if (isTextLike(a)) { + const data: SolidAction[] = /\.csv$/i.test(a.filename ?? "") ? [{ id: "tab", label: "Preview data", icon: Table2 }] : []; + return { primary: openTab, more: [...data, openFile, download("Download file")] }; + } + return { primary: download("Download file"), more: [openFile] }; +} diff --git a/ui/src/artifact-directions/directions/d1-solid/panel-model.ts b/ui/src/artifact-directions/directions/d1-solid/panel-model.ts new file mode 100644 index 00000000000..5f21c57174a --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/panel-model.ts @@ -0,0 +1,63 @@ +import type { LabArtifact } from "../../kit"; +import { isTaskDocument } from "./kinds"; + +/** + * How the panel is sectioned and ordered: exactly as the product does it. + * + * Work products are grouped by the run that produced them (agent + run start); + * task documents, which no run produced, are a section of their own. Sections + * and the cards inside them keep the order the product lists them in — Solid + * changes how an artifact looks, not where it is found. + */ + +export interface PanelSection { + id: string; + /** The producing agent, or a name for what was not produced by a run. */ + title: string; + /** The agent every artifact in the section shares, when it is a run. A card repeats an author only when it differs. */ + agent?: string; + /** "Sep 28, 2026, 10:52 AM" — a run's start. */ + startedLabel?: string; + startedAt?: string; + /** "artifact", or "document" when the section holds task documents only. */ + noun: string; + items: LabArtifact[]; +} + +export function panelSections(artifacts: LabArtifact[]): PanelSection[] { + // A Map keeps insertion order: sections appear where their first artifact does, cards as listed. + const byRun = new Map(); + for (const artifact of artifacts) { + const key = artifact.run?.id ?? ""; + byRun.set(key, [...(byRun.get(key) ?? []), artifact]); + } + return [...byRun.entries()].map(([runId, items]): PanelSection => { + const run = items[0].run; + const documentsOnly = items.every(isTaskDocument); + return { + id: runId || "loose", + title: run ? run.agent : documentsOnly ? "Task documents" : "Other artifacts", + agent: run?.agent, + startedLabel: run?.startedLabel, + startedAt: run?.startedAt, + noun: !run && documentsOnly ? "document" : "artifact", + items, + }; + }); +} + +/** + * A timestamp for a narrow row: the product's label without the year when it + * is this year ("Sep 28, 11:07 AM"). The full label stays in the tooltip. + */ +export const shortStamp = (label: string) => label.replace(`, ${new Date().getFullYear()}`, ""); + +const day = (label: string) => label.slice(0, label.lastIndexOf(", ")); + +/** + * A card's stamp under a section header that already carries a date: the time + * alone when the day is the section's ("11:07 AM"), the short stamp otherwise. + */ +export function stampUnder(label: string, sectionLabel: string | undefined): string { + return sectionLabel && day(label) === day(sectionLabel) ? label.slice(label.lastIndexOf(", ") + 2) : shortStamp(label); +} diff --git a/ui/src/artifact-directions/directions/d1-solid/slab.ts b/ui/src/artifact-directions/directions/d1-solid/slab.ts new file mode 100644 index 00000000000..b4b8cfb084c --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/slab.ts @@ -0,0 +1,73 @@ +/** + * SIGNATURE 3 — the slab: one clear fill step off the canvas, one radius + * (`rounded-md`, used by every slab, block, sheet, button and notch in the + * set), no stroke, no shadow. + * + * Fill logic, shared by all three surfaces (values in tokens.css): + * slab the artifact itself + * slab-hover a slab that is a target, hovered or holding keyboard focus; slab-press while it is pressed + * button a resting button on a slab: a step past the slab, away from the canvas + * sheet the artifact's own content (an excerpt, a file's facts): the lightest fill short of ink + * ink the kind block and tab; the primary action of a hovered slab, and of an artifact that waits for review + */ + +export const MOTION = "transition-colors duration-(--motion-duration-fast) ease-(--motion-ease-standard) motion-reduce:transition-none"; + +/** A resting slab. Add `group/slab` + SLAB_HOVER when the whole slab is a target. */ +export const SLAB = `rounded-md bg-(--solid-slab) ${MOTION}`; +/** A slab that is a target steps one fill when hovered or when its primary holds keyboard focus, and one more while pressed. */ +export const SLAB_HOVER = + "hover:bg-(--solid-slab-hover) has-[[data-primary]:focus-visible]:bg-(--solid-slab-hover) active:bg-(--solid-slab-press)"; + +/** + * For anything laid over a preview that must stay the colour of the slab under + * it (a corner bite, the ring round an ink tab): it takes the slab's fill and + * follows the same three steps. + */ +export const SLAB_FILL = + "bg-(--solid-slab) group-hover/slab:bg-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:bg-(--solid-slab-hover) group-active/slab:bg-(--solid-slab-press)"; +export const SLAB_RING = + "ring-2 ring-(--solid-slab) group-hover/slab:ring-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:ring-(--solid-slab-hover) group-active/slab:ring-(--solid-slab-press)"; +/** The same for `currentColor` on the two pseudo-elements of a corner bite (its joints, StateMarker.tsx). */ +export const SLAB_JOINTS = + "before:text-(--solid-slab) after:text-(--solid-slab) group-hover/slab:before:text-(--solid-slab-hover) group-hover/slab:after:text-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:before:text-(--solid-slab-hover) group-has-[[data-primary]:focus-visible]/slab:after:text-(--solid-slab-hover) group-active/slab:before:text-(--solid-slab-press) group-active/slab:after:text-(--solid-slab-press)"; + +/** Keyboard focus: a 2px ink outline, 2px off the slab so it reads against both the slab and the canvas. */ +export const FOCUS = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--solid-ink)"; + +/** + * For the one element that makes a whole slab clickable: its `::after` covers + * the slab (which must be `relative`) and carries the focus outline. + */ +export const COVER = + "after:absolute after:inset-0 after:rounded-md focus-visible:outline-none focus-visible:after:outline-2 focus-visible:after:outline-offset-2 focus-visible:after:outline-(--solid-ink)"; + +/** A filled button on a slab. */ +export const BUTTON = `inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-md bg-(--solid-button) px-2 text-xs font-medium whitespace-nowrap text-foreground ${MOTION}`; + +/** Ink at rest: the primary of an artifact that waits for review, so "this needs you" reads without a hover. */ +const BUTTON_INK = "bg-(--solid-ink) text-(--solid-ink-foreground)"; + +/** The button's own hover and press, for a button that is not a slab's cover. */ +export const BUTTON_HOVER = "hover:bg-(--solid-ink) hover:text-(--solid-ink-foreground) active:bg-(--solid-ink) active:text-(--solid-ink-foreground)"; + +/** + * The primary action of a `group/slab` (mark it `data-primary`): turns to ink + * when the slab is hovered or the action holds keyboard focus, and goes back + * to the button fill while the pointer is on a secondary control + * (`data-secondary`), which takes the ink instead. + */ +export const BUTTON_PRIMARY = + "group-hover/slab:bg-(--solid-ink) group-hover/slab:text-(--solid-ink-foreground) focus-visible:bg-(--solid-ink) focus-visible:text-(--solid-ink-foreground) group-has-[[data-secondary]:hover]/slab:bg-(--solid-button) group-has-[[data-secondary]:hover]/slab:text-foreground"; + +/** The primary's two looks at rest. */ +export const primaryRest = (waiting: boolean) => (waiting ? BUTTON_INK : null); + +/** A secondary control on a slab: no fill at rest, ink on hover. Sits above the cover. Mark it `data-secondary`. */ +const GHOST = `relative z-10 inline-flex h-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-(--solid-meta) ${MOTION} ${BUTTON_HOVER} ${FOCUS}`; + +/** The icon-only ghost: 28px square. */ +export const ICON_BUTTON = `${GHOST} w-7`; + +/** A ghost that carries a short mono value beside its icon (the panel's comments count). */ +export const COUNT_BUTTON = `${GHOST} gap-1 px-1.5 font-mono text-(length:--text-micro) leading-none`; diff --git a/ui/src/artifact-directions/directions/d1-solid/tokens.css b/ui/src/artifact-directions/directions/d1-solid/tokens.css new file mode 100644 index 00000000000..2f96ef2a9b8 --- /dev/null +++ b/ui/src/artifact-directions/directions/d1-solid/tokens.css @@ -0,0 +1,111 @@ +/* + Direction 1 — SOLID. Custom properties, scoped to the wrapper every frame puts + around a kit slot. Everything here is achromatic: the only chroma in the + direction is the product's own status vocabulary (see StateMarker.tsx). + + The fill ladder, by value only (OKLCH lightness; canvas is --background). + Each step is named for what it is, and each is a visible step from the one + it sits on (contrast in brackets, Y = L³ for greys): + + light canvas 1.000 → slab 0.900 [1.35] → slab-hover 0.865 → slab-press 0.830 + button 0.800 [1.39 on slab] sheet 1.000 [1.35 on slab] + dark canvas 0.205 → slab 0.325 [1.44] → slab-hover 0.365 → slab-press 0.400 + button 0.400 [1.35 on slab] sheet 0.430 [1.54 on slab] + + SHEET is where an artifact's own content is set (an excerpt, a file's facts): + the lightest fill short of ink in both themes — paper on a mat. + + A gallery pile steps the other way: what is behind a stack's face is nearer + the canvas, in both themes. + + light slab 0.900 → pile-1 0.920 → pile-2 0.945 + dark slab 0.325 → pile-1 0.290 → pile-2 0.265 + + and one inverted fill, INK, for the kind block, the kind tab, and the primary + action of a hovered slab or of an artifact that waits for review: + + light ink 0.205 / on-ink 0.985 + dark ink 0.800 / on-ink 0.205 (silver, not white: it must not out-shout the prose) + + Text contrast, all ≥ 4.5:1: + --solid-meta on slab light 5.8 dark 6.2 + --solid-meta on slab-hover light 5.2 dark 5.3 + --solid-meta on pile-1 light 6.1 dark 7.1 + --solid-sheet-text on sheet light 10.4 dark 6.4 + --foreground on sheet light 19.3 dark 7.8 + --foreground on button light 10.3 dark 8.8 + --solid-ink-foreground on ink light 17.2 dark 9.6 +*/ + +[data-direction="d1-solid"] { + /* Fills */ + --solid-slab: oklch(0.9 0 0); + --solid-slab-hover: oklch(0.865 0 0); + --solid-slab-press: oklch(0.83 0 0); + --solid-button: oklch(0.8 0 0); + --solid-sheet: oklch(1 0 0); + --solid-ink: oklch(0.205 0 0); + /* A gallery pile: the slabs behind a stack's face, each one step nearer the canvas. */ + --solid-pile-1: oklch(0.92 0 0); + --solid-pile-2: oklch(0.945 0 0); + + /* Text on those fills */ + --solid-ink-foreground: oklch(0.985 0 0); + /* Secondary text on a slab or a pile. --muted-foreground is 3.4:1 on the light slab. */ + --solid-meta: oklch(0.44 0 0); + /* Body text on a sheet: a step under --foreground, well over the meta tone. */ + --solid-sheet-text: oklch(0.37 0 0); + + /* State dots: the product's status hues. */ + --solid-state-review: var(--status-task-icon-in_review); + --solid-state-changes: var(--status-task-icon-blocked); + + /* Geometry */ + /* The kind block and every gallery preview: one landscape plate (72×44 in a thread row). */ + --solid-plate-ratio: 18 / 11; + /* A picture or a video leading a panel card. */ + --solid-lead-ratio: 16 / 9; + /* The attachments block: as many equal slabs as fit, never narrower than 16rem; a file is one 52px row, a picture two. */ + --solid-attach-cols: repeat(auto-fill, minmax(16rem, 1fr)); + --solid-attach-row: 3.25rem; + /* The panel's cards: one column in the docked pane; a maximized pane sets as many 22rem columns as fit, row by row. */ + --solid-panel-cols: repeat(auto-fill, minmax(min(22rem, 100%), 1fr)); + /* + Text on a gallery sheet: as many WHOLE lines as the sheet has room for above + the band kept clear for the kind tab. Resolved where it is used, against + the sheet as a size container: 100cqh is the sheet inside its own inset, so + taking 1rem more off leaves a band of that inset + 1rem under the text + (32px on a plate, 24px in a mosaic cell). + */ + --solid-sheet-fit: round(down, calc(100cqh - 1rem), 1lh); + /* + A joint of a corner bite: a 6px box with one rounded corner, whose shadow + (its own shape grown by 3px and moved 3px right and down, so it starts at + the box's own top-left) paints only what lies outside the quarter circle. + The colour is the box's currentColor — the slab's fill, at any step. + */ + --solid-joint: 0.1875rem 0.1875rem 0 0.1875rem currentColor; +} + +.dark [data-direction="d1-solid"] { + --solid-slab: oklch(0.325 0 0); + --solid-slab-hover: oklch(0.365 0 0); + --solid-slab-press: oklch(0.4 0 0); + --solid-button: oklch(0.4 0 0); + --solid-sheet: oklch(0.43 0 0); + --solid-ink: oklch(0.8 0 0); + --solid-pile-1: oklch(0.29 0 0); + --solid-pile-2: oklch(0.265 0 0); + + --solid-ink-foreground: oklch(0.205 0 0); + --solid-meta: oklch(0.78 0 0); + --solid-sheet-text: oklch(0.92 0 0); + + /* The product's blocked red (#dc2626) is 2.6:1 on the dark slab; a 6px dot that stands for a state needs 3:1. */ + --solid-state-changes: oklch(0.704 0.191 22.216); +} + +/* A gallery sheet is a size container, so its text can be fitted to whole lines of its own height (--solid-sheet-fit). */ +[data-direction="d1-solid"] [data-solid-sheet="fit"] { + container-type: size; +} diff --git a/ui/src/artifact-directions/directions/d2-tint/Content.tsx b/ui/src/artifact-directions/directions/d2-tint/Content.tsx new file mode 100644 index 00000000000..6b5ae59fabb --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/Content.tsx @@ -0,0 +1,138 @@ +import { Fragment, useLayoutEffect, useRef, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { documentExcerpt, sheetPreview } from "./excerpt"; + +/** + * What is INSIDE a text-like artifact, as bare content: the caller supplies + * the sheet it sits on (`FoldedSheet`) and the type size. The panel, the + * gallery tile and a folder's face set the same three things at three sizes. + * + * ProseExcerpt a document's opening in two tiers: at most one heading on + * its own line (foreground, medium), body lines in secondary + * ink, list items each on a line behind a dash. One clamped + * block, so it always ends on a whole line with an ellipsis. + * SheetTable header + first rows, monospace; the first column truncates, + * value columns keep their width; columns that do not fit the + * measured width are counted, never clipped + * CodeLines the first lines, monospace, each cut with an ellipsis + */ + +export function ProseExcerpt({ artifact, maxChars, className }: { artifact: LabArtifact; maxChars?: number; className?: string }) { + const lines = documentExcerpt(artifact, maxChars); + if (lines.length === 0) return null; + return ( + // Inline content with explicit breaks, so the line clamp counts every line of every tier. +

+ {lines.map((line, index) => ( + + {index > 0 ?
: null} + {line.heading ? {line.text} : line.item ? <>– {line.text} : line.text} +
+ ))} +

+ ); +} + +/** How many monospace cells fit across an element, re-measured when it resizes. */ +function useCells() { + const ref = useRef(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(); + 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 ( +
+
+ + + {preview.header.map((cell, index) => ( + + ))} + + + + {preview.rows.map((row, r) => ( + + {row.map((cell, index) => ( + + ))} + + ))} + +
+ {cell} +
+ {cell} +
+ {more.length > 0 ?

{more.join(" · ")}

: null} +
+ ); +} + +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 ( +
+      {artifact.excerpt
+        .split("\n")
+        .slice(0, lines)
+        .map((line, index) => (
+          
+            {line || " "}
+          
+        ))}
+    
+ ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/FileName.tsx b/ui/src/artifact-directions/directions/d2-tint/FileName.tsx new file mode 100644 index 00000000000..51f55071ae8 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/FileName.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils"; + +/** + * A filename is a machine value: monospace. When it does not fit, the stem + * truncates and the extension never does — two files that share a long stem + * (`acmehats-interface-craft.pptx` / `.pdf`) stay tellable apart. + * + * `mono={false}` is for a human title standing in where a file has no name. + */ +export function FileName({ name, mono = true, className }: { name: string; mono?: boolean; className?: string }) { + const dot = mono ? name.lastIndexOf(".") : -1; + const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""]; + return ( + + {stem} + {ext ? {ext} : null} + + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx new file mode 100644 index 00000000000..f85455df36e --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/GalleryPreview.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { CodeLines, ProseExcerpt, SheetTable } from "./Content"; +import { FileName } from "./FileName"; +import { FoldedPreview, FoldedSheet, KindWell } from "./Lead"; +import { hasTextPreview, headlineFact, linkLabel } from "./kinds"; + +/** + * GALLERY — the preview of one artifact, the largest object a tile has, by + * the one rule (Lead.tsx). Never an empty box: + * + * picture image, video poster, link screenshot — fold lg, extension tab, + * play mark and duration on a video + * sheet document, Markdown, sheet, code — its own text at full width on + * a neutral sheet in the same frame (fold lg, tab). Clamped to + * whole lines for the tile's width, so nothing is sliced. + * named PDF, slides, archive, generic file, or a picture that failed — + * the lg glyph on its kind well with what is known beside it: the + * countable fact as a headline ("12 slides", else the size), what + * the agent said is inside, and the filename. This replaces + * today's empty box that says FILE. + * + * The box is 16:9 and the tile is its `@container`. `depth="short"` is the + * shallower box of a single-artifact stack, whose height the caller sets. + */ + +type PreviewKind = "picture" | "sheet" | "named"; +type PreviewDepth = "tile" | "short"; + +/** Which preview an artifact gets, and the handler that turns a failed picture into a named one. */ +export function usePreviewKind(artifact: LabArtifact): [PreviewKind, () => void] { + const [failed, setFailed] = useState(false); + const kind: PreviewKind = artifact.thumbnailUrl && !failed ? "picture" : hasTextPreview(artifact) ? "sheet" : "named"; + return [kind, () => setFailed(true)]; +} + +const BOX = "aspect-video w-full"; +const TEXT = "text-(length:--text-compact) leading-5"; +const MONO = "text-(length:--text-micro) leading-5"; +// Whole lines that fit a 16:9 sheet, by the tile's width: 20px lines under a 24px head, above the tab. +const CLAMP: Record = { tile: "line-clamp-3 @3xs:line-clamp-4 @2xs:line-clamp-5 @xs:line-clamp-6 @sm:line-clamp-8", short: "line-clamp-4" }; +const CODE_STEP = ["", "", "", "hidden @3xs:block", "hidden @2xs:block", "hidden @xs:block", "hidden @sm:block", "hidden @sm:block"]; +const CODE_LINES: Record = { tile: CODE_STEP.length, short: 4 }; + +/** Data rows that fit a 16:9 sheet whose text column is `width` wide: its lines, less the header and the "+N more" note. */ +function rowsThatFit(width: number): number { + const SIDE = 28; // the sheet's two side insets + const HEAD_AND_TAB = 44; // top inset + the tab's height + const lines = Math.floor((((width + SIDE) * 9) / 16 - HEAD_AND_TAB) / 20); + return Math.max(1, lines - 2); +} + +function Sheet({ artifact, depth, className }: { artifact: LabArtifact; depth: PreviewDepth; className?: string }) { + return ( + + {artifact.kind === "document" ? ( + + ) : artifact.kind === "sheet" ? ( + + ) : ( + + )} + + ); +} + +function Named({ artifact, depth, className }: { artifact: LabArtifact; depth: PreviewDepth; className?: string }) { + const headline = headlineFact(artifact); + const address = linkLabel(artifact.url); + const words = Boolean(headline ?? artifact.summary ?? artifact.filename ?? address); + const small = "relative z-10 mt-1.5 cursor-text select-text text-(length:--text-micro) leading-4 text-(--tint-ink-2)"; + return ( + + {words ? ( +
+ {/* A count is a machine value: the mono voice, at headline size. */} + {headline ?

{headline}

: null} + {artifact.summary ? ( +

+ {artifact.summary} +

+ ) : null} + {artifact.filename ? ( + + ) : address ? ( + + {address} + + ) : null} +
+ ) : null} +
+ ); +} + +const DURATION = "absolute bottom-2 right-2 rounded-(--rad-4) bg-(--tint-scrim) px-1.5 py-1 font-mono text-(length:--text-micro) leading-none text-(--tint-on-kind)"; + +export function GalleryPreview({ + artifact, + kind, + onError, + depth = "tile", + className, +}: { + artifact: LabArtifact; + kind: PreviewKind; + onError: () => void; + depth?: PreviewDepth; + className?: string; +}) { + if (kind === "sheet") return ; + if (kind === "named") return ; + return ( + + {artifact.kind === "video" && artifact.durationLabel ? {artifact.durationLabel} : null} + + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx new file mode 100644 index 00000000000..22dc179ebfa --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/GalleryStack.tsx @@ -0,0 +1,66 @@ +import { useId } from "react"; +import type { LabGroup } from "../../kit"; +import { GalleryTile } from "./GalleryTile"; +import { KindTally } from "./KindTally"; +import { MetaLine } from "./MetaLine"; +import { ReviewTally } from "./StateMark"; +import { artifactCount, stackSections, type StackSection } from "./gallery-model"; +import { countReview } from "./panel-model"; + +/** + * GALLERY — one opened stack: everything below the "← All stacks" row. + * + * caption what the folder's pocket said, as one line of type on the + * page: the kinds spelled out with their mini glyphs, how much + * is waiting, the count and the updated date. A caption, not a + * control — it has no fill, so it cannot be mistaken for a second + * row of the page's own filter tabs right above it. + * sections the artifacts by the day of their last edit, newest first; a + * day that cannot fill a row shares its heading with its quiet + * neighbours (gallery-model.ts), so headings do not strand tiles + * tiles GalleryTile.tsx, three to a row on the page's grid + */ + +function Caption({ group }: { group: LabGroup }) { + return ( +
+ + + +
+ ); +} + +function Section({ section }: { section: StackSection }) { + const headingId = useId(); + return ( +
+
+

+ {section.label} +

+ {artifactCount(section.artifacts.length)} +
+
    + {section.artifacts.map((artifact) => ( + + ))} +
+
+ ); +} + +export function GalleryStack({ group }: { group: LabGroup }) { + if (group.artifacts.length === 0) { + // The shipped empty state's copy. + return

No artifacts in this stack match the current filters.

; + } + return ( +
+ + {stackSections(group.artifacts).map((section) => ( +
+ ))} +
+ ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx new file mode 100644 index 00000000000..c7dac4b47ca --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/GalleryStacks.tsx @@ -0,0 +1,22 @@ +import type { LabGroup } from "../../kit"; +import { StackFolder } from "./StackFolder"; +import { StackSingle } from "./StackSingle"; + +/** + * GALLERY — the grid of task stacks under the page toolbar. + * + * The page's own grid (1 / 2 / 3 columns, 24px gap). A stack of two or more + * is a folder; a stack of one is the artifact itself. Every unit in a row + * takes the row's height, so the pockets and the single tiles end level. + */ +export function GalleryStacks({ groups }: { groups: LabGroup[] }) { + if (groups.length === 0) { + // The shipped empty state's copy. + return

No artifact stacks yet.

; + } + return ( +
    + {groups.map((group) => (group.count > 1 ? : ))} +
+ ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx b/ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx new file mode 100644 index 00000000000..d5121fb1485 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/GalleryTile.tsx @@ -0,0 +1,70 @@ +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { GalleryPreview, usePreviewKind } from "./GalleryPreview"; +import { MetaLine } from "./MetaLine"; +import { StateMark } from "./StateMark"; +import { SLAB, SecondaryAction, TitleAction } from "./UnitAction"; +import { galleryAction, gallerySecondary } from "./actions"; +import { headlineFact, metaParts, shortDateTime, timeOnly } from "./kinds"; + +/** + * GALLERY — one artifact in an opened stack. + * + * The panel card made preview-first for every kind: the same filled slab with + * the same 8px inset, the lead across the whole width, then three lines — + * + * ┌───────────── preview, 16:9 ──────────────┐ + * │ folded picture · text sheet · kind well │ + * └──────────────────────────────────────────┘ + * Title one line + * kind · size · rev · fact ○ Review the mono meta line + * Author · 11:07 AM ↗ ↓ icon actions, always visible + * + * The title is the link (stretched over the tile); it goes to the artifact in + * its task, as today's whole-card link does. "Open file in new tab" and + * "Download file" are their own targets, visible at rest and reachable by + * keyboard — today they exist only on hover. + * + * A fact the preview already states (a video's duration chip, a kind well's + * headline) is not repeated on the meta line. The author truncates before the + * time does. Under a day heading the tile shows the time; under a heading + * that spans days (`dated`) it shows the date too. + */ +export function GalleryTile({ artifact, dated = false }: { artifact: LabArtifact; dated?: boolean }) { + const [kind, onError] = usePreviewKind(artifact); + const secondary = gallerySecondary(artifact); + const stated = kind === "named" ? headlineFact(artifact) : kind === "picture" ? artifact.durationLabel : undefined; + return ( +
  • + +
    +

    + + {artifact.title} + +

    + {/* Every tile keeps the height of a state mark here, so a row of tiles stays level whether or not one is waiting. */} +
    + + +
    +
    +

    + {artifact.author} + · + +

    + {secondary.length > 0 ? ( +
    + {secondary.map((action, index) => ( + + ))} +
    + ) : null} +
    +
    +
  • + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx b/ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx new file mode 100644 index 00000000000..3975c8f9bcb --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/KindGlyph.tsx @@ -0,0 +1,127 @@ +import { cn } from "@/lib/utils"; +import type { LabKind } from "../../kit"; +import { Pictogram } from "./Pictogram"; + +/** + * SIGNATURE ELEMENT 1 — the kind glyph. + * + * A rounded tile with a folded top-right corner (a flat, lighter facet of the + * same hue — never a shadow), a pictogram top-left and the real extension in + * bold along the bottom. Solid `--kind` fill, flat. It is the one place a + * unit carries its hue at full strength. + * + * mini 16×18 pictogram only — index chips, kind tallies + * xs 34×38 thread: attachment slabs, and row leads on their tinted well + * sm 40×46 spare (specimen) + * md 56×64 panel leads on their tinted well; narrow gallery tiles + * lg 96×110 gallery tiles with nothing to show + * tuck 96×190 standing in a folder (StackFolder.tsx): the same top — fold, + * pictogram, label — on a tile tall enough that its bottom + * edge is always behind the pocket + * + * Without a `label` (a task document: it has no extension) the pictogram + * stands alone, larger and centred — the mini glyph's composition at size. + * + * The ancestor that sets `data-kind` supplies `--kind`, `--kind-facet` and + * `--kind-ink` (tokens.css); pass `kind` here only for the pictogram. + */ + +export type GlyphSize = "mini" | "xs" | "sm" | "md" | "lg" | "tuck"; + +interface Placement { + x: number; + y: number; + size: number; +} + +interface Spec { + w: number; + h: number; + /** Corner radius and fold size, in the tile's own px. */ + r: number; + fold: number; + picto: Placement; + /** The pictogram when there is no label to share the tile with. */ + solo: Placement; + label: { size: number; baseline: number; inset: number } | null; + box: string; +} + +const MINI: Placement = { x: 3.2, y: 5.6, size: 9.6 }; +const SPEC: Record = { + mini: { w: 16, h: 18, r: 3.5, fold: 5, picto: MINI, solo: MINI, label: null, box: "w-4 h-4.5" }, + xs: { w: 34, h: 38, r: 6, fold: 9.5, picto: { x: 4.5, y: 4.5, size: 10 }, solo: { x: 7.5, y: 12, size: 19 }, label: { size: 10, baseline: 32.2, inset: 3 }, box: "w-8.5 h-9.5" }, + sm: { w: 40, h: 46, r: 7, fold: 11.5, picto: { x: 5.5, y: 5.5, size: 14 }, solo: { x: 9, y: 14.5, size: 22 }, label: { size: 10.5, baseline: 39.2, inset: 4 }, box: "w-10 h-11.5" }, + md: { w: 56, h: 64, r: 9.5, fold: 16, picto: { x: 8, y: 8, size: 18 }, solo: { x: 12.5, y: 20, size: 31 }, label: { size: 14, baseline: 54.4, inset: 6 }, box: "w-14 h-16" }, + lg: { w: 96, h: 110, r: 16, fold: 27, picto: { x: 14, y: 14, size: 30 }, solo: { x: 22, y: 35, size: 52 }, label: { size: 23.5, baseline: 93.5, inset: 10 }, box: "w-24 h-27.5" }, + tuck: { w: 96, h: 190, r: 16, fold: 27, picto: { x: 13, y: 13, size: 26 }, solo: { x: 24, y: 30, size: 48 }, label: { size: 22, baseline: 68, inset: 10 }, box: "w-24 h-47.5" }, +}; + +/** The tile with its top-right corner cut on the diagonal. */ +function tilePath({ w, h, r, fold }: Spec): string { + const k = r * 0.45; // softens the two ends of the cut + return [ + `M${r} 0`, + `H${w - fold - k}`, + `Q${w - fold} 0 ${w - fold + k * 0.7} ${k * 0.7}`, + `L${w - k * 0.7} ${fold - k * 0.7}`, + `Q${w} ${fold} ${w} ${fold + k}`, + `V${h - r}`, + `Q${w} ${h} ${w - r} ${h}`, + `H${r}`, + `Q0 ${h} 0 ${h - r}`, + `V${r}`, + `Q0 0 ${r} 0Z`, + ].join(""); +} + +/** The folded flap: the cut-off triangle laid back onto the tile, inner corner rounded. */ +function facetPath({ w, r, fold }: Spec): string { + const k = r * 0.45; + const c = Math.min(r * 0.7, fold * 0.4); + return [ + `M${w - fold} ${k * 0.6}`, + `Q${w - fold} 0 ${w - fold + k * 0.7} ${k * 0.7}`, + `L${w - k * 0.7} ${fold - k * 0.7}`, + `Q${w} ${fold} ${w - k * 0.6} ${fold}`, + `H${w - fold + c}`, + `Q${w - fold} ${fold} ${w - fold} ${fold - c}Z`, + ].join(""); +} + +// Inter Bold capitals, as a fraction of the font size — only to decide when a label must be squeezed. +const WIDE: Record = { W: 0.99, M: 0.9, O: 0.78, Q: 0.78, G: 0.76, C: 0.73, D: 0.74, H: 0.74, N: 0.75, U: 0.73, I: 0.29, J: 0.56, L: 0.59, F: 0.61, E: 0.63, T: 0.64 }; +const labelWidth = (label: string, size: number) => [...label].reduce((sum, ch) => sum + (WIDE[ch] ?? 0.68), 0) * size; + +export function KindGlyph({ kind, label, size = "sm", className }: { kind: LabKind; label?: string; size?: GlyphSize; className?: string }) { + const spec = SPEC[size]; + const text = spec.label && label ? label : null; + const room = spec.label ? spec.w - spec.label.inset * 2 : 0; + const squeeze = text && spec.label && labelWidth(text, spec.label.size) > room; + return ( + + + + + {text && spec.label ? ( + + {text} + + ) : null} + + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/KindTally.tsx b/ui/src/artifact-directions/directions/d2-tint/KindTally.tsx new file mode 100644 index 00000000000..b3c3a894a36 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/KindTally.tsx @@ -0,0 +1,31 @@ +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { KindGlyph } from "./KindGlyph"; +import { kindNoun, kindNounInline, tallyKinds } from "./panel-model"; + +/** + * What is in a pile, as a CAPTION: one entry per kind present, in the + * vocabulary's order — the mini glyph and a mono count. Never a control: it + * has no fill and no button shape (the panel's index, which filters, is built + * from buttons — PanelIndex.tsx). + * + * `named` spells each kind out ("10 documents"), where there is a line to + * itself — the caption of an opened stack. + */ +export function KindTally({ artifacts, named = false, className }: { artifacts: LabArtifact[]; named?: boolean; className?: string }) { + const kinds = tallyKinds(artifacts); + if (kinds.length === 0) return null; + return ( +
      + {kinds.map(({ kind, count }) => ( +
    • + + + {count} + {named ? {kindNounInline(kind, count)} : {kindNoun(kind, count)}} + +
    • + ))} +
    + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/Lead.tsx b/ui/src/artifact-directions/directions/d2-tint/Lead.tsx new file mode 100644 index 00000000000..bb1d3cd962d --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/Lead.tsx @@ -0,0 +1,146 @@ +import { useState, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { KindGlyph, type GlyphSize } from "./KindGlyph"; +import { glyphLabel } from "./kinds"; + +/** + * THE ONE RULE — the lead of every unit, on every surface, is one of two things. + * + * SHOWN the artifact's own content wearing the kind's two marks: the top- + * right corner folded back as a solid kind-hue flap, and the + * extension as a hue tab on the bottom-left edge. A picture + * (`FoldedPreview`) or its text on a neutral sheet (`FoldedSheet`). + * NAMED when there is nothing to show, or no room to show it: the kind + * glyph centred on a well filled with the kind's quiet hue (`KindWell`). + * + * So colour is the fold and tab on real content, or the glyph and its well — + * never both, and nowhere else on the unit at rest. A missing or failed + * picture falls back to NAMED, so the placeholder is never an empty box. + */ + +type FoldSize = "sm" | "md" | "lg"; +/** Where the extension tab sits. `top` is for a folder, where the bottom edge is tucked behind the pocket. */ +type TabEdge = "bottom" | "top" | "none"; + +// The line-height sits AFTER the size in `tab`: tailwind-merge drops a leading that precedes a font size. +// `sheet` pads a text sheet so its first line clears the fold; `room` is the extra a tab needs on its edge. +const FOLD: Record }> = { + sm: { clip: "var(--tint-clip-fold-sm)", flap: "size-(--tint-fold-sm)", tab: "px-1 py-0.5 text-(length:--text-nano) leading-none", play: "top-(--tint-play-y-sm) size-5", sheet: "px-2 pt-2.5 pb-2", room: { bottom: "pb-5", top: "pt-5" } }, + md: { clip: "var(--tint-clip-fold-md)", flap: "size-(--tint-fold-md)", tab: "px-1.5 py-1 text-(length:--text-micro) leading-none", play: "top-1/2 size-9", sheet: "px-3 pt-4 pb-3", room: { bottom: "pb-7", top: "pt-7" } }, + lg: { clip: "var(--tint-clip-fold-lg)", flap: "size-(--tint-fold-lg)", tab: "px-2 py-1 text-xs leading-none", play: "top-1/2 size-11", sheet: "px-3.5 pt-6 pb-3.5", room: { bottom: "pb-8", top: "pt-8" } }, +}; + +const TAB_EDGE = { bottom: "bottom-0 rounded-tr-(--rad-4)", top: "top-0 rounded-br-(--rad-4)" } as const; + +/** The folded flap: the cut-off corner laid back, inner corner rounded. Square, scales with its box. */ +function FoldFlap({ className }: { className?: string }) { + return ( + + + + ); +} + +function Tab({ artifact, fold, edge }: { artifact: LabArtifact; fold: FoldSize; edge: TabEdge }) { + const label = glyphLabel(artifact); + if (!label || edge === "none") return null; + return {label}; +} + +/** The play mark on a video poster: white on a neutral scrim, never the kind hue. */ +export function PlayMark({ fold, className }: { fold: FoldSize; className?: string }) { + return ( + + + + + + ); +} + +interface FrameProps { + artifact: LabArtifact; + fold: FoldSize; + tab?: TabEdge; + className?: string; + children?: ReactNode; +} + +/** + * SHOWN, a picture. The caller sets the box (`className`) and picks the fold; + * the image fills the box and can never set its height. `fit="contain"` shows + * the whole picture on the neutral well (the panel, where it is judged). + * Children render over the image (a duration, a play button). + */ +export function FoldedPreview({ artifact, fold, tab = "bottom", fit = "cover", play = true, className, onError, children }: FrameProps & { fit?: "cover" | "contain"; play?: boolean; onError?: () => void }) { + // A cropped photograph keeps its middle; a cropped screenshot keeps its top-left, where a page starts. + const crop = artifact.kind === "link" ? "object-cover object-left-top" : "object-cover"; + return ( + + + {artifact.kind === "video" && play ? : null} + {children} + + + + ); +} + +/** + * SHOWN, text. A neutral sheet (a hole back to the page fill) holding the + * artifact's own words at full width, in the frame a picture wears. Children + * are the content (Content.tsx); anything that does not fit is clipped by the + * sheet, so the caller clamps to whole lines. + */ +export function FoldedSheet({ artifact, fold, tab = "bottom", className, children }: FrameProps) { + const spec = FOLD[fold]; + const tabbed = tab !== "none" && glyphLabel(artifact) ? spec.room[tab] : null; + return ( +
    + {children} + + +
    + ); +} + +/** NAMED. The glyph centred on its kind well; children sit beside it (a headline, a summary). */ +export function KindWell({ + artifact, + size = "md", + className, + glyphClassName, + children, +}: { + artifact: LabArtifact; + size?: GlyphSize; + className?: string; + /** Overrides the glyph's box (the SVG scales), e.g. a container-query step down in a narrow unit. */ + glyphClassName?: string; + children?: ReactNode; +}) { + return ( +
    + + {children} +
    + ); +} + +// 72×46: the one lead box of a thread row, so every title starts on the same edge. +const ROW_BOX = "h-11.5 w-18 shrink-0"; + +/** The lead of a thread row: a picture when there is one; otherwise the xs glyph on its well (no room to show text here). */ +export function Lead({ artifact }: { artifact: LabArtifact }) { + const [failed, setFailed] = useState(false); + if (!artifact.thumbnailUrl || failed) return ; + return setFailed(true)} />; +} diff --git a/ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx b/ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx new file mode 100644 index 00000000000..69c150591f8 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/MetaLine.tsx @@ -0,0 +1,29 @@ +import { Fragment } from "react"; +import { cn } from "@/lib/utils"; + +/** + * SIGNATURE ELEMENT 2 — the mono meta line. + * + * One grammar on every surface — kind · size · rev — set in the monospace + * token because every part of it is a machine value. Never coloured: the hue + * belongs to the lead. + * + * One line with an ellipsis by default. `wrap` lets it break between parts + * instead, where the product's rule is that facts wrap rather than disappear + * (a narrow pane, a narrow folder). + */ +export function MetaLine({ parts, wrap = false, className }: { parts: string[]; wrap?: boolean; className?: string }) { + return ( + + {parts.map((part, index) => ( + + {/* A full monospace " · " is three cells wide; the dot gets half-cell margins instead. */} + {index > 0 ? · : null} + {/* When wrapping, a part never breaks inside itself; the line breaks after a dot. */} + {index > 0 && wrap ? : null} + {part} + + ))} + + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx new file mode 100644 index 00000000000..4cdcdd35181 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/PanelBody.tsx @@ -0,0 +1,41 @@ +import { useMemo, useState } from "react"; +import type { LabArtifact } from "../../kit"; +import { PanelIndex } from "./PanelIndex"; +import { PanelSection } from "./PanelSection"; +import { buildSections, type PanelFilter } from "./panel-model"; + +/** + * PANEL — the whole scroll body of the task pane's Artifacts tab. + * + * index one row of filter buttons: All · Review · the kinds with two or + * more · "+N" (PanelIndex.tsx) + * sections producing runs and the task's documents, in the product's + * order — newest first — each saying how many of its items are + * waiting for review (panel-model.ts) + * cards one filled slab per artifact (PanelCard.tsx) + * + * A card is as tall as what it has to show: 136–208px for a file (the tall + * ones carry a three-line MIME type), 226–264 for a document, about 435 for a + * 4:3 picture — against today's 390–500 for every kind. The 21-artifact task + * is 5,400px of cards against today's 8,300. + * The filter is local state; in the product the index would stay pinned while + * the list scrolls. + */ +export function PanelBody({ artifacts }: { artifacts: LabArtifact[] }) { + const [filter, setFilter] = useState("all"); + const sections = useMemo(() => buildSections(artifacts, filter), [artifacts, filter]); + + if (artifacts.length === 0) { + // The shipped empty state, word for word. + return

    No artifacts yet. Work products, documents, and agent-produced files will appear here.

    ; + } + + return ( +
    + + {sections.map((section) => ( + + ))} +
    + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx new file mode 100644 index 00000000000..52c02a12ab2 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/PanelCard.tsx @@ -0,0 +1,120 @@ +import { Fragment, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { FileName } from "./FileName"; +import { MetaLine } from "./MetaLine"; +import { PanelNamed, PanelPicture, PanelSheet, panelLeadKind } from "./PanelLead"; +import { StateMark } from "./StateMark"; +import { ButtonAction, SLAB, SecondaryAction } from "./UnitAction"; +import { panelAction, panelSecondary } from "./actions"; +import { kindLabel, linkLabel, metaParts, shortDateTime } from "./kinds"; + +/** + * PANEL — one artifact on the task's shelf. One skeleton for every kind; the + * lead follows the one rule (PanelLead.tsx): + * + * SHOWN (picture, or text on a sheet) NAMED (nothing to show) + * ┌──────── lead, full width ────────┐ ┌ well ┐ title + * └──────────────────────────────────┘ │ glyph│ kind · size · fact ○ Review + * title │ │ summary + * kind · size · rev · fact ○ Review └──────┘ filename · MIME type + * summary · filename + * author · updated icon actions [Primary] (the same footer on both) + * + * Everything the shipped card prints is printed here — nothing lives only in + * a tooltip. Filename, address and MIME type sit above the card's stretched + * action so they can be selected and copied. Hover is neutral (the hue is + * spent on the lead): the slab steps up and the primary button inverts. + */ + +// Machine values a reviewer may want to copy: above the stretched link, selectable. +const COPYABLE = "relative z-10 cursor-text select-text"; +const SMALL = "mt-1 text-(length:--text-micro) leading-4 text-(--tint-ink-2)"; + +/** The product prints the MIME type on file cards — not on documents, pictures or links. */ +const showsMime = (a: LabArtifact) => Boolean(a.contentType) && a.kind !== "document" && a.kind !== "image" && a.kind !== "video" && a.kind !== "link"; + +/** A long MIME type wraps after its own separators, not in the middle of a word — and still copies as the plain string. */ +function breakable(value: string) { + return value.split(/(?<=[/.+-])/).map((part, index) => ( + + {index > 0 ? : null} + {part} + + )); +} + +function Byline({ artifact, grouped }: { artifact: LabArtifact; grouped: boolean }) { + return ( +

    + {/* Inside a run section the agent is the section's heading; only the edit time is the card's own. */} + {grouped ? null : {artifact.author}} + +

    + ); +} + +export function PanelCard({ artifact, grouped }: { artifact: LabArtifact; grouped: boolean }) { + const [failed, setFailed] = useState(false); + const lead = panelLeadKind(artifact, failed); + const shown = lead !== "named"; + const primary = panelAction(artifact); + const secondary = panelSecondary(artifact); + // A link's address is too long for the meta line at this width; it takes the filename's place. + const address = artifact.kind === "link" ? linkLabel(artifact.url) : undefined; + const meta = address ? [kindLabel(artifact)] : metaParts(artifact, { facts: true }); + const { filename } = artifact; + return ( +
  • + {lead === "picture" ? setFailed(true)} /> : lead === "sheet" ? : null} +
    + {lead === "named" ? : null} +
    + {/* Long titles wrap rather than truncate, as the shipped card's do; a third line in a narrow pane. */} +

    {artifact.title}

    + {/* The state mark shares the meta line when both fit and drops under it when they do not; neither truncates the other. */} +
    + + +
    + {artifact.summary ? ( +

    + {artifact.summary} +

    + ) : null} + {filename ? ( + + ) : address ? ( + + {address} + + ) : null} + {showsMime(artifact) ? ( + + {breakable(artifact.contentType ?? "")} + + ) : null} +
    +
    + {/* `mt-auto`: in a wide pane the cards sit two or three to a row and share its height. */} +
    + + {/* If the byline and the actions do not fit one row (a narrow pane), the actions wrap under it, still at the right. */} +
    + {secondary.map((action) => ( + + ))} + 0 ? "ml-1" : undefined} /> +
    +
    +
  • + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx new file mode 100644 index 00000000000..6e62dfbf1ec --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/PanelIndex.tsx @@ -0,0 +1,99 @@ +import { useState, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact, LabKind } from "../../kit"; +import { KindGlyph } from "./KindGlyph"; +import { StateDot } from "./StateMark"; +import { FOCUS_RING, MOTION } from "./UnitAction"; +import { countReview, kindNoun, tallyKinds, type KindTally, type PanelFilter } from "./panel-model"; + +/** + * PANEL — the shelf's index: a FILTER, and built to look like one. A row of + * buttons — All, then what is waiting for review (the ring the cards use), + * then the kinds that hold two or more artifacts (mini glyph + count), then + * one "+N" button that opens the rest. Held to one row at the pane's width, + * pressed or not; in a pane dragged narrow every kind folds into the "+N". + * + * One button is pressed at a time, and pressed is unmistakable without hue: + * the foreground inversion the primary button uses, with the kind's name + * spelled out ("Images 3"). The only colour in the row is the glyphs. + * + * (The same mini glyph and count, without the button, is a CAPTION on the + * Artifacts page — KindTally.tsx.) + */ + +const CHIP = cn("inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2 text-xs font-medium leading-none", MOTION, FOCUS_RING); +const REST = "bg-(--tint-slab) text-(--tint-ink-2) hover:bg-(--tint-slab-raised) hover:text-foreground active:bg-(--tint-button)"; +const PRESSED = "bg-foreground text-background active:bg-foreground/85"; + +function Chip({ pressed, onPress, name, kind, className, children }: { pressed?: boolean; onPress: () => void; name: string; kind?: LabKind; className?: string; children: ReactNode }) { + return ( + // The accessible name starts with the words on the button, so speech input can find it. + + ); +} + +// What one row holds at the pane's 434px: five kind chips at rest, three while one of them is spelled out. +const FRONT = 5; +const FRONT_PRESSED = 3; + +/** + * The kinds that get a chip in the row. Kinds with two or more lead; singles + * wait behind "+N", and so does the tail of the row while a pressed chip needs + * the room for its name. The pressed kind is always in the row. + */ +function frontKinds(kinds: KindTally[], filter: PanelFilter, open: boolean): KindTally[] { + if (open) return kinds; + const pressed = kinds.find((t) => t.kind === filter); + const room = pressed ? FRONT_PRESSED : FRONT; + const lead = kinds.filter((t) => t.count > 1 && t !== pressed).slice(0, pressed ? room - 1 : room); + return kinds.filter((t) => t === pressed || lead.includes(t)); +} + +const Count = ({ value }: { value: number }) => {value}; + +export function PanelIndex({ artifacts, filter, onFilter }: { artifacts: LabArtifact[]; filter: PanelFilter; onFilter: (filter: PanelFilter) => void }) { + const [open, setOpen] = useState(false); + const review = countReview(artifacts); + const kinds = tallyKinds(artifacts); + if (kinds.length < 2 && review === 0) return null; + const toggle = (next: PanelFilter) => onFilter(filter === next ? "all" : next); + const shown = frontKinds(kinds, filter, open); + const behind = kinds.length - shown.length; + return ( +
    + onFilter("all")} name={`All ${artifacts.length} artifacts`}> + All + + {review > 0 ? ( + toggle("review")} name={`Review ${review} waiting`}> + + Review + + ) : 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. + toggle(kind)} name={`${kindNoun(kind, count)} ${count}`} className={cn("gap-1 px-1.5", pressed || open ? null : "@max-xs/panel:hidden")}> + + {pressed ? kindNoun(kind, count) : null} + + ); + })} + {open ? null : ( + <> + {behind > 0 ? ( + setOpen(true)} name={`+${behind} more kinds`} className="@max-xs/panel:hidden"> + +{behind} + + ) : null} + setOpen(true)} name={`+${kinds.length} kinds`} className="hidden @max-xs/panel:inline-flex"> + +{kinds.length} kinds + + + )} +
    + ); +} diff --git a/ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx b/ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx new file mode 100644 index 00000000000..6fa386d0780 --- /dev/null +++ b/ui/src/artifact-directions/directions/d2-tint/PanelLead.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import type { LabArtifact } from "../../kit"; +import { CodeLines, ProseExcerpt, SheetTable } from "./Content"; +import { FoldedPreview, FoldedSheet, KindWell, PlayMark } from "./Lead"; +import { MOTION } from "./UnitAction"; +import { hasTextPreview } from "./kinds"; + +/** + * PANEL — the lead of a card, by the one rule (Lead.tsx): + * + * picture image, video poster, link screenshot — the full width of the + * card, the whole picture (never cropped: this is where it is + * judged), fold lg and tab. A video plays in place: the poster is + * a real button, and pressing it swaps in the player with controls. + * sheet document, Markdown, sheet, code — the artifact's own text at full + * width on a neutral sheet, fold md and tab: five lines of a + * document, header + three rows of a sheet, five lines of code. + * named PDF, slides, archive, generic file, a picture that failed — the md + * glyph on its kind well, 112px wide, as tall as the words beside it. + */ + +type PanelLeadKind = "picture" | "sheet" | "named"; + +export function panelLeadKind(a: LabArtifact, failed: boolean): PanelLeadKind { + if (a.thumbnailUrl && !failed) return "picture"; + return hasTextPreview(a) ? "sheet" : "named"; +} + +/** The picture's own shape, held between 4:3 and 16:9; anything outside that is shown whole on the neutral well. */ +function pictureBox(aspect: number | undefined): string { + if (aspect == null || aspect >= 1.7) return "aspect-video"; + return aspect >= 1.45 ? "aspect-3/2" : "aspect-4/3"; +} + +const PLAY_RING = "focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-(--tint-on-kind)"; +const DURATION = "absolute bottom-2 right-2 rounded-(--rad-4) bg-(--tint-scrim) px-1.5 py-1 font-mono text-(length:--text-micro) leading-none text-(--tint-on-kind)"; + +export function PanelPicture({ artifact, onError }: { artifact: LabArtifact; onError: () => void }) { + const [playing, setPlaying] = useState(false); + const box = cn("w-full", pictureBox(artifact.aspect)); + const video = artifact.kind === "video"; + if (video && playing && artifact.videoUrl) { + // Above the card's stretched action, so the player's own controls work. + return