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 @@ } - +
- 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} +