diff --git a/docs/design/artboards/d215/README.md b/docs/design/artboards/d215/README.md index 1bbeaf1a2a..a20f8f8523 100644 --- a/docs/design/artboards/d215/README.md +++ b/docs/design/artboards/d215/README.md @@ -19,7 +19,7 @@ Passing the prod check means a page matches its frame. It means the page matches | 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. **Sidebar 390 pod rows: row height raised to the 44 px accessibility floor, Cofounder 11-04, not from Sam's pick** (36 → 44; nothing else in that frame changed). Sam can reverse it. | +| **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. **Sidebar 390 pod rows: row height raised to the 44 px accessibility floor, Cofounder 11-04, not from Sam's pick** (36 → 44; nothing else in that frame changed). Sam can reverse it. **Pin on touch via press and hold, Cofounder 11-04:** the frame shows the pin only in a row's hover state, so on a phone a 500 ms press and hold is that hover. It reveals the pin in the time's place, and a tap on it pins. At rest, pinned pods show under the view switch's Pinned option, as the frame draws it. No frame change. | | **Drawn after the pick** | dm, byo, byo-handoff, connect, team, firstrun-\*, signin, signup, signup-sent, verify-email, forgot-password, reset-password, cli-authorize, invite, invite-required, profile, agent-profile, run-trace-after, chat-trace, chat-agents-away, and the agent-settings and settings state blocks | New drawings in the picked language, from 11-01 on. Sam has not reviewed them as a set. | | **Approved by Sam, 11-03** | activity-busy, activity-dayzero (`#s-activity-signal`); artifacts-busy, artifacts-dayzero (`#s-artifacts-signal`); task-record-\*; handoff-\* | Sam saw each one live-beside-proposed and answered "Build as drawn" for all four pages. Activity and Artifacts are built only from the picked surfaces' blocks, and both are scored since their builds. Task record and handoff still carry `data-state`, so the scorer skips them. | diff --git a/frontend/src/v2/__tests__/V2PersonProfile.test.tsx b/frontend/src/v2/__tests__/V2PersonProfile.test.tsx index fe3bb96a3b..5af24671f2 100644 --- a/frontend/src/v2/__tests__/V2PersonProfile.test.tsx +++ b/frontend/src/v2/__tests__/V2PersonProfile.test.tsx @@ -73,6 +73,8 @@ describe('V2PersonProfile', () => { expect(await screen.findByRole('heading', { level: 1, name: 'Juno Park' })).toBeInTheDocument(); expect(screen.getByText(/@junopark/)).toBeInTheDocument(); expect(screen.getByTestId('profile-counts')).toHaveTextContent('12 followers · 8 following'); + // d215 s-profile: each number is bold, its word stays in the line's tone. + expect([...screen.getByTestId('profile-counts').querySelectorAll('b')].map((b) => b.textContent)).toEqual(['12', '8']); expect(screen.getByRole('heading', { name: 'Pods in common' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Northwind/ })).toHaveAttribute('href', '/v2/pods/pod-1'); expect(screen.getByText('7 members')).toBeInTheDocument(); diff --git a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx index c0dad35d14..ff53068f21 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx @@ -441,6 +441,53 @@ describe('V2PodsSidebar — direction C', () => { expect(screen.getByTestId('current-path')).toHaveTextContent('/v2/pods/sharpen'); }); + test('a touch press and hold reveals the row pin in the time slot without opening the pod', () => { + jest.useFakeTimers(); + try { + renderSidebar(fleet, null); + const pin = screen.getAllByRole('button', { name: 'Pin pod' })[0]; + const wrap = pin.parentElement as HTMLElement; + const row = wrap.querySelector('.v2-pods__row') as HTMLElement; + const before = screen.getByTestId('current-path').textContent; + // A quick tap opens the pod and reveals nothing. + fireEvent.pointerDown(row, { pointerType: 'touch' }); + fireEvent.pointerUp(row, { pointerType: 'touch' }); + act(() => { jest.advanceTimersByTime(600); }); + expect(wrap).not.toHaveClass('v2-pods__rowwrap--reveal'); + // A hold reveals the pin; the click it ends in stays on the list. + fireEvent.pointerDown(row, { pointerType: 'touch' }); + act(() => { jest.advanceTimersByTime(600); }); + expect(wrap).toHaveClass('v2-pods__rowwrap--reveal'); + fireEvent.pointerUp(row, { pointerType: 'touch' }); + fireEvent.click(row); + expect(screen.getByTestId('current-path').textContent).toBe(before); + // A tap on the revealed pin pins the pod, still without opening it (Cofounder 11-04: + // press and hold is how a touch user pins). + mockTogglePin.mockClear(); + fireEvent.pointerDown(pin, { pointerType: 'touch' }); + fireEvent.pointerUp(pin, { pointerType: 'touch' }); + fireEvent.click(pin); + expect(mockTogglePin).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('current-path').textContent).toBe(before); + // A tap outside the row puts the time back. + fireEvent.pointerDown(document.body); + expect(wrap).not.toHaveClass('v2-pods__rowwrap--reveal'); + // A browser that sends no click after a long press must not leave the next + // keyboard Enter or mouse click swallowed (#3412 review). + const other = screen.getAllByRole('button', { name: 'Pin pod' })[1].parentElement + ?.querySelector('.v2-pods__row') as HTMLElement; + fireEvent.pointerDown(other, { pointerType: 'touch' }); + act(() => { jest.advanceTimersByTime(600); }); + fireEvent.pointerUp(other, { pointerType: 'touch' }); + act(() => { jest.advanceTimersByTime(1000); }); + const pathBefore = screen.getByTestId('current-path').textContent; + fireEvent.click(other); + expect(screen.getByTestId('current-path').textContent).not.toBe(pathBefore); + } finally { + jest.useRealTimers(); + } + }); + test('recentPods excludes pinned pods and caps at the limit', () => { const rows = recentPods(fleet, new Set(['sharpen']), 3); expect(rows.map((p) => p._id)).toEqual(['connectors', 'hq', 'rewire']); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 21cdba387b..61b0817ddc 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -600,12 +600,14 @@ describe('v2 layout invariants (CSS rule presence)', () => { // visibility, not opacity: an opacity-0 pin still catches a tap on the time. expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).toContain('visibility: hidden'); expect(ruleBody(v2, '.v2-root button.v2-pods__pin')).not.toContain('opacity: 0'); - expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__pin,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__pin { visibility: visible; }'); - // Touch has no hover: the pin shows at rest there, or a phone cannot pin. - expect(v2).toMatch(/@media \(hover: none\) and \(pointer: coarse\) \{\n \.v2-root button\.v2-pods__pin \{ visibility: visible; \}\n \.v2-pods__row-time \{ visibility: hidden; \}/); + expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__pin,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__pin,\n.v2-root .v2-pods__rowwrap--reveal .v2-pods__pin { visibility: visible; }'); + // Touch has no hover: the 390 frame still draws the time at rest, and a press and + // hold (--reveal) is the row's hover state there, or a phone cannot pin. + expect(v2).not.toMatch(/@media \(hover: none\) and \(pointer: coarse\) \{\n \.v2-root button\.v2-pods__pin \{ visibility: visible; \}/); + expect(podsSidebar).toContain("revealedPin === pod._id ? 'v2-pods__rowwrap--reveal' : ''"); expect(v2).not.toContain('.v2-root button.v2-pods__pin--active { opacity: 1; }'); expect(v2).not.toContain("button.v2-pods__pin::before { content: '⌖'"); - expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__row-time,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__row-time { visibility: hidden; }'); + expect(v2).toContain('.v2-pods__rowwrap:hover .v2-pods__row-time,\n.v2-pods__rowwrap:has(:focus-visible) .v2-pods__row-time,\n.v2-pods__rowwrap--reveal .v2-pods__row-time { visibility: hidden; }'); expect(ruleBody(v2, '.v2-pods__row-time')).toContain('min-width: 24px'); // The shared segment: a tinted track with the checked option raised. // D223: the segment draws as the primitives' v2p-seg; v2.css keeps only its states. @@ -6414,3 +6416,49 @@ describe('front door keys, auth gutter and the shell state line (11-04)', () => expect(gate.match(/className="v2-state"/g) || []).toHaveLength(2); }); }); + +// G11 polish (11-04): four mismatches a reviewer found by eye in the frame-vs-live +// pngs, which the box scorer cannot see. Each guard pins what the frame draws. +describe('G11 polish: what the d215 frames draw that the scorer cannot see', () => { + const profileCss = read('../components/V2PersonProfile.css'); + const profile = read('../components/V2PersonProfile.tsx'); + const byoCss = read('../components/V2AgentBYO.css'); + const byo = read('../components/V2AgentBYO.tsx'); + const feature = read('../components/V2FeaturePage.tsx'); + const rt = read('../components/V2RunTrace.css'); + + test('profile: each count is a 600 number in the primary ink (frame .v2-profile__counts b)', () => { + const b = ruleBody(profileCss, '.v2-profile .v2-profile__counts b'); + expect(b).toContain('font-weight: 600'); + expect(b).toContain('color: var(--v2-text-primary)'); + expect(profile).toContain('{n}'); + }); + + test('BYO 390: the phone head leads with a 44px back key, hidden above 760', () => { + expect(feature).toContain('lead={lead}'); + expect(byo).toContain('className="v2-byo__back"'); + expect(byo).toContain("onClick={() => navigate('/v2/agents')}"); + const phone = mediaBlockContaining(byoCss, '.v2-root .v2-feature--byo button.v2-byo__back'); + const back = ruleBody(byoCss, '.v2-root .v2-feature--byo button.v2-byo__back', { within: phone }); + expect(back).toContain('width: 44px; height: 44px'); + expect(byoCss).toMatch(/@media \(min-width: 761px\) \{\n \.v2-feature--byo \.v2-byo__back \{ display: none; \}/); + }); + + test('BYO 390: the 16px title fills the bar, and each "i" is a 44 box with the plain 15px glyph', () => { + const phone = mediaBlockContaining(byoCss, '.v2-feature--byo > .v2-feature__header .v2-feature__title'); + const title = ruleBody(byoCss, '.v2-feature--byo > .v2-feature__header .v2-feature__title', { within: phone }); + expect(title).toContain('flex: 1 1 0; font: 700 16px/1.1 var(--v2-font-display)'); + const hint = ruleBody(byoCss, '.v2-root .v2-feature--byo button.v2-hint__trigger', { within: phone }); + expect(hint).toContain('width: 44px; height: 44px'); + expect(hint).toContain('border: 0'); + expect(hint).toContain('font-size: 0'); + expect(ruleBody(byoCss, '.v2-root .v2-feature--byo button.v2-hint__trigger::before', { within: phone })).toContain('width: 15px; height: 15px'); + expect(byoCss).not.toContain('button.v2-hint__trigger::after { content: \'\'; position: absolute; inset: -9px; }'); + }); + + test('run trace: headings balance their lines and "Back to pod" is underlined, as the frame draws', () => { + expect(ruleBody(rt, '.v2-run-trace h1')).toContain('text-wrap: balance'); + expect(ruleBody(rt, '.v2-run-trace h2')).toContain('text-wrap: balance'); + expect(ruleBody(rt, '.v2-root a.v2-run-trace__back')).toContain('text-decoration: underline'); + }); +}); diff --git a/frontend/src/v2/components/V2AgentBYO.css b/frontend/src/v2/components/V2AgentBYO.css index dc049a64a4..d686c022fb 100644 --- a/frontend/src/v2/components/V2AgentBYO.css +++ b/frontend/src/v2/components/V2AgentBYO.css @@ -99,11 +99,38 @@ .v2-feature--byo textarea.v2-byo__memory-text { height: auto; padding: 10px 12px; line-height: 20px; } @media (max-width: 760px) { .v2-feature--byo .v2-byo__mode-option > .v2-hint { min-width: 44px; min-height: 44px; justify-content: center; } - /* Every "i" hint on the page (the head's and each start's) draws 28 with a 1px border; - the frame's info--lg is a 44 touch box. An overlay measures from inside the border - (26), so it reaches 9px each way: 26 + 18 = 44. */ - .v2-root .v2-feature--byo button.v2-hint__trigger { position: relative; } - .v2-root .v2-feature--byo button.v2-hint__trigger::after { content: ''; position: absolute; inset: -9px; } + /* Every "i" hint on the page (the head's and each start's) is the frame's info--lg: + a 44 box with no border holding the 15px i-info glyph in the placeholder tone. V2Hint + draws the letter "i", so the letter is hidden and the glyph is masked in its place. */ + .v2-root .v2-feature--byo button.v2-hint__trigger { + position: relative; width: 44px; height: 44px; flex: 0 0 44px; + border: 0; border-radius: 50%; font-size: 0; color: var(--v2-text-placeholder); + } + .v2-root .v2-feature--byo button.v2-hint__trigger:hover { background: transparent; color: var(--v2-text-primary); } + .v2-root .v2-feature--byo button.v2-hint__trigger::before { + content: ''; position: absolute; inset: 0; margin: auto; width: 15px; height: 15px; background: currentColor; + -webkit-mask: var(--v2-byo-info-glyph) center / 15px 15px no-repeat; + mask: var(--v2-byo-info-glyph) center / 15px 15px no-repeat; + } + /* The phone head (frame .mtop): back key, the 16px title filling the bar, the hint at its right edge. */ + .v2-feature--byo { + --v2-byo-info-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M12 16v-4M12 8h.01'/%3E%3C/svg%3E"); + } + .v2-feature--byo > .v2-feature__header .v2-page-head__identity { flex: 1 1 0; } + .v2-feature--byo > .v2-feature__header .v2-feature__title { + flex: 1 1 0; font: 700 16px/1.1 var(--v2-font-display); letter-spacing: -0.02em; + } + .v2-root .v2-feature--byo button.v2-byo__back { + all: unset; box-sizing: border-box; cursor: pointer; flex: none; + display: inline-flex; align-items: center; justify-content: center; + width: 44px; height: 44px; border-radius: 12px; color: var(--v2-text-secondary); + } + .v2-root .v2-feature--byo button.v2-byo__back:hover { background: var(--v2-surface-hover); color: var(--v2-text-primary); } + .v2-root .v2-feature--byo button.v2-byo__back:focus-visible { box-shadow: var(--v2-focus-ring); } + .v2-feature--byo .v2-byo__back svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +} +@media (min-width: 761px) { + .v2-feature--byo .v2-byo__back { display: none; } } /* not drawn in the frame yet: kept as it was */ diff --git a/frontend/src/v2/components/V2AgentBYO.tsx b/frontend/src/v2/components/V2AgentBYO.tsx index ffbe657232..eb0edc1a97 100644 --- a/frontend/src/v2/components/V2AgentBYO.tsx +++ b/frontend/src/v2/components/V2AgentBYO.tsx @@ -1257,6 +1257,20 @@ const V2AgentBYO: React.FC = () => { if (showCompactByo) return t(setupOs === 'windows' ? 'agentByo.compactByo.leadWindows' : 'agentByo.compactByo.lead'); return t('agentByo.description'); })()} + // d215 s-byo 390: the phone head leads with a back key to Your team; the + // 1440 head has none, so the key is hidden above 760. + lead={( + + )} showPodsSidebar={false} className="v2-feature--byo" > diff --git a/frontend/src/v2/components/V2FeaturePage.tsx b/frontend/src/v2/components/V2FeaturePage.tsx index 31f904fd62..13b3b410a1 100644 --- a/frontend/src/v2/components/V2FeaturePage.tsx +++ b/frontend/src/v2/components/V2FeaturePage.tsx @@ -12,6 +12,8 @@ import V2PageHead from './V2PageHead'; interface V2FeaturePageProps { title: string; hint?: React.ReactNode; + /** Drawn before the title in the head bar (BYO's phone back key). */ + lead?: React.ReactNode; children: React.ReactNode; showPodsSidebar?: boolean; showHeader?: boolean; @@ -295,6 +297,7 @@ const v2FeatureTheme = createTheme({ const V2FeaturePage: React.FC = ({ title, hint, + lead, children, showPodsSidebar = false, showHeader = true, @@ -333,7 +336,7 @@ const V2FeaturePage: React.FC = ({ {showPodsSidebar && }
- {showHeader && } + {showHeader && }
diff --git a/frontend/src/v2/components/V2PersonProfile.css b/frontend/src/v2/components/V2PersonProfile.css index 0a1ae38b38..040be33021 100644 --- a/frontend/src/v2/components/V2PersonProfile.css +++ b/frontend/src/v2/components/V2PersonProfile.css @@ -12,6 +12,7 @@ .v2-profile .v2-profile__name { margin: 0; font: 800 24px/1.15 var(--v2-font-display); letter-spacing: -0.03em; color: var(--v2-text-primary); overflow-wrap: anywhere; } .v2-profile .v2-profile__meta { font: 500 12px/16px var(--v2-font-mono); color: var(--v2-text-muted); } .v2-profile .v2-profile__counts { font-size: 13px; line-height: 18px; color: var(--v2-text-secondary); } +.v2-profile .v2-profile__counts b { font-weight: 600; color: var(--v2-text-primary); } .v2-profile .v2-profile__follow, .v2-profile .v2-profile__edit { all: unset; box-sizing: border-box; cursor: pointer; flex: none; display: inline-flex; align-items: center; justify-content: center; height: 34px; padding: 0 14px; border-radius: 10px; font: 600 13px/1 var(--v2-font); white-space: nowrap; text-decoration: none; } .v2-profile .v2-profile__follow { color: var(--v2-on-fill); background: linear-gradient(180deg, var(--v2-fill-a), var(--v2-fill-b)); border: 1px solid var(--v2-fill-edge); } .v2-profile .v2-profile__follow--on, .v2-profile .v2-profile__edit { color: var(--v2-text-primary); background: linear-gradient(180deg, var(--v2-key-a), var(--v2-key-b)); border: 1px solid var(--v2-key-edge); box-shadow: var(--v2-key-inset); } diff --git a/frontend/src/v2/components/V2PersonProfile.tsx b/frontend/src/v2/components/V2PersonProfile.tsx index 59f923acab..1f8b185000 100644 --- a/frontend/src/v2/components/V2PersonProfile.tsx +++ b/frontend/src/v2/components/V2PersonProfile.tsx @@ -66,6 +66,16 @@ const formatDate = (value: string | undefined, language: string, options: Intl.D } }; +// The frame sets each count in bold and its word in the secondary tone. The +// locale strings carry the number inside the sentence ("12 followers", +// "关注了8人"), so the formatted string is split at the number, not rebuilt. +const boldCount = (text: string, count: number): React.ReactNode => { + const n = String(count); + const at = text.indexOf(n); + if (at < 0) return text; + return <>{text.slice(0, at)}{n}{text.slice(at + n.length)}; +}; + const V2PersonProfile: React.FC = () => { const { id = '' } = useParams<{ id: string }>(); const { t, i18n } = useTranslation(); @@ -198,9 +208,9 @@ const V2PersonProfile: React.FC = () => { {!profile.isBot && ( - {t('profile.followers', { count: profile.followersCount || 0 })} + {boldCount(t('profile.followers', { count: profile.followersCount || 0 }), profile.followersCount || 0)} {' · '} - {t('profile.followingCount', { count: profile.followingCount || 0 })} + {boldCount(t('profile.followingCount', { count: profile.followingCount || 0 }), profile.followingCount || 0)} )} diff --git a/frontend/src/v2/components/V2PodsSidebar.tsx b/frontend/src/v2/components/V2PodsSidebar.tsx index 0bcd54bb3d..75d7456a1e 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -273,6 +273,47 @@ const V2PodsSidebar: React.FC = ({ navigate(`/v2/pods/${podId}`); }, [navigate]); + // d215 s-sidebar 390 draws each row's time at rest and the pin only in the + // row's hover state (.pod.hov), which a touch screen has no pointer for. A + // press and hold is that state on touch: it reveals the row's pin in the + // time's slot, and the click the press ends in does not open the pod. A tap + // anywhere outside the row puts the time back. + const [revealedPin, setRevealedPin] = useState(null); + const pressTimer = useRef | null>(null); + const pressRevealed = useRef(false); + const cancelPress = useCallback(() => { + if (pressTimer.current) clearTimeout(pressTimer.current); + pressTimer.current = null; + }, []); + const startPress = (podId: string) => (event: React.PointerEvent) => { + pressRevealed.current = false; + if (event.pointerType === 'mouse') return; + cancelPress(); + pressTimer.current = setTimeout(() => { + pressTimer.current = null; + pressRevealed.current = true; + setRevealedPin(podId); + }, 500); + }; + // The click a hold ends in comes straight after the release, so the guard + // lives only that long. Some browsers (Chrome on Android) send no click after + // a long press, and a guard left set would swallow the next mouse click or + // keyboard Enter instead (#3412 review). + const endPress = useCallback(() => { + cancelPress(); + if (pressRevealed.current) setTimeout(() => { pressRevealed.current = false; }, 400); + }, [cancelPress]); + useEffect(() => cancelPress, [cancelPress]); + useEffect(() => { + if (!revealedPin) return undefined; + const dismiss = (event: PointerEvent) => { + const target = event.target as Element | null; + if (!target?.closest?.('.v2-pods__rowwrap--reveal')) setRevealedPin(null); + }; + document.addEventListener('pointerdown', dismiss); + return () => document.removeEventListener('pointerdown', dismiss); + }, [revealedPin]); + const chooseListView = (view: PodListView) => { setListView(view); writeListView(view); @@ -323,7 +364,14 @@ const V2PodsSidebar: React.FC = ({ const time = relativeTime(podMessageTime(pod) || null, now); const isPinned = pinned.has(pod._id); return ( -
+
- {/* D215: the pin is an icon on row hover that takes the time's slot. */} + {/* D215: the pin is an icon on row hover (or a touch press and hold) that takes the time's slot. */}