From e371ac5ff998204a2c420b6ee9a9c7a105410e5f Mon Sep 17 00:00:00 2001 From: "codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie)" Date: Sat, 1 Aug 2026 19:14:36 +0000 Subject: [PATCH] Stack mobile list rows Co-Authored-By: codexbot.disnetdev.com (did:plc:hbonvqr5ysrscg5wdyb5klie) --- DESIGN.md | 39 ++++-- packages/ui/src/app.css | 112 ++++++++++++++++-- packages/ui/src/lib/components/Badge.svelte | 8 +- packages/ui/src/lib/components/GoalRow.svelte | 17 ++- packages/ui/src/lib/components/PaneBar.svelte | 84 ++++++++++++- packages/ui/src/lib/components/UnitRow.svelte | 2 +- packages/ui/src/lib/keys.ts | 6 + packages/ui/src/lib/space.test.ts | 11 ++ packages/ui/src/lib/ui.svelte.ts | 6 + packages/ui/src/lib/units.ts | 16 ++- packages/ui/src/routes/space/+page.svelte | 2 +- packages/ui/test/pane-bar.test.mjs | 70 +++++++++++ packages/ui/test/row-stack.test.mjs | 74 ++++++++++++ 13 files changed, 409 insertions(+), 38 deletions(-) create mode 100644 packages/ui/test/pane-bar.test.mjs create mode 100644 packages/ui/test/row-stack.test.mjs diff --git a/DESIGN.md b/DESIGN.md index 0d78381..57b6aaf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -307,11 +307,31 @@ iPad is wider than 860px and behaves the same — every typed control rises to a identifier fields included, so focusing one does not trigger Safari's viewport zoom. The three field sizes are tokens (`--field-size`, `--field-ident-size`, `--field-find-size`) and the floor raises the tokens, not the rules that read them: a value cannot be out-ranked by a longer selector, and nothing -that somebody types into states its size any other way. At **560px** the -sheet sheds what it can afford to lose, in a fixed order: a unit row's summary, the breadcrumb's tail, the -fixture chip, an actor's disc on a goal row, the second badge in a tail, and every badge's long form for -its short one. A System row is the exception — its title is the record's identity, so the summary stays -and the timestamp goes. +that somebody types into states its size any other way. At **560px**, a list row stops being a flex +line and becomes a paragraph: its name wraps as text and its metadata tail floats into that text, so +the tail takes the space the name left over on the line and only drops below when there is none. The +state glyph leaves the flow into a reserved gutter so wrapped lines stay aligned under each other, +and the tail is capped at 58% of the row — past that the leftover is too narrow for a word to fit +and the name would be pushed under its own metadata. Nothing is shed for the width: a name that +would have been ellipsised wraps instead, and actor discs and every badge stay. Admin-row +descriptions may wrap instead of ellipsising. The compact badge form, breadcrumb tail, fixture chip, +account name and keyboard-help button still shed at this width because each has a nearby visual or +interaction equivalent. + +The pane bar sheds the two things in it that can be said another way, and gives each a tap to get +back. **Freshness keeps its dot and loses its sentence**: a phone cannot hover the `title` off it, so +the dot is a second control that answers a tap with what that tooltip would have said and offers the +sync the wide chip does on a click — two buttons, each doing one thing, rather than one that asks how +wide the window is. **Quick find becomes a search icon**, and opening it gives it the bar: the crumb +and the dot step aside for as long as something is typed, because a box being typed into is what the +reader is doing. The box is never `display: none` while it is collapsed — `/` and the icon both open +it by putting focus in it, and a field that is not rendered cannot take focus — so it is clipped, and +its own `onfocus` is what opens it. One way in, and no second state to keep in step. + +A float only shortens the line boxes that follow it, so each row's tail is **written before the name +it floats into** and `order` puts it back on the end of the flex line at wider widths. This is the +one place the DOM is not in reading order, and it is the layout's load-bearing detail rather than a +tidiness question — `test/row-stack.test.mjs` holds both halves together. ## Elevation & Depth @@ -363,13 +383,14 @@ fill *is* the state (a pressed version button, an "on" auto-review switch). ### Badges - **Style:** pill, `600 11.5px`, a glyph then a word, on an asymmetric 6px/8px pad (the glyph side is tighter). - **Variants:** `ok` / `bad` / `warn` / `accent` washes for verdicts, checks and "for you"; `flat` (sunk) for everything factual — a count, "archived", "custom type", a drift count. -- **Narrow form:** below 560px a badge swaps its wide span for a narrow one — the count without the sentence. +- **Narrow form:** below 560px a badge swaps its wide span for a narrow one — the count without the sentence. The goal row's counts (`with an agent`, `open`, `to review`) carry one, because they are what decides how much of a narrow row is left for the goal's own name; `awaiting you` has no count to fall back on and is the one thing on the row a human is meant to act on, so it keeps its words. `completed` goes further and keeps its glyph alone, because a green tick means completion in every list this product draws; the other endings cannot, since `parked`, `dropped` and `superseded` share one glyph and one colour and the word is the only thing telling them apart. The sentence is hidden from the eye and not from the accessible name: it is clipped rather than `display: none`, since the narrow form beside it is `aria-hidden` and a badge that shed both would say nothing at all. ### Rows -- **Unit row:** state circle, type name, an optional version chip, a one-line summary, then a tail of badges, the actor's disc and an age. Rows are separated by a soft hairline drawn by the *next* row, so the first has none and an open row's neighbours drop theirs. -- **That one line, and when it is ink rather than ink 2:** an artifact carries its own short title, and where the line IS that title it is the record's name and reads in **ink**. Everything else in that slot is metadata and stays **ink 2**: a word about what an agent is doing, and — for a record written before artifacts had titles — the sentence lifted out of its body. It ellipsises either way; a title is capped at 200 characters and a row is narrower than that. +- **Unit row:** state circle, type name, an optional version chip, a one-line summary, then a tail of badges, the actor's disc and an age. Below 560px the summary wraps as text and the tail floats into it. Rows are separated by a soft hairline drawn by the *next* row, so the first has none and an open row's neighbours drop theirs. +- **That summary line, and when it is ink rather than ink 2:** an artifact carries its own short title, and where the line IS that title it is the record's name and reads in **ink**. Everything else in that slot is metadata and stays **ink 2**: a word about what an agent is doing, and — for a record written before artifacts had titles — the sentence lifted out of its body. It ellipsises on wider viewports and wraps below 560px; a title is capped at 200 characters, which is the only thing bounding how tall a narrow row may get. - **Open state:** the row keeps its place and the drawer below it animates open (`grid-template-rows: 0fr → 1fr`, 400ms); the whole unit lifts onto `raised` with the drawer shadow. Nothing navigates. -- **Admin row:** the same object five times over — a name, what the space knows about it, and at most one thing you can do to it — used for members, registry entries, projects, checks and switches. +- **Goal row:** a progress or state glyph, the goal or target name, then badges, actor discs and age. Below 560px its name wraps as text and the complete metadata tail floats into it, so a goal with a short name and a date costs one line rather than two. +- **Admin row:** the same object five times over — a name, what the space knows about it, and at most one thing you can do to it — used for members, registry entries, projects, checks and switches. Below 560px its description wraps and a tail that no longer fits drops below as a group. ### Inputs - **Style:** paper fill, hairline border, 8px radius, 13.5px/1.5, 8px/10px padding; a textarea starts at 60px and resizes vertically. The composer's box is the exception, and the reason is under **The composer** below. diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index e9be07f..7981f62 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -265,6 +265,9 @@ a { color: var(--accent); text-underline-offset: 2px; } transition: border-color 140ms ease, background 140ms ease; } .find:focus-within { border-color: var(--accent-mark); background: var(--paper); } +/* Qualified, because `.icon-btn` sets its own `display` further down and would otherwise out-rank a + bare `.find-btn` by order. The narrow rule that brings it back is qualified for the same reason. */ +.icon-btn.find-btn { display: none; } .find input { border: 0; background: none; outline: 0; width: 100%; font-size: var(--field-find-size); } .find input::placeholder { color: var(--ink-2); } .chip-mock { @@ -282,6 +285,21 @@ a { color: var(--accent); text-underline-offset: 2px; } } .chip-sync:hover { background: var(--hover); color: var(--ink); } .chip-sync .dot { width: 6px; height: 6px; border-radius: 999px; background: var(--ok-mark); flex: none; } +/* The dot alone, at the size of the bar's other icon buttons so a thumb has the same target. */ +.sync { position: relative; display: none; } +.chip-sync.mini { width: 28px; height: 28px; padding: 0; justify-content: center; border-radius: 6px; } +.sync-pop { + position: absolute; top: calc(100% + 8px); right: 0; z-index: 30; width: 216px; padding: 12px; + background: var(--paper); border: 1px solid var(--line); border-radius: 11px; + box-shadow: 0 2px 6px oklch(0.25 0.03 262 / 0.10), 0 18px 40px -10px oklch(0.25 0.03 262 / 0.34); + text-align: left; +} +.sync-pop[hidden] { display: none; } +.sync-pop .sync-state { font-size: 13px; font-weight: 600; color: var(--ink); } +.sync-pop .hint { + margin: 3px 0 10px; font-size: 12px; line-height: 1.5; color: var(--ink-2); + overflow-wrap: anywhere; +} .chip-sync.busy .dot { animation: pulse 1.4s ease-in-out infinite; } .chip-sync.stale { color: var(--warn); } .chip-sync.stale .dot { background: var(--warn-mark); } @@ -337,9 +355,13 @@ a { color: var(--accent); text-underline-offset: 2px; } transition: background 110ms ease; } .goal-row:hover { background: var(--hover); } +/* The tail is written before the name so that below 560px it can float into the name's text (the + mobile block); `order` puts it back on the end of the flex line here. */ .goal-row .gtitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .goal-row.done .gtitle { color: var(--ink-2); } -.goal-row .tail { display: flex; align-items: center; gap: 8px; flex: none; } +/* The tail is written before the name so that below 560px it can float into the name's text (the + mobile block); `order` puts it back on the end of the flex line here. */ +.goal-row .tail { order: 1; display: flex; align-items: center; gap: 8px; flex: none; } .pie { color: var(--accent); flex: none; } .goal-row.done .pie { color: var(--ok-mark); } .when { font-size: 11.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; white-space: nowrap; } @@ -389,7 +411,8 @@ a { color: var(--accent); text-underline-offset: 2px; } agent is doing. It is the record's name, so it reads as text rather than as metadata — and it still ellipsises in the same cell, because a valid 200-character title must not widen the row. */ .row .sub.titled { color: var(--ink); } -.row .tail { display: flex; align-items: center; gap: 7px; flex: none; } +/* Written before the summary so that below 560px it can float into it; see `.goal-row .tail`. */ +.row .tail { order: 1; display: flex; align-items: center; gap: 7px; flex: none; } .vchip { font: 600 11px/1 var(--mono); color: var(--ink-2); background: var(--sunk); border-radius: 4px; padding: 3px 5px; @@ -1220,19 +1243,84 @@ select.sl { resize: none; cursor: pointer; padding-right: 8px; } } } @media (max-width: 560px) { - .row .sub { display: none; } + /* A row stops being a flex line and becomes a paragraph: the name wraps as text and the tail + floats into it, so metadata takes whatever the name's last line left over and only drops to a + line of its own when there is none. Nothing is shed for the width — a name that would have been + ellipsised wraps instead — and the common row (a name and a date) still costs one line. + + The state glyph leaves the flow entirely and is positioned into a reserved gutter. Floated it + would only hold the first line, and wrapped text would run back under it. */ + .goal-row { + display: flow-root; position: relative; padding-left: 38px; + } + .goal-row > .pie, .goal-row > .dot { position: absolute; left: 10px; top: 9px; } + .goal-row .gtitle { + display: inline; float: none; white-space: normal; + overflow: visible; text-overflow: clip; + } + /* A float wider than the text beside it leaves a gap no word fits into, and the name gets pushed + under its own metadata. The cap keeps a readable measure on the name's first line; the tail + wraps within it instead of taking the line. */ + .goal-row .tail { + float: right; margin: 1px 0 0 10px; max-width: 58%; + flex-wrap: wrap; justify-content: flex-end; + } + .row { + display: flow-root; padding-left: 41px; + } + .row > .dot { position: absolute; left: 12px; top: 10px; } + /* The flex gap goes with the flex line; inline children state their own separation. */ + .row .name, .row .vchip { margin-right: 6px; } + .row .sub { + display: inline; float: none; white-space: normal; + overflow: visible; text-overflow: clip; + } + .row .tail { + float: right; margin: 1px 0 0 10px; max-width: 58%; + flex-wrap: wrap; justify-content: flex-end; + } .crumb .full { display: none; } .chip-mock { display: none; } - /* On a system row the title IS the record's identity, so it outranks the - timestamp and the author disc when space runs out. */ - .row.sysrow .sub { display: block; } - .row.sysrow .when, .row.sysrow .tail > .disc { display: none; } - .row.sysrow .tail .badge ~ .badge { display: none; } - /* The goal's name outranks its metadata when the row gets this narrow. */ - .goal-row .who { display: none; } - .goal-row .tail .badge ~ .badge { display: none; } - .badge .wide { display: none; } + /* Freshness keeps the dot and sheds the sentence; the tap that a hover cannot happen on opens the + rest. `.full` means the same here as it does on the crumb — the long form, shed when narrow. */ + .chip-sync.full { display: none; } + .sync { display: flex; } + /* Quick find is a search icon until it is asked for, and then it takes the bar — the crumb and the + freshness dot are what a box being typed into can afford to push aside, and both are a tap from + coming back. The box is never `display: none`: `/` and the icon beside it both open it by + putting focus in it, and a field that is not rendered cannot take focus. */ + .icon-btn.find-btn { display: grid; } + .find { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip-path: inset(50%); + } + .find.open { + position: static; width: auto; height: auto; margin: 0; padding: 4px 9px; + overflow: visible; clip-path: none; flex: 1; + } + .pane-bar.searching .crumb, + .pane-bar.searching .chip-mock, + .pane-bar.searching .bar-spacer, + .pane-bar.searching .sync, + .pane-bar.searching .icon-btn.find-btn { display: none; } + /* The sentence is hidden from the eye, not from the accessible name: `display: none` would take it + out of the tree too, and the narrow form beside it is aria-hidden — a badge that shed both would + say nothing at all to a screen reader, which is the one place a row cannot afford to be terse. */ + .badge .wide { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; + overflow: hidden; clip-path: inset(50%); white-space: nowrap; + } .badge .narrow { display: inline; } + /* A bare badge has nothing beside its glyph, so the padding that was making room for a word on the + right goes symmetrical and the pill closes up around it. */ + .badge.bare { padding-inline: 6px; } + /* Admin metadata may wrap rather than squeezing a handle or description into an ellipsis. The + basis leaves useful text on line one and lets a larger action tail fall below as a group. */ + .arow { flex-wrap: wrap; align-items: flex-start; } + .arow .meta { + flex-basis: 18ch; white-space: normal; text-overflow: clip; overflow-wrap: anywhere; + } + .arow .tail { margin-left: auto; flex-wrap: wrap; justify-content: flex-end; } .page-meta { gap: 3px 10px; font-size: 12.5px; } /* Signed in, the disc already says who you are, and the bar has quick find to fit. Signed out there is no disc, so the word stays — it is the only thing that would say what the button does. */ diff --git a/packages/ui/src/lib/components/Badge.svelte b/packages/ui/src/lib/components/Badge.svelte index 7466540..43837a0 100644 --- a/packages/ui/src/lib/components/Badge.svelte +++ b/packages/ui/src/lib/components/Badge.svelte @@ -6,11 +6,13 @@ const { badge }: { badge: RowBadge } = $props() - + - {#if badge.narrow} + {#if badge.narrow !== undefined} {badge.text} - + {#if badge.narrow} + + {/if} {:else} {badge.text} {/if} diff --git a/packages/ui/src/lib/components/GoalRow.svelte b/packages/ui/src/lib/components/GoalRow.svelte index c51e928..101bdb0 100644 --- a/packages/ui/src/lib/components/GoalRow.svelte +++ b/packages/ui/src/lib/components/GoalRow.svelte @@ -57,18 +57,26 @@
  • - {goal.target.value.title} + {#if awaiting > 0} {:else if moving > 0} - + {/if} {#if open > 0} - + {/if} {#if toJudge > 0} - + {/if} {#if ending} @@ -80,5 +88,6 @@ {shortDate(goal.target.value.createdAt)} + {goal.target.value.title}
  • diff --git a/packages/ui/src/lib/components/PaneBar.svelte b/packages/ui/src/lib/components/PaneBar.svelte index 6b56930..b828bce 100644 --- a/packages/ui/src/lib/components/PaneBar.svelte +++ b/packages/ui/src/lib/components/PaneBar.svelte @@ -14,6 +14,12 @@ let find = $state(null) + // Below 560px the box is a search icon until it is asked for, and then it takes the bar. Local, + // because unlike the popovers nothing outside this component opens it — `/` and a tap both arrive + // as focus on the input, and the input's own `onfocus` is what opens it. One way in, so there is + // no state to keep in step. + let findOpen = $state(false) + // How fresh the screen is. A live space polls every member's repo on an interval, so the honest // thing to show is when that last succeeded — and, when it did not, that the view is stale rather // than wrong. Clicking it syncs now. @@ -26,11 +32,29 @@ }) // `/` focuses this box and Escape leaves it, but neither is handled here: the whole map is one table - // in `keys.ts`, and this hands it the box it is talking about. + // in `keys.ts`, and this hands it the box it is talking about. It stays the input itself and never a + // wrapper — Escape clears the query by comparing what it blurred against what was registered. $effect(() => registerQuickFind(find)) + + // The expanded box closes when the query goes away and the caret is somewhere else: Escape clears + // then blurs, and the layout clears on the way to another view. Both land here as a query that is + // empty with the box unfocused, which is the only state an open box has nothing left to show. + $effect(() => { + if (ui.query === '' && document.activeElement !== find) findOpen = false + }) -
    + + { + const target = event.target as HTMLElement | null + if (ui.syncOpen && !target?.closest('.sync')) ui.syncOpen = false + }} +/> + + +
    + + +
    + + + +
    {/if} -
    + + + +
    @@ -93,6 +165,10 @@ aria-label="Quick find" aria-keyshortcuts="/" title="Press / to search this view" + onfocus={() => (findOpen = true)} + onblur={() => { + if (ui.query === '') findOpen = false + }} />
    diff --git a/packages/ui/src/lib/components/UnitRow.svelte b/packages/ui/src/lib/components/UnitRow.svelte index 6d829b8..25b9ff0 100644 --- a/packages/ui/src/lib/components/UnitRow.svelte +++ b/packages/ui/src/lib/components/UnitRow.svelte @@ -83,7 +83,6 @@ v{unit.current.version} {/if} {/if} - {summary} {#if drift > 0} @@ -100,6 +99,7 @@ {/if} {unitAge(unit, space.asOf)} + {summary}
    diff --git a/packages/ui/src/lib/keys.ts b/packages/ui/src/lib/keys.ts index 4800840..da2f9e8 100644 --- a/packages/ui/src/lib/keys.ts +++ b/packages/ui/src/lib/keys.ts @@ -166,6 +166,12 @@ function dismiss(): boolean { ui.accountOpen = false return true } + // Beside the account popover in the same bar, and the same size of thing: a surface the reader + // opened for one fact, which Escape should take away before it starts closing the page's furniture. + if (ui.syncOpen) { + ui.syncOpen = false + return true + } if (ui.railOpen) { ui.railOpen = false return true diff --git a/packages/ui/src/lib/space.test.ts b/packages/ui/src/lib/space.test.ts index fad950a..45bec44 100644 --- a/packages/ui/src/lib/space.test.ts +++ b/packages/ui/src/lib/space.test.ts @@ -308,6 +308,7 @@ describe('how a goal’s ending reads', () => { kind: 'ok', glyph: '✓', text: 'completed', + narrow: '', title: expect.any(String), }) for (const [uri, word] of [ @@ -319,6 +320,16 @@ describe('how a goal’s ending reads', () => { } }) + // The green tick means completion in every list this product draws, so it can carry that badge on + // its own where space is short. The rest share one glyph and one colour: shed the word and + // `parked`, `dropped` and `superseded` become the same badge. + it('lets only `completed` fall back to its glyph alone', () => { + expect(badgeFor(fixture.goals.autoReview)?.narrow).toBe('') + for (const uri of [fixture.goals.budgets, fixture.goals.diffViewer, fixture.goals.permissioned]) { + expect(badgeFor(uri)?.narrow).toBeUndefined() + } + }) + it('says a goal ended even when it says nothing about how, and nothing when it has not', () => { const ended = space.index.goals.find((goal) => goal.ended) if (!ended) throw new Error('fixture goal missing') diff --git a/packages/ui/src/lib/ui.svelte.ts b/packages/ui/src/lib/ui.svelte.ts index 6f1139d..b25bd79 100644 --- a/packages/ui/src/lib/ui.svelte.ts +++ b/packages/ui/src/lib/ui.svelte.ts @@ -8,6 +8,12 @@ export const ui = $state({ toast: '', /** The account popover. Shared rather than local because a composer offers to open it. */ accountOpen: false, + /** + * The freshness popover, which is the narrow viewport's answer to a `title` tooltip: a phone has + * no hover to show one with. Shared for the same reason as the account popover — Escape unwinds + * one layer per press and that stack lives in `keys.ts`. + */ + syncOpen: false, /** The ⊕ menu. Shared because ⌘N opens it from `keys.ts` and Escape closes it from there too. */ menuOpen: false, /** The keyboard map, drawn over everything. `?` opens it and the pane bar has a button for it. */ diff --git a/packages/ui/src/lib/units.ts b/packages/ui/src/lib/units.ts index bc5f256..db9ecc2 100644 --- a/packages/ui/src/lib/units.ts +++ b/packages/ui/src/lib/units.ts @@ -31,7 +31,12 @@ export interface RowBadge { kind: BadgeKind glyph: string text: string - /** What the badge collapses to on a narrow viewport, when it must not disappear entirely. */ + /** + * What the badge collapses to on a narrow viewport, when it must not disappear entirely. The + * empty string asks for the glyph alone, and is only for a badge whose glyph and colour already + * say the whole thing — never for one whose word is what tells it apart from its neighbours. + * Either way the full text stays in the accessible name; this is what the eye gets. + */ narrow?: string title?: string } @@ -543,8 +548,11 @@ const verdictBadge = (verdict: 'approve' | 'request_changes'): RowBadge => export function endingBadge(goal: GoalView): RowBadge | undefined { if (!goal.ended) return undefined const title = 'agents skip an ended goal; nothing was deleted, and it can be reopened' + // A green tick is completion in every list this product draws, so below 560px it can carry the + // badge alone. The other dispositions cannot: `parked`, `dropped` and `superseded` share one + // glyph and one colour, and the word is the only thing telling them apart. return goal.disposition === 'completed' - ? { kind: 'ok', glyph: '✓', text: 'completed', title } + ? { kind: 'ok', glyph: '✓', text: 'completed', narrow: '', title } : { kind: 'flat', glyph: '⌁', text: goal.disposition ?? 'ended', title } } @@ -564,7 +572,7 @@ export function unitBadges(unit: UnitView, options: { now: string } = { now: '' if (request === 'assigned') badges.push({ kind: 'accent', glyph: '◍', text: 'assigned', title: 'named to an agent, and not claimed yet' }) if (request === 'claimed') - badges.push({ kind: 'accent', glyph: '◌', text: 'claimed', title: claimExpiry(unit, options.now) ?? 'an agent holds a live lease' }) + badges.push({ kind: 'accent', glyph: '◐', text: 'claimed', title: claimExpiry(unit, options.now) ?? 'an agent holds a live lease' }) if (request === 'awaiting') badges.push({ kind: 'warn', glyph: '◉', text: 'awaiting you' }) if (unit.retracted) badges.push({ kind: 'flat', glyph: '⊘', text: 'retracted' }) @@ -599,7 +607,7 @@ export function askBadges(ask: AskContext, options: { now: string } = { now: '' if (ask.state === 'assigned') badges.push({ kind: 'accent', glyph: '◍', text: 'assigned', title: 'named to an agent, and not claimed yet' }) if (ask.state === 'claimed') - badges.push({ kind: 'accent', glyph: '◌', text: 'claimed', title: leaseExpiry(ask.claim, options.now) ?? 'an agent holds a live lease' }) + badges.push({ kind: 'accent', glyph: '◐', text: 'claimed', title: leaseExpiry(ask.claim, options.now) ?? 'an agent holds a live lease' }) if (ask.state === 'awaiting') badges.push({ kind: 'warn', glyph: '◉', text: 'awaiting you' }) if (ask.pinned) badges.push({ kind: 'flat', glyph: '◇', text: pinnedLabel(ask.pinned.unit, ask.pinned.version) }) diff --git a/packages/ui/src/routes/space/+page.svelte b/packages/ui/src/routes/space/+page.svelte index ef70343..2e7efed 100644 --- a/packages/ui/src/routes/space/+page.svelte +++ b/packages/ui/src/routes/space/+page.svelte @@ -180,7 +180,7 @@ {#if row.active && !row.arrived && row.actor.kind === 'human'} - readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8') +const css = read('app.css') +const mobile = /@media \(max-width: 560px\) \{([\s\S]*?)\n\}/.exec(css)?.[1] +const bar = read('lib/components/PaneBar.svelte') + +describe('the pane bar below 560px', () => { + it('collapses quick find without taking it out of the document', () => { + assert.ok(mobile, 'the 560px block is gone') + // `display: none` would be the obvious way to hide it and would break every way in: `/` and the + // icon both open this box by putting focus in it, and an unrendered field cannot take focus. + assert.doesNotMatch(mobile, /\.find \{[^}]*display:\s*none/) + assert.match(mobile, /\.find \{[^}]*position: absolute;[^}]*clip-path: inset\(50%\);/) + assert.match(mobile, /\.find\.open \{[^}]*position: static;[^}]*flex: 1;/) + }) + + // `.icon-btn` states its own `display` further down the sheet, so a bare `.find-btn` loses to it by + // order and the icon comes back on every viewport. Both rules stay qualified or neither works. + it('qualifies the icon’s display rules past `.icon-btn`', () => { + assert.match(css, /\.icon-btn\.find-btn \{ display: none; \}/) + assert.match(mobile, /\.icon-btn\.find-btn \{ display: grid; \}/) + }) + + it('gives the open box the bar, and the crumb and the dot back when it closes', () => { + assert.ok(mobile, 'the 560px block is gone') + const hidden = /\.pane-bar\.searching ([\s\S]*?)\{ display: none; \}/.exec(mobile)?.[0] ?? '' + for (const part of ['.crumb', '.chip-mock', '.bar-spacer', '.sync']) { + assert.ok(hidden.includes(part), `${part} keeps its place while the box is open`) + } + assert.match(bar, /class:searching=\{findOpen\}/) + }) + + // One way in: a tap on the icon and `/` both land as focus on the input, and the input's own + // handler is what opens the box. Anything that opened it another way would be a second state to + // keep in step with this one. + it('opens on focus and closes on a blur with nothing typed', () => { + assert.match(bar, /onfocus=\{\(\) => \(findOpen = true\)\}/) + assert.match(bar, /onblur=\{\(\) => \{\s*if \(ui\.query === ''\) findOpen = false/) + assert.match(bar, /onclick=\{\(\) => find\?\.focus\(\)\}/) + // Escape clears the query by comparing what it blurred against what was registered, so what is + // registered stays the input itself and never a wrapper around it. + assert.match(bar, /registerQuickFind\(find\)/) + }) + + it('sheds the freshness sentence and keeps its dot', () => { + assert.ok(mobile, 'the 560px block is gone') + assert.match(mobile, /\.chip-sync\.full \{ display: none; \}/) + assert.match(mobile, /\.sync \{ display: flex; \}/) + assert.match(bar, /class="chip-sync full"/) + }) + + // A phone cannot hover a `title` off the chip, so the dot answers a tap with what the tooltip would + // have said — and keeps the sync the wide chip does on a click. + it('gives the dot the popover a tooltip cannot be on touch', () => { + assert.match(bar, /class="chip-sync mini"[\s\S]*?aria-haspopup="dialog"/) + assert.match(bar, /onclick=\{\(\) => \(ui\.syncOpen = !ui\.syncOpen\)\}/) + assert.match(bar, /