@forwardreach/saas-ui 0.10.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,29 +1,249 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **`SelectMenu`: a single choice whose popup this package draws.** A native
8
+ `<select>` styled down to a line of text has exactly one piece of chrome left —
9
+ the platform's own popup — and it is the one piece CSS cannot reach. This is
10
+ the component for that case. It is additive in API — no existing export
11
+ changes shape — and the one existing call site that renders differently is
12
+ named below.
13
+
14
+ - **Two trigger presentations.** `variant="control"` (the default) is `Select`'s
15
+ bounded field with a chevron, in the same `sm`/`md` scale, so swapping one for
16
+ the other returns the same field with a themed popup. `variant="inline"` draws
17
+ no box at rest: the trigger is the label's own text at the size of the line it
18
+ sits in, with the hover surface, the chevron, and a focus ring without an
19
+ offset arriving on hover, focus, and while open. The inline variant is why the
20
+ component exists, and shipping it is what keeps a consumer from forcing a 40px
21
+ bordered control down to a 20px line through `className` and child selectors.
22
+ - **The same options `Combobox` takes**, groups included: consecutive options
23
+ declaring one group collapse under one heading, an ungrouped option ends the
24
+ run above it, and the component never reorders. The option list, its keyboard
25
+ model, and its group semantics are `Combobox`'s own, extracted into an
26
+ internal module both components render — `Combobox`'s suite passes unedited
27
+ across the extraction.
28
+ - **A filter once the list is long enough**, at `SELECT_MENU_SEARCH_THRESHOLD`
29
+ options (eight) and not before; `searchable` forces it either way. With a
30
+ filter, focus stays in the field while the list is navigated; without one, the
31
+ listbox itself holds focus. Both run the listbox pattern through
32
+ `aria-activedescendant`.
33
+ - **`emptyLabel`** offers clearing the value as a row that reports `""`.
34
+ **`placeholder`** is what the trigger shows when no option matches `value` — a
35
+ field holding nothing, or one holding a value the list no longer offers, which
36
+ is neither shown as chosen nor replaced by whatever happens to be first.
37
+ - **`defaultOpen`, `open`, `onOpenChange`**, for the consumer whose control
38
+ appears in response to an edit gesture: open the popup as the row enters edit
39
+ mode, so choosing costs one click rather than two, and read a close that
40
+ followed no `onValueChange` as a cancel.
41
+ - **Form participation and `FormField` wiring** exactly as `RoleMenu` does
42
+ them: a hidden input when `name` is set, nothing submitted while disabled, the
43
+ enclosing field's label naming the trigger, and `aria-label` composing purpose
44
+ with the current value.
45
+
46
+ **The option list is drawn to be read as the choice**, which is the one
47
+ visible change to an existing component: `Combobox` renders the same list, so
48
+ its popup picks all three corrections up. Options take the full text colour
49
+ rather than the muted one, which came from a popup that hangs under a field
50
+ holding the answer; where the popup *is* the choice, options dimmer than the
51
+ heading over them read as less available than the label for them. The current
52
+ option carries a check and a medium weight rather than a fill alone, because
53
+ that fill is `--ssui-surface-muted` on `--ssui-surface-elevated` — a clear step
54
+ in a light theme and almost none in a dark one. And the keyboard highlight now
55
+ wins the background over the selected fill, so it stays visible on the selected
56
+ row, which for `SelectMenu` is the row a popup opens on.
57
+
58
+ **A bounded popup starts at the field's own width** and grows only if an option
59
+ needs more, so the two read as one control. It used to carry a fixed minimum for
60
+ both variants, which made the popup of any field narrower than that minimum
61
+ overhang it. An inline trigger is as wide as its text, which is no width for a
62
+ list, so it keeps a floor of its own.
63
+
64
+ **`Popover`** — `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`,
65
+ `PopoverClose`, `PopoverPortal` — is exported alongside it: the thin styled
66
+ wrapper `DropdownMenu` already has, on `@radix-ui/react-popover`, a new direct
67
+ dependency sharing its internals with the dialog and dropdown-menu packages
68
+ already here. `SelectMenu` is built on it rather than on `DropdownMenu` because a
69
+ menu's roving focus and typeahead both fight a filter field.
70
+
71
+ **What this does not do, on purpose.** It does not replace `Select`. On a phone
72
+ a native `<select>` opens the platform's own picker — thumb-sized, familiar,
73
+ outside the page — and `SelectMenu` cannot and will not. For a bounded field in
74
+ an ordinary form that is worth more than a themed popup, and `Select` stays the
75
+ default there. `docs/packages/saas-ui.md` now tables all four single-choice
76
+ controls — `Select`, `SelectMenu`, `Combobox`, `RoleMenu` — and which case each
77
+ answers, and the three older components' doc comments point at that table
78
+ rather than carrying partial copies of it. No multi-select: a chip-holding
79
+ trigger and checkable rows roughly double the API and the test matrix, and are
80
+ a change of their own.
81
+
82
+ ## 0.11.0
83
+
84
+ ### Minor Changes
85
+
86
+ - **Four additive form-primitive changes, every default unchanged.** Found by a
87
+ consumer adopting these components rather than by reading them; grouped because
88
+ they ship in one release, not because they are otherwise related.
89
+
90
+ - **`--ssui-border-control`.** A control's boundary is now separable from a
91
+ container's edge. Six boundaries read it — `Input`, `Textarea`, `Select`,
92
+ `Combobox`, `Checkbox`, and `AuditLog`'s filter selects — as
93
+ `var(--ssui-border-control,var(--ssui-border))`, the fallback pattern
94
+ `--ssui-switch-track` already uses. The package defines no value for it
95
+ anywhere, so **this is a visual no-op until a consumer sets it**, and setting
96
+ it is the whole of the opt-in.
97
+
98
+ WCAG 1.4.11 asks 3:1 of a boundary needed to identify an interactive control
99
+ and exempts a container's edge; those are two jobs and one token was doing
100
+ both, so raising a text field's edge meant raising eighteen container edges
101
+ with it. Worth being blunt rather than reassuring about what this does not
102
+ fix: the package's own `--ssui-border` is `#d0d5dd`, **1.47:1** on white, and
103
+ that is still what a control boundary resolves to if you do nothing. A
104
+ default that met the floor would darken every control in every consuming app
105
+ on a version bump, which is the decision this token exists to hand to the
106
+ consumer rather than make for them. See the token contract in
107
+ `docs/packages/saas-ui.md`.
108
+
109
+ - **`Select` gains `size`.** `"sm"` is 32px/12px with the chevron re-inset, to
110
+ sit in a row of `Button size="sm"`. `"md"` is today's styling verbatim and
111
+ the default. The name is taken over from the native `size` attribute, which
112
+ sets visible rows on a list-box select and is meaningless on the collapsed
113
+ control this styles.
114
+
115
+ - **`ChoiceCardGroup` gains `variant`.** `"list"` draws one bordered container
116
+ of divided rows — selection carried by a fill, the radio, and the title's
117
+ weight rather than a per-option border, with hover scoped to unchosen rows so
118
+ it cannot dim the chosen one. `"card"` remains the default and is unchanged.
119
+ Both shapes are legitimate; boxing _only_ the chosen option is the third
120
+ convention and the one that does not work. `TimeZoneField` takes the same
121
+ prop and passes it through.
122
+
123
+ - **`Combobox` gains `onCreate` / `createLabel` / `isDuplicate`.** When
124
+ `onCreate` is set, and the trimmed query matches no existing option, a create
125
+ entry joins the listbox as a real option — arrow-navigable,
126
+ Enter-committable, in the same index space — so no consumer needs a parallel
127
+ keyboard path to offer one row. It suppresses `emptyMessage`, and passes the
128
+ query to `onCreate` unnormalized, because what the created thing is called is
129
+ a product rule.
130
+
131
+ `isDuplicate` is what decides "no existing option", and it defaults to
132
+ case-insensitive equality on an option's `label` or `value`. Exact equality
133
+ would be the obvious default and is the wrong one: the offer is only honest
134
+ if it agrees with the normalization the consumer was just handed, and casing
135
+ is the cheapest way for the two to disagree — with `urgent` in the list,
136
+ typing `Urgent` would offer to create it, the consumer would lowercase it,
137
+ and the user has taken an action that did nothing or made a duplicate. Pass
138
+ your own when your rule goes further than casing.
139
+
140
+ Four more landed after a consumer reviewed the running app, and they are the
141
+ reason to take this release rather than the one before it:
142
+
143
+ - **`ChoiceCard` no longer misreports what was saved.** React resets a
144
+ `<form action={fn}>` once the action resolves, and a reset restores every
145
+ control to its content attribute. A controlled radio only ever had its
146
+ `checked` _property_ written, so after a successful save the group snapped
147
+ back to whatever was selected on first render, while the surrounding state,
148
+ the hidden inputs, and the stored value all held the new selection. The write
149
+ succeeded and only the control disagreed, which reads to a user as "my change
150
+ did not take". `defaultChecked` now tracks the selection.
151
+
152
+ - **`Combobox`'s field is a search box, not editable text.** It used to keep
153
+ the committed label in the input, so a caret landed inside it, typing
154
+ appended to it, and the result matched nothing — and it invited the belief
155
+ that the text was the value and could be corrected in place, which it never
156
+ was. Focus now starts an empty query and the committed value moves to the
157
+ placeholder, so it stays visible while searching and cannot be edited into
158
+ something that was never a choice.
159
+
160
+ - **`ComboboxOption.inputLabel`** — the text the field shows once an option is
161
+ committed, defaulting to `label`. Where `trailing` carries part of an
162
+ option's identity rather than decoration, the collapsed field used to drop
163
+ it. `TimeZoneField` uses it to keep a chosen zone's GMT offset on screen.
164
+
165
+ - **A consumer's `filter` now runs with an empty query** rather than being
166
+ skipped while nothing is typed. A predicate that only searches is
167
+ unaffected; one that _excludes_ had its rule ignored for exactly as long as
168
+ the field was empty, which is the state the popup opens in.
169
+
170
+ Two more came out of the same consumer looking at the running app a second
171
+ time, and both are visible changes to what this package already rendered:
172
+
173
+ - **`ChoiceCard`'s description is body text.** It was `text-xs` on
174
+ `--ssui-text-subtle`, a pairing that fails the contrast floor on the row
175
+ most likely to be read: measured against a selected row's
176
+ `--ssui-accent-subtle` fill in a consuming app, 2.87:1 light and 3.77:1
177
+ dark, with unselected rows at 3.31:1 light, where 12px body text wants
178
+ 4.5:1. It is now `text-sm` on `--ssui-text-muted`, which clears the floor on
179
+ every one of those pairings. The size half matters independently: the local
180
+ treatments this component replaced across one consumer ran 14px, 12px, and
181
+ 11px, and normalising on the smallest silently shrank the caller whose
182
+ descriptions are whole sentences. **This changes existing rendering** — a
183
+ caller wanting a terser hint can pass `children` and size it itself.
184
+
185
+ - **`Combobox` gains `keepOpenOnSelect`.** Off by default. When set, a commit
186
+ clears the query and leaves the popup open instead of closing, for the
187
+ picker that adds to a set rather than setting a value. Without it such a
188
+ caller pays a full open-the-control round trip per item, and — if it keeps
189
+ its own panel open around the control, as a tag picker does — is left
190
+ showing an empty field with no list beneath it, which reads as inert rather
191
+ than as ready for the next choice.
192
+
193
+ A review of the change closed four more gaps in it, three of them in the parts
194
+ it had shipped to improve accessibility:
195
+
196
+ - **The list variant no longer clips its own focus ring.** The container
197
+ carried `overflow-hidden` to keep the first and last row's fills inside its
198
+ radius. A Tailwind ring is a box-shadow drawn _outside_ the element, and a
199
+ list row has no border or radius of its own, so it fills the container's
200
+ padding box exactly — the clip amputated both vertical sides of every row's
201
+ focus indicator plus the outer edges of the first and last. A keyboard user
202
+ tabbing in saw one horizontal line, easily read as the divider. The rows now
203
+ round their own outer corners instead, which squares the fills off without
204
+ clipping anything.
205
+
206
+ - **The committed value is no longer styled as a hint.** Moving it into the
207
+ placeholder gave it `--ssui-text-subtle` — the pairing this same release
208
+ retired from `ChoiceCard`'s description for measuring 2.87:1. It now takes
209
+ `--ssui-text-muted` while a true placeholder stays subtle, and it is exposed
210
+ through `aria-describedby` as well, because screen-reader handling of
211
+ placeholder text is inconsistent and the field is empty while you search.
212
+
213
+ - **The default filter searches `inputLabel`.** An option showing
214
+ `Asia/Tokyo (GMT+09:00)` in the collapsed field was not findable by that
215
+ string once focus cleared the box — displayed but unsearchable.
216
+
217
+ - **`AuditLog`'s filters are the shared `Select`.** They were a hand-rolled
218
+ `<select>` on a local class string, which existed only because `Select` had
219
+ no size that fitted an inline filter row; `size="sm"` is that size. The copy
220
+ had already drifted — no focus ring, no chevron. **This changes existing
221
+ rendering**: the filters are 32px rather than 36px and gain the chevron and
222
+ the focus ring.
223
+
224
+ Existing behavior is otherwise unchanged: of the 174 tests predating this
225
+ release, the only two edited are the `TimeZoneField` assertions that pinned the
226
+ bare zone name in the collapsed field — the contract `inputLabel` deliberately
227
+ changes.
228
+
3
229
  ## 0.10.4
4
230
 
5
231
  ### Patch Changes
6
232
 
7
- - First release published with an npm provenance attestation. The package contents
8
- are unchanged from `0.10.2`; what is new is that the registry now records where
9
- this artifact came from — this repository, the commit it was built from, and the
10
- workflow run that built it — signed by the CI identity and recorded in a public
11
- transparency log, so a consumer can check a version's origin rather than trust
12
- its number.
13
-
14
- That check is what was missing when two vendored tarballs, `@forwardreach/saas-ui@0.9.1`
15
- and `@forwardreach/saas-mcp@0.1.3`, turned out to carry versions no commit in this
16
- repository ever produced. One left a consumer a month stale behind a _higher_
17
- version number than the real thing. Nothing in the pipeline could have caught
18
- either, because a locally packed tarball records no origin at all.
233
+ - **No provenance attestation, and none is coming.** Like `0.10.3`, this release
234
+ existed to carry one and does not; contents remain identical to `0.10.2`.
19
235
 
20
- `0.10.3` was meant to be this release and shipped without an attestation; see its
21
- entry for why. Both numbers stay retired rather than reissued.
236
+ npm refuses a provenance attestation whose GitHub source repository is
237
+ private, and this repository is private — the package being public on npm is
238
+ not sufficient, because the source repository is what an attestation has to
239
+ name. So no version of this package will carry one unless that changes. Use
240
+ the version lineage and the changelog to answer "which code is this?", and see
241
+ `future.md` for the packing-provenance work that remains open.
22
242
 
23
- Verify an installed copy with `npm audit signatures`, or from a clone of this
24
- repository with `node scripts/verify-provenance.mjs @forwardreach/saas-ui@<version>`.
25
- Neither needs registry credentials. Versions published before this one cannot
26
- gain an attestation retroactively.
243
+ This was established the expensive way: a third attempt generated and signed a
244
+ sigstore bundle and the registry rejected it with `422 ... Unsupported GitHub
245
+ Actions source repository visibility: "private"`. That attempt published
246
+ nothing, so `0.10.4` is the current release.
27
247
 
28
248
  ## 0.10.3
29
249
 
@@ -32,22 +252,21 @@
32
252
  - **No provenance attestation, despite what this release was for.** `0.10.3` was
33
253
  published to be the first artifact carrying one, and it is not: the package
34
254
  contents are identical to `0.10.2` and the registry reports no
35
- `dist.attestations` for it. Use `0.10.4` or later if you want a version whose
36
- origin you can check; this number is spent and will not be reissued.
37
-
38
- The cause is worth recording, because it is exactly the silent failure the
39
- verification step was added to catch. `changeset publish` detects a pnpm
40
- workspace and shells out to `pnpm publish`, and pnpm does not read arbitrary
41
- `NPM_CONFIG_*` environment variables into its configuration — it reads
42
- `provenance` as an rc option only. So `NPM_CONFIG_PROVENANCE=true` was
43
- accepted by the shell, ignored by the publisher, and the package went out
44
- with no attestation and no warning of any kind. Nothing in the publish output
45
- suggested anything was wrong. `pnpm config get provenance` even reports
46
- `true` for that env var, so it is not a usable pre-flight check either.
47
-
48
- The release failed red rather than reporting success, which is the property
49
- that mattered: the post-publish check queried the registry, found no
50
- attestation, and stopped the run before tags were pushed.
255
+ `dist.attestations` for it. It is real and installable; it is simply the same
256
+ code as `0.10.2` under a number that bought nothing. See `0.10.4`.
257
+
258
+ Two mechanisms were tried before the real blocker surfaced, and both failed
259
+ silently, which is worth recording. `changeset publish` detects a pnpm
260
+ workspace and shells out to `pnpm publish`; pnpm does not read arbitrary
261
+ `NPM_CONFIG_*` environment variables into its configuration, and
262
+ libnpmpublish gates provenance on `provenance === true`, a strict boolean
263
+ identity check. So both `NPM_CONFIG_PROVENANCE=true` (this release) and
264
+ `provenance=true` in an rc file (`0.10.4`) were accepted by the shell and
265
+ ignored by the publisher, with nothing in the publish output to say so.
266
+ `pnpm config get provenance` even reports `true` for the environment variable
267
+ the publisher ignores, so it is not a usable pre-flight check.
268
+
269
+ Neither number is reissued.
51
270
 
52
271
  ## 0.10.2
53
272
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { cn } from "../utils/cn.js";
4
+ import { Select } from "./select.js";
5
5
  import { SettingsSection } from "./settings-section.js";
6
6
  function defaultFormatTimestamp(iso) {
7
7
  const date = new Date(iso);
@@ -9,7 +9,6 @@ function defaultFormatTimestamp(iso) {
9
9
  return iso;
10
10
  return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
11
11
  }
12
- const selectClass = "h-9 rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-2 text-xs text-[color:var(--ssui-text)]";
13
12
  const targetBadgeClass = "inline-flex items-center gap-1 rounded-md bg-[color:var(--ssui-surface-muted)] px-1.5 py-0.5 text-xs text-[color:var(--ssui-text-muted)]";
14
13
  /**
15
14
  * A domain-agnostic audit log: a titled section with optional filter dropdowns,
@@ -20,5 +19,11 @@ const targetBadgeClass = "inline-flex items-center gap-1 rounded-md bg-[color:va
20
19
  * reuse it directly.
21
20
  */
22
21
  export function AuditLog({ title = "Activity", description, icon, entries, filters = [], loading = false, error, emptyState = "No activity has been recorded.", renderTargetLink, formatTimestamp = defaultFormatTimestamp, }) {
23
- return (_jsxs(SettingsSection, { action: _jsx("span", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: loading ? "Loading..." : `${entries.length} events` }), description: description, icon: icon, title: title, children: [filters.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: filters.map((filter) => (_jsx("select", { "aria-label": filter.ariaLabel, className: cn(selectClass, "min-w-32"), onChange: (event) => filter.onChange(event.target.value), value: filter.value, children: filter.options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) }, filter.id))) })), error && _jsx("div", { className: "mt-2 text-xs text-red-600", children: error }), _jsxs("div", { className: "mt-3 divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)]", children: [entries.length === 0 && _jsx("div", { className: "px-3 py-6 text-center text-sm text-[color:var(--ssui-text-muted)]", children: emptyState }), entries.map((entry) => (_jsxs("details", { children: [_jsxs("summary", { className: "grid cursor-pointer list-none gap-2 px-3 py-3 md:grid-cols-[11rem_minmax(0,1fr)_8rem_8rem]", children: [_jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: formatTimestamp(entry.timestamp) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium", children: entry.title }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-1 text-xs text-[color:var(--ssui-text-muted)]", children: [entry.actorLabel && _jsx("span", { className: "truncate font-medium text-[color:var(--ssui-text)]", children: entry.actorLabel }), (entry.badges ?? []).map((badge) => (_jsx("span", { children: badge }, badge)))] })] }), _jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: entry.outcome }), _jsx("div", { className: "flex flex-wrap gap-1", children: (entry.targetIds ?? []).slice(0, 2).map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsx("span", { className: targetBadgeClass, children: "Record" }) }, targetId))) })] }), _jsxs("div", { className: "space-y-3 border-t border-[color:var(--ssui-border)] px-3 py-3", children: [entry.details && entry.details.length > 0 && (_jsx("div", { className: "grid gap-2 text-xs text-[color:var(--ssui-text-muted)] md:grid-cols-3", children: entry.details.map((detail) => (_jsxs("div", { children: [detail.label, ": ", detail.value] }, detail.label))) })), entry.targetIds && entry.targetIds.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5", children: entry.targetIds.map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsxs("span", { className: targetBadgeClass, children: ["Record ", targetId] }) }, targetId))) })), entry.metadata !== undefined && entry.metadata !== null && (_jsx("pre", { className: "max-h-56 overflow-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-muted)] p-3 font-mono text-xs text-[color:var(--ssui-text-muted)]", children: JSON.stringify(entry.metadata, null, 2) }))] })] }, entry.id)))] })] }));
22
+ return (_jsxs(SettingsSection, { action: _jsx("span", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: loading ? "Loading..." : `${entries.length} events` }), description: description, icon: icon, title: title, children: [filters.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: filters.map((filter) => (
23
+ // The shared small select rather than a local class string. This
24
+ // was a hand-rolled `<select>` only because `Select` had no size
25
+ // that fitted an inline filter row; it has one now, and keeping the
26
+ // copy meant a second control drifting away from the first — it had
27
+ // already lost the focus ring and the chevron.
28
+ _jsx(Select, { "aria-label": filter.ariaLabel, className: "min-w-32", onChange: (event) => filter.onChange(event.target.value), size: "sm", value: filter.value, children: filter.options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) }, filter.id))) })), error && _jsx("div", { className: "mt-2 text-xs text-red-600", children: error }), _jsxs("div", { className: "mt-3 divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)]", children: [entries.length === 0 && _jsx("div", { className: "px-3 py-6 text-center text-sm text-[color:var(--ssui-text-muted)]", children: emptyState }), entries.map((entry) => (_jsxs("details", { children: [_jsxs("summary", { className: "grid cursor-pointer list-none gap-2 px-3 py-3 md:grid-cols-[11rem_minmax(0,1fr)_8rem_8rem]", children: [_jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: formatTimestamp(entry.timestamp) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium", children: entry.title }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-1 text-xs text-[color:var(--ssui-text-muted)]", children: [entry.actorLabel && _jsx("span", { className: "truncate font-medium text-[color:var(--ssui-text)]", children: entry.actorLabel }), (entry.badges ?? []).map((badge) => (_jsx("span", { children: badge }, badge)))] })] }), _jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: entry.outcome }), _jsx("div", { className: "flex flex-wrap gap-1", children: (entry.targetIds ?? []).slice(0, 2).map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsx("span", { className: targetBadgeClass, children: "Record" }) }, targetId))) })] }), _jsxs("div", { className: "space-y-3 border-t border-[color:var(--ssui-border)] px-3 py-3", children: [entry.details && entry.details.length > 0 && (_jsx("div", { className: "grid gap-2 text-xs text-[color:var(--ssui-text-muted)] md:grid-cols-3", children: entry.details.map((detail) => (_jsxs("div", { children: [detail.label, ": ", detail.value] }, detail.label))) })), entry.targetIds && entry.targetIds.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5", children: entry.targetIds.map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsxs("span", { className: targetBadgeClass, children: ["Record ", targetId] }) }, targetId))) })), entry.metadata !== undefined && entry.metadata !== null && (_jsx("pre", { className: "max-h-56 overflow-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-muted)] p-3 font-mono text-xs text-[color:var(--ssui-text-muted)]", children: JSON.stringify(entry.metadata, null, 2) }))] })] }, entry.id)))] })] }));
24
29
  }
@@ -9,7 +9,7 @@ import { useFormFieldProps } from "./form-field.js";
9
9
  * support and platform semantics come from the browser, and only the box itself
10
10
  * needs theming. Participates in `FormField` wiring like the other controls.
11
11
  */
12
- export const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx("input", { ref: ref, type: "checkbox", className: cn("size-4 shrink-0 cursor-pointer rounded-[calc(var(--ssui-radius)/2)] border border-[color:var(--ssui-border)] accent-[color:var(--ssui-primary)] transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:opacity-50", className), ...useFormFieldProps(props) })));
12
+ export const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx("input", { ref: ref, type: "checkbox", className: cn("size-4 shrink-0 cursor-pointer rounded-[calc(var(--ssui-radius)/2)] border border-[color:var(--ssui-border-control,var(--ssui-border))] accent-[color:var(--ssui-primary)] transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:opacity-50", className), ...useFormFieldProps(props) })));
13
13
  Checkbox.displayName = "Checkbox";
14
14
  const CheckboxGroupContext = React.createContext(null);
15
15
  /**
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ export type ChoiceGroupVariant = "card" | "list";
2
3
  export interface ChoiceCardGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
3
4
  /** Form field name shared by the underlying radios. */
4
5
  name?: string;
@@ -9,13 +10,32 @@ export interface ChoiceCardGroupProps extends Omit<React.HTMLAttributes<HTMLDivE
9
10
  onValueChange?: (value: string) => void;
10
11
  /** Accessible label for the radio group. */
11
12
  "aria-label"?: string;
13
+ /**
14
+ * How the group is drawn. Defaults to `"card"`.
15
+ *
16
+ * `"card"` boxes every option and lays them out in a responsive two-column
17
+ * grid — the selection-card treatment, for a choice that earns that weight.
18
+ * `"list"` draws one bordered container with divided rows inside it.
19
+ *
20
+ * The two are not interchangeable and the reason to have both is that boxing
21
+ * *only the chosen* option is the third convention, and it is the one that
22
+ * does not work: it reads as that row being the only live control on the
23
+ * page. So either every option gets a border or none does. Boxing none is
24
+ * right for an ordinary settings question, but leaves the options floating
25
+ * with nothing saying they are one question — a pair of them in a two-column
26
+ * grid drifts far enough apart to read as two unrelated fields. The
27
+ * container answers that without spending a border per option.
28
+ */
29
+ variant?: ChoiceGroupVariant;
12
30
  }
13
31
  /**
14
- * Radio group rendered as selectable bordered cards. Uses native radio
15
- * inputs, so form participation and arrow-key navigation between cards come
16
- * from the browser. Children are `ChoiceCard`s.
32
+ * Radio group rendered as selectable cards or as a bordered list. Uses native
33
+ * radio inputs, so form participation and arrow-key navigation between options
34
+ * come from the browser. Children are `ChoiceCard`s, which read the variant
35
+ * from this group rather than taking it themselves — a `ChoiceCard` outside a
36
+ * group is already an error, so the context is where the variant belongs.
17
37
  */
18
- export declare function ChoiceCardGroup({ name, value, defaultValue, onValueChange, className, children, ...props }: ChoiceCardGroupProps): import("react/jsx-runtime").JSX.Element;
38
+ export declare function ChoiceCardGroup({ name, value, defaultValue, onValueChange, variant, className, children, ...props }: ChoiceCardGroupProps): import("react/jsx-runtime").JSX.Element;
19
39
  export interface ChoiceCardProps extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
20
40
  /** Value submitted when this card is selected. */
21
41
  value: string;
@@ -26,8 +46,10 @@ export interface ChoiceCardProps extends Omit<React.LabelHTMLAttributes<HTMLLabe
26
46
  disabled?: boolean;
27
47
  }
28
48
  /**
29
- * One selectable card inside a `ChoiceCardGroup`: a bordered label wrapping a
30
- * native radio plus title and supporting text. The selected card gets the
31
- * emphasized border and muted fill.
49
+ * One option inside a `ChoiceCardGroup`: a label wrapping a native radio plus
50
+ * title and supporting text. In the card variant it is a bordered box whose
51
+ * selected state is an emphasized border and muted fill; in the list variant
52
+ * it is a divided row that carries no border of its own and marks selection
53
+ * with a fill.
32
54
  */
33
55
  export declare function ChoiceCard({ value, title, description, disabled, className, children, ...props }: ChoiceCardProps): import("react/jsx-runtime").JSX.Element;
@@ -4,11 +4,13 @@ import * as React from "react";
4
4
  import { cn } from "../utils/cn.js";
5
5
  const ChoiceCardGroupContext = React.createContext(null);
6
6
  /**
7
- * Radio group rendered as selectable bordered cards. Uses native radio
8
- * inputs, so form participation and arrow-key navigation between cards come
9
- * from the browser. Children are `ChoiceCard`s.
7
+ * Radio group rendered as selectable cards or as a bordered list. Uses native
8
+ * radio inputs, so form participation and arrow-key navigation between options
9
+ * come from the browser. Children are `ChoiceCard`s, which read the variant
10
+ * from this group rather than taking it themselves — a `ChoiceCard` outside a
11
+ * group is already an error, so the context is where the variant belongs.
10
12
  */
11
- export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, className, children, ...props }) {
13
+ export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, variant = "card", className, children, ...props }) {
12
14
  const generatedName = React.useId();
13
15
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
14
16
  const selected = value ?? uncontrolledValue;
@@ -20,13 +22,27 @@ export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, clas
20
22
  setUncontrolledValue(next);
21
23
  onValueChange?.(next);
22
24
  },
23
- }), [name, generatedName, selected, value, onValueChange]);
24
- return (_jsx("div", { role: "radiogroup", className: cn("grid gap-2 md:grid-cols-2", className), ...props, children: _jsx(ChoiceCardGroupContext.Provider, { value: contextValue, children: children }) }));
25
+ variant,
26
+ }), [name, generatedName, selected, value, onValueChange, variant]);
27
+ return (_jsx("div", { role: "radiogroup", className: cn(variant === "list"
28
+ ? // Deliberately *not* `overflow-hidden`, which is the obvious way to
29
+ // stop the first and last row's fills overhanging the container's
30
+ // radius and is the wrong one. A Tailwind ring is a box-shadow drawn
31
+ // outside the element's border box, and a list row fills this
32
+ // container's padding box exactly — so clipping here amputates the
33
+ // row's own focus ring: both vertical sides on every row, plus the
34
+ // top of the first and the bottom of the last. A keyboard user
35
+ // tabbing in would see a stray horizontal line easily read as the
36
+ // `divide-y` rule. The rows round their own outer corners instead.
37
+ "divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)]"
38
+ : "grid gap-2 md:grid-cols-2", className), ...props, children: _jsx(ChoiceCardGroupContext.Provider, { value: contextValue, children: children }) }));
25
39
  }
26
40
  /**
27
- * One selectable card inside a `ChoiceCardGroup`: a bordered label wrapping a
28
- * native radio plus title and supporting text. The selected card gets the
29
- * emphasized border and muted fill.
41
+ * One option inside a `ChoiceCardGroup`: a label wrapping a native radio plus
42
+ * title and supporting text. In the card variant it is a bordered box whose
43
+ * selected state is an emphasized border and muted fill; in the list variant
44
+ * it is a divided row that carries no border of its own and marks selection
45
+ * with a fill.
30
46
  */
31
47
  export function ChoiceCard({ value, title, description, disabled, className, children, ...props }) {
32
48
  const group = React.useContext(ChoiceCardGroupContext);
@@ -34,7 +50,71 @@ export function ChoiceCard({ value, title, description, disabled, className, chi
34
50
  throw new Error("ChoiceCard must be rendered inside a ChoiceCardGroup");
35
51
  }
36
52
  const selected = group.value === value;
37
- return (_jsxs("label", { className: cn("flex cursor-pointer gap-2 rounded-[var(--ssui-radius)] border px-3 py-2 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", selected
38
- ? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
39
- : "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", disabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx("input", { checked: selected, className: "mt-0.5 size-4 shrink-0 accent-current focus-visible:outline-none", disabled: disabled, name: group.name, onChange: () => group.setValue(value), type: "radio", value: value }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-medium", children: title }), description ? (_jsx("span", { className: "block text-xs text-[color:var(--ssui-text-subtle)]", children: description })) : null, children] })] }));
53
+ const list = group.variant === "list";
54
+ // Keep the radio's *default* in step with its current state, so a form reset
55
+ // lands on what is selected now rather than on what was selected first.
56
+ //
57
+ // This is not defensive coding; it is the difference between a settings form
58
+ // that reports the truth and one that lies. React resets a `<form action={fn}>`
59
+ // once the action resolves, and a reset restores every control to its content
60
+ // attribute. For a controlled radio React only ever writes the `checked`
61
+ // *property*, so the attribute keeps whatever the first render put there — and
62
+ // after a successful save the group snaps back to the option that was selected
63
+ // when the page loaded, while the surrounding state, the hidden inputs, and the
64
+ // database all hold the new one. The save worked; only the control disagrees,
65
+ // which is worse than a visible failure.
66
+ //
67
+ // `defaultChecked` is the property backing that attribute, so assigning it is
68
+ // the direct way to say "this is what a reset should return to".
69
+ const inputRef = React.useRef(null);
70
+ React.useEffect(() => {
71
+ if (inputRef.current)
72
+ inputRef.current.defaultChecked = selected;
73
+ }, [selected]);
74
+ return (_jsxs("label", { className: cn("flex cursor-pointer gap-2 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", list
75
+ ? // No border: the container supplies it, and the dividers between
76
+ // rows are its `divide-y`. Selection is the fill plus the radio plus
77
+ // the title's weight — deliberately not a border, because the border
78
+ // here groups the options rather than marking one of them.
79
+ //
80
+ // The first and last rows do round their outer corners, so their
81
+ // fills square off against the container's radius. That is the job
82
+ // `overflow-hidden` on the container would have done, moved here
83
+ // because clipping there would cut the focus ring these rows draw
84
+ // outside themselves.
85
+ "items-start gap-3 p-3 first:rounded-t-[var(--ssui-radius)] last:rounded-b-[var(--ssui-radius)]"
86
+ : "rounded-[var(--ssui-radius)] border px-3 py-2", list
87
+ ? selected
88
+ ? // An opaque fill, not an alpha of one: a translucent tint over
89
+ // the surface composites to within a value or two of the hover
90
+ // highlight, which puts the chosen row and a merely pointed-at
91
+ // one at the same weight.
92
+ "bg-[color:var(--ssui-accent-subtle)] text-[color:var(--ssui-text)]"
93
+ : // Hover is scoped to unchosen rows. The hover overlay sits
94
+ // *below* the selected fill, so applying it unscoped would
95
+ // visibly dim the chosen row when you point at it.
96
+ "text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]"
97
+ : selected
98
+ ? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
99
+ : "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", disabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx("input", { ref: inputRef, checked: selected,
100
+ // `accent-current` so the dot is the row's own foreground rather than
101
+ // the user agent's blue, which matters more in the list variant where
102
+ // the radio is the primary marker rather than one signal among several.
103
+ className: "mt-0.5 size-4 shrink-0 accent-current focus-visible:outline-none", disabled: disabled, name: group.name, onChange: () => group.setValue(value), type: "radio", value: value }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-medium", children: title }), description ? (
104
+ // Same size as the title and one step down in colour, not two. A
105
+ // description here is the content that decides the choice — often a
106
+ // full sentence — so it is read, not glanced at, and `text-subtle`
107
+ // put it below the contrast floor on the row most likely to be read:
108
+ // against the selected row's `--ssui-accent-subtle` fill it measured
109
+ // 2.87:1 in a consuming app's light theme and 3.77:1 in its dark
110
+ // one, where 12px text wants 4.5:1. Unselected rows fared little
111
+ // better at 3.31:1 light. `--ssui-text-muted` clears the floor on every one of
112
+ // those pairings with room to spare.
113
+ //
114
+ // 14px rather than 12px because the three-way split this component
115
+ // replaced across its consumers ran 14px, 12px, and 11px, and
116
+ // normalising on the smallest silently shrank the one caller whose
117
+ // descriptions are whole sentences. A caller wanting a terser hint
118
+ // can still pass `children` and size it itself.
119
+ _jsx("span", { className: "block text-sm text-[color:var(--ssui-text-muted)]", children: description })) : null, children] })] }));
40
120
  }