From f266e36035280da878c149141ac2bb66dab88494 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 3 Nov 2026 23:39:30 -0800 Subject: [PATCH 1/4] feat(artifacts): build the page to the approved d215 signal frames, and score them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six-column table becomes one group per pod: a mono label over one hairline list. Each row has the extension badge, the name, " · " and a trailing size. Hovering or focusing a row shows Open and Download keys in the size's place. At 390 the size folds into the line and Download is the one 44px key. The two pill groups become one kind segment (V2Segmented) and one native pod select dressed as the frame's .sel. At 390 search is the top bar's key. Day zero is one quiet row with one key. Open, the lightbox, the signed URLs, sorting, filters, the URL params and whenLabel are unchanged. Download is new: it saves the signed URL's bytes under the stored name, and opens the URL instead when the bytes can't be read. The artboard's signal frames lose data-state and become the scored artifacts-busy and artifacts-dayzero frames. The traced frames and their CSS go, targets.json gains the four entries, and measure_after.js measures artifacts-busy on prod. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- docs/design/artboards/d215/README.md | 9 +- .../artboards/d215/cap/measure_after.js | 15 +- .../artboards/d215/cap/measure_after.test.js | 4 +- docs/design/artboards/d215/index.html | 72 +--- docs/design/artboards/d215/targets.json | 16 + frontend/src/i18n/locales/en.json | 27 +- frontend/src/i18n/locales/zh-CN.json | 27 +- .../src/v2/__tests__/V2ArtifactsPage.test.tsx | 245 ++++++++++---- .../v2/__tests__/v2-layout-invariants.test.ts | 134 ++++---- .../src/v2/components/V2ArtifactsPage.css | 102 ++++++ .../src/v2/components/V2ArtifactsPage.tsx | 311 ++++++++++++------ frontend/src/v2/v2.css | 106 +----- 12 files changed, 602 insertions(+), 466 deletions(-) create mode 100644 frontend/src/v2/components/V2ArtifactsPage.css diff --git a/docs/design/artboards/d215/README.md b/docs/design/artboards/d215/README.md index 1f11e902f3..3a601014a9 100644 --- a/docs/design/artboards/d215/README.md +++ b/docs/design/artboards/d215/README.md @@ -6,23 +6,22 @@ The spec for D215 (artboard v2). Open `index.html` in a browser. The toggle at t - A second block under agent settings draws the states of that page (policy and machine, identity edit, connector grants, runtime with the model and effort selects, own API key) at 1440 and 390. The runtime and key frames follow V2RuntimeControls and V2AgentProviderKey, including the stored-model refusal and the key-save error. Those frames carry `data-state`, so `measure_after.js` skips them and `targets.json` does not move. - Two state-only surfaces: `task-record` (the task record page, loaded and not-available, under the reply trace) and `handoff` (the OAuth and Discord return pages, `data-state` working, done, error, on the auth card). Every frame carries `data-state`, so the scorer skips them and `targets.json` does not move. - A block under Settings draws the free-plan Plan row and the administrator-only Administration group (`data-state="admin-free"`, 1440 and 390, also skipped by the scorer). -- Under Activity and under Artifacts, a block redraws each page in the picked language (`#s-activity-signal`, `#s-artifacts-signal`): busy and day zero at 1440 and 390, built only from the seven picked surfaces' blocks. Activity's build scores its four frames (no `data-state`) and retired the traced `#s-activity` section. Artifacts' frames still carry `data-state="signal"`, so the scorer skips them until its build lands. +- Under Activity and under Artifacts, a block redraws each page in the picked language (`#s-activity-signal`, `#s-artifacts-signal`): busy and day zero at 1440 and 390, built only from the seven picked surfaces' blocks. Sam approved both on 11-03. Each page's build dropped the `data-state`, so the eight frames (`activity-busy`, `activity-dayzero`, `artifacts-busy`, `artifacts-dayzero`) are the scored ones, and retired the traced `#s-activity` and `#s-artifacts` sections and their CSS. - The theme switch (sun / moon, D224) sits in the rail above the avatar at 1440. At 390 there is no rail, so the same switch rides the Pods top bar, left of Search. It is a two-item segment: the item for the theme in effect is the white key. - `before/` holds prod captures from 2026-10-31, blurred so they show layout and density only. - `targets.json` holds the helper-word and scroll targets per frame. `cap/measure_after.js` writes it from `index.html`. - `cap/` holds the measuring scripts. See the header of `measure_after.js` for its env. -- Every frame without `data-state` must be listed in `measure_after.js`, either in `SURFACES` (scored) or in `SKIPPED` (not scored, with a reason). Activity, Artifacts and the two trace "before" frames are skipped. `node --test cap/measure_after.test.js` fails on any frame that is in neither list. +- Every frame without `data-state` must be listed in `measure_after.js`, either in `SURFACES` (scored) or in `SKIPPED` (not scored, with a reason). The two trace "before" frames, `run-trace-before` and `chat-trace-before`, draw the old pages and are skipped: they are never a target. On prod, `activity-busy` is measured at `/v2/activity` and `artifacts-busy` at `/v2/artifacts`; the two day-zero frames need an account with nothing in it, so they are artboard-only. `node --test cap/measure_after.test.js` fails on any frame that is in neither list. ## Where each frame came from -Passing the prod check means a page matches its frame. It means the page matches the style Sam picked only when the frame came from his pick. Every frame is in one of four groups. A group's frames are scored only where the table says so, and prod scores are reported per group, never as one total. +Passing the prod check means a page matches its frame. It means the page matches the style Sam picked only when the frame came from his pick. Every frame is in one of three groups. A group's frames are scored only where the table says so, and prod scores are reported per group, never as one total. | Group | Frames | Source | |---|---|---| | **From Sam's pick** | sidebar, chat, board, agent-memory, agent-settings, settings, connectors | The seven surfaces picked on 10-31 (#3059). Later edits: the theme switch (#3175) and the open thread (#3178). #3288 (the 390 agent-settings and settings frames, brought up to the shipped pages) and #3289 (memory notes drawn as the API holds them) moved those frames toward the live page, so read them with that in mind. | | **Drawn after the pick** | dm, byo, byo-handoff, connect, team, firstrun-\*, signin, signup, signup-sent, verify-email, forgot-password, reset-password, cli-authorize, invite, invite-required, profile, agent-profile, run-trace-after, chat-trace, chat-agents-away, and the agent-settings and settings state blocks | New drawings in the picked language, from 11-01 on. Sam has not reviewed them as a set. | -| **Approved by Sam, 11-03** | activity-busy, activity-dayzero (`#s-activity-signal`, scored since the Activity build); artifacts-\* with `data-state="signal"`; task-record-\*; handoff-\* | Sam saw each one live-beside-proposed and answered "Build as drawn" for all four pages. Activity and Artifacts are built only from the picked surfaces' blocks. The scorer still skips these frames (they carry `data-state`). Each page's build PR makes its approved frame the scored one and retires the frame it replaces. | -| **Drawn from live, being retired** | artifacts-\* without `data-state` | Traced from the live page with small fixes (#3318). The scorer skips them (`SKIPPED` in `cap/measure_after.js`); scoring a page against them would only show it has not drifted from itself. The Artifacts build PR removes them; the Activity build removed its traced frames. The two before frames, `run-trace-before` and `chat-trace-before`, draw the old pages and are never a target. | +| **Approved by Sam, 11-03** | activity-busy, activity-dayzero (`#s-activity-signal`); artifacts-busy, artifacts-dayzero (`#s-artifacts-signal`); task-record-\*; handoff-\* | Sam saw each one live-beside-proposed and answered "Build as drawn" for all four pages. Activity and Artifacts are built only from the picked surfaces' blocks, and both are scored since their builds. Task record and handoff still carry `data-state`, so the scorer skips them. | ## Rules for D215 PRs diff --git a/docs/design/artboards/d215/cap/measure_after.js b/docs/design/artboards/d215/cap/measure_after.js index 85d4f75e7b..ad667d86eb 100644 --- a/docs/design/artboards/d215/cap/measure_after.js +++ b/docs/design/artboards/d215/cap/measure_after.js @@ -29,15 +29,12 @@ const SURFACES = [ 'settings', 'agent-settings', 'agent-memory', 'board', 'connectors', 'chat', 'sidebar', 'byo', 'byo-handoff', 'firstrun-welcome', 'firstrun-waiting', 'firstrun-connected', 'firstrun-error', 'connect', 'team', 'chat-agents-away', 'chat-trace', 'run-trace-after', 'profile', 'firstrun-welcome-nohost', - // Activity: the frames Sam approved 11-03 ("Build as drawn"), built in V2ActivityPage. - 'activity-busy', 'activity-dayzero', + // Activity and Artifacts: the frames Sam approved 11-03 ("Build as drawn"), built in + // V2ActivityPage and V2ArtifactsPage. + 'activity-busy', 'activity-dayzero', 'artifacts-busy', 'artifacts-dayzero', ]; // Base frames (no data-state) that are drawn but deliberately not scored. Each needs a reason. const SKIPPED = { - // Artifacts is being redesigned in another session; targets taken now would pin frames that - // are about to change. Score them once that redesign lands. - 'artifacts-busy': 'redesign in progress elsewhere', - 'artifacts-dayzero': 'redesign in progress elsewhere', // Drawings of the old page beside their "after" frames (390 only). They are what the fix moves // away from, so they are never a target. 'chat-trace-before': 'before shot, not a target', @@ -47,8 +44,9 @@ const SKIPPED = { // a real key. The first-run dialog is a latch-gated overlay, and the Scout welcome only exists in a // brand-new pod. chat-agents-away and chat-trace need a pod in that exact state, run-trace-after a // run id and profile a person id, none of which prod() takes yet, so they are artboard-only for now. -// activity-dayzero needs an account with no asks and no agents, so only the busy page is checked. -const PROD_SURFACES = ['settings', 'agent-settings', 'agent-memory', 'board', 'connectors', 'chat', 'sidebar', 'byo', 'connect', 'team', 'activity-busy']; +// activity-dayzero needs an account with no asks and no agents, and artifacts-dayzero one with no +// shared file, so only the two busy pages are checked on prod. +const PROD_SURFACES = ['settings', 'agent-settings', 'agent-memory', 'board', 'connectors', 'chat', 'sidebar', 'byo', 'connect', 'team', 'activity-busy', 'artifacts-busy']; const VIEWPORTS = ['1440', '390']; const EXPECTED = SURFACES.flatMap(s => VIEWPORTS.map(v => `${s}-${v}`)); @@ -222,6 +220,7 @@ async function prod(only) { ['connect', () => '/v2/connect'], ['team', () => '/v2/agents'], ['activity-busy', () => '/v2/activity'], + ['artifacts-busy', () => '/v2/artifacts'], ['chat', () => `/v2/pods/${pod}`], ['sidebar', vp => (vp === '1440' ? `/v2/pods/${side}` : '/v2'), { list390: SIDEBAR_390 }], ]; diff --git a/docs/design/artboards/d215/cap/measure_after.test.js b/docs/design/artboards/d215/cap/measure_after.test.js index 8090d4229e..e99c8d98f0 100644 --- a/docs/design/artboards/d215/cap/measure_after.test.js +++ b/docs/design/artboards/d215/cap/measure_after.test.js @@ -29,7 +29,7 @@ test('--only with an unknown surface throws', () => { test('the committed targets.json passes its own validation', () => { const t = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'targets.json'), 'utf8')); - assert.strictEqual(Object.keys(t).length, 44); + assert.strictEqual(Object.keys(t).length, 48); validateTargets(t); }); @@ -39,7 +39,7 @@ test('an empty or partial measurement is refused', () => { const partial = { ...t }; delete partial['board-390']; assert.throws(() => validateTargets(partial), /missing board-390/); assert.throws(() => validateTargets({ ...t, 'chat-1440': { helperWords: NaN, innerRatio: 2 } }), /invalid chat-1440/); - assert.strictEqual(EXPECTED.length, 44); + assert.strictEqual(EXPECTED.length, 48); }); test('a frame the scorer does not know is named, with the way to fix it', () => { diff --git a/docs/design/artboards/d215/index.html b/docs/design/artboards/d215/index.html index 3c9a2fc547..aad205ba4e 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -871,56 +871,6 @@ .app.m .v2-activity .v2-activity__moved-line { grid-template-columns: minmax(0, 1fr) auto; } .app.m .v2-activity .v2-activity__moved-line span { grid-column: 1 / -1; grid-row: 2; } /* activity:end */ -/* artifacts: the live v2-artifacts__* names (V2ArtifactsPage) and V2PageHead's v2-page-head__*, - redrawn in the connectors family. Scoped to .v2-artifacts, the page's root. New names: __zero, __open. */ -.v2-artifacts { display: flex; flex-direction: column; gap: 20px; min-width: 0; } -.v2-artifacts button { all: unset; box-sizing: border-box; cursor: pointer; } -.v2-artifacts .info, .v2-artifacts__head .info { all: unset; cursor: help; position: relative; display: inline-grid; place-items: center; width: 18px; height: 18px; border-radius: 50%; color: var(--ph); } -.v2-artifacts .v2-page-head__identity { display: flex; align-items: center; gap: 10px; min-width: 0; } -.v2-artifacts .v2-artifacts__title { margin: 0; font: 800 30px/1.05 var(--f-display); letter-spacing: -0.03em; color: var(--ink); } -.v2-artifacts .v2-page-head__meta, .v2-artifacts__head .v2-page-head__meta { font: 500 12px/16px var(--f-mono); color: var(--mu); white-space: nowrap; } -.v2-artifacts .v2-artifacts__controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } -.v2-artifacts .v2-artifacts__seg { display: flex; gap: 2px; padding: 3px; border-radius: 9px; background: var(--sf); box-shadow: inset 0 0 0 1px var(--d); min-width: 0; } -.v2-artifacts .v2-artifacts__seg-button { display: inline-flex; align-items: center; height: 28px; padding: 0 10px; border-radius: 7px; font: 600 11.5px/1 var(--f-mono); color: var(--se); white-space: nowrap; } -.v2-artifacts .v2-artifacts__seg-button--active { background: var(--t); color: var(--ink); } -.v2-artifacts .v2-artifacts__search { display: flex; align-items: center; gap: 8px; min-width: 220px; height: 36px; margin-left: auto; padding: 0 10px; border-radius: 9px; box-shadow: inset 0 0 0 1px var(--d); background: var(--sf); } -.v2-artifacts .v2-artifacts__search-input { flex: 1; font-size: 13px; color: var(--ph); } -.v2-artifacts .v2-artifacts__search-key { font: 500 11px/16px var(--f-mono); color: var(--mu); } -.v2-artifacts .v2-artifacts__table-wrap { border-radius: 10px; box-shadow: inset 0 0 0 1px var(--d); background: var(--sf); overflow: hidden; } -.v2-artifacts .v2-artifacts__table { width: 100%; border-collapse: collapse; font-size: 13px; line-height: 18px; color: var(--ink); } -.v2-artifacts .v2-artifacts__table th { padding: 8px 12px; border-bottom: 1px solid var(--d); font: 500 11px/16px var(--f-mono); color: var(--mu); text-align: left; text-transform: lowercase; white-space: nowrap; } -.v2-artifacts .v2-artifacts__table td { padding: 9px 12px; border-bottom: 1px solid var(--d); vertical-align: middle; } -.v2-artifacts .v2-artifacts__table tr:last-child td { border-bottom: 0; } -.v2-artifacts .v2-artifacts__file { display: flex; align-items: center; gap: 10px; min-width: 0; } -.v2-artifacts .v2-artifacts__ext { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 20px; padding: 0 5px; border-radius: 5px; box-shadow: inset 0 0 0 1px var(--d); font: 500 11px/14px var(--f-mono); color: var(--se); } -.v2-artifacts .v2-artifacts__name { font-weight: 600; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; } -.v2-artifacts .v2-artifacts__pod-line { display: none; font: 500 11px/16px var(--f-mono); color: var(--se); } -.v2-artifacts .v2-artifacts__mono { font: 500 11px/16px var(--f-mono); color: var(--se); white-space: nowrap; } -.v2-artifacts .v2-artifacts__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.v2-artifacts .v2-artifacts__more { font: 600 12px/16px var(--f-body); color: var(--c-text); } -.v2-artifacts .v2-artifacts__zero { width: 100%; max-width: 760px; padding: 14px; border-radius: 10px; background: var(--sf); box-shadow: inset 0 0 0 1px var(--d); } -.v2-artifacts .v2-artifacts__zero h2 { margin: 0; font: 600 14px/20px var(--f-body); letter-spacing: 0; color: var(--ink); } -.v2-artifacts .v2-artifacts__zero p { margin: 2px 0 0; font-size: 14px; line-height: 20px; color: var(--se); } -.v2-artifacts .v2-artifacts__open { display: inline-flex; align-items: center; justify-content: center; height: 32px; margin-top: 12px; padding: 0 13px; border-radius: 9px; font: 600 13px/1 var(--f-body); color: var(--on-fill); background: linear-gradient(180deg, var(--fill-a), var(--fill-b)); box-shadow: inset 0 0 0 1px var(--fill-edge); text-decoration: none; white-space: nowrap; } -/* 390: head is the top bar; filters stack; the pod folds under the name; the day-zero key is full width, 44px */ -.app.m .mtop.v2-artifacts__head { padding: 0 12px 0 16px; gap: 8px; } -.app.m .mtop.v2-artifacts__head h1 { font-size: 22px; } -.app.m .mtop.v2-artifacts__head .v2-page-head__meta { margin-left: auto; font-size: 11.5px; } -.app.m .v2-artifacts { gap: 12px; } -.app.m .v2-artifacts .v2-artifacts__controls { flex-direction: column; align-items: stretch; } -.app.m .v2-artifacts .v2-artifacts__seg-button { flex: 1 1 0; justify-content: center; height: 36px; } -.app.m .v2-artifacts .v2-artifacts__search { margin-left: 0; min-width: 0; height: 44px; } -.app.m .v2-artifacts .v2-artifacts__table { table-layout: fixed; } -.app.m .v2-artifacts .v2-artifacts__table th:nth-child(3), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(3), -.app.m .v2-artifacts .v2-artifacts__table th:nth-child(4), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(4), -.app.m .v2-artifacts .v2-artifacts__table th:nth-child(6), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(6) { display: none; } -.app.m .v2-artifacts .v2-artifacts__table th:nth-child(2), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(2) { width: 64px; } -.app.m .v2-artifacts .v2-artifacts__table th:nth-child(5), .app.m .v2-artifacts .v2-artifacts__table td:nth-child(5) { width: 52px; } -.app.m .v2-artifacts .v2-artifacts__file { flex-wrap: wrap; row-gap: 2px; } -.app.m .v2-artifacts .v2-artifacts__pod-line { display: block; flex: 1 1 100%; } -.app.m .v2-artifacts .v2-artifacts__name { white-space: normal; overflow-wrap: anywhere; } -.app.m .v2-artifacts .v2-artifacts__open { display: flex; width: 100%; height: 44px; font-size: 14px; } -/* artifacts:end */ /* run trace: the live v2-run-trace__* names (V2RunTrace.tsx), redrawn with the page's own rules. Scoped to .v2-run-trace, the page's root. New name: __foot. */ .v2-run-trace { max-width: 880px; min-width: 0; color: var(--ink); font-size: 14px; line-height: 20px; } @@ -1282,7 +1232,7 @@

Same look. Less said.

  • One screen firstRepeated blocks become a table; rare settings collapse.
  • @@ -1764,27 +1714,15 @@

    Every live function, mapped

    Not drawnLoading, error, Get started · checking (the same two rows with bars), no agent yet, the ruled and Other… editors. Each keeps the row shape above.
    -
    -

    Artifacts

    /v2/artifacts
    -
    -
    Day zero · 1440nothing shared, no filter set

    Artifacts

    Nothing shared yet

    Files you and your agents share in a pod land here: docs, images and pages.

    Open My Workspace
    -
    Busy · 144014 files across 2 pods

    Artifacts

    14
    FileKindPodShared byWhenSize
    Northwind
    docNorthwindFern12m2.1 MB
    Northwind
    imageNorthwindJuno Park1h412 KB
    Field notes
    pageField notesScout3h—
    Field notes
    docField notesFern1d18 KB
    Northwind
    docNorthwindAlex Chen2d96 KB
    Field notes
    imageField notesJuno Park4d1.4 MB
    6 of 14 · newest first
    -
    -
    -
    Day zero · 390key full width, 44px

    Artifacts

    Nothing shared yet

    Files you and your agents share in a pod land here: docs, images and pages.

    Open My Workspace
    Pods
    Needs you
    Team
    Settings
    -
    Busy · 390pod folds under the name

    Artifacts

    14
    FileKindPodShared byWhenSize
    Northwind
    docNorthwindFern12m2.1 MB
    Northwind
    imageNorthwindJuno Park1h412 KB
    Field notes
    pageField notesScout3h—
    Field notes
    docField notesFern1d18 KB
    Northwind
    docNorthwindAlex Chen2d96 KB
    Field notes
    imageField notesJuno Park4d1.4 MB
    6 of 14 · newest first
    Pods
    Needs you
    Team
    Settings
    -
    • Class names are the page's own (v2-artifacts__*), so the build copies them. New names: __zero (the card) and __open (its ink key).
    • Day zero has nothing to filter, so it shows no filters. It applies when the total is 0 and no kind, pod or search filter is set, and the page is not loading and has no error. Then the kind segment, the pod segment, the search box and the head's count all drop, and one bordered card (not dashed) takes their place.
    • The card is the only act on the page. Its key opens the account's oldest pod (signup creates My Workspace first), labelled with that pod's name. An account with no pods gets the card without the key.
    • A filtered empty result keeps the controls. With a kind, pod or search set, the page keeps prod's dashed line, Nothing matches. Clear a filter or the search., under the controls, so the way out stays on screen.
    • At 390 the key is full width and 44px tall. Busy is prod's table, unchanged: the pod folds under the name as a mono line, and pod, shared by and size hide.

    Each state, one line

    Day zeroDrawn at 1440 and 390. Total 0, no kind, pod or search set, not loading, no error: no controls, no count, the card.
    BusyDrawn at 1440 and 390. Prod's table and foot, unchanged.
    Filtered, emptyNot drawn. Controls stay; prod's dashed emptyFiltered line under them.
    No podsNot drawn. The day-zero card without its key.
    Loading, errorNot drawn. They keep prod's spinner and __error line.
    -
    -
    -

    Artifacts · in the picked language

    /v2/artifacts
    approved 11-03, not scored yet
    -
    Busy · 144014 files across 2 pods; one row hovered

    Artifacts

    14 files · 2 pods
    Northwind
    pricing-v3-final.pdf
    Fern · 12m
    2.1 MB
    onboarding-flow-1440.png
    Juno Park · 1h
    412 KB
    q4-pipeline.xlsx
    Alex Chen · 2d
    96 KB
    Field notes
    launch-checklist.html
    Scout · 3h
    —
    interview-synthesis.md
    Fern · 1d
    18 KB
    whiteboard-friday.jpg
    Juno Park · 4d
    1.4 MB
    6 of 14 · newest first
    Day zero · 1440nothing shared, no filter set

    Artifacts

    Files you and your agents share in a pod land here.
    -
    Busy · 390size folds under the name

    Artifacts

    14 files · 2 pods
    Northwind
    pricing-v3-final.pdf
    Fern · 12m · 2.1 MB
    onboarding-flow-1440.png
    Juno Park · 1h · 412 KB
    q4-pipeline.xlsx
    Alex Chen · 2d · 96 KB
    Field notes
    launch-checklist.html
    Scout · 3h
    interview-synthesis.md
    Fern · 1d · 18 KB
    whiteboard-friday.jpg
    Juno Park · 4d · 1.4 MB
    6 of 14 · newest first
    Pods
    Needs you
    Team
    Settings
    Day zero · 390key full width, 44px

    Artifacts

    Files you and your agents share in a pod land here.
    Pods
    Needs you
    Team
    Settings
      +

      Artifacts

      /v2/artifacts
      approved 11-03 (D242), scored
      +
      Busy · 144014 files across 2 pods; one row hovered

      Artifacts

      14 files · 2 pods
      Northwind
      pricing-v3-final.pdf
      Fern · 12m
      2.1 MB
      onboarding-flow-1440.png
      Juno Park · 1h
      412 KB
      q4-pipeline.xlsx
      Alex Chen · 2d
      96 KB
      Field notes
      launch-checklist.html
      Scout · 3h
      —
      interview-synthesis.md
      Fern · 1d
      18 KB
      whiteboard-friday.jpg
      Juno Park · 4d
      1.4 MB
      6 of 14 · newest first
      Day zero · 1440nothing shared, no filter set

      Artifacts

      Files you and your agents share in a pod land here.
      +
      Busy · 390size folds under the name

      Artifacts

      14 files · 2 pods
      Northwind
      pricing-v3-final.pdf
      Fern · 12m · 2.1 MB
      onboarding-flow-1440.png
      Juno Park · 1h · 412 KB
      q4-pipeline.xlsx
      Alex Chen · 2d · 96 KB
      Field notes
      launch-checklist.html
      Scout · 3h
      interview-synthesis.md
      Fern · 1d · 18 KB
      whiteboard-friday.jpg
      Juno Park · 4d · 1.4 MB
      6 of 14 · newest first
      Pods
      Needs you
      Team
      Settings
      Day zero · 390key full width, 44px

      Artifacts

      Files you and your agents share in a pod land here.
      Pods
      Needs you
      Team
      Settings
      • Head as Connectors draws it. Display title, one mono count (14 files · 2 pods), then the search field (⌘K), one All / Images / Docs / Pages segment and an All pods ▾ select at the right. The two pill groups and the count-only meta go.
      • The table becomes groups. No header row and no six columns: a mono section label per pod over one bordered list. Each row carries a mono file-type badge (grey, the pod-mark shape, so the Kind column is the badge), the bold name, one secondary line Fern · 12m (Shared by and When) and the size as a trailing mono value. Pod, Kind, Shared by, When and Size all survive; only the column chrome is cut.
      • Hover keys, as on Memory. A hovered row (drawn on onboarding-flow-1440.png) swaps its size for ↗ Open and ↓ Download. Clicking the row opens the file. ↓ is one new 24px line glyph in the sprite (#i-down), drawn in the same stroke as the rest.
      • Order and paging are unchanged. Newest first inside each pod, and pods ordered by their newest file. The foot keeps 6 of 14 · newest first and a quiet 8 more, which loads into both groups.
      • -
      • Day zero is one quiet row in the same group shape. It has the file glyph, one sentence and one raised key, Open My Workspace. The "Nothing shared yet" heading and the head controls drop, as the current frame already drops them. At 390 the key is full width at 44px inside the row.
      • +
      • Day zero is one quiet row in the same group shape. It has the file glyph, one sentence and one raised key, Open My Workspace. The "Nothing shared yet" heading and the head controls drop. At 390 the key is full width at 44px inside the row.
      • At 390, the same rows. The size folds into the secondary line (Fern · 12m · 2.1 MB), the row is the open target, and the one trailing key is ↓ Download at 44px. Search is the top bar's search key, as on Connectors. Kind and pod share one 44px control row.

      Every live function, mapped

      diff --git a/docs/design/artboards/d215/targets.json b/docs/design/artboards/d215/targets.json index f2396ad673..40c654a7a5 100644 --- a/docs/design/artboards/d215/targets.json +++ b/docs/design/artboards/d215/targets.json @@ -174,5 +174,21 @@ "firstrun-welcome-nohost-390": { "helperWords": 9, "innerRatio": 1 + }, + "artifacts-busy-1440": { + "helperWords": 36, + "innerRatio": 1 + }, + "artifacts-busy-390": { + "helperWords": 34, + "innerRatio": 1 + }, + "artifacts-dayzero-1440": { + "helperWords": 0, + "innerRatio": 1 + }, + "artifacts-dayzero-390": { + "helperWords": 0, + "innerRatio": 1 } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index b534b3578f..171522979f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2634,12 +2634,17 @@ }, "artifacts": { "title": "Artifacts", - "about": "About artifacts", - "meta": "every file and page shared in your pods", + "fileCount_one": "{{count}} file", + "fileCount_other": "{{count}} files", + "podCount_one": "{{count}} pod", + "podCount_other": "{{count}} pods", + "open": "Open", + "openFile": "Open {{name}}", + "download": "Download", + "downloadFile": "Download {{name}}", "kindLabel": "Kind", "podLabel": "Pod", "allPods": "All pods", - "morePods": "more ▾", "searchPlaceholder": "Search files", "kinds": { "all": "All", @@ -2647,25 +2652,11 @@ "docs": "Docs", "pages": "Pages" }, - "kindNames": { - "image": "image", - "page": "page", - "doc": "doc" - }, - "columns": { - "file": "File", - "kind": "Kind", - "pod": "Pod", - "sharedBy": "Shared by", - "when": "When", - "size": "Size" - }, "foot": "{{shown}} of {{total}} · newest first", "more": "{{count}} more", "loadingMore": "Loading…", "dayZero": { - "title": "Nothing shared yet", - "body": "Files you and your agents share in a pod land here: docs, images and pages.", + "body": "Files you and your agents share in a pod land here.", "open": "Open {{pod}}" }, "emptyFiltered": "Nothing matches. Clear a filter or the search.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 39b719d10a..5e08e9b3ec 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2635,12 +2635,17 @@ }, "artifacts": { "title": "产物", - "about": "关于产物", - "meta": "你所有 Pod 中共享的文件与页面", + "fileCount_one": "{{count}}个文件", + "fileCount_other": "{{count}}个文件", + "podCount_one": "{{count}}个 Pod", + "podCount_other": "{{count}}个 Pod", + "open": "打开", + "openFile": "打开 {{name}}", + "download": "下载", + "downloadFile": "下载 {{name}}", "kindLabel": "类型", "podLabel": "Pod", "allPods": "全部 Pod", - "morePods": "更多 ▾", "searchPlaceholder": "搜索文件", "kinds": { "all": "全部", @@ -2648,25 +2653,11 @@ "docs": "文档", "pages": "页面" }, - "kindNames": { - "image": "图片", - "page": "页面", - "doc": "文档" - }, - "columns": { - "file": "文件", - "kind": "类型", - "pod": "Pod", - "sharedBy": "分享者", - "when": "时间", - "size": "大小" - }, "foot": "{{shown}} / {{total}} · 最新在前", "more": "还有{{count}}项", "loadingMore": "加载中…", "dayZero": { - "title": "还没有分享内容", - "body": "你和你的智能体在 Pod 里分享的文件会出现在这里,包括文档、图片和页面。", + "body": "你和你的智能体在 Pod 里分享的文件会出现在这里。", "open": "打开 {{pod}}" }, "emptyFiltered": "没有匹配项。清除筛选或搜索。", diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index c1412e1f7a..1a3e41e872 100644 --- a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx @@ -1,10 +1,12 @@ // @ts-nocheck -// Artifacts, direction C (ScaleArtifacts.dc.html) — PR 5. +// Artifacts, d215 #s-artifacts-signal: one group per pod, a type badge, name, " · ", +// a trailing size and hover keys; one kind segment and one pod select in the head. import React from 'react'; -import { render, screen, fireEvent, waitFor, within, cleanup, act } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import axios from 'axios'; -import V2ArtifactsPage, { extOf, formatSize, whenLabel } from '../components/V2ArtifactsPage'; +import i18n from '../../i18n'; +import V2ArtifactsPage, { extOf, formatSize, whenLabel, groupByPod } from '../components/V2ArtifactsPage'; jest.mock('axios', () => { const mock = { get: jest.fn(), post: jest.fn(), defaults: { baseURL: '', headers: { common: {} } }, interceptors: { request: { use: jest.fn(), eject: jest.fn() }, response: { use: jest.fn(), eject: jest.fn() } } }; @@ -16,20 +18,26 @@ jest.mock('../../utils/signedAttachmentUrl', () => ({ getSignedAttachmentUrl: (. const minutesAgo = (m) => new Date(Date.now() - m * 60000).toISOString(); const items = [ { id: 'a1', fileName: 'x1.html', name: 'workspace-at-scale.html', contentType: 'text/html', kind: 'page', size: 5120, podId: 'p1', podName: 'Sharpen — pod model', sharedBy: { id: 'u1', username: 'lily-shen', displayName: null }, createdAt: minutesAgo(20) }, + { id: 'a3', fileName: 'x3.md', name: 'plan.md', contentType: 'text/markdown', kind: 'doc', size: 4096, podId: 'p2', podName: 'Connectors v2 — channel routing', sharedBy: { id: 'b2', username: 'kai', displayName: 'Kai' }, createdAt: minutesAgo(60) }, { id: 'a2', fileName: 'x2.png', name: 'walk-1440.png', contentType: 'image/png', kind: 'image', size: 421888, podId: 'p1', podName: 'Sharpen — pod model', sharedBy: { id: 'b1', username: 'ux-lead', displayName: 'UX Lead' }, createdAt: minutesAgo(90) }, - { id: 'a3', fileName: 'x3.md', name: 'plan.md', contentType: 'text/markdown', kind: 'doc', size: 4096, podId: 'p2', podName: 'Connectors v2 — channel routing', sharedBy: { id: 'b2', username: 'kai', displayName: 'Kai' }, createdAt: minutesAgo(60 * 26) }, ]; +const setPhone = (phone) => { + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: phone && query.includes('max-width'), media: query, addEventListener: jest.fn(), removeEventListener: jest.fn(), addListener: jest.fn(), removeListener: jest.fn(), + })); +}; + const renderPage = (initial = '/v2/artifacts', over = {}) => { axios.get.mockImplementation((url, config) => { if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Sharpen — pod model' }, { _id: 'p2', name: 'Connectors v2 — channel routing' }, { _id: 'p3', name: 'YC S27 prep' }] }); if (url === '/api/artifacts') { const params = config?.params || {}; - let list = items; + let list = over.items || items; if (params.podId) list = list.filter((i) => i.podId === params.podId); if (params.kind) list = list.filter((i) => i.kind === params.kind); if (params.q) list = list.filter((i) => i.name.toLowerCase().includes(String(params.q).toLowerCase())); - if (params.after === 'cur-1') return Promise.resolve({ data: { items: [{ ...items[2], id: 'a4', name: 'skydeck-b23.pdf', kind: 'doc' }], nextCursor: null, total: 4, limit: 50 } }); + if (params.after === 'cur-1') return Promise.resolve({ data: { items: [{ ...items[1], id: 'a4', name: 'skydeck-b23.pdf', kind: 'doc' }], nextCursor: null, total: 4, limit: 50 } }); return Promise.resolve({ data: { items: list, nextCursor: over.nextCursor ?? null, total: over.total ?? list.length, limit: 50 } }); } return Promise.resolve({ data: {} }); @@ -37,10 +45,15 @@ const renderPage = (initial = '/v2/artifacts', over = {}) => { return render(); }; -describe('V2ArtifactsPage (direction C, PR 5)', () => { - beforeEach(() => { jest.clearAllMocks(); }); +const groupNamed = (name) => screen.getByRole('region', { name }); +const rowOf = (fileName) => screen.getByRole('button', { name: fileName }).closest('li'); - test('helpers: ext chip is the file’s own extension, size and when use the shared vocabulary', () => { +describe('V2ArtifactsPage (d215 signal)', () => { + const realMatchMedia = window.matchMedia; + beforeEach(() => { jest.clearAllMocks(); setPhone(false); }); + afterEach(() => { window.matchMedia = realMatchMedia; }); + + test('helpers: the badge is the file’s own extension, size and when use the shared vocabulary', () => { expect(extOf('plan.md')).toBe('MD'); expect(extOf('archive.tar.gz')).toBe('GZ'); expect(extOf('noext')).toBe('FILE'); @@ -51,63 +64,109 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { expect(whenLabel(minutesAgo(60 * 24 * 40))).toBe('1mo'); }); - test('renders the board: display head, three columns of meta, ext chips, short pod names, shared-by labels, newest first, foot count', async () => { - renderPage(); - expect(await screen.findByRole('heading', { name: 'Artifacts' })).toBeInTheDocument(); - expect(screen.getByText('every file and page shared in your pods')).toBeInTheDocument(); - const rows = await screen.findAllByRole('row'); - // header + 3 - expect(rows).toHaveLength(4); - const first = within(rows[1]); + test('whenLabel reads in Chinese under zh-CN', async () => { + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + expect(whenLabel(minutesAgo(41))).not.toBe('41m'); + expect(whenLabel(minutesAgo(41))).toMatch(/41/); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + test('groupByPod: one group per pod, first-seen order, rows newest first inside each', () => { + const groups = groupByPod(items); + expect(groups.map((g) => g.name)).toEqual(['Sharpen', 'Connectors v2']); + expect(groups[0].items.map((i) => i.id)).toEqual(['a1', 'a2']); + expect(groupByPod([{ ...items[0], podId: null, podName: null }])[0].name).toBe('—'); + }); + + test('busy: head count, one group per pod, each row a badge, the name, " · " and a trailing size', async () => { + const { container } = renderPage(); + expect(await screen.findByRole('heading', { level: 1, name: 'Artifacts' })).toBeInTheDocument(); + await screen.findByRole('button', { name: 'workspace-at-scale.html' }); + // No (i) hint in the redraw; the count says files and, with every file loaded, pods. + expect(screen.queryByText('every file and page shared in your pods')).not.toBeInTheDocument(); + expect(container.querySelector('.v2a-meta')).toHaveTextContent('3 files · 2 pods'); + // No table chrome. + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader')).not.toBeInTheDocument(); + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Sharpen', 'Connectors v2']); + const sharpen = within(groupNamed('Sharpen')); + expect(sharpen.getAllByRole('listitem')).toHaveLength(2); + const first = within(rowOf('workspace-at-scale.html')); expect(first.getByText('HTML')).toBeInTheDocument(); - expect(first.getByRole('button', { name: 'workspace-at-scale.html' })).toBeInTheDocument(); - expect(first.getByText('page')).toBeInTheDocument(); - // The pod cell plus the ≤760 pod line under the name (hidden by CSS on desktop). - expect(first.getAllByText('Sharpen')).toHaveLength(2); - expect(first.getByText('lily-shen')).toBeInTheDocument(); - expect(first.getByText('20m')).toBeInTheDocument(); - // A page has no size on the board. - expect(first.getAllByText('—').length).toBe(1); - const second = within(rows[2]); + expect(first.getByText(/lily-shen · 20m/)).toBeInTheDocument(); + // A page has no size. + expect(first.getByText('—')).toBeInTheDocument(); + const second = within(rowOf('walk-1440.png')); expect(second.getByText('PNG')).toBeInTheDocument(); - expect(second.getByText('UX Lead')).toBeInTheDocument(); + expect(second.getByText(/UX Lead · 1h/)).toBeInTheDocument(); expect(second.getByText('412 KB')).toBeInTheDocument(); - expect(within(rows[3]).getAllByText('Connectors v2').length).toBeGreaterThan(0); + expect(second.getByRole('button', { name: 'Open walk-1440.png' })).toBeInTheDocument(); + expect(second.getByRole('button', { name: 'Download walk-1440.png' })).toBeInTheDocument(); + expect(within(groupNamed('Connectors v2')).getByRole('button', { name: 'plan.md' })).toBeInTheDocument(); expect(screen.getByText('3 of 3 · newest first')).toBeInTheDocument(); expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: { limit: 50 } })); - // Busy is unchanged: the controls and the count stay; no day-zero card. - expect(screen.getByRole('group', { name: 'Kind' })).toBeInTheDocument(); + // One segment, one select, one search. + expect(screen.getByRole('radiogroup', { name: 'Kind' })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Pod' })).toBeInTheDocument(); expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); + }); + + test('a CJK name shows as stored, in the row, the lightbox and the key labels', async () => { + const cjk = { ...items[2], id: 'c1', name: '季度报告.png' }; + renderPage('/v2/artifacts', { items: [cjk] }); + const name = await screen.findByRole('button', { name: '季度报告.png' }); + expect(name).toHaveTextContent('季度报告.png'); + expect(screen.getByRole('button', { name: 'Download 季度报告.png' })).toBeInTheDocument(); + fireEvent.click(name); + expect(await screen.findByRole('dialog')).toHaveTextContent('季度报告.png'); + }); + + test('the pod count waits until every file is loaded', async () => { + const { container } = renderPage('/v2/artifacts', { nextCursor: 'cur-1', total: 4 }); + await screen.findByText('3 of 4 · newest first'); + expect(container.querySelector('.v2a-meta')).toHaveTextContent(/^4 files$/); + fireEvent.click(screen.getByRole('button', { name: '1 more' })); + await screen.findByText('4 of 4 · newest first'); + expect(container.querySelector('.v2a-meta')).toHaveTextContent('4 files · 2 pods'); }); test('kind, pod and search filters travel through the URL to the one query', async () => { renderPage(); await screen.findByRole('button', { name: 'workspace-at-scale.html' }); - fireEvent.click(screen.getByRole('button', { name: 'Images' })); + fireEvent.click(screen.getByRole('radio', { name: 'Images' })); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: expect.objectContaining({ kind: 'image' }) }))); expect(await screen.findByRole('button', { name: 'walk-1440.png' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'plan.md' })).not.toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Images' })).toHaveAttribute('aria-checked', 'true'); - fireEvent.click(screen.getByRole('button', { name: 'All' })); - fireEvent.click(screen.getByRole('button', { name: 'Connectors v2' })); + fireEvent.click(screen.getByRole('radio', { name: 'All' })); + const pod = screen.getByRole('combobox', { name: 'Pod' }); + // Every pod is in the select, by its short name, after All pods. + expect(within(pod).getAllByRole('option').map((o) => o.textContent)).toEqual(['All pods', 'Sharpen', 'Connectors v2', 'YC S27 prep']); + fireEvent.change(pod, { target: { value: 'p2' } }); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: expect.objectContaining({ podId: 'p2' }) }))); expect(await screen.findByRole('button', { name: 'plan.md' })).toBeInTheDocument(); + // A chosen pod leaves one group. + expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Connectors v2']); - fireEvent.click(screen.getByRole('button', { name: 'All pods' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Pod' }), { target: { value: '' } }); fireEvent.change(screen.getByRole('searchbox', { name: 'Search files' }), { target: { value: 'walk' } }); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: expect.objectContaining({ q: 'walk' }) }))); expect(await screen.findByRole('button', { name: 'walk-1440.png' })).toBeInTheDocument(); }); - test('a podId in the URL (the inspector’s All N files link) scopes the first read', async () => { + test('a podId in the URL (the inspector’s All N files link) scopes the first read and shows in the select', async () => { renderPage('/v2/artifacts?podId=p2'); expect(await screen.findByRole('button', { name: 'plan.md' })).toBeInTheDocument(); expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: expect.objectContaining({ podId: 'p2' }) })); expect(screen.queryByRole('button', { name: 'walk-1440.png' })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('combobox', { name: 'Pod' })).toHaveValue('p2')); }); - test('pages by cursor from the foot and opens a file through a signed URL', async () => { + test('pages by cursor from the foot and opens a file through a signed URL, from the name or the open key', async () => { const open = jest.spyOn(window, 'open').mockImplementation(() => null); renderPage('/v2/artifacts', { nextCursor: 'cur-1', total: 4 }); await screen.findByText('3 of 4 · newest first'); @@ -115,17 +174,68 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { expect(await screen.findByRole('button', { name: 'skydeck-b23.pdf' })).toBeInTheDocument(); expect(axios.get).toHaveBeenCalledWith('/api/artifacts', expect.objectContaining({ params: expect.objectContaining({ after: 'cur-1' }) })); expect(screen.getByText('4 of 4 · newest first')).toBeInTheDocument(); + // The second page lands in the group it belongs to, not a new one. + expect(within(groupNamed('Connectors v2')).getByRole('button', { name: 'skydeck-b23.pdf' })).toBeInTheDocument(); // A doc opens in a new tab through a signed URL; an image opens the chat lightbox. fireEvent.click(screen.getByRole('button', { name: 'plan.md' })); await waitFor(() => expect(mockSigned).toHaveBeenCalledWith('/api/uploads/x3.md')); await waitFor(() => expect(open).toHaveBeenCalledWith('/api/uploads/x3.md?t=signed', '_blank', 'noopener')); + fireEvent.click(screen.getByRole('button', { name: 'Open plan.md' })); + await waitFor(() => expect(open).toHaveBeenCalledTimes(2)); fireEvent.click(screen.getByRole('button', { name: 'walk-1440.png' })); expect(await screen.findByRole('dialog')).toBeInTheDocument(); - expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledTimes(2); open.mockRestore(); }); - // Day zero (d215 s-artifacts): nothing shared and no filter set → no controls, no count, one card. + test('download saves the bytes from the signed URL under the file’s own name', async () => { + const blob = new Blob(['x']); + global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: async () => blob }); + URL.createObjectURL = jest.fn(() => 'blob:artifact'); + URL.revokeObjectURL = jest.fn(); + const clicked = []; + const click = jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function record() { clicked.push({ href: this.href, download: this.download }); }); + const open = jest.spyOn(window, 'open').mockImplementation(() => null); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Download plan.md' })); + await waitFor(() => expect(clicked).toEqual([{ href: 'blob:artifact', download: 'plan.md' }])); + expect(mockSigned).toHaveBeenCalledWith('/api/uploads/x3.md'); + expect(global.fetch).toHaveBeenCalledWith('/api/uploads/x3.md?t=signed'); + expect(open).not.toHaveBeenCalled(); + // When the bytes can't be read, the signed URL opens and the server decides. + global.fetch = jest.fn().mockRejectedValue(new TypeError('cors')); + fireEvent.click(screen.getByRole('button', { name: 'Download walk-1440.png' })); + await waitFor(() => expect(open).toHaveBeenCalledWith('/api/uploads/x2.png?t=signed', '_blank', 'noopener')); + click.mockRestore(); open.mockRestore(); delete global.fetch; + }); + + test('390: the search is the top bar key, kind and pod share the control row, the size folds into the line, ↓ is the one key', async () => { + setPhone(true); + renderPage(); + await screen.findByRole('button', { name: 'walk-1440.png' }); + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + const key = screen.getByRole('button', { name: 'Search files' }); + expect(key).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(key); + expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument(); + expect(key).toHaveAttribute('aria-expanded', 'true'); + const row = within(rowOf('walk-1440.png')); + expect(row.getByText('412 KB').closest('.v2a-sn')).not.toBeNull(); + expect(row.queryByRole('button', { name: 'Open walk-1440.png' })).not.toBeInTheDocument(); + expect(row.getByRole('button', { name: 'Download walk-1440.png' })).toBeInTheDocument(); + // A page has no size, so its line stops at the time. + expect(within(rowOf('workspace-at-scale.html')).queryByText('—')).not.toBeInTheDocument(); + expect(document.querySelector('.v2a-ctl')).toContainElement(screen.getByRole('radiogroup', { name: 'Kind' })); + expect(document.querySelector('.v2a-ctl')).toContainElement(screen.getByRole('combobox', { name: 'Pod' })); + }); + + test('390: a search in the URL keeps the field open', async () => { + setPhone(true); + renderPage('/v2/artifacts?q=walk'); + expect(await screen.findByRole('searchbox', { name: 'Search files' })).toHaveValue('walk'); + }); + + // Day zero: nothing shared and no filter set → no controls, no count, one quiet row. const renderEmpty = (initial, { pods = [] } = {}) => { axios.get.mockImplementation((url) => { if (url === '/api/pods') return Promise.resolve({ data: pods }); @@ -136,18 +246,20 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { }; const workspace = { _id: 'w1', name: 'My Workspace', createdAt: '2026-10-01T00:00:00.000Z' }; - test('day zero: no kind, pod or search controls and no count; one card whose key opens My Workspace', async () => { + test.each([[false], [true]])('day zero (phone %s): no controls and no count; one row whose key opens My Workspace', async (phone) => { + setPhone(phone); const { container } = renderEmpty('/v2/artifacts', { pods: [workspace] }); - expect(await screen.findByRole('heading', { name: 'Nothing shared yet' })).toBeInTheDocument(); - expect(screen.getByText('Files you and your agents share in a pod land here: docs, images and pages.')).toBeInTheDocument(); + expect(await screen.findByText('Files you and your agents share in a pod land here.')).toBeInTheDocument(); const open = await screen.findByRole('link', { name: 'Open My Workspace' }); expect(open).toHaveAttribute('href', '/v2/pods/w1'); - expect(open).toHaveClass('v2-artifacts__open'); - expect(screen.queryByRole('group', { name: 'Kind' })).not.toBeInTheDocument(); - expect(screen.queryByRole('group', { name: 'Pod' })).not.toBeInTheDocument(); + expect(open).toHaveClass('v2a-open'); + expect(screen.queryByRole('heading', { level: 2 })).not.toBeInTheDocument(); + expect(screen.queryByRole('radiogroup', { name: 'Kind' })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: 'Pod' })).not.toBeInTheDocument(); expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); - expect(container.querySelector('.v2-artifacts__controls')).toBeNull(); - expect(container.querySelector('.v2-page-head__meta')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Search files' })).not.toBeInTheDocument(); + expect(container.querySelector('.v2a-meta')).toBeNull(); + expect(container.querySelector(phone ? '.v2a-row--open' : '.v2a-row--one')).not.toBeNull(); expect(screen.queryByText(/Nothing matches/)).not.toBeInTheDocument(); }); @@ -171,33 +283,40 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { record.removedNodes.forEach((node) => { if (node.classList) seen.push(`rm:${node.className}`); }); })); observer.observe(container, { childList: true, subtree: true }); - const all = within(screen.getByRole('group', { name: 'Pod' })).getByRole('button', { name: 'All pods' }); - all.focus(); - fireEvent.click(all); + const pod = screen.getByRole('combobox', { name: 'Pod' }); + pod.focus(); + fireEvent.change(pod, { target: { value: '' } }); await screen.findByRole('button', { name: 'workspace-at-scale.html' }); observer.disconnect(); - expect(seen.filter((entry) => /v2-artifacts__(zero|controls)/.test(entry))).toEqual([]); - expect(document.activeElement).toBe(within(screen.getByRole('group', { name: 'Pod' })).getByRole('button', { name: 'All pods' })); + expect(seen.filter((entry) => /v2a-(row--one|row--open|sel|search|seg)\b/.test(entry))).toEqual([]); + expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'Pod' })); }); - test('day zero with no pods keeps the card and omits the key', async () => { + test('day zero with no pods keeps the row and omits the key', async () => { renderEmpty('/v2/artifacts'); - expect(await screen.findByRole('heading', { name: 'Nothing shared yet' })).toBeInTheDocument(); + expect(await screen.findByText('Files you and your agents share in a pod land here.')).toBeInTheDocument(); await waitFor(() => expect(axios.get).toHaveBeenCalledWith('/api/pods', expect.anything())); expect(screen.queryByRole('link')).not.toBeInTheDocument(); }); - test('a filtered empty result keeps the controls, the count and the dashed line, not the card', async () => { + test('a filtered empty result keeps the controls, the count and one row, not day zero', async () => { const { container } = renderEmpty('/v2/artifacts?kind=image', { pods: [workspace] }); expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument(); - expect(screen.getByRole('group', { name: 'Kind' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Images' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('radiogroup', { name: 'Kind' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Images' })).toHaveAttribute('aria-checked', 'true'); expect(screen.getByRole('searchbox', { name: 'Search files' })).toBeInTheDocument(); - expect(container.querySelector('.v2-page-head__meta')).toHaveTextContent('0'); - expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); + expect(container.querySelector('.v2a-meta')).toHaveTextContent('0 files · 0 pods'); + expect(screen.queryByText('Files you and your agents share in a pod land here.')).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /^Open / })).not.toBeInTheDocument(); }); + test('a load failure is one alert row, never day zero', async () => { + axios.get.mockImplementation((url) => (url === '/api/artifacts' ? Promise.reject(new Error('500')) : Promise.resolve({ data: [] }))); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load artifacts.'); + expect(screen.queryByText('Files you and your agents share in a pod land here.')).not.toBeInTheDocument(); + }); + test('typing a search before the first empty answer lands keeps the search box and focus', async () => { let answer; axios.get.mockImplementation((url) => { @@ -211,7 +330,7 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { fireEvent.change(search, { target: { value: 'pl' } }); // The unfiltered answer lands inside the 250ms debounce, before ?q reaches the URL. await act(async () => { answer({ data: { items: [], nextCursor: null, total: 0, limit: 50 } }); }); - expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); + expect(screen.queryByText('Files you and your agents share in a pod land here.')).not.toBeInTheDocument(); expect(screen.getByRole('searchbox', { name: 'Search files' })).toBe(search); expect(document.activeElement).toBe(search); }); @@ -220,9 +339,9 @@ describe('V2ArtifactsPage (direction C, PR 5)', () => { ['a search', '/v2/artifacts?q=unmatched'], ['a pod', '/v2/artifacts?podId=w1'], ])('%s with no matches keeps the controls and the filtered line, never day zero', async (_label, url) => { - const { container } = renderEmpty(url, { pods: [workspace] }); + renderEmpty(url, { pods: [workspace] }); expect(await screen.findByText('Nothing matches. Clear a filter or the search.')).toBeInTheDocument(); - expect(container.querySelector('.v2-artifacts__controls')).not.toBeNull(); - expect(screen.queryByRole('heading', { name: 'Nothing shared yet' })).not.toBeInTheDocument(); + expect(screen.getByRole('radiogroup', { name: 'Kind' })).toBeInTheDocument(); + expect(screen.queryByText('Files you and your agents share in a pod land here.')).not.toBeInTheDocument(); }); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 9077a83607..2478e838db 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -174,34 +174,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(phone).toMatch(/\.v2-profile \.v2-profile__follow, \.v2-profile \.v2-profile__edit \{[^}]*flex: 1 0 100%/); }); - test('/v2/artifacts: on a phone the page head is the pinned 52px bar (d215 .mtop 390)', () => { + test('/v2/activity and /v2/artifacts: the shared phone head bar left v2.css with both pages (d215 builds)', () => { const v2 = readV2Css(); - const barSelector = '.v2-artifacts__head {'; - const phone = mediaBlockContaining(v2, ' .v2-artifacts__head {\n position: sticky;'); - // The head renders inside the scrolling .v2-feature__body; sticky is what keeps it on screen. - const bar = selectorRuleBody(phone, barSelector); - expect(bar).toContain('position: sticky'); - expect(bar).toContain('top: 0'); - expect(bar).toContain('min-height: 52px'); - expect(bar).toContain('align-items: center'); - expect(bar).toContain('border-bottom: 1px solid var(--v2-border-soft)'); - // Opaque, or rows scroll visibly under the bar. - expect(bar).toContain('background: var(--v2-surface)'); - // The compact 22px title, not the desktop 32px display head. - expect(selectorRuleBody(phone, '.v2-artifacts__head .v2-artifacts__title {')).toContain('font: 800 22px/1'); - // The meta sits at the bar's right edge and truncates rather than wrapping. - expect(selectorRuleBody(phone, '.v2-artifacts__head .v2-page-head__meta {')).toContain('margin-left: auto'); - // Artifacts pads its column 12px; the bar bleeds back out to the pane edges. - // ...and the column's 16px phone top padding is dropped, so the bar starts at 0,0 before any scroll. - expect(phone).toContain('.v2-artifacts { padding-top: 0; }'); - expect(phone).toContain('.v2-artifacts__head { margin: 0 -12px; }'); - expect(v2.indexOf(phone)).toBeGreaterThan(v2.indexOf('\n.v2-artifacts__head {')); - // ...and after the Artifacts phone rule, whose 16px top padding would push the bar down until scrolled. - const artifactsPhonePad = v2.indexOf('.v2-artifacts { padding: 16px 12px 0;'); - expect(artifactsPhonePad).toBeGreaterThan(-1); - expect(v2.indexOf(phone)).toBeGreaterThan(artifactsPhonePad); - // Activity left this shared bar for its own sheet (d215 activity build). + // Each page now pins its own 52px bar in its own sheet (V2ActivityPage.css, V2ArtifactsPage.css), + // guarded in their describes below; a copy left here would fight those rules by source order. expect(v2).not.toContain('v2-activity'); + expect(v2).not.toContain('v2-artifacts'); }); @@ -956,64 +934,74 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toContain(':not(.v2-pods-aside):not(.v2-pane--inspector)'); }); - test('Artifacts (direction C, PR 5): display head, one segment grammar, bordered ext chip, mono numbers, phone block', () => { - const artifactsPage = fs.readFileSync(path.join(__dirname, '../components/V2ArtifactsPage.tsx'), 'utf8'); + test('Artifacts (d215 #s-artifacts-signal): .ph head, one .seg and one .sel, pod groups of hairline rows, hover keys, 390 block', () => { + const page = read('../components/V2ArtifactsPage.tsx'); + const ac = read('../components/V2ArtifactsPage.css'); expect(v2App).toContain("'v2-feature--artifacts'"); expect(v2App).toContain(''); - expect(ruleBody(v2, '.v2-artifacts__title')).toContain('32px/1.1 var(--v2-font-display)'); - expect(ruleBody(v2, '.v2-page-head__meta')).toContain('color: var(--v2-text-muted)'); - expect(ruleBody(v2, '.v2-artifacts__seg')).toContain('border: 1px solid var(--v2-border)'); - expect(ruleBody(v2, '.v2-root button.v2-artifacts__seg-button--active')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root button.v2-artifacts__seg-button--active')).not.toContain('var(--v2-ink)'); - const ext = ruleBody(v2, '.v2-artifacts__ext'); - expect(ext).toContain('border: 1px solid var(--v2-border)'); + expect(page).toContain("import './V2ArtifactsPage.css';"); + // The page fills the pane; its head stays put and the body scrolls under it. + expect(ruleBody(ac, '.v2-feature--artifacts > .v2-feature__body')).toContain('overflow: hidden'); + expect(ruleBody(ac, '.v2a-body')).toContain('overflow-y: auto'); + // The retired table and two pill groups are gone from the sheet and the page. + expect(v2).not.toMatch(/\.v2-artifacts__(table|seg|controls|zero|head)\b/); + expect(page).not.toMatch(/\s* setQ(event.target.value)} /> + + + ); + const kindSeg = ( + + className="v2a-seg" + ariaLabel={t('artifacts.kindLabel')} + value={kind} + onChange={(value) => setParam('kind', value)} + options={KINDS.map((option) => ({ value: option.key, label: t(`artifacts.kinds.${option.label}`) }))} + /> + ); + // A pod id in the URL that /api/pods does not list (yet) still reads as chosen. + const podOptions = podId && !pods.some((pod) => pod._id === podId) ? [...pods, { _id: podId, name: podId }] : pods; + const podSel = ( + + ); + const showSearch = !phone || searchOpen || Boolean(q); return ( -
      - - {!dayZero && ( -
      -
      - {KINDS.map((option) => ( - - ))} -
      -
      { if (event.key === 'Escape') setPodMenuOpen(false); }}> - - {scopedPods.slice(0, 2).map((pod) => ( - - ))} - {scopedPods.length > 2 && ( - - )} - {podMenuOpen && ( -
      - {scopedPods.slice(2).map((pod) => ( - - ))} -
      - )} -
      - -
      - )} +
      +
      +

      {t('artifacts.title')}

      + {meta && {meta}} + + {!dayZero && !phone && ( + <> + {searchField} + {kindSeg} + {podSel} + + )} + {!dayZero && phone && ( + + )} +
      + +
      +
      + {!dayZero && phone && ( + <> + {showSearch && searchField} +
      {kindSeg}{podSel}
      + + )} - {error &&
      {error}
      } - {loading && !error &&
      } - {dayZero && ( -
      -

      {t('artifacts.dayZero.title')}

      -

      {t('artifacts.dayZero.body')}

      - {workspacePod && {t('artifacts.dayZero.open', { pod: workspacePod.name })}} -
      - )} - {!loading && !error && items.length === 0 && (urlQ || kind || podId) && ( -
      {t('artifacts.emptyFiltered')}
      - )} - {!loading && items.length > 0 && ( -
      - - - - - - - - - - - - - {items.map((item) => ( - - - - - - - - - ))} - -
      {t('artifacts.columns.file')}{t('artifacts.columns.kind')}{t('artifacts.columns.pod')}{t('artifacts.columns.sharedBy')}{t('artifacts.columns.when')}{t('artifacts.columns.size')}
      -
      - - - {/* ≤760 (66462 miss 2): the pod folds under the name as a mono line; the pod column hides. */} - {shortPodName(item.podName)} + {error &&
      {error}
      } + {loading && !error &&
      } + {dayZero && ( +
      +
      + +
      +

      {t('artifacts.dayZero.body')}

      + {phone && workspacePod && ( +
      + {t('artifacts.dayZero.open', { pod: workspacePod.name })}
      -
      {t(`artifacts.kindNames.${item.kind}`)}{shortPodName(item.podName)}{sharedByLabel(item)}{whenLabel(item.createdAt)}{item.kind === 'page' ? '—' : formatSize(item.size)}
      + )} +
      + {!phone && workspacePod && ( + + {t('artifacts.dayZero.open', { pod: workspacePod.name })} + + )} +
      +
      + )} + {!loading && !error && items.length === 0 && (urlQ || kind || podId) && ( +

      {t('artifacts.emptyFiltered')}

      + )} + {!loading && groups.map((group) => ( +
      +

      {group.name}

      +
        + {group.items.map((item) => { + const size = sizeLabel(item); + return ( +
      • + +
        + +
        + {sharedByLabel(item)} · {whenLabel(item.createdAt)} + {phone && size !== '—' && <> · {size}} +
        +
        + + + {!phone && ( + + )} + + + {!phone && {size}} + +
      • + ); + })} +
      +
      + ))} + {!loading && items.length > 0 && ( +
      + {t('artifacts.foot', { shown: items.length, total })} + {nextCursor && } +
      + )}
      - )} +
      {lightbox && setLightbox(null)} />} - {!loading && items.length > 0 && ( -
      - {t('artifacts.foot', { shown: items.length, total })} - {nextCursor && } -
      - )}
      ); }; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 82b3beb6d5..6ad5a4388e 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -3553,7 +3553,7 @@ body.modern-ui.v2-canvas { * ------------------------------------------------------------------------ */ .v2-root:lang(zh) .v2-rail__brand, .v2-root:lang(zh) .v2-tools__count strong, -.v2-root:lang(zh) .v2-artifacts__title, +.v2-root:lang(zh) .v2a-title, .v2-root:lang(zh) .v2-pods__page-title, .v2-root:lang(zh) .v2-first-run__title, .v2-root:lang(zh) .v2-login__title, @@ -4749,72 +4749,6 @@ body.modern-ui.v2-canvas { .v2-rail__item-icon .v2-rail__badge--activity .MuiBadge-badge { right: -6px; } -/* --------------------------------------------------------------------------- - Artifacts — direction C, PR 5 (ScaleArtifacts.dc.html). - Every file and page shared in your pods. Display head + muted meta; two - segment groups on one grammar (bordered white group, active = tint + ink - 600); a bordered search; a plain table whose numbers are mono; the ext chip - is the file's own extension, bordered mono, never a colour square. - --------------------------------------------------------------------------- */ -.v2-feature--artifacts > .v2-feature__body { padding: 0 0 28px; } -.v2-feature--artifacts .v2-feature__legacy { max-width: none; } -.v2-artifacts { display: flex; flex-direction: column; gap: 20px; width: 100%; padding: 20px 16px 0; } -.v2-artifacts__head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } -.v2-artifacts__title { margin: 0; font: 700 32px/1.1 var(--v2-font-display); letter-spacing: -0.03em; color: var(--v2-text-primary); } -.v2-artifacts__controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } -.v2-artifacts__seg { position: relative; display: flex; flex-wrap: nowrap; gap: 2px; padding: 3px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); } -.v2-root button.v2-artifacts__seg-button { min-height: 30px; max-width: 150px; padding: 0 9px; border: 0; border-radius: calc(var(--v2-radius-sm) - 2px); background: transparent; color: var(--v2-text-secondary); font: 600 11px/16px var(--v2-font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.v2-root button.v2-artifacts__seg-button--active { background: var(--v2-surface-hover); color: var(--v2-text-primary); font-weight: 600; } -.v2-root button.v2-artifacts__seg-button--menu { width: 100%; max-width: none; white-space: normal; text-align: left; } -.v2-artifacts__seg-menu { position: absolute; top: calc(100% + 4px); right: 0; z-index: 3; display: grid; gap: 2px; width: min(300px, 85vw); max-height: 240px; overflow-y: auto; padding: 4px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); } -.v2-artifacts__search { display: flex; align-items: center; gap: 8px; min-width: 220px; height: 36px; margin-left: auto; padding: 0 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); } -.v2-artifacts__search input { flex: 1 1 auto; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--v2-text-primary); font: 400 13px/18px var(--v2-font); } -.v2-artifacts__search input::placeholder { color: var(--v2-text-muted); } -.v2-artifacts__search:focus-within { border-color: var(--v2-accent); } -.v2-artifacts__search-key { color: var(--v2-text-muted); font: 500 11px/16px var(--v2-font-mono); } -.v2-artifacts__table-wrap { overflow-x: auto; border: 1px solid var(--v2-border); border-radius: 6px; background: var(--v2-surface); } -.v2-artifacts__table { width: 100%; border-collapse: collapse; font: 400 13px/18px var(--v2-font); color: var(--v2-text-primary); } -.v2-artifacts__table th { padding: 8px 12px; border-bottom: 1px solid var(--v2-border-soft); color: var(--v2-text-muted); font: 500 11px/16px var(--v2-font-mono); text-align: left; text-transform: lowercase; white-space: nowrap; } -.v2-artifacts__table td { padding: 9px 12px; border-bottom: 1px solid var(--v2-border-soft); vertical-align: middle; } -.v2-artifacts__table tr:last-child td { border-bottom: 0; } -.v2-artifacts__row:hover td { background: var(--v2-surface-hover); } -.v2-artifacts__file { display: flex; align-items: center; gap: 10px; min-width: 0; } -.v2-artifacts__ext { display: inline-flex; align-items: center; justify-content: center; min-width: 34px; height: 20px; padding: 0 5px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); color: var(--v2-text-secondary); font: 500 11px/14px var(--v2-font-mono); letter-spacing: 0.02em; } -.v2-artifacts__pod-line { display: none; color: var(--v2-text-secondary); font: 500 11px/16px var(--v2-font-mono); } -.v2-root button.v2-artifacts__name { min-width: 0; padding: 0; border: 0; background: transparent; color: var(--v2-text-primary); font: 600 13px/18px var(--v2-font); text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.v2-root button.v2-artifacts__name:hover { color: var(--v2-accent-text); } -.v2-artifacts__mono { color: var(--v2-text-secondary); font: 500 11px/16px var(--v2-font-mono); white-space: nowrap; } -.v2-artifacts__foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.v2-root button.v2-artifacts__more { padding: 6px 4px; border: 0; background: transparent; color: var(--v2-accent-text); font: 600 12px/16px var(--v2-font); } -.v2-artifacts__empty { padding: 18px 20px; border: 1px dashed var(--v2-border); border-radius: var(--v2-radius); color: var(--v2-text-secondary); font: 400 13px/18px var(--v2-font); } -.v2-artifacts__error { padding: 12px 14px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); color: var(--v2-text-primary); font: 400 13px/18px var(--v2-font); } -.v2-artifacts__loading { display: grid; place-items: center; padding: 24px; } -/* Day zero (d215 s-artifacts): nothing shared and no filter set, so the page drops its controls and - count for one bordered card — Get started's card grammar (14px 20px lines, ink key, 32px at desktop, - 44 full width at ≤760). The key links to the account's oldest pod, the one signup creates. */ -.v2-artifacts__zero { width: min(100%, 760px); padding: 14px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius); background: var(--v2-surface); } -.v2-artifacts__zero h2 { margin: 0; color: var(--v2-text-primary); font: 600 14px/20px var(--v2-font); } -.v2-artifacts__zero p { margin: 2px 0 0; color: var(--v2-text-secondary); font: 400 14px/20px var(--v2-font); } -.v2-root a.v2-artifacts__open { display: inline-flex; align-items: center; justify-content: center; min-height: 32px; margin-top: 12px; padding: 0 12px; border: 1px solid var(--v2-ink); border-radius: var(--v2-radius-sm); background: var(--v2-ink); color: var(--v2-on-ink); font: 600 13px/18px var(--v2-font); text-align: center; overflow-wrap: anywhere; } -.v2-root a.v2-artifacts__open:hover { background: var(--v2-ink-hover); } -/* ≤760 (ux-lead 66462): no sideways scroll — keep file / kind / when, fold the pod under - the name as a mono line, hide pod / shared-by / size. The head is the pinned 52px bar (the d215 .mtop block below). */ -@media (max-width: 760px) { - .v2-artifacts { padding: 16px 12px 0; gap: 16px; } - .v2-artifacts__controls { flex-direction: column; align-items: stretch; } - .v2-artifacts__search { margin-left: 0; } - .v2-artifacts__table { table-layout: fixed; } - .v2-artifacts__table th:nth-child(3), .v2-artifacts__table td:nth-child(3), - .v2-artifacts__table th:nth-child(4), .v2-artifacts__table td:nth-child(4), - .v2-artifacts__table th:nth-child(6), .v2-artifacts__table td:nth-child(6) { display: none; } - .v2-artifacts__table th:nth-child(2), .v2-artifacts__table td:nth-child(2) { width: 64px; } - .v2-artifacts__table th:nth-child(5), .v2-artifacts__table td:nth-child(5) { width: 52px; } - .v2-artifacts__file { flex-wrap: wrap; row-gap: 2px; } - .v2-artifacts__pod-line { display: block; flex: 1 1 100%; } - .v2-root button.v2-artifacts__name { white-space: normal; overflow-wrap: anywhere; } - .v2-root a.v2-artifacts__open { display: flex; width: 100%; min-height: 44px; } -} - /* Public Privacy and Terms pages (TASK-530). Outside .v2-root, so no root-scoped selectors. */ /* .v2-root sets height:100vh and overflow:hidden; the legal pages must scroll, so override both (same as the landing). */ .v2-root.v2-legal { display: block; height: 100vh; overflow-y: auto; overflow-x: hidden; box-sizing: border-box; padding: 48px 24px; background: var(--v2-bg-subtle); color: var(--v2-text-primary); } @@ -4851,44 +4785,6 @@ body.modern-ui.v2-canvas { .v2-feature__body { padding: 12px 12px 16px; } } -/* d215 artifacts 390: the page head is the frame's pinned 52px bar (.mtop), the - bar .v2-feature__header gives the other feature pages. The page renders its own - head (it carries its counts) inside the scrolling .v2-feature__body, so the bar - pins with sticky at the body's top edge, where the body has no top padding. - Desktop keeps the 32px display head. Activity's head lives in - components/V2ActivityPage.css. */ -@media (max-width: 760px) { - .v2-artifacts__head { - position: sticky; - top: 0; - z-index: 3; - box-sizing: border-box; - display: flex; - flex-wrap: nowrap; - align-items: center; - min-height: 52px; - padding: 0 12px 0 16px; - gap: 8px; - border-bottom: 1px solid var(--v2-border-soft); - background: var(--v2-surface); - } - .v2-artifacts__head .v2-page-head__identity { flex: 1; align-items: center; gap: 8px; } - /* Closed, the bar is one line: the meta truncates instead of wrapping under the title. An open - hint still takes its own row (flex-basis 100% above), so wrap stays on while one is open. */ - .v2-artifacts__head .v2-page-head__identity:not(:has(.v2-hint--open)) { flex-wrap: nowrap; } - .v2-artifacts__head .v2-artifacts__title { - font: 800 22px/1 var(--v2-font-display); /* tracking stays the head's own, so :lang(zh) still resets it */ - flex-shrink: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - .v2-artifacts__head .v2-page-head__meta { margin-left: auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - /* Artifacts pads its column 12px; the bar bleeds to the pane edge and the column starts under it. */ - .v2-artifacts { padding-top: 0; } - .v2-artifacts__head { margin: 0 -12px; } -} - /* ---------- Motion (D220) ---------- */ /* Theme switch: utils/theme.ts makes the change inside document.startViewTransition, From 24afe6f6b2d5042620ce8c195ea1f3d5568115a8 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:59:07 -0800 Subject: [PATCH 2/4] fix(artifacts): the head is Activity's head bar, and the phone gutter is 12px The consistency rulings (11-04) win over the build brief: - The head is the shared .v2p-ph bar Activity uses: 64px, a hairline under it, Bricolage 24/800 at -0.72px, 28px in, the count in Plex Mono 11.5/500. At 390 it is 52px with a 21px title, 12px in, as on Activity. The page's own copies of the bar, title and spacer rules go, and zh drops the title's negative tracking, as Activity does. - The phone side gutter is 12px, not 16. - Download makes its one read an explicit GET, and a test asserts every request it makes is a GET. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- .../src/v2/__tests__/V2ArtifactsPage.test.tsx | 4 ++- .../v2/__tests__/v2-layout-invariants.test.ts | 31 +++++++++++++------ .../src/v2/components/V2ArtifactsPage.css | 21 +++++++------ .../src/v2/components/V2ArtifactsPage.tsx | 9 +++--- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx index 1a3e41e872..87b6c7f6f2 100644 --- a/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ArtifactsPage.test.tsx @@ -200,7 +200,9 @@ describe('V2ArtifactsPage (d215 signal)', () => { fireEvent.click(await screen.findByRole('button', { name: 'Download plan.md' })); await waitFor(() => expect(clicked).toEqual([{ href: 'blob:artifact', download: 'plan.md' }])); expect(mockSigned).toHaveBeenCalledWith('/api/uploads/x3.md'); - expect(global.fetch).toHaveBeenCalledWith('/api/uploads/x3.md?t=signed'); + expect(global.fetch).toHaveBeenCalledWith('/api/uploads/x3.md?t=signed', { method: 'GET' }); + // Download is a read: every request it makes is a GET. + expect(global.fetch.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true); expect(open).not.toHaveBeenCalled(); // When the bytes can't be read, the signed URL opens and the server decides. global.fetch = jest.fn().mockRejectedValue(new TypeError('cors')); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 2478e838db..e60dd55c45 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -946,12 +946,18 @@ describe('v2 layout invariants (CSS rule presence)', () => { // The retired table and two pill groups are gone from the sheet and the page. expect(v2).not.toMatch(/\.v2-artifacts__(table|seg|controls|zero|head)\b/); expect(page).not.toMatch(/'); + expect(page).toContain(''); + expect(ac).not.toMatch(/^\.v2a-(head|title) \{/m); + const ph = ruleBody(read('../primitives/primitives.css'), '.v2p-ph'); + expect(ph).toContain('height: 64px'); + expect(ph).toContain('padding: 0 20px 0 28px'); + expect(ph).toContain('border-bottom: 1px solid var(--v2-border-soft)'); + expect(selectorRuleBody(read('../primitives/primitives.css'), '.v2p-ph h1 {')).toContain('font: 800 24px/1 var(--v2-font-display)'); + expect(selectorRuleBody(ac, '\n.v2a-head.v2p-ph .meta {')).toContain('font: 500 11.5px/1 var(--v2-font-mono)'); + expect(ac).toContain('.v2-root:lang(zh) .v2a-title { letter-spacing: 0; }'); // Kind is the shared segment; pod is a native select dressed as the frame's .sel. expect(page).toContain('\s*