Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions web/src/lib/components/SSEStatusIndicator.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -31,6 +39,7 @@

<span
class="sse-state sse-state-{status}"
class:compact
title={title}
role="status"
aria-label={`Live updates: ${label}`}
Expand All @@ -48,6 +57,52 @@
color: var(--text-muted);
white-space: nowrap;
}
/* Dot-only mode: clip the label out of the layout but leave it in the
accessibility tree so the live region keeps announcing. */
.sse-state.compact .sse-state-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
.sse-state.compact {
gap: 0;
}
/* The dot is the only visible affordance in compact mode — size it off a
fixed length instead of the inherited 0.8em font so it stays legible. */
.sse-state.compact .sse-state-dot {
width: 8px;
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;
}
/* 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;
height: 0.5em;
Expand Down
82 changes: 80 additions & 2 deletions web/src/routes/[username]/[workspace]/[collection]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2948,8 +2948,13 @@

<!-- Realtime stream status (PLAN-1984 / TASK-2027): surfaces the
already-computed SSE connection state so a stale board hints
when live updates are down or unauthorized. -->
<SSEStatusIndicator />
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}
<SSEStatusIndicator />
{/if}
</div>

<div class="header-actions">
Expand Down Expand Up @@ -3210,6 +3215,15 @@
+ <span class="new-btn-label">New {singularName()}</span>
</button>
{/if}

<!-- Mobile mount of the realtime badge (IDEA-2297): dot-only, pushed
to the far end of the action bar by .sse-mobile's auto margin
so it shares the toolbar row instead of owning one. -->
{#if viewport.isMobile}
<span class="sse-mobile">
<SSEStatusIndicator compact />
</span>
{/if}
</div>
</div>

Expand Down Expand Up @@ -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-<hash>` 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. */
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down