@stonedogcode/style 0.15.0 → 0.17.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.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { log } from "../config/logger";
4
- import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
4
+ import React, { useRef, useState, useLayoutEffect, useEffect, useCallback } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
  import { styled } from "styled-system/jsx";
7
7
  import StyledText from "./StyledText";
@@ -44,7 +44,6 @@ const HelpTrigger = styled("button", {
44
44
  // is hard to hit is a help control that does not get used.
45
45
  minWidth: "48px",
46
46
  minHeight: "48px",
47
- marginLeft: "4px",
48
47
  borderRadius: "9999px",
49
48
  borderWidth: "1px",
50
49
  borderStyle: "solid",
@@ -53,6 +52,17 @@ const HelpTrigger = styled("button", {
53
52
  lineHeight: "1",
54
53
  verticalAlign: "middle",
55
54
  },
55
+ variants: {
56
+ // Which side of the children the control sits on — see helpGoesFirst
57
+ // below for how that is decided. The gap has to follow the side, or the
58
+ // control touches its subject on one side and floats away from it on the
59
+ // other, which is exactly the ambiguity this fix is about.
60
+ side: {
61
+ before: { marginRight: "4px" },
62
+ after: { marginLeft: "4px" },
63
+ },
64
+ },
65
+ defaultVariants: { side: "after" },
56
66
  });
57
67
 
58
68
  /**
@@ -111,7 +121,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
111
121
  "aria-label": ariaLabel,
112
122
  variant,
113
123
  trigger = "hover",
114
- helpLabel = "More information",
124
+ helpLabel,
115
125
  ...rest
116
126
  }) => {
117
127
  // Caller's variant, else the app-wide one, else `solid` — and anything the
@@ -133,6 +143,42 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
133
143
  const triggerRef = useRef<HTMLDivElement>(null);
134
144
  const tooltipRef = useRef<HTMLDivElement>(null);
135
145
  const helpRef = useRef<HTMLButtonElement>(null);
146
+ /**
147
+ * Schedule the open.
148
+ *
149
+ * **Clearing first is the whole of NEH-818.** `show()` runs from four
150
+ * places — the trigger's mouseenter and focus, and the tooltip's own
151
+ * mouseenter — and more than one of them fires for a single gesture: a press
152
+ * both hovers and focuses the trigger, ~0ms apart. Assigning over
153
+ * `timeoutRef.current` left the earlier timer running with nothing holding
154
+ * its id, so `hide()` could cancel only the last one scheduled.
155
+ *
156
+ * The orphan then fired into a page the reader had already left, opening a
157
+ * tooltip that no departure event could ever close — measured as a live,
158
+ * opaque, click-eating overlay sitting over the dialog the press had just
159
+ * opened, gone only on reload.
160
+ *
161
+ * One timer at a time; the id is nulled when it fires so `hide()` never
162
+ * clears a stale one.
163
+ *
164
+ * Hoisted above the effects (and memoised) rather than declared beside the
165
+ * JSX: the ancestor-focus effect added for NEH-950 has to bind these as
166
+ * listeners, and a second copy of the timer discipline above is exactly how
167
+ * NEH-818 would come back.
168
+ */
169
+ const show = useCallback(() => {
170
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
171
+ timeoutRef.current = setTimeout(() => {
172
+ timeoutRef.current = null;
173
+ setVisible(true);
174
+ }, delay);
175
+ }, [delay]);
176
+ const hide = useCallback(() => {
177
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
178
+ timeoutRef.current = null;
179
+ setVisible(false);
180
+ }, []);
181
+
136
182
  /**
137
183
  * A hover trigger on a device that cannot hover is not a worse experience —
138
184
  * it is an unreachable one. There is no hover event, and tapping the control
@@ -146,6 +192,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
146
192
  const canHover = useCanHover();
147
193
  const isClick = trigger === "click" || !canHover;
148
194
 
195
+
149
196
  // The child may be any component (StyledIconButton, a link, a bare span), so
150
197
  // whether it is focusable can only be known from the rendered DOM — React
151
198
  // cannot see inside a child component's output. Starts as "yes" so the common
@@ -154,6 +201,38 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
154
201
  const [focusableChild, setFocusableChild] = useState<HTMLElement | null>(null);
155
202
  const [hasFocusableChild, setHasFocusableChild] = useState(true);
156
203
 
204
+ /**
205
+ * The focusable element this trigger sits *inside*, if any (NEH-950).
206
+ *
207
+ * `hasFocusableChild` looks down and cannot see upwards, so it answers "no
208
+ * focusable child" for an icon that is decorative content inside a control
209
+ * that is already focusable and already named — and the trigger then took a
210
+ * `tabIndex` of its own. The result was the exact failure the conditional
211
+ * above exists to prevent, one level in: a second tab stop inside a button
212
+ * the reader has already passed, carrying no role and no name because
213
+ * `needsFallbackName` correctly declines to name it (the ancestor already
214
+ * has). Every icon in `stonedog-icons` that carries its own tooltip
215
+ * reproduced it, in every consumer.
216
+ *
217
+ * Deleting the `tabIndex` alone would have been a different WCAG failure
218
+ * rather than a fix — the tooltip must stay reachable by keyboard (2.1.1).
219
+ * So the ancestor becomes the trigger instead: it already owns the tab stop,
220
+ * and the effect below opens the tooltip when it takes focus, exactly as a
221
+ * focusable *child* already does by bubbling.
222
+ *
223
+ * Starts null, and the layout effect below can only ever find an ancestor
224
+ * when there is no focusable child — the two are mutually exclusive by
225
+ * construction, so nothing has to decide between them.
226
+ */
227
+ const [focusableAncestor, setFocusableAncestor] = useState<HTMLElement | null>(null);
228
+
229
+ /**
230
+ * True when something else — a descendant or an ancestor — already puts this
231
+ * trigger's content in the tab sequence. When it does, the trigger must add
232
+ * no stop of its own, and must not invent a role or a name for one.
233
+ */
234
+ const insideFocusable = hasFocusableChild || focusableAncestor !== null;
235
+
157
236
  // When the trigger KEEPS its tab stop it must have a role and a name (WCAG
158
237
  // 2.2 4.1.2) — but only if nothing else already provides one. Borrowing the
159
238
  // tooltip text unconditionally is what broke SharedWithIndicator, which names
@@ -165,14 +244,65 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
165
244
  // a duplicated one is a new bug.
166
245
  const [needsFallbackName, setNeedsFallbackName] = useState(false);
167
246
 
247
+ /**
248
+ * The child's own visible text, used to name the help control after the
249
+ * thing it explains (NEH-769).
250
+ *
251
+ * A screen carrying twenty tooltips carried twenty buttons all called "More
252
+ * information", which names nothing: a reader tabbing through hears the same
253
+ * four words twenty times and cannot tell which one answers their question.
254
+ * "Help: Require PIN" is the same control with a name that distinguishes it.
255
+ *
256
+ * Measured from the DOM rather than read from `children` because the child
257
+ * may be any component — React cannot see the text inside a child component's
258
+ * output, only the element it was handed.
259
+ */
260
+ const [subjectLabel, setSubjectLabel] = useState("");
261
+
168
262
  useLayoutEffect(() => {
169
263
  const node = triggerRef.current;
170
- const found = node?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
264
+ const help = helpRef.current;
265
+
266
+ // The help control is itself a `button`, so it matches FOCUSABLE_SELECTOR
267
+ // and must be excluded from every question asked about the CHILD. This was
268
+ // already wrong before the control could be rendered first — with a
269
+ // non-focusable child the query returned the help button, so
270
+ // aria-describedby landed on the button that already names itself instead
271
+ // of on the thing being described. Once the control renders first it would
272
+ // have matched every time (NEH-769).
273
+ const found =
274
+ Array.from(node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? []).find(
275
+ (candidate) => candidate !== help,
276
+ ) ?? null;
171
277
  // Same-value setState is a no-op in React, so this cannot loop.
172
278
  setFocusableChild((prev) => (prev === found ? prev : found));
173
279
  setHasFocusableChild(found !== null);
174
280
 
281
+ // `parentElement.closest`, not `node.closest`: the trigger itself may be
282
+ // carrying the very `tabindex` this is deciding whether to keep, and
283
+ // matching ourselves would make the answer depend on the previous render.
284
+ // Only asked when there is no focusable child, because a child already
285
+ // settles the question and is the nearer trigger of the two.
286
+ const ancestor = found
287
+ ? null
288
+ : node?.parentElement?.closest<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
289
+ setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
290
+
175
291
  if (!node) return;
292
+
293
+ // The child's own text — the help control's "?" deliberately excluded, or
294
+ // every subject would be named "… ?" and a text-free child would look as
295
+ // though it had text.
296
+ const ownText = Array.from(node.childNodes)
297
+ .filter((child) => child !== help)
298
+ .map((child) => child.textContent ?? "")
299
+ .join(" ")
300
+ .replace(/\s+/g, " ")
301
+ .trim();
302
+ // Long enough to distinguish twenty controls, short enough that a screen
303
+ // reader does not read a paragraph before the reader can act on it.
304
+ setSubjectLabel(ownText.length > 80 ? `${ownText.slice(0, 80).trimEnd()}…` : ownText);
305
+
176
306
  // parentElement, not the node itself: closest() would match our own
177
307
  // aria-label once we set one, and the answer would flip every render.
178
308
  const namedByAncestor = Boolean(
@@ -181,12 +311,12 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
181
311
  // Text content names an element for free; so does a labelled descendant
182
312
  // (an icon carrying its own aria-label, an <img alt>).
183
313
  const namedByContent =
184
- (node.textContent ?? "").trim().length > 0 ||
185
- Boolean(
186
- node.querySelector('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
187
- );
314
+ ownText.length > 0 ||
315
+ Array.from(
316
+ node.querySelectorAll('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
317
+ ).some((el) => el !== help);
188
318
  setNeedsFallbackName(!namedByAncestor && !namedByContent);
189
- }, [children]);
319
+ }, [children, isClick]);
190
320
 
191
321
  // aria-describedby has to sit on whatever actually receives focus, or a screen
192
322
  // reader announces the control with no description. Set imperatively rather
@@ -194,7 +324,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
194
324
  // component forwarding the prop, and a child that quietly drops it would fail
195
325
  // invisibly.
196
326
  useLayoutEffect(() => {
197
- const node = focusableChild;
327
+ const node = focusableChild ?? focusableAncestor;
198
328
  if (!node || !visible) return;
199
329
  const previous = node.getAttribute("aria-describedby");
200
330
  node.setAttribute(
@@ -205,7 +335,31 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
205
335
  if (previous === null) node.removeAttribute("aria-describedby");
206
336
  else node.setAttribute("aria-describedby", previous);
207
337
  };
208
- }, [focusableChild, visible, tooltipId]);
338
+ }, [focusableChild, focusableAncestor, visible, tooltipId]);
339
+
340
+ /**
341
+ * Open on the ANCESTOR's focus, when the trigger is inside one (NEH-950).
342
+ *
343
+ * A focusable *child* needs nothing here: `focusin`/`focusout` bubble, so the
344
+ * wrapper's own `onFocus`/`onBlur` already fire for it. An ancestor is the
345
+ * other direction, where nothing bubbles, so the listeners go on the ancestor
346
+ * itself.
347
+ *
348
+ * Without this the fix would trade WCAG 2.2 4.1.2 (a focusable element with
349
+ * no role and no name) for 2.1.1 — the explanation would be rendered and
350
+ * reachable by pointer only. Hover mode only: click mode never took a tab
351
+ * stop, so it has nothing to give back.
352
+ */
353
+ useEffect(() => {
354
+ const node = focusableAncestor;
355
+ if (isClick || !node) return;
356
+ node.addEventListener("focusin", show);
357
+ node.addEventListener("focusout", hide);
358
+ return () => {
359
+ node.removeEventListener("focusin", show);
360
+ node.removeEventListener("focusout", hide);
361
+ };
362
+ }, [focusableAncestor, isClick, show, hide]);
209
363
 
210
364
  useLayoutEffect(() => {
211
365
  if (visible && triggerRef.current && tooltipRef.current) {
@@ -269,9 +423,19 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
269
423
  }
270
424
  }, [visible, placement]);
271
425
 
272
- // Click mode owns its own dismissal. Hover mode needs none of this: it
273
- // closes when the pointer leaves. A panel opened by a deliberate press has
274
- // to be closable by a deliberate action, and Escape has to work, or a
426
+ // A pending open timer must not outlive the component. Nothing else clears
427
+ // it on unmount, so a trigger removed inside the delay window fired
428
+ // setVisible on a component React had already torn down.
429
+ useEffect(
430
+ () => () => {
431
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
432
+ timeoutRef.current = null;
433
+ },
434
+ [],
435
+ );
436
+
437
+ // Click mode's dismissal. A panel opened by a deliberate press has to be
438
+ // closable by a deliberate action — a press outside it, or Escape — or a
275
439
  // keyboard user is stuck with it open.
276
440
  useEffect(() => {
277
441
  if (!isClick || !visible || typeof document === "undefined") return;
@@ -280,6 +444,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
280
444
  if (event.key !== "Escape") return;
281
445
  setVisible(false);
282
446
  // Focus goes back to what opened it — never to the top of the document.
447
+ // Hover mode has no equivalent, because nothing was focused to open it.
283
448
  helpRef.current?.focus();
284
449
  };
285
450
  const onPointerDown = (event: MouseEvent) => {
@@ -298,6 +463,69 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
298
463
  };
299
464
  }, [isClick, visible]);
300
465
 
466
+ /**
467
+ * Hover mode's dismissal, which used to be nothing at all (NEH-818).
468
+ *
469
+ * `hide()` is reachable only from the trigger's own `onMouseLeave` /
470
+ * `onBlur`, so an open tooltip whose trigger never receives another
471
+ * departure event stays on the page for the life of the document — opaque,
472
+ * taking pointer events, over whatever opened on top of it. That is not a
473
+ * hypothetical ordering: a press both focuses the trigger and covers it, so
474
+ * blur cannot fire (focus stays put) and mouseleave has already been and
475
+ * gone.
476
+ *
477
+ * These two listeners are on `document`, so neither depends on the trigger
478
+ * being reachable — which is the property the trigger's own handlers lack.
479
+ *
480
+ * - **`pointermove`** closes it once the pointer is over neither the trigger
481
+ * nor the tooltip. It deliberately does not fire on the tooltip itself:
482
+ * WCAG 2.2 1.4.13 *Hoverable* requires the reader be able to move onto the
483
+ * revealed text without it vanishing, which is also why the portal keeps
484
+ * `pointer-events: auto`.
485
+ * - **Escape** satisfies 1.4.13 *Dismissible*, which hover mode did not meet
486
+ * before: content revealed on hover or focus must be dismissable without
487
+ * moving the pointer or focus, and a reader whose pointer is parked had no
488
+ * way to clear it.
489
+ *
490
+ * Bound only while a hover tooltip is actually open, so the common case
491
+ * costs nothing.
492
+ */
493
+ useEffect(() => {
494
+ if (isClick || !visible || typeof document === "undefined") return;
495
+
496
+ const onKeyDown = (event: KeyboardEvent) => {
497
+ if (event.key !== "Escape") return;
498
+ // No focus move: in hover mode nothing was focused to open this, and
499
+ // stealing focus on Escape would be its own bug.
500
+ setVisible(false);
501
+ };
502
+ const onPointerMove = (event: PointerEvent) => {
503
+ const target = event.target as Node | null;
504
+ if (!target) return;
505
+ if (triggerRef.current?.contains(target) || tooltipRef.current?.contains(target)) return;
506
+ // A tooltip revealed by FOCUS belongs to the focus, not to the pointer.
507
+ // WCAG 2.2 1.4.13 Persistent requires it to stay until its trigger is
508
+ // released, so taking it away because an unrelated mouse moved would
509
+ // trade one conformance failure for another — and would do it to a
510
+ // keyboard reader who never touched the mouse. Escape above is their
511
+ // dismissal.
512
+ if (
513
+ document.activeElement &&
514
+ triggerRef.current?.contains(document.activeElement)
515
+ ) {
516
+ return;
517
+ }
518
+ setVisible(false);
519
+ };
520
+
521
+ document.addEventListener("keydown", onKeyDown);
522
+ document.addEventListener("pointermove", onPointerMove);
523
+ return () => {
524
+ document.removeEventListener("keydown", onKeyDown);
525
+ document.removeEventListener("pointermove", onPointerMove);
526
+ };
527
+ }, [isClick, visible]);
528
+
301
529
  if (!tooltip) {
302
530
  return <>{children}</>;
303
531
  }
@@ -306,13 +534,51 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
306
534
  // would produce "[object Object]" in the accessibility tree.
307
535
  const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
308
536
 
309
- const show = () => {
310
- timeoutRef.current = setTimeout(() => setVisible(true), delay);
311
- };
312
- const hide = () => {
313
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
314
- setVisible(false);
315
- };
537
+ /**
538
+ * `Label ? Input` — the help control goes after the label and before the
539
+ * control it explains, never after it (NEH-769).
540
+ *
541
+ * Not aesthetic. A screen-magnifier user reads linearly at high zoom, so a
542
+ * `?` placed after a long input is pushed off the visible viewport entirely;
543
+ * before the control, the reader meets the concept, can ask what it means,
544
+ * and only then enters data.
545
+ *
546
+ * Consumers wrap two shapes and both have to obey that rule, which is why
547
+ * neither a fixed "always before" nor a fixed "always after" is right:
548
+ *
549
+ * `<Tooltip><Label/></Tooltip> <Input/>` the input is OUTSIDE us, so
550
+ * the control goes AFTER → Label ? | Input
551
+ * `<Tooltip><Row><Label/><Toggle/></Row></Tooltip>`
552
+ * the control is INSIDE us, so
553
+ * it goes BEFORE → ? Label Toggle
554
+ *
555
+ * So the side keys on whether the children contain something focusable —
556
+ * which the component already measures for its own tab-stop logic. It is
557
+ * measured in a layout effect, so it settles before paint rather than
558
+ * flickering into place.
559
+ */
560
+ const helpGoesFirst = hasFocusableChild;
561
+
562
+ /**
563
+ * Explicit label wins; otherwise name the control after its subject. Only
564
+ * when there is no text at all does it fall back to the old generic name.
565
+ */
566
+ const resolvedHelpLabel =
567
+ helpLabel ?? (subjectLabel ? `Help: ${subjectLabel}` : "More information");
568
+
569
+ const helpControl = isClick ? (
570
+ <HelpTrigger
571
+ ref={helpRef}
572
+ type="button"
573
+ side={helpGoesFirst ? "before" : "after"}
574
+ aria-label={resolvedHelpLabel}
575
+ aria-expanded={visible}
576
+ aria-controls={visible ? tooltipId : undefined}
577
+ onClick={() => setVisible((open) => !open)}
578
+ >
579
+ ?
580
+ </HelpTrigger>
581
+ ) : null;
316
582
 
317
583
  return (
318
584
  <>
@@ -323,7 +589,13 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
323
589
  // tooltip still fires without the wrapper taking focus itself. Adding
324
590
  // tabIndex here regardless is what gave every tooltipped control two
325
591
  // tab stops, the second of them silent (NEH-127).
326
- tabIndex={isClick || hasFocusableChild ? undefined : 0}
592
+ //
593
+ // `insideFocusable`, not `hasFocusableChild`: an ANCESTOR owns the tab
594
+ // stop just as effectively as a descendant, and looking only downwards
595
+ // put the same silent second stop inside every icon button in the
596
+ // fleet (NEH-950). The ancestor-focus effect above is what keeps the
597
+ // tooltip reachable once the trigger stops taking focus itself.
598
+ tabIndex={isClick || insideFocusable ? undefined : 0}
327
599
  // A focusable element needs a role and a name (WCAG 2.2 4.1.2). Applied
328
600
  // only when the trigger keeps the tab stop AND nothing else names it —
329
601
  // see needsFallbackName above for why the condition matters (NEH-151).
@@ -332,9 +604,9 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
332
604
  // reveals content on focus, which is the closest standard role and what
333
605
  // the ARIA tooltip pattern assumes of a trigger. A focusable generic
334
606
  // with only a name still fails 4.1.2, which asks for both.
335
- role={!isClick && !hasFocusableChild && needsFallbackName ? "button" : undefined}
607
+ role={!isClick && !insideFocusable && needsFallbackName ? "button" : undefined}
336
608
  aria-label={
337
- isClick || hasFocusableChild
609
+ isClick || insideFocusable
338
610
  ? undefined
339
611
  : ariaLabel ?? (needsFallbackName ? tooltipLabel : undefined)
340
612
  }
@@ -344,24 +616,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
344
616
  onMouseLeave={isClick ? undefined : hide}
345
617
  onFocus={isClick ? undefined : show}
346
618
  onBlur={isClick ? undefined : hide}
347
- aria-describedby={
348
- !isClick && !hasFocusableChild && visible ? tooltipId : undefined
349
- }
619
+ // When something focusable is in play the description is set
620
+ // imperatively on THAT element instead (see the layout effect above) —
621
+ // it has to sit on whatever actually receives focus. This wrapper is
622
+ // the fallback for label-only children, and it applies in click mode
623
+ // too: help that is reachable but never announced is help a screen
624
+ // reader user does not know exists (NEH-769).
625
+ //
626
+ // `insideFocusable`, not `hasFocusableChild`, so a focusable ANCESTOR
627
+ // still owns the description rather than this wrapper (NEH-950).
628
+ aria-describedby={!insideFocusable && visible ? tooltipId : undefined}
350
629
  {...rest}
351
630
  >
631
+ {helpGoesFirst && helpControl}
352
632
  {children}
353
- {isClick && (
354
- <HelpTrigger
355
- ref={helpRef}
356
- type="button"
357
- aria-label={helpLabel}
358
- aria-expanded={visible}
359
- aria-controls={visible ? tooltipId : undefined}
360
- onClick={() => setVisible((open) => !open)}
361
- >
362
- ?
363
- </HelpTrigger>
364
- )}
633
+ {!helpGoesFirst && helpControl}
365
634
  </TooltipTrigger>
366
635
  {visible && typeof document !== "undefined" &&
367
636
  createPortal(
@@ -147,3 +147,28 @@ export function stepUpFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
147
147
  // clamp fails safe instead of returning undefined to a caller typed otherwise.
148
148
  return next ?? size;
149
149
  }
150
+
151
+ /**
152
+ * The next size DOWN, clamped at the bottom of the scale.
153
+ *
154
+ * The counterpart to `stepUpFontSize`, added for `StyledFieldHelp` (NEH-972),
155
+ * and the clamp is the load-bearing half. Inline help is deliberately one tier
156
+ * below the text it accompanies — but "one tier below" must never mean "below
157
+ * the smallest tier the host offers", because the reader who has turned their
158
+ * text size all the way down is the reader with the least room to spare. At
159
+ * `xs` this returns `xs`, so help matches the body text rather than shrinking
160
+ * past it.
161
+ *
162
+ * Steps through `FONT_SIZE_ORDER`, so it moves through whatever scale the host
163
+ * has pinned its `--font-sizes-*` properties to rather than through a fixed set
164
+ * of pixel values.
165
+ */
166
+ export function stepDownFontSize(size: FontSizeKey, steps = 1): FontSizeKey {
167
+ const index = FONT_SIZE_ORDER.indexOf(size);
168
+ if (index === -1) return size;
169
+ const next = FONT_SIZE_ORDER[Math.max(index - steps, 0)];
170
+ // Clamped into range above, so this cannot miss — but staying total means a
171
+ // future change to the clamp fails safe rather than handing a caller
172
+ // `undefined` from a function typed otherwise. Same shape as stepUpFontSize.
173
+ return next ?? size;
174
+ }
package/src/index.ts CHANGED
@@ -48,6 +48,7 @@ export {
48
48
  getFontSizeLabel,
49
49
  getFontSizeValue,
50
50
  stepUpFontSize,
51
+ stepDownFontSize,
51
52
  FONT_SIZE_ORDER,
52
53
  } from "./config/font-size";
53
54
 
@@ -197,6 +198,18 @@ export type { StyledTooltipProps } from "./components/StyledTooltip";
197
198
  export { default as StyledFormLabel } from "./components/StyledFormLabel";
198
199
  export type { StyledFormLabelProps } from "./components/StyledFormLabel";
199
200
 
201
+ /**
202
+ * Permanent inline help for a field — PRD-0037's replacement for the hover
203
+ * tooltip and its `?` button. `fieldHelpId` is exported so a host can put the
204
+ * `aria-describedby` association in server-rendered HTML.
205
+ */
206
+ export {
207
+ default as StyledFieldHelp,
208
+ StyledFieldHelp as FieldHelp,
209
+ fieldHelpId,
210
+ } from "./components/StyledFieldHelp";
211
+ export type { StyledFieldHelpProps } from "./components/StyledFieldHelp";
212
+
200
213
  // ---------------------------------------------------------------------------
201
214
  // Components that were blocked on a runtime dependency until NEH-430 gave each
202
215
  // a seam with a working default. None of them adds a dependency; the host
@@ -36,6 +36,7 @@ import {
36
36
  createSemanticFonts,
37
37
  createSemanticSizes,
38
38
  } from "./semantic-variables";
39
+ import { createZIndexTokens } from "./z-layers";
39
40
 
40
41
  export interface StonedogStylePresetOptions {
41
42
  /**
@@ -184,6 +185,19 @@ export function stonedogStylePreset(options: StonedogStylePresetOptions = {}) {
184
185
  */
185
186
  fonts: createSemanticFonts(cssVarPrefix),
186
187
  fontWeights: createSemanticFontWeights(cssVarPrefix),
188
+ /**
189
+ * Named stacking layers (NEH-830). Neither this preset nor either
190
+ * base Panda preset defined any, so `drawerRecipe`'s `zIndex:
191
+ * "modal"` resolved to nothing and was emitted as the literal
192
+ * `z-index: modal` — invalid CSS the browser discards, leaving that
193
+ * panel with no z-index at all.
194
+ *
195
+ * Unlike the colour tokens these carry no custom property: a layer
196
+ * is not a brand decision and there is nothing for a theme to
197
+ * restyle. A HOST overrides the numbers in its own config; see
198
+ * z-layers.ts for why the names live here and the values do not.
199
+ */
200
+ zIndex: createZIndexTokens(),
187
201
  },
188
202
  keyframes: {
189
203
  spin: {
@@ -250,6 +264,13 @@ export {
250
264
  requiredCssCustomProperties,
251
265
  } from "./semantic-variables";
252
266
 
267
+ export {
268
+ Z_LAYERS,
269
+ createZIndexTokens,
270
+ zIndexTokenNames,
271
+ type ZLayerName,
272
+ } from "./z-layers";
273
+
253
274
  export { recipes as stonedogStyleRecipes };
254
275
 
255
276
  /* ------------------------------------------------------------------------- *
@@ -37,10 +37,20 @@ export const buttonRecipe = defineRecipe({
37
37
  variant: {
38
38
  solid: {
39
39
  bg: "buttonBgAccent",
40
- color: "textPrimary",
40
+ // The same pairing `outline` states below, and for the same reason
41
+ // (NEH-441, NEH-796). `textPrimary` is the colour that goes on
42
+ // `boxBgPrimary`, not on an accent surface: against optima's light
43
+ // theme that pairing measures 2.43:1, below WCAG AA, while
44
+ // `buttonTextAccent` measures 7.34:1. The dark theme happens to
45
+ // survive it, which is what let a default variant ship unreadable.
46
+ //
47
+ // `TEXT_BACKGROUND_PAIRS` in `semantic-variables.ts` is the contract
48
+ // being obeyed: `buttonTextAccent` sits on `buttonBgAccent`, and
49
+ // `buttonTextSecondary` on the `buttonBgSecondary` the hover repaints.
50
+ color: "buttonTextAccent",
41
51
  _hover: {
42
52
  bg: "buttonBgSecondary",
43
- color: "textPrimary",
53
+ color: "buttonTextSecondary",
44
54
  },
45
55
  },
46
56
  outline: {
@@ -77,7 +87,15 @@ export const buttonRecipe = defineRecipe({
77
87
  border: "2px solid",
78
88
  borderRadius: "xl",
79
89
  bg: "buttonBgAccent",
80
- color: "textPrimary",
90
+ // The same correction `solid` took in NEH-796, which this variant was
91
+ // missed by twice over (NEH-877): the pairing sweep that landed with it
92
+ // was scoped to this recipe, but it also skipped every variant named
93
+ // `glass` — on the reasoning that a translucent surface should inherit.
94
+ // This one is not translucent. It paints an opaque `buttonBgAccent` and
95
+ // blurs what is BEHIND it, so it owes a paired foreground like any
96
+ // other accent surface: 2.43:1 with `textPrimary` against optima's
97
+ // light theme, 7.34:1 with this.
98
+ color: "buttonTextAccent",
81
99
  boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
82
100
  backdropFilter: "blur(12px)",
83
101
  WebkitBackdropFilter: "blur(12px)",
@@ -86,6 +104,13 @@ export const buttonRecipe = defineRecipe({
86
104
  transition: "all 0.3s ease",
87
105
  _hover: {
88
106
  bg: "buttonBgSecondary",
107
+ // Stated, because the hover repaints a DIFFERENT surface (NEH-877).
108
+ // Without it the label rides its base `buttonTextAccent` onto a
109
+ // secondary background — 1.06:1 in optima's light theme, white on
110
+ // near-white, i.e. the label vanishing on hover. This is the same
111
+ // move `solid` made in NEH-796 and the reason a base-colour fix has
112
+ // to look at every pseudo-state that repaints beneath it.
113
+ color: "buttonTextSecondary",
89
114
  borderColor: "rgba(255,255,255,0.4)",
90
115
  boxShadow: "0 8px 32px rgba(0,0,0,0.3), inset 0 0 20px rgba(255,255,255,0.1)",
91
116
  transform: "translateY(-1px)",
@@ -126,7 +151,12 @@ export const buttonRecipe = defineRecipe({
126
151
  },
127
152
  },
128
153
  selected: {
129
- color: "textPrimary",
154
+ // `textAccent`, not `textPrimary` — the same mispairing `solid` had
155
+ // (NEH-796). `boxBgAccent` is an accent surface and the contract's
156
+ // partner for it is `textAccent`; `textPrimary` is the colour for
157
+ // `boxBgPrimary`, a different surface. Adjacent instance of the defect
158
+ // the issue names, in the same recipe, so it is fixed here.
159
+ color: "textAccent",
130
160
  border: "3px dashed black",
131
161
  borderRadius: "xl",
132
162
  bg: "boxBgAccent",
@@ -15,7 +15,12 @@ export const formRecipe = defineRecipe({
15
15
  variant: {
16
16
  solid: {
17
17
  bg: "boxBgAccent",
18
- color: "textPrimary",
18
+ // `textAccent`, not `textPrimary` (NEH-877). `textPrimary` is the
19
+ // contract's partner for `boxBgPrimary`, a different surface: against
20
+ // optima's light theme, whose accent surface is a near-black graphite,
21
+ // that pairing measures 1.17:1 — a form whose every label is the exact
22
+ // colour of the panel behind it. `textAccent` measures 15.27:1.
23
+ color: "textAccent",
19
24
  borderColor: "borderBgPrimary",
20
25
  "& > li:not(:last-child)": {
21
26
  borderBottom: "1px solid",