From 86e1596bed394e0c0af8b19b6c22d65acb8e1674 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 27 Jul 2026 01:56:29 +0000 Subject: [PATCH 1/3] fix(web): collapse the mobile "Live" badge into the action bar (IDEA-2297) At <=768px the collection page hid the h1 (the name lives in MobileContextBar) but left the SSE status badge in .title-group, so that row rendered a full line containing nothing but "* Live". The badge now has a mobile mount at the trailing edge of .header-actions, collapsed to the coloured dot alone, and .title-group drops out of layout entirely -- a 0-height flex item still collected .title-row's 12px column gap, which was the last of the wasted row. Desktop is unchanged: the labelled badge stays beside the title. Compact mode CLIPS the label rather than removing it. The span is a role="status" live region and live regions announce on text-content change, so an aria-label-only element wouldn't reliably announce a drop to "Offline". Also gives the action bar one control height (IDEA-2297's second half). The row mixed four: the quick-actions trigger ~22px, New ~24px (no border), the view dropdown ~26px, icon buttons 28px. All are 28px now. The trigger is normalised in the page rather than in QuickActionsMenu because ItemDetail's .meta-actions band sizes the same trigger to its own padding-based metrics; a height baked into the shared component would fight it. Same override shape and specificity reasoning that band already documents -- the child's scoped .trigger-btn.svelte- is (0,2,0), so a bare :global(.trigger-btn) would tie and be settled by cross-file source order. Mobile gaps go 12px -> 4px. The six controls total ~255px, so the dot's 12px inset didn't fit on one line at 360px (a common Android width) and wrapped, re-creating the row this change removes. Only the gaps shrink; the controls stay 28px, so touch targets are untouched. Verified in the browser against the installed binary: single row with a 12px inset at 430/390/375/360 (wraps at 340, as before); desktop badge still in .title-group with its label visible and aria-label="Live updates: Live" intact on both breakpoints; no horizontal overflow. npm run check clean, 490 web unit tests pass. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- .../lib/components/SSEStatusIndicator.svelte | 32 ++++++++ .../[workspace]/[collection]/+page.svelte | 82 ++++++++++++++++++- 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/SSEStatusIndicator.svelte b/web/src/lib/components/SSEStatusIndicator.svelte index 32979db1..2b9522c1 100644 --- a/web/src/lib/components/SSEStatusIndicator.svelte +++ b/web/src/lib/components/SSEStatusIndicator.svelte @@ -6,6 +6,14 @@ // already computed for the event stream but never surfaced, so a stale // board gave no hint the stream was down (PLAN-1984 / TASK-2027). // Colour + dot idiom mirrors the collab-state badge on the item page. + // + // `compact` collapses the badge to the coloured dot alone (IDEA-2297). The + // label text is CLIPPED, not removed: this span is a role="status" live + // region, and live regions announce on text-content change — an + // aria-label-only element wouldn't reliably announce a drop to "Offline". + // The title tooltip still carries the full explanation. + + let { compact = false }: { compact?: boolean } = $props(); const status = $derived(sseService.status as SSEStatus); @@ -31,6 +39,7 @@ - + when live updates are down or unauthorized. + Desktop only — at ≤768px the h1 is hidden, so leaving the badge + here held .title-group open as a row containing nothing else + (IDEA-2297). The mobile mount lives in .header-actions below. --> + {#if !viewport.isMobile} + + {/if}
@@ -3210,6 +3215,15 @@ + New {singularName()} {/if} + + + {#if viewport.isMobile} + + + + {/if}
@@ -3691,6 +3705,54 @@ flex-wrap: wrap; } + /* Mobile realtime badge (IDEA-2297). .header-actions is width:100% under + 768px, so the auto margin pushes the dot to the trailing edge — the + "justified across from the toolbar" placement — without disturbing + justify-content for the buttons themselves. */ + .sse-mobile { + display: inline-flex; + align-items: center; + margin-left: auto; + flex-shrink: 0; + /* Without this the bare 8px dot sits flush against the content edge, + while every control beside it insets its glyph by the same --space-3 + its box uses horizontally. Padding (not margin) so the dot's touch + target still reaches the edge. */ + padding-right: var(--space-3); + } + + /* Uniform control height across the action bar (IDEA-2297). Before this the + row mixed four heights: ⚡ trigger ~22px, New ~24px (no border), view + dropdown ~26px, icon buttons 28px. 28px is the tallest, so nothing has to + shrink, and the app-wide `* { box-sizing: border-box }` means the + unbordered New button lands on the same box as its bordered siblings. + .toolbar-icon-btn is already 28px. */ + .view-dd-trigger, + .view-chip, + .new-btn { + height: 28px; + } + /* The ⚡ trigger belongs to QuickActionsMenu, whose own rule sets 2px block + padding — the shortest control in the row. Normalize it HERE rather than + in the component: ItemDetail's meta-actions band sizes the same trigger + to its own metrics, and a height baked into the shared component would + fight that. Same override pattern (and specificity reasoning) ItemDetail + already uses — the child's scoped `.trigger-btn.svelte-` is (0,2,0), + so a bare `:global(.trigger-btn)` would tie and be settled by cross-file + source order; the child combinator takes this to (0,3,0) and wins. + The wrapper needs `display: flex` for the same reason it does there — + QuickActionsMenu sets `inline-block`, whose descender space would make + the wrapper taller than the button it contains. The `div` qualifier is + load-bearing: it breaks the (0,2,0) tie with the child's own rule. */ + .header-actions :global(div.quick-actions-menu) { + display: flex; + } + .header-actions :global(.quick-actions-menu > .trigger-btn) { + height: 28px; + display: inline-flex; + align-items: center; + } + /* Toolbar consolidation (PLAN-2290 Phase 3): View dropdown + icon buttons + ⋯ menu replace the segmented toggle / sort select / labeled buttons row. */ @@ -4011,6 +4073,14 @@ display: none; } + /* With the h1 hidden and the realtime badge moved into .header-actions + (IDEA-2297), .title-group has no rendered children — but a 0-height + flex item still collects .title-row's 12px column gap, which was the + last of the wasted row. Take it out of layout entirely. */ + .title-group { + display: none; + } + .title-row { flex-direction: column; align-items: flex-start; @@ -4020,6 +4090,14 @@ .header-actions { width: 100%; justify-content: flex-start; + /* 4px instead of 12px between controls. The six controls total + ~255px, so at 360px (a very common Android width) the content + column has ~56px left — not enough for the dot plus its inset + plus 12px gaps, and the dot wrapped onto a second line, + re-creating the wasted row IDEA-2297 removed. Six gaps × 8px + saved keeps the bar on one line down to ~340px. Only the gaps + shrink — the controls stay 28px, so touch targets are unchanged. */ + gap: var(--space-1); } .new-btn-label { From 6efd352e84b15083fa73f27d42ab1e374aa2a382 Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 27 Jul 2026 02:00:17 +0000 Subject: [PATCH 2/3] fix(web): don't convey mobile SSE state by colour alone (IDEA-2297) Codex review of #1038: with the label clipped in compact mode, hue was the only thing separating Live from Offline -- colour-alone conveyance (WCAG 1.4.1), and red/green is the exact pair dichromatic vision collapses. Healthy is now a FILLED dot and every unhealthy state is a hollow ring, so the distinction that matters ("is the stream up?") is carried by shape. Reconnecting stays separated from Offline by its pulse, and by hue for anyone running reduced-motion. Scoped to compact mode -- the labelled desktop variant already names the state in words. Verified by forcing each status class onto the live badge and reading computed styles at 390px: connected is a filled green 8px dot (border-width 0), reconnecting/disconnected/unauthorized are transparent with a 2px currentColor ring in their own hue, all 8px. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- web/src/lib/components/SSEStatusIndicator.svelte | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/src/lib/components/SSEStatusIndicator.svelte b/web/src/lib/components/SSEStatusIndicator.svelte index 2b9522c1..22c6fbbe 100644 --- a/web/src/lib/components/SSEStatusIndicator.svelte +++ b/web/src/lib/components/SSEStatusIndicator.svelte @@ -80,6 +80,21 @@ height: 8px; } + /* With the label clipped, hue would be the only thing separating Live from + Offline — colour-alone conveyance (WCAG 1.4.1), and red/green is the + exact pair dichromatic vision collapses. So healthy is a FILLED dot and + every unhealthy state is a hollow ring: the distinction that actually + matters ("is the stream up?") is carried by shape. Reconnecting is then + separated from Offline by its pulse, and by hue for anyone who has + reduced-motion on. Only compact mode needs this — the labelled variant + already says which state it is in words. */ + .sse-state.compact.sse-state-reconnecting .sse-state-dot, + .sse-state.compact.sse-state-disconnected .sse-state-dot, + .sse-state.compact.sse-state-unauthorized .sse-state-dot { + background: transparent; + border: 2px solid currentColor; + } + .sse-state-dot { width: 0.5em; height: 0.5em; From 00ed369dda09a30c60ee6c3de6743187b2f4b83e Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 27 Jul 2026 02:04:51 +0000 Subject: [PATCH 3/3] fix(web): separate reconnecting from offline without motion (IDEA-2297) Codex re-review of #1038: the hollow ring told Live apart from the unhealthy states, but Reconnecting leaned on its pulse to separate itself from Offline -- and the pulse is switched off under prefers-reduced-motion, leaving amber-vs-red as the only difference for those users. Reconnecting now takes a dashed ring. Three states, three shapes -- filled, dashed ring, solid ring -- independent of both hue and motion. Verified at 390px by forcing each status class and reading computed styles under both prefers-reduced-motion settings: connected filled (border-width 0), reconnecting transparent + 2px dashed, disconnected transparent + 2px solid; the pulse animation resolves to none under reduce while the dashed ring persists. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT --- web/src/lib/components/SSEStatusIndicator.svelte | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/lib/components/SSEStatusIndicator.svelte b/web/src/lib/components/SSEStatusIndicator.svelte index 22c6fbbe..a0e093b6 100644 --- a/web/src/lib/components/SSEStatusIndicator.svelte +++ b/web/src/lib/components/SSEStatusIndicator.svelte @@ -94,6 +94,14 @@ background: transparent; border: 2px solid currentColor; } + /* Reconnecting takes a DASHED ring rather than leaning on its pulse to + separate it from Offline: the pulse is switched off under + prefers-reduced-motion, which would leave amber-vs-red as the only + difference between the two. Three states, three shapes — filled, dashed + ring, solid ring — independent of both hue and motion. */ + .sse-state.compact.sse-state-reconnecting .sse-state-dot { + border-style: dashed; + } .sse-state-dot { width: 0.5em;