From 2d5c76a1e4743127250e18b0e70efcf8bea8632d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:47:49 -0800 Subject: [PATCH 1/3] fix(v2): profile counts, the BYO phone head, the phone sidebar's time and the run trace title match their frames Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MDs9DT6tbhKm6UhsmFhLk9 --- .../src/v2/__tests__/V2PersonProfile.test.tsx | 2 + .../v2/__tests__/V2PodsSidebar.scale.test.tsx | 28 ++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 56 +++++++++++++++++-- frontend/src/v2/components/V2AgentBYO.css | 37 ++++++++++-- frontend/src/v2/components/V2AgentBYO.tsx | 14 +++++ frontend/src/v2/components/V2FeaturePage.tsx | 5 +- .../src/v2/components/V2PersonProfile.css | 1 + .../src/v2/components/V2PersonProfile.tsx | 14 ++++- frontend/src/v2/components/V2PodsSidebar.tsx | 54 +++++++++++++++++- frontend/src/v2/components/V2RunTrace.css | 8 ++- frontend/src/v2/v2.css | 15 +++-- 11 files changed, 211 insertions(+), 23 deletions(-) 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..a2b28792cc 100644 --- a/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx +++ b/frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx @@ -441,6 +441,34 @@ 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 outside the row puts the time back. + fireEvent.pointerDown(document.body); + expect(wrap).not.toHaveClass('v2-pods__rowwrap--reveal'); + } 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..af3b3d8772 100644 --- a/frontend/src/v2/components/V2PodsSidebar.tsx +++ b/frontend/src/v2/components/V2PodsSidebar.tsx @@ -273,6 +273,39 @@ 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) => { + if (event.pointerType === 'mouse') return; + cancelPress(); + pressRevealed.current = false; + pressTimer.current = setTimeout(() => { + pressTimer.current = null; + pressRevealed.current = true; + setRevealedPin(podId); + }, 500); + }; + 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 +356,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. */}