@stonedogcode/style 0.16.0 → 0.19.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.19.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.",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/stonedog-code/stonedog-style.git"
10
10
  },
11
- "//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer \u2014 which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
11
+ "//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
@@ -20,7 +20,7 @@
20
20
  "./preset": "./src/preset/index.ts",
21
21
  "./package.json": "./package.json"
22
22
  },
23
- "//files": "The package ships SOURCE, so `src` has to go out whole \u2014 but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
23
+ "//files": "The package ships SOURCE, so `src` has to go out whole but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
24
24
  "files": [
25
25
  "src",
26
26
  "!src/**/__tests__/**",
@@ -38,7 +38,7 @@
38
38
  "pretype-check": "npm run panda:prepare",
39
39
  "type-check": "tsc --noEmit",
40
40
  "lint": "eslint . --ext ts,tsx",
41
- "//pretest": "panda:build, not panda:prepare \u2014 the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
41
+ "//pretest": "panda:build, not panda:prepare the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
42
42
  "pretest": "npm run panda:build",
43
43
  "test": "jest",
44
44
  "pretest:ct": "npm run panda:build",
@@ -46,7 +46,7 @@
46
46
  "test:ct:ui": "playwright test --config playwright-ct.config.ts --ui",
47
47
  "test:coverage": "jest --coverage",
48
48
  "gate": "npm run type-check && npm run lint && npm run test",
49
- "//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively \u2014 npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
49
+ "//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
50
50
  "publish:stonedog-style": "bash scripts/publish-package.sh",
51
51
  "version:bump:patch": "npm version patch --no-git-tag-version",
52
52
  "version:bump:minor": "npm version minor --no-git-tag-version",
@@ -1,7 +1,8 @@
1
1
  "use client";
2
2
 
3
- import React, { useId, useState } from "react";
3
+ import React from "react";
4
4
  import { styled } from "styled-system/jsx";
5
+ import { useDisclosure } from "./useDisclosure";
5
6
 
6
7
  /**
7
8
  * A disclosure: a trigger that shows and hides a region.
@@ -82,41 +83,27 @@ const StyledCollapsible: React.FC<StyledCollapsibleProps> = ({
82
83
  triggerTestId,
83
84
  contentTestId,
84
85
  }) => {
85
- const [uncontrolled, setUncontrolled] = useState(defaultOpen);
86
-
87
- // Controlled the moment `open` is supplied, and uncontrolled otherwise
88
- // decided per render rather than latched at mount, because a host that
89
- // switches between the two mid-life has a bug we should not paper over by
90
- // silently ignoring the prop.
91
- const isControlled = open !== undefined;
92
- const isOpen = isControlled ? open : uncontrolled;
93
-
94
- const contentId = useId();
95
-
96
- const toggle = () => {
97
- const next = !isOpen;
98
- // The internal state moves even when controlled. If the host ignores the
99
- // callback the component would otherwise appear dead to the pointer, and a
100
- // control that does nothing when pressed is indistinguishable from a broken
101
- // one — for this audience, the reader concludes the app is broken, not that
102
- // they misread the affordance.
103
- if (!isControlled) setUncontrolled(next);
104
- onOpenChange?.(next);
105
- };
86
+ // The mechanics live in `useDisclosure`, not here. This component is one
87
+ // ARRANGEMENT of a disclosure — a button with the trigger inside it, above
88
+ // the region and a host whose control is already a button composes the
89
+ // same hook into its own layout instead of nesting one button in another
90
+ // (NEH-1100). Two arrangements, one implementation.
91
+ const { triggerProps, contentProps } = useDisclosure({
92
+ open,
93
+ defaultOpen,
94
+ onOpenChange,
95
+ });
106
96
 
107
97
  return (
108
98
  <>
109
99
  <CollapsibleTrigger
110
- type="button"
111
- onClick={toggle}
112
- aria-expanded={isOpen}
113
- aria-controls={contentId}
100
+ {...triggerProps}
114
101
  aria-label={ariaLabel}
115
102
  data-testid={triggerTestId}
116
103
  >
117
104
  {trigger}
118
105
  </CollapsibleTrigger>
119
- <div id={contentId} hidden={!isOpen} data-testid={contentTestId}>
106
+ <div {...contentProps} data-testid={contentTestId}>
120
107
  {children}
121
108
  </div>
122
109
  </>
@@ -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 };