@stonedogcode/style 0.9.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.
Files changed (75) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +699 -0
  4. package/package.json +95 -0
  5. package/src/components/DictationControls.tsx +141 -0
  6. package/src/components/DictationPrompt.tsx +78 -0
  7. package/src/components/StyledBox.tsx +174 -0
  8. package/src/components/StyledButton.tsx +144 -0
  9. package/src/components/StyledCollapsible.tsx +127 -0
  10. package/src/components/StyledDefinitionList.tsx +134 -0
  11. package/src/components/StyledFieldset.tsx +157 -0
  12. package/src/components/StyledFlex.tsx +13 -0
  13. package/src/components/StyledFooter.tsx +399 -0
  14. package/src/components/StyledFormLabel.tsx +141 -0
  15. package/src/components/StyledGrid.tsx +109 -0
  16. package/src/components/StyledGridItem.tsx +19 -0
  17. package/src/components/StyledHStack.tsx +145 -0
  18. package/src/components/StyledHeading.tsx +79 -0
  19. package/src/components/StyledHrRule.tsx +33 -0
  20. package/src/components/StyledIcon.tsx +172 -0
  21. package/src/components/StyledIconButton.tsx +135 -0
  22. package/src/components/StyledInputBool.tsx +81 -0
  23. package/src/components/StyledInputRadio.tsx +141 -0
  24. package/src/components/StyledInputSelect.tsx +115 -0
  25. package/src/components/StyledInputSlider.tsx +83 -0
  26. package/src/components/StyledInputText.tsx +146 -0
  27. package/src/components/StyledInputTextArea.tsx +119 -0
  28. package/src/components/StyledInputToggle.tsx +224 -0
  29. package/src/components/StyledList.tsx +188 -0
  30. package/src/components/StyledScrollbar.tsx +53 -0
  31. package/src/components/StyledSearch.tsx +78 -0
  32. package/src/components/StyledSeparator.tsx +38 -0
  33. package/src/components/StyledSidebar.tsx +555 -0
  34. package/src/components/StyledSimpleGrid.tsx +99 -0
  35. package/src/components/StyledSparkLine.tsx +119 -0
  36. package/src/components/StyledSpinner.tsx +91 -0
  37. package/src/components/StyledStack.tsx +62 -0
  38. package/src/components/StyledText.tsx +99 -0
  39. package/src/components/StyledTooltip.tsx +398 -0
  40. package/src/components/StyledVStack.tsx +143 -0
  41. package/src/components/TitleLogo.tsx +223 -0
  42. package/src/components/create-icon.tsx +66 -0
  43. package/src/components/create-intent-button.tsx +134 -0
  44. package/src/components/dictation.ts +71 -0
  45. package/src/components/intent-buttons.ts +154 -0
  46. package/src/config/can-hover.ts +75 -0
  47. package/src/config/density.ts +138 -0
  48. package/src/config/font-size.ts +113 -0
  49. package/src/config/intent-icons.tsx +116 -0
  50. package/src/config/logger.ts +60 -0
  51. package/src/config/style-config.tsx +263 -0
  52. package/src/config/types.ts +137 -0
  53. package/src/index.ts +259 -0
  54. package/src/preset/index.ts +243 -0
  55. package/src/preset/recipes/arrows.ts +29 -0
  56. package/src/preset/recipes/box.ts +122 -0
  57. package/src/preset/recipes/button.ts +161 -0
  58. package/src/preset/recipes/dl-list.ts +109 -0
  59. package/src/preset/recipes/drawer.ts +125 -0
  60. package/src/preset/recipes/form.ts +95 -0
  61. package/src/preset/recipes/icon-button.ts +161 -0
  62. package/src/preset/recipes/icon.ts +34 -0
  63. package/src/preset/recipes/input-bool.ts +184 -0
  64. package/src/preset/recipes/input-dropdown.ts +93 -0
  65. package/src/preset/recipes/input-radio.ts +158 -0
  66. package/src/preset/recipes/input-surface.ts +152 -0
  67. package/src/preset/recipes/input-text.ts +17 -0
  68. package/src/preset/recipes/list.ts +196 -0
  69. package/src/preset/recipes/menu.ts +28 -0
  70. package/src/preset/recipes/separator.ts +89 -0
  71. package/src/preset/recipes/stack.ts +89 -0
  72. package/src/preset/recipes/striped.ts +34 -0
  73. package/src/preset/recipes/text.ts +41 -0
  74. package/src/preset/recipes/tooltip.ts +77 -0
  75. package/src/preset/semantic-variables.ts +283 -0
@@ -0,0 +1,399 @@
1
+ "use client";
2
+
3
+ import React, { useCallback, useId, useState } from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import StyledBox from "./StyledBox";
6
+ import StyledText from "./StyledText";
7
+
8
+ /**
9
+ * The application footer: a bar that is always on screen, and a panel that
10
+ * opens to show version and service status (NEH-394).
11
+ *
12
+ * Shared by HopperGuard, rozcards.com and optimafilings.com, which is what
13
+ * every decision below follows from.
14
+ *
15
+ * ## It knows nothing about any product
16
+ *
17
+ * Copyright text, trademark legend, version strings, status content and the
18
+ * action buttons all arrive as props. In particular **the actions are a prop,
19
+ * not built in**: HopperGuard passes a row of controls, while rozcards and
20
+ * Optima pass a single dark-mode toggle. A component that knew what the buttons
21
+ * were could not be shared, which is the whole point.
22
+ *
23
+ * ## The toggle is its own control, and the actions are NOT inside it
24
+ *
25
+ * The obvious shape — make the whole bar the trigger — nests the action buttons
26
+ * inside a clickable region, so pressing "dark mode" also toggles the panel and
27
+ * screen readers announce a button inside a button. Here the trigger is one
28
+ * explicit control with `aria-expanded` / `aria-controls`, and the actions sit
29
+ * beside it as siblings.
30
+ *
31
+ * It opens on click, never hover: a panel that closes when the pointer drifts
32
+ * fails anyone whose hand is unsteady, and the harder they try the more it
33
+ * moves.
34
+ *
35
+ * ## No Chakra
36
+ *
37
+ * HopperGuard's version is built on a Chakra `Collapsible`. This package cannot
38
+ * take that dependency, so the disclosure is rebuilt here on a button and a
39
+ * region. It is deliberately NOT `<details>`/`<summary>` either — a `<summary>`
40
+ * would have to contain the action buttons to keep them on the collapsed bar,
41
+ * which is the nested-interactive problem again.
42
+ *
43
+ * ## Sizing lives in inline `style`
44
+ *
45
+ * Panda extracts LITERAL values only, so a metric passed as a prop resolves to
46
+ * no CSS while the class name still lands in the DOM. Anything dimensional here
47
+ * is an inline style reaching the host's scale through custom properties, which
48
+ * also makes it immune to a consumer's Panda `include` glob being wrong.
49
+ */
50
+
51
+ const Bar = styled("div", {
52
+ base: {
53
+ display: "flex",
54
+ alignItems: "center",
55
+ justifyContent: "space-between",
56
+ gap: 2,
57
+ width: "100%",
58
+ flexWrap: "wrap",
59
+ },
60
+ });
61
+
62
+ /**
63
+ * `minHeight`/`minWidth` are stated rather than left to emerge from padding, so
64
+ * no density rung or font scale can erode the target below the house floor of
65
+ * 48px (above WCAG 2.5.5 AAA's 44, deliberately — our readers mis-aim more).
66
+ */
67
+ const ToggleButton = styled("button", {
68
+ base: {
69
+ display: "inline-flex",
70
+ alignItems: "center",
71
+ justifyContent: "center",
72
+ gap: 2,
73
+ minHeight: "48px",
74
+ minWidth: "48px",
75
+ paddingInline: 3,
76
+ borderRadius: "md",
77
+ border: "1px solid transparent",
78
+ backgroundColor: "transparent",
79
+ color: "textMain",
80
+ cursor: "pointer",
81
+ _hover: { textDecoration: "underline" },
82
+ _focusVisible: { outline: "2px solid", outlineOffset: "2px" },
83
+ },
84
+ });
85
+
86
+ /** Rotates rather than swapping glyphs, so there is one element to label. */
87
+ const Chevron = styled("span", {
88
+ base: {
89
+ display: "inline-block",
90
+ lineHeight: 1,
91
+ transition: "transform 150ms ease",
92
+ },
93
+ });
94
+
95
+ /**
96
+ * Links get their OWN line inside the panel rather than sharing the meta row.
97
+ *
98
+ * `width: 100%` is what forces the break: Panel wraps, so a full-width child
99
+ * necessarily starts a new line. HopperGuard's row is seven-plus links with
100
+ * separators, and inlining it beside the version chips leaves the two reading
101
+ * as one run-on sentence.
102
+ */
103
+ const LinkRow = styled("div", {
104
+ base: {
105
+ display: "flex",
106
+ flexWrap: "wrap",
107
+ alignItems: "center",
108
+ gap: 2,
109
+ width: "100%",
110
+ },
111
+ });
112
+
113
+ const Panel = styled("div", {
114
+ base: {
115
+ display: "flex",
116
+ flexWrap: "wrap",
117
+ alignItems: "center",
118
+ gap: 4,
119
+ width: "100%",
120
+ paddingBlockStart: 2,
121
+ },
122
+ });
123
+
124
+ export interface StyledFooterVersion {
125
+ /** Human-facing release name, e.g. "Spring 2026". */
126
+ name?: string | undefined;
127
+ /** Build identifier, e.g. the package version. */
128
+ build?: string | undefined;
129
+ }
130
+
131
+ export interface StyledFooterStatusBadge {
132
+ /**
133
+ * Where to load the badge image from.
134
+ *
135
+ * **A SAME-ORIGIN path, not the monitoring vendor's URL.** This is the whole
136
+ * reason the prop takes a URL rather than a monitor id, and it is not a
137
+ * preference:
138
+ *
139
+ * A cross-origin image that fails to load logs a console error, and nothing
140
+ * the element does can suppress it — `onError` can hide the picture but
141
+ * cannot un-log the request. HopperGuard's post-deploy smoke asserts zero
142
+ * console errors, so a monitoring host having a bad day **withholds a
143
+ * production release tag**. That has already happened twice (NEH-387), which
144
+ * is why an embedded status page was removed from that app entirely.
145
+ *
146
+ * The shape that works is a route on your own origin that proxies the vendor
147
+ * and **always answers with an image** — a placeholder SVG on failure, never
148
+ * a 4xx/5xx. HopperGuard's `/api/status-badge` does exactly that, and reads
149
+ * its `UPTIMESIGNAL_MONITOR_ID` from server env, so the id never reaches the
150
+ * browser and this component never needs to know it.
151
+ */
152
+ src: string;
153
+ /**
154
+ * Accessible name, used as the image's `alt`. Default: "Service status".
155
+ *
156
+ * Not empty by default: the badge carries information (a service is up or it
157
+ * is not), so it is not decorative, and `alt=""` would hide that from a
158
+ * screen reader entirely.
159
+ */
160
+ label?: string | undefined;
161
+ /** Optional link to a fuller status page, wrapped around the badge. */
162
+ href?: string | undefined;
163
+ }
164
+
165
+ export interface StyledFooterProps {
166
+ /** Always visible. The consumer's own line — no product is named here. */
167
+ copyright: string;
168
+ /**
169
+ * Always visible, on its OWN line beneath the copyright.
170
+ *
171
+ * Rendered as a block: `StyledText` is a `<span>`, and two inline siblings
172
+ * share a line however their container is styled — the defect NEH-388 fixed
173
+ * in HopperGuard's footer, reproduced here if this were left inline.
174
+ */
175
+ legend?: string | undefined;
176
+ /** Always visible, at the end of the bar. Whatever controls the product has. */
177
+ actions?: React.ReactNode | undefined;
178
+ /** Shown in the panel when open. */
179
+ version?: StyledFooterVersion | undefined;
180
+ /**
181
+ * An uptime badge, shown in the panel when open.
182
+ *
183
+ * **Optional, and the footer is complete without it.** Every product using
184
+ * this today has a monitor, but a page that has none — a marketing site, a
185
+ * preview build, a product before its monitor exists — must still render a
186
+ * correct footer rather than a gap or a broken image. Omitting it renders
187
+ * nothing at all, and a test pins that.
188
+ *
189
+ * See `StyledFooterStatusBadge.src` for why this is a URL and not a monitor
190
+ * id. It is the difference between a badge and a withheld release tag.
191
+ */
192
+ statusBadge?: StyledFooterStatusBadge | undefined;
193
+ /**
194
+ * A row of links, shown in the panel when open, on its own line.
195
+ *
196
+ * **Optional, and two of the three products have none.** HopperGuard carries
197
+ * Privacy, Terms, its compliance pages and (only when signed in) Release
198
+ * Notes; rozcards and Optima pass nothing and no empty row is rendered.
199
+ *
200
+ * A slot rather than a `{href,label}[]`: HopperGuard interleaves separators,
201
+ * gates one entry on session state, and builds part of the list from config.
202
+ * A data-shaped prop would have to grow a predicate and a separator strategy
203
+ * to express that, and would still be wrong for the next consumer.
204
+ */
205
+ links?: React.ReactNode | undefined;
206
+ /**
207
+ * Anything else for the panel — extra links, a build date, a region.
208
+ *
209
+ * The general escape hatch, kept alongside `statusBadge` rather than replaced
210
+ * by it. `statusBadge` exists because three products would otherwise each
211
+ * hand-roll the same `<img>` and each get the failure handling subtly
212
+ * different; this is for the cases it does not cover.
213
+ */
214
+ status?: React.ReactNode | undefined;
215
+ /** Controlled open state. Omit to let the component own it. */
216
+ open?: boolean | undefined;
217
+ /** Initial state when uncontrolled. Closed by default: the bar is the point. */
218
+ defaultOpen?: boolean | undefined;
219
+ onOpenChange?: ((open: boolean) => void) | undefined;
220
+ /** Visible toggle text. Icons alone assume the reader already knows. */
221
+ showDetailsLabel?: string | undefined;
222
+ hideDetailsLabel?: string | undefined;
223
+ "data-testid"?: string | undefined;
224
+ }
225
+
226
+ /**
227
+ * The badge, or nothing.
228
+ *
229
+ * Hides itself if the image fails rather than leaving a broken-image icon in
230
+ * the footer. Note what this does NOT do: it cannot prevent the browser logging
231
+ * the failed request. That is why `src` must be a same-origin route that always
232
+ * answers with an image — see `StyledFooterStatusBadge.src`. This handles the
233
+ * cosmetic half; the src choice handles the half that breaks deploys.
234
+ */
235
+ function StatusBadge({ src, label, href }: StyledFooterStatusBadge) {
236
+ const [failed, setFailed] = useState(false);
237
+ if (failed) return null;
238
+
239
+ const img = (
240
+ <img
241
+ src={src}
242
+ alt={label ?? "Service status"}
243
+ // Height only: a badge's width varies with its text ("99.98% uptime" is
244
+ // wider than "up"), so constraining both would distort it. A literal in
245
+ // inline style rather than a Panda prop, because a consumer's `include`
246
+ // glob being wrong would otherwise leave this unsized.
247
+ style={{ height: "20px", display: "block" }}
248
+ onError={() => setFailed(true)}
249
+ data-testid="footer-status-badge"
250
+ />
251
+ );
252
+
253
+ if (!href) return img;
254
+ return (
255
+ <a href={href} target="_blank" rel="noreferrer noopener" data-testid="footer-status-link">
256
+ {img}
257
+ </a>
258
+ );
259
+ }
260
+
261
+ export function StyledFooter({
262
+ copyright,
263
+ legend,
264
+ actions,
265
+ version,
266
+ statusBadge,
267
+ status,
268
+ links,
269
+ open,
270
+ defaultOpen = false,
271
+ onOpenChange,
272
+ showDetailsLabel = "Details",
273
+ hideDetailsLabel = "Hide details",
274
+ "data-testid": testId = "styled-footer",
275
+ }: StyledFooterProps) {
276
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
277
+ const panelId = useId();
278
+
279
+ // Controlled when `open` is supplied, uncontrolled otherwise — both, because
280
+ // HopperGuard drives this from app state while a marketing site does not want
281
+ // to hold state it has no other use for.
282
+ const isOpen = open ?? uncontrolledOpen;
283
+
284
+ const toggle = useCallback(() => {
285
+ const next = !isOpen;
286
+ if (open === undefined) setUncontrolledOpen(next);
287
+ onOpenChange?.(next);
288
+ }, [isOpen, open, onOpenChange]);
289
+
290
+ return (
291
+ <StyledBox as="footer" width="100%" p={2} data-testid={testId}>
292
+ <Bar>
293
+ {/*
294
+ `flex: 1 1 auto` / `width: auto` are LOAD-BEARING, not tidying.
295
+
296
+ boxRecipe's base is `width: 100%` — right for the many places
297
+ StyledBox is a layout container, wrong for exactly this one. As a flex
298
+ item it claimed the whole line, and because the Bar wraps, the actions
299
+ and toggle were pushed to a SECOND ROW, where the Bar's
300
+ `space-between` no longer governs them and they bunched left. That is
301
+ the reported defect: on Optima the "Details" button sat under the
302
+ copyright with the action beside it, at a width where everything fits
303
+ on one row.
304
+
305
+ `minWidth: 0` lets a long copyright shrink instead of forcing the wrap
306
+ back — a flex item otherwise refuses to go below its content width,
307
+ and the second row returns at narrower viewports.
308
+ */}
309
+ <StyledBox
310
+ p={0}
311
+ data-testid="footer-identity"
312
+ style={{ flex: "1 1 auto", width: "auto", minWidth: 0 }}
313
+ >
314
+ <StyledText size="lg" display="block" data-testid="footer-copyright">
315
+ {copyright}
316
+ </StyledText>
317
+ {legend && (
318
+ <StyledText
319
+ size="sm"
320
+ color="textSecondary"
321
+ display="block"
322
+ data-testid="footer-legend"
323
+ >
324
+ {legend}
325
+ </StyledText>
326
+ )}
327
+ </StyledBox>
328
+
329
+ {/*
330
+ `marginInlineStart: auto` pins this group right even when the
331
+ copyright is short — `space-between` alone only does that while the
332
+ left column is the sole other child, which stops being true the
333
+ moment anything else is added.
334
+
335
+ ACTIONS COME FIRST, TOGGLE LAST. The expander belongs at the far
336
+ right: it is the control that changes the footer's shape, and a
337
+ reader scanning right-to-left for "how do I see more" should find it
338
+ at the edge rather than between the product's own buttons. Previously
339
+ the toggle rendered before `actions`, so it could never be the
340
+ rightmost element however the row was laid out.
341
+ */}
342
+ <Bar style={{ width: "auto", flex: "0 0 auto", marginInlineStart: "auto" }}>
343
+ {actions}
344
+ <ToggleButton
345
+ type="button"
346
+ onClick={toggle}
347
+ aria-expanded={isOpen}
348
+ // Only while the panel EXISTS. It is unmounted when closed
349
+ // (deliberately — see the panel below), and aria-controls pointing
350
+ // at an absent id is invalid ARIA that some screen readers announce
351
+ // oddly. Spread rather than `aria-controls={undefined}` so the
352
+ // attribute is genuinely absent rather than present-and-empty.
353
+ {...(isOpen ? { "aria-controls": panelId } : {})}
354
+ data-testid="footer-toggle"
355
+ >
356
+ <Chevron
357
+ aria-hidden="true"
358
+ style={{ transform: isOpen ? "rotate(90deg)" : "rotate(0deg)" }}
359
+ >
360
+
361
+ </Chevron>
362
+ {/* Visible, not just an aria-label: a label a sighted reader
363
+ cannot see is no label for the audience this serves. */}
364
+ <StyledText size="sm">
365
+ {isOpen ? hideDetailsLabel : showDetailsLabel}
366
+ </StyledText>
367
+ </ToggleButton>
368
+ </Bar>
369
+ </Bar>
370
+
371
+ {/* Unmounted rather than hidden: nothing in the panel should be tabbable,
372
+ announced, or fetching while it is closed. */}
373
+ {isOpen && (
374
+ <Panel id={panelId} data-testid="footer-panel">
375
+ {version?.name && (
376
+ <StyledText size="sm" data-testid="footer-version-name">
377
+ {version.name}
378
+ </StyledText>
379
+ )}
380
+ {version?.build && (
381
+ <StyledText size="sm" color="textSecondary" data-testid="footer-version-build">
382
+ {`build ${version.build}`}
383
+ </StyledText>
384
+ )}
385
+ {/* `key` on src: StatusBadge latches `failed` so a broken image does
386
+ not leave a torn icon in the footer, but that latch must not
387
+ outlive the URL that failed. Re-keying remounts it, which is the
388
+ cheapest correct reset — without it a badge that failed once
389
+ stays hidden after the src is fixed. */}
390
+ {statusBadge && <StatusBadge key={statusBadge.src} {...statusBadge} />}
391
+ {status}
392
+ {links && <LinkRow data-testid="footer-links">{links}</LinkRow>}
393
+ </Panel>
394
+ )}
395
+ </StyledBox>
396
+ );
397
+ }
398
+
399
+ export default StyledFooter;
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { styled } from "styled-system/jsx";
5
+ import type { HTMLStyledProps } from "styled-system/types";
6
+ import { useFontSizeProfile } from "../config/style-config";
7
+ import { fontSizeMap } from "../config/font-size";
8
+
9
+ /**
10
+ * The label above a form control.
11
+ *
12
+ * Clicking it focuses the control, which is most of the point: it turns a
13
+ * 14×14px checkbox into a target the size of its own text, and that is the
14
+ * difference between usable and not for anyone with a tremor. Always pass
15
+ * `htmlFor` — without it the association is lost and so is the tap target.
16
+ *
17
+ * ## Three hardcoded colours were fixed on the way in
18
+ *
19
+ * The originating version painted itself with literals — a dark grey label, a
20
+ * `#e53e3e` required asterisk, a `#888` "(optional)". Each looks right on a
21
+ * light theme and wrong on every other, and none of them respond to dark mode
22
+ * or a high-contrast theme at all. They are now `textPrimary`, `textError` and
23
+ * `textSecondary`, which is the same defect class as NEH-165/166/171.
24
+ *
25
+ * The label colour was the interesting one. It read
26
+ * `var(--chakra-colors-gray-700, #2D3748)` — a **Chakra** custom property, in
27
+ * an app that finished removing Chakra. Nothing had defined that property for
28
+ * some time, so every label in the product was silently painted by the
29
+ * fallback. It was invisible because it *worked*: the fallback is a reasonable
30
+ * grey, so nothing looked broken, and the label simply sat outside theming.
31
+ *
32
+ * ## It follows the app-wide text size (NEH-233)
33
+ *
34
+ * The label used to declare a flat `fontSize: "1rem"`, which opted it out of
35
+ * the type scale. In a product whose default body text is 1.375rem that made
36
+ * the label the *smaller* text — the caption under its own field — and, worse,
37
+ * it did not move when a user raised their text-size setting. That setting is
38
+ * the accommodation a low-vision reader actually reaches for, so a control
39
+ * ignoring it is ignoring them.
40
+ *
41
+ * It now reads the profile the same way `StyledText` does. Note that plain
42
+ * inheritance would NOT have worked: nothing puts a font size on the DOM, so
43
+ * dropping the declaration just pinned the label to the browser's 16px — still
44
+ * fixed, and no longer even declared. The profile has to be read.
45
+ *
46
+ * Callers wanting a specific size still pass `fontSize`, which wins. That is
47
+ * the right way round: the deviation is visible at the call site instead of
48
+ * baked into every label in every consumer.
49
+ *
50
+ * **This visibly resizes existing labels** — that was the whole reason it was
51
+ * split out of the migration rather than smuggled into it.
52
+ *
53
+ * ## `required` and `optional` are not symmetric, deliberately
54
+ *
55
+ * `optional` renders visible text, because "(optional)" is information the
56
+ * reader needs and there is no other channel carrying it.
57
+ *
58
+ * `required` renders an asterisk marked `aria-hidden`. That is correct **only
59
+ * when the control itself is `required` or `aria-required`** — then assistive
60
+ * tech announces the requirement from the input, and an announced "star" on top
61
+ * of it is noise. If your control does not set that attribute, the asterisk is
62
+ * decoration and screen-reader users are told nothing, so set it. This is the
63
+ * one thing about this component worth checking at a call site.
64
+ */
65
+
66
+ const PandaFormLabel = styled("label", {
67
+ base: {
68
+ display: "block",
69
+ fontWeight: "bold",
70
+ marginBottom: "0.5rem",
71
+ color: "textPrimary",
72
+ // No fontSize — the label inherits, so it follows the app-wide text size.
73
+ // See the NEH-233 note above.
74
+ lineHeight: "1.25",
75
+ cursor: "pointer",
76
+ },
77
+ });
78
+
79
+ export interface StyledFormLabelProps extends HTMLStyledProps<"label"> {
80
+ children: React.ReactNode;
81
+ /** The `id` of the control this labels. Pass it — see above. */
82
+ htmlFor?: string;
83
+ /** Append a muted "(optional)". */
84
+ optional?: boolean;
85
+ /** Append a red asterisk. Mark the control `required` too — see above. */
86
+ required?: boolean;
87
+ }
88
+
89
+ const StyledFormLabel: React.FC<StyledFormLabelProps> = ({
90
+ children,
91
+ htmlFor,
92
+ optional,
93
+ required,
94
+ fontSize,
95
+ style,
96
+ ...props
97
+ }) => {
98
+ // Unconditional, at the top: inlining this into the expression below reads
99
+ // fine and is a hooks-order violation the moment `fontSize` is passed.
100
+ const profile = useFontSizeProfile();
101
+
102
+ // An inline style, not a Panda prop — and this is the part that is easy to
103
+ // get wrong twice. Panda extracts styles by parsing source at BUILD time, so
104
+ // a prop whose value is only known at runtime produces a class name with no
105
+ // rule behind it: the element renders at the browser default and nothing
106
+ // errors. `StyledText` reaches for an inline style for exactly this reason.
107
+ //
108
+ // Applied only when the caller named no size, so their Panda `fontSize` class
109
+ // is not beaten by an inline declaration.
110
+ const sized = fontSize ? undefined : fontSizeMap[profile] ?? fontSizeMap.md;
111
+
112
+ return (
113
+ <PandaFormLabel
114
+ htmlFor={htmlFor}
115
+ fontSize={fontSize}
116
+ style={{ ...(sized ? { fontSize: sized } : {}), ...style }}
117
+ {...props}
118
+ >
119
+ {children}
120
+ {required && (
121
+ <styled.span color="textError" marginLeft="0.25em" aria-hidden="true">
122
+ *
123
+ </styled.span>
124
+ )}
125
+ {optional && (
126
+ <>
127
+ {/* A real space, not a margin. The margin separated the words visually
128
+ but not in the accessible name, which came out "Middle
129
+ name(optional)" — the space has to be a text node to be announced. */}{" "}
130
+ <styled.span fontWeight="normal" color="textSecondary" fontSize="0.95em">
131
+ (optional)
132
+ </styled.span>
133
+ </>
134
+ )}
135
+ </PandaFormLabel>
136
+ );
137
+ };
138
+
139
+ StyledFormLabel.displayName = "StyledFormLabel";
140
+
141
+ export default StyledFormLabel;
@@ -0,0 +1,109 @@
1
+ import { styled, type HTMLStyledProps } from "styled-system/jsx";
2
+ import { cx, css } from "styled-system/css";
3
+ import { stripedRecipe } from "styled-system/recipes";
4
+ import React from "react";
5
+ import { log } from "../config/logger";
6
+ import type { ConditionalValue } from "styled-system/types";
7
+
8
+ const PandaGrid = styled("div", {
9
+ base: {
10
+ display: "grid",
11
+ },
12
+ });
13
+
14
+ export interface StyledGridProps extends Omit<HTMLStyledProps<"div">, "columns"> {
15
+ children?: React.ReactNode;
16
+ isStriped?: boolean;
17
+ showGridLines?: boolean;
18
+ templateColumns?: ConditionalValue<string>;
19
+ templateRows?: ConditionalValue<string>;
20
+ templateAreas?: ConditionalValue<string>;
21
+ columns?: ConditionalValue<number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }>;
22
+ }
23
+
24
+ const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
25
+ ({
26
+ isStriped,
27
+ showGridLines,
28
+ className,
29
+ templateColumns,
30
+ templateRows,
31
+ templateAreas,
32
+ columns, ...props }, ref) => {
33
+ const combinedClassName = cx(
34
+ isStriped ? stripedRecipe() : undefined,
35
+ showGridLines
36
+ ? css({
37
+ "& > *": {
38
+ outline: "1px solid",
39
+ outlineColor: "borderBgPrimary",
40
+ backgroundColor: "boxBgPrimary",
41
+ borderRadius: "15px",
42
+ },
43
+ })
44
+ : undefined,
45
+ className,
46
+ );
47
+
48
+ // Map shorthand props to CSS grid properties
49
+ const gridTemplateColumns = props.gridTemplateColumns ?? templateColumns;
50
+ const gridTemplateRows = props.gridTemplateRows ?? templateRows;
51
+ const gridTemplateAreas = props.gridTemplateAreas ?? templateAreas;
52
+ delete props.gridTemplateColumns;
53
+ delete props.gridTemplateRows;
54
+ delete props.gridTemplateAreas;
55
+
56
+ // Handle columns prop (responsive column count)
57
+ let resolvedColumns = gridTemplateColumns;
58
+ if (columns !== undefined && !resolvedColumns) {
59
+ if (typeof columns === "number") {
60
+ resolvedColumns = `repeat(${columns}, 1fr)`;
61
+ } else if (typeof columns === "object" && columns !== null) {
62
+ const colObj = columns as { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
63
+ // For responsive objects, we need to generate a responsive value
64
+ const responsive: Record<string, string> = {};
65
+ if (colObj.base !== undefined) responsive.base = `repeat(${colObj.base}, 1fr)`;
66
+ if (colObj.sm !== undefined) responsive.sm = `repeat(${colObj.sm}, 1fr)`;
67
+ if (colObj.md !== undefined) responsive.md = `repeat(${colObj.md}, 1fr)`;
68
+ if (colObj.lg !== undefined) responsive.lg = `repeat(${colObj.lg}, 1fr)`;
69
+ if (colObj.xl !== undefined) responsive.xl = `repeat(${colObj.xl}, 1fr)`;
70
+ resolvedColumns = responsive as ConditionalValue<string>;
71
+ }
72
+ }
73
+
74
+ const childrenDetails = React.Children.map(props.children, (child) => {
75
+ if (React.isValidElement(child)) {
76
+ const element = child as React.ReactElement<{ id?: string }>;
77
+ const params = element.props;
78
+ return {
79
+ key: child.key,
80
+ id: params?.id,
81
+ type: typeof child.type === 'string' ? child.type : (child.type as React.FunctionComponent).displayName || (child.type as React.FunctionComponent).name || 'Unknown',
82
+ isNull: child === null
83
+ };
84
+ }
85
+ return "Non-Element Child";
86
+ });
87
+
88
+ log.debug("[StyledGrid] Rendering Grid", {
89
+ childCount: React.Children.count(props.children),
90
+ showGridLines,
91
+ childrenDetails
92
+ });
93
+
94
+ return (
95
+ <PandaGrid
96
+ ref={ref}
97
+ className={combinedClassName}
98
+ gridTemplateColumns={resolvedColumns}
99
+ gridTemplateRows={gridTemplateRows}
100
+ gridTemplateAreas={gridTemplateAreas}
101
+ {...props}
102
+ />
103
+ );
104
+ },
105
+ );
106
+
107
+ StyledGrid.displayName = "StyledGrid";
108
+
109
+ export default StyledGrid;
@@ -0,0 +1,19 @@
1
+ import { styled } from "styled-system/jsx";
2
+ import type { HTMLStyledProps } from "styled-system/types";
3
+
4
+ const PandaGridItem = styled("div", {
5
+ base: {
6
+ gridColumn: "auto",
7
+ gridRow: "auto",
8
+ minWidth: 0,
9
+ minHeight: 0,
10
+ },
11
+ });
12
+
13
+ export type StyledGridItemProps = HTMLStyledProps<"div">;
14
+
15
+ const StyledGridItem = PandaGridItem;
16
+
17
+ StyledGridItem.displayName = "StyledGridItem";
18
+
19
+ export default StyledGridItem;