@vegastack/design 0.3.2 → 0.4.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/dist/index.d.cts CHANGED
@@ -1,5 +1,79 @@
1
1
  import { ClassValue } from 'clsx';
2
2
  export { ClassValue } from 'clsx';
3
+ import * as React from 'react';
4
+
5
+ /**
6
+ * THE prose recipe — one token vocabulary for rendered rich text (audit B4-09, 2026-09-07).
7
+ *
8
+ * Two surfaces render prose the system did not author element-by-element: `MarkdownView`
9
+ * (react-markdown turns a markdown string into plain HTML elements) and `TextEdit` (ProseMirror
10
+ * owns the contenteditable DOM). Before this file each restated the same heading/paragraph/list/
11
+ * code/quote recipe in its own grammar — a `Components` map in one, 69 `[&_h1]:` rules in the
12
+ * other — and they had already drifted (`h4`–`h6`, `table`, `img` existed on one side only).
13
+ *
14
+ * ## Why descendant variants and not per-element classes
15
+ *
16
+ * A prose root cannot put a class on every element: ProseMirror generates the editor's DOM, and
17
+ * react-markdown's output is only reachable through an override map. Descendant variants
18
+ * (`[&_h1]:mt-6` → `.recipe h1 { margin-top: … }`) style both from ONE string on the root, which
19
+ * is what makes "MarkdownView and TextEdit render the same computed styles" true by construction
20
+ * rather than by review.
21
+ *
22
+ * It also settles a cascade trap the two-grammar version would have re-created: `[&_h1]:mt-6`
23
+ * compiles at specificity (0,1,1) and a plain `.mt-6` on the element at (0,1,0), so an
24
+ * element-level class silently LOSES to a root-level descendant rule. The two forms cannot
25
+ * coexist on one tree; the root form is the one that works for both consumers.
26
+ *
27
+ * Every value is a literal so Tailwind's scanner sees it in this file — and in the shipped `dist`,
28
+ * which `preset.css` scans through `@source "./dist"` (the same contract `surfaceInteractive` and
29
+ * `fillInteractive` rely on).
30
+ *
31
+ * Logical properties throughout (`ms`/`ps`/`border-s`/`text-start`): prose is the surface most
32
+ * likely to carry translated content, and the contract lane asserts RTL containment.
33
+ *
34
+ * @example
35
+ * // A prose root — the whole recipe:
36
+ * <div className={cn(proseClassName, className)} />
37
+ *
38
+ * @example
39
+ * // One element's rules, composed into a narrower surface:
40
+ * <div className={cn(prose.root, prose.p, prose.code)} />
41
+ */
42
+ declare const prose: {
43
+ /** The root's own ink and size — every rule below is relative to this. */
44
+ readonly root: "text-base text-foreground";
45
+ readonly h1: "[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:scroll-m-20 [&_h1]:text-h1 [&_h1]:text-foreground [&_h1]:first:mt-0";
46
+ readonly h2: "[&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:scroll-m-20 [&_h2]:text-h2 [&_h2]:text-foreground [&_h2]:first:mt-0";
47
+ readonly h3: "[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:scroll-m-20 [&_h3]:text-h3 [&_h3]:text-foreground [&_h3]:first:mt-0";
48
+ readonly h4: "[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:scroll-m-20 [&_h4]:text-h4 [&_h4]:text-foreground [&_h4]:first:mt-0";
49
+ readonly h5: "[&_h5]:mt-4 [&_h5]:mb-2 [&_h5]:text-label [&_h5]:text-foreground [&_h5]:first:mt-0";
50
+ readonly h6: "[&_h6]:mt-4 [&_h6]:mb-2 [&_h6]:text-label [&_h6]:text-muted-foreground [&_h6]:first:mt-0";
51
+ readonly p: "[&_p]:my-3 [&_p]:leading-relaxed [&_p]:text-foreground [&_p]:first:mt-0 [&_p]:last:mb-0";
52
+ readonly a: "[&_a]:font-medium [&_a]:text-info-text [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-info-text/(--alpha-link-hover)";
53
+ readonly strong: "[&_strong]:font-medium [&_strong]:text-foreground";
54
+ readonly em: "[&_em]:italic";
55
+ readonly del: "[&_del]:text-muted-foreground [&_del]:line-through [&_s]:text-muted-foreground [&_s]:line-through";
56
+ readonly ul: "[&_ul]:my-3 [&_ul]:ms-6 [&_ul]:list-disc [&_ul]:text-foreground [&_ul]:marker:text-muted-foreground [&_ul.contains-task-list]:list-none";
57
+ readonly ol: "[&_ol]:my-3 [&_ol]:ms-6 [&_ol]:list-decimal [&_ol]:text-foreground [&_ol]:marker:text-muted-foreground";
58
+ readonly li: "[&_li]:mt-1.5 [&_li]:leading-relaxed";
59
+ readonly blockquote: "[&_blockquote]:my-3 [&_blockquote]:border-s-2 [&_blockquote]:border-border [&_blockquote]:ps-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic";
60
+ readonly code: "[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-base [&_code]:text-foreground";
61
+ readonly pre: "[&_pre:not([data-slot='code-block-pre'])]:my-3 [&_pre:not([data-slot='code-block-pre'])]:overflow-x-auto [&_pre:not([data-slot='code-block-pre'])]:rounded-lg [&_pre:not([data-slot='code-block-pre'])]:border [&_pre:not([data-slot='code-block-pre'])]:border-border [&_pre:not([data-slot='code-block-pre'])]:bg-muted [&_pre:not([data-slot='code-block-pre'])]:p-4 [&_pre:not([data-slot='code-block-pre'])]:text-foreground";
62
+ readonly preCode: "[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:font-mono [&_pre_code]:text-code [&_pre_code]:text-foreground";
63
+ readonly hr: "[&_hr]:my-6 [&_hr]:border-border";
64
+ readonly table: "[&_table]:w-full [&_table]:border-collapse [&_table]:text-base [&_table]:text-foreground [&_thead]:border-b [&_thead]:border-border [&_tr]:border-b [&_tr]:border-border [&_tr]:last:border-0 [&_th]:px-3 [&_th]:py-2 [&_th]:text-start [&_th]:font-medium [&_th]:text-foreground [&_td]:px-3 [&_td]:py-2 [&_td]:text-muted-foreground";
65
+ readonly img: "[&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:border [&_img]:border-border";
66
+ };
67
+ /** The element roles the prose recipe covers. */
68
+ type ProseElement = keyof typeof prose;
69
+ /**
70
+ * The whole recipe as one class string — what a prose root wears.
71
+ *
72
+ * Joined rather than merged: every entry scopes a different selector, so there is no conflict for
73
+ * `cn` to resolve, and a plain join keeps the composition free of a `tailwind-merge` pass on a
74
+ * string that never changes.
75
+ */
76
+ declare const proseClassName: string;
3
77
 
4
78
  /**
5
79
  * Merges Tailwind CSS class names with intelligent conflict resolution.
@@ -16,6 +90,160 @@ export { ClassValue } from 'clsx';
16
90
  */
17
91
  declare function cn(...inputs: ClassValue[]): string;
18
92
 
93
+ /**
94
+ * THE hover/pressed recipe for a control that sits on a KNOWN surface (page, card, popover, well):
95
+ * hover climbs one rung of the surface ladder, pressing climbs one more. Rows, menu items, ghost and
96
+ * outline buttons, sidebar buttons, toggles, tabs, table rows, pagination — every transparent
97
+ * control — spread this string instead of writing a `hover:bg-*` literal.
98
+ *
99
+ * `hover:` compiles under `@media (hover: hover)` in Tailwind v4, so touch devices keep the rest
100
+ * fill and still get the pressed rung through `active:`. A selected/current state is the SAME rung
101
+ * as pressed (`data-selected:bg-surface-3`), which is why the two are never far apart.
102
+ *
103
+ * @example
104
+ * <button className={cn("rounded-md px-2", surfaceInteractive)} />
105
+ */
106
+ declare const surfaceInteractive = "hover:bg-surface-2 active:bg-surface-3";
107
+ /**
108
+ * The GROUP-SCOPED twin of {@link surfaceInteractive}, for the one geometry where the two rungs
109
+ * cannot live on the interactive element itself: a wash painted by an INNER chip inset from a
110
+ * container hairline (`design.md` §Hover geometry — "a wash is inset ≥4px from a container hairline
111
+ * and inherits its inner radius"). NumberField's steppers are the case: the button is full-height and
112
+ * flush to the field's border, so its own background would run into that hairline; a `size-full` chip
113
+ * inside the button's `p-1` paints the inset wash instead, and it must react to the BUTTON's hover.
114
+ *
115
+ * The group is named `wash` rather than left unnamed so a consumer's own `group` on an ancestor of a
116
+ * copied-in component cannot fire it. Put `group/wash` on the interactive element, this string on the
117
+ * chip. Everything else spreads {@link surfaceInteractive} directly — a group indirection where the
118
+ * element can carry the rungs itself is noise.
119
+ *
120
+ * @example
121
+ * <button className="group/wash p-1">
122
+ * <span className={cn("size-full rounded-sm", surfaceInteractiveGroup)} />
123
+ * </button>
124
+ */
125
+ declare const surfaceInteractiveGroup = "group-hover/wash:bg-surface-2 group-active/wash:bg-surface-3";
126
+ /**
127
+ * The inks a translucent hover/pressed wash can be composited from — the neutral ink and the five
128
+ * chromatic families the Button matrix and its outline/soft variants use.
129
+ */
130
+ type FillTone = "foreground" | "primary" | "destructive" | "success" | "warning" | "info" | "brand";
131
+ /**
132
+ * The ALPHA twin of {@link surfaceInteractive}: the same two rungs composited from an ink at
133
+ * `--alpha-hover` / `--alpha-pressed`, for a control whose backdrop is not a ladder surface (a kbd
134
+ * inside a hovered row, a chip on a well, chrome over media) or one that hovers in its OWN hue (the
135
+ * outline/soft status buttons). `foreground` is the neutral twin, and it is anchored to a DIFFERENT
136
+ * host per theme: in light it lands within 0.003 L of `surface-2`/`surface-3` over the page
137
+ * (measured L 0.9430 vs 0.945 and 0.9210 vs 0.922), but the dark ladder is CARD-anchored — over the
138
+ * dark `card` it is within 0.003 (0.2665 vs 0.269, 0.2918 vs 0.290), while over the dark
139
+ * `background` it is Δ0.028 / Δ0.023, a full rung off (measured 2026-09-09, LOW-7). Read that as
140
+ * the constraint it is: the alpha twin substitutes for the opaque rung on the surface a control of
141
+ * that theme actually sits on. It is AA-gated over page, card and popover in both themes.
142
+ *
143
+ * Solid fills do NOT use this: a solid already owns its darker `<tone>-hover` / `<tone>-active`
144
+ * steps (`bg-primary hover:bg-primary-hover active:bg-primary-active`) — an alpha over a solid
145
+ * would only thin it.
146
+ *
147
+ * Every value is a literal so Tailwind's scanner sees it in this file (and in the shipped `dist`,
148
+ * which `preset.css` scans).
149
+ *
150
+ * @example
151
+ * <button className={cn("bg-destructive-subtle text-destructive-text", fillInteractive.destructive)} />
152
+ */
153
+ declare const fillInteractive: Record<FillTone, string>;
154
+ /**
155
+ * THE field chrome — the one border/fill/hover/focus/invalid/disabled grammar every text-entry
156
+ * control wears (audit B1-11, 2026-09-07). Input, Textarea, NumberField's input, OTP slots, the
157
+ * Select trigger and the Combobox input all spread this string; before it existed the same nine
158
+ * declarations were copy-pasted in four files and restated a fifth time as slot overrides, so
159
+ * retuning the field meant finding every copy.
160
+ *
161
+ * It is CHROME only — no width, padding, height or type. Those differ per control (a square OTP
162
+ * slot is not `w-full`; a Textarea sizes by min-height, not `--size-*`), so each component adds its
163
+ * own layout and size classes after this string.
164
+ *
165
+ * The three border rungs, in ascending weight:
166
+ * - rest `border-input` (the derived `foreground` alpha hairline),
167
+ * - hover `foreground` at `--alpha-border-subtle` — neutral ink, one step darker, guarded by
168
+ * `not-disabled:not-data-disabled:` because D7 keeps pointer events ON a disabled control so a
169
+ * Tooltip can explain it, which would otherwise let a dead field light up under the cursor,
170
+ * - focus `ring` at `--alpha-tint-border`, on plain `focus` (not `focus-visible`) — a raw text field
171
+ * cannot tell mouse from keyboard, so the tint is the one cue for both. Forced colours erase a
172
+ * border tint outright, so the outline fallback for that case is written ONCE, unlayered, in
173
+ * `@vegastack/design-tokens`' `base.css` — never per component.
174
+ *
175
+ * **FOCUS OUTRANKS INVALID, and it has to be said in the selector** (#100, 2026-09-09). The invalid
176
+ * tint and the focus tint are the same property at the same specificity, and Tailwind v4 emits
177
+ * `aria-invalid:`/`data-invalid:` AFTER `focus:`, so an invalid field simply kept its destructive
178
+ * border when focused. Text entry carries `outline-hidden`, so that border IS the whole affordance:
179
+ * a focused invalid field had NO focus indicator at all, which is a WCAG 2.2 §2.4.7 failure the
180
+ * geometry lane's focus assertion found on its first run. `not-focus:` makes the invalid tint stand
181
+ * down while the field is focused rather than fighting the cascade — the error is still carried by
182
+ * `aria-invalid`, by Field's message and icon, and by the tint returning on blur, whereas focus has
183
+ * exactly one channel. design.md § Accessibility ("focus is the neutral `ring`, never a colour")
184
+ * settles which one owns the border when both want it.
185
+ *
186
+ * @example
187
+ * <input className={cn(fieldControl, "h-(--size-md) w-full min-w-0 px-3 text-base")} />
188
+ */
189
+ declare const fieldControl: string;
190
+ /**
191
+ * The WRAPPER twin of {@link fieldControl}: the identical chrome on a bordered group whose state
192
+ * comes from a descendant — Input's prefix/suffix group, NumberField's stepper group, ChipInput and
193
+ * the Combobox input-group. Same three border rungs, read through `focus-within` / `has-*` /
194
+ * Base UI's `data-focused` instead of the control's own pseudo-classes.
195
+ *
196
+ * Every element carrying this string must also carry `data-field-group` (a bare attribute). That is
197
+ * the hook `base.css` uses to paint the forced-colours focus outline on the GROUP: the inner input's
198
+ * own outline would be clipped by the group's `overflow-hidden`, which is exactly how a High
199
+ * Contrast user lost the caret location on an addon field.
200
+ *
201
+ * @example
202
+ * <div data-field-group className={cn(fieldControlGroup, "flex h-(--size-md) items-center")} />
203
+ */
204
+ declare const fieldControlGroup: string;
205
+ /**
206
+ * THE selected-chip recipe — one formula for every "raised chip on a muted track" control:
207
+ * Tabs `pill` and `chip`, `Segmented`, `Toggle` pressed and `ToggleGroup` pressed. Before this
208
+ * existed the four wrote four different selected looks (`bg-background`, `bg-secondary` + hairline,
209
+ * `bg-foreground/10`); audit 2026-09-07 B6-02.
210
+ *
211
+ * The track is the ladder's well rung (`surface-1`); the chip is the ladder's alpha form of the
212
+ * pressed/selected step (§Surfaces) — `bg-foreground/(--alpha-ink-tint)`, which doctrine reaches
213
+ * for exactly here ("a chip on a well"). Over the `surface-1` track it composites to L 0.899 light
214
+ * / 0.318 dark, which is Δ0.023 / Δ0.028 PAST `surface-3` — a full extra rung, since the ladder's
215
+ * own step is 0.021–0.033 (measured 2026-09-09, LOW-6; the doc used to claim "within a hair of
216
+ * `surface-3`", which is only true of the twin composited over the PAGE, not over the track).
217
+ * That extra rung is deliberate and is what makes a selected chip read as raised off its own
218
+ * track rather than level with it. Being an alpha is also what lets the SELECTED chip keep stepping: a hovered
219
+ * selected chip strengthens to `--alpha-ink-tint-strong` and a pressed one drops back to the resting
220
+ * tint (previewing the release), so no state ever reads as dead — an opaque `surface-3` chip would
221
+ * have nowhere left to climb.
222
+ *
223
+ * Base UI spells "selected" differently per primitive, so the state rules ship as two literal
224
+ * strings rather than a selector parameter (Tailwind v4's scanner only sees literals):
225
+ * {@link selectedChipVariants.pressed} for Toggle/ToggleGroup/Segmented (`data-pressed`) and
226
+ * {@link selectedChipVariants.active} for Tabs (`data-active`). The unselected steps are guarded by
227
+ * the matching `not-*` variant so the two sets are mutually exclusive and never race on specificity.
228
+ *
229
+ * @example
230
+ * <div className={cn("rounded-md p-0.5", selectedChipVariants.track)}>
231
+ * <Toggle className={cn("rounded-sm", selectedChipVariants.item, selectedChipVariants.pressed)} />
232
+ * </div>
233
+ */
234
+ declare const selectedChipVariants: {
235
+ /** The muted track the chips sit in — the ladder's well rung. */
236
+ readonly track: "bg-surface-1";
237
+ /**
238
+ * Chrome shared by every chip: a transparent hairline reserved at rest (so selecting adds no
239
+ * layout shift) and the muted→ink text step.
240
+ */
241
+ readonly item: "border border-transparent hover:text-foreground";
242
+ /** Selected keyed on Base UI's `data-pressed` — Toggle, ToggleGroup, Segmented. */
243
+ readonly pressed: "not-data-pressed:hover:bg-foreground/(--alpha-hover) not-data-pressed:active:bg-foreground/(--alpha-pressed) data-pressed:border-border data-pressed:bg-foreground/(--alpha-ink-tint) data-pressed:text-foreground data-pressed:hover:bg-foreground/(--alpha-ink-tint-strong) data-pressed:active:bg-foreground/(--alpha-ink-tint)";
244
+ /** Selected keyed on Base UI's `data-active` — Tabs. */
245
+ readonly active: "not-data-[active]:hover:bg-foreground/(--alpha-hover) not-data-[active]:active:bg-foreground/(--alpha-pressed) data-[active]:border-border data-[active]:bg-foreground/(--alpha-ink-tint) data-[active]:text-foreground data-[active]:hover:bg-foreground/(--alpha-ink-tint-strong) data-[active]:active:bg-foreground/(--alpha-ink-tint)";
246
+ };
19
247
  /**
20
248
  * @internal Registry theme-scope plumbing lives at `@vegastack/design/theme-scope`, NOT here.
21
249
  * It calls `React.createContext()` at module scope, which is `undefined` under the `react-server`
@@ -37,6 +265,20 @@ declare const TIMINGS: {
37
265
  readonly hoverOpenDelayMs: 700;
38
266
  /** Hover delay before a rich preview closes — lets the pointer travel into the card. */
39
267
  readonly hoverCloseDelayMs: 300;
268
+ /**
269
+ * Hover delay before a tooltip opens. A tooltip is a cheap label, not a preview, so it is
270
+ * deliberately much faster than {@link TIMINGS.hoverOpenDelayMs} — long enough that sweeping
271
+ * across a toolbar does not flash five tips, short enough that a deliberate hover feels
272
+ * instant. Base UI's `Tooltip.Provider` grouping then opens adjacent tips with no delay at
273
+ * all. Set once, in the app-root provider.
274
+ */
275
+ readonly tooltipOpenDelayMs: 300;
276
+ /**
277
+ * Delay before a tooltip closes. Zero: a tooltip has nothing to travel into (it is not
278
+ * hoverable content, unlike a HoverCard), so holding it after the pointer leaves only
279
+ * obscures what the user moved on to.
280
+ */
281
+ readonly tooltipCloseDelayMs: 0;
40
282
  };
41
283
  /**
42
284
  * Floating-surface positioning constants (px) — the unified sideOffset/collisionPadding
@@ -53,4 +295,34 @@ declare const FLOATING: {
53
295
  readonly collisionPadding: 8;
54
296
  };
55
297
 
56
- export { FLOATING, TIMINGS, cn };
298
+ /**
299
+ * Fan one DOM node out to several refs — a forwarded `ref` prop plus one or more internal
300
+ * refs — as a single ref callback. Skips `null`/`undefined` entries, so an optional
301
+ * forwarded ref needs no guard at the call site. Handles both ref shapes React 19 accepts:
302
+ * a callback ref is invoked, an object ref has its `.current` assigned.
303
+ *
304
+ * This lives in `@vegastack/design` rather than in any one component because ref-as-prop
305
+ * (React 19, no `forwardRef`) makes "the component needs the node AND has to forward it"
306
+ * the normal case, not a special one — it was hand-inlined in nine registry files and
307
+ * exported from `use-animation-replay` before this. It touches no React runtime value
308
+ * (only ref objects the caller already holds), so it is server-safe like `cn`.
309
+ *
310
+ * **Not memoized.** Calling it produces a NEW function every time, and React detaches a
311
+ * changed ref callback (calls it with `null`) and reattaches it on every render. Wrap the
312
+ * CALL at the call site when the inputs are stable:
313
+ *
314
+ * @example
315
+ * const mergedRef = React.useMemo(() => mergeRefs(ref, internalRef), [ref]);
316
+ * return <input ref={mergedRef} />;
317
+ *
318
+ * @example
319
+ * // A ref callback that also does work — merge it with the forwarded ref
320
+ * const setRef = React.useCallback(
321
+ * (node: HTMLDivElement | null) => { setContainer(node); },
322
+ * [],
323
+ * );
324
+ * const mergedRef = React.useMemo(() => mergeRefs(ref, setRef), [ref, setRef]);
325
+ */
326
+ declare function mergeRefs<T>(...refs: Array<React.Ref<T> | null | undefined>): React.RefCallback<T>;
327
+
328
+ export { FLOATING, type FillTone, type ProseElement, TIMINGS, cn, fieldControl, fieldControlGroup, fillInteractive, mergeRefs, prose, proseClassName, selectedChipVariants, surfaceInteractive, surfaceInteractiveGroup };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,79 @@
1
1
  import { ClassValue } from 'clsx';
2
2
  export { ClassValue } from 'clsx';
3
+ import * as React from 'react';
4
+
5
+ /**
6
+ * THE prose recipe — one token vocabulary for rendered rich text (audit B4-09, 2026-09-07).
7
+ *
8
+ * Two surfaces render prose the system did not author element-by-element: `MarkdownView`
9
+ * (react-markdown turns a markdown string into plain HTML elements) and `TextEdit` (ProseMirror
10
+ * owns the contenteditable DOM). Before this file each restated the same heading/paragraph/list/
11
+ * code/quote recipe in its own grammar — a `Components` map in one, 69 `[&_h1]:` rules in the
12
+ * other — and they had already drifted (`h4`–`h6`, `table`, `img` existed on one side only).
13
+ *
14
+ * ## Why descendant variants and not per-element classes
15
+ *
16
+ * A prose root cannot put a class on every element: ProseMirror generates the editor's DOM, and
17
+ * react-markdown's output is only reachable through an override map. Descendant variants
18
+ * (`[&_h1]:mt-6` → `.recipe h1 { margin-top: … }`) style both from ONE string on the root, which
19
+ * is what makes "MarkdownView and TextEdit render the same computed styles" true by construction
20
+ * rather than by review.
21
+ *
22
+ * It also settles a cascade trap the two-grammar version would have re-created: `[&_h1]:mt-6`
23
+ * compiles at specificity (0,1,1) and a plain `.mt-6` on the element at (0,1,0), so an
24
+ * element-level class silently LOSES to a root-level descendant rule. The two forms cannot
25
+ * coexist on one tree; the root form is the one that works for both consumers.
26
+ *
27
+ * Every value is a literal so Tailwind's scanner sees it in this file — and in the shipped `dist`,
28
+ * which `preset.css` scans through `@source "./dist"` (the same contract `surfaceInteractive` and
29
+ * `fillInteractive` rely on).
30
+ *
31
+ * Logical properties throughout (`ms`/`ps`/`border-s`/`text-start`): prose is the surface most
32
+ * likely to carry translated content, and the contract lane asserts RTL containment.
33
+ *
34
+ * @example
35
+ * // A prose root — the whole recipe:
36
+ * <div className={cn(proseClassName, className)} />
37
+ *
38
+ * @example
39
+ * // One element's rules, composed into a narrower surface:
40
+ * <div className={cn(prose.root, prose.p, prose.code)} />
41
+ */
42
+ declare const prose: {
43
+ /** The root's own ink and size — every rule below is relative to this. */
44
+ readonly root: "text-base text-foreground";
45
+ readonly h1: "[&_h1]:mt-6 [&_h1]:mb-3 [&_h1]:scroll-m-20 [&_h1]:text-h1 [&_h1]:text-foreground [&_h1]:first:mt-0";
46
+ readonly h2: "[&_h2]:mt-6 [&_h2]:mb-3 [&_h2]:scroll-m-20 [&_h2]:text-h2 [&_h2]:text-foreground [&_h2]:first:mt-0";
47
+ readonly h3: "[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:scroll-m-20 [&_h3]:text-h3 [&_h3]:text-foreground [&_h3]:first:mt-0";
48
+ readonly h4: "[&_h4]:mt-4 [&_h4]:mb-2 [&_h4]:scroll-m-20 [&_h4]:text-h4 [&_h4]:text-foreground [&_h4]:first:mt-0";
49
+ readonly h5: "[&_h5]:mt-4 [&_h5]:mb-2 [&_h5]:text-label [&_h5]:text-foreground [&_h5]:first:mt-0";
50
+ readonly h6: "[&_h6]:mt-4 [&_h6]:mb-2 [&_h6]:text-label [&_h6]:text-muted-foreground [&_h6]:first:mt-0";
51
+ readonly p: "[&_p]:my-3 [&_p]:leading-relaxed [&_p]:text-foreground [&_p]:first:mt-0 [&_p]:last:mb-0";
52
+ readonly a: "[&_a]:font-medium [&_a]:text-info-text [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-info-text/(--alpha-link-hover)";
53
+ readonly strong: "[&_strong]:font-medium [&_strong]:text-foreground";
54
+ readonly em: "[&_em]:italic";
55
+ readonly del: "[&_del]:text-muted-foreground [&_del]:line-through [&_s]:text-muted-foreground [&_s]:line-through";
56
+ readonly ul: "[&_ul]:my-3 [&_ul]:ms-6 [&_ul]:list-disc [&_ul]:text-foreground [&_ul]:marker:text-muted-foreground [&_ul.contains-task-list]:list-none";
57
+ readonly ol: "[&_ol]:my-3 [&_ol]:ms-6 [&_ol]:list-decimal [&_ol]:text-foreground [&_ol]:marker:text-muted-foreground";
58
+ readonly li: "[&_li]:mt-1.5 [&_li]:leading-relaxed";
59
+ readonly blockquote: "[&_blockquote]:my-3 [&_blockquote]:border-s-2 [&_blockquote]:border-border [&_blockquote]:ps-4 [&_blockquote]:text-muted-foreground [&_blockquote]:italic";
60
+ readonly code: "[&_code]:rounded-sm [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-base [&_code]:text-foreground";
61
+ readonly pre: "[&_pre:not([data-slot='code-block-pre'])]:my-3 [&_pre:not([data-slot='code-block-pre'])]:overflow-x-auto [&_pre:not([data-slot='code-block-pre'])]:rounded-lg [&_pre:not([data-slot='code-block-pre'])]:border [&_pre:not([data-slot='code-block-pre'])]:border-border [&_pre:not([data-slot='code-block-pre'])]:bg-muted [&_pre:not([data-slot='code-block-pre'])]:p-4 [&_pre:not([data-slot='code-block-pre'])]:text-foreground";
62
+ readonly preCode: "[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:font-mono [&_pre_code]:text-code [&_pre_code]:text-foreground";
63
+ readonly hr: "[&_hr]:my-6 [&_hr]:border-border";
64
+ readonly table: "[&_table]:w-full [&_table]:border-collapse [&_table]:text-base [&_table]:text-foreground [&_thead]:border-b [&_thead]:border-border [&_tr]:border-b [&_tr]:border-border [&_tr]:last:border-0 [&_th]:px-3 [&_th]:py-2 [&_th]:text-start [&_th]:font-medium [&_th]:text-foreground [&_td]:px-3 [&_td]:py-2 [&_td]:text-muted-foreground";
65
+ readonly img: "[&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-lg [&_img]:border [&_img]:border-border";
66
+ };
67
+ /** The element roles the prose recipe covers. */
68
+ type ProseElement = keyof typeof prose;
69
+ /**
70
+ * The whole recipe as one class string — what a prose root wears.
71
+ *
72
+ * Joined rather than merged: every entry scopes a different selector, so there is no conflict for
73
+ * `cn` to resolve, and a plain join keeps the composition free of a `tailwind-merge` pass on a
74
+ * string that never changes.
75
+ */
76
+ declare const proseClassName: string;
3
77
 
4
78
  /**
5
79
  * Merges Tailwind CSS class names with intelligent conflict resolution.
@@ -16,6 +90,160 @@ export { ClassValue } from 'clsx';
16
90
  */
17
91
  declare function cn(...inputs: ClassValue[]): string;
18
92
 
93
+ /**
94
+ * THE hover/pressed recipe for a control that sits on a KNOWN surface (page, card, popover, well):
95
+ * hover climbs one rung of the surface ladder, pressing climbs one more. Rows, menu items, ghost and
96
+ * outline buttons, sidebar buttons, toggles, tabs, table rows, pagination — every transparent
97
+ * control — spread this string instead of writing a `hover:bg-*` literal.
98
+ *
99
+ * `hover:` compiles under `@media (hover: hover)` in Tailwind v4, so touch devices keep the rest
100
+ * fill and still get the pressed rung through `active:`. A selected/current state is the SAME rung
101
+ * as pressed (`data-selected:bg-surface-3`), which is why the two are never far apart.
102
+ *
103
+ * @example
104
+ * <button className={cn("rounded-md px-2", surfaceInteractive)} />
105
+ */
106
+ declare const surfaceInteractive = "hover:bg-surface-2 active:bg-surface-3";
107
+ /**
108
+ * The GROUP-SCOPED twin of {@link surfaceInteractive}, for the one geometry where the two rungs
109
+ * cannot live on the interactive element itself: a wash painted by an INNER chip inset from a
110
+ * container hairline (`design.md` §Hover geometry — "a wash is inset ≥4px from a container hairline
111
+ * and inherits its inner radius"). NumberField's steppers are the case: the button is full-height and
112
+ * flush to the field's border, so its own background would run into that hairline; a `size-full` chip
113
+ * inside the button's `p-1` paints the inset wash instead, and it must react to the BUTTON's hover.
114
+ *
115
+ * The group is named `wash` rather than left unnamed so a consumer's own `group` on an ancestor of a
116
+ * copied-in component cannot fire it. Put `group/wash` on the interactive element, this string on the
117
+ * chip. Everything else spreads {@link surfaceInteractive} directly — a group indirection where the
118
+ * element can carry the rungs itself is noise.
119
+ *
120
+ * @example
121
+ * <button className="group/wash p-1">
122
+ * <span className={cn("size-full rounded-sm", surfaceInteractiveGroup)} />
123
+ * </button>
124
+ */
125
+ declare const surfaceInteractiveGroup = "group-hover/wash:bg-surface-2 group-active/wash:bg-surface-3";
126
+ /**
127
+ * The inks a translucent hover/pressed wash can be composited from — the neutral ink and the five
128
+ * chromatic families the Button matrix and its outline/soft variants use.
129
+ */
130
+ type FillTone = "foreground" | "primary" | "destructive" | "success" | "warning" | "info" | "brand";
131
+ /**
132
+ * The ALPHA twin of {@link surfaceInteractive}: the same two rungs composited from an ink at
133
+ * `--alpha-hover` / `--alpha-pressed`, for a control whose backdrop is not a ladder surface (a kbd
134
+ * inside a hovered row, a chip on a well, chrome over media) or one that hovers in its OWN hue (the
135
+ * outline/soft status buttons). `foreground` is the neutral twin, and it is anchored to a DIFFERENT
136
+ * host per theme: in light it lands within 0.003 L of `surface-2`/`surface-3` over the page
137
+ * (measured L 0.9430 vs 0.945 and 0.9210 vs 0.922), but the dark ladder is CARD-anchored — over the
138
+ * dark `card` it is within 0.003 (0.2665 vs 0.269, 0.2918 vs 0.290), while over the dark
139
+ * `background` it is Δ0.028 / Δ0.023, a full rung off (measured 2026-09-09, LOW-7). Read that as
140
+ * the constraint it is: the alpha twin substitutes for the opaque rung on the surface a control of
141
+ * that theme actually sits on. It is AA-gated over page, card and popover in both themes.
142
+ *
143
+ * Solid fills do NOT use this: a solid already owns its darker `<tone>-hover` / `<tone>-active`
144
+ * steps (`bg-primary hover:bg-primary-hover active:bg-primary-active`) — an alpha over a solid
145
+ * would only thin it.
146
+ *
147
+ * Every value is a literal so Tailwind's scanner sees it in this file (and in the shipped `dist`,
148
+ * which `preset.css` scans).
149
+ *
150
+ * @example
151
+ * <button className={cn("bg-destructive-subtle text-destructive-text", fillInteractive.destructive)} />
152
+ */
153
+ declare const fillInteractive: Record<FillTone, string>;
154
+ /**
155
+ * THE field chrome — the one border/fill/hover/focus/invalid/disabled grammar every text-entry
156
+ * control wears (audit B1-11, 2026-09-07). Input, Textarea, NumberField's input, OTP slots, the
157
+ * Select trigger and the Combobox input all spread this string; before it existed the same nine
158
+ * declarations were copy-pasted in four files and restated a fifth time as slot overrides, so
159
+ * retuning the field meant finding every copy.
160
+ *
161
+ * It is CHROME only — no width, padding, height or type. Those differ per control (a square OTP
162
+ * slot is not `w-full`; a Textarea sizes by min-height, not `--size-*`), so each component adds its
163
+ * own layout and size classes after this string.
164
+ *
165
+ * The three border rungs, in ascending weight:
166
+ * - rest `border-input` (the derived `foreground` alpha hairline),
167
+ * - hover `foreground` at `--alpha-border-subtle` — neutral ink, one step darker, guarded by
168
+ * `not-disabled:not-data-disabled:` because D7 keeps pointer events ON a disabled control so a
169
+ * Tooltip can explain it, which would otherwise let a dead field light up under the cursor,
170
+ * - focus `ring` at `--alpha-tint-border`, on plain `focus` (not `focus-visible`) — a raw text field
171
+ * cannot tell mouse from keyboard, so the tint is the one cue for both. Forced colours erase a
172
+ * border tint outright, so the outline fallback for that case is written ONCE, unlayered, in
173
+ * `@vegastack/design-tokens`' `base.css` — never per component.
174
+ *
175
+ * **FOCUS OUTRANKS INVALID, and it has to be said in the selector** (#100, 2026-09-09). The invalid
176
+ * tint and the focus tint are the same property at the same specificity, and Tailwind v4 emits
177
+ * `aria-invalid:`/`data-invalid:` AFTER `focus:`, so an invalid field simply kept its destructive
178
+ * border when focused. Text entry carries `outline-hidden`, so that border IS the whole affordance:
179
+ * a focused invalid field had NO focus indicator at all, which is a WCAG 2.2 §2.4.7 failure the
180
+ * geometry lane's focus assertion found on its first run. `not-focus:` makes the invalid tint stand
181
+ * down while the field is focused rather than fighting the cascade — the error is still carried by
182
+ * `aria-invalid`, by Field's message and icon, and by the tint returning on blur, whereas focus has
183
+ * exactly one channel. design.md § Accessibility ("focus is the neutral `ring`, never a colour")
184
+ * settles which one owns the border when both want it.
185
+ *
186
+ * @example
187
+ * <input className={cn(fieldControl, "h-(--size-md) w-full min-w-0 px-3 text-base")} />
188
+ */
189
+ declare const fieldControl: string;
190
+ /**
191
+ * The WRAPPER twin of {@link fieldControl}: the identical chrome on a bordered group whose state
192
+ * comes from a descendant — Input's prefix/suffix group, NumberField's stepper group, ChipInput and
193
+ * the Combobox input-group. Same three border rungs, read through `focus-within` / `has-*` /
194
+ * Base UI's `data-focused` instead of the control's own pseudo-classes.
195
+ *
196
+ * Every element carrying this string must also carry `data-field-group` (a bare attribute). That is
197
+ * the hook `base.css` uses to paint the forced-colours focus outline on the GROUP: the inner input's
198
+ * own outline would be clipped by the group's `overflow-hidden`, which is exactly how a High
199
+ * Contrast user lost the caret location on an addon field.
200
+ *
201
+ * @example
202
+ * <div data-field-group className={cn(fieldControlGroup, "flex h-(--size-md) items-center")} />
203
+ */
204
+ declare const fieldControlGroup: string;
205
+ /**
206
+ * THE selected-chip recipe — one formula for every "raised chip on a muted track" control:
207
+ * Tabs `pill` and `chip`, `Segmented`, `Toggle` pressed and `ToggleGroup` pressed. Before this
208
+ * existed the four wrote four different selected looks (`bg-background`, `bg-secondary` + hairline,
209
+ * `bg-foreground/10`); audit 2026-09-07 B6-02.
210
+ *
211
+ * The track is the ladder's well rung (`surface-1`); the chip is the ladder's alpha form of the
212
+ * pressed/selected step (§Surfaces) — `bg-foreground/(--alpha-ink-tint)`, which doctrine reaches
213
+ * for exactly here ("a chip on a well"). Over the `surface-1` track it composites to L 0.899 light
214
+ * / 0.318 dark, which is Δ0.023 / Δ0.028 PAST `surface-3` — a full extra rung, since the ladder's
215
+ * own step is 0.021–0.033 (measured 2026-09-09, LOW-6; the doc used to claim "within a hair of
216
+ * `surface-3`", which is only true of the twin composited over the PAGE, not over the track).
217
+ * That extra rung is deliberate and is what makes a selected chip read as raised off its own
218
+ * track rather than level with it. Being an alpha is also what lets the SELECTED chip keep stepping: a hovered
219
+ * selected chip strengthens to `--alpha-ink-tint-strong` and a pressed one drops back to the resting
220
+ * tint (previewing the release), so no state ever reads as dead — an opaque `surface-3` chip would
221
+ * have nowhere left to climb.
222
+ *
223
+ * Base UI spells "selected" differently per primitive, so the state rules ship as two literal
224
+ * strings rather than a selector parameter (Tailwind v4's scanner only sees literals):
225
+ * {@link selectedChipVariants.pressed} for Toggle/ToggleGroup/Segmented (`data-pressed`) and
226
+ * {@link selectedChipVariants.active} for Tabs (`data-active`). The unselected steps are guarded by
227
+ * the matching `not-*` variant so the two sets are mutually exclusive and never race on specificity.
228
+ *
229
+ * @example
230
+ * <div className={cn("rounded-md p-0.5", selectedChipVariants.track)}>
231
+ * <Toggle className={cn("rounded-sm", selectedChipVariants.item, selectedChipVariants.pressed)} />
232
+ * </div>
233
+ */
234
+ declare const selectedChipVariants: {
235
+ /** The muted track the chips sit in — the ladder's well rung. */
236
+ readonly track: "bg-surface-1";
237
+ /**
238
+ * Chrome shared by every chip: a transparent hairline reserved at rest (so selecting adds no
239
+ * layout shift) and the muted→ink text step.
240
+ */
241
+ readonly item: "border border-transparent hover:text-foreground";
242
+ /** Selected keyed on Base UI's `data-pressed` — Toggle, ToggleGroup, Segmented. */
243
+ readonly pressed: "not-data-pressed:hover:bg-foreground/(--alpha-hover) not-data-pressed:active:bg-foreground/(--alpha-pressed) data-pressed:border-border data-pressed:bg-foreground/(--alpha-ink-tint) data-pressed:text-foreground data-pressed:hover:bg-foreground/(--alpha-ink-tint-strong) data-pressed:active:bg-foreground/(--alpha-ink-tint)";
244
+ /** Selected keyed on Base UI's `data-active` — Tabs. */
245
+ readonly active: "not-data-[active]:hover:bg-foreground/(--alpha-hover) not-data-[active]:active:bg-foreground/(--alpha-pressed) data-[active]:border-border data-[active]:bg-foreground/(--alpha-ink-tint) data-[active]:text-foreground data-[active]:hover:bg-foreground/(--alpha-ink-tint-strong) data-[active]:active:bg-foreground/(--alpha-ink-tint)";
246
+ };
19
247
  /**
20
248
  * @internal Registry theme-scope plumbing lives at `@vegastack/design/theme-scope`, NOT here.
21
249
  * It calls `React.createContext()` at module scope, which is `undefined` under the `react-server`
@@ -37,6 +265,20 @@ declare const TIMINGS: {
37
265
  readonly hoverOpenDelayMs: 700;
38
266
  /** Hover delay before a rich preview closes — lets the pointer travel into the card. */
39
267
  readonly hoverCloseDelayMs: 300;
268
+ /**
269
+ * Hover delay before a tooltip opens. A tooltip is a cheap label, not a preview, so it is
270
+ * deliberately much faster than {@link TIMINGS.hoverOpenDelayMs} — long enough that sweeping
271
+ * across a toolbar does not flash five tips, short enough that a deliberate hover feels
272
+ * instant. Base UI's `Tooltip.Provider` grouping then opens adjacent tips with no delay at
273
+ * all. Set once, in the app-root provider.
274
+ */
275
+ readonly tooltipOpenDelayMs: 300;
276
+ /**
277
+ * Delay before a tooltip closes. Zero: a tooltip has nothing to travel into (it is not
278
+ * hoverable content, unlike a HoverCard), so holding it after the pointer leaves only
279
+ * obscures what the user moved on to.
280
+ */
281
+ readonly tooltipCloseDelayMs: 0;
40
282
  };
41
283
  /**
42
284
  * Floating-surface positioning constants (px) — the unified sideOffset/collisionPadding
@@ -53,4 +295,34 @@ declare const FLOATING: {
53
295
  readonly collisionPadding: 8;
54
296
  };
55
297
 
56
- export { FLOATING, TIMINGS, cn };
298
+ /**
299
+ * Fan one DOM node out to several refs — a forwarded `ref` prop plus one or more internal
300
+ * refs — as a single ref callback. Skips `null`/`undefined` entries, so an optional
301
+ * forwarded ref needs no guard at the call site. Handles both ref shapes React 19 accepts:
302
+ * a callback ref is invoked, an object ref has its `.current` assigned.
303
+ *
304
+ * This lives in `@vegastack/design` rather than in any one component because ref-as-prop
305
+ * (React 19, no `forwardRef`) makes "the component needs the node AND has to forward it"
306
+ * the normal case, not a special one — it was hand-inlined in nine registry files and
307
+ * exported from `use-animation-replay` before this. It touches no React runtime value
308
+ * (only ref objects the caller already holds), so it is server-safe like `cn`.
309
+ *
310
+ * **Not memoized.** Calling it produces a NEW function every time, and React detaches a
311
+ * changed ref callback (calls it with `null`) and reattaches it on every render. Wrap the
312
+ * CALL at the call site when the inputs are stable:
313
+ *
314
+ * @example
315
+ * const mergedRef = React.useMemo(() => mergeRefs(ref, internalRef), [ref]);
316
+ * return <input ref={mergedRef} />;
317
+ *
318
+ * @example
319
+ * // A ref callback that also does work — merge it with the forwarded ref
320
+ * const setRef = React.useCallback(
321
+ * (node: HTMLDivElement | null) => { setContainer(node); },
322
+ * [],
323
+ * );
324
+ * const mergedRef = React.useMemo(() => mergeRefs(ref, setRef), [ref, setRef]);
325
+ */
326
+ declare function mergeRefs<T>(...refs: Array<React.Ref<T> | null | undefined>): React.RefCallback<T>;
327
+
328
+ export { FLOATING, type FillTone, type ProseElement, TIMINGS, cn, fieldControl, fieldControlGroup, fillInteractive, mergeRefs, prose, proseClassName, selectedChipVariants, surfaceInteractive, surfaceInteractiveGroup };
package/dist/index.js CHANGED
@@ -1,10 +1,28 @@
1
1
  import {
2
2
  FLOATING,
3
3
  TIMINGS,
4
- cn
5
- } from "./chunk-FBU37ITM.js";
4
+ cn,
5
+ fieldControl,
6
+ fieldControlGroup,
7
+ fillInteractive,
8
+ mergeRefs,
9
+ prose,
10
+ proseClassName,
11
+ selectedChipVariants,
12
+ surfaceInteractive,
13
+ surfaceInteractiveGroup
14
+ } from "./chunk-LRQSVCP6.js";
6
15
  export {
7
16
  FLOATING,
8
17
  TIMINGS,
9
- cn
18
+ cn,
19
+ fieldControl,
20
+ fieldControlGroup,
21
+ fillInteractive,
22
+ mergeRefs,
23
+ prose,
24
+ proseClassName,
25
+ selectedChipVariants,
26
+ surfaceInteractive,
27
+ surfaceInteractiveGroup
10
28
  };