@stonedogcode/style 0.10.1 → 0.13.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.
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ /**
6
+ * The props any link implementation must accept.
7
+ *
8
+ * Deliberately the native anchor's own surface plus a required `href`. That is
9
+ * not a compromise to keep the type simple — it is the boundary. A router's
10
+ * link component accepts these and adds its own (prefetch, scroll, replace);
11
+ * this package neither knows nor passes those, so the extra props stay the
12
+ * host's business and no routing concept leaks in here.
13
+ *
14
+ * `href` is a `string`, not `string | UrlObject`. Next.js accepts the object
15
+ * form, but naming it here would put a Next.js type in a package whose whole
16
+ * premise is that it has none — and a host that wants the object form can wrap
17
+ * its own component and take it there, which is exactly what the seam is for.
18
+ */
19
+ export interface LinkComponentProps
20
+ extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
21
+ href: string;
22
+ children?: React.ReactNode;
23
+ }
24
+
25
+ /**
26
+ * What `StyledLink` renders for an in-app destination.
27
+ *
28
+ * A host swaps in its router's link — `next/link`, `react-router`'s `Link`,
29
+ * whatever it has — and gets client-side navigation and prefetching. A host
30
+ * that says nothing gets `DefaultLinkComponent` below.
31
+ *
32
+ * Typed as a component rather than an `ElementType` union so the intrinsic
33
+ * `"a"` string is not a valid value. Allowing it would mean two ways to say the
34
+ * same thing, and the one that looks simpler is the one that cannot be given a
35
+ * `displayName` or wrapped.
36
+ *
37
+ * **It must forward its ref to the underlying anchor.** `RefAttributes` is in
38
+ * the props type rather than left implicit because this package's peer range
39
+ * starts at React 18, where a `ref` handed to a plain function component is not
40
+ * a prop — React 18 drops it and logs "Function components cannot be given
41
+ * refs". Stating it in the type is what makes that a compile error for the host
42
+ * instead of a console warning nobody reads. `next/link` and react-router's
43
+ * `Link` both forward already, so the common cases need nothing.
44
+ */
45
+ export type LinkComponent = React.ComponentType<
46
+ LinkComponentProps & React.RefAttributes<HTMLAnchorElement>
47
+ >;
48
+
49
+ /**
50
+ * A plain anchor, and the reason this seam is safe to leave unconfigured.
51
+ *
52
+ * This is the point of the whole arrangement (NEH-430): the default is not a
53
+ * placeholder that throws, warns, or renders nothing until someone wires a
54
+ * router. It is a **real, correct, accessible link** — it navigates, it opens in
55
+ * a new tab when told to, middle-click and "open in new window" work, and a
56
+ * screen reader announces it as a link. What a host gains by overriding is
57
+ * client-side navigation and prefetching: real benefits, and neither of them
58
+ * load-bearing for the link *working*.
59
+ *
60
+ * A seam whose default is broken is a required configuration step wearing a
61
+ * disguise, and it recreates exactly the adoption deadlock this issue set out
62
+ * to remove.
63
+ */
64
+ export const DefaultLinkComponent: LinkComponent = React.forwardRef<
65
+ HTMLAnchorElement,
66
+ LinkComponentProps
67
+ >(function DefaultLinkComponent(props, ref) {
68
+ return <a ref={ref} {...props} />;
69
+ }) as LinkComponent;
@@ -5,6 +5,7 @@ import type { DensityProfile, FontSizeProfile, IconSize, ThemeVariant } from "./
5
5
  import { resolveDensityStep, type DensityBase, type DensityStep } from "./density";
6
6
  import { THEME_VARIANTS } from "./types";
7
7
  import { IntentIconProvider, type IntentIcons } from "./intent-icons";
8
+ import { DefaultLinkComponent, type LinkComponent } from "./link-component";
8
9
 
9
10
  /**
10
11
  * Everything this component library needs to know about the host application.
@@ -69,6 +70,26 @@ export interface StyleConfig {
69
70
  * (a business tool for a general audience) sets `"md"`.
70
71
  */
71
72
  iconSize: IconSize;
73
+
74
+ /**
75
+ * What `StyledLink` renders for an in-app destination (NEH-430).
76
+ *
77
+ * This is a seam, not a dependency. `StyledLink` used to import `next/link`
78
+ * directly, which is why it could not live in this package at all — CLAUDE.md
79
+ * names `next/*` as forbidden, and three products with three framework
80
+ * choices cannot be made to share one.
81
+ *
82
+ * The default is a plain `<a>`: a real, working, accessible link, not a
83
+ * placeholder. A host passes its router's component to gain client-side
84
+ * navigation and prefetching, and gains nothing else — so an unconfigured
85
+ * host is not broken, merely un-optimised.
86
+ *
87
+ * Note this is the only field here whose value is a *component*. It sits on
88
+ * `StyleConfig` rather than being a `StyledLink` prop because the choice is
89
+ * app-wide by nature: passing it per call site is how ~40 links end up with
90
+ * two navigation behaviours and no way to retune either.
91
+ */
92
+ linkComponent: LinkComponent;
72
93
  }
73
94
 
74
95
  /**
@@ -91,6 +112,10 @@ export const DEFAULT_STYLE_CONFIG: StyleConfig = {
91
112
  // what the originating application already renders. Changing it would be an
92
113
  // invisible, app-wide visual change to every existing consumer.
93
114
  iconSize: "2x",
115
+ // A plain anchor. Unlike `iconSize` above this is genuinely the safe middle:
116
+ // it navigates correctly everywhere, and a host overriding it is opting into
117
+ // an improvement rather than repairing a default.
118
+ linkComponent: DefaultLinkComponent,
94
119
  };
95
120
 
96
121
  const StyleConfigContext = createContext<StyleConfig>(DEFAULT_STYLE_CONFIG);
@@ -143,6 +168,7 @@ export function StonedogStyleProvider({
143
168
  iconSize,
144
169
  density,
145
170
  densityBase,
171
+ linkComponent,
146
172
  icons,
147
173
  }: StonedogStyleProviderProps) {
148
174
  const value = useMemo<StyleConfig>(
@@ -153,8 +179,9 @@ export function StonedogStyleProvider({
153
179
  iconSize: iconSize ?? DEFAULT_STYLE_CONFIG.iconSize,
154
180
  density: density ?? DEFAULT_STYLE_CONFIG.density,
155
181
  densityBase: densityBase ?? DEFAULT_STYLE_CONFIG.densityBase,
182
+ linkComponent: linkComponent ?? DEFAULT_STYLE_CONFIG.linkComponent,
156
183
  }),
157
- [fontSizeProfile, variant, iconSize, density, densityBase],
184
+ [fontSizeProfile, variant, iconSize, density, densityBase, linkComponent],
158
185
  );
159
186
 
160
187
  return (
@@ -196,6 +223,17 @@ export function useIconSize(): IconSize {
196
223
  return useStyleConfig().iconSize;
197
224
  }
198
225
 
226
+ /**
227
+ * The host's link implementation, or a plain `<a>` if it supplied none.
228
+ *
229
+ * Exported so an application component outside this package can render a link
230
+ * the same way `StyledLink` does, without reaching for the router directly and
231
+ * re-creating the coupling the seam removed.
232
+ */
233
+ export function useLinkComponent(): LinkComponent {
234
+ return useStyleConfig().linkComponent;
235
+ }
236
+
199
237
  /**
200
238
  * Resolve a control's appearance: **the caller's, else the user's app-wide
201
239
  * setting, else `solid`.**
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  useStyleConfig,
21
21
  useFontSizeProfile,
22
22
  useIconSize,
23
+ useLinkComponent,
23
24
  useResolvedVariant,
24
25
  DEFAULT_STYLE_CONFIG,
25
26
  } from "./config/style-config";
@@ -28,6 +29,10 @@ export type {
28
29
  StonedogStyleProviderProps,
29
30
  } from "./config/style-config";
30
31
 
32
+ /** The link seam — see `config/link-component.tsx` and NEH-430. */
33
+ export { DefaultLinkComponent } from "./config/link-component";
34
+ export type { LinkComponent, LinkComponentProps } from "./config/link-component";
35
+
31
36
  /**
32
37
  * Deprecated `Hopper*` aliases — NEH-251. See `config/style-config.tsx`.
33
38
  * Removed once every consumer has landed its rename PR.
@@ -192,6 +197,43 @@ export type { StyledTooltipProps } from "./components/StyledTooltip";
192
197
  export { default as StyledFormLabel } from "./components/StyledFormLabel";
193
198
  export type { StyledFormLabelProps } from "./components/StyledFormLabel";
194
199
 
200
+ // ---------------------------------------------------------------------------
201
+ // Components that were blocked on a runtime dependency until NEH-430 gave each
202
+ // a seam with a working default. None of them adds a dependency; the host
203
+ // supplies the framework-specific half, or takes the default and loses nothing
204
+ // that stops it working.
205
+ // ---------------------------------------------------------------------------
206
+ export { default as StyledLink, StyledLink as Link } from "./components/StyledLink";
207
+ export type { StyledLinkProps } from "./components/StyledLink";
208
+
209
+ export { default as StyledTag, StyledTag as Tag } from "./components/StyledTag";
210
+ export type { StyledTagProps } from "./components/StyledTag";
211
+
212
+ export {
213
+ default as StyledFieldErrors,
214
+ StyledFieldErrors as FieldErrors,
215
+ } from "./components/StyledFieldErrors";
216
+ export type {
217
+ StyledFieldErrorsProps,
218
+ FieldError,
219
+ } from "./components/StyledFieldErrors";
220
+
221
+ export { default as StyledPage, StyledPage as Page } from "./components/StyledPage";
222
+ export type { StyledPageProps } from "./components/StyledPage";
223
+
224
+ export { default as StyledForm, StyledForm as Form } from "./components/StyledForm";
225
+ export type { StyledFormProps } from "./components/StyledForm";
226
+
227
+ export {
228
+ default as StyledConfetti,
229
+ StyledConfetti as Confetti,
230
+ } from "./components/StyledConfetti";
231
+ export type {
232
+ StyledConfettiProps,
233
+ CelebrateFn,
234
+ CelebrateOptions,
235
+ } from "./components/StyledConfetti";
236
+
195
237
  export { default as StyledInputBool } from "./components/StyledInputBool";
196
238
  export type { StyledInputBoolProps, InputBoolVariant } from "./components/StyledInputBool";
197
239
  export { INPUT_BOOL_VARIANTS } from "./components/StyledInputBool";
@@ -229,6 +271,9 @@ export type { StyledSearchProps } from "./components/StyledSearch";
229
271
  export { default as StyledInputToggle } from "./components/StyledInputToggle";
230
272
  export type { StyledInputToggleProps } from "./components/StyledInputToggle";
231
273
 
274
+ export { default as StyledAlert } from "./components/StyledAlert";
275
+ export type { StyledAlertProps, AlertStatus } from "./components/StyledAlert";
276
+
232
277
  export { default as StyledInputRadio } from "./components/StyledInputRadio";
233
278
  export type { StyledInputRadioProps, RadioItem, RadioVariant } from "./components/StyledInputRadio";
234
279
  export { RADIO_VARIANTS } from "./components/StyledInputRadio";
@@ -14,6 +14,7 @@ import {
14
14
  inputDropdownItemRecipe,
15
15
  inputDropdownRecipe,
16
16
  } from "./recipes/input-dropdown";
17
+ import { alertRecipe } from "./recipes/alert";
17
18
  import { inputRadioRootRecipe } from "./recipes/input-radio";
18
19
  import { inputTextRecipe } from "./recipes/input-text";
19
20
  import { listRecipe } from "./recipes/list";
@@ -53,7 +54,7 @@ export interface StonedogStylePresetOptions {
53
54
  /**
54
55
  * Every recipe, keyed by the name it is exported under in `styled-system/recipes`.
55
56
  *
56
- * Four of these (`listRecipe`, `menuRecipe`, `inputBoolRecipe`,
57
+ * Five of these (`alertRecipe`, `listRecipe`, `menuRecipe`, `inputBoolRecipe`,
57
58
  * `inputRadioRootRecipe`) are slot recipes declared with `defineSlotRecipe`.
58
59
  * Panda accepts them here rather than under `slotRecipes` and generates them
59
60
  * correctly — verified against HopperGuard's own generated output. Moving them
@@ -61,6 +62,7 @@ export interface StonedogStylePresetOptions {
61
62
  * generated surface, so it is deliberately not done during extraction.
62
63
  */
63
64
  const recipes = {
65
+ alertRecipe,
64
66
  arrowRecipe,
65
67
  boxRecipe,
66
68
  buttonRecipe,
@@ -193,6 +195,34 @@ export function stonedogStylePreset(options: StonedogStylePresetOptions = {}) {
193
195
  "0%": { transform: "scaleX(0)" },
194
196
  "100%": { transform: "scaleX(1)" },
195
197
  },
198
+ /**
199
+ * One confetti particle's flight — `StyledConfetti`'s zero-dependency
200
+ * default (NEH-430).
201
+ *
202
+ * The three `--sd-confetti-*` properties are set inline, per
203
+ * particle, so one keyframe serves a whole burst travelling in every
204
+ * direction. A keyframe cannot randomise, and a hundred generated
205
+ * keyframes would be a hundred rules in every consumer's stylesheet.
206
+ *
207
+ * **These are NOT the theme namespace and must not be confused with
208
+ * it.** CLAUDE.md's rule — never write `var(--…)` for anything the
209
+ * HOST supplies — is about `--<prefix>-*` properties that a theme
210
+ * defines and `cssVarPrefix` re-points. These are component-internal,
211
+ * written and read in the same breath by the same component, and
212
+ * named `--sd-confetti-*` precisely so they cannot collide with a
213
+ * host's namespace. The particle's COLOUR still comes from a token.
214
+ */
215
+ stonedogConfettiBurst: {
216
+ "0%": {
217
+ transform: "translate3d(0, 0, 0) rotate(0deg)",
218
+ opacity: "1",
219
+ },
220
+ "100%": {
221
+ transform:
222
+ "translate3d(var(--sd-confetti-dx, 0), var(--sd-confetti-dy, 0), 0) rotate(var(--sd-confetti-rot, 0deg))",
223
+ opacity: "0",
224
+ },
225
+ },
196
226
  },
197
227
  recipes,
198
228
  },
@@ -0,0 +1,95 @@
1
+ import { defineSlotRecipe } from "@pandacss/dev";
2
+
3
+ /**
4
+ * A status banner: an icon, a title and a message, on a tinted chip.
5
+ *
6
+ * Extracted from HopperGuard, where all four statuses painted from the raw
7
+ * Panda palette (`red.50` / `red.200` / `red.700` and friends). Those ignore the
8
+ * theme and dark mode entirely and sit outside contrast validation, so on a dark
9
+ * theme the component rendered dark text on a light chip regardless of its
10
+ * surroundings — the NEH-278 family (NEH-421).
11
+ *
12
+ * Every colour here is now a token. Three of the four statuses needed tokens
13
+ * that did not exist; see `STATUS_SURFACE_TOKENS` for why those carry a default
14
+ * where the rest of the contract does not.
15
+ */
16
+ export const alertRecipe = defineSlotRecipe({
17
+ className: "alert",
18
+ description: "A status banner — info, success, warning or error",
19
+ slots: ["root", "indicator", "content", "title", "description"],
20
+ base: {
21
+ root: {
22
+ position: "relative",
23
+ display: "flex",
24
+ alignItems: "flex-start",
25
+ gap: "3",
26
+ padding: "4",
27
+ borderRadius: "md",
28
+ borderWidth: "1px",
29
+ borderStyle: "solid",
30
+ // Stated, not inherited: a themed typeface otherwise reaches the page and
31
+ // stops at the edge of the component (NEH-289).
32
+ fontFamily: "body",
33
+ },
34
+ indicator: {
35
+ flexShrink: 0,
36
+ display: "flex",
37
+ alignItems: "center",
38
+ justifyContent: "center",
39
+ // Sized to sit on the first line of the title rather than centred against
40
+ // the whole block, which drifts as the message grows.
41
+ width: "1.25em",
42
+ height: "1.5em",
43
+ lineHeight: "1",
44
+ fontSize: "lg",
45
+ // The glyph inherits the chip's text colour, so it never needs a colour of
46
+ // its own and can never disagree with the message beside it.
47
+ color: "inherit",
48
+ },
49
+ content: {
50
+ flex: "1",
51
+ minWidth: "0",
52
+ },
53
+ title: {
54
+ fontWeight: "bold",
55
+ },
56
+ description: {
57
+ display: "block",
58
+ },
59
+ },
60
+ variants: {
61
+ status: {
62
+ info: {
63
+ root: {
64
+ backgroundColor: "boxInfo",
65
+ borderColor: "borderBgAccent",
66
+ color: "textMain",
67
+ },
68
+ },
69
+ success: {
70
+ root: {
71
+ backgroundColor: "boxSuccess",
72
+ borderColor: "borderSuccess",
73
+ color: "textSuccess",
74
+ },
75
+ },
76
+ warning: {
77
+ root: {
78
+ backgroundColor: "boxWarning",
79
+ borderColor: "borderWarning",
80
+ color: "textWarning",
81
+ },
82
+ },
83
+ error: {
84
+ root: {
85
+ backgroundColor: "boxError",
86
+ borderColor: "borderError",
87
+ color: "textError",
88
+ },
89
+ },
90
+ },
91
+ },
92
+ defaultVariants: {
93
+ status: "info",
94
+ },
95
+ });
@@ -34,6 +34,12 @@ export const boxRecipe = defineRecipe({
34
34
  },
35
35
  link: {
36
36
  bg: "boxBgPrimary",
37
+ // Its two siblings above, `solid` and `outline`, paint the same
38
+ // background and both state `textPrimary`; this one did not, so its
39
+ // text inherited from the page and could land unreadable on the same
40
+ // surface they render correctly on (NEH-441). Character for character
41
+ // the defect already fixed in `listRecipe` under NEH-167 cycle 9.
42
+ color: "textPrimary",
37
43
  _hover: {
38
44
  textDecoration: "underline",
39
45
  },
@@ -45,11 +45,23 @@ export const buttonRecipe = defineRecipe({
45
45
  },
46
46
  outline: {
47
47
  bg: "buttonBgAccent",
48
+ // Paints an accent background, so it must state the text colour that
49
+ // goes with it. Without this the label inherits whatever the page has,
50
+ // and under a theme whose surface sits at the same end of the scale as
51
+ // the inherited text it is unreadable — the NEH-278 family (NEH-441).
52
+ //
53
+ // `buttonTextAccent` exists in the token contract specifically to pair
54
+ // with `buttonBgAccent`, and every host already defines it, so this
55
+ // costs no host action. The hover state repaints the background, so it
56
+ // takes the matching hover pairing rather than letting the base colour
57
+ // ride along against a different surface.
58
+ color: "buttonTextAccent",
48
59
  border: "2px solid",
49
60
  borderRadius: 0,
50
61
  _hover: {
51
62
  border: "2px solid",
52
63
  bg: "buttonBgAccentHover",
64
+ color: "buttonTextAccentHover",
53
65
  },
54
66
  },
55
67
  aurora: {
@@ -3,7 +3,19 @@ import { defineRecipe } from "@pandacss/dev";
3
3
  export const iconRecipe = defineRecipe({
4
4
  className: "icon",
5
5
  base: {
6
- display: "inline-block",
6
+ // A centring flex box, not `inline-block` (NEH-562). `StyledIcon` sizes
7
+ // this wrapper in pixels while the icon set sizes the glyph from
8
+ // `font-size` — two independent numbers — so on an inline-block wrapper the
9
+ // glyph sat on a text baseline rather than in the middle of the box, and
10
+ // Font Awesome's `vertical-align: -0.125em` pushed it a further ~2px down.
11
+ // That was a visible sag under the label on every icon-bearing button in
12
+ // the product. A flex item ignores `vertical-align`, so centring here fixes
13
+ // the whole class rather than one call site.
14
+ display: "inline-flex",
15
+ alignItems: "center",
16
+ justifyContent: "center",
17
+ // Still meaningful: it aligns this wrapper within a parent's line box, for
18
+ // the call sites that drop an icon into running text.
7
19
  verticalAlign: "middle",
8
20
  lineHeight: 1,
9
21
  fontSize: "var(--font-sizes-2xl, 1.5rem)",
@@ -24,9 +24,16 @@ export const inputBoolRecipe = defineSlotRecipe({
24
24
  * Verified in the component-test harness rather than assumed: a raw
25
25
  * checkbox given a red 2px border and a slate background paints as the
26
26
  * default white box. The UA draws the widget and discards
27
- * `background-color`, `border-*` and `border-radius` — while
28
- * `getComputedStyle` cheerfully reports all of them, which is what made
29
- * this recipe look styled for so long.
27
+ * `background-color` and `border-*` — while `getComputedStyle`
28
+ * cheerfully reports both, which is what made this recipe look styled
29
+ * for so long.
30
+ *
31
+ * `border-radius` is worse still and worth separating out (NEH-310): it
32
+ * does not even COMPUTE. A control set to `9999px` reports `0px`, so it
33
+ * cannot be asserted on, cannot be differed by, and is not merely
34
+ * invisible. The `borderRadius: "md"` below is therefore inert too; it is
35
+ * kept only because it belongs to the same `appearance: none` fallback
36
+ * set as `border` and `background-color`.
30
37
  *
31
38
  * The three it DOES honour, and therefore the only levers here:
32
39
  * `accent-color` (the checked fill and tick), `box-shadow` (painted
@@ -77,9 +84,21 @@ export const inputBoolRecipe = defineSlotRecipe({
77
84
  *
78
85
  * `buttonRecipe` expresses outline as a 2px edge with squared corners.
79
86
  * A native checkbox discards `border`, so the same reading is carried by
80
- * a `box-shadow` ring, which it does paint — squared to match, and
81
- * themed. `solid` states `none` explicitly rather than by omission, so
82
- * switching between them cannot leave a ring behind.
87
+ * a `box-shadow` ring, which it does paint — themed. `solid` states
88
+ * `none` explicitly rather than by omission, so switching between them
89
+ * cannot leave a ring behind.
90
+ *
91
+ * **The squared corners were dropped in NEH-310, because they never
92
+ * existed.** This comment used to say the ring was "squared to match",
93
+ * and the variant carried `borderRadius: "0"` to do it. Probed in the
94
+ * harness: Chromium computes `border-radius: 0px` on a checkbox at
95
+ * `appearance: auto` **whatever the stylesheet says** — a control set to
96
+ * `9999px` reports `0px`, while a plain `<span>` beside it reports its
97
+ * `12px` correctly. So the property is not merely discarded at paint
98
+ * time like `background-color`; it does not even compute, and no variant
99
+ * here can differ by corner. The declaration is removed rather than left
100
+ * as decoration, since a recipe full of inert declarations is the exact
101
+ * condition that made this defect take three issues to find.
83
102
  */
84
103
  solid: {
85
104
  control: {
@@ -100,18 +119,60 @@ export const inputBoolRecipe = defineSlotRecipe({
100
119
  * different — and made a ticked outline checkbox a dark box on a dark
101
120
  * surface, which is the checked state, the one thing the control
102
121
  * exists to communicate. Distinguishing an appearance must not cost
103
- * state legibility, so the difference is carried entirely by the ring
104
- * and the corners.
122
+ * state legibility, so the difference is carried entirely by the
123
+ * ring.
105
124
  */
106
125
  accentColor: "buttonBgPrimary",
107
126
  boxShadow: "0 0 0 2px {colors.borderBgPrimary}",
108
- borderRadius: "0",
109
127
  },
110
128
  },
129
+ /**
130
+ * The remaining variants, given a painted difference (NEH-310).
131
+ *
132
+ * NEH-234 fixed `solid` vs `outline` and stopped there, so these still
133
+ * differed only in `background`, `background-image`, `color` and a
134
+ * pseudo-element — every one of which this control discards. A user
135
+ * picking `aurora` app-wide watched every other control change and every
136
+ * checkbox stay put: the same complaint NEH-234 was filed for, one layer
137
+ * down.
138
+ *
139
+ * ## The rule they all follow
140
+ *
141
+ * **Appearance is carried by the ring; the checked colour never varies.**
142
+ *
143
+ * Not a stylistic choice — it is the lesson recorded on `outline` above.
144
+ * Giving a variant a recessive `accentColor` did make it more distinct,
145
+ * and made a ticked box dark-on-dark: illegible in the one state the
146
+ * control exists to communicate. So every variant keeps
147
+ * `accentColor: buttonBgPrimary` and differs by `box-shadow` alone.
148
+ *
149
+ * `outline` (the CSS property) is not available as a lever either: it is
150
+ * the focus ring, and a variant using it would look permanently focused.
151
+ * `border-radius` is not available because it does not even COMPUTE here
152
+ * — see the note on the `outline` variant.
153
+ *
154
+ * ## What this deliberately does NOT attempt
155
+ *
156
+ * `aurora` is a gradient and `glass` is a blur; a box-shadow ring is
157
+ * neither. These are **approximations** — a two-tone ring, a soft halo —
158
+ * not renderings of the intent. The real thing needs `appearance: none`
159
+ * plus a hand-drawn tick, which means owning forced-colors mode and every
160
+ * engine's default widget, and this repo's CT tier is Chromium-only so it
161
+ * cannot answer that. NEH-310 names it as option 2 and says it needs
162
+ * someone to look at the result in more than one engine.
163
+ *
164
+ * The unpainted `bg` / `color` / gradient declarations are left exactly
165
+ * as they were, per the note in `base`.
166
+ */
111
167
  aurora: {
112
168
  control: {
113
169
  backgroundImage: "linear-gradient(to right, #ff7e5f, #feb47b)",
114
170
  color: "buttonTextPrimary",
171
+ accentColor: "buttonBgPrimary",
172
+ // Two stops, two rings — the nearest a box-shadow gets to the
173
+ // gradient this variant means. Layers paint inner-first.
174
+ boxShadow:
175
+ "0 0 0 2px {colors.borderBgAccent}, 0 0 0 4px {colors.borderBgPrimary}",
115
176
  },
116
177
  },
117
178
  glass: {
@@ -120,7 +181,6 @@ export const inputBoolRecipe = defineSlotRecipe({
120
181
  overflow: "hidden",
121
182
  bg: "buttonBgPrimary/20",
122
183
  color: "textPrimary/10",
123
- boxShadow: "xl",
124
184
  backdropFilter: "blur(8px)",
125
185
  fontWeight: "bold",
126
186
  lineHeight: "shorter",
@@ -135,6 +195,19 @@ export const inputBoolRecipe = defineSlotRecipe({
135
195
  "linear(to-br, rgba(255,255,255,0.1), rgba(255,255,255,0.05))",
136
196
  zIndex: -1,
137
197
  },
198
+ accentColor: "buttonBgPrimary",
199
+ // A soft halo rather than a hard edge — the nearest painted reading
200
+ // of "frosted".
201
+ //
202
+ // This REPLACES the `boxShadow: "xl"` this variant used to carry
203
+ // (removed above, not shadowed — a second `boxShadow` key here was a
204
+ // TS1117 duplicate-property error that the CSS build silently
205
+ // resolved in favour of the last one). `xl` was the only thing glass
206
+ // ever painted, and it is Panda's own neutral shadow: the same grey
207
+ // in every theme this package can wear, which is the one thing a
208
+ // themeable package must not ship.
209
+ boxShadow:
210
+ "0 0 0 1px {colors.borderBgSecondary}, 0 0 12px 2px {colors.boxshadowBgAccent}",
138
211
  },
139
212
  },
140
213
  matte: {
@@ -145,18 +218,46 @@ export const inputBoolRecipe = defineSlotRecipe({
145
218
  // is a FIXED dark gradient, so themed text on it risks dark-on-dark.
146
219
  color: "white",
147
220
  fontWeight: "bold",
221
+ accentColor: "buttonBgPrimary",
222
+ // Wide, blurred and low-contrast: a matte surface absorbs light
223
+ // rather than edging it. The only soft-edged ring in the set, so it
224
+ // cannot be mistaken for `outline` at a glance.
225
+ boxShadow: "0 2px 8px 0 {colors.boxshadowBgSecondary}",
148
226
  },
149
227
  },
150
228
  ghost: {
151
229
  control: {
152
230
  color: "textSecondary",
153
231
  bg: "buttonBgSecondary",
232
+ accentColor: "buttonBgPrimary",
233
+ // The thinnest ring in the set, in the secondary border colour.
234
+ // `ghost` means "present but not asserting itself", which every other
235
+ // recipe expresses by having no fill — exactly the property this
236
+ // control discards.
237
+ boxShadow: "0 0 0 1px {colors.borderBgSecondary}",
154
238
  },
155
239
  },
240
+ /**
241
+ * `none` is the one variant that CANNOT be distinguished, and saying so
242
+ * is more useful than inventing a difference (NEH-310).
243
+ *
244
+ * Every lever this control has is additive — a ring, a halo, a checked
245
+ * colour. `none` means "do not style this", so the only honest rendering
246
+ * of it is the bare widget, which is what `solid` already is. Giving it a
247
+ * ring to make a test pass would mean the variant named `none` was the
248
+ * only one wearing decoration.
249
+ *
250
+ * So `none` and `solid` render identically, deliberately, and the
251
+ * component test asserts that pair is equal rather than skipping it —
252
+ * so if a future `appearance: none` redesign (option 2 on NEH-310) makes
253
+ * them separable, the test says so instead of quietly passing.
254
+ */
156
255
  none: {
157
256
  control: {
158
257
  color: "buttonTextPrimary",
159
258
  bg: "gray.300",
259
+ accentColor: "buttonBgPrimary",
260
+ boxShadow: "none",
160
261
  },
161
262
  },
162
263
  button: {
@@ -126,10 +126,20 @@ export const inputRadioRootRecipe = defineSlotRecipe({
126
126
  border: "none",
127
127
  },
128
128
  },
129
+ // "none" means no chrome, not no surface: it drops the border and sits
130
+ // the item on the page's own background. That was written as a literal
131
+ // `white`, which is the page background of exactly one theme — under a
132
+ // dark one it painted a white slab, and no theme-aware text colour could
133
+ // be paired with it (a light `textPrimary` on it is white-on-white, the
134
+ // same NEH-278 illegibility in the other direction). `boxBgMain` is the
135
+ // token that means "the page surface", so the variant now follows the
136
+ // theme instead of contradicting it, and `textMain` is its documented
137
+ // partner in TEXT_BACKGROUND_PAIRS.
129
138
  none: {
130
139
  item: {
131
140
  border: "none",
132
- backgroundColor: "white",
141
+ backgroundColor: "boxBgMain",
142
+ color: "textMain",
133
143
  },
134
144
  },
135
145
  },
@@ -13,6 +13,15 @@ export const menuRecipe = defineSlotRecipe({
13
13
  gap: 3,
14
14
  px: 4,
15
15
  py: 2,
16
+ // Stated, not derived — the same reason `button`, `icon-button`,
17
+ // `input-bool`, `input-radio` and `input-surface` all state it. Height
18
+ // that emerges from padding plus the current font size moves whenever
19
+ // either does, and this recipe measured 34px at the default profile.
20
+ //
21
+ // A menu is a column of adjacent targets, so missing one does not fail:
22
+ // it performs the neighbour's action instead. An unexpected navigation
23
+ // is worse than a dead tap for the audience this system is built for.
24
+ minHeight: "48px",
16
25
  borderRadius: "md",
17
26
  cursor: "pointer",
18
27
  _hover: {