From 3b347576b784248b008453121cdab8ae60b78277 Mon Sep 17 00:00:00 2001 From: "claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)" Date: Fri, 7 Aug 2026 21:39:56 +0000 Subject: [PATCH] Quieten the label sections and anchor the add-a-label field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things a reader said about the label surfaces, and none of them is a new capability. The sentence explaining that a goal stands under every label it carries is gone. It was arithmetic about the page rather than anything about the work, and each section is counted where it stands, so a heading is already true of what is under it. `overlapping()` goes with it, since the surface was its only caller. A section heading is plain text now, not the label's own chip. The pressable pills all live in the bar a few pixels above; the same word painted twice in two weights read as two controls for one thing, and a page of quiet section headings does not want the loudest one to be the one a section is filed under. Both kinds of heading — a label and "No labels" — are the one shape. "Group by label" moves up between the all/open/ended segment and the chips, so every control a reader operates stands on the bar's first line in the order they narrow in: which goals, how they are arranged, which labels. The second line keeps its reserved height because that reservation is what stops a chip press moving the list, but it is now blank until something is narrowed, so the bar sheds most of its bottom padding — the held-open row is that padding. The add-a-label field opens in a panel anchored under the chip that opens it rather than in the goal's meta row. A field and a button arriving in that row pushed every fact after them along the line and, on a full one, wrapped it and moved the description underneath — which is the one thing a control a reader is pointing at must not do. Out of flow, nothing on the row moves at all. The field is the app's ordinary field again now that it no longer stands in a row of chips, and that is what fixes the datalist arrow: it sat off centre because a field shrunk to chip size was given an arrow shrunk to match, and the browser centres its own indicator when it is left at its own size. Dismissal follows the board's status filter — pointerdown outside closes and keeps the draft, Escape cancels, clears and hands focus back to the chip. Covered by a mounted assertion that opening the field leaves the meta row's items exactly as they were, one for the draft surviving a click away but not an Escape, one that a section heading holds no button and no chip and that its count is the rows beneath it, and `test/label-surfaces.test.mjs` for the two promises only the stylesheet and the markup can keep: the panel is out of flow and the bar's second line is reserved. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- DESIGN.md | 7 +- packages/ui/README.md | 4 +- packages/ui/src/app.css | 84 +++++++++------ .../ui/src/lib/components/FilterBar.svelte | 48 +++++---- .../ui/src/lib/components/GoalGroups.svelte | 38 ++----- .../lib/components/GoalGroups.svelte.test.ts | 17 ++- .../ui/src/lib/components/LabelEditor.svelte | 101 ++++++++++++------ .../lib/components/LabelEditor.svelte.test.ts | 40 +++++++ packages/ui/src/lib/grouping.test.ts | 12 +-- packages/ui/src/lib/grouping.ts | 13 +-- packages/ui/test/label-surfaces.test.mjs | 42 ++++++++ 11 files changed, 261 insertions(+), 145 deletions(-) create mode 100644 packages/ui/test/label-surfaces.test.mjs diff --git a/DESIGN.md b/DESIGN.md index 3fdd288..289c072 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -357,9 +357,10 @@ open, floating or modal — and it got there because the reader opened it. One soft-rectangle language, sized to the object: 4–5px for an inline chip or a nav item, 7px for a button or an input, 8px for a row and a textarea, 10px for an open unit, 11px for a popover, 13px for the window -and the picker card. Pills (999px) are reserved for badges, the freshness chip, the ⊕ and a label — a -label chip, the dashed one that adds a label, and the field that types one, which are all the same object -at different moments and would read as three if only some of them were pills. +and the picker card. Pills (999px) are reserved for badges, the freshness chip, the ⊕ and a label — a label +chip and the dashed one that adds a label, which are the same object at two moments and would read as two +different things if only one of them were a pill. The field that types a label is not among them: it stands +in a panel of its own rather than in the row, so it is the ordinary 7px field every other panel holds. Two shapes carry meaning and are never decorative. **The state circle** is an 18px SVG whose form *is* the state: a hollow ring (open), an accent ring with a wash centre (assigned), the same ring dashed and slowly diff --git a/packages/ui/README.md b/packages/ui/README.md index efc81a0..aa48122 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -39,7 +39,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/requests.ts`, `verdicts.ts`, `admin.ts` | What each surface may offer and what it writes, as pure functions: the ⊕ menu, the review form and its findings, space administration. Tested without rendering anything. | | `src/lib/labels.ts` | The reading side of goal labels: the argv a label editor writes (always `--set`, always the whole set — the record has no add or remove), the space's label vocabulary with counts (which IS the registry: there is none on-protocol), and a chip's hue as a pure function of its text. The normalization *rule* is `@radial/core`'s, shared with the sidecar so a label typed here and one typed at a shell cannot differ. | | `src/lib/filters.ts`, `filters.svelte.ts` | Narrowing a goal list by label and state, and the URL round-trip that makes a narrowed list a link. Pure derivation — nothing here writes, and nothing reads prose: the predicate reads `GoalView.labels` and `GoalView.ended` and nothing else. Composed *with* quick find rather than replacing it, and the labels' own text joins the corpus `matches()` searches. | -| `src/lib/grouping.ts` | Arranging that same list once the filter has decided what is in it: one section per label, in the vocabulary's own order, with the unlabelled goals last. A goal stands under *every* label it carries — a set has no primary member for this module to invent one from — so the sections hold more rows than the list and `GoalGroups.svelte` says so. Grouping is not narrowing: it stays per-tab and out of the URL, because a `group` parameter would be one more thing `viewHref` has to reproduce exactly for the rail's active-view highlight to keep matching. | +| `src/lib/grouping.ts` | Arranging that same list once the filter has decided what is in it: one section per label, in the vocabulary's own order, with the unlabelled goals last. A goal stands under *every* label it carries — a set has no primary member for this module to invent one from — so the sections can hold more rows than the list, and each is counted where it stands. Grouping is not narrowing: it stays per-tab and out of the URL, because a `group` parameter would be one more thing `viewHref` has to reproduce exactly for the rail's active-view highlight to keep matching. | | `src/lib/views.ts` | A saved view: that same filter under a name, kept as a personal on-protocol record. `viewFilter()` is the single bridge back to `goalMatchesFilter`, so a view and the filter bar can never disagree about what it holds; the rest is `myViews`, its count, and the argv that saves (or re-saves, which is the edit) and tombstones one. | | `src/routes/goals/` | Every goal in the space in one list, narrowed by the filter bar — where a space-wide saved view lands, since a view cut by label alone spans projects and no existing list is "the goals matching this". | | `src/lib/guests.ts` | Comments from people who are not members: the Constellation backlink query, the re-validation that makes the index a hint rather than an authority, and the rows the Community section draws. The only module that reads a non-member's repo, and nothing it returns enters the fold. | @@ -59,7 +59,7 @@ if a `node:*` import creeps back onto that path. | `src/lib/meta.ts` | What a URL says about itself: the tab title, and the `og:*` a link preview draws. Pure text, shared by the browser and the edge worker so the two cannot disagree. | | `src/edge/` | The Cloudflare Pages worker that upgrades those tags per record for `/g/*` and `/p/*` — see *Link previews* below. Beside the browser path, never on it. | | `src/lib/components/` | Rail, pane bar, space picker, state circle, pie, badges, discs, unit rows and drawers, compose cards, smart lists. | -| `src/lib/components/FilterBar.svelte`, `GoalGroups.svelte`, `LabelEditor.svelte` | The label surfaces. The bar narrows on two lines — the controls on the first, and everything that *appears* in response to them on a second whose height is reserved, so pressing a chip never moves the list a reader is looking at. `GoalGroups` draws what survived, flat or in sections, with each section headed by the very chip that narrows to it. `LabelEditor` stands on a goal's meta row and writes the whole label set on every gesture, because the record has no add and no remove. | +| `src/lib/components/FilterBar.svelte`, `GoalGroups.svelte`, `LabelEditor.svelte` | The label surfaces. The bar narrows on two lines — every control a reader operates on the first (which goals, how they are arranged, which labels), and everything that *appears* in response to them on a second whose height is reserved, so pressing a chip never moves the list a reader is looking at. `GoalGroups` draws what survived, flat or in sections headed by plain text; the pressable pills all live in the bar. `LabelEditor` stands on a goal's meta row, opens its field in a panel anchored under the chip that opens it rather than in the row itself (for the same reason the bar's second line is reserved), and writes the whole label set on every gesture, because the record has no add and no remove. | | `src/lib/components/MarkdownEditor.svelte` | The one editor every markdown field is. Bindable string in, string out; degrades to a textarea; optionally offers image upload. | | `src/lib/components/PrivateDisclosure.svelte`, `JoinPrivate.svelte`, `PrivateDevices.svelte` | The private-mode surfaces: the disclosure, the picker's ticket card (whose Join button does nothing until the disclosure is acknowledged), and the space page's section — what this replica holds, your devices, and everybody else's as the directory has them. Tickets are not here: one is how a person gets in, so it is minted on that person's row in Members. | | `src/lib/components/TicketPanel.svelte` | The one surface that displays a ticket (ADR §23) — fingerprint beside it, copy button, and the line asking for the fingerprint to be read back. Used by the invite and by a member row's Ticket button. | diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 453749e..2561b5e 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -558,19 +558,28 @@ button.lchip[aria-pressed='true']::after { visibility: visible; } the row's gap — so they read as belonging to the name rather than to the list. */ .row-labels { padding: 0 0 8px 32px; margin-top: -4px; } -/* Adding a label, standing in the row of chips it adds to (`LabelEditor.svelte`). Chip-shaped rather - than field-shaped: this control lives on a line of pills in a goal's meta row, and the app's - ordinary 8px/10px field beside them read as a second, larger kind of thing. What it does not do is - state a text size — that still comes from `--field-size`, which is what the touch floor raises. */ -.labeladd { display: inline-flex; align-items: center; gap: 5px; } -input.ti.chipfield { padding: 3px 9px; border-radius: 999px; } -/* Chrome draws a datalist's arrow at its own size inside the field, which on a control this small is - a lozenge sitting off the baseline. Sized and centred rather than removed: it is the only thing - saying the space's own vocabulary is behind the field. */ -input.ti.chipfield::-webkit-calendar-picker-indicator { - width: 9px; height: 9px; margin: 0 -3px 0 5px; padding: 0; opacity: 0.45; cursor: pointer; -} -input.ti.chipfield::-webkit-calendar-picker-indicator:hover { opacity: 0.85; } +/* Adding a label (`LabelEditor.svelte`). The affordance stands in the row of chips it adds to; the + field it opens does NOT — it is a small panel anchored under that chip, out of flow, because a + field and a button appearing in a goal's meta row pushed every fact after them along the line and, + on a full one, wrapped it and moved the description underneath. A control a reader is pointing at + may not move what they were reaching for. */ +.labeladd { position: relative; display: inline-flex; } +.labeladd-pop { + position: absolute; z-index: 30; top: calc(100% + 6px); left: 0; + display: flex; align-items: center; gap: 7px; padding: 8px; + border: 1px solid var(--line); border-radius: 11px; + background: var(--raised); box-shadow: 0 12px 30px color-mix(in oklch, var(--ink) 14%, transparent); +} +.labeladd-pop[hidden] { display: none; } +.labeladd-pop input.ti { width: 13em; } +/* Chrome draws the datalist's own arrow inside the field, and it is left at the browser's size: the + arrow sat off centre because a field shrunk to chip size was given one shrunk to match. Here it is + only centred and toned down — it is the one thing saying the space's vocabulary is behind the + field, so it is not removed either. */ +.labeladd-pop input.ti::-webkit-calendar-picker-indicator { + align-self: center; opacity: 0.5; cursor: pointer; +} +.labeladd-pop input.ti::-webkit-calendar-picker-indicator:hover { opacity: 0.85; } /* The chip that adds a chip: the same pill as the labels beside it, dashed because it is an affordance rather than something this goal carries. */ button.lchip-add { @@ -580,7 +589,10 @@ button.lchip-add { color: var(--ink-2); background: transparent; border: 1px dashed var(--stroke); transition: color 130ms ease, border-color 130ms ease, background 130ms ease; } -button.lchip-add:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-wash); } +button.lchip-add:hover, +button.lchip-add[aria-expanded='true'] { + color: var(--accent); border-color: var(--accent); background: var(--accent-wash); +} /* ─── filter bar ───────────────────────────────────────────────────────────── Above a goal list, and only ever narrowing what is already on screen — nothing here writes, and @@ -589,14 +601,17 @@ button.lchip-add:hover { color: var(--accent); border-color: var(--accent); back No rule under it. The list below opens with a `.sec-head`, which is a hairline with a word on it, and a second hairline four pixels above that one drew a box around the controls rather than separating them from anything. */ -.filters { display: flex; flex-direction: column; gap: 7px; padding: 9px 2px 10px; } +/* Little padding under it, because the reserved line below usually IS the padding: a row held open to + its full height with nothing on it, and then a step of padding under that, put a band of empty space + between the chips and the list they narrow. */ +.filters { display: flex; flex-direction: column; gap: 7px; padding: 9px 2px 2px; } .filters .frow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } -/* The second line is drawn whether or not anything is on its right-hand end: "Clear" and "Save as - view…" arrive the instant a chip is pressed, and a bar that grew a line under the reader's pointer - would move the list they were reaching for. It is not blank while it waits — the arrangement - control stands on it — and its reserved height is the naming field's, the tallest thing that ever - stands here, taken from the type token so the touch floor raises the reservation with the text. */ -.filters .frow.fend { min-height: calc(var(--field-size) * 1.5 + 12px); } +/* The second line is drawn whether or not anything stands on it: "Clear" and "Save as view…" arrive + the instant a chip is pressed, and a bar that grew a line under the reader's pointer would move the + list they were reaching for. It is blank until then, which is what the reservation costs, and its + height is the naming field's — the tallest thing that ever stands here, taken from the type token + so the touch floor raises the reservation along with the text. */ +.filters .frow.fend { min-height: calc(var(--field-size) * 1.5 + 12px); justify-content: flex-end; } .filters .fend .ti { padding: 5px 9px; } .filters .seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; } .filters .seg button { @@ -609,23 +624,22 @@ button.lchip-add:hover { color: var(--accent); border-color: var(--accent); back list. The control stays where it is and stops being pressable, rather than coming and going with the space's first label. */ .filters .seg button:disabled { opacity: 0.45; cursor: default; } -.filters .grow { flex: 1 1 auto; min-width: 0; } -/* A remark the bar or the list under it owes the reader — why there is nothing to filter by, why the - sections sum to more than the list. Not scoped to `.filters`: it is one voice in one size, and the - sentence about grouping is the same kind of aside as the one about an empty vocabulary. */ +/* A remark the bar owes the reader — why there is nothing here to filter by. Not scoped to + `.filters`: it is one voice in one size wherever a control has to explain its own absence. */ .fnote { margin: 0; color: var(--ink-2); font: 500 11.5px/1.4 var(--sans); } -.fnote.grp-note { margin: 8px 0 0; } /* A label's own section inside a goal list (`GoalGroups.svelte`), lighter than `.sec-head` on - purpose: the heading above already said what the list is, and this says which part of it. The - heading IS the chip, so the section a reader is looking at and the chip that narrows to it are one - object rather than two spellings of a label. */ -.grp-head { display: flex; align-items: center; gap: 8px; margin: 18px 0 5px; } -/* The first section opens right under whatever introduced the list — a `.sec-head`, or the note about - goals standing in more than one section. Only the gaps BETWEEN sections are the full step. */ -.sec-head + .grp-head, .fnote + .grp-head { margin-top: 9px; } -.grp-head .none { font: 600 11px/1 var(--sans); color: var(--ink-2); padding: 3px 1px; } -.grp-head .none .n { margin-left: 5px; font-weight: 500; opacity: 0.7; font-variant-numeric: tabular-nums; } + purpose: the heading above already said what the list is, and this says which part of it. Plain + text and not the label's own chip — the pressable pills are all in the bar a few pixels up, and + the same word painted twice in two weights read as two controls for one thing. */ +.grp-head { + display: flex; align-items: baseline; gap: 7px; margin: 18px 0 5px; padding: 0 1px; + font: 700 11.5px/1.3 var(--sans); letter-spacing: 0.01em; color: var(--ink-2); +} +.grp-head .n { font-weight: 500; opacity: 0.7; font-variant-numeric: tabular-nums; } +/* The first section opens right under whatever introduced the list — a `.sec-head`. Only the gaps + BETWEEN sections are the full step. */ +.sec-head + .grp-head { margin-top: 9px; } /* ─── actor discs ────────────────────────────────────────────────────────── */ .who { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } diff --git a/packages/ui/src/lib/components/FilterBar.svelte b/packages/ui/src/lib/components/FilterBar.svelte index 222be40..c820978 100644 --- a/packages/ui/src/lib/components/FilterBar.svelte +++ b/packages/ui/src/lib/components/FilterBar.svelte @@ -120,10 +120,11 @@ } - +
@@ -138,6 +139,24 @@ {/each}
+ +
+ +
+ {#if chips.length > 0} {#each chips as entry (entry.label)} @@ -154,26 +173,9 @@ {/if}
+
- -
- -
- - - {#if narrowing(filter.current)} {#if live && member} diff --git a/packages/ui/src/lib/components/GoalGroups.svelte b/packages/ui/src/lib/components/GoalGroups.svelte index 4e8ae96..91fa824 100644 --- a/packages/ui/src/lib/components/GoalGroups.svelte +++ b/packages/ui/src/lib/components/GoalGroups.svelte @@ -1,18 +1,15 @@ {#if grouped} - {#if repeats} -

- A goal stands under every label it carries, so these sections hold more rows than the list does. -

- {/if} {#each groups as group (group.key)} - {@const label = group.label} +
- {#if label === undefined} - No labels{group.goals.length} - {:else} - - toggleLabel(label)} - /> - {/if} + {group.label ?? 'No labels'} + {group.goals.length}
    {#each group.goals as goal (goal.target.uri)} diff --git a/packages/ui/src/lib/components/GoalGroups.svelte.test.ts b/packages/ui/src/lib/components/GoalGroups.svelte.test.ts index b70f1f4..e482b16 100644 --- a/packages/ui/src/lib/components/GoalGroups.svelte.test.ts +++ b/packages/ui/src/lib/components/GoalGroups.svelte.test.ts @@ -70,7 +70,7 @@ describe('a goal list arranged by label', () => { ]) }) - it('stands a goal under every label it carries, and says so', () => { + it('stands a goal under every label it carries', () => { setGrouping('label') render() const by = new Map(sections().map((section) => [section.label, section.goals])) @@ -81,7 +81,20 @@ describe('a goal list arranged by label', () => { for (const label of multi.labels) { expect(by.get(label)).toContain(multi.target.value.title) } - expect(host.querySelector('.fnote')?.textContent).toContain('more rows than the list does') + }) + + it('heads a section with plain text, never with the chip that narrows to it', () => { + setGrouping('label') + render() + // The bar above holds the pressable pills. A heading here is a heading: pressing one is not a + // gesture this list offers, and a section that looked like a chip claimed it was. + expect(host.querySelectorAll('.grp-head button')).toHaveLength(0) + expect(host.querySelectorAll('.grp-head .lchip')).toHaveLength(0) + // Counted where it stands — the count is the rows under this heading. + const first = host.querySelector('.grp-head')! + expect(first.querySelector('.n')!.textContent).toBe( + String(first.nextElementSibling!.querySelectorAll('.gtitle').length), + ) }) it('collects the unlabelled goals into a last section of their own', () => { diff --git a/packages/ui/src/lib/components/LabelEditor.svelte b/packages/ui/src/lib/components/LabelEditor.svelte index 2b710fa..544732a 100644 --- a/packages/ui/src/lib/components/LabelEditor.svelte +++ b/packages/ui/src/lib/components/LabelEditor.svelte @@ -10,7 +10,7 @@ // A goal's labels, edited in place on the goal's own meta row — beside who opened it and how much // of it has been reviewed, because a label is one more fact about the goal rather than a control - // hanging off the bottom of its description. Everything it draws is chip-sized for that reason. + // hanging off the bottom of its description. // // Every gesture here writes the WHOLE set, because that is the only write the record has: this // component holds the fold, so it does the read-modify-write the sidecar cannot (`labelArgs`) and @@ -35,6 +35,7 @@ let busy = $state(false) let error = $state('') let field = $state(undefined) + let trigger = $state(undefined) const listId = 'goal-label-suggestions' // Autocomplete IS the label registry: there is no governed vocabulary in v1, so what the space @@ -52,6 +53,22 @@ */ const shows = $derived(goal.labels.length > 0 || (live && member)) + /** Dismissed by looking elsewhere. What was typed is kept — nothing asked for it to be thrown away. */ + function close(): void { + adding = false + } + + /** + * Dismissed on purpose, which is the gesture that does throw the draft away. Focus goes back to the + * chip that opened the panel, so a keyboard reader who cancelled is standing on the control that + * reopens it rather than at the top of the document. + */ + function cancel(): void { + adding = false + draft = '' + trigger?.focus() + } + async function commit(labels: string[]): Promise { if (busy) return busy = true @@ -77,6 +94,21 @@ } + + { + const target = event.target as HTMLElement | null + if (adding && !target?.closest('.labeladd')) close() + }} + onkeydown={(event) => { + if (!adding || event.key !== 'Escape') return + event.preventDefault() + cancel() + }} +/> + {#if shows} @@ -88,60 +120,61 @@ /> {/each} - {#if live && member} - {#if adding} - - + {#if live && member && !full} + + + + + + + {/if} {/if} diff --git a/packages/ui/src/lib/components/LabelEditor.svelte.test.ts b/packages/ui/src/lib/components/LabelEditor.svelte.test.ts index e4d0cff..3fe3d08 100644 --- a/packages/ui/src/lib/components/LabelEditor.svelte.test.ts +++ b/packages/ui/src/lib/components/LabelEditor.svelte.test.ts @@ -94,6 +94,46 @@ describe('editing a goal labels', () => { expect(setValue()).toBe('') }) + it('opens the field without changing what stands on the goal meta row', () => { + render(['infra']) + const row = host.querySelector('.lchips')! + const before = [...row.children].map((child) => child.className) + host.querySelector('.lchip-add')!.click() + flushSync() + // Same items, in the same order, with the trigger still among them: the field arrived in a panel + // anchored under it (`app.css`), so nothing on the row was pushed along or wrapped off it. + expect([...row.children].map((child) => child.className)).toEqual(before) + expect(host.querySelector('.labeladd-pop')!.contains(host.querySelector('input'))).toBe(true) + }) + + it('keeps a draft when dismissed by looking elsewhere, and drops it on Escape', () => { + render(['infra']) + host.querySelector('.lchip-add')!.click() + flushSync() + const field = host.querySelector('input')! + field.value = 'needs design' + field.dispatchEvent(new Event('input')) + flushSync() + + // Clicking away closes the panel. Nothing asked for the typing to be thrown away, so it is not. + document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + flushSync() + expect(host.querySelector('.labeladd-pop')!.hasAttribute('hidden')).toBe(true) + host.querySelector('.lchip-add')!.click() + flushSync() + expect(host.querySelector('input')!.value).toBe('needs design') + + // Escape is the gesture that means cancel, and it is the one that clears. + host + .querySelector('input')! + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + flushSync() + expect(host.querySelector('.labeladd-pop')!.hasAttribute('hidden')).toBe(true) + host.querySelector('.lchip-add')!.click() + flushSync() + expect(host.querySelector('input')!.value).toBe('') + }) + it('offers the space vocabulary minus what this goal already carries', () => { render(['infra']) host.querySelector('.lchip-add')!.click() diff --git a/packages/ui/src/lib/grouping.test.ts b/packages/ui/src/lib/grouping.test.ts index 27ed7b1..9cb394a 100644 --- a/packages/ui/src/lib/grouping.test.ts +++ b/packages/ui/src/lib/grouping.test.ts @@ -1,6 +1,6 @@ import type { GoalView } from '@radial/core' import { describe, expect, it } from 'vitest' -import { groupByLabel, overlapping } from './grouping.js' +import { groupByLabel } from './grouping.js' const goal = (title: string, labels: string[]): GoalView => ({ target: { uri: `at://did:plc:me/goal/${title}` }, labels }) as unknown as GoalView @@ -28,13 +28,9 @@ describe('grouping a goal list by label', () => { expect(groups.map((group) => group.label)).toEqual(['ux', 'infra']) expect(names(groups[0]!.goals)).toEqual(['a', 'b']) expect(names(groups[1]!.goals)).toEqual(['a']) - // Which is why the surface owes the reader a sentence: the sections hold three rows for two goals. - expect(overlapping(groups, goals)).toBe(true) - }) - - it('says nothing about repeats when every goal is in one section', () => { - const goals = [goal('a', ['infra']), goal('b', [])] - expect(overlapping(groupByLabel(goals), goals)).toBe(false) + // Three rows for two goals, which is the point: each section is whole, and each is counted where + // it stands rather than against the list's own total. + expect(groups.reduce((total, group) => total + group.goals.length, 0)).toBe(3) }) it('matches a label case-insensitively, under the spelling the most goals carry', () => { diff --git a/packages/ui/src/lib/grouping.ts b/packages/ui/src/lib/grouping.ts index 01264b9..8a83770 100644 --- a/packages/ui/src/lib/grouping.ts +++ b/packages/ui/src/lib/grouping.ts @@ -26,9 +26,9 @@ export interface GoalGroup { * A goal appears under EVERY label it carries, not under a first one picked for it: labels are an * unordered set on the record, so a "primary" label would be this module inventing a rank the fold * does not have — and a reader scanning for `infra` would find a goal missing from it because - * somebody happened to type `ux` first. The consequence is that the sections hold more rows than the - * list does, and the surface says so (`GoalGroups.svelte`) rather than leaving the arithmetic to be - * discovered. + * somebody happened to type `ux` first. The consequence is that the sections can hold more rows than + * the list does; each section is counted where it stands, so what a heading says is true of what is + * under it. * * Sections come in the vocabulary's own order — most-used first, alphabetical within a tie — so they * agree with the filter bar's chips above them, and each is counted and spelled by the same @@ -47,10 +47,3 @@ export function groupByLabel(goals: readonly GoalView[]): GoalGroup[] { const loose = goals.filter((goal) => goal.labels.length === 0) return loose.length > 0 ? [...groups, { key: 'none', goals: loose }] : groups } - -/** - * Whether these sections hold a goal more than once — what decides if the surface owes the reader - * the sentence explaining why the counts sum higher than the list. - */ -export const overlapping = (groups: readonly GoalGroup[], goals: readonly GoalView[]): boolean => - groups.reduce((total, group) => total + group.goals.length, 0) > goals.length diff --git a/packages/ui/test/label-surfaces.test.mjs b/packages/ui/test/label-surfaces.test.mjs new file mode 100644 index 0000000..ac16aa4 --- /dev/null +++ b/packages/ui/test/label-surfaces.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { describe, it } from 'node:test' + +// What the label surfaces promise about MOVEMENT, asserted where the promise is actually kept — in +// the stylesheet and in the markup, neither of which a mounted jsdom test can measure. A reader who +// presses a control must not have the thing they were reaching for moved out from under them, and +// every rule below is one half of that. + +const read = (path) => readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8') + +describe('label surfaces hold still', () => { + it('opens the add-a-label field out of flow, anchored under the chip that opens it', () => { + // A field and a button drawn IN the goal's meta row pushed every fact after them along the line, + // and on a full one wrapped it and moved the description underneath. + assert.match(read('app.css'), /\.labeladd \{[^}]*position: relative/) + assert.match(read('app.css'), /\.labeladd-pop \{[^}]*position: absolute/) + const editor = read('lib/components/LabelEditor.svelte') + assert.match(editor, /class="labeladd-pop" hidden=\{!adding\}/) + // The trigger is not swapped out for the field: it stands there whether the panel is open or not. + assert.match(editor, /class="lchip-add"[\s\S]*?aria-expanded=\{adding\}/) + }) + + it('reserves the filter bar second line rather than growing one when a chip is pressed', () => { + assert.match(read('app.css'), /\.filters \.frow\.fend \{[^}]*min-height:/) + }) + + it('heads a label section with text, and keeps the pressable chips in the bar', () => { + const groups = read('lib/components/GoalGroups.svelte') + assert.doesNotMatch(groups, /LabelChip/) + assert.match(groups, /\{group\.label \?\? 'No labels'\}<\/span>/) + }) + + it('puts every control a reader operates on the bar first line, in narrowing order', () => { + const bar = read('lib/components/FilterBar.svelte') + const first = bar.indexOf('aria-label="Which goals"') + const arrange = bar.indexOf('aria-label="How goals are arranged"') + const chips = bar.indexOf('') + const second = bar.indexOf('class="frow fend"') + assert.ok(first > 0 && arrange > first && chips > arrange && second > chips) + }) +}) -- 2.51.2