From 043f2514e67c50320433a0013fb228a75a51325c Mon Sep 17 00:00:00 2001 From: Tonio Date: Wed, 21 Oct 2026 22:21:42 -0700 Subject: [PATCH 1/7] fix(ui): keep task tabs steady when switching views (#17068) ## Thinking Path > - Paperclip helps people manage AI agents and their work. > - A task side panel contains properties, artifacts, and document views. > - These views share one row of tabs. > - Selecting Artifacts adds an upload control beside the row. > - That control reduces the space available to the tabs and changes their widths. > - This pull request reserves the upload control space while the Artifacts tab is open. > - Switching views now keeps the tab widths and positions stable. ## Linked Issues or Issue Description Refs #14949. This fixes a layout shift observed after that PR merged. **What happened?** In a narrow task panel, tabs shrink when Artifacts is selected. They expand when another tab is selected. The upload control enters and leaves the header during selection. **Expected behavior** Tab selection must not change the space available to the tab row. Tabs must keep equal widths while the panel width and open tabs remain unchanged. **Steps to reproduce** 1. Open Properties, Artifacts, and a document or attachment in a task side panel. 2. Make the side panel narrow enough for tabs to compress. 3. Switch between Artifacts and the other tabs. 4. Observe the tab widths and positions as the upload control appears and disappears. ## What Changed - Keep an upload control slot in the header while an upload-capable Artifacts tab is open. - Hide the control and make it inert when another tab is active. Remove the slot when Artifacts is closed. Agent conversations do not reserve upload space. - Add a component regression test for selection, focus exclusion, and closing the Artifacts tab. - Add a constrained production-panel Storybook case with assertions for equal widths, stable widths across selections, and upload-control visibility. ## Verification - The focused TaskSidePanel and SidePanelTabs suites passed: 52 tests. - UI typecheck, UI build, and token gates passed after replaying the fix onto current master. - The Storybook catalog check and all 28 map/catalog tests passed. - The browser ran `html-artifacts-03-task-journey--stable-tab-widths` successfully. All three tabs measured 71.328125px before and after selecting Properties, the report, and Artifacts. - Review the Stable Tab Widths story under HTML artifacts / 03 Task journey. The fixture mounts the production task panel. Upload requests are outside its scope. - Full repository typecheck, tests, and build were not rerun locally. This machine has no Cargo, and an earlier broad test run hit unrelated server workspace/runtime failures. Full PR CI passed on commit `019e324c0eca815f90d9797194e768fb48cb46b6`. - All 55 successful PR checks are green on the latest head, with no pending or failing checks. The failed end-to-end shard passed on one rerun after a transient database lock timeout during test cleanup; no code changes were needed for the rerun. [CI run](https://github.com/paperclipai/paperclip/actions/runs/39557963507). ## Risks - A blank upload-control space remains when Artifacts is open but inactive. This keeps the tab row stable. - Hidden controls use both visibility and inert state to exclude them from keyboard navigation and accessibility exposure. - No API, database, upload permission, or saved-tab format changes. ## Model Used - OpenAI Codex, GPT-6. The runtime did not expose a more specific model ID or context window. Used reasoning, code execution, and browser inspection. ## 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 - [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 - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../task-side-panel/TaskSidePanel.test.tsx | 22 +++++++++ .../task-side-panel/TaskSidePanel.tsx | 17 ++++++- .../03-task-journey.stories.tsx | 45 ++++++++++++++++--- 3 files changed, 77 insertions(+), 7 deletions(-) diff --git a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx index 4ea43c2be57..2e2a9f61cd6 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.test.tsx @@ -179,6 +179,28 @@ describe("TaskSidePanel", () => { ); } + it("keeps the upload slot mounted and inert when switching away from Artifacts", async () => { + await render(panel({ streamlinedTabs: true, artifactsOpenRequestId: 1 })); + const slot = container.querySelector("[data-artifact-upload-slot]"); + expect(slot).not.toBeNull(); + await act(async () => container.querySelector("#side-panel-tab-artifacts")?.click()); + expect(slot?.getAttribute("aria-hidden")).toBe("false"); + expect(slot?.hasAttribute("inert")).toBe(false); + expect(slot?.className).not.toContain("invisible"); + + await act(async () => container.querySelector("#side-panel-tab-properties")?.click()); + expect(container.querySelector("[data-artifact-upload-slot]")).toBe(slot); + expect(slot?.getAttribute("aria-hidden")).toBe("true"); + expect(slot?.hasAttribute("inert")).toBe(true); + expect(slot?.className).toContain("invisible"); + + await act(async () => container.querySelector("#side-panel-tab-artifacts")?.click()); + expect(container.querySelector("[data-artifact-upload-slot]")).toBe(slot); + expect(slot?.hasAttribute("inert")).toBe(false); + await act(async () => container.querySelector('button[aria-label="Close Artifacts"]')?.click()); + expect(container.querySelector("[data-artifact-upload-slot]")).toBeNull(); + }); + it("opens, deduplicates, switches and closes text attachment tabs", async () => { const onAttachmentOpened = vi.fn(); await render(panel({ openAttachment: { id: "file-1", title: "AGENTS.md", requestId: 1 }, onAttachmentOpened })); diff --git a/ui/src/components/task-side-panel/TaskSidePanel.tsx b/ui/src/components/task-side-panel/TaskSidePanel.tsx index ee28cab900d..5f7f738ac2c 100644 --- a/ui/src/components/task-side-panel/TaskSidePanel.tsx +++ b/ui/src/components/task-side-panel/TaskSidePanel.tsx @@ -357,7 +357,9 @@ export function TaskSidePanel({ } }, [openBrowserId, controller.activeTabId, onBrowserOpened, accountScope, issue.companyId, issue.id, fileTabsEnabled]); const activeTab = controller.tabs.find((tab) => tab.id === controller.activeTabId) ?? null; - const canUploadArtifacts = activeTab?.payload.kind === "artifacts" && !conversationAgentId && !issue.id.startsWith("chat:"); + const hasArtifactUploadSlot = !conversationAgentId && !issue.id.startsWith("chat:") + && controller.tabs.some((tab) => tab.payload.kind === "artifacts"); + const canUploadArtifacts = hasArtifactUploadSlot && activeTab?.payload.kind === "artifacts"; const subtasksAvailable = showRelatedTasks && (taskCount > 0 || tasksTab?.hasError === true); const hasSubtasksTab = controller.tabs.some((tab) => tab.id === "subtasks"); @@ -820,7 +822,18 @@ export function TaskSidePanel({ } const contentMode = activeTab?.contentMode ?? "full-bleed"; - const header = <>{tabStrip}{canUploadArtifacts ? : null}; + const header = <>{tabStrip}{hasArtifactUploadSlot ? ( + // Keep the header footprint stable when selection changes. Hidden controls + // retain their space but cannot receive focus or open the file picker. +
+ +
+ ) : null}; return ( ("[data-presentation]")} onUpload={artifactUploads.upload}>
diff --git a/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx b/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx index 8b28b5ee046..7479c138a50 100644 --- a/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx +++ b/ui/storybook/stories/documents-artifacts/html-artifacts/03-task-journey.stories.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { IssueAttachment } from "@paperclipai/shared"; import { Routes, Route } from "@/lib/router"; @@ -26,7 +27,13 @@ const attachment: IssueAttachment = { updatedAt: new Date("2026-10-07T12:54:00Z"), }; -function TaskJourney({ openInitially = false }: { openInitially?: boolean }) { +interface JourneyProps { + openInitially?: boolean; + streamlinedTabs?: boolean; + panelWidth?: number; +} + +function TaskJourney({ openInitially = false, streamlinedTabs = false, panelWidth }: JourneyProps) { const [open, setOpen] = useState<{ id: string; title: string; requestId: number } | null>(() => openInitially ? { id: attachment.id, title: attachment.originalFilename!, requestId: 1 } : null); const [client] = useState(() => { const cache = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } }); @@ -49,15 +56,15 @@ function TaskJourney({ openInitially = false }: { openInitially?: boolean }) { -
- {}} fileTabsEnabled={false} openAttachment={open} /> +
+ {}} fileTabsEnabled={false} openAttachment={open} streamlinedTabs={streamlinedTabs} artifactsOpenRequestId={streamlinedTabs ? 1 : undefined} />
; } -function Journey(props: { openInitially?: boolean }) { +function Journey(props: JourneyProps) { return }> } /> ; @@ -69,7 +76,8 @@ const meta = { paperclip: { "capabilities": [ "documents-artifacts/attachments", - "workspaces/files-git" + "workspaces/files-git", + "navigation-preferences/navigation" ], "implementation": "production", "boundary": "fixtures" @@ -92,3 +100,30 @@ type Story = StoryObj; export const OpenFromTask: Story = {}; export const ReportOpen: Story = { args: { openInitially: true } }; export const Mobile: Story = { args: { openInitially: true }, globals: { viewport: { value: "mobile", isRotated: false } } }; + +export const StableTabWidths: Story = { + args: { openInitially: true, streamlinedTabs: true, panelWidth: 320 }, + parameters: { + docs: { description: { story: "Production task panel in a constrained pane. Switching between Artifacts, Properties, and the report keeps every tab the same width while the upload control appears or disappears. Upload requests remain outside this fixture's scope." } }, + }, + play: async ({ canvasElement }) => { + const panel = within(canvasElement).getByRole("region", { name: "Task side panel" }); + const canvas = within(panel); + const widths = () => Array.from(panel.querySelectorAll("[data-side-panel-tab-wrapper]")) + .map((tab) => tab.getBoundingClientRect().width); + await userEvent.click(await canvas.findByRole("tab", { name: "Artifacts" })); + await expect(canvas.getByRole("button", { name: "Upload files" })).toBeVisible(); + const baseline = widths(); + await expect(baseline).toHaveLength(3); + await expect(new Set(baseline).size).toBe(1); + for (const name of ["Properties", "repository-usage.html", "Artifacts"]) { + await userEvent.click(canvas.getByRole("tab", { name })); + await waitFor(() => expect(widths()).toEqual(baseline)); + if (name === "Artifacts") { + await expect(canvas.getByRole("button", { name: "Upload files" })).toBeVisible(); + } else { + await expect(canvas.queryByRole("button", { name: "Upload files" })).toBeNull(); + } + } + }, +}; From 25f2d05282f990949343ea473b0eb367d5998984 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:02:51 -0500 Subject: [PATCH 2/7] fix(ui): remove the provider trace run button (#17211) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The agent header provides actions to assign work and start runs. > - A second run button starts a run with raw provider tracing. > - Agent configuration already has a switch for raw tracing on future runs. > - This pull request removes the extra run button and its unused code. > - The header keeps one normal run action and the saved tracing status. ## Linked Issues or Issue Description **What existing behavior does this improve?** The action buttons in the agent detail header. **Current behavior** Administrators see Run now and Run with provider trace next to each other. **Proposed behavior** Remove Run with provider trace from the header. Use Capture raw provider traces in the configuration Debugging section to enable tracing for future runs. **Reason and benefit** Keep the normal run action clear. Put the tracing setting with the other agent settings. **Breaking changes** The separate trace button is removed. The tracing configuration, trace API, and run inspector actions stay available. ## What Changed - Remove the trace button, its property, and its mutation. - Remove the property from the agent detail page. - Update tests for normal runs with saved tracing on and off. - Check normal actions for Codex, Dot, and Muse. - Document the tracing setting in the feature map. ## Verification - Passed: 178 focused UI tests across AgentActionButtons, AgentDetail.accounting, RunnerInspector, AgentConfigForm, and AgentConfigForm.render. - Passed: pnpm check:token-gates. - Passed: node scripts/check-maps.mjs. - Passed: git diff --check. - Passed: [PR CI workflow](https://github.com/paperclipai/paperclip/actions/runs/39796866659), including the required verify and e2e gates. - Passed: Greptile 5/5 with no open review threads. - Two optional job badges still say running. Their test, cleanup, and final job steps passed. The parent workflow completed with success. - The broad local checks could not complete with the installed dependencies. The full typecheck and build require smol-toml. The UI checks also require @spekoai/client and the plugin SDK UI build. The full test run was stopped after many suites could not load. The PR workflow and both required gates passed for the current head. - To inspect the UI, open an agent. Confirm there is one Run now action. Enable Capture raw provider traces under configuration, Debugging. Save the change. Confirm the header shows Raw tracing on and still has one run action. ## Risks - Low risk. This removes one UI action and its unused code. - Administrators use the saved Debugging setting for tracing future runs. - No API, database, or permission changes. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The exact serving model ID and context window are not exposed in this run. ## 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 - [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 - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- maps/feature-map/runs-adapters.md | 3 ++ ui/src/components/AgentActionButtons.test.tsx | 17 ++++----- ui/src/components/AgentActionButtons.tsx | 38 +------------------ ui/src/pages/AgentDetail.accounting.test.tsx | 18 +++++---- ui/src/pages/AgentDetail.tsx | 1 - 5 files changed, 21 insertions(+), 56 deletions(-) diff --git a/maps/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md index 1767da493b0..ef9b83201dc 100644 --- a/maps/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -28,6 +28,9 @@ Open an agent’s model/adapter configuration or the instance adapter settings p ### `run-history` Open an agent run at `/agents/:agentId/runs/:runId` from the agent or task activity. +The agent header has one Run now action. Administrators can enable +Capture raw provider traces under the agent configuration’s Debugging section +for future runs. The header shows Raw tracing on while that setting is enabled. For latency diagnosis with an operator OTLP endpoint, search the hashed run ID without restricting the root span name. Compare workspace preparation with its diff --git a/ui/src/components/AgentActionButtons.test.tsx b/ui/src/components/AgentActionButtons.test.tsx index 08648570fb9..1177ccb02d1 100644 --- a/ui/src/components/AgentActionButtons.test.tsx +++ b/ui/src/components/AgentActionButtons.test.tsx @@ -212,23 +212,20 @@ describe("AgentActionButtons", () => { expect(container.textContent).not.toContain("Clear error"); }); - it("starts an administrator-selected run with raw provider tracing", async () => { - render(makeAgent(), { canRunWithProviderTrace: true }); + it.each([false, true])("keeps the normal run action when persistent tracing is %s", async tracingEnabled => { + render(makeAgent({ runtimeConfig: tracingEnabled ? { debug: { providerTrace: "raw" } } : {} })); await flushReact(); - const traceButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Run with provider trace"), - ); - expect(traceButton).toBeTruthy(); + const buttons = Array.from(container.querySelectorAll("button")); + expect(buttons.some(button => button.textContent?.includes("Run with provider trace"))).toBe(false); + expect(container.textContent?.includes("Raw tracing on")).toBe(tracingEnabled); await act(async () => { - traceButton?.click(); + buttons.find(button => button.textContent === "Run Heartbeat")!.click(); }); await flushReact(); - expect(mockAgentsApi.invoke).toHaveBeenCalledWith("agent-1", "company-1", { - debug: { providerTrace: "raw" }, - }); + expect(mockAgentsApi.invoke).toHaveBeenCalledExactlyOnceWith("agent-1", "company-1"); expect(mockNavigate).toHaveBeenCalledWith("/agents/alpha/runs/run-1"); }); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index f9498d9a23f..6ee48a346e7 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -167,7 +167,6 @@ export function AgentActionButtons({ workActionsDisabled = false, workActionsDisabledReason, navigateToRunOnInvoke = true, - canRunWithProviderTrace = false, hasPendingNavigationChanges = false, onBeforeNavigate, onActionError, @@ -188,8 +187,6 @@ export function AgentActionButtons({ workActionsDisabled?: boolean; workActionsDisabledReason?: string; navigateToRunOnInvoke?: boolean; - /** Instance administrators may opt one manual run into short-lived raw provider capture. */ - canRunWithProviderTrace?: boolean; /** Whether the caller currently has an unsaved draft that navigation would discard. */ hasPendingNavigationChanges?: boolean; /** Return false to stop an action whose success would navigate away. */ @@ -297,24 +294,6 @@ export function AgentActionButtons({ }, }); - const providerTraceAction = useMutation({ - mutationFn: () => - agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined, { - debug: { providerTrace: "raw" }, - }), - onSuccess: (run) => { - onActionError?.(null); - invalidateAgent(); - if (navigateToRunOnInvoke) { - if (!confirmLateNavigationChanges(agentActionStartedDirtyRef)) return; - navigate(`/agents/${canonicalAgentRef}/runs/${run.id}`); - } - }, - onError: (err) => { - reportError(err instanceof Error ? err.message : "Failed to start traced run"); - }, - }); - const duplicateAgent = useMutation({ mutationFn: async () => { if (!resolvedCompanyId) { @@ -370,7 +349,7 @@ export function AgentActionButtons({ }); const isPendingApproval = agent.status === "pending_approval"; - const disabled = actionsDisabled || agentAction.isPending || providerTraceAction.isPending; + const disabled = actionsDisabled || agentAction.isPending; const assignAndRunDisabled = disabled || isPendingApproval || workActionsDisabled || agent.status === "terminated"; const pauseResumeDisabled = disabled || isPendingApproval || (isPaused && workActionsDisabled) || Boolean(agent.lifecycleState && ["pausing", "terminating", "cleaning_up", "terminated", "rejected"].includes(agent.lifecycleState)); @@ -412,21 +391,6 @@ export function AgentActionButtons({ label={runLabel} size={size} />} - {canRunWithProviderTrace && ( - - )} {isError ? ( agentAction.mutate("clear_error")} diff --git a/ui/src/pages/AgentDetail.accounting.test.tsx b/ui/src/pages/AgentDetail.accounting.test.tsx index 6a443bbc9ed..68becbc2666 100644 --- a/ui/src/pages/AgentDetail.accounting.test.tsx +++ b/ui/src/pages/AgentDetail.accounting.test.tsx @@ -5,7 +5,7 @@ import { AgentActionButtons } from "../components/AgentActionButtons"; import type { Agent, HeartbeatRun } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { RunAccountingMetrics } from "./AgentDetail"; -import { hasUnavailableProviderAccounting, supportsRawProviderTrace } from "../lib/utils"; +import { hasUnavailableProviderAccounting } from "../lib/utils"; vi.mock("@/lib/router", () => ({ useNavigate: () => vi.fn() })); vi.mock("../context/DialogContext", () => ({ useDialogActions: () => ({ openNewIssue: vi.fn() }) })); @@ -41,7 +41,7 @@ describe("run accounting provenance", () => { describe("provider trace support", () => { - const renderActions = (provider: string, isAdmin: boolean) => { + const renderActions = (provider: string) => { const agent: Agent = { id: "agent-1", companyId: "company-1", name: "Researcher", urlKey: "researcher", role: "researcher", title: null, icon: null, status: "active", reportsTo: null, capabilities: null, @@ -52,22 +52,24 @@ describe("provider trace support", () => { }; return renderToStaticMarkup( - + , ); }; it("keeps normal Muse actions but removes the unsupported raw traffic promise", () => { - const markup = renderActions("muse", true); + const markup = renderActions("muse"); expect(markup).toContain("Run now"); expect(markup).toContain("Pause"); expect(markup).not.toContain("Run with provider trace"); expect(markup).not.toContain("Capture exact provider traffic"); }); - it("retains other provider tracing only for authorized administrators", () => { - expect(renderActions("codex", true)).toContain("Run with provider trace"); - expect(renderActions("openai_dot", true)).toContain("Run with provider trace"); - expect(renderActions("codex", false)).not.toContain("Run with provider trace"); + it.each(["codex", "openai_dot"])("keeps normal %s actions without a separate trace button", provider => { + const markup = renderActions(provider); + expect(markup).toContain("Run now"); + expect(markup).toContain("Pause"); + expect(markup).not.toContain("Run with provider trace"); + expect(markup).not.toContain("Capture exact provider traffic"); }); }); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 596c11fcb86..a1f7ee43606 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1294,7 +1294,6 @@ export function AgentDetail() { companyId={resolvedCompanyId} assignLabel="Assign Task" showStatus={false} - canRunWithProviderTrace={canUseProviderTrace && supportsRawProviderTrace(currentAgent.adapterType, currentAgent.adapterConfig)} actionsDisabled={agentAction.isPending} workActionsDisabled={hasInvalidOrgChain} workActionsDisabledReason="Repair this agent's reporting chain before assigning tasks or starting runs" From def4726bea0e3b340457100038d80ca55c2b7d4f Mon Sep 17 00:00:00 2001 From: Tonio Date: Thu, 22 Oct 2026 10:38:12 -0700 Subject: [PATCH 3/7] 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