@stonedogcode/style 0.16.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.
package/README.md CHANGED
@@ -629,6 +629,55 @@ Scroll mode needs a height to scroll inside: `StyledScrollbar` is
629
629
  height (`display: flex; flex-direction: column; height: …`). Unconstrained, the
630
630
  rail simply grows — which is correct, and is not a bug.
631
631
 
632
+ ## Form help — `StyledFieldHelp`
633
+
634
+ Permanent explanatory text for a field: below the label, above the control,
635
+ always visible.
636
+
637
+ ```tsx
638
+ <StyledFormLabel htmlFor="dose">Dose</StyledFormLabel>
639
+ <StyledFieldHelp htmlFor="dose">
640
+ Milligrams per tablet, as printed on the bottle.
641
+ </StyledFieldHelp>
642
+ <StyledInputText id="dose" />
643
+ ```
644
+
645
+ It is **text, and nothing else** — no trigger, no disclosure, no popover, no
646
+ preference. That is the design rather than a simplification, and it is what the
647
+ guarantees below rest on.
648
+
649
+ ### What it guarantees
650
+
651
+ - **Zero tab stops.** Nothing here is focusable, so explaining a hundred fields
652
+ costs the keyboard nothing. The pattern this replaces put a help *button*
653
+ beside every explained control, which roughly doubled keyboard traversal on a
654
+ busy form and cannot be fixed while a per-control control remains: taking the
655
+ buttons out of the tab order loses the help for sighted keyboard users
656
+ instead.
657
+ - **The control's `aria-describedby` points at it**, so a screen reader
658
+ announces the words as the field's description rather than reading them as
659
+ stray prose further down the page. The component sets the attribute itself,
660
+ merging with any description the control already had and removing only its own
661
+ id when it unmounts — so a call site that forgets still gets the association.
662
+ `fieldHelpId("dose")` is `"dose-help"`, exported so a host can put the
663
+ attribute in server-rendered markup instead; the component notices and stands
664
+ down.
665
+ - **No pointer is involved**, which sidesteps WCAG 1.4.13 (Content on Hover or
666
+ Focus) rather than trying to satisfy it. Touch, mouse and keyboard all get the
667
+ same words with no gesture and no setting.
668
+ - **One tier below the app-wide text size, and never below the smallest tier**
669
+ the host offers. A reader who has already turned their text down is the one
670
+ with the least room to spare.
671
+ - **Contrast is measured, not assumed.** The colour is the emphasis token
672
+ `textMuted`, which is `currentColor`-relative, so it de-emphasises correctly
673
+ on a light theme and a dark one. The component tests composite the whole
674
+ ancestor chain — every translucent layer, not the page background — and assert
675
+ WCAG 1.4.3 AA against the surface the text really paints on. Measuring against
676
+ the page is how text on a tinted chip gets a confident, wrong pass.
677
+
678
+ `children` is typed `ReactNode` for formatting — a unit, a `<strong>`, a line
679
+ break. Putting a control in there defeats the only promise the component makes.
680
+
632
681
  ## Adopting a component as it is migrated
633
682
 
634
683
  Components move out of HopperGuard into this package one at a time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "StoneDogCode L.L.C.",
@@ -0,0 +1,248 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef } from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import type { HTMLStyledProps } from "styled-system/types";
6
+ import { log } from "../config/logger";
7
+ import { useFontSizeProfile } from "../config/style-config";
8
+ import { fontSizeMap, stepDownFontSize } from "../config/font-size";
9
+
10
+ /**
11
+ * Permanent help text for a form control: below the label, above the control,
12
+ * always visible, never interactive.
13
+ *
14
+ * ```tsx
15
+ * <StyledFormLabel htmlFor="dose">Dose</StyledFormLabel>
16
+ * <StyledFieldHelp htmlFor="dose">
17
+ * Milligrams per tablet, as printed on the bottle.
18
+ * </StyledFieldHelp>
19
+ * <StyledInputText id="dose" />
20
+ * ```
21
+ *
22
+ * ## Why this exists rather than another tooltip (PRD-0037, NEH-972)
23
+ *
24
+ * The pattern this replaces is `StyledTooltip` plus its `HelpTrigger`, and the
25
+ * problem with it is not placement — NEH-769 fixed the placement and the
26
+ * pattern was still wrong. Four things fail at once:
27
+ *
28
+ * - **Hover-only help excludes touch entirely**, and is actively hostile to a
29
+ * reader with a tremor: they open tooltips by accident and cannot reliably
30
+ * move a pointer *into* one before it closes.
31
+ * - **`HelpTrigger` is a `<button>`, so every instance is a tab stop.** A
32
+ * screenshot of one production screen showed roughly twenty of them; ~160
33
+ * across the app. That roughly doubles keyboard traversal, and it cannot be
34
+ * fixed by taking them out of the tab order — a sighted keyboard user not
35
+ * running a screen reader would lose the help altogether. There is no fix
36
+ * for the tab-stop tax that keeps a per-control control.
37
+ * - **Twenty identical glyphs are not twenty helps, they are noise.** Older
38
+ * readers have measurably low tolerance for hidden information behind an
39
+ * abstract icon, so the pattern penalised exactly the audience it was for.
40
+ * - It needed a **preference** (`accessibility.clickForTooltips`) to be usable
41
+ * on a touch device, and a preference is a thing to get wrong.
42
+ *
43
+ * So this component has **no trigger, no state, no preference and no
44
+ * interaction**. It is text. That is the entire design, and every constraint
45
+ * below follows from it:
46
+ *
47
+ * - it adds **zero tab stops** — no `tabindex`, no focusable element, nothing
48
+ * Tab can land on;
49
+ * - it needs **no pointer**, so it sidesteps WCAG 1.4.13 (Content on Hover or
50
+ * Focus) rather than trying to satisfy dismissible/hoverable/persistent;
51
+ * - it is in the DOM from first paint, so a touch reader, a keyboard reader and
52
+ * a screen-reader user all get the same words with no gesture at all.
53
+ *
54
+ * ## It wires `aria-describedby` itself, and that is deliberate
55
+ *
56
+ * Text sitting near a control is not a description of it. Without
57
+ * `aria-describedby` a screen reader announces "Dose, edit text" and the help
58
+ * is stray prose somewhere else in the reading order — which is how a field
59
+ * ends up *looking* explained and being unexplained.
60
+ *
61
+ * Two things make the association hard to get wrong, because this pattern is
62
+ * about to be applied at well over a hundred call sites and the one that gets
63
+ * skipped is the one nobody notices:
64
+ *
65
+ * 1. **The id is derived, not generated.** `fieldHelpId("dose")` is
66
+ * `"dose-help"` — deterministic from the control's own id, so both sides can
67
+ * name it without passing a generated value around, and it is stable across
68
+ * server and client render.
69
+ * 2. **The component sets the attribute on the control** in an effect, merging
70
+ * with anything already there. A call site that forgets still gets the
71
+ * association.
72
+ *
73
+ * Set imperatively rather than by cloning the child, for the reason
74
+ * `StyledTooltip` records: `cloneElement` depends on every child component
75
+ * forwarding the prop, and a child that quietly drops it fails invisibly. It is
76
+ * not a wrapper for the same reason — a wrapper would have to own the control's
77
+ * markup, and this has to drop into a form whose markup already exists.
78
+ *
79
+ * `useEffect` rather than `useLayoutEffect`: nothing here affects layout, and
80
+ * the accessibility tree is read after hydration. A host may still write
81
+ * `aria-describedby={fieldHelpId("dose")}` on the control itself if it wants
82
+ * the association present in server-rendered HTML; the effect sees it is
83
+ * already there and leaves it alone.
84
+ *
85
+ * ## Size and colour
86
+ *
87
+ * **One tier below the app-wide text size, never below `xs`.** The size is an
88
+ * inline style rather than a Panda prop because Panda extracts styles by
89
+ * parsing source at BUILD time: a prop whose value is only known at runtime
90
+ * yields a class name with no rule behind it, and nothing errors.
91
+ * `StyledFormLabel` and `StyledText` reach for an inline style for exactly this
92
+ * reason. Reading the profile also matters — plain inheritance would pin the
93
+ * help to whatever the browser default is, which in a product whose body text
94
+ * is 1.375rem makes the help less than two-thirds the size of the text it
95
+ * explains.
96
+ *
97
+ * **Colour is `textMuted`**, the emphasis axis, which resolves relative to
98
+ * `currentColor` — so it de-emphasises against the surface it is actually on,
99
+ * light theme or dark, rather than picking a grey that is right on one of them.
100
+ * `StyledFieldHelp.contrast.ct.tsx` measures the rendered result against the
101
+ * **composited** background — every ancestor layer, not the page — and asserts
102
+ * WCAG 1.4.3 AA. Measuring against the page background is how a confidently
103
+ * wrong pass gets produced for text that sits on a tinted chip.
104
+ *
105
+ * The size step and the colour step are two signals, not one, so the help still
106
+ * reads as secondary for anyone who cannot see the colour difference.
107
+ */
108
+
109
+ const PandaFieldHelp = styled("p", {
110
+ base: {
111
+ display: "block",
112
+ // Longhands, never the `margin` shorthand. Panda emits atomic rules, and a
113
+ // shorthand competing with a longhand for the same box is decided by
114
+ // stylesheet order rather than by what was written.
115
+ marginTop: "0",
116
+ marginInline: "0",
117
+ // The gap before the control. `StyledFormLabel` supplies the gap above.
118
+ marginBottom: "0.5rem",
119
+ color: "textMuted",
120
+ // Prose, and prose that is being read carefully — a little more leading
121
+ // than the label above it.
122
+ lineHeight: "1.4",
123
+ fontWeight: "normal",
124
+ // No `fontSize`: it is resolved at runtime from the profile. See above.
125
+ },
126
+ });
127
+
128
+ /**
129
+ * The `id` this component gives its help text, derived from the control's id.
130
+ *
131
+ * Exported so a call site can put the association in server-rendered HTML —
132
+ * `aria-describedby={fieldHelpId("dose")}` — and so a test can name the element
133
+ * without reaching into the DOM for it. Deterministic on purpose: a generated
134
+ * id (`useId`) cannot be named by the other half of the pair without threading
135
+ * a value between two siblings, and threading is what gets skipped.
136
+ */
137
+ export function fieldHelpId(controlId: string): string {
138
+ return `${controlId}-help`;
139
+ }
140
+
141
+ /** Split an `aria-describedby` attribute into its id tokens. */
142
+ function idTokens(value: string | null): string[] {
143
+ return value ? value.split(/\s+/).filter(Boolean) : [];
144
+ }
145
+
146
+ export interface StyledFieldHelpProps
147
+ extends Omit<HTMLStyledProps<"p">, "children"> {
148
+ /**
149
+ * The `id` of the control this describes.
150
+ *
151
+ * Required, and it is the whole point: without it this is prose near a
152
+ * control rather than the control's description. Named `htmlFor` to match
153
+ * `StyledFormLabel`, so the pair reads the same at a call site.
154
+ */
155
+ htmlFor: string;
156
+ /**
157
+ * The help itself. **Text.** Anything focusable put in here defeats the one
158
+ * guarantee this component makes, so it is typed as `ReactNode` for
159
+ * formatting (`<strong>`, a unit, a line break) rather than for controls.
160
+ */
161
+ children: React.ReactNode;
162
+ /** Override the derived id. Rarely wanted — see `fieldHelpId`. */
163
+ id?: string;
164
+ }
165
+
166
+ const StyledFieldHelp: React.FC<StyledFieldHelpProps> = ({
167
+ htmlFor,
168
+ children,
169
+ id,
170
+ style,
171
+ fontSize,
172
+ ...props
173
+ }) => {
174
+ // Unconditional and at the top: folding this into the expression below reads
175
+ // fine and is a hooks-order violation the moment `fontSize` is passed.
176
+ const profile = useFontSizeProfile();
177
+ const ref = useRef<HTMLParagraphElement | null>(null);
178
+
179
+ const helpId = id ?? fieldHelpId(htmlFor);
180
+
181
+ useEffect(() => {
182
+ const node = ref.current;
183
+ if (!node) return;
184
+
185
+ // The element's own document, not the global one: a component test mounts
186
+ // inside an iframe, and a host may portal into another window.
187
+ const control = node.ownerDocument.getElementById(htmlFor);
188
+ if (!control) {
189
+ // Not thrown. A missing control is a call-site bug, but the help text is
190
+ // still readable on screen and throwing would take the whole form down
191
+ // over an attribute. The host hears about it through its own logger.
192
+ log.warn(
193
+ "StyledFieldHelp: no element has this id, so the help is not announced as the field's description",
194
+ { htmlFor, helpId },
195
+ );
196
+ return;
197
+ }
198
+
199
+ const tokens = idTokens(control.getAttribute("aria-describedby"));
200
+ // Already named — the call site wired it statically. Leave it be, or the
201
+ // id lands twice and a screen reader reads the description twice.
202
+ if (tokens.includes(helpId)) return;
203
+
204
+ control.setAttribute("aria-describedby", [...tokens, helpId].join(" "));
205
+
206
+ return () => {
207
+ // Read the attribute again rather than restoring the value captured
208
+ // above. Something else may have added its own id in the meantime — an
209
+ // error summary is the obvious one — and restoring a stale string would
210
+ // silently drop it.
211
+ const remaining = idTokens(
212
+ control.getAttribute("aria-describedby"),
213
+ ).filter((token) => token !== helpId);
214
+ if (remaining.length > 0) {
215
+ control.setAttribute("aria-describedby", remaining.join(" "));
216
+ } else {
217
+ control.removeAttribute("aria-describedby");
218
+ }
219
+ };
220
+ }, [htmlFor, helpId]);
221
+
222
+ // Applied only when the caller named no size, so their Panda `fontSize` class
223
+ // is not beaten by an inline declaration.
224
+ const sized = fontSize
225
+ ? undefined
226
+ : fontSizeMap[stepDownFontSize(profile)] ?? fontSizeMap.sm;
227
+
228
+ return (
229
+ <PandaFieldHelp
230
+ ref={ref}
231
+ id={helpId}
232
+ fontSize={fontSize}
233
+ // A stable hook for the app's own end-to-end assertion that help is in
234
+ // the DOM with no pointer interaction (PRD-0037's success criteria), and
235
+ // for finding the call sites during the migration.
236
+ data-field-help="true"
237
+ style={{ ...(sized ? { fontSize: sized } : {}), ...style }}
238
+ {...props}
239
+ >
240
+ {children}
241
+ </PandaFieldHelp>
242
+ );
243
+ };
244
+
245
+ StyledFieldHelp.displayName = "StyledFieldHelp";
246
+
247
+ export default StyledFieldHelp;
248
+ export { StyledFieldHelp };
@@ -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
@@ -182,6 +192,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
182
192
  const canHover = useCanHover();
183
193
  const isClick = trigger === "click" || !canHover;
184
194
 
195
+
185
196
  // The child may be any component (StyledIconButton, a link, a bare span), so
186
197
  // whether it is focusable can only be known from the rendered DOM — React
187
198
  // cannot see inside a child component's output. Starts as "yes" so the common
@@ -233,9 +244,36 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
233
244
  // a duplicated one is a new bug.
234
245
  const [needsFallbackName, setNeedsFallbackName] = useState(false);
235
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
+
236
262
  useLayoutEffect(() => {
237
263
  const node = triggerRef.current;
238
- 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;
239
277
  // Same-value setState is a no-op in React, so this cannot loop.
240
278
  setFocusableChild((prev) => (prev === found ? prev : found));
241
279
  setHasFocusableChild(found !== null);
@@ -251,6 +289,20 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
251
289
  setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
252
290
 
253
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
+
254
306
  // parentElement, not the node itself: closest() would match our own
255
307
  // aria-label once we set one, and the answer would flip every render.
256
308
  const namedByAncestor = Boolean(
@@ -259,12 +311,12 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
259
311
  // Text content names an element for free; so does a labelled descendant
260
312
  // (an icon carrying its own aria-label, an <img alt>).
261
313
  const namedByContent =
262
- (node.textContent ?? "").trim().length > 0 ||
263
- Boolean(
264
- node.querySelector('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
265
- );
314
+ ownText.length > 0 ||
315
+ Array.from(
316
+ node.querySelectorAll('[aria-label], [aria-labelledby], img[alt]:not([alt=""])'),
317
+ ).some((el) => el !== help);
266
318
  setNeedsFallbackName(!namedByAncestor && !namedByContent);
267
- }, [children]);
319
+ }, [children, isClick]);
268
320
 
269
321
  // aria-describedby has to sit on whatever actually receives focus, or a screen
270
322
  // reader announces the control with no description. Set imperatively rather
@@ -482,6 +534,51 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
482
534
  // would produce "[object Object]" in the accessibility tree.
483
535
  const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
484
536
 
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;
485
582
 
486
583
  return (
487
584
  <>
@@ -519,24 +616,21 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
519
616
  onMouseLeave={isClick ? undefined : hide}
520
617
  onFocus={isClick ? undefined : show}
521
618
  onBlur={isClick ? undefined : hide}
522
- aria-describedby={
523
- !isClick && !insideFocusable && visible ? tooltipId : undefined
524
- }
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}
525
629
  {...rest}
526
630
  >
631
+ {helpGoesFirst && helpControl}
527
632
  {children}
528
- {isClick && (
529
- <HelpTrigger
530
- ref={helpRef}
531
- type="button"
532
- aria-label={helpLabel}
533
- aria-expanded={visible}
534
- aria-controls={visible ? tooltipId : undefined}
535
- onClick={() => setVisible((open) => !open)}
536
- >
537
- ?
538
- </HelpTrigger>
539
- )}
633
+ {!helpGoesFirst && helpControl}
540
634
  </TooltipTrigger>
541
635
  {visible && typeof document !== "undefined" &&
542
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
@@ -154,13 +154,30 @@ const COLOR_TOKENS: TokenMap = {
154
154
  * with a sensible default so every project can adopt it immediately — applied
155
155
  * to the case where a default is genuinely knowable.
156
156
  *
157
- * ## The percentages are measured, not chosen
157
+ * ## `textMuted` is measured. `textSubtle` is still only chosen (NEH-974)
158
158
  *
159
159
  * Alpha de-emphasis trades contrast for hierarchy, and past some point it
160
- * trades away legibility. `emphasis-contrast.ct.tsx` measures both tiers
161
- * against the harness theme in a real browser and asserts they clear WCAG AA
162
- * (4.5:1); the values below are what passed. A host that wants a stronger or
163
- * weaker step defines the property.
160
+ * trades away legibility, so these percentages want measuring rather than
161
+ * picking.
162
+ *
163
+ * This comment claimed both tiers were measured by a file called
164
+ * `emphasis-contrast.ct.tsx`, **and no such file has ever existed** — the whole
165
+ * repo contains exactly one reference to that name, this one. A documented
166
+ * guard nobody implemented is worse than an absent one: it is a guard everybody
167
+ * believes in, and it is what made "the values below are what passed" read as a
168
+ * measurement rather than as a guess.
169
+ *
170
+ * What is true today: `components/StyledFieldHelp.contrast.ct.tsx` measures
171
+ * **`textMuted`** in a real browser, composited over the surface it actually
172
+ * paints on rather than over the page, and asserts WCAG 1.4.3 AA (4.5:1). On
173
+ * the harness theme it clears comfortably — 11.7:1 on the page, 9.1:1 on an
174
+ * opaque card, 8.2:1 on a translucent chip over that card.
175
+ *
176
+ * **`textSubtle` at 64% is not measured anywhere.** It is the tier closer to
177
+ * the legibility floor, so it is the one that needed the check more. Tracked;
178
+ * do not restore the claim that it passed something.
179
+ *
180
+ * A host that wants a stronger or weaker step defines the property.
164
181
  */
165
182
  const EMPHASIS_TOKENS: Record<string, [suffix: string, fallback: string]> = {
166
183
  /** Secondary information: still read, just not first. */