@tristan2828/ui-foundation 3.13.0 → 3.14.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.
@@ -168,7 +168,10 @@ is a filled-in example while the Widgets demo is still here).
168
168
  `src/routes/<entity>/<entity>-schema.ts` (zod schema mirroring
169
169
  `<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
170
170
  functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
171
- the shape)
171
+ the shape. A Markdown long text is checked with `refine`, never
172
+ trimmed, like Widget's `description`: a trim rewrites text nobody
173
+ touched, a leading indent that makes a code block or the text's
174
+ own ending)
172
175
  - `src/routes/widgets/widget-format.ts` →
173
176
  `src/routes/<entity>/<entity>-format.ts` (the lookups and formatters
174
177
  both the columns and the view render values with: badge variant maps,
@@ -10,7 +10,12 @@ alternatives ([`column-options.md`](column-options.md)), not designed in the abs
10
10
  Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
11
11
  to its view), 17 (quick action on the view) and 18 (editable value) are
12
12
  the template's own references: Widget's In Stock and Name columns and its
13
- view render them, and their specs pin them. The other patterns from 11 on are
13
+ view render them, and their specs pin them. Pattern 19 (stage circle)
14
+ and the multi-value variant of 4, the dependency list in 10 and the
15
+ pressed icon in 15 were each built for a real app's screen first; the
16
+ package's Storybook renders them (`app/StageCircle`,
17
+ `patterns/CellPatterns`), and its checks pin their accessibility and
18
+ contrast in both themes. The other patterns from 11 on are
14
19
  marked **Unproven**: written ahead of a real
15
20
  column, so no app has tested them on real data yet. Prefer a proven
16
21
  pattern when one fits. When you use an unproven one, say so in the
@@ -109,6 +114,63 @@ narrow.
109
114
  cell, absent in an isolated Storybook story.
110
115
  - Encode the value in the glyph's **shape** (solid / half / slashed /
111
116
  dashed), not its colour.
117
+ - **Give the trigger a focus ring**
118
+ (`rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50`):
119
+ it's focusable, and a span has none of its own.
120
+
121
+ ### Several values: the icons side by side, every name in one tooltip
122
+
123
+ A multi value (several categories on one record) where each value has
124
+ its own icon and colour slot, and the column is narrow. The package's
125
+ Storybook renders it (`patterns/CellPatterns`, "Icons with one tooltip").
126
+
127
+ ```tsx
128
+ // values: the row's categories, `undefined` for one still loading.
129
+ const names = values.map((value) => value?.name ?? '…').join(', ')
130
+ if (values.length === 0) return emptyCell
131
+
132
+ <Tooltip>
133
+ <TooltipTrigger
134
+ render={
135
+ <span role="img" aria-label={names} tabIndex={0}
136
+ className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
137
+ }
138
+ >
139
+ {values.map((value, index) => {
140
+ if (!value) return <span key={index} className="text-muted-foreground">…</span>
141
+ const Icon = CATEGORY_ICON[value.icon]
142
+ return <Icon key={value.id} className={`size-4 shrink-0 ${CATEGORY_COLOR[value.color]}`} />
143
+ })}
144
+ </TooltipTrigger>
145
+ <TooltipContent>{names}</TooltipContent>
146
+ </Tooltip>
147
+ ```
148
+
149
+ - **One tooltip on the whole group**, listing every name in the record's
150
+ order ("Home, Finance"), not one per icon: one tab stop per cell, and
151
+ the names read as one value.
152
+ - **The trigger is the `role="img"` span**, named by that same list. The
153
+ icons inside are presentational (children of an `img`), so they need no
154
+ `aria-hidden` of their own. Read-only: never a button.
155
+ - **Each icon tinted with its value's colour slot**
156
+ ([`design-language.md`](design-language.md) "Categorical colour"), the
157
+ icon's shape telling values apart in greyscale.
158
+ - **A value still loading shows "…"**, in the cell and in the names,
159
+ never a bare id.
160
+ - **Where there's room** (the record's view), each icon sits beside its
161
+ name instead (pattern 2 per value, in a `flex flex-wrap gap-x-3 gap-y-1`
162
+ list), with no tooltip.
163
+
164
+ **Testing note.** With several tooltips on a page, one that's closing
165
+ stays in the DOM for a moment beside the one opening. A spec that reads
166
+ the open tooltip must select `[data-slot=tooltip-content][data-open]`;
167
+ the bare `[data-slot=tooltip-content]` can match the closing one, and
168
+ reads the wrong text:
169
+
170
+ ```ts
171
+ await page.getByRole('img', { name: 'Home, Finance', exact: true }).focus()
172
+ await expect(page.locator('[data-slot=tooltip-content][data-open]')).toHaveText('Home, Finance')
173
+ ```
112
174
 
113
175
  ## 5. Enum → tone-mapped badge
114
176
 
@@ -116,6 +178,11 @@ A many-valued enum that collapses to good / neutral / bad. **The grouping
116
178
  is the decision, not the style** — see
117
179
  [`design-language.md`](design-language.md#mapping-an-enum-to-tones).
118
180
 
181
+ An **ordered** status (a lifecycle every record moves through toward
182
+ *done*) reads better as a stage circle beside the word (pattern 19);
183
+ the tone badge stays right for a status with no order, or one whose end
184
+ isn't *done*.
185
+
119
186
  Keep a separate short-label map when the API strings are full sentences
120
187
  (`"Yes – Full Crossplay"` → `Full`): read once in a dropdown they're right,
121
188
  read down 25 rows under a column header they're noise.
@@ -232,6 +299,40 @@ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
232
299
  can't be sorted server-side without a backend change
233
300
  (`enableSorting: false`).
234
301
 
302
+ ### A second example: what a row depends on
303
+
304
+ The records a row waits on (parts it needs, a date that has to pass,
305
+ other records that have to finish), summarised in the cell, listed in
306
+ the popover. The package's Storybook renders it
307
+ (`patterns/CellPatterns`, "Dependency list").
308
+
309
+ - **The cell: a computed summary.** "Ready" when nothing holds the row,
310
+ else the count of what does ("1 hold", "2 holds"). Compute it where the
311
+ data is: the backend, when the column sorts or filters on it.
312
+ - **The popover: every related record, grouped by kind**, each kind
313
+ under a small heading (`type-caption text-muted-foreground`) in its own
314
+ labelled group, in a fixed order. Each record keeps its kind's glyph
315
+ (`aria-hidden`, beside the name), so the kinds stay apart in
316
+ greyscale.
317
+ - **Held reads dark, with its reason; clear reads muted.** A held record
318
+ is `text-foreground` with why after its name ("Bulb: unavailable",
319
+ "Delivery day: hasn't happened", or the blocking record's status);
320
+ a clear one is `text-muted-foreground` with a visually hidden ": clear",
321
+ so a screen reader hears the state the colour shows. No tone: a hold
322
+ is the normal state of waiting, not an error, and the count already
323
+ says how many.
324
+ - **Name the popup**: `<PopoverContent aria-label={`What ${name} depends on`}>`.
325
+ It's a `dialog`, and axe fails one with no name.
326
+ - **A row that links nothing has nothing to open**: render the summary as
327
+ plain text (`<span>Ready</span>`), not a button. A button that opens an
328
+ empty popover is a dead end.
329
+ - **The trigger reads as the value**, with a dotted underline
330
+ (`underline decoration-dotted underline-offset-4`) and a focus ring,
331
+ so it looks openable without becoming a column of buttons.
332
+ - The trigger's name starts with the summary it shows
333
+ (`aria-label={`${summary}: what ${name} depends on`}`), so it's still
334
+ found by its visible text, and says which row.
335
+
235
336
  ## 11. Number or currency — *Unproven*
236
337
 
237
338
  A count, a quantity, a price.
@@ -358,6 +459,91 @@ now", "happened"). Widget's In Stock column is the reference
358
459
  - **Cost:** one click changes data, with no confirmation. Only for values
359
460
  that are cheap to flip back; never for anything destructive.
360
461
 
462
+ ### A variant: a pressed icon inside another value's cell
463
+
464
+ A yes/no that belongs to another value rather than having a column of
465
+ its own: a "focus" flag that only exists while the status is "doing", a
466
+ "pinned" mark on an open item. It's a small icon button inside the owning
467
+ value's cell, beside its word. The package's Storybook renders it
468
+ (`patterns/CellPatterns`, "Pressed icon in a cell").
469
+
470
+ **Pick it over the Switch** when the yes/no only means something while
471
+ another value allows it, or when a Switch and its words would crowd that
472
+ value's cell. A yes/no that stands on its own keeps its own column and
473
+ the Switch.
474
+
475
+ ```tsx
476
+ // Off: faint. On: the destructive text tone, filled. The primitive's
477
+ // pressed background is dropped: the icon carries the value.
478
+ const FLAG_TOGGLE_CLASS =
479
+ 'text-muted-foreground/80 hover:text-muted-foreground aria-pressed:bg-transparent ' +
480
+ 'aria-pressed:text-destructive-text'
481
+
482
+ // <entity>-focus-toggle.tsx, rendered inside the status cell
483
+ export function FocusToggle({ item }: { item: Item }) {
484
+ const save = useSaveItemField(item.id) // useRecordUpdate, like Widget's useSaveWidgetField
485
+ return (
486
+ <>
487
+ <Toggle size="icon-xs" pressed={item.focus} aria-label={`Focus: ${item.name}`}
488
+ className={FLAG_TOGGLE_CLASS} onPressedChange={(focus) => save.mutate({ focus })}>
489
+ <FlagIcon className="group-aria-pressed/toggle:fill-current" />
490
+ </Toggle>
491
+ <span className="inline-flex size-3.5 shrink-0">
492
+ {save.isPending && <Spinner aria-label="Saving" className="size-3.5 text-muted-foreground" />}
493
+ </span>
494
+ </>
495
+ )
496
+ }
497
+
498
+ // The status cell: the word stays, the toggle only while the status allows it.
499
+ <span className="inline-flex items-center gap-1.5">
500
+ <StageCircle {...STATUS_STAGE[item.status]} />
501
+ {STATUS_LABEL[item.status]}
502
+ {item.status === 'doing' && <FocusToggle item={item} />}
503
+ </span>
504
+ ```
505
+
506
+ - **`Toggle` from the package** (`/ui/toggle`, shadcn's, on Base UI), at
507
+ `size="icon-xs"`: a 24px target, the smallest WCAG 2.2 allows, which
508
+ keeps the row's height. It's `aria-pressed` by itself.
509
+ - **Named for the row, not the state**: `Focus: <record name>`. The
510
+ pressed state is announced on its own; a name that changes with it
511
+ ("Unfocus") reads twice.
512
+ - **The icon carries the value.** Filled when on
513
+ (`group-aria-pressed/toggle:fill-current`), outline when off, so it
514
+ reads in greyscale. Pick a glyph with an area to fill (`FlagIcon`,
515
+ `StarIcon`, `PinIcon`), not a line.
516
+ - **Faint, but never below 3:1.** Off is `text-muted-foreground/80`, the
517
+ faintest step that still clears WCAG's 3:1 non-text minimum in light
518
+ mode (3.2:1; dark is 5.2:1). `/70` measures 2.7:1, and a control a
519
+ reader can't see fails them. Hover lifts it to full
520
+ `muted-foreground`. axe doesn't measure icons, so the package's
521
+ Storybook glyph check does.
522
+ - **Tone: `destructive-text`, read as "flagged", not "error".** A flag
523
+ claims attention ahead of the rest of the row, and the destructive
524
+ tone is the one with that pull. The other tones are taken where it
525
+ sits: `info` is the in-progress status it belongs to (an info flag
526
+ beside an info stage disappears), `success` reads as done, and
527
+ `warning` says something may be wrong. It stays honest because it's
528
+ rare (a flag nearly every row carries is the "about a third" limit
529
+ broken) and because no word beside it says error. If a flag is common,
530
+ or does mean "something's wrong", it's an enum with a verdict
531
+ (pattern 5) instead.
532
+ - **It saves on its own, through `useRecordUpdate`**, like the Switch: the
533
+ new value shows at once, and a refusal (a cap the server enforces)
534
+ puts it back with the toast giving the server's reason. The hook does
535
+ both; add nothing.
536
+ - **The "Saving" slot is always there**, after the toggle, so nothing
537
+ moves while it saves, and nothing is disabled.
538
+ - **Only rendered while the owning value allows it.** When the status
539
+ moves on, the toggle goes; what the server does with the flag then (it
540
+ clears it, or keeps it for next time) is the plan's rule, and shows
541
+ through the record it returns.
542
+ - **On the record's view** it's the same component beside the owning
543
+ value in the header, as a quick action (pattern 17): the plan names it
544
+ under `Quick actions`. There its name is the field's label alone
545
+ (`Focus`), like the view's other controls.
546
+
361
547
  ## 16. Title linking to the record's view
362
548
 
363
549
  The column that names a record (its name or title), when the entity has a
@@ -415,6 +601,7 @@ type or pick from a list (text, a status) edits in place instead
415
601
  | The plan's field | The control | Accessible name |
416
602
  |---|---|---|
417
603
  | `yes/no`, in the header or a field row | A `Switch` (`size="sm"`) with the value in words beside it, `aria-hidden` (pattern 15) | The field's label: `In stock` |
604
+ | A `yes/no` that belongs to another value (pattern 15's variant), beside that value in the header | The same pressed icon `Toggle` as in the table's cell | The field's label: `Focus` |
418
605
  | A sub-record list's `yes/no` item field | Each item a `Checkbox` inside a `<label>` with the item's text, so the text ticks it too; the list in one `role="group"` named for the list, described by the done-count | `Done: <item text>` (a visually hidden `Done: ` inside the label) |
419
606
 
420
607
  ```tsx
@@ -558,3 +745,71 @@ const price = editInPlace({
558
745
  - **Cost:** a value that looks like text but edits on click can surprise.
559
746
  Keep it to the fields the plan names, and leave the form for everything
560
747
  else.
748
+
749
+ ## 19. Stage circle for an ordered status
750
+
751
+ A status that's an ordered lifecycle (each record moves through the same
752
+ stages, in the same order, toward one that means *done*), with one or
753
+ more exit states off the side. A small circle beside the word fills as
754
+ the record moves on. The package ships it: `StageCircle`, rendered with
755
+ every stage and tone in its Storybook (`app/StageCircle`).
756
+
757
+ | Stage (`stage`) | Drawn as | Say it for |
758
+ |---|---|---|
759
+ | `0` | a dashed outline | the first stage: not started |
760
+ | `1`, `2`, `3` | the outline with a quarter, a half, three quarters filled | a stage under way, in order |
761
+ | `'complete'` | a solid circle with a tick | the final stage: done |
762
+ | `'exit'` | the outline, struck through | off the lifecycle: dropped, cancelled |
763
+
764
+ ```tsx
765
+ import { StageCircle, type StageCircleStage, type StageCircleTone } from '@tristan2828/ui-foundation'
766
+
767
+ // <entity>-format.ts: typed by the enum, so a new value can't go unmapped.
768
+ const STATUS_STAGE: Record<ItemStatus, { stage: StageCircleStage; tone?: StageCircleTone }> = {
769
+ idea: { stage: 0 },
770
+ considering: { stage: 1 },
771
+ doing: { stage: 2, tone: 'info' },
772
+ done: { stage: 'complete', tone: 'success' },
773
+ dropped: { stage: 'exit' },
774
+ }
775
+
776
+ cell: ({ getValue }) => {
777
+ const status = getValue() as ItemStatus
778
+ return (
779
+ <span className="inline-flex items-center gap-1.5">
780
+ <StageCircle {...STATUS_STAGE[status]} />
781
+ {STATUS_LABEL[status]}
782
+ </span>
783
+ )
784
+ }
785
+ ```
786
+
787
+ - **The word stays**, in plain `text-foreground`. The circle is
788
+ `aria-hidden`; the word is what's read, and what a reader who doesn't
789
+ know the shapes yet goes by.
790
+ - **The shape carries the stage, in greyscale.** Dashed, filling, solid,
791
+ struck through: the stage reads from the shape alone, and the tone only
792
+ reinforces it.
793
+ - **Tone it like pattern 5:** the stage in progress is `info` ("notice
794
+ this"), complete is `success`, and the rest stay `muted` (the default).
795
+ The tones are the `*-text` shades, so the circle clears 3:1 on the page
796
+ in both themes, measured by the package's glyph check. An exit is
797
+ muted: dropped is an end, not a failure. Give it `destructive` only if
798
+ the exit really is bad news (failed, rejected).
799
+ - **Map the stages in order**, the first `0`, the final `'complete'`, the
800
+ ones between `1`–`3` in order. Two middle stages fill a quarter and a
801
+ half; three, up to three quarters. More than three stages between
802
+ first and done is more than a fill can tell apart: use the tone badge.
803
+ - **Stage circle or tone badge (pattern 5)?** The circle is for an
804
+ *ordered* status: every record goes the same way, and "how far along
805
+ toward done" is the question. A status with no order (`active` /
806
+ `paused` / `blocked`), or one whose end isn't *done*, keeps pattern 5.
807
+ Widget's Status is the second kind: `draft` → `active` → `archived` is
808
+ an order in time, but `archived` is retirement, not completion, and its
809
+ good state is the middle one. A tick on `archived` would say "done"
810
+ where nothing was finished, so it stays a tone badge.
811
+ - **On the record's view** it's the same markup in the header, beside the
812
+ title; editing the status in place (pattern 18) wraps it in
813
+ `<EditableValue>` like a badge.
814
+ - **Cost:** a reader learns the shapes once. Until then the word does the
815
+ work, which is why it stays.
@@ -82,6 +82,24 @@ Two rules that keep this honest:
82
82
  greyscale screenshot and a printed page all lose it. The label text
83
83
  usually carries the meaning already; where a cell is a glyph rather than
84
84
  a word, vary the *shape* (filled / half / slashed) too, not just the hue.
85
+ The package's `StageCircle` does this for an ordered status: dashed,
86
+ filling, solid with a tick, struck through
87
+ ([`cell-patterns.md`](cell-patterns.md) pattern 19).
88
+
89
+ ## Glyphs and icons: 3:1
90
+
91
+ An icon that carries meaning on its own (a stage circle, a category's
92
+ icon, a pressed flag) must clear WCAG's **3:1 non-text minimum** against
93
+ the background behind it, in both themes. axe measures text only, so the
94
+ package's Storybook has a glyph check of its own: every `<svg>` in its
95
+ design-language stories, its ink (with any opacity modifier painted over
96
+ its background) against what's behind it. Every `*-text` tone, every
97
+ category slot and `muted-foreground` clear it as they are. An opacity
98
+ modifier is where it goes wrong: `text-muted-foreground/80` is the
99
+ faintest step that still clears it in light mode (3.2:1), and `/70`
100
+ doesn't (2.7:1). A deliberately faint icon that carries nothing on its
101
+ own (an "empty" star beside a rating's filled ones, pattern 9) may go
102
+ lower; say so in a comment.
85
103
 
86
104
  ## Categorical colour
87
105
 
@@ -119,10 +137,11 @@ Four things about them:
119
137
  `text-foreground`, so readability never depends on the hue. A slot used
120
138
  as a background needs its own contrast check, which nothing here does
121
139
  for you.
122
- - **Nothing re-measures them automatically.** All eight clear the 3:1
123
- non-text minimum in both themes (measured, recorded in `theme.css`), but
124
- axe checks text contrast, not icon contrast — so a change to these
125
- values is not caught by any gate.
140
+ - **The glyph check re-measures them.** All eight clear the 3:1
141
+ non-text minimum in both themes (recorded in `theme.css`). axe checks
142
+ text contrast, not icon contrast, so the package's Storybook glyph check
143
+ draws an icon in each slot (`patterns/CellPatterns`, "Icons with one
144
+ tooltip") and fails a change that takes one below 3:1.
126
145
 
127
146
  If you find yourself wanting a ninth, that is usually a sign the column
128
147
  should be showing a shape or a label rather than more colours.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Where the record is: `0` the first stage (a dashed outline), `1`–`3` a
3
+ * stage under way (a quarter, a half, three quarters filled), `'complete'`
4
+ * the final stage (solid, with a tick), `'exit'` off the lifecycle (struck
5
+ * through: dropped, cancelled).
6
+ */
7
+ export type StageCircleStage = 0 | 1 | 2 | 3 | 'complete' | 'exit';
8
+ /**
9
+ * The tone, as text on the page (`*-text` tokens). `muted` is the default:
10
+ * colour only the stages that mean something (in progress `info`,
11
+ * complete `success`), like a tone-mapped badge (cell pattern 5).
12
+ */
13
+ export type StageCircleTone = 'muted' | 'info' | 'success' | 'warning' | 'destructive';
14
+ export interface StageCircleProps {
15
+ stage: StageCircleStage;
16
+ tone?: StageCircleTone;
17
+ className?: string;
18
+ }
19
+ /** The glyph only: put the status word beside it. Hidden from screen readers, which read the word. */
20
+ export declare function StageCircle({ stage, tone, className }: StageCircleProps): import("react").JSX.Element;
@@ -0,0 +1,30 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A stage circle (cell pattern 19): where a record is in an ordered
3
+ // lifecycle, as a small circle that fills as it moves on. It sits beside
4
+ // the status word, which stays: the shape carries the stage in greyscale
5
+ // and the tone only reinforces it.
6
+ //
7
+ // Lucide has no part-filled circle, so it's drawn here, in currentColor
8
+ // (16×16 viewBox, the ring and fill in the tone, the tick cut out of the
9
+ // solid circle in the page's background).
10
+ import { cn } from 'cn';
11
+ const TONE_CLASS = {
12
+ muted: 'text-muted-foreground',
13
+ info: 'text-info-text',
14
+ success: 'text-success-text',
15
+ warning: 'text-warning-text',
16
+ destructive: 'text-destructive-text',
17
+ };
18
+ // The filled part of a stage under way: a pie from twelve o'clock,
19
+ // clockwise, inside the ring with a gap so it reads as filling, not as a
20
+ // thicker ring.
21
+ const PIE = {
22
+ 1: 'M8 8V4.5A3.5 3.5 0 0 1 11.5 8Z',
23
+ 2: 'M8 8V4.5A3.5 3.5 0 0 1 8 11.5Z',
24
+ 3: 'M8 8V4.5A3.5 3.5 0 1 1 4.5 8Z',
25
+ };
26
+ const RING = { cx: 8, cy: 8, r: 6, strokeWidth: 1.5, className: 'fill-none stroke-current' };
27
+ /** The glyph only: put the status word beside it. Hidden from screen readers, which read the word. */
28
+ export function StageCircle({ stage, tone = 'muted', className }) {
29
+ return (_jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": "true", "data-slot": "stage-circle", "data-stage": stage, className: cn('size-4 shrink-0', TONE_CLASS[tone], className), children: [stage === 0 && _jsx("circle", { ...RING, strokeDasharray: "2.4 2.3" }), (stage === 1 || stage === 2 || stage === 3) && (_jsxs(_Fragment, { children: [_jsx("circle", { ...RING }), _jsx("path", { d: PIE[stage], className: "fill-current" })] })), stage === 'complete' && (_jsxs(_Fragment, { children: [_jsx("circle", { cx: "8", cy: "8", r: "7", className: "fill-current" }), _jsx("path", { d: "M5 8.2 7.1 10.2 11 6", className: "fill-none stroke-background", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" })] })), stage === 'exit' && (_jsxs(_Fragment, { children: [_jsx("circle", { ...RING }), _jsx("path", { d: "M4 12 12 4", className: "fill-none stroke-current", strokeWidth: "1.5", strokeLinecap: "round" })] }))] }));
30
+ }
@@ -1,7 +1,7 @@
1
1
  import { useRender } from "@base-ui/react/use-render";
2
2
  import { type VariantProps } from "class-variance-authority";
3
3
  declare const badgeVariants: (props?: ({
4
- variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "warning" | "info" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
4
+ variant?: "link" | "success" | "default" | "outline" | "secondary" | "ghost" | "destructive" | "info" | "warning" | "outline-success" | "outline-warning" | "outline-info" | "outline-destructive" | "tinted-success" | "tinted-warning" | "tinted-info" | "tinted-destructive" | null | undefined;
5
5
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
6
6
  declare function Badge({ className, variant, render, ...props }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>): import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>>;
7
7
  export { Badge, badgeVariants };
@@ -0,0 +1,8 @@
1
+ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
2
+ import { type VariantProps } from "class-variance-authority";
3
+ declare const toggleVariants: (props?: ({
4
+ variant?: "default" | "outline" | null | undefined;
5
+ size?: "default" | "sm" | "lg" | "icon-xs" | "icon-sm" | null | undefined;
6
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
+ declare function Toggle({ className, variant, size, ...props }: TogglePrimitive.Props & VariantProps<typeof toggleVariants>): import("react").JSX.Element;
8
+ export { Toggle, toggleVariants };
@@ -0,0 +1,33 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
3
+ import { cva } from "class-variance-authority";
4
+ import { cn } from "cn";
5
+ // The `icon-xs`/`icon-sm` sizes are this repo's patch to the upstream
6
+ // primitive, matching Button's: a square toggle holding one icon, like the
7
+ // pressed flag inside a table cell (cell pattern 15). A local patch:
8
+ // re-running `shadcn add toggle` here would overwrite it. Diff against
9
+ // upstream before taking a new version.
10
+ const toggleVariants = cva("group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", {
11
+ variants: {
12
+ variant: {
13
+ default: "bg-transparent",
14
+ outline: "border border-input bg-transparent hover:bg-muted",
15
+ },
16
+ size: {
17
+ default: "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
18
+ sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
19
+ lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
20
+ // 24px: the smallest target WCAG 2.2 allows (2.5.8).
21
+ "icon-xs": "size-6 min-w-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*='size-'])]:size-3.5",
22
+ "icon-sm": "size-7 min-w-7 rounded-[min(var(--radius-md),12px)]",
23
+ },
24
+ },
25
+ defaultVariants: {
26
+ variant: "default",
27
+ size: "default",
28
+ },
29
+ });
30
+ function Toggle({ className, variant = "default", size = "default", ...props }) {
31
+ return (_jsx(TogglePrimitive, { "data-slot": "toggle", className: cn(toggleVariants({ variant, size, className })), ...props }));
32
+ }
33
+ export { Toggle, toggleVariants };
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export { ListEditor } from './components/app/list-editor.js';
14
14
  export { PasswordInput } from './components/app/password-input.js';
15
15
  export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-text-editor.js';
16
16
  export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
17
+ export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
17
18
  export { ThemeProvider } from './components/theme-provider.js';
18
19
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
19
20
  export { RegisterRoute } from './routes/register.js';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export { ListEditor } from './components/app/list-editor.js';
19
19
  export { PasswordInput } from './components/app/password-input.js';
20
20
  export { RichTextEditor } from './components/app/rich-text-editor.js';
21
21
  export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
22
+ export { StageCircle, } from './components/app/stage-circle.js';
22
23
  export { ThemeProvider } from './components/theme-provider.js';
23
24
  // Screens outside the app shell
24
25
  export { LoginRoute } from './routes/login.js';
@@ -169,13 +169,14 @@ export function mergeMarkdown(original, baseline, current) {
169
169
  }
170
170
  out += 'original' in item ? originals[item.original].source : item.written.source;
171
171
  });
172
- const last = items[items.length - 1];
173
- if (last && 'original' in last && last.original === originals.length - 1) {
174
- out += original.slice(originals[originals.length - 1].end);
175
- }
176
- else if (items.length > 0) {
177
- out += '\n';
178
- }
172
+ // The document ends the way the original ended, whichever block is last
173
+ // now: an edit to the last block adds no newline the person didn't type.
174
+ // Text written from nothing ends the way the editor ends it.
175
+ const ending = originals.length > 0
176
+ ? original.slice(originals[originals.length - 1].end)
177
+ : current.slice(edited.at(-1)?.end ?? current.length);
178
+ if (items.length > 0)
179
+ out += ending;
179
180
  const saved = blocks(out)
180
181
  .filter((block) => block.type !== 'definition')
181
182
  .map((block) => block.key);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {