diff --git a/docs/design/ai-slop-tells.md b/docs/design/ai-slop-tells.md index 2aa51becf..a7c0ab57b 100644 --- a/docs/design/ai-slop-tells.md +++ b/docs/design/ai-slop-tells.md @@ -57,8 +57,7 @@ listed again. line, so it excuses one literal, never the rest of the file. *Exception:* keyboard notation (`⌘↵`), the reply-aim `↳`, the DM pair `↔`; user content (the reaction picker); the parser for the kernel's `🤝` DM - event; masked-token bullets; `↵` in the marketing demo. Dated 2026-11-03: - `activity.decision.ruled`, left to the Activity redesign owner. + event; masked-token bullets; `↵` in the marketing demo. 7. **A sparkle "AI" icon.** It is the category's cliché, and it names the technology rather than the act. *Check:* no `AutoAwesome`, `AutoFixHigh`, `Sparkle(s)` or `✨` in v2 @@ -101,9 +100,9 @@ any live app surface. It found one chromatic glow, the logo key, which is drawn. | 3 | Frosted glass | `landing/v2-landing.css:87` | the marketing sticky header only | acceptable | guard | | 4 | Gradients | 30+ key rules | all d215 a/b key pairs; 4 named masks, track and hatch | acceptable | guard | | 5 | Glow | `v2.css` `.v2-rail__brand-icon`, `primitives.css` `.v2p-b-c` | artboard values (`d215/index.html:167`, `:226`) | acceptable | guard | -| 5 | Focus halo | `v2.css` `.v2-activity__compose:focus-within` | hard-coded `rgba(29,63,209,.10)` 4px, not `--v2-focus-ring`; on the dark ground it is all but invisible (`rgb(138,162,255)` 4px ring with the token, measured on a local build) | real | proposal for the Activity/Artifacts redesign owner | +| 5 | Focus halo | `v2.css` `.v2-activity__compose:focus-within` | hard-coded `rgba(29,63,209,.10)` 4px, not `--v2-focus-ring`; on the dark ground it is all but invisible (`rgb(138,162,255)` 4px ring with the token, measured on a local build) | real | fixed by the Activity build: the open composer's ring is `var(--v2-focus-ring)` (`components/V2ActivityPage.css`) | | 6 | Glyph icons | `en`/`zh-CN`: `inviteModal.connectOwnAgent`, `adminUsers.registered.analyticsLink`, `adminAnalytics.userAdminLink` (`→`), `approvalCard.approved` (`✓`) | strings | real | glyphs dropped from the copy | -| 6 | Glyph icons | `activity.decision.ruled` (`✓ {{by}} ruled`), shown on Activity and the thread decision card | string | real | proposal for the Activity/Artifacts redesign owner; allowlisted by name and date in the guard | +| 6 | Glyph icons | `activity.decision.ruled` (`✓ {{by}} ruled`), shown on Activity and the thread decision card | string | real | fixed by the Activity build: the `✓` left both locales and the guard's allowlist | | 6 | Glyph icons | `V2PodBoard.tsx` Follow `✓`, `V2LangSwitch.tsx` `✓`, `V2FirstRunHero.tsx` `↗`, `V2Lightbox.tsx` `←`/`→`, `V2SettingsFrame.tsx` `✎` | JSX text | real | SVG glyphs from `icons/glyphs.tsx` (two chevrons added) | | 6 | Close and chevron glyphs | `×` on the close buttons in `V2Lightbox`, `V2PodBoard` (focus editor and new task, both with no accessible name), `V2Composer` (reply aim), `V2InviteModal`, `V2CatchUpStrip` and `V2AvatarCropDialog`; `›` in `V2SettingsFrame` | JSX text | real | `ShellGlyph` `close` (added) and `chevron-right`; the two board buttons gained `aria-label` from `common.close` | | 6 | Emoji | `V2MessageRow.tsx` reply quote | quoted uploads rendered as `📎 name` | real | an `aria-hidden` SVG file glyph before the name | @@ -129,11 +128,9 @@ floors burn-down allowlist under a heading dated 2026-11-03, not fixed here. ### Proposals for the Activity/Artifacts redesign owner -- `.v2-activity__compose:focus-within` should use `var(--v2-focus-ring)`. The - hard-coded 10% cobalt halo barely shows in dark mode. -- Drop the `✓` from `activity.decision.ruled` (en and zh-CN), and the - allowlist entry in `v2-slop-tells.test.ts` with it. -- The Class A note on "Needs you" below. +- Done for Activity (the d215 build): the composer ring is the token, the `✓` + left `activity.decision.ruled`, and "Needs you" is one hairline-split list + (the Class A note below). ### Proposals for UX Lead (Class A, not made here) diff --git a/docs/design/artboards/d215/README.md b/docs/design/artboards/d215/README.md index 4612c051f..1f11e902f 100644 --- a/docs/design/artboards/d215/README.md +++ b/docs/design/artboards/d215/README.md @@ -6,7 +6,7 @@ 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. Sam approved them on 11-03. The frames carry `data-state="signal"`, so the scorer skips them until each page's build PR makes them the scored frames; the frames above them stay as the record of the live page until then. +- 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. - 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`. @@ -15,14 +15,14 @@ The spec for D215 (artboard v2). Open `index.html` in a browser. The toggle at t ## 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. Only the first two have scored frames, 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 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. | 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-\*, 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** | activity-\*, artifacts-\* without `data-state` | Traced from the live pages with small fixes (#3316, #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 Activity and Artifacts build PRs remove them. 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`, 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. | ## 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 25d9ec07c..85d4f75e7 100644 --- a/docs/design/artboards/d215/cap/measure_after.js +++ b/docs/design/artboards/d215/cap/measure_after.js @@ -29,14 +29,13 @@ 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', ]; // Base frames (no data-state) that are drawn but deliberately not scored. Each needs a reason. const SKIPPED = { - // Activity and Artifacts are being redesigned in another session; targets taken now would pin - // frames that are about to change. Score them once that redesign lands. - 'activity-busy': 'redesign in progress elsewhere', - 'activity-dayzero': 'redesign in progress elsewhere', - 'activity-dayzero-pending': 'redesign in progress elsewhere', + // 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 @@ -48,7 +47,8 @@ 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. -const PROD_SURFACES = ['settings', 'agent-settings', 'agent-memory', 'board', 'connectors', 'chat', 'sidebar', 'byo', 'connect', 'team']; +// 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']; const VIEWPORTS = ['1440', '390']; const EXPECTED = SURFACES.flatMap(s => VIEWPORTS.map(v => `${s}-${v}`)); @@ -221,6 +221,7 @@ async function prod(only) { ['byo', () => '/v2/agents/byo'], ['connect', () => '/v2/connect'], ['team', () => '/v2/agents'], + ['activity-busy', () => '/v2/activity'], ['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 8da521996..8090d4229 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, 40); + assert.strictEqual(Object.keys(t).length, 44); 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, 40); + assert.strictEqual(EXPECTED.length, 44); }); 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 eb954c040..3c9a2fc54 100644 --- a/docs/design/artboards/d215/index.html +++ b/docs/design/artboards/d215/index.html @@ -1282,7 +1282,7 @@

Same look. Less said.

  • One screen firstRepeated blocks become a table; rare settings collapse.
  • @@ -1739,30 +1739,10 @@

    Same look. Less said.

    -
    -

    Activity

    /v2/activity
    -
    -
    Day zero · 1440one pod, only Scout

    Activity

    0 need you · 0 moved
    Post in
    Write a message. Mention an agent to wake it…
    ⌘↵ to send

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    Needs you

    Nothing needs you.

    Moved forward

    Nothing yet today.
    -
    Busy · 14404 need you, 2 pods moved

    Activity

    4 need you · 5 moved
    Post in
    Write a message. Mention an agent to wake it…
    ⌘↵ to send

    Needs you

    4
    Northwind · 12m
    Which launch date goes in the customer email?
    Recommended
    Northwind · 40m
    Send the October invoices to 14 customers
    Field notes · 1h
    Fern
    Northwind · 2h
    Juno Park

    Moved forward

    Northwind3
    IvyReconciled the October ledger; two refunds flagged for review.
    SageDrafted the launch email in two versions.
    AlexMoved “Pricing page copy” to Done.
    Field notes2
    FernSummarized six customer interviews into one doc.
    Juno ParkAdded notes from the Friday call.
    -
    -
    -
    Day zero · 3907 days selected

    Activity

    0 need you · 0 moved
    Post in
    Write a message. Mention an agent to wake it…
    ⌘↵ to send

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    Needs you

    Nothing needs you.

    Moved forward

    Nothing moved in the last 7 days.
    Pods
    Needs you
    Team
    Settings
    -
    Busy · 390scrolled to Needs you

    Activity

    4 need you · 5 moved

    Needs you

    4
    Northwind · 12m
    Which launch date goes in the customer email?
    Recommended
    Northwind · 40m
    Send the October invoices to 14 customers
    Field notes · 1h
    Fern
    Northwind · 2h
    Juno Park

    Moved forward

    Northwind3
    IvyReconciled the October ledger; two refunds flagged for review.
    SageDrafted the launch email in two versions.
    AlexMoved “Pricing page copy” to Done.
    Field notes2
    FernSummarized six customer interviews into one doc.
    Juno ParkAdded notes from the Friday call.
    Pods
    4Needs you
    Team
    Settings
    -
    • Class names are the page's own (v2-activity__*), so the build copies them. The card marks redraw KindGlyph and the square keys ActGlyph; the rail and avatars stand in for the shell.
    • Day zero is one card above two quiet sections. Get started is the only filled key on the page (the current step); Needs you and Moved forward stay as dashed one-liners, never a second card.
    • Get started never pops in. Its steps wait on a second fetch (/api/registry/pods/:id/agents) and the connector count, so before this build it landed a beat after the composer and pushed Needs you down. While those are in flight on a day-zero account, the card renders as a placeholder: real head, two step rows of bars, the height of the two-step day-zero card (Scout seated, no connector). An account with one or three steps left shifts by one row when they land, and the card drops if the steps come back empty.
    • At 390 a card's acts are one row. Reply (or Approve) flexes to fill it and the 44px ✓ and ↗ squares sit on its right. Before this build the ≤760 rule .v2-activity__queue-actions > button { flex: 1 1 100% } stacked them, about 60px a card. Decision options may still stack full width; Other… and ↗ share the last row.
    • Moved forward names its window when empty. Today keeps Nothing yet today.; 7 days reads Nothing moved in the last 7 days. (new key, e.g. activity.movedForward.emptyWeek).

    Each state, one line

    -
    Day zero · TodayDrawn. One pod, only Scout: 2 steps, current key Open My Workspace, both sections empty.
    Step copy"Say something to it" no longer claims an @mention is needed: Scout answers a plain hello in My Workspace (walk 11-02). The line now reads "Say hello in the pod where it lives. If it doesn't answer, @mention it."
    Day zero · 7 daysDrawn at 390. Moved forward reads "Nothing moved in the last 7 days."
    Get started · checkingDrawn beside the loaded card, at the height of the two-step day-zero card. A one- or three-step result shifts by one row when it lands, and an empty result removes the card. Reserved only when hasEverHadAttention === false, the queue is 0 and the scope is All pods.
    BusyDrawn. One decision (Recommended under the first option), one approval, two mentions, two pod groups.
    No agent yet (step 1)Not drawn. The composer stays hidden and Hire an agent is the current key; same card, three rows.
    Reply open, ruled, Other…Not drawn. The editor takes the full card width, as prod does.
    Loading, error, count unavailableNot drawn. They keep prod's spinner, __error and Retry.
    -
    -
    -
    Day zero · 390, scrolled7 days, both empty

    Activity

    0 need you · 0 moved

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    Needs you

    Nothing needs you.

    Moved forward

    Nothing moved in the last 7 days.
    Pods
    Needs you
    Team
    Settings
    -
    -
    Get started · checkingfirst paint, steps in flight

    Get started

    checking…
    -
    Get started · loadedtwo steps: same height, nothing moves

    Get started

    2 steps · until your first ask arrives
    Say something to it

    Say hello in the pod where it lives. If it doesn't answer, @mention it.

    Connect a channel

    Slack or Telegram — the agent answers where your team already talks.

    -
    -
    -
    -

    Activity · in the picked language

    /v2/activity
    approved 11-03, not scored yet
    -
    Busy · 14404 need you, 2 pods moved; Fern hovered

    Activity

    4 need you · 5 moved
    Needs you4 · oldest first
    Which launch date goes in the customer email?Northwind · 12m
    Legal cleared both dates. Sage needs one before the 3pm send.
    Send the October invoices to 14 customersNorthwind · 40m
    Totals match the ledger. Nothing goes out until you approve.
    FernField notes · 1h
    @alex the interview notes are summarized. Post the three open questions to the pod, or keep them in the doc?
    Juno ParkNorthwind · 2h
    @alex can you check the pricing table before Ivy sends it? The annual column looks off.
    Moved forward5 · newest first
    Northwind3
    25mIvy Reconciled the October ledger; two refunds flagged for review.
    1hSage Drafted the launch email in two versions.
    2hAlex Moved “Pricing page copy” to Done.
    Field notes2
    1hFern Summarized six customer interviews into one doc.
    3hJuno Park Added notes from the Friday call.
    Day zero · 1440one pod, only Scout; composer focused

    Activity

    0 need you · 0 moved
    Write a message. Mention an agent to wake it…
    Post in⌘↵
    Get started2 steps
    Say something to it
    Say hello in the pod where it lives. If it doesn't answer, @mention it.
    Connect a channel
    Slack or Telegram — the agent answers where your team already talks.
    Needs you
    Nothing needs you.
    Moved forward
    Nothing yet today.
    -
    Busy · 390top of page

    Activity

    4 need you · 5 moved
    Needs you4
    Which launch date goes in the customer email?
    Legal cleared both dates. Sage needs one before the 3pm send.
    Northwind · 12m
    Send the October invoices to 14 customersNorthwind · 40m
    Totals match the ledger. Nothing goes out until you approve.
    FernField notes · 1h
    @alex the interview notes are summarized. Post the three open questions to the pod, or keep them in the doc?
    Juno ParkNorthwind · 2h
    @alex can you check the pricing table before Ivy sends it? The annual column looks off.
    Moved forward5
    Northwind3
    25mIvy Reconciled the October ledger; two refunds flagged for review.
    1hSage Drafted the launch email in two versions.
    2hAlex Moved “Pricing page copy” to Done.
    Field notes2
    1hFern Summarized six customer interviews into one doc.
    3hJuno Park Added notes from the Friday call.
    Pods
    4Needs you
    Team
    Settings
    Day zero · 3907 days selected

    Activity

    0 need you · 0 moved
    Get started2 steps
    Say something to it
    Say hello in the pod where it lives. If it doesn't answer, @mention it.
    Connect a channel
    Slack or Telegram — the agent answers where your team already talks.
    Needs you
    Nothing needs you.
    Moved forward
    Nothing moved in the last 7 days.
    Pods
    Needs you
    Team
    Settings
      +

      Activity

      /v2/activity
      approved by Sam 11-03, scored; built in V2ActivityPage
      +
      Busy · 14404 need you, 2 pods moved; Fern hovered

      Activity

      4 need you · 5 moved
      Needs you4 · oldest first
      Which launch date goes in the customer email?Northwind · 12m
      Legal cleared both dates. Sage needs one before the 3pm send.
      Send the October invoices to 14 customersNorthwind · 40m
      Totals match the ledger. Nothing goes out until you approve.
      FernField notes · 1h
      @alex the interview notes are summarized. Post the three open questions to the pod, or keep them in the doc?
      Juno ParkNorthwind · 2h
      @alex can you check the pricing table before Ivy sends it? The annual column looks off.
      Moved forward5 · newest first
      Northwind3
      25mIvy Reconciled the October ledger; two refunds flagged for review.
      1hSage Drafted the launch email in two versions.
      2hAlex Moved “Pricing page copy” to Done.
      Field notes2
      1hFern Summarized six customer interviews into one doc.
      3hJuno Park Added notes from the Friday call.
      Day zero · 1440one pod, only Scout; composer focused

      Activity

      0 need you · 0 moved
      Write a message. Mention an agent to wake it…
      Post in⌘↵
      Get started2 steps
      Say something to it
      Say hello in the pod where it lives. If it doesn't answer, @mention it.
      Connect a channel
      Slack or Telegram — the agent answers where your team already talks.
      Needs you
      Nothing needs you.
      Moved forward
      Nothing yet today.
      +
      Busy · 390top of page

      Activity

      4 need you · 5 moved
      Needs you4
      Which launch date goes in the customer email?
      Legal cleared both dates. Sage needs one before the 3pm send.
      Northwind · 12m
      Send the October invoices to 14 customersNorthwind · 40m
      Totals match the ledger. Nothing goes out until you approve.
      FernField notes · 1h
      @alex the interview notes are summarized. Post the three open questions to the pod, or keep them in the doc?
      Juno ParkNorthwind · 2h
      @alex can you check the pricing table before Ivy sends it? The annual column looks off.
      Moved forward5
      Northwind3
      25mIvy Reconciled the October ledger; two refunds flagged for review.
      1hSage Drafted the launch email in two versions.
      2hAlex Moved “Pricing page copy” to Done.
      Field notes2
      1hFern Summarized six customer interviews into one doc.
      3hJuno Park Added notes from the Friday call.
      Pods
      4Needs you
      Team
      Settings
      Day zero · 3907 days selected

      Activity

      0 need you · 0 moved
      Get started2 steps
      Say something to it
      Say hello in the pod where it lives. If it doesn't answer, @mention it.
      Connect a channel
      Slack or Telegram — the agent answers where your team already talks.
      Needs you
      Nothing needs you.
      Moved forward
      Nothing moved in the last 7 days.
      Pods
      Needs you
      Team
      Settings
      • Built only from the seven picked surfaces. Head is the Connectors .ph: display title, one mono count, then one Today / 7 days .seg and one All pods ▾ select. Body is a centred 760px column of mono section labels (.gtitle with a 13px line icon), each over ONE .list split by hairlines. No card stack, no filled glyph squares, no (i) on any heading: "oldest first" and "newest first" ride the label's right edge, as "saved 1h ago" does on Memory.
      • Against the current frame. The composer card becomes a one-line row in Memory's search shape that expands on focus (drawn open on day zero). The four equal cards become one list: the decision row stays open, the other three are two-line rows with one quiet key. The scope pill row (All pods / Northwind / Field notes / more ▾) becomes one select. "Moved forward" loses its table head and becomes one list with a mono pod sub-row per pod and Journal's mono time column.
      • The decision is the only open row. Its choices are three 28px keys inside the row: Tuesday (marked recommended in mono inside its own key, replacing the caption under a pill), Thursday, and a quiet Other…. ↗ opens it in the pod. At 390 the same keys go full width at 44px, inside that row only.
      • diff --git a/docs/design/artboards/d215/targets.json b/docs/design/artboards/d215/targets.json index 1e1257d14..f2396ad67 100644 --- a/docs/design/artboards/d215/targets.json +++ b/docs/design/artboards/d215/targets.json @@ -87,6 +87,22 @@ "helperWords": 34, "innerRatio": 1.93 }, + "activity-busy-1440": { + "helperWords": 94, + "innerRatio": 1 + }, + "activity-busy-390": { + "helperWords": 84, + "innerRatio": 1.31 + }, + "activity-dayzero-1440": { + "helperWords": 52, + "innerRatio": 1 + }, + "activity-dayzero-390": { + "helperWords": 53, + "innerRatio": 1 + }, "firstrun-welcome-1440": { "helperWords": 16, "innerRatio": 1 diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f2c62c480..b534b3578 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -232,8 +232,6 @@ }, "activity": { "title": "Activity", - "subtitle": "what needs you, then what moved", - "about": "About activity", "head": { "needsYou_one": "{{count}} needs you", "needsYou_other": "{{count}} need you", @@ -248,11 +246,11 @@ }, "podScopeLabel": "Pod scope", "allPods": "All pods", - "morePods": "more ▾", "loadFailed": "Activity could not be loaded. Try again.", "openThread": "Open thread", "open": "Open", "openPod": "Open pod", + "moreActions": "More actions", "openBoard": "Open board", "compose": { "eyebrow": "Start a conversation", @@ -264,7 +262,7 @@ "working": "Sending…", "actionFailed": "Your message could not be sent. Try again.", "label": "Tell your agents", - "hint": "⌘↵ to send" + "hint": "⌘↵" }, "reply": { "open": "Reply", @@ -278,12 +276,10 @@ "updatesCount_one": "{{count}} update", "updatesCount_other": "{{count}} updates", "needsYou": { - "about": "About needs you", "eyebrow": "Decision queue", "title": "Needs you", + "order": "oldest first", "description": "Direct mentions, approvals, and active decisions that need your call.", - "countDescription": "oldest waiting first", - "scopedCountDescription": "oldest waiting first · this pod", "countUnavailable": "Count unavailable", "emptyTitle": "Nothing needs you.", "emptyDescription": "New mentions and approval requests appear here when they need a response.", @@ -317,7 +313,7 @@ "other": "Other…", "otherPlaceholder": "Write your ruling…", "sendOther": "Send ruling", - "ruled": "✓ {{by}} ruled: {{value}}", + "ruled": "{{by}} ruled: {{value}}", "ruledShort": "ruled", "actionFailed": "That ruling could not be saved. Try again." }, @@ -353,9 +349,8 @@ } }, "movedForward": { - "about": "About moved forward", "title": "Moved forward", - "description": "grouped by pod · newest first", + "order": "newest first", "empty": "Nothing yet today.", "emptyWeek": "Nothing moved in the last 7 days.", "more": "{{count}} more in {{pod}}", @@ -366,8 +361,8 @@ "footer": "Open the source thread to act; this recap leaves when the underlying fact changes.", "getStarted": { "title": "Get started", - "kicker_one": "1 step · until your first ask arrives", - "kicker_other": "{{count}} steps · until your first ask arrives", + "kicker_one": "1 step", + "kicker_other": "{{count}} steps", "checking": "checking…", "hire": { "title": "Hire your first agent", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index ddf38bb44..39b719d10 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -231,8 +231,6 @@ }, "activity": { "title": "动态", - "subtitle": "需要你处理的事项,以及刚刚推进的工作", - "about": "关于动态", "head": { "needsYou_one": "{{count}}项需要你处理", "needsYou_other": "{{count}}项需要你处理", @@ -247,11 +245,11 @@ }, "podScopeLabel": "Pod 范围", "allPods": "所有 Pod", - "morePods": "更多 ▾", "loadFailed": "无法加载动态,请重试。", "openThread": "打开讨论", "open": "打开", "openPod": "打开 pod", + "moreActions": "更多操作", "openBoard": "打开看板", "compose": { "eyebrow": "发起对话", @@ -263,7 +261,7 @@ "working": "发送中…", "actionFailed": "无法发送消息,请重试。", "label": "告诉你的智能体", - "hint": "⌘↵ 发送" + "hint": "⌘↵" }, "reply": { "open": "回复", @@ -277,12 +275,10 @@ "updatesCount_one": "{{count}}条更新", "updatesCount_other": "{{count}}条更新", "needsYou": { - "about": "关于需要你处理的事项", "eyebrow": "决策队列", "title": "需要你处理", + "order": "最早在前", "description": "这里只显示需要你回应的直接提及、审批和待决事项。", - "countDescription": "最早等待的在前", - "scopedCountDescription": "最早等待的在前 · 此 Pod", "countUnavailable": "数量暂不可用", "emptyTitle": "没有需要你处理的事项。", "emptyDescription": "需要回复的提及或审批请求会显示在这里。", @@ -316,7 +312,7 @@ "other": "其他…", "otherPlaceholder": "写下你的裁定…", "sendOther": "发送裁定", - "ruled": "✓ {{by}} 已裁定:{{value}}", + "ruled": "{{by}} 已裁定:{{value}}", "ruledShort": "已裁定", "actionFailed": "无法保存该裁定,请重试。" }, @@ -352,9 +348,8 @@ } }, "movedForward": { - "about": "关于推进的工作", "title": "推进的工作", - "description": "按 Pod 分组 · 最新在前", + "order": "最新在前", "empty": "今天还没有更新。", "emptyWeek": "最近7天没有新进展。", "more": "{{pod}} 还有{{count}}项", @@ -365,8 +360,8 @@ "footer": "请在源讨论中行动;底层事实变化后,这份回顾会自动离开。", "getStarted": { "title": "开始使用", - "kicker_one": "还有1步 · 直到你的第一个请求到来", - "kicker_other": "还有{{count}}步 · 直到你的第一个请求到来", + "kicker_one": "还有1步", + "kicker_other": "还有{{count}}步", "checking": "正在检查…", "hire": { "title": "雇用你的第一个智能体", diff --git a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx index 981a6bf0c..6ab7a13c1 100644 --- a/frontend/src/v2/__tests__/V2ActivityPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ActivityPage.test.tsx @@ -70,6 +70,12 @@ const renderPage = () => render( , ); +// The pod scope is one select key (d215 activity): open it, then pick the pod. +const chooseScope = (name: string) => { + fireEvent.click(document.querySelector('.v2-activity__scope-button') as HTMLElement); + fireEvent.click(within(screen.getByRole('listbox', { name: 'Pod scope' })).getByRole('option', { name, exact: true })); +}; + const renderPageWithAuth = (currentUser: { _id: string } | null) => render( @@ -180,7 +186,7 @@ describe('V2ActivityPage', () => { })); }); - test('preserves authored decision order and makes only the first option primary', async () => { + test('preserves authored decision order; every choice is a raised key inside the row (d215 activity)', async () => { const authoredOrder = { ...decisionQueue, items: [{ @@ -197,12 +203,17 @@ describe('V2ActivityPage', () => { const first = await screen.findByRole('button', { name: 'Rule: Hold for review' }); const second = screen.getByRole('button', { name: 'Rule: Ship now (Recommended)' }); - expect(first).toHaveClass('v2-activity__option--primary'); - expect(second).not.toHaveClass('v2-activity__option--primary'); - expect(second).not.toHaveTextContent('Recommended'); - expect(within(second.parentElement as HTMLElement).getByText('Recommended')).toBeInTheDocument(); + // No cobalt primary: the frame draws every option as the same raised key, Other… quiet. + for (const key of [first, second]) expect(key).toHaveClass('v2p-b', 'v2p-b-s'); + expect(screen.getByRole('button', { name: 'Other…' })).toHaveClass('v2p-b-g'); + // "recommended" rides inside its own key, in mono, not as a caption under it. + expect(second).toHaveTextContent('Recommended'); expect(first).not.toHaveTextContent('Recommended'); expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // The keys sit in the decision row itself, which is the one open row. + const row = first.closest('article') as HTMLElement; + expect(row).toHaveClass('v2-activity__queue-row--open'); + expect(row).toContainElement(second); }); test('keeps long decision labels and descriptions readable when the recommendation is second', async () => { @@ -238,15 +249,11 @@ describe('V2ActivityPage', () => { const first = await screen.findByRole('button', { name: 'Rule: Keep account-wide cap' }); const second = screen.getByRole('button', { name: 'Rule: Change to squad-drafted only (Recommended)' }); const third = screen.getByRole('button', { name: 'Rule: Use a rolling seven-day cap for all sends' }); - expect(first).toHaveClass('v2-activity__option--primary'); - expect(second).not.toHaveClass('v2-activity__option--primary'); - expect(third).not.toHaveClass('v2-activity__option--primary'); expect(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(second.compareDocumentPosition(third) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.getByText('Recommended default: retain five total X posts per day, including casual replies. Simple to count and limits total volume; casual use reduces squad capacity.')).toBeInTheDocument(); expect(screen.getByText('Change the current rule to five squad-drafted sends per day, excluding Sam’s casual replies. Preserves squad capacity but permits more than five total posts and requires identifying draft origin.')).toBeInTheDocument(); - expect(second).not.toHaveTextContent('Recommended'); - expect(within(second.parentElement as HTMLElement).getByText('Recommended')).toBeInTheDocument(); + expect(second).toHaveTextContent('Recommended'); expect(second).toHaveAttribute('aria-describedby', expect.stringContaining('description')); }); @@ -298,7 +305,7 @@ describe('V2ActivityPage', () => { renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: '7 days' })); + fireEvent.click(screen.getByRole('radio', { name: '7 days' })); // toHaveBeenCalledWith, not Last: the decision-queue request now fires // alongside recap, so "last call" is no longer the recap by construction. await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/api/activity/recap', expect.objectContaining({ @@ -353,7 +360,7 @@ describe('V2ActivityPage', () => { mockGet.mockImplementation(mockFor([], [])); const first = renderPage(); expect(await screen.findByRole('heading', { name: 'Get started' })).toBeInTheDocument(); - expect(await screen.findByText('3 steps · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('3 steps')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Hire an agent' })).toHaveClass('v2-activity__start-cta--current'); expect(screen.getByRole('button', { name: 'Open Launch pod' })).not.toHaveClass('v2-activity__start-cta--current'); expect(screen.getByRole('button', { name: 'Add a connector' })).toBeInTheDocument(); @@ -374,7 +381,7 @@ describe('V2ActivityPage', () => { const introSeat = { name: 'scout', displayName: 'Scout', lastActiveAt: '2026-09-08T10:00:00.000Z', lastMessage: { content: 'Hi, I am Scout.', createdAt: '2026-09-08T10:00:01.000Z' } }; mockGet.mockImplementation(mockFor([introSeat, { name: 'hosted-smoke', internal: true }], [], false)); const second = renderPage(); - expect(await screen.findByText('2 steps · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('2 steps')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Hire an agent' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Open Launch pod' })).toHaveClass('v2-activity__start-cta--current'); expect(screen.getByRole('textbox', { name: i18n.t('activity.compose.placeholder') })).toBeInTheDocument(); @@ -424,7 +431,7 @@ describe('V2ActivityPage', () => { await waitFor(() => expect(screen.queryByTestId('activity-start-pending')).not.toBeInTheDocument()); const card = screen.getByRole('heading', { name: 'Get started' }).closest('section') as HTMLElement; expect(card).not.toHaveAttribute('aria-busy'); - expect(within(card).getByText('2 steps · until your first ask arrives')).toBeInTheDocument(); + expect(within(card).getByText('2 steps')).toBeInTheDocument(); expect(within(card).getByText("Say hello in the pod where it lives. If it doesn't answer, @mention it.")).toBeInTheDocument(); first.unmount(); @@ -455,7 +462,7 @@ describe('V2ActivityPage', () => { ? { items: [], count: 0, countsByPod: {} } : { ...recap, needsYou: [], agents: [], board: [] } })); renderPage(); expect(await screen.findByText('Nothing yet today.')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: '7 days' })); + fireEvent.click(screen.getByRole('radio', { name: '7 days' })); expect(await screen.findByText('Nothing moved in the last 7 days.')).toBeInTheDocument(); expect(screen.queryByText('Nothing yet today.')).not.toBeInTheDocument(); @@ -475,7 +482,7 @@ describe('V2ActivityPage', () => { return Promise.resolve({ data: empty }); }); const first = renderPage(); - expect(await screen.findByText('1 step · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('1 step')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Open Launch pod' })).toHaveClass('v2-activity__start-cta--current'); first.unmount(); @@ -489,7 +496,7 @@ describe('V2ActivityPage', () => { return Promise.resolve({ data: empty }); }); renderPage(); - expect(await screen.findByText('1 step · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('1 step')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Open Launch pod' })); expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); }); @@ -505,7 +512,7 @@ describe('V2ActivityPage', () => { return Promise.resolve({ data: empty }); }); renderPage(); - expect(await screen.findByText('1 step · until your first ask arrives')).toBeInTheDocument(); + expect(await screen.findByText('1 step')).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Open Launch pod' })); expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/pod-1'); }); @@ -668,7 +675,7 @@ describe('V2ActivityPage', () => { renderPage(); fireEvent.click(await screen.findByRole('button', { name: 'Rule: Ship now (Recommended)' })); - expect(await screen.findByText('✓ You ruled: Ship now')).toBeInTheDocument(); + expect(await screen.findByText('You ruled: Ship now')).toBeInTheDocument(); }); test('restores a settled Activity card from durable history after leave and return', async () => { @@ -685,11 +692,11 @@ describe('V2ActivityPage', () => { }); const first = renderPage(); - expect(await screen.findByText('✓ You ruled: Ship now')).toBeInTheDocument(); + expect(await screen.findByText('You ruled: Ship now')).toBeInTheDocument(); first.unmount(); renderPage(); - expect(await screen.findByText('✓ You ruled: Ship now')).toBeInTheDocument(); + expect(await screen.findByText('You ruled: Ship now')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Rule:/ })).not.toBeInTheDocument(); }); @@ -897,24 +904,117 @@ describe('V2ActivityPage', () => { const settled = await screen.findByText('Settled earlier'); // Panel first, settled card under it. expect(panel.compareDocumentPosition(settled) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - expect(screen.queryByText('oldest waiting first')).not.toBeInTheDocument(); + expect(screen.queryByText(/oldest first/)).not.toBeInTheDocument(); }); - test('a human whose name matches an agent label keeps the human mark when the id is present', async () => { - mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' - ? { ...decisionQueue, items: [ - { id: 'm-human', attentionItemId: 'a-human', kind: 'mention', title: 'Kai mentioned you', detail: 'x', podId: 'pod-1', podName: 'Launch pod', actorName: 'Kai', actorUserId: 'human-user-99', createdAt: '2026-08-26T11:00:00.000Z' }, - { id: 'm-agent', attentionItemId: 'a-agent', kind: 'mention', title: 'Kai mentioned you', detail: 'y', podId: 'pod-1', podName: 'Launch pod', actorName: 'Kai', createdAt: '2026-08-26T10:00:00.000Z' }, - ], count: 2, countsByPod: { 'pod-1': 2 } } - : { ...recap, agents: [{ ...recap.agents[0], id: 'agent-kai', name: 'Kai' }] } })); + test('every ask carries one line mark named by its kind, never a filled square (d215 activity)', async () => { renderPage(); - await screen.findAllByText('Kai'); + await screen.findByText('Review requested'); const marks = [...document.querySelectorAll('.v2-activity__queue-mark')]; - const human = document.querySelector('[data-activity-item-id="m-human"] .v2-activity__queue-mark') as HTMLElement; - const agent = document.querySelector('[data-activity-item-id="m-agent"] .v2-activity__queue-mark') as HTMLElement; - expect(marks.length).toBeGreaterThanOrEqual(2); - expect(human.className).toContain('v2-activity__queue-mark--human'); - expect(agent.className).toContain('v2-activity__queue-mark--agent'); + expect(marks).toHaveLength(2); + expect(screen.getByRole('img', { name: 'mention' })).toHaveClass('v2-activity__queue-mark'); + expect(screen.getByRole('img', { name: 'decision' })).toHaveClass('v2-activity__queue-mark'); + for (const mark of marks) expect(mark.className).toBe('v2-activity__queue-mark'); + // Both asks are rows of one list, not a stack of cards. + expect(document.querySelectorAll('.v2-activity__queue > article')).toHaveLength(2); + }); + + test('a desktop row keeps its secondary acts in the hover cluster beside its one key', async () => { + const approval = { id: 'approval-9', kind: 'approval', title: 'Approval requested', detail: 'Deploy.', podId: 'pod-1', podName: 'Launch pod', createdAt: '2026-08-26T08:00:00.000Z' }; + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? { ...decisionQueue, items: [...decisionQueue.items, approval], count: 3 } : recap })); + renderPage(); + const mention = (await screen.findByText('Review requested')).closest('article') as HTMLElement; + const cluster = mention.querySelector('.v2-activity__hv') as HTMLElement; + expect(within(cluster).getAllByRole('button').map((b) => b.getAttribute('aria-label'))).toEqual(['Mark handled', 'Open']); + // The key follows the cluster in the DOM, so Tab reaches Reply first; CSS draws the cluster before it. + const reply = within(mention).getByRole('button', { name: 'Reply' }); + expect(reply.compareDocumentPosition(cluster) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + const approvalRow = screen.getByText('Approval requested').closest('article') as HTMLElement; + expect(within(approvalRow.querySelector('.v2-activity__hv') as HTMLElement).getAllByRole('button').map((b) => b.getAttribute('aria-label'))) + .toEqual(['Deny', 'Open pod']); + expect(within(approvalRow).getByRole('button', { name: 'Approve' })).toHaveClass('v2p-b-s'); + // No ⋯ menu at desktop, and the decision row's Open sits beside the row, not in a cluster. + expect(screen.queryByRole('button', { name: 'More actions' })).not.toBeInTheDocument(); + const decision = screen.getByText('Choose the eslint scope').closest('article') as HTMLElement; + expect(decision.querySelector('.v2-activity__hv')).toBeNull(); + expect(within(decision).getByRole('button', { name: 'Open' })).toBeInTheDocument(); + }); + + test('the composer is one line until its text area takes focus, and stays open while it holds a draft', async () => { + renderPage(); + const box = await screen.findByRole('textbox', { name: i18n.t('activity.compose.placeholder') }); + const composer = box.closest('section') as HTMLElement; + expect(composer).not.toHaveClass('v2-activity__compose--open'); + expect(box).toHaveAttribute('rows', '1'); + fireEvent.focus(box); + expect(composer).toHaveClass('v2-activity__compose--open'); + expect(box).toHaveAttribute('rows', '2'); + fireEvent.blur(box, { relatedTarget: document.body }); + expect(composer).not.toHaveClass('v2-activity__compose--open'); + fireEvent.focus(box); + fireEvent.change(box, { target: { value: 'Draft' } }); + fireEvent.blur(box, { relatedTarget: document.body }); + expect(composer).toHaveClass('v2-activity__compose--open'); + // Focus moving to the destination key inside the composer keeps it open. + fireEvent.change(box, { target: { value: '' } }); + fireEvent.focus(box); + fireEvent.blur(box, { relatedTarget: composer.querySelector('.v2-activity__compose-picker-button') }); + expect(composer).toHaveClass('v2-activity__compose--open'); + }); + + test('the ruled line is text only, with no ✓ glyph', async () => { + expect(i18n.t('activity.decision.ruled', { by: 'You', value: 'Ship now', lng: 'en' })).toBe('You ruled: Ship now'); + expect(i18n.t('activity.decision.ruled', { by: 'sam', value: 'A', lng: 'zh-CN' })).toBe('sam 已裁定:A'); + }); + + describe('at phone width', () => { + let original: typeof window.matchMedia; + beforeEach(() => { + original = window.matchMedia; + window.matchMedia = ((query: string) => ({ + matches: query === '(max-width: 760px)', media: query, onchange: null, + addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; + }); + afterEach(() => { window.matchMedia = original; }); + + test('the head holds only the title and count; the window and scope are one row in the page', async () => { + renderPage(); + await screen.findByText('Review requested'); + const head = screen.getByRole('heading', { name: 'Activity' }).closest('header') as HTMLElement; + expect(within(head).queryByRole('radiogroup')).not.toBeInTheDocument(); + const controls = screen.getByRole('group', { name: 'Activity controls' }); + expect(head).not.toContainElement(controls); + expect(within(controls).getByRole('radio', { name: 'Today' })).toBeInTheDocument(); + expect(controls.querySelector('.v2-activity__scope-button')).toHaveTextContent('All pods'); + // The order text gives way on a phone; the count stays. + expect(screen.getByLabelText('2 waiting on you')).toBeInTheDocument(); + expect(screen.queryByText('· oldest first')).not.toBeInTheDocument(); + }); + + test('a row\'s secondary acts live behind its ⋯ key, and the menu runs them', async () => { + let reads = 0; + mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' + ? (++reads === 1 ? { ...decisionQueue, items: [decisionQueue.items[0]], count: 1 } : { items: [], count: 0, countsByPod: {} }) + : recap })); + mockPost.mockResolvedValue({ data: { success: true } }); + renderPage(); + const row = (await screen.findByText('Review requested')).closest('article') as HTMLElement; + expect(row.querySelector('.v2-activity__hv')).toBeNull(); + expect(within(row).queryByRole('button', { name: 'Mark handled' })).not.toBeInTheDocument(); + const more = within(row).getByRole('button', { name: 'More actions' }); + expect(more).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(more); + const menu = within(row).getByRole('menu'); + expect(within(menu).getAllByRole('menuitem').map((item) => item.textContent)).toEqual(['Mark handled', 'Open']); + fireEvent.keyDown(menu, { key: 'Escape' }); + expect(within(row).queryByRole('menu')).not.toBeInTheDocument(); + expect(more).toHaveFocus(); + fireEvent.click(more); + fireEvent.click(within(within(row).getByRole('menu')).getByRole('menuitem', { name: 'Mark handled' })); + await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/activity/attention-1/acknowledge', {}, expect.anything())); + }); }); test('renders a handoff as a handled action, never as a decision', async () => { @@ -1164,7 +1264,7 @@ describe('V2ActivityPage', () => { expect(await screen.findByLabelText('91 waiting on you')).toHaveTextContent('91'); }); - test('describes the count in the active queue scope without claiming rail/inspector parity', async () => { + test('counts the active queue scope beside its label, with the order and no hint', async () => { const scopedRecap = { ...recap, pods: [...recap.pods, { id: 'pod-2', name: 'GTM Programs' }] }; mockGet.mockImplementation((url: string, config: any) => { if (url === '/api/activity/decision-queue') { @@ -1176,11 +1276,13 @@ describe('V2ActivityPage', () => { }); renderPage(); - expect(await screen.findByText('oldest waiting first')).toBeInTheDocument(); + // The order rides the label's right edge as plain text; no (i) hint carries it any more. + expect(await screen.findByText('· oldest first')).toBeInTheDocument(); expect(screen.getByLabelText('56 waiting on you')).toHaveTextContent('56'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs' })); - expect(await screen.findByText('oldest waiting first · this pod')).toBeInTheDocument(); - expect(screen.getByLabelText('9 waiting on you')).toHaveTextContent('9'); + expect(screen.queryByRole('button', { name: /about/i })).not.toBeInTheDocument(); + chooseScope('GTM Programs'); + expect(await screen.findByLabelText('9 waiting on you')).toHaveTextContent('9'); + expect(document.querySelector('.v2-activity__scope-button')).toHaveTextContent('GTM Programs'); }); test('appends the next server page without losing the existing rows', async () => { @@ -1275,7 +1377,7 @@ describe('V2ActivityPage', () => { }); renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs' })); + chooseScope('GTM Programs'); expect(await screen.findByText('GTM 8')).toBeInTheDocument(); expect(screen.queryByText('Nothing needs you.')).not.toBeInTheDocument(); expect(mockGet).toHaveBeenCalledWith('/api/activity/decision-queue', expect.objectContaining({ @@ -1300,9 +1402,9 @@ describe('V2ActivityPage', () => { }); renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs' })); + chooseScope('GTM Programs'); await waitFor(() => expect(resolveScoped).not.toBeNull()); - fireEvent.click(screen.getByRole('button', { name: 'All pods' })); + chooseScope('All pods'); await screen.findByText('Review requested'); await act(async () => { @@ -1401,9 +1503,9 @@ describe('V2ActivityPage', () => { }); renderPage(); await screen.findByText('Review requested'); - fireEvent.click(screen.getByRole('button', { name: 'GTM Programs', exact: true })); + chooseScope('GTM Programs'); await screen.findByRole('button', { name: 'Retry' }); - expect(screen.getByRole('button', { name: 'GTM Programs', exact: true })).toHaveAttribute('aria-pressed', 'true'); + expect(document.querySelector('.v2-activity__scope-button')).toHaveTextContent('GTM Programs'); expect(screen.queryByText('Review requested')).not.toBeInTheDocument(); expect(screen.getByText('Count unavailable')).toBeInTheDocument(); }); @@ -1538,22 +1640,30 @@ describe('V2ActivityPage', () => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); - test('keeps an overflow pod selection visible and returns focus to its trigger', async () => { + test('every pod is in the one scope select, and a choice returns focus to it (d215 activity)', async () => { const extraPods = [2, 3, 4].map((id) => ({ id: `pod-${id}`, name: `Pod ${id}` })); mockGet.mockImplementation((url: string) => Promise.resolve({ data: url === '/api/activity/decision-queue' ? decisionQueue : { ...recap, pods: [...recap.pods, ...extraPods] } })); renderPage(); - const more = await screen.findByRole('button', { name: i18n.t('activity.morePods') }); - fireEvent.click(more); - const choice = screen.getByRole('button', { name: 'Pod 4', exact: true }); + await screen.findByText('Review requested'); + const scope = document.querySelector('.v2-activity__scope-button') as HTMLButtonElement; + expect(scope).toHaveTextContent('All pods'); + expect(scope).toHaveAttribute('aria-haspopup', 'listbox'); + // No pill row: the pods are not buttons in the head until the select opens. + expect(screen.queryByRole('button', { name: 'Pod 4', exact: true })).not.toBeInTheDocument(); + fireEvent.click(scope); + const list = screen.getByRole('listbox', { name: 'Pod scope' }); + expect(within(list).getAllByRole('option').map((option) => option.textContent)).toEqual(['All pods', 'Launch pod', 'Pod 2', 'Pod 3', 'Pod 4']); + const choice = within(list).getByRole('option', { name: 'Pod 4' }); choice.focus(); fireEvent.click(choice); - expect(more).toHaveTextContent('Pod 4'); - expect(more).toHaveAttribute('aria-expanded', 'false'); - expect(more).toHaveFocus(); - fireEvent.click(more); - fireEvent.keyDown(screen.getByRole('button', { name: 'Pod 3', exact: true }), { key: 'Escape' }); - expect(more).toHaveAttribute('aria-expanded', 'false'); - expect(more).toHaveFocus(); + expect(scope).toHaveTextContent('Pod 4'); + expect(scope).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => expect(scope).toHaveFocus()); + await waitFor(() => expect(mockGet).toHaveBeenCalledWith('/api/activity/recap', expect.objectContaining({ params: { window: 'today', podId: 'pod-4' } }))); + fireEvent.click(scope); + fireEvent.keyDown(within(screen.getByRole('listbox', { name: 'Pod scope' })).getByRole('option', { name: 'Pod 3' }), { key: 'Escape' }); + expect(scope).toHaveAttribute('aria-expanded', 'false'); + expect(scope).toHaveFocus(); }); test('paginates all moved-forward lines in twenty-line batches and retains keyboard focus', async () => { diff --git a/frontend/src/v2/__tests__/signalAccent.test.ts b/frontend/src/v2/__tests__/signalAccent.test.ts index 1b0cbc609..4cd585785 100644 --- a/frontend/src/v2/__tests__/signalAccent.test.ts +++ b/frontend/src/v2/__tests__/signalAccent.test.ts @@ -58,7 +58,8 @@ describe('Signal accent on standalone and bridged surfaces', () => { ['src/components/landing/guideShell.ts', /accent:\s*'#1d3fd1'/], ['src/v2/components/V2FeaturePage.tsx', /accent:\s*'#1d3fd1'/], ['design-system/tokens.css', /::selection\s*\{\s*background:\s*rgba\(29, 63, 209, 0\.20\)/], - ['src/v2/v2.css', /\.v2-activity__compose:focus-within\s*\{\s*box-shadow: 0 0 0 4px rgba\(29, 63, 209, 0\.10\)/], + // The open composer's ring is the focus token, so it shows on the dark ground too (d215 activity). + ['src/v2/components/V2ActivityPage.css', /\.v2-activity__compose--open:focus-within \{ box-shadow: inset 0 0 0 1px var\(--v2-accent\), var\(--v2-focus-ring\); \}/], ])('%s retains its intended cobalt accent', (name, expected) => { expect(read(name as string)).toMatch(expected as RegExp); }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 67ab89b39..9077a8360 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -174,11 +174,11 @@ 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/activity and /v2/artifacts: on a phone the page head is the pinned 52px bar (d215 .mtop 390)', () => { + test('/v2/artifacts: on a phone the page head is the pinned 52px bar (d215 .mtop 390)', () => { const v2 = readV2Css(); - const barSelector = '.v2-activity__header,\n .v2-artifacts__head {'; - const phone = mediaBlockContaining(v2, barSelector); - // Both heads render inside the scrolling .v2-feature__body; sticky is what keeps them on screen. + 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'); @@ -189,66 +189,21 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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'); - // Activity's title shares the rule, and keeps its width: the meta is what gives way. - const activityTitle = selectorRuleBody(phone, '.v2-activity__header .v2-activity__title,'); - expect(activityTitle).toContain('font: 800 22px/1'); - expect(activityTitle).toContain('flex-shrink: 0'); - // The meta sits at the bar's right edge, on both pages, and truncates rather than wrapping. + // 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'); - const meta = selectorRuleBody(phone, '.v2-activity__header .v2-page-head__meta,'); - expect(meta).toContain('margin-left: auto'); - expect(meta).toContain('text-overflow: ellipsis'); - // Closed, the identity is one line (identity wraps by default, which would drop the meta to a - // second row and grow the bar); an open hint keeps its own row. - expect(selectorRuleBody(phone, '.v2-activity__header .v2-page-head__identity:not(:has(.v2-hint--open)),')).toContain('flex-wrap: nowrap'); // 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; }'); - // The block must come after the pages' own head rules, or their padding and baseline win by order. - expect(v2.indexOf(phone)).toBeGreaterThan(v2.lastIndexOf('.v2-activity__header { min-height: 56px;')); 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). + expect(v2).not.toContain('v2-activity'); }); - test('/v2/activity: on a phone the window and scope controls are two full-width rows under the bar (d215 activity 390)', () => { - const v2 = readV2Css(); - const phone = mediaBlockContaining(v2, '.v2-activity__controls { flex-direction: column;'); - const controls = selectorRuleBody(phone, '.v2-activity__controls {'); - expect(controls).toContain('flex-direction: column'); - expect(controls).toContain('flex-wrap: nowrap'); - expect(controls).toContain('align-items: stretch'); - // The 12px gutter is margin, so the block is the frame's 366px box; no filter-row rule under it. - // width: auto drops the ≤1100 width: 100%, which plus the margin would overflow the pane by 24px. - expect(controls).toContain('width: auto'); - expect(controls).toContain('margin: 12px 12px 0'); - expect(controls).toContain('padding: 0'); - expect(controls).toContain('border-bottom: 0'); - // 1px border + 2px padding is the frame's 3px inset. The frame's 36px buttons hold at the 44px touch floor. - expect(selectorRuleBody(phone, '.v2-activity__window,\n .v2-activity__scope {')).toContain('padding: 2px'); - expect(selectorRuleBody(phone, '.v2-activity__window,\n .v2-activity__scope {')).toContain('width: auto'); - const windowButton = selectorRuleBody(phone, '.v2-root button.v2-activity__window-button {'); - expect(windowButton).toContain('flex: 1 1 0'); - expect(windowButton).toContain('justify-content: center'); - expect(windowButton).toContain('min-height: 44px'); - const scopeButton = selectorRuleBody(phone, '.v2-root button.v2-activity__scope-button {'); - expect(scopeButton).toContain('min-height: 44px'); - // The scope stays one row: pod buttons shrink into their ellipsis rather than wrap, never below 44px wide. - expect(scopeButton).toContain('min-width: 44px'); - // Its own rule: `.v2-activity__scope {` alone would match the shared window/scope rule above it. - expect(phone).toContain('\n .v2-activity__scope { flex-wrap: nowrap; }'); - // "All pods" and the "more" trigger never shrink, so the trigger keeps its label and caret. - expect(phone).toContain('.v2-root .v2-activity__scope > button.v2-activity__scope-button:first-child,\n .v2-root button.v2-activity__scope-button--more { flex-shrink: 0; }'); - expect(fs.readFileSync(path.join(__dirname, '../components/V2ActivityPage.tsx'), 'utf8')).toContain("' v2-activity__scope-button--active' : ' v2-activity__scope-button--more'"); - expect(selectorRuleBody(phone, '.v2-activity__controls + .v2-activity__compose {')).toContain('margin-top: 12px'); - // Same selectors as the ≤1100 filter row (a row with a rule under it), so this block must come after it. - const filterRow = v2.indexOf('.v2-activity__controls { position: relative; top: auto; right: auto;'); - expect(filterRow).toBeGreaterThan(-1); - expect(v2.indexOf(phone)).toBeGreaterThan(filterRow); - }); test('/invite: a long pod name or description wraps inside the auth card (d215 s-invite)', () => { const invite = read('../components/V2InviteRedeem.css'); @@ -310,7 +265,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { const podBoard = read('../components/V2PodBoard.tsx'); const activityPage = read('../components/V2ActivityPage.tsx'); const yourTeamPage = read('../components/V2YourTeamPage.tsx'); - const featurePage = read('../components/V2FeaturePage.tsx'); const v2App = read('../V2App.tsx'); const app = read('../../App.tsx'); const appStyles = read('../../App.css'); @@ -951,86 +905,12 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(v2).toMatch(/\.v2-root button\.v2-verification-banner__dismiss \{[\s\S]*?width: 44px;/); }); - test('Activity queue actions stay in the row grammar and wrap on narrow screens', () => { - // Day-zero onboarding and approval actions share the queue row. Keeping - // their action cluster explicit prevents a later button refactor from - // forcing a third column past a 390px viewport. - // The desktop declaration shares its rule with the buttons, so it is not - // eligible for ruleBody's exact-selector helper. - expect(v2).toMatch(/\.v2-activity__queue-actions,\n\.v2-activity__queue-row button \{\n\s*display: flex;[\s\S]*?gap: 6px;/); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-actions \{ grid-column: 2; \}[\s\S]*?\.v2-activity__queue-actions \{ flex-wrap: wrap; \}/); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/); - // Every non-option card act is 32 tall at desktop, level with the options (ux-lead 67327): - // Approve / Deny / Mark handled / Reply / Open read 30 from the shared row-button rule. - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button')).toContain('min-height: 32px'); - }); - - test('Activity details clamp to two lines and reveal full copy on hover, focus, or expand', () => { - const detail = ruleBody(v2, '.v2-root .v2-activity__queue-detail'); - expect(detail).toContain('display: -webkit-box'); - expect(detail).toContain('-webkit-line-clamp: 2'); - expect(detail).toContain('overflow: hidden'); - const reveal = selectorRuleBody(v2, '.v2-root .v2-activity__queue-detail--expanded'); - expect(reveal).toContain('display: block'); - // Tab-in preview is kept; a reader's collapse is excluded so focus can't re-open it (#2407). - const focusReveal = selectorRuleBody(v2, '.v2-root .v2-activity__queue-detail:focus-visible:not(.v2-activity__queue-detail--dismissed)'); - expect(focusReveal).toContain('display: block'); - expect(focusReveal).toContain('-webkit-line-clamp: unset'); - expect(activityPage).toContain('v2-activity__queue-detail--dismissed'); - expect(reveal).toContain('-webkit-line-clamp: unset'); - const baseDetail = selectorRuleBody(v2, '.v2-root button.v2-activity__queue-detail'); - expect(baseDetail).toContain('color: var(--v2-text-secondary)'); - const mentionDetail = selectorRuleBody(v2, '.v2-root .v2-activity__queue-row--mention .v2-activity__queue-detail'); - expect(mentionDetail).toContain('color: var(--v2-text-primary)'); - expect(v2).toContain('.v2-root:lang(zh) .v2-activity__queue-detail,'); - expect(v2).toContain('.v2-root .v2-activity__queue-detail:hover:not(:focus)'); - expect(v2).toContain('@media (hover: hover)'); - expect(activityPage).toContain('aria-expanded={expandedDetailIds.has(item.id)}'); - expect(activityPage).toContain('v2-activity__queue-detail'); - expect(activityPage).toContain('aria-label={t(\'activity.compose.placeholder\')}'); - expect(activityPage).not.toContain('v2-activity__compose-label'); - }); - - test('Activity pagination keeps the Show more affordance visible and keyboard-sized', () => { - const more = ruleBody(v2, '.v2-activity__queue-more'); - expect(more).toContain('min-height: 36px'); - expect(more).toContain('border: 1px solid var(--v2-accent)'); - expect(more).toContain('color: var(--v2-accent-text)'); - expect(v2).toContain('.v2-activity__queue-more:hover:not(:disabled)'); - expect(v2).toContain('.v2-activity__queue-more:disabled'); - }); - - test('DecisionRequest options preserve authored order with one cobalt primary choice', () => { - // The first build left options inside the narrow actions column. Generic - // queue-button CSS once made every non-recommended option blue while the - // first authored option looked secondary — exactly backwards for a fork card. - const decisionActions = ruleBody(v2, '.v2-activity__queue-row--decision .v2-activity__queue-actions'); - expect(decisionActions).toContain('grid-column: 1 / -1'); - expect(decisionActions).toContain('justify-content: flex-start'); - const decisionActionsOverride = lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__queue-actions'); - expect(decisionActionsOverride).toContain('grid-column: 1 / -1'); - expect(decisionActionsOverride).toContain('max-width: none'); - expect(decisionActionsOverride).toContain('width: 100%'); - // Inline at 32px with Other… beside (ux-lead 66400 fix 4), never stacked bars. - expect(decisionActionsOverride).toContain('flex-direction: row'); - expect(decisionActionsOverride).toContain('flex-wrap: wrap'); - expect(decisionActionsOverride).toContain('gap: 8px'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__decision-footer')).toContain('display: contents'); - expect(ruleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision')).toContain('align-items: start'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice')) - .toContain('flex: 0 1 auto'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button')) - .toContain('min-height: 32px'); - expect(lastRuleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button')) - .toContain('padding-inline: 12px'); - const neutralOptionHover = ruleBody(v2, '.v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__option-choice button.v2-activity__option:not(.v2-activity__option--primary):hover:not(:disabled)'); - expect(neutralOptionHover).toContain('background: var(--v2-surface-hover)'); - expect(neutralOptionHover).toContain('color: var(--v2-text-primary)'); - const primaryOptionHover = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary:hover:not(:disabled)'); - expect(primaryOptionHover).toContain('background: var(--v2-accent-strong)'); - expect(primaryOptionHover).toContain('color: var(--v2-on-ink)'); - expect(activityPage).toContain('aria-describedby={describedBy}'); - expect(activityPage).toContain('v2-activity__decision-footer'); + + + + test('DecisionRequest options preserve authored order on the thread card', () => { + // Activity's decision row is pinned in the d215 activity block below; the + // thread card keeps its own grammar and its decision loader here. expect(decisionCard).toContain('aria-describedby={describedBy}'); expect(decisionCard).toContain('v2-decision-card__option-recommended'); expect(decisionCard).toContain('v2-decision-card__option-description'); @@ -1038,36 +918,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-decision-card__options')).toContain('gap: 12px'); expect(ruleBody(v2, '.v2-decision-card__option')).toContain('display: grid'); expect(ruleBody(v2, '.v2-decision-card__option')).toContain('gap: 4px'); - - const neutralOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option'); - expect(neutralOption).toContain('border: 1px solid var(--v2-border)'); - expect(neutralOption).toContain('background: var(--v2-surface)'); - expect(neutralOption).toContain('border-radius: var(--v2-radius-sm)'); - - const primaryOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary'); - expect(primaryOption).toContain('background: var(--v2-accent)'); - expect(primaryOption).toContain('color: var(--v2-on-ink)'); - expect(v2).toContain('v2-activity__queue-action--bordered'); - const borderedAction = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--bordered'); - expect(borderedAction).toContain('border: 1px solid var(--v2-border)'); - expect(ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-button')).toContain('border: 1px solid var(--v2-border)'); - const composeMenu = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-menu'); - expect(composeMenu).toContain('max-height: 240px'); - expect(composeMenu).toContain('overflow-y: auto'); - const composeOption = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-option'); - expect(composeOption).toContain('min-height: 30px'); - expect(composeOption).toContain('background: transparent'); - expect(composeOption).toContain('color: var(--v2-text-primary)'); - const composeOptionHover = ruleBody(v2, '.v2-root .v2-activity__compose .v2-activity__compose-picker-menu button.v2-activity__compose-picker-option:hover:not(:disabled)'); - expect(composeOptionHover).toContain('background: var(--v2-surface-hover)'); - expect(composeOptionHover).toContain('color: var(--v2-text-primary)'); - expect(composeOptionHover).toContain('border-color: transparent'); - expect(v2).toMatch(/@media \(max-width: 640px\) \{[\s\S]*?\.v2-root \.v2-activity__compose \.v2-activity__compose-picker-menu \.v2-activity__compose-picker-option \{ min-height: 44px; \}/); - const otherOption = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option.v2-activity__queue-action--secondary'); - expect(otherOption).toContain('color: var(--v2-accent-text)'); - expect(v2).toContain('.v2-activity__option-description'); - expect(activityPage).toContain('(option, index)'); - expect(activityPage).not.toContain('.sort((a, b) => Number(Boolean(b.recommended))'); expect(thread).toContain("loadDecisionPages("); expect(thread).toContain("'/api/activity/decision-queue'"); expect(thread).toContain("'/api/activity/decision-history'"); @@ -1075,7 +925,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(thread).toContain('loadDecisionPagesForMessageIds'); expect(thread).toContain('loadedMessageIdsRef.current = [...new Set(messages'); expect(thread).toContain('if (pendingData)'); - expect(thread).toContain("'/api/activity/decision-history'"); expect(thread).toContain('settledDecisionByMessageId'); }); @@ -2083,35 +1932,8 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(phone).toContain('.v2-pane--rail { display: none; }'); }); - test('Activity cards have shrinkable desktop and mobile layout guards', () => { - // The recap is a feature-wide page, but it is still reachable at 390px. - // The zero-min grid tracks are the load-bearing no-horizontal-overflow - // rule; jsdom cannot observe the scrollbar they prevent. - expect(ruleBody(v2, '.v2-activity__queue-row')) - .toContain('28px minmax(0, 1fr) auto'); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-activity__queue-row \{[\s\S]*?28px minmax\(0, 1fr\)/); - // The agent grid and board rows this also pinned are gone from the page, - // and their rules with them (TASK-758). - }); - test('Activity queue actions distinguish an action from the thread handoff', () => { - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button')).toContain('background: var(--v2-ink)'); - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--secondary')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--thread')).toContain('background: transparent'); - const bordered = ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--bordered'); - expect(bordered).toContain('border: 1px solid var(--v2-border)'); - expect(bordered).toContain('background: var(--v2-surface)'); - }); - test('DecisionRequest options remain 44px touch targets when they wrap at 390px', () => { - // Options are agent-authored data, not compact task metadata. Keep the - // primary state and the free-text escape hatch visible in the CSS - // source because jsdom has no layout engine to catch a narrow regression. - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--primary')) - .toContain('background: var(--v2-accent)'); - expect(ruleBody(v2, '.v2-activity__decision-other')).toContain('flex-basis: 100%'); - expect(v2).toMatch(/@media \(max-width: 640px\)[\s\S]*?\.v2-root \.v2-activity__queue-actions button \{ min-height: 44px; \}/); - }); test('starter prompts wrap within the mobile chat pane', () => { // On the edge-to-edge phone thread, both the row and each chip need @@ -2883,7 +2705,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(ruleBody(v2, '.v2-connector-gate__switch')).toContain('accent-color: var(--v2-ink)'); expect(ruleBody(v2, '.v2-connector-gate__switch')).not.toContain('border-radius'); expect(ruleBody(v2, '.v2-connector-gate__tag')).toContain('var(--v2-font-mono)'); - const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* Activity queue')); + const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* ---------- Workspace sidebar (TASK-129 PR 1)')); expect(connectorCss.length).toBeGreaterThan(1000); expect(connectorCss).not.toContain('var(--v2-success)'); expect(connectorCss).not.toContain('var(--v2-warning)'); @@ -3029,28 +2851,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { } }); - test('Needs-you rows: open asks sit on a 1px neutral border, no shadow, no dividers (66390; ring moved to focus, TASK-748)', () => { - const row = ruleBody(v2, '.v2-activity__queue-row'); - expect(row).toContain('border: 1px solid var(--v2-border)'); - expect(row).toContain('border-radius: var(--v2-radius)'); - expect(v2).not.toContain('.v2-activity__queue-row + .v2-activity__queue-row'); - const hover = ruleBody(v2, '.v2-activity__queue-row:hover'); - const declarations = hover.slice(hover.indexOf('{') + 1).split(';').map((d) => d.trim()).filter(Boolean); - expect(declarations.length).toBeGreaterThan(0); - for (const declaration of declarations) expect(declaration).toMatch(/^background/); - // A pending decision or approval wears the same neutral border as every other ask — never a lift. - const pending = ruleBody(v2, '.v2-activity__queue-row--decision,\n.v2-activity__queue-row--approval'); - expect(pending).toContain('border-color: var(--v2-border)'); - expect(pending).toContain('box-shadow: none'); + test('the pending-card shadow token stays in step with the design system', () => { expect(cssVariable(v2Root, '--v2-shadow-pending')).toBe(cssVariable(tokens, '--c-shadow-pending')); - // A ruled decision settles to a flat bordered row. - const settled = ruleBody(v2, '.v2-activity__queue-row--settled'); - expect(settled).toContain('border: 1px solid var(--v2-border)'); - expect(settled).toContain('box-shadow: none'); - expect(activityPage).toContain("' v2-activity__queue-row--settled'"); }); - test('Needs you never folds; one count; toggles are tint-selected; kicker and marks per 66389/66390', () => { + test('Needs you never folds and runs oldest waiting first; the rail badge is the exact count (66311, 66390)', () => { // Pages after the first load themselves — the only queue control left is Retry/Loading. expect(activityPage).toContain('void loadMoreQueue(true);'); // The remaining arm stays as the way back if auto-load ever stops short (sprint-review 66398). @@ -3060,76 +2865,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(activityPage).toContain('disabled={queueLoadingMore}'); // The stall detector clears the expectation when nothing fetches the next page (sprint-review 66417). expect(activityPage).toContain('globalThis.window.setTimeout(() => setQueueAutoPending(false), 1500)'); - expect(activityPage).toContain('item.actorUserId\n ? agentUserIds.has(item.actorUserId)'); // Oldest waiting first. expect(activityPage).toContain('new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime()'); - // The section count is mono text beside the heading, not an ink pill. - const count = ruleBody(v2, '.v2-activity__count'); - expect(count).toContain('var(--v2-font-mono)'); - expect(count).not.toContain('var(--v2-ink)'); - // Selected segment: tint ground + ink text inside the bordered group. Ink fill is for acts. - expect(ruleBody(v2, '.v2-root button.v2-activity__window-button--active')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root button.v2-activity__window-button--active')).toContain('box-shadow: none'); - expect(ruleBody(v2, '.v2-root button.v2-activity__scope-button--active')).toContain('background: var(--v2-surface-hover)'); - expect(ruleBody(v2, '.v2-root button.v2-activity__scope-button--active')).not.toContain('var(--v2-ink)'); - // Card kicker: kind · pod (lowercase) · time, in mono 500, lowercase — and the LAST rule says so. - expect(activityPage).toContain('shortPodName(item.podName)'); - expect(lastRuleBody(v2, '.v2-activity__queue-kind')).toContain('500 11px/16px var(--v2-font-mono)'); - expect(lastRuleBody(v2, '.v2-activity__queue-kind')).toContain('text-transform: lowercase'); - // Open asks sit on a 1px neutral border (slop tell 2); the cobalt ring belongs to the focused row only. - expect(lastRuleBody(v2, '.v2-activity__queue-row')).toContain('border: 1px solid var(--v2-border)'); - expect(lastRuleBody(v2, '.v2-activity__queue-row')).not.toContain('var(--v2-accent)'); - expect(lastRuleBody(v2, '.v2-activity__queue-row--approval')).toContain('border-color: var(--v2-border)'); - expect(v2).toContain('.v2-activity__queue-row--approval:focus-within { border-color: var(--v2-accent); box-shadow: 0 0 0 1px var(--v2-accent); }'); - expect(lastRuleBody(v2, '.v2-activity__queue-row--decision')).toContain('box-shadow: none'); - expect(v2).toContain('.v2-activity__queue-row--settled,\n.v2-activity__queue-row--onboarding { padding: 12px; border: 1px solid var(--v2-border); }'); - // Day zero (66658/66666): Get started is its own card above Needs you; a step is not an ask. - expect(activityPage).toContain('className="v2-activity__start" aria-labelledby="activity-get-started"'); - expect(activityPage).not.toContain('v2-activity__queue-row--onboarding'); + // Day zero (66658/66666): the composer hides while nobody is there to wake. expect(activityPage).toContain("{!startSteps.includes('hire') && ("); - expect(ruleBody(v2, '.v2-activity__start-num')).toContain('background: var(--v2-ink)'); - expect(ruleBody(v2, '.v2-root .v2-activity__start-act button')).toContain('min-height: 32px'); - expect(v2).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-root \.v2-activity__start-act button \{ width: 100%; min-height: 44px; \}/); - // One segment grammar (66400 fix 6): both groups bordered, active = tint + ink 600, inactive = white + secondary. - expect(lastRuleBody(v2, '.v2-activity__scope')).toContain('border: 1px solid var(--v2-border)'); - expect(lastRuleBody(v2, '.v2-activity__scope')).toContain('background: var(--v2-surface)'); - expect(lastRuleBody(v2, '.v2-root button.v2-activity__scope-button')).toContain('border: 0'); - expect(lastRuleBody(v2, '.v2-activity__window')).toContain('background: var(--v2-surface)'); - // 0-state (66400 fix 5): a dashed panel with the sentence, not plain text. - expect(lastRuleBody(v2, '.v2-activity__empty--plain')).toContain('border: 1px dashed var(--v2-border)'); expect(activityPage).toContain("t('activity.needsYou.emptyLast', { age: relativeTime(lastAnsweredAt) })"); - // One seat, one mark: agent cobalt, human tint. - expect(ruleBody(v2, '.v2-activity__queue-row .v2-activity__queue-mark--agent')).toContain('background: var(--v2-accent)'); - expect(ruleBody(v2, '.v2-activity__queue-row .v2-activity__queue-mark--human')).toContain('background: var(--v2-surface-hover)'); - // Direction A: the mark holds a 16px glyph; a secondary act is a 32px square - // (44 under 760), never a word — and never a menu. - expect(lastRuleBody(v2, '.v2-root .v2-activity__queue-mark svg')).toContain('width: 16px'); - expect(lastRuleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon svg')).toContain('width: 16px'); - const iconAct = lastRuleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon'); - expect(iconAct).toContain('width: 32px'); - expect(iconAct).toContain('padding: 0'); - // Both 44px rules must sit INSIDE a ≤760 block — sprint-review's gate on #1671: a lazy regex - // matched from an earlier 760 query into the 640 block, so 641–760 got 674px bars, and a move - // to top level passed too. Resolve the media block that actually encloses each rule. - const enclosingMedia = (rule: string): string | null => { - const at = v2.indexOf(rule); - expect(at).toBeGreaterThan(-1); - const open = v2.lastIndexOf('@media', at); - if (open === -1) return null; - const close = v2.indexOf('\n}', open); - return close > at ? v2.slice(open, v2.indexOf('{', open)).trim() : null; - }; - const icon44 = '.v2-root .v2-activity__queue-actions button.v2-activity__queue-action--icon { width: 44px; min-width: 44px; flex: none; }'; - const footer44 = '.v2-root .v2-activity__queue-row.v2-activity__queue-row--decision .v2-activity__decision-footer > button.v2-activity__queue-action--icon { flex: none; width: 44px; min-width: 44px; }'; - expect(enclosingMedia(icon44)).toBe('@media (max-width: 760px)'); - expect(enclosingMedia(footer44)).toBe('@media (max-width: 760px)'); - // And the footer override follows the text-button rule it beats, inside that same block. - const footerText = '.v2-activity__decision-footer > button { flex: 1 1 auto;'; - expect(v2.indexOf(footer44)).toBeGreaterThan(v2.indexOf(footerText)); - expect(enclosingMedia(footerText)).toBe('@media (max-width: 760px)'); - // Other… is cobalt text; the handoff act is ink (no bordered modifier). - expect(ruleBody(v2, '.v2-root .v2-activity__queue-actions button.v2-activity__option--other')).toContain('color: var(--v2-accent-text)'); - expect(activityPage).toContain(' + {open && ( +
        + {options.map((option, index) => ( + + ))} +
        + )} +
      + ); +}; + +interface RowAct { key: string; label: string; glyph: React.ReactNode; onSelect: () => void; disabled?: boolean } + +// The phone's ⋯ key (d215 activity 390): there is no hover, so the row's +// secondary acts live behind one 44px key, in the same order as the cluster. +const RowMenu: React.FC<{ acts: RowAct[]; label: string; open: boolean; setOpen: (open: boolean) => void }> = ({ + acts, label, open, setOpen, +}) => { + const holderRef = useRef(null); + const buttonRef = useRef(null); + useOutsidePress(open, holderRef, () => setOpen(false)); + return ( +
      { + if (event.key === 'Escape' && open) { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + buttonRef.current?.focus(); + } + }} + > + + {open && ( +
      + {acts.map((act) => ( + + ))} +
      + )} +
      + ); +}; + const V2ActivityPage: React.FC = () => { const location = useLocation(); const navigate = useNavigate(); @@ -238,10 +416,13 @@ const V2ActivityPage: React.FC = () => { const [composePodId, setComposePodId] = useState(''); const [composeDraft, setComposeDraft] = useState(''); const [composeMenuOpen, setComposeMenuOpen] = useState(false); - const composePickerRef = useRef(null); - const composePickerButtonRef = useRef(null); - const composePickerOptionRefs = useRef>({}); - const scopeMenuButtonRef = useRef(null); + // The composer is one line until its text area takes focus (d215 activity), and stays open + // while focus is inside it or it holds a draft, a send or an error. + const [composeFocused, setComposeFocused] = useState(false); + const [rowMenuId, setRowMenuId] = useState(null); + // At phone width the head holds only the title and count; the window and scope move into + // the page as one row, and each row's secondary acts sit behind ⋯ (d215 activity 390). + const phone = usePhone('(max-width: 760px)'); const [composing, setComposing] = useState(false); const [composeError, setComposeError] = useState(null); const [movedVisibleCounts, setMovedVisibleCounts] = useState>({}); @@ -320,22 +501,6 @@ const V2ActivityPage: React.FC = () => { }; }, []); - useEffect(() => { - if (!composeMenuOpen) return undefined; - const closeOnOutsidePress = (event: MouseEvent | TouchEvent) => { - const target = event.target; - if (target instanceof Node && composePickerRef.current && !composePickerRef.current.contains(target)) { - setComposeMenuOpen(false); - } - }; - document.addEventListener('mousedown', closeOnOutsidePress); - document.addEventListener('touchstart', closeOnOutsidePress); - return () => { - document.removeEventListener('mousedown', closeOnOutsidePress); - document.removeEventListener('touchstart', closeOnOutsidePress); - }; - }, [composeMenuOpen]); - useEffect(() => { const snapshot = restoredSnapshotRef.current; if (!snapshotReady || !snapshot || !queueHydrated) return; @@ -823,32 +988,9 @@ const V2ActivityPage: React.FC = () => { return recap?.pods || []; }, [recap]); const composePodName = scopedPods.find((pod) => pod.id === composePodId)?.name || t('activity.allPods'); - const selectComposePod = (nextPodId: string) => { - setComposePodId(nextPodId); - setComposeMenuOpen(false); - globalThis.window.requestAnimationFrame(() => composePickerButtonRef.current?.focus()); - }; - const focusComposePickerOption = (index: number) => { - if (scopedPods.length === 0) return; - const boundedIndex = (index + scopedPods.length) % scopedPods.length; - const option = scopedPods[boundedIndex]; - if (!option) return; - setComposeMenuOpen(true); - globalThis.window.requestAnimationFrame(() => composePickerOptionRefs.current[option.id]?.focus()); - }; - // One seat has one mark everywhere (66390): an agent actor takes the chat's - // cobalt mark, a human the tint mark. The kind glyph stays for asks with no actor. - // Rows written before the actor id was stamped still carry the name, so the - // name is the second door. - const agentUserIds = useMemo(() => new Set((recap?.agents || []).map((agent) => String(agent.id))), [recap]); - const agentNames = useMemo(() => new Set((recap?.agents || []).map((agent) => agent.name.trim().toLowerCase())), [recap]); - // The id is the authority when it exists — a failed id lookup means "not an - // agent", not "unknown" — so the name door opens only for rows with no id. - const isAgentActor = (item: NeedsYouItem): boolean => ( - item.actorUserId - ? agentUserIds.has(item.actorUserId) - : (!!item.actorName && agentNames.has(item.actorName.trim().toLowerCase())) - ); + const podOptions = useMemo(() => scopedPods.map((pod) => ({ id: pod.id, label: pod.name })), [scopedPods]); + const scopeOptions = useMemo(() => [{ id: 'all', label: t('activity.allPods') }, ...podOptions], [podOptions, t]); + const scopeName = scopeOptions.find((option) => option.id === podId)?.label || t('activity.allPods'); // 0-state (66400 fix 5): "Nothing needs you." and, when the history knows it, // when the last ask was answered — the newest ruling in this scope. const lastAnsweredAt = useMemo(() => { @@ -1091,335 +1233,322 @@ const V2ActivityPage: React.FC = () => { const markHandoffHandled = (item: NeedsYouItem) => acknowledgeAttention(item, 'activity.handoff.actionFailed'); - return ( -
      - sum + group.lines.length, 0) })}`} - hint={t('activity.subtitle')} - hintLabel={t('activity.about')} + const openLabel = (item: NeedsYouItem) => ( + item.messageId === undefined || item.messageId === null || item.messageId === '' ? t('activity.openPod') : t('activity.open') + ); + const openAct = (item: NeedsYouItem): RowAct => ({ + key: 'open', label: openLabel(item), glyph: , onSelect: () => openPod(item.podId, item.messageId), disabled: !item.podId, + }); + const iconKey = (act: RowAct) => ( + + ); + // A row's secondary acts: the hover cluster at desktop, the ⋯ menu on a phone (d215 activity). + const secondaryActs = (item: NeedsYouItem): RowAct[] => { + if (item.kind === 'approval') { + return [{ + key: 'deny', label: t('activity.approval.deny'), glyph: , onSelect: () => { void actOnApproval(item, 'reject'); }, disabled: actingApprovalId === item.id, + }, openAct(item)]; + } + if (item.kind === 'mention') { + return [{ + key: 'handled', + label: acknowledgingAttentionId === item.id ? t('activity.mention.working') : t('activity.mention.markHandled'), + glyph: , + onSelect: () => { void acknowledgeMention(item); }, + disabled: acknowledgingAttentionId === item.id, + }, openAct(item)]; + } + if (item.kind === 'handoff' || item.kind === 'blocked') return [openAct(item)]; + return []; + }; + const primaryKey = (item: NeedsYouItem): React.ReactNode => { + if (item.kind === 'approval') { + return ( + + ); + } + if (item.kind === 'mention') { + if (replyOpenIds.has(item.id)) return null; + return ( + + ); + } + if (item.kind === 'handoff' || item.kind === 'blocked') { + return ( + + ); + } + // A decision's only act beside its row is Open; its choices are keys inside the row. + return iconKey(openAct(item)); + }; + const rowDomId = (item: NeedsYouItem) => `activity-row-${item.id}`.replace(/[^a-zA-Z0-9_-]/g, '-'); + const movedCount = movedGroups.reduce((sum, group) => sum + group.lines.length, 0); + const startGlyph = { hire: 'agents', speak: 'chat', connect: 'plug' } as const; + + const controls = ( +
      + + className="v2-activity__window" + ariaLabel={t('activity.windowAriaLabel')} + value={window} + onChange={setWindow} + options={(['today', '7d'] as ActivityWindow[]).map((value) => ({ value, label: t(`activity.windows.${value}`) }))} /> -
      -
      - {(['today', '7d'] as ActivityWindow[]).map((value) => ( - - ))} -
      -
      { - if (event.key === 'Escape' && scopeMenuOpen) { - event.preventDefault(); - event.stopPropagation(); // This menu restores focus; the global Escape handler blurs it. - setScopeMenuOpen(false); - scopeMenuButtonRef.current?.focus(); - } - }}> - - {scopedPods.slice(0, 2).map((pod) => ( - - ))} - {scopedPods.length > 2 && } - {scopeMenuOpen &&
      {scopedPods.slice(2).map((pod) => ( - - ))}
      } -
      -
      + +
      + ); + const composeOpen = composeFocused || composeDraft.length > 0 || composing || Boolean(composeError); - {loading &&
      } - {!loading && error &&
      - {error} - -
      } - {!loading && !error && recap && ( - <> - {/* The composer stays hidden while step 1 is open — nobody to wake — and returns the moment an agent exists. */} - {!startSteps.includes('hire') && ( -
      -
      -
      - {t('activity.compose.podLabel')} -
      - - {composeMenuOpen &&
      - {scopedPods.map((pod, index) => ( - - ))} -
      } -
      -
      -
      -