@stonedogcode/style 0.20.2 → 0.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.20.2",
3
+ "version": "0.22.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.",
@@ -37,7 +37,7 @@
37
37
  "prepare": "npm run panda:prepare",
38
38
  "pretype-check": "npm run panda:prepare",
39
39
  "type-check": "tsc --noEmit",
40
- "lint": "eslint . --ext ts,tsx",
40
+ "lint": "eslint .",
41
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",
@@ -49,6 +49,8 @@
49
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
  "publishnpm": "npm run publish:stonedog-style",
52
+ "//release": "One memorable command per repo. It cannot be called `publish`: that is a reserved npm lifecycle name, so `npm publish` inside scripts/publish-package.sh would fire it and run the whole gate and publish a second time, hitting the already-published guard and reporting a successful release as a failure.",
53
+ "release": "npm run publish:stonedog-style",
52
54
  "version:bump:patch": "npm version patch --no-git-tag-version",
53
55
  "version:bump:minor": "npm version minor --no-git-tag-version",
54
56
  "audit": "npm audit --audit-level=moderate"
@@ -62,6 +64,7 @@
62
64
  "csstype": "^3.2.3"
63
65
  },
64
66
  "devDependencies": {
67
+ "@eslint/js": "^9.0.0",
65
68
  "@pandacss/dev": "^1.11.1",
66
69
  "@playwright/experimental-ct-react": "^1.62.1",
67
70
  "@playwright/test": "^1.62.1",
@@ -73,9 +76,10 @@
73
76
  "@types/react-dom": "^19.2.3",
74
77
  "@typescript-eslint/eslint-plugin": "^8.0.0",
75
78
  "@typescript-eslint/parser": "^8.0.0",
76
- "eslint": "^8.57.1",
79
+ "eslint": "^9.39.5",
77
80
  "eslint-plugin-react": "^7.37.0",
78
81
  "eslint-plugin-react-hooks": "^5.0.0",
82
+ "globals": "^17.12.0",
79
83
  "jest": "^29.7.0",
80
84
  "jest-environment-jsdom": "^29.7.0",
81
85
  "react": "^19.0.4",
@@ -3,7 +3,6 @@ import { cx, css } from "styled-system/css";
3
3
  import { stripedRecipe } from "styled-system/recipes";
4
4
  import React from "react";
5
5
  import { log } from "../config/logger";
6
- import type { ConditionalValue } from "styled-system/types";
7
6
 
8
7
  const PandaGrid = styled("div", {
9
8
  base: {
@@ -11,25 +10,300 @@ const PandaGrid = styled("div", {
11
10
  },
12
11
  });
13
12
 
14
- export interface StyledGridProps extends Omit<HTMLStyledProps<"div">, "columns"> {
13
+ /**
14
+ * The breakpoints this component's responsive objects understand.
15
+ *
16
+ * Exactly the six declared by the preset, plus `base`. Order matters: the
17
+ * runtime fills a value forward from each key to the next, so this array is the
18
+ * cascade.
19
+ */
20
+ const GRID_BREAKPOINTS = ["base", "sm", "md", "lg", "xl", "2xl", "3xl"] as const;
21
+
22
+ export type GridBreakpoint = (typeof GRID_BREAKPOINTS)[number];
23
+
24
+ /** A plain value, or one value per breakpoint. */
25
+ export type GridResponsiveValue<T> = T | Partial<Record<GridBreakpoint, T>>;
26
+
27
+ /*
28
+ * ---------------------------------------------------------------------------
29
+ * Why the track definitions travel as CSS custom properties (NEH-1453)
30
+ * ---------------------------------------------------------------------------
31
+ *
32
+ * `StyledGrid` used to compute its `grid-template-*` values at runtime and hand
33
+ * them to Panda as style props:
34
+ *
35
+ * resolvedColumns = `repeat(${columns}, 1fr)`;
36
+ * <PandaGrid gridTemplateColumns={resolvedColumns} />
37
+ *
38
+ * Panda extracts styles by **statically parsing source at build time**. A value
39
+ * computed at runtime is never seen by `panda cssgen`, so Panda's runtime
40
+ * constructs a class *name* derived from the value and no rule is ever emitted
41
+ * for it. Measured in this package's own component tier, `<StyledGrid
42
+ * columns={2}>` inside a 320px container:
43
+ *
44
+ * class="d_grid grid-tc_repeat(2,_1fr)"
45
+ * inline style: null
46
+ * computed grid-template-columns: 445.188px <- ONE implicit, content-sized track
47
+ * rules in the sheet mentioning grid-template-columns: 1, and it is
48
+ * `.grid-tc_max-content_1fr` from StyledDefinitionList
49
+ *
50
+ * The same is true of `templateColumns` / `templateRows` / `templateAreas`,
51
+ * which are not Panda property names at all, so even a static literal at the
52
+ * call site is never extracted.
53
+ *
54
+ * Where it appeared to work in an app it was a **coincidence**: the class name
55
+ * is derived from the value, so a consumer whose own source happened to contain
56
+ * the same literal elsewhere got a rule by accident. That is why the fix cannot
57
+ * simply rewrite the emitted string — a new string breaks the coincidence and
58
+ * silently drops the columns.
59
+ *
60
+ * So the value has to reach the element by a route that does not depend on
61
+ * static extraction. Inline `style` is that route, and it is what
62
+ * `StyledSimpleGrid` already does. But a plain inline `grid-template-columns`
63
+ * cannot carry media queries, which is why `StyledSimpleGrid` pays for the
64
+ * responsive form with a JS resize listener (no server-rendered value, a
65
+ * listener per grid, a flash on first paint).
66
+ *
67
+ * This component avoids that trade by splitting the two halves:
68
+ *
69
+ * - the **rules** are literals in this file, so Panda really does extract
70
+ * them, one per breakpoint, with real `@media` conditions;
71
+ * - the **values** ride in on inline custom properties, which are never
72
+ * extracted, never parsed by Panda, and always applied.
73
+ *
74
+ * Result: real CSS breakpoints, correct on the server, no resize listener, and
75
+ * no upper bound on the column count.
76
+ *
77
+ * The runtime fills a value FORWARD across breakpoints rather than relying on
78
+ * nested `var()` fallbacks, so each rule is a flat `var(--x, none)`. `none` is
79
+ * the initial value of every `grid-template-*` property, so an axis nobody set
80
+ * resolves to exactly what it would have been.
81
+ */
82
+
83
+ const TRACK_VAR = {
84
+ columns: "--sds-grid-tc",
85
+ rows: "--sds-grid-tr",
86
+ areas: "--sds-grid-ta",
87
+ } as const;
88
+
89
+ type GridAxis = keyof typeof TRACK_VAR;
90
+
91
+ /**
92
+ * One class per axis, applied only when that axis has a value.
93
+ *
94
+ * They are separate constants on purpose. A single combined class would declare
95
+ * `grid-template-rows: none` on every grid, which is the initial value but is
96
+ * still a *declaration* — and it would then race, at equal specificity, with
97
+ * any `grid-template-rows` a consumer set through `className`. An axis nobody
98
+ * asked about is left untouched instead.
99
+ *
100
+ * Every value below is a string LITERAL. That is the entire point: Panda has to
101
+ * be able to read them without running anything.
102
+ */
103
+ const AXIS_CLASS: Record<GridAxis, string> = {
104
+ columns: css({
105
+ gridTemplateColumns: "var(--sds-grid-tc-base, none)",
106
+ sm: { gridTemplateColumns: "var(--sds-grid-tc-sm, none)" },
107
+ md: { gridTemplateColumns: "var(--sds-grid-tc-md, none)" },
108
+ lg: { gridTemplateColumns: "var(--sds-grid-tc-lg, none)" },
109
+ xl: { gridTemplateColumns: "var(--sds-grid-tc-xl, none)" },
110
+ "2xl": { gridTemplateColumns: "var(--sds-grid-tc-2xl, none)" },
111
+ "3xl": { gridTemplateColumns: "var(--sds-grid-tc-3xl, none)" },
112
+ }),
113
+ rows: css({
114
+ gridTemplateRows: "var(--sds-grid-tr-base, none)",
115
+ sm: { gridTemplateRows: "var(--sds-grid-tr-sm, none)" },
116
+ md: { gridTemplateRows: "var(--sds-grid-tr-md, none)" },
117
+ lg: { gridTemplateRows: "var(--sds-grid-tr-lg, none)" },
118
+ xl: { gridTemplateRows: "var(--sds-grid-tr-xl, none)" },
119
+ "2xl": { gridTemplateRows: "var(--sds-grid-tr-2xl, none)" },
120
+ "3xl": { gridTemplateRows: "var(--sds-grid-tr-3xl, none)" },
121
+ }),
122
+ areas: css({
123
+ gridTemplateAreas: "var(--sds-grid-ta-base, none)",
124
+ sm: { gridTemplateAreas: "var(--sds-grid-ta-sm, none)" },
125
+ md: { gridTemplateAreas: "var(--sds-grid-ta-md, none)" },
126
+ lg: { gridTemplateAreas: "var(--sds-grid-ta-lg, none)" },
127
+ xl: { gridTemplateAreas: "var(--sds-grid-ta-xl, none)" },
128
+ "2xl": { gridTemplateAreas: "var(--sds-grid-ta-2xl, none)" },
129
+ "3xl": { gridTemplateAreas: "var(--sds-grid-ta-3xl, none)" },
130
+ }),
131
+ };
132
+
133
+ /**
134
+ * Spread a responsive value across every breakpoint, carrying each value
135
+ * forward until the next one overrides it.
136
+ *
137
+ * Filling forward is what lets each emitted rule be a flat `var(--x, none)`: at
138
+ * `lg` the rule reads `--sds-grid-tc-lg`, so that property has to hold the
139
+ * value in force at `lg` whether it was set there or inherited from `md`.
140
+ *
141
+ * Returns `undefined` when the caller supplied nothing, so the axis class is
142
+ * not applied at all.
143
+ */
144
+ function resolveAxis(
145
+ value: GridResponsiveValue<string> | undefined,
146
+ componentName: string,
147
+ ): Partial<Record<GridBreakpoint, string>> | undefined {
148
+ if (value === undefined || value === null) return undefined;
149
+
150
+ if (typeof value === "string") {
151
+ const filled: Partial<Record<GridBreakpoint, string>> = {};
152
+ for (const breakpoint of GRID_BREAKPOINTS) filled[breakpoint] = value;
153
+ return filled;
154
+ }
155
+
156
+ if (typeof value !== "object") return undefined;
157
+
158
+ const unsupported = Object.keys(value).filter(
159
+ (key) => !(GRID_BREAKPOINTS as readonly string[]).includes(key),
160
+ );
161
+ if (unsupported.length > 0) {
162
+ // Loud rather than silent. Panda conditions other than a breakpoint
163
+ // (`_hover`, `_dark`, the array syntax) cannot be carried by a custom
164
+ // property, because there is no rule here that reads one under that
165
+ // condition. Before NEH-1453 they were dropped without a word; now they are
166
+ // dropped with one. Reach for `className={css({ ... })}` instead, which
167
+ // Panda extracts from the call site.
168
+ log.warn(`[${componentName}] ignoring unsupported responsive key(s)`, {
169
+ unsupported,
170
+ supported: GRID_BREAKPOINTS,
171
+ });
172
+ }
173
+
174
+ const filled: Partial<Record<GridBreakpoint, string>> = {};
175
+ let carried: string | undefined;
176
+ for (const breakpoint of GRID_BREAKPOINTS) {
177
+ const declared = (value as Partial<Record<GridBreakpoint, string>>)[breakpoint];
178
+ if (declared !== undefined) carried = declared;
179
+ if (carried !== undefined) filled[breakpoint] = carried;
180
+ }
181
+ return Object.keys(filled).length > 0 ? filled : undefined;
182
+ }
183
+
184
+ /**
185
+ * `repeat(n, minmax(<minTrackWidth>, 1fr))`, not `repeat(n, 1fr)`.
186
+ *
187
+ * In CSS `1fr` **is** shorthand for `minmax(auto, 1fr)`, and that `auto` floor
188
+ * is the grid item's automatic minimum size — its min-content. One child that
189
+ * cannot shrink therefore drags the whole track past the grid's container, and
190
+ * every sibling sized `width: 100%` inherits the overflow (NEH-1446/NEH-1447,
191
+ * measured at 425.875px inside a 375px container).
192
+ *
193
+ * `StyledSimpleGrid` made this change in 0.21.0 and `StyledGrid` deliberately
194
+ * did not, because at the time the emitted string still had to match a literal
195
+ * in the consumer's own source for any rule to exist at all — a new string
196
+ * would have broken that coincidence and dropped the columns entirely. Once the
197
+ * value stops travelling through Panda, that objection disappears, which is why
198
+ * the two changes belong in the same commit and not before it.
199
+ */
200
+ function columnsToTemplate(count: number, minTrackWidth: string): string {
201
+ return `repeat(${count}, minmax(${minTrackWidth}, 1fr))`;
202
+ }
203
+
204
+ function columnsToTemplateValue(
205
+ columns: GridResponsiveValue<number>,
206
+ minTrackWidth: string,
207
+ ): GridResponsiveValue<string> | undefined {
208
+ if (typeof columns === "number") return columnsToTemplate(columns, minTrackWidth);
209
+ if (typeof columns !== "object" || columns === null) return undefined;
210
+
211
+ const mapped: Partial<Record<GridBreakpoint, string>> = {};
212
+ for (const [key, count] of Object.entries(columns)) {
213
+ if (typeof count === "number") {
214
+ mapped[key as GridBreakpoint] = columnsToTemplate(count, minTrackWidth);
215
+ }
216
+ }
217
+ return mapped;
218
+ }
219
+
220
+ /** Write one axis' resolved values out as inline custom properties. */
221
+ function writeAxisVars(
222
+ target: Record<string, string>,
223
+ axis: GridAxis,
224
+ resolved: Partial<Record<GridBreakpoint, string>>,
225
+ ) {
226
+ for (const breakpoint of GRID_BREAKPOINTS) {
227
+ const value = resolved[breakpoint];
228
+ if (value !== undefined) target[`${TRACK_VAR[axis]}-${breakpoint}`] = value;
229
+ }
230
+ }
231
+
232
+ export interface StyledGridProps
233
+ extends Omit<
234
+ HTMLStyledProps<"div">,
235
+ "columns" | "gridTemplateColumns" | "gridTemplateRows" | "gridTemplateAreas"
236
+ > {
15
237
  children?: React.ReactNode;
16
238
  isStriped?: boolean;
17
239
  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 }>;
240
+ /**
241
+ * A `grid-template-columns` value, or one per breakpoint.
242
+ *
243
+ * Narrower than Panda's `ConditionalValue` on purpose: only `base` and the
244
+ * six preset breakpoints are carried. Any other condition is ignored with a
245
+ * warning — see `resolveAxis`.
246
+ */
247
+ templateColumns?: GridResponsiveValue<string>;
248
+ templateRows?: GridResponsiveValue<string>;
249
+ templateAreas?: GridResponsiveValue<string>;
250
+ /** Alias for `templateColumns`; takes precedence when both are supplied. */
251
+ gridTemplateColumns?: GridResponsiveValue<string>;
252
+ gridTemplateRows?: GridResponsiveValue<string>;
253
+ gridTemplateAreas?: GridResponsiveValue<string>;
254
+ /** Column count. Emits `repeat(n, minmax(minTrackWidth, 1fr))`. */
255
+ columns?: GridResponsiveValue<number>;
256
+ /**
257
+ * The minimum size of each `columns`-generated track. Defaults to `"0"`.
258
+ *
259
+ * Pass `"auto"` to let a track refuse to shrink below its widest unbreakable
260
+ * child, which is occasionally what you want — a deliberately horizontally
261
+ * scrolled strip — but now has to be asked for by name. Mirrors
262
+ * `StyledSimpleGrid`.
263
+ */
264
+ minTrackWidth?: string;
22
265
  }
23
266
 
24
267
  const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
25
- ({
26
- isStriped,
27
- showGridLines,
28
- className,
29
- templateColumns,
30
- templateRows,
31
- templateAreas,
32
- columns, ...props }, ref) => {
268
+ (
269
+ {
270
+ isStriped,
271
+ showGridLines,
272
+ className,
273
+ style,
274
+ templateColumns,
275
+ templateRows,
276
+ templateAreas,
277
+ gridTemplateColumns,
278
+ gridTemplateRows,
279
+ gridTemplateAreas,
280
+ columns,
281
+ minTrackWidth = "0",
282
+ ...props
283
+ },
284
+ ref,
285
+ ) => {
286
+ // Explicit template wins over the `columns` shorthand, as before.
287
+ const columnsValue =
288
+ gridTemplateColumns ??
289
+ templateColumns ??
290
+ (columns === undefined ? undefined : columnsToTemplateValue(columns, minTrackWidth));
291
+
292
+ const resolved: Record<GridAxis, Partial<Record<GridBreakpoint, string>> | undefined> = {
293
+ columns: resolveAxis(columnsValue, "StyledGrid"),
294
+ rows: resolveAxis(gridTemplateRows ?? templateRows, "StyledGrid"),
295
+ areas: resolveAxis(gridTemplateAreas ?? templateAreas, "StyledGrid"),
296
+ };
297
+
298
+ const trackVars: Record<string, string> = {};
299
+ const axisClasses: string[] = [];
300
+ for (const axis of ["columns", "rows", "areas"] as const) {
301
+ const axisValues = resolved[axis];
302
+ if (axisValues === undefined) continue;
303
+ writeAxisVars(trackVars, axis, axisValues);
304
+ axisClasses.push(AXIS_CLASS[axis]);
305
+ }
306
+
33
307
  const combinedClassName = cx(
34
308
  isStriped ? stripedRecipe() : undefined,
35
309
  showGridLines
@@ -42,35 +316,10 @@ const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
42
316
  },
43
317
  })
44
318
  : undefined,
319
+ ...axisClasses,
45
320
  className,
46
321
  );
47
322
 
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
323
  const childrenDetails = React.Children.map(props.children, (child) => {
75
324
  if (React.isValidElement(child)) {
76
325
  const element = child as React.ReactElement<{ id?: string }>;
@@ -91,13 +340,19 @@ const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
91
340
  childrenDetails
92
341
  });
93
342
 
343
+ // The caller's own `style` goes first: a `grid-template-columns` they set
344
+ // inline is their business and should still beat our class. Our custom
345
+ // properties are appended so nothing can accidentally shadow them.
346
+ const mergedStyle =
347
+ Object.keys(trackVars).length > 0
348
+ ? ({ ...style, ...trackVars } as React.CSSProperties)
349
+ : style;
350
+
94
351
  return (
95
352
  <PandaGrid
96
353
  ref={ref}
97
354
  className={combinedClassName}
98
- gridTemplateColumns={resolvedColumns}
99
- gridTemplateRows={gridTemplateRows}
100
- gridTemplateAreas={gridTemplateAreas}
355
+ {...(mergedStyle === undefined ? {} : { style: mergedStyle })}
101
356
  {...props}
102
357
  />
103
358
  );
@@ -1,3 +1,30 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `"use client"`, and it is load-bearing.
5
+ *
6
+ * This component calls `useFontSizeProfile()` (a client hook) during its
7
+ * render. Without the directive above, the module is a Server Component in a
8
+ * consumer's App Router tree, the hook is invoked on the server, and React
9
+ * throws:
10
+ *
11
+ * Attempted to call useFontSizeProfile() from the server but
12
+ * useFontSizeProfile is on the client.
13
+ *
14
+ * Next serves that as its blank "This page couldn't load" page with no detail
15
+ * anywhere in the browser, so a consumer sees a dead route and nothing naming
16
+ * this component. Every PRD and how-to page on stonedogcode.com was unreachable
17
+ * this way (NEH-1290).
18
+ *
19
+ * Every other component here that calls the hook already declared it — this was
20
+ * the only one that did not, which is why the failure looked like something
21
+ * specific to whichever page happened to render a heading on the server.
22
+ *
23
+ * `src/components/__tests__/client-directive.test.ts` keeps this honest. It has
24
+ * to be a source assertion: a jsdom render imports the module directly, so no
25
+ * RSC boundary exists and every test here passes with or without the directive.
26
+ */
27
+
1
28
  import React from "react";
2
29
  import StyledSeparator from "./StyledSeparator";
3
30
  import StyledText from "./StyledText";
@@ -17,6 +17,19 @@ interface StyledSimpleGridProps extends Omit<HTMLStyledProps<"div">, "columns">
17
17
  columns?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
18
18
  gridTemplateRows?: string;
19
19
  gap?: string | number;
20
+ /**
21
+ * The minimum size of each `columns`-generated track. Defaults to `"0"`.
22
+ *
23
+ * `columns={n}` emits `repeat(n, minmax(<minTrackWidth>, 1fr))`. The default
24
+ * of `0` lets a track shrink below its content's min-content width, which is
25
+ * what keeps the grid inside its container.
26
+ *
27
+ * Pass `"auto"` to restore the pre-0.21.0 behaviour, in which a track refuses
28
+ * to shrink below its widest unbreakable child and the whole grid grows past
29
+ * its container. That is occasionally what you want — a deliberately
30
+ * horizontally scrolled strip — but it now has to be asked for by name.
31
+ */
32
+ minTrackWidth?: string;
20
33
  }
21
34
 
22
35
  const PandaSimpleGrid = styled("div", {
@@ -51,6 +64,7 @@ const StyledSimpleGrid: React.FC<StyledSimpleGridProps> = ({
51
64
  columns,
52
65
  gridTemplateRows,
53
66
  gap,
67
+ minTrackWidth = "0",
54
68
  style,
55
69
  children,
56
70
  ...rest
@@ -78,9 +92,22 @@ const StyledSimpleGrid: React.FC<StyledSimpleGridProps> = ({
78
92
  }, [recalculate]);
79
93
 
80
94
  // Runtime-computed grid values MUST use inline style — Panda CSS drops them at build time
95
+ //
96
+ // `minmax(minTrackWidth, 1fr)`, not a bare `1fr`. In CSS `1fr` IS shorthand
97
+ // for `minmax(auto, 1fr)`, and that `auto` floor is the grid item's automatic
98
+ // minimum size — its min-content. So one child that cannot shrink (a long
99
+ // unbroken string, a `white-space: nowrap` row, a fixed-width control) drags
100
+ // the track wider than the grid's own container, and every sibling sized
101
+ // `width: 100%` inherits the overflow. Measured on HopperGuard's dashboard at
102
+ // 375px: a 1-column grid resolved a 425.875px track inside a 375px container,
103
+ // and the shell clipped rather than scrolled (NEH-1446, NEH-1447).
104
+ //
105
+ // Guarded by grid-track-shrink.ct.tsx, which measures the resolved track in a
106
+ // real browser. jsdom cannot see this at all — it has no layout engine, so it
107
+ // reports every box as 0x0 and would agree that a 426px track fits 375px.
81
108
  const gridStyles: React.CSSProperties = {
82
109
  ...style,
83
- gridTemplateColumns: `repeat(${resolvedCols}, 1fr)`,
110
+ gridTemplateColumns: `repeat(${resolvedCols}, minmax(${minTrackWidth}, 1fr))`,
84
111
  gridTemplateRows,
85
112
  gap,
86
113
  };