@stonedogcode/style 0.12.0 → 0.15.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
@@ -12,7 +12,7 @@ the whole component set re-skins at runtime. No component here knows a colour.
12
12
 
13
13
  ## Status
14
14
 
15
- Early. The preset is complete (22 recipes, 43 colour tokens); the component set
15
+ Early. The preset is complete (23 recipes, 43 colour tokens); the component set
16
16
  is being extracted incrementally and currently covers the layout and typography
17
17
  primitives. See [CLAUDE.md](./CLAUDE.md) for the architecture and the
18
18
  contribution rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.12.0",
3
+ "version": "0.15.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,274 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { css, cx } from "styled-system/css";
5
+
6
+ /** What a celebration is asked for. */
7
+ export interface CelebrateOptions {
8
+ /** How many pieces to throw. */
9
+ particleCount: number;
10
+ /** Render these characters instead of coloured pieces. */
11
+ emojis?: ReadonlyArray<string> | undefined;
12
+ }
13
+
14
+ /**
15
+ * A host's own celebration.
16
+ *
17
+ * This is the seam that replaced a `js-confetti` import (NEH-430). A host that
18
+ * wants that library — or canvas-confetti, or a Lottie animation — passes a
19
+ * function; everyone else gets the CSS burst below.
20
+ *
21
+ * It may return a promise, in which case `onComplete` fires when it settles.
22
+ * A rejection is deliberately NOT propagated: a celebration that fails is not
23
+ * an error the user needs, and the surrounding flow (a save, a signup) has
24
+ * already succeeded by the time anything fires confetti.
25
+ */
26
+ export type CelebrateFn = (
27
+ options: CelebrateOptions,
28
+ ) => void | Promise<unknown>;
29
+
30
+ /** How long the default burst runs, in ms. Also the `onComplete` delay. */
31
+ const BURST_MS = 1200;
32
+
33
+ /**
34
+ * Tokens the default burst cycles through.
35
+ *
36
+ * Theme tokens rather than literal colours, so a celebration is on-brand and
37
+ * follows dark mode — and so this component does not become the one place in
38
+ * the package that knows a hex value.
39
+ */
40
+ const PARTICLE_TOKENS = [
41
+ "boxBgAccent",
42
+ "boxBgPrimary",
43
+ "boxBgSecondary",
44
+ "textAccent",
45
+ ] as const;
46
+
47
+ /**
48
+ * One pre-built class per particle colour.
49
+ *
50
+ * These are written out as four literal `css()` calls rather than generated in
51
+ * the render loop, and that is a requirement rather than a style preference:
52
+ * **Panda extracts styles by parsing source statically**, so
53
+ * `css({ backgroundColor: token })` — with `token` a variable — resolves to
54
+ * nothing and emits no rule, while the class name still lands in the DOM. The
55
+ * particles would be invisible, with no build error and nothing in the console.
56
+ * It is the same trap the CLAUDE.md note about `width={metrics.mark}` records.
57
+ */
58
+ const PARTICLE_CLASS: Record<(typeof PARTICLE_TOKENS)[number], string> = {
59
+ boxBgAccent: css({ backgroundColor: "boxBgAccent" }),
60
+ boxBgPrimary: css({ backgroundColor: "boxBgPrimary" }),
61
+ boxBgSecondary: css({ backgroundColor: "boxBgSecondary" }),
62
+ textAccent: css({ backgroundColor: "textAccent" }),
63
+ };
64
+
65
+ interface Particle {
66
+ id: number;
67
+ dx: string;
68
+ dy: string;
69
+ rot: string;
70
+ delay: string;
71
+ token: (typeof PARTICLE_TOKENS)[number];
72
+ emoji: string | undefined;
73
+ }
74
+
75
+ function buildParticles(
76
+ count: number,
77
+ emojis: ReadonlyArray<string> | undefined,
78
+ ): Particle[] {
79
+ const particles: Particle[] = [];
80
+ for (let i = 0; i < count; i += 1) {
81
+ // Upward-biased spread: real confetti is thrown up and falls, so a
82
+ // symmetric circle reads as an explosion rather than a celebration.
83
+ const angle = Math.PI + Math.random() * Math.PI;
84
+ const distance = 80 + Math.random() * 160;
85
+ particles.push({
86
+ id: i,
87
+ dx: `${Math.cos(angle) * distance}px`,
88
+ dy: `${Math.sin(angle) * distance}px`,
89
+ rot: `${Math.random() * 720 - 360}deg`,
90
+ delay: `${Math.random() * 150}ms`,
91
+ token: PARTICLE_TOKENS[i % PARTICLE_TOKENS.length]!,
92
+ emoji: emojis && emojis.length > 0 ? emojis[i % emojis.length] : undefined,
93
+ });
94
+ }
95
+ return particles;
96
+ }
97
+
98
+ /** Whether the user has asked for less motion. */
99
+ function prefersReducedMotion(): boolean {
100
+ if (typeof window === "undefined" || !window.matchMedia) return false;
101
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
102
+ }
103
+
104
+ export interface StyledConfettiProps {
105
+ /** Rising edge fires the celebration. Falling edge re-arms it. */
106
+ trigger?: boolean;
107
+ particleCount?: number;
108
+ emojis?: ReadonlyArray<string> | undefined;
109
+ /** Swap in a host implementation — see `CelebrateFn`. */
110
+ celebrate?: CelebrateFn | undefined;
111
+ /** Fires when the celebration has finished, however it was rendered. */
112
+ onComplete?: (() => void) | undefined;
113
+ }
114
+
115
+ /**
116
+ * A celebration, fired by a rising edge on `trigger`.
117
+ *
118
+ * ```tsx
119
+ * <StyledConfetti trigger={saved} onComplete={() => setSaved(false)} />
120
+ * ```
121
+ *
122
+ * ## The default is a real burst, not a no-op
123
+ *
124
+ * The component this replaces imported `js-confetti` — a dependency on every
125
+ * consumer for a decoration three call sites use. The seam is `celebrate`; the
126
+ * default is a CSS-only burst of themed particles, which needs no canvas, no
127
+ * library, and no host wiring.
128
+ *
129
+ * A no-op default was the other option the issue offered and would have been
130
+ * the weaker one: "nothing happens" is indistinguishable from "the seam is
131
+ * broken", and it is the reading someone reaches for first.
132
+ *
133
+ * ## It honours `prefers-reduced-motion`, and by skipping rather than shortening
134
+ *
135
+ * Confetti is purely decorative — it carries no information — which is exactly
136
+ * the category a reduced-motion preference is about. So when the preference is
137
+ * set the burst does not play at all, and **`onComplete` still fires**. That
138
+ * second half matters more than it looks: hosts commonly use `onComplete` to
139
+ * reset the trigger, so swallowing it would leave the flag stuck true and the
140
+ * celebration permanently armed.
141
+ *
142
+ * The check is deliberately made at fire time rather than subscribed to. A
143
+ * user changing the preference mid-burst is not worth a listener, and reading
144
+ * it during render would make the component's output differ between server and
145
+ * first client paint.
146
+ */
147
+ export const StyledConfetti: React.FC<StyledConfettiProps> = ({
148
+ trigger = false,
149
+ particleCount = 60,
150
+ emojis,
151
+ celebrate,
152
+ onComplete,
153
+ }) => {
154
+ const [particles, setParticles] = React.useState<Particle[] | null>(null);
155
+ const hasFired = React.useRef(false);
156
+
157
+ // The callback is held in a ref so it is not a dependency of the effect
158
+ // below. A host writing `onComplete={() => setSaved(false)}` inline passes a
159
+ // new function every render, which as a dependency would re-run the effect
160
+ // and re-fire the burst on every parent render.
161
+ const onCompleteRef = React.useRef(onComplete);
162
+ React.useEffect(() => {
163
+ onCompleteRef.current = onComplete;
164
+ }, [onComplete]);
165
+
166
+ React.useEffect(() => {
167
+ if (!trigger) {
168
+ // Falling edge re-arms, so the same component can celebrate twice.
169
+ hasFired.current = false;
170
+ return;
171
+ }
172
+ if (hasFired.current) return;
173
+ hasFired.current = true;
174
+
175
+ if (celebrate !== undefined) {
176
+ const result = celebrate({ particleCount, emojis });
177
+ if (result && typeof (result as Promise<unknown>).then === "function") {
178
+ // The SAME handler on both arms, rather than `.finally()`.
179
+ //
180
+ // Two things have to be true at once. A host implementation that
181
+ // rejects must still release the trigger — `onComplete` is what a host
182
+ // resets its flag in, so skipping it leaves the celebration armed for
183
+ // ever and nothing can fire again. And the rejection must be
184
+ // *consumed*: `.finally()` returns a promise that rejects onward, so it
185
+ // would satisfy the first requirement while emitting an unhandled
186
+ // rejection into the host's console for a decoration that failed. A
187
+ // two-armed `.then` does both.
188
+ void (result as Promise<unknown>).then(
189
+ () => onCompleteRef.current?.(),
190
+ () => onCompleteRef.current?.(),
191
+ );
192
+ } else {
193
+ onCompleteRef.current?.();
194
+ }
195
+ return;
196
+ }
197
+
198
+ if (prefersReducedMotion()) {
199
+ onCompleteRef.current?.();
200
+ return;
201
+ }
202
+
203
+ setParticles(buildParticles(particleCount, emojis));
204
+ const timer = setTimeout(() => {
205
+ setParticles(null);
206
+ onCompleteRef.current?.();
207
+ }, BURST_MS);
208
+
209
+ return () => clearTimeout(timer);
210
+ }, [trigger, particleCount, emojis, celebrate]);
211
+
212
+ if (particles === null) return null;
213
+
214
+ return (
215
+ <div
216
+ data-testid="styled-confetti"
217
+ // Decoration, and nothing else. `aria-hidden` because there is nothing
218
+ // here to announce, and `pointer-events: none` because a celebration
219
+ // that swallows the click on the button underneath it is a real bug.
220
+ aria-hidden="true"
221
+ className={css({
222
+ position: "fixed",
223
+ inset: "0",
224
+ pointerEvents: "none",
225
+ overflow: "hidden",
226
+ display: "grid",
227
+ placeItems: "center",
228
+ zIndex: "50",
229
+ })}
230
+ >
231
+ {particles.map((p) => (
232
+ <span
233
+ key={p.id}
234
+ data-testid="styled-confetti-particle"
235
+ className={cx(
236
+ css({
237
+ gridArea: "1 / 1",
238
+ width: "8px",
239
+ height: "8px",
240
+ borderRadius: "sm",
241
+ animation: "stonedogConfettiBurst 1.2s ease-out forwards",
242
+ }),
243
+ // Omitted for an emoji particle: the glyph is the decoration, and a
244
+ // coloured square behind it is not.
245
+ p.emoji === undefined ? PARTICLE_CLASS[p.token] : undefined,
246
+ )}
247
+ style={
248
+ {
249
+ // Per-particle values feeding the shared keyframe. See the
250
+ // keyframe's own comment for why these are custom properties and
251
+ // why they are not the theme namespace.
252
+ "--sd-confetti-dx": p.dx,
253
+ "--sd-confetti-dy": p.dy,
254
+ "--sd-confetti-rot": p.rot,
255
+ animationDelay: p.delay,
256
+ // An emoji particle is a glyph, so it must not also be a coloured
257
+ // square behind that glyph.
258
+ ...(p.emoji !== undefined
259
+ ? { fontSize: "1.5rem", width: "auto", height: "auto" }
260
+ : {}),
261
+ } as React.CSSProperties
262
+ }
263
+ {...(p.emoji === undefined
264
+ ? { "data-particle-token": p.token }
265
+ : {})}
266
+ >
267
+ {p.emoji}
268
+ </span>
269
+ ))}
270
+ </div>
271
+ );
272
+ };
273
+
274
+ export default StyledConfetti;
@@ -0,0 +1,94 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { css } from "styled-system/css";
5
+ import StyledAlert from "./StyledAlert";
6
+
7
+ /**
8
+ * One validation failure.
9
+ *
10
+ * `path` is the field it belongs to, as segments — `["address", "postcode"]`.
11
+ * An array rather than a dotted string because that is the shape every
12
+ * validator already produces, and joining is lossy in the one case that
13
+ * matters: a key containing a dot becomes indistinguishable from nesting.
14
+ */
15
+ export interface FieldError {
16
+ path: ReadonlyArray<string | number>;
17
+ message: string;
18
+ }
19
+
20
+ export interface StyledFieldErrorsProps {
21
+ errors: ReadonlyArray<FieldError>;
22
+ /** Heading above the list. */
23
+ title?: React.ReactNode;
24
+ className?: string;
25
+ }
26
+
27
+ /**
28
+ * A summary of validation failures.
29
+ *
30
+ * ```tsx
31
+ * <StyledFieldErrors errors={result.error.issues} />
32
+ * ```
33
+ *
34
+ * ## Renamed from `StyledZodErrorDisplay`, and that is the whole point
35
+ *
36
+ * The component it replaces took `z.ZodIssue[]`, which put **zod in the
37
+ * dependency list of a design system** — imposed on every consumer, including
38
+ * ones that validate with something else or not at all (NEH-430).
39
+ *
40
+ * Nothing about rendering a list of field errors is zod-specific. `FieldError`
41
+ * is structurally what `ZodIssue` already is for these purposes, so a zod host
42
+ * passes `result.error.issues` **unchanged** — `ZodIssue` has both `path` and
43
+ * `message` — and a yup/valibot/hand-rolled host maps two fields. The rename is
44
+ * not cosmetic: `StyledZodErrorDisplay` is a name that tells every reader the
45
+ * package knows about zod, which is the thing being removed.
46
+ *
47
+ * ## Two behaviours that deliberately differ from the original
48
+ *
49
+ * **It is not dismissible.** The original carried a `dismissed` state and an
50
+ * effect resetting it whenever `errors` changed. A summary the user can dismiss
51
+ * while the errors are still there — and while the submit button still refuses
52
+ * — is a way to hide the explanation for a form that will not submit. If a host
53
+ * wants that, it can conditionally render this component, which is clearer at
54
+ * the call site than a hidden state inside it.
55
+ *
56
+ * **It paints from tokens, not from `red.*`.** The original used `red.50` /
57
+ * `red.200` / `red.900/30` and a `_dark` block, which is a literal palette
58
+ * colour: right in one theme, wrong in every other, and invisible to the
59
+ * contrast floor. This delegates to `StyledAlert status="error"`, so it inherits
60
+ * the error tokens, the `role="alert"` announcement, and the non-colour glyph.
61
+ */
62
+ export const StyledFieldErrors = React.forwardRef<
63
+ HTMLDivElement,
64
+ StyledFieldErrorsProps
65
+ >(function StyledFieldErrors(
66
+ { errors, title = "Please fix the following:", className },
67
+ ref,
68
+ ) {
69
+ // Nothing to say, so say nothing. Rendering an empty alert would announce
70
+ // itself to a screen reader — `role="alert"` is an assertive live region —
71
+ // and interrupt the user to tell them about no problems.
72
+ if (errors.length === 0) return null;
73
+
74
+ return (
75
+ <StyledAlert
76
+ ref={ref}
77
+ status="error"
78
+ title={title}
79
+ {...(className !== undefined ? { className } : {})}
80
+ >
81
+ <ul className={css({ listStyle: "disc", paddingInlineStart: "5" })}>
82
+ {errors.map((error, index) => (
83
+ // The path is part of the key because two fields commonly fail the
84
+ // same rule with the same message ("Required"), and a message-only
85
+ // key would collide. The index is the tail-breaker for the case where
86
+ // one field carries two failures.
87
+ <li key={`${error.path.join(".")}-${index}`}>{error.message}</li>
88
+ ))}
89
+ </ul>
90
+ </StyledAlert>
91
+ );
92
+ });
93
+
94
+ export default StyledFieldErrors;
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { css, cx } from "styled-system/css";
5
+ import StyledFieldErrors, { type FieldError } from "./StyledFieldErrors";
6
+
7
+ export interface StyledFormProps
8
+ extends React.FormHTMLAttributes<HTMLFormElement> {
9
+ children: React.ReactNode;
10
+ /**
11
+ * Validation failures to summarise above the fields.
12
+ *
13
+ * `FieldError[]`, not `z.ZodIssue[]` — which is the whole reason this
14
+ * component can live here. A zod host passes `result.error.issues`
15
+ * unchanged; see `StyledFieldErrors`.
16
+ */
17
+ errors?: ReadonlyArray<FieldError>;
18
+ /** Heading for the error summary. */
19
+ errorsTitle?: React.ReactNode;
20
+ }
21
+
22
+ /**
23
+ * A form with a validation summary above its fields.
24
+ *
25
+ * ```tsx
26
+ * <StyledForm errors={issues} onSubmit={handleSubmit}>
27
+ * <StyledInputText … />
28
+ * </StyledForm>
29
+ * ```
30
+ *
31
+ * ## It renders a real `<form>`, which the original did not
32
+ *
33
+ * The component this replaces rendered a `StyledBox` — a `<div>`. That is not
34
+ * a cosmetic difference:
35
+ *
36
+ * - **Enter does not submit a div.** Pressing Enter in a text input submits the
37
+ * form it belongs to; in a div it does nothing, so every such form needed a
38
+ * pointer.
39
+ * - **Assistive technology loses the form role**, and with it the ability to
40
+ * navigate by form.
41
+ * - **`required`, `type="email"` and friends do nothing** without a form to
42
+ * validate against, which is the native validation the seam strategy for this
43
+ * component leans on.
44
+ *
45
+ * The summary is rendered *before* the fields deliberately: a summary below the
46
+ * inputs is one a keyboard user reaches only after passing everything it is
47
+ * telling them about.
48
+ */
49
+ export const StyledForm = React.forwardRef<HTMLFormElement, StyledFormProps>(
50
+ function StyledForm(
51
+ { children, errors, errorsTitle, className, ...rest },
52
+ ref,
53
+ ) {
54
+ return (
55
+ <form
56
+ ref={ref}
57
+ className={cx(
58
+ css({ display: "flex", flexDirection: "column", gap: "3" }),
59
+ className,
60
+ )}
61
+ {...rest}
62
+ >
63
+ {errors !== undefined && errors.length > 0 && (
64
+ <StyledFieldErrors
65
+ errors={errors}
66
+ {...(errorsTitle !== undefined ? { title: errorsTitle } : {})}
67
+ />
68
+ )}
69
+ {children}
70
+ </form>
71
+ );
72
+ },
73
+ );
74
+
75
+ export default StyledForm;