@stonedogcode/style 0.15.0 → 0.16.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.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "StoneDogCode L.L.C.",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/stonedog-code/stonedog-style.git"
10
10
  },
11
- "//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
11
+ "//publishConfig": "A SCOPED package defaults to access: restricted. Publishing one without this succeeds, prints nothing unusual, and then 404s for every consumer \u2014 which reads as a missing package rather than as a private one. It was not needed while the name was unscoped (those default to public), so it is new as of the scope migration and it is the single thing most likely to be forgotten in one.",
12
12
  "publishConfig": {
13
13
  "access": "public"
14
14
  },
@@ -20,7 +20,7 @@
20
20
  "./preset": "./src/preset/index.ts",
21
21
  "./package.json": "./package.json"
22
22
  },
23
- "//files": "The package ships SOURCE, so `src` has to go out whole but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
23
+ "//files": "The package ships SOURCE, so `src` has to go out whole \u2014 but only the part a consumer imports. Tests, Playwright component tests and their harnesses were 57 of 130 entries and never resolvable from any export, so every one of them was a file a consumer's Panda run had to be told to skip. `src/__tests__/published-package.test.ts` asserts this listing rather than this array: npm's own resolution of these negations is the only thing that decides what ships.",
24
24
  "files": [
25
25
  "src",
26
26
  "!src/**/__tests__/**",
@@ -38,7 +38,7 @@
38
38
  "pretype-check": "npm run panda:prepare",
39
39
  "type-check": "tsc --noEmit",
40
40
  "lint": "eslint . --ext ts,tsx",
41
- "//pretest": "panda:build, not panda:prepare the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
41
+ "//pretest": "panda:build, not panda:prepare \u2014 the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
42
42
  "pretest": "npm run panda:build",
43
43
  "test": "jest",
44
44
  "pretest:ct": "npm run panda:build",
@@ -46,7 +46,7 @@
46
46
  "test:ct:ui": "playwright test --config playwright-ct.config.ts --ui",
47
47
  "test:coverage": "jest --coverage",
48
48
  "gate": "npm run type-check && npm run lint && npm run test",
49
- "//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
49
+ "//publish:stonedog-style": "Publish to npm, end to end. Run from a terminal, interactively \u2014 npm prompts for the 2FA one-time password and the login flow needs a browser. It refuses a checkout that is detached, dirty, or behind origin/main: a submodule sits detached at the consumer's gitlink by default, and publishing from one commit behind ships a tarball missing the very thing you are publishing for while looking like a success (it did, on 2026-08-04, without TitleLogo.tsx). Runs the gate, prints the tarball listing, then proves the result by installing from the registry into a temp directory.",
50
50
  "publish:stonedog-style": "bash scripts/publish-package.sh",
51
51
  "version:bump:patch": "npm version patch --no-git-tag-version",
52
52
  "version:bump:minor": "npm version minor --no-git-tag-version",
@@ -173,6 +173,28 @@ export const StyledPage = React.forwardRef<HTMLDivElement, StyledPageProps>(
173
173
  flexDirection: "column",
174
174
  flex: "1",
175
175
  width: "100%",
176
+ // BOTH `flex: 1` and `height: 100%`, and the second is not
177
+ // redundant (NEH-802).
178
+ //
179
+ // `flex: 1` covers the common case — the page is a flex item in a
180
+ // column, and takes the space the column offers. That is what the
181
+ // app shell does, so it is easy to conclude this is the only case
182
+ // and drop the height. It was dropped, and it broke a production
183
+ // dashboard.
184
+ //
185
+ // `flex: 1` is INERT inside a block-level parent. HopperGuard's
186
+ // `/dashboard` nests one page inside another through plain
187
+ // `display: block` wrappers, and there the page's height came
188
+ // entirely from `height: 100%`. Without it the inner page fell
189
+ // back to CONTENT height — 138px, of which a widget header took
190
+ // ~128, leaving its `1fr` body 10px of padding and the grid zero.
191
+ // The header rendered; every tile was clipped to nothing.
192
+ //
193
+ // The failure is silent in the worst way: nothing errors, nothing
194
+ // is unstyled, and the page looks deliberately empty. Only a
195
+ // computed-height walk up the DOM shows it, which is why no unit
196
+ // test or type-check can stand in for the assertion below.
197
+ height: "100%",
176
198
  // See the contract note above: without this the overflow escapes
177
199
  // the row rather than scrolling inside it.
178
200
  minHeight: "0",
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { log } from "../config/logger";
4
- import React, { useRef, useState, useLayoutEffect, useEffect } from "react";
4
+ import React, { useRef, useState, useLayoutEffect, useEffect, useCallback } from "react";
5
5
  import { createPortal } from "react-dom";
6
6
  import { styled } from "styled-system/jsx";
7
7
  import StyledText from "./StyledText";
@@ -133,6 +133,42 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
133
133
  const triggerRef = useRef<HTMLDivElement>(null);
134
134
  const tooltipRef = useRef<HTMLDivElement>(null);
135
135
  const helpRef = useRef<HTMLButtonElement>(null);
136
+ /**
137
+ * Schedule the open.
138
+ *
139
+ * **Clearing first is the whole of NEH-818.** `show()` runs from four
140
+ * places — the trigger's mouseenter and focus, and the tooltip's own
141
+ * mouseenter — and more than one of them fires for a single gesture: a press
142
+ * both hovers and focuses the trigger, ~0ms apart. Assigning over
143
+ * `timeoutRef.current` left the earlier timer running with nothing holding
144
+ * its id, so `hide()` could cancel only the last one scheduled.
145
+ *
146
+ * The orphan then fired into a page the reader had already left, opening a
147
+ * tooltip that no departure event could ever close — measured as a live,
148
+ * opaque, click-eating overlay sitting over the dialog the press had just
149
+ * opened, gone only on reload.
150
+ *
151
+ * One timer at a time; the id is nulled when it fires so `hide()` never
152
+ * clears a stale one.
153
+ *
154
+ * Hoisted above the effects (and memoised) rather than declared beside the
155
+ * JSX: the ancestor-focus effect added for NEH-950 has to bind these as
156
+ * listeners, and a second copy of the timer discipline above is exactly how
157
+ * NEH-818 would come back.
158
+ */
159
+ const show = useCallback(() => {
160
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
161
+ timeoutRef.current = setTimeout(() => {
162
+ timeoutRef.current = null;
163
+ setVisible(true);
164
+ }, delay);
165
+ }, [delay]);
166
+ const hide = useCallback(() => {
167
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
168
+ timeoutRef.current = null;
169
+ setVisible(false);
170
+ }, []);
171
+
136
172
  /**
137
173
  * A hover trigger on a device that cannot hover is not a worse experience —
138
174
  * it is an unreachable one. There is no hover event, and tapping the control
@@ -154,6 +190,38 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
154
190
  const [focusableChild, setFocusableChild] = useState<HTMLElement | null>(null);
155
191
  const [hasFocusableChild, setHasFocusableChild] = useState(true);
156
192
 
193
+ /**
194
+ * The focusable element this trigger sits *inside*, if any (NEH-950).
195
+ *
196
+ * `hasFocusableChild` looks down and cannot see upwards, so it answers "no
197
+ * focusable child" for an icon that is decorative content inside a control
198
+ * that is already focusable and already named — and the trigger then took a
199
+ * `tabIndex` of its own. The result was the exact failure the conditional
200
+ * above exists to prevent, one level in: a second tab stop inside a button
201
+ * the reader has already passed, carrying no role and no name because
202
+ * `needsFallbackName` correctly declines to name it (the ancestor already
203
+ * has). Every icon in `stonedog-icons` that carries its own tooltip
204
+ * reproduced it, in every consumer.
205
+ *
206
+ * Deleting the `tabIndex` alone would have been a different WCAG failure
207
+ * rather than a fix — the tooltip must stay reachable by keyboard (2.1.1).
208
+ * So the ancestor becomes the trigger instead: it already owns the tab stop,
209
+ * and the effect below opens the tooltip when it takes focus, exactly as a
210
+ * focusable *child* already does by bubbling.
211
+ *
212
+ * Starts null, and the layout effect below can only ever find an ancestor
213
+ * when there is no focusable child — the two are mutually exclusive by
214
+ * construction, so nothing has to decide between them.
215
+ */
216
+ const [focusableAncestor, setFocusableAncestor] = useState<HTMLElement | null>(null);
217
+
218
+ /**
219
+ * True when something else — a descendant or an ancestor — already puts this
220
+ * trigger's content in the tab sequence. When it does, the trigger must add
221
+ * no stop of its own, and must not invent a role or a name for one.
222
+ */
223
+ const insideFocusable = hasFocusableChild || focusableAncestor !== null;
224
+
157
225
  // When the trigger KEEPS its tab stop it must have a role and a name (WCAG
158
226
  // 2.2 4.1.2) — but only if nothing else already provides one. Borrowing the
159
227
  // tooltip text unconditionally is what broke SharedWithIndicator, which names
@@ -172,6 +240,16 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
172
240
  setFocusableChild((prev) => (prev === found ? prev : found));
173
241
  setHasFocusableChild(found !== null);
174
242
 
243
+ // `parentElement.closest`, not `node.closest`: the trigger itself may be
244
+ // carrying the very `tabindex` this is deciding whether to keep, and
245
+ // matching ourselves would make the answer depend on the previous render.
246
+ // Only asked when there is no focusable child, because a child already
247
+ // settles the question and is the nearer trigger of the two.
248
+ const ancestor = found
249
+ ? null
250
+ : node?.parentElement?.closest<HTMLElement>(FOCUSABLE_SELECTOR) ?? null;
251
+ setFocusableAncestor((prev) => (prev === ancestor ? prev : ancestor));
252
+
175
253
  if (!node) return;
176
254
  // parentElement, not the node itself: closest() would match our own
177
255
  // aria-label once we set one, and the answer would flip every render.
@@ -194,7 +272,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
194
272
  // component forwarding the prop, and a child that quietly drops it would fail
195
273
  // invisibly.
196
274
  useLayoutEffect(() => {
197
- const node = focusableChild;
275
+ const node = focusableChild ?? focusableAncestor;
198
276
  if (!node || !visible) return;
199
277
  const previous = node.getAttribute("aria-describedby");
200
278
  node.setAttribute(
@@ -205,7 +283,31 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
205
283
  if (previous === null) node.removeAttribute("aria-describedby");
206
284
  else node.setAttribute("aria-describedby", previous);
207
285
  };
208
- }, [focusableChild, visible, tooltipId]);
286
+ }, [focusableChild, focusableAncestor, visible, tooltipId]);
287
+
288
+ /**
289
+ * Open on the ANCESTOR's focus, when the trigger is inside one (NEH-950).
290
+ *
291
+ * A focusable *child* needs nothing here: `focusin`/`focusout` bubble, so the
292
+ * wrapper's own `onFocus`/`onBlur` already fire for it. An ancestor is the
293
+ * other direction, where nothing bubbles, so the listeners go on the ancestor
294
+ * itself.
295
+ *
296
+ * Without this the fix would trade WCAG 2.2 4.1.2 (a focusable element with
297
+ * no role and no name) for 2.1.1 — the explanation would be rendered and
298
+ * reachable by pointer only. Hover mode only: click mode never took a tab
299
+ * stop, so it has nothing to give back.
300
+ */
301
+ useEffect(() => {
302
+ const node = focusableAncestor;
303
+ if (isClick || !node) return;
304
+ node.addEventListener("focusin", show);
305
+ node.addEventListener("focusout", hide);
306
+ return () => {
307
+ node.removeEventListener("focusin", show);
308
+ node.removeEventListener("focusout", hide);
309
+ };
310
+ }, [focusableAncestor, isClick, show, hide]);
209
311
 
210
312
  useLayoutEffect(() => {
211
313
  if (visible && triggerRef.current && tooltipRef.current) {
@@ -269,9 +371,19 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
269
371
  }
270
372
  }, [visible, placement]);
271
373
 
272
- // Click mode owns its own dismissal. Hover mode needs none of this: it
273
- // closes when the pointer leaves. A panel opened by a deliberate press has
274
- // to be closable by a deliberate action, and Escape has to work, or a
374
+ // A pending open timer must not outlive the component. Nothing else clears
375
+ // it on unmount, so a trigger removed inside the delay window fired
376
+ // setVisible on a component React had already torn down.
377
+ useEffect(
378
+ () => () => {
379
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
380
+ timeoutRef.current = null;
381
+ },
382
+ [],
383
+ );
384
+
385
+ // Click mode's dismissal. A panel opened by a deliberate press has to be
386
+ // closable by a deliberate action — a press outside it, or Escape — or a
275
387
  // keyboard user is stuck with it open.
276
388
  useEffect(() => {
277
389
  if (!isClick || !visible || typeof document === "undefined") return;
@@ -280,6 +392,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
280
392
  if (event.key !== "Escape") return;
281
393
  setVisible(false);
282
394
  // Focus goes back to what opened it — never to the top of the document.
395
+ // Hover mode has no equivalent, because nothing was focused to open it.
283
396
  helpRef.current?.focus();
284
397
  };
285
398
  const onPointerDown = (event: MouseEvent) => {
@@ -298,6 +411,69 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
298
411
  };
299
412
  }, [isClick, visible]);
300
413
 
414
+ /**
415
+ * Hover mode's dismissal, which used to be nothing at all (NEH-818).
416
+ *
417
+ * `hide()` is reachable only from the trigger's own `onMouseLeave` /
418
+ * `onBlur`, so an open tooltip whose trigger never receives another
419
+ * departure event stays on the page for the life of the document — opaque,
420
+ * taking pointer events, over whatever opened on top of it. That is not a
421
+ * hypothetical ordering: a press both focuses the trigger and covers it, so
422
+ * blur cannot fire (focus stays put) and mouseleave has already been and
423
+ * gone.
424
+ *
425
+ * These two listeners are on `document`, so neither depends on the trigger
426
+ * being reachable — which is the property the trigger's own handlers lack.
427
+ *
428
+ * - **`pointermove`** closes it once the pointer is over neither the trigger
429
+ * nor the tooltip. It deliberately does not fire on the tooltip itself:
430
+ * WCAG 2.2 1.4.13 *Hoverable* requires the reader be able to move onto the
431
+ * revealed text without it vanishing, which is also why the portal keeps
432
+ * `pointer-events: auto`.
433
+ * - **Escape** satisfies 1.4.13 *Dismissible*, which hover mode did not meet
434
+ * before: content revealed on hover or focus must be dismissable without
435
+ * moving the pointer or focus, and a reader whose pointer is parked had no
436
+ * way to clear it.
437
+ *
438
+ * Bound only while a hover tooltip is actually open, so the common case
439
+ * costs nothing.
440
+ */
441
+ useEffect(() => {
442
+ if (isClick || !visible || typeof document === "undefined") return;
443
+
444
+ const onKeyDown = (event: KeyboardEvent) => {
445
+ if (event.key !== "Escape") return;
446
+ // No focus move: in hover mode nothing was focused to open this, and
447
+ // stealing focus on Escape would be its own bug.
448
+ setVisible(false);
449
+ };
450
+ const onPointerMove = (event: PointerEvent) => {
451
+ const target = event.target as Node | null;
452
+ if (!target) return;
453
+ if (triggerRef.current?.contains(target) || tooltipRef.current?.contains(target)) return;
454
+ // A tooltip revealed by FOCUS belongs to the focus, not to the pointer.
455
+ // WCAG 2.2 1.4.13 Persistent requires it to stay until its trigger is
456
+ // released, so taking it away because an unrelated mouse moved would
457
+ // trade one conformance failure for another — and would do it to a
458
+ // keyboard reader who never touched the mouse. Escape above is their
459
+ // dismissal.
460
+ if (
461
+ document.activeElement &&
462
+ triggerRef.current?.contains(document.activeElement)
463
+ ) {
464
+ return;
465
+ }
466
+ setVisible(false);
467
+ };
468
+
469
+ document.addEventListener("keydown", onKeyDown);
470
+ document.addEventListener("pointermove", onPointerMove);
471
+ return () => {
472
+ document.removeEventListener("keydown", onKeyDown);
473
+ document.removeEventListener("pointermove", onPointerMove);
474
+ };
475
+ }, [isClick, visible]);
476
+
301
477
  if (!tooltip) {
302
478
  return <>{children}</>;
303
479
  }
@@ -306,13 +482,6 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
306
482
  // would produce "[object Object]" in the accessibility tree.
307
483
  const tooltipLabel = typeof tooltip === "string" ? tooltip : undefined;
308
484
 
309
- const show = () => {
310
- timeoutRef.current = setTimeout(() => setVisible(true), delay);
311
- };
312
- const hide = () => {
313
- if (timeoutRef.current) clearTimeout(timeoutRef.current);
314
- setVisible(false);
315
- };
316
485
 
317
486
  return (
318
487
  <>
@@ -323,7 +492,13 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
323
492
  // tooltip still fires without the wrapper taking focus itself. Adding
324
493
  // tabIndex here regardless is what gave every tooltipped control two
325
494
  // tab stops, the second of them silent (NEH-127).
326
- tabIndex={isClick || hasFocusableChild ? undefined : 0}
495
+ //
496
+ // `insideFocusable`, not `hasFocusableChild`: an ANCESTOR owns the tab
497
+ // stop just as effectively as a descendant, and looking only downwards
498
+ // put the same silent second stop inside every icon button in the
499
+ // fleet (NEH-950). The ancestor-focus effect above is what keeps the
500
+ // tooltip reachable once the trigger stops taking focus itself.
501
+ tabIndex={isClick || insideFocusable ? undefined : 0}
327
502
  // A focusable element needs a role and a name (WCAG 2.2 4.1.2). Applied
328
503
  // only when the trigger keeps the tab stop AND nothing else names it —
329
504
  // see needsFallbackName above for why the condition matters (NEH-151).
@@ -332,9 +507,9 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
332
507
  // reveals content on focus, which is the closest standard role and what
333
508
  // the ARIA tooltip pattern assumes of a trigger. A focusable generic
334
509
  // with only a name still fails 4.1.2, which asks for both.
335
- role={!isClick && !hasFocusableChild && needsFallbackName ? "button" : undefined}
510
+ role={!isClick && !insideFocusable && needsFallbackName ? "button" : undefined}
336
511
  aria-label={
337
- isClick || hasFocusableChild
512
+ isClick || insideFocusable
338
513
  ? undefined
339
514
  : ariaLabel ?? (needsFallbackName ? tooltipLabel : undefined)
340
515
  }
@@ -345,7 +520,7 @@ const StyledTooltip: React.FC<StyledTooltipProps> = ({
345
520
  onFocus={isClick ? undefined : show}
346
521
  onBlur={isClick ? undefined : hide}
347
522
  aria-describedby={
348
- !isClick && !hasFocusableChild && visible ? tooltipId : undefined
523
+ !isClick && !insideFocusable && visible ? tooltipId : undefined
349
524
  }
350
525
  {...rest}
351
526
  >
@@ -36,6 +36,7 @@ import {
36
36
  createSemanticFonts,
37
37
  createSemanticSizes,
38
38
  } from "./semantic-variables";
39
+ import { createZIndexTokens } from "./z-layers";
39
40
 
40
41
  export interface StonedogStylePresetOptions {
41
42
  /**
@@ -184,6 +185,19 @@ export function stonedogStylePreset(options: StonedogStylePresetOptions = {}) {
184
185
  */
185
186
  fonts: createSemanticFonts(cssVarPrefix),
186
187
  fontWeights: createSemanticFontWeights(cssVarPrefix),
188
+ /**
189
+ * Named stacking layers (NEH-830). Neither this preset nor either
190
+ * base Panda preset defined any, so `drawerRecipe`'s `zIndex:
191
+ * "modal"` resolved to nothing and was emitted as the literal
192
+ * `z-index: modal` — invalid CSS the browser discards, leaving that
193
+ * panel with no z-index at all.
194
+ *
195
+ * Unlike the colour tokens these carry no custom property: a layer
196
+ * is not a brand decision and there is nothing for a theme to
197
+ * restyle. A HOST overrides the numbers in its own config; see
198
+ * z-layers.ts for why the names live here and the values do not.
199
+ */
200
+ zIndex: createZIndexTokens(),
187
201
  },
188
202
  keyframes: {
189
203
  spin: {
@@ -250,6 +264,13 @@ export {
250
264
  requiredCssCustomProperties,
251
265
  } from "./semantic-variables";
252
266
 
267
+ export {
268
+ Z_LAYERS,
269
+ createZIndexTokens,
270
+ zIndexTokenNames,
271
+ type ZLayerName,
272
+ } from "./z-layers";
273
+
253
274
  export { recipes as stonedogStyleRecipes };
254
275
 
255
276
  /* ------------------------------------------------------------------------- *
@@ -37,10 +37,20 @@ export const buttonRecipe = defineRecipe({
37
37
  variant: {
38
38
  solid: {
39
39
  bg: "buttonBgAccent",
40
- color: "textPrimary",
40
+ // The same pairing `outline` states below, and for the same reason
41
+ // (NEH-441, NEH-796). `textPrimary` is the colour that goes on
42
+ // `boxBgPrimary`, not on an accent surface: against optima's light
43
+ // theme that pairing measures 2.43:1, below WCAG AA, while
44
+ // `buttonTextAccent` measures 7.34:1. The dark theme happens to
45
+ // survive it, which is what let a default variant ship unreadable.
46
+ //
47
+ // `TEXT_BACKGROUND_PAIRS` in `semantic-variables.ts` is the contract
48
+ // being obeyed: `buttonTextAccent` sits on `buttonBgAccent`, and
49
+ // `buttonTextSecondary` on the `buttonBgSecondary` the hover repaints.
50
+ color: "buttonTextAccent",
41
51
  _hover: {
42
52
  bg: "buttonBgSecondary",
43
- color: "textPrimary",
53
+ color: "buttonTextSecondary",
44
54
  },
45
55
  },
46
56
  outline: {
@@ -77,7 +87,15 @@ export const buttonRecipe = defineRecipe({
77
87
  border: "2px solid",
78
88
  borderRadius: "xl",
79
89
  bg: "buttonBgAccent",
80
- color: "textPrimary",
90
+ // The same correction `solid` took in NEH-796, which this variant was
91
+ // missed by twice over (NEH-877): the pairing sweep that landed with it
92
+ // was scoped to this recipe, but it also skipped every variant named
93
+ // `glass` — on the reasoning that a translucent surface should inherit.
94
+ // This one is not translucent. It paints an opaque `buttonBgAccent` and
95
+ // blurs what is BEHIND it, so it owes a paired foreground like any
96
+ // other accent surface: 2.43:1 with `textPrimary` against optima's
97
+ // light theme, 7.34:1 with this.
98
+ color: "buttonTextAccent",
81
99
  boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
82
100
  backdropFilter: "blur(12px)",
83
101
  WebkitBackdropFilter: "blur(12px)",
@@ -86,6 +104,13 @@ export const buttonRecipe = defineRecipe({
86
104
  transition: "all 0.3s ease",
87
105
  _hover: {
88
106
  bg: "buttonBgSecondary",
107
+ // Stated, because the hover repaints a DIFFERENT surface (NEH-877).
108
+ // Without it the label rides its base `buttonTextAccent` onto a
109
+ // secondary background — 1.06:1 in optima's light theme, white on
110
+ // near-white, i.e. the label vanishing on hover. This is the same
111
+ // move `solid` made in NEH-796 and the reason a base-colour fix has
112
+ // to look at every pseudo-state that repaints beneath it.
113
+ color: "buttonTextSecondary",
89
114
  borderColor: "rgba(255,255,255,0.4)",
90
115
  boxShadow: "0 8px 32px rgba(0,0,0,0.3), inset 0 0 20px rgba(255,255,255,0.1)",
91
116
  transform: "translateY(-1px)",
@@ -126,7 +151,12 @@ export const buttonRecipe = defineRecipe({
126
151
  },
127
152
  },
128
153
  selected: {
129
- color: "textPrimary",
154
+ // `textAccent`, not `textPrimary` — the same mispairing `solid` had
155
+ // (NEH-796). `boxBgAccent` is an accent surface and the contract's
156
+ // partner for it is `textAccent`; `textPrimary` is the colour for
157
+ // `boxBgPrimary`, a different surface. Adjacent instance of the defect
158
+ // the issue names, in the same recipe, so it is fixed here.
159
+ color: "textAccent",
130
160
  border: "3px dashed black",
131
161
  borderRadius: "xl",
132
162
  bg: "boxBgAccent",
@@ -15,7 +15,12 @@ export const formRecipe = defineRecipe({
15
15
  variant: {
16
16
  solid: {
17
17
  bg: "boxBgAccent",
18
- color: "textPrimary",
18
+ // `textAccent`, not `textPrimary` (NEH-877). `textPrimary` is the
19
+ // contract's partner for `boxBgPrimary`, a different surface: against
20
+ // optima's light theme, whose accent surface is a near-black graphite,
21
+ // that pairing measures 1.17:1 — a form whose every label is the exact
22
+ // colour of the panel behind it. `textAccent` measures 15.27:1.
23
+ color: "textAccent",
19
24
  borderColor: "borderBgPrimary",
20
25
  "& > li:not(:last-child)": {
21
26
  borderBottom: "1px solid",
@@ -64,23 +64,42 @@ export const buttonIconRecipe = defineRecipe({
64
64
  },
65
65
  },
66
66
  variant: {
67
+ /**
68
+ * The NEH-796 pairing, applied to the recipe it was never applied to
69
+ * (NEH-877).
70
+ *
71
+ * `buttonRecipe` and this one are the same control in two shapes, and
72
+ * `iconButton` carried the identical defect in three variants: an accent
73
+ * fill labelled with `textPrimary`, the contract's partner for
74
+ * `boxBgPrimary`. Against optima's light theme that measures 2.43:1,
75
+ * below WCAG AA; `buttonTextAccent` measures 7.34:1. The dark theme
76
+ * clears AA either way, which is what let it ship.
77
+ *
78
+ * Each `_hover` that repaints a different surface states its own colour
79
+ * for the same reason — the base colour riding onto a secondary fill is
80
+ * the failure one state along.
81
+ */
67
82
  solid: {
68
83
  bg: "buttonBgAccent",
69
- color: "textPrimary",
84
+ color: "buttonTextAccent",
70
85
  _hover: {
71
86
  bg: "buttonBgSecondary",
72
- color: "textPrimary",
87
+ color: "buttonTextSecondary",
73
88
  },
74
89
  },
75
90
  outline: {
76
91
  bg: "buttonBgAccent",
77
- color: "textPrimary",
92
+ color: "buttonTextAccent",
78
93
  border: "1px solid",
79
94
  borderColor: "borderBgSecondary",
80
95
  borderRadius: 0,
81
96
  _hover: {
82
97
  border: "2px solid",
83
98
  bg: "buttonBgAccentHover",
99
+ // The hover surface has its own partner in the contract, and it is
100
+ // not the base one. Both are white in optima's palette today; the
101
+ // point is that a host is free to make them differ.
102
+ color: "buttonTextAccentHover",
84
103
  },
85
104
  },
86
105
  aurora: {
@@ -98,7 +117,10 @@ export const buttonIconRecipe = defineRecipe({
98
117
  borderColor: "black",
99
118
  borderRadius: "xl",
100
119
  bg: "buttonBgAccent",
101
- color: "textPrimary",
120
+ // Not translucent, whatever the name suggests: the fill is an opaque
121
+ // `buttonBgAccent` and the blur applies to what is behind it. So it
122
+ // takes the accent partner like its neighbours above (NEH-877).
123
+ color: "buttonTextAccent",
102
124
  boxShadow: "0 8px 32px rgba(0,0,0,0.2)",
103
125
  backdropFilter: "blur(12px)",
104
126
  WebkitBackdropFilter: "blur(12px)",
@@ -107,6 +129,10 @@ export const buttonIconRecipe = defineRecipe({
107
129
  transition: "all 0.3s ease",
108
130
  _hover: {
109
131
  bg: "buttonBgSecondary",
132
+ // Stated because the hover repaints a different surface — otherwise
133
+ // the glyph keeps `buttonTextAccent` over a secondary fill, 1.06:1 in
134
+ // optima's light theme (NEH-877).
135
+ color: "buttonTextSecondary",
110
136
  borderColor: "rgba(255,255,255,0.4)",
111
137
  boxShadow: "0 8px 32px rgba(0,0,0,0.3), inset 0 0 20px rgba(255,255,255,0.1)",
112
138
  transform: "translateY(-1px)",
@@ -140,11 +166,16 @@ export const buttonIconRecipe = defineRecipe({
140
166
  fontWeight: "bold",
141
167
  },
142
168
  ghost: {
169
+ // The base stays `textPrimary`: a 50% accent fill is a BLEND with
170
+ // whatever is behind it, so the contract has no partner for it and
171
+ // `textPrimary` measures 5.9:1 over the light-theme blend. The hover
172
+ // takes the fill to full opacity, which IS a contract surface — and
173
+ // there `textPrimary` is the 2.43:1 pairing again (NEH-877).
143
174
  bg: "buttonBgAccent/50",
144
175
  color: "textPrimary",
145
176
  _hover: {
146
177
  bg: "buttonBgAccent",
147
- color: "textPrimary",
178
+ color: "buttonTextAccent",
148
179
  border: "1px solid",
149
180
  borderColor: "gray.700",
150
181
  },
@@ -103,7 +103,19 @@ export const inputBoolRecipe = defineSlotRecipe({
103
103
  solid: {
104
104
  control: {
105
105
  bg: "buttonBgAccent",
106
- color: "textPrimary",
106
+ /**
107
+ * The contract's partner for this fill (NEH-877), and INERT today.
108
+ *
109
+ * Neither `bg` nor `color` paints on a checkbox at `appearance:
110
+ * auto` — the note in `base` and NEH-234 are about exactly that — so
111
+ * this changes nothing anyone can see, and that is the honest reason
112
+ * to make it: the declarations are left in place per that note, and
113
+ * a declaration left in place should not be left WRONG. `textPrimary`
114
+ * is the partner of `boxBgPrimary`; the day option 2 on NEH-310
115
+ * (`appearance: none` plus a hand-drawn tick) makes these paint, it
116
+ * would paint at 2.43:1 against optima's light theme.
117
+ */
118
+ color: "buttonTextAccent",
107
119
  accentColor: "buttonBgPrimary",
108
120
  boxShadow: "none",
109
121
  },
@@ -111,7 +123,8 @@ export const inputBoolRecipe = defineSlotRecipe({
111
123
  outline: {
112
124
  control: {
113
125
  bg: "buttonBgAccent",
114
- color: "textPrimary",
126
+ // Same fill, same partner, same inertness as `solid` above.
127
+ color: "buttonTextAccent",
115
128
  /**
116
129
  * The SAME checked fill as `solid`, deliberately.
117
130
  *
@@ -227,7 +240,10 @@ export const inputBoolRecipe = defineSlotRecipe({
227
240
  },
228
241
  ghost: {
229
242
  control: {
230
- color: "textSecondary",
243
+ // `buttonTextSecondary` is the partner of the fill below;
244
+ // `textSecondary` belongs to `boxBgSecondary` (NEH-877). Inert for
245
+ // the same reason as `solid`.
246
+ color: "buttonTextSecondary",
231
247
  bg: "buttonBgSecondary",
232
248
  accentColor: "buttonBgPrimary",
233
249
  // The thinnest ring in the set, in the secondary border colour.
@@ -82,7 +82,11 @@ export const inputRadioRootRecipe = defineSlotRecipe({
82
82
  solid: {
83
83
  item: {
84
84
  bg: "boxBgAccent",
85
- color: "textPrimary",
85
+ // `textAccent` is the contract's partner for this surface;
86
+ // `textPrimary` belongs to `boxBgPrimary` and measures 1.17:1 on it
87
+ // against optima's light theme, whose accent surface is a near-black
88
+ // graphite (NEH-877). This is the DEFAULT variant of this recipe.
89
+ color: "textAccent",
86
90
  borderColor: "borderBgPrimary",
87
91
  },
88
92
  },
@@ -116,12 +120,16 @@ export const inputRadioRootRecipe = defineSlotRecipe({
116
120
  matte: {
117
121
  item: {
118
122
  bg: "buttonBgSecondary",
119
- color: "textAccent",
123
+ // Was `textAccent` — the partner of `boxBgAccent`, a surface this
124
+ // variant does not paint. 1.14:1 in optima's light theme, where the
125
+ // accent text is white and this fill is near-white (NEH-877).
126
+ color: "buttonTextSecondary",
120
127
  },
121
128
  },
122
129
  ghost: {
123
130
  item: {
124
- color: "textSecondary",
131
+ // `textSecondary` is `boxBgSecondary`'s partner, not this fill's.
132
+ color: "buttonTextSecondary",
125
133
  bg: "buttonBgSecondary",
126
134
  border: "none",
127
135
  },
@@ -66,12 +66,31 @@ export const inputSurfaceBase = {
66
66
  * The variant map. Every variant states its own `bg`, `color`, and
67
67
  * `borderColor` rather than inheriting some and not others — that asymmetry is
68
68
  * what let the two recipes disagree without either looking wrong on its own.
69
+ *
70
+ * ## The colour has to be the surface's PARTNER, not just a colour (NEH-877)
71
+ *
72
+ * Four of these paired a surface with the wrong contract token, and because
73
+ * `inputTextRecipe` and `inputDropdownRecipe` both spread this map, each one was
74
+ * eight offending selectors rather than four. `solid` and `glass` paint
75
+ * `boxBgAccent` and said `textPrimary` — the partner of `boxBgPrimary` — which
76
+ * measures **1.17:1** against optima's light theme, a control whose typed value
77
+ * is the exact colour of the field. `matte` and `ghost` paint
78
+ * `buttonBgSecondary` and said `textSecondary`, the partner of `boxBgSecondary`.
79
+ *
80
+ * **The placeholder moves with the text**, which is the part easy to miss here:
81
+ * `inputSurfaceBase` sets `&::placeholder { color: textPrimary }` for the
82
+ * base's own `textPrimary`, so a variant that repaints the surface and only
83
+ * fixes `color` leaves the placeholder on the old pairing — the same defect,
84
+ * surviving in the one piece of text an empty field actually shows.
69
85
  */
70
86
  export const inputSurfaceVariants = {
71
87
  solid: {
72
88
  bg: "boxBgAccent",
73
- color: "textPrimary",
89
+ color: "textAccent",
74
90
  borderColor: "borderBgPrimary",
91
+ "&::placeholder": {
92
+ color: "textAccent",
93
+ },
75
94
  },
76
95
  outline: {
77
96
  // Stated, not omitted. An outline control is meant to show whatever is
@@ -97,12 +116,15 @@ export const inputSurfaceVariants = {
97
116
  borderStyle: "solid",
98
117
  borderRadius: "xl",
99
118
  bg: "boxBgAccent",
100
- color: "textPrimary",
119
+ // An opaque accent fill with a blur applied to what is behind it — not a
120
+ // translucent surface, whatever the name says, so it takes the accent
121
+ // partner like `solid` (NEH-877).
122
+ color: "textAccent",
101
123
  borderColor: "borderBgSecondary",
102
124
  backdropFilter: "blur(12px)",
103
125
  fontWeight: "normal",
104
126
  _placeholder: {
105
- color: "textPrimary",
127
+ color: "textAccent",
106
128
  opacity: 0.8,
107
129
  },
108
130
  _focusVisible: {
@@ -130,15 +152,20 @@ export const inputSurfaceVariants = {
130
152
  },
131
153
  matte: {
132
154
  bg: "buttonBgSecondary",
133
- color: "textSecondary",
155
+ color: "buttonTextSecondary",
134
156
  borderColor: "borderBgPrimary",
157
+ // Otherwise the placeholder keeps the base's `textPrimary` while the value
158
+ // above it moves — see the note on this map (NEH-877).
159
+ "&::placeholder": {
160
+ color: "buttonTextSecondary",
161
+ },
135
162
  },
136
163
  ghost: {
137
- color: "textSecondary",
164
+ color: "buttonTextSecondary",
138
165
  bg: "buttonBgSecondary",
139
166
  borderColor: "borderBgPrimary",
140
167
  "&::placeholder": {
141
- color: "textSecondary/60",
168
+ color: "buttonTextSecondary/60",
142
169
  },
143
170
  },
144
171
  none: {
@@ -164,12 +164,18 @@ export const listRecipe = defineSlotRecipe({
164
164
  },
165
165
  ghost: {
166
166
  item: {
167
- color: "textSecondary",
167
+ // The partner of `buttonBgSecondary` (NEH-877). `textSecondary`
168
+ // belongs to `boxBgSecondary`, a surface this variant does not paint.
169
+ color: "buttonTextSecondary",
168
170
  bg: "buttonBgSecondary",
169
171
  borderRadius: "md",
170
172
  cursor: "pointer",
171
173
  _hover: {
172
174
  bg: "boxBgAccent",
175
+ // The hover repaints an accent surface, so the row must take that
176
+ // surface's partner rather than ride the base colour onto it —
177
+ // 1.14:1 in optima's light theme if it did.
178
+ color: "textAccent",
173
179
  },
174
180
  },
175
181
  },
@@ -1,12 +1,34 @@
1
1
  import { defineRecipe } from "@pandacss/dev";
2
2
 
3
+ /**
4
+ * Every variant here paints the SAME surface, so every variant owes the same
5
+ * text colour (NEH-877).
6
+ *
7
+ * `boxBgPrimary` is the background of all nine, base included — the variants
8
+ * differ by border and nothing else. Yet seven of them named a text token
9
+ * belonging to some other surface: `buttonTextSecondary`, `textSecondary`,
10
+ * `textMain`, `buttonTextPrimary`. The contract's partner for `boxBgPrimary` is
11
+ * `textPrimary`, and there is no reading of these variants under which a
12
+ * different one is intended — they are copy-paste drift, not design.
13
+ *
14
+ * Measured against optima-cloud-saas's light theme, the worst was `glass` at
15
+ * **1.05:1** — `buttonTextPrimary` is white and `boxBgPrimary` is #f8f9fa, so
16
+ * that tooltip rendered white text on a near-white panel and said nothing at
17
+ * all. The dark theme reads it at 17.84:1, which is why nobody saw it.
18
+ * `textPrimary` measures 16.92:1 light and 15.02:1 dark.
19
+ *
20
+ * A tooltip is the one surface where this class of defect is invisible in
21
+ * review: it appears on hover, over the thing it describes, and an empty-looking
22
+ * one reads as a tooltip that has not opened yet.
23
+ */
3
24
  export const tooltipRecipe = defineRecipe({
4
25
  className: "tooltip",
5
26
  base: {
6
27
  position: "absolute",
7
28
  zIndex: 9999,
8
29
  pointerEvents: "none",
9
- color: "buttonTextSecondary",
30
+ // The partner of the `boxBgPrimary` this base paints (NEH-877).
31
+ color: "textPrimary",
10
32
  padding: "2px",
11
33
  borderRadius: "md",
12
34
  fontSize: "var(--font-sizes-lg, 1rem)",
@@ -25,7 +47,7 @@ export const tooltipRecipe = defineRecipe({
25
47
  variant: {
26
48
  solid: {
27
49
  bg: "boxBgPrimary",
28
- color: "buttonTextSecondary",
50
+ color: "textPrimary",
29
51
  borderColor: "borderBgPrimary",
30
52
  },
31
53
  outline: {
@@ -36,25 +58,25 @@ export const tooltipRecipe = defineRecipe({
36
58
  },
37
59
  aurora: {
38
60
  bg: "boxBgPrimary",
39
- color: "textSecondary",
61
+ color: "textPrimary",
40
62
  borderColor: "borderBgSecondary",
41
63
  border: "1px solid",
42
64
  },
43
65
  glass: {
44
66
  bg: "boxBgPrimary",
45
- color: "buttonTextPrimary",
67
+ color: "textPrimary",
46
68
  border: "1px solid",
47
69
  borderColor: "borderBgPrimary",
48
70
  },
49
71
  matte: {
50
72
  bg: "boxBgPrimary",
51
- color: "textSecondary",
73
+ color: "textPrimary",
52
74
  borderColor: "borderBgSecondary",
53
75
  border: "1px solid",
54
76
  },
55
77
  ghost: {
56
78
  bg: "boxBgPrimary",
57
- color: "buttonTextSecondary",
79
+ color: "textPrimary",
58
80
  },
59
81
  link: {
60
82
  bg: "boxBgPrimary",
@@ -64,13 +86,13 @@ export const tooltipRecipe = defineRecipe({
64
86
  },
65
87
  none: {
66
88
  bg: "boxBgPrimary",
67
- color: "textMain",
89
+ color: "textPrimary",
68
90
  border: "1px solid",
69
91
  borderColor: "borderBgPrimary",
70
92
  },
71
93
  unstyled: {
72
94
  bg: "boxBgPrimary",
73
- color: "textMain",
95
+ color: "textPrimary",
74
96
  },
75
97
  },
76
98
  },
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The stacking-order vocabulary: names for the layers a UI actually has.
3
+ *
4
+ * ## The bug this exists to fix
5
+ *
6
+ * `drawerRecipe` has said `zIndex: "modal"` since it was extracted, and there
7
+ * has never been a `zIndex` token scale for `"modal"` to resolve against —
8
+ * neither here nor in either base Panda preset. Panda passes an unknown token
9
+ * through as a **literal**, so the generated stylesheet said
10
+ *
11
+ * .drawer { position: fixed; z-index: modal; }
12
+ *
13
+ * `modal` is not a valid `z-index` value, so the browser discards the whole
14
+ * declaration. **The drawer panel has never had a z-index at all.** No build
15
+ * error, no console warning, no type error — the class is in the DOM and the
16
+ * rule behind it is one line shorter than it looks.
17
+ *
18
+ * This is precisely the defect class CLAUDE.md records under "Token
19
+ * compliance", one property along from `bg: "buttonBgHover"`. The colour half
20
+ * is guarded by `token-contract.test.ts`; the z-index half is now guarded
21
+ * beside it.
22
+ *
23
+ * ## This package owns the NAMES. The host owns the NUMBERS
24
+ *
25
+ * A z-index ladder is an application concern — it encodes which of *that
26
+ * product's* surfaces may cover which, and no two products agree. So the
27
+ * values below are conventional defaults chosen to be sane for a fresh
28
+ * consumer, and a host is expected to override them:
29
+ *
30
+ * ```ts
31
+ * // the host's panda.config.ts
32
+ * theme: { extend: { tokens: { zIndex: { modal: { value: 99999 } } } } }
33
+ * ```
34
+ *
35
+ * Overriding a value keeps the name, and the name is the part that makes the
36
+ * order reviewable. A literal at a call site expresses nothing; `zIndex:
37
+ * "menu"` says what the element *is*, and a reader can check it against this
38
+ * table without opening a second file.
39
+ *
40
+ * ## The ladder
41
+ *
42
+ * Ascending, and the ORDER is the contract — not the numbers:
43
+ *
44
+ * hide behind its own box (decorative pseudo-elements)
45
+ * base the ordinary flow
46
+ * raised lifted within its own stacking context
47
+ * docked a bar or rail pinned inside a region
48
+ * sticky a sticky header inside a scroll region
49
+ * banner page-level chrome above sticky content
50
+ * surface a page surface that fills the viewport
51
+ * dialog a modal dialog and its scrim
52
+ * menu menus, drawer scrims, docked panels — the floating band's floor
53
+ * popover a popover that has to clear an open menu
54
+ * overlay a full-viewport cover: a splash, a loading shade
55
+ * toast toasts, pickers, transient chrome
56
+ * modal a drawer or modal panel that must clear everything but a tip
57
+ * tooltip the top of the application
58
+ *
59
+ * **A dialog sits LOW on purpose.** Menus, toasts, tooltips and dropdowns all
60
+ * have to be able to open *on* a dialog, so every one of them is above it.
61
+ * Raising the dialog to "win" is the change that looks right and breaks every
62
+ * control opened inside one.
63
+ */
64
+
65
+ /**
66
+ * The layer names, in ascending order, with this package's default values.
67
+ *
68
+ * Exported as plain numbers as well as tokens because a **portalled** element
69
+ * usually sets its z-index from an inline `style`, and an inline style cannot
70
+ * name a Panda token. Both readings have to come from one place or they drift.
71
+ */
72
+ export const Z_LAYERS = {
73
+ hide: -1,
74
+ base: 0,
75
+ raised: 1,
76
+ docked: 10,
77
+ sticky: 20,
78
+ banner: 50,
79
+ surface: 100,
80
+ dialog: 200,
81
+ menu: 300,
82
+ popover: 400,
83
+ overlay: 500,
84
+ toast: 600,
85
+ modal: 700,
86
+ tooltip: 800,
87
+ } as const;
88
+
89
+ export type ZLayerName = keyof typeof Z_LAYERS;
90
+
91
+ /** Every layer name. Useful to a consumer's guard test. */
92
+ export function zIndexTokenNames(): ZLayerName[] {
93
+ return Object.keys(Z_LAYERS) as ZLayerName[];
94
+ }
95
+
96
+ /** The scale in the shape Panda's `theme.extend.tokens.zIndex` wants. */
97
+ export function createZIndexTokens(): Record<string, { value: number }> {
98
+ return Object.fromEntries(
99
+ Object.entries(Z_LAYERS).map(([token, value]) => [token, { value }]),
100
+ );
101
+ }