@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 +5 -5
- package/src/components/StyledPage.tsx +22 -0
- package/src/components/StyledTooltip.tsx +192 -17
- package/src/preset/index.ts +21 -0
- package/src/preset/recipes/button.ts +34 -4
- package/src/preset/recipes/form.ts +6 -1
- package/src/preset/recipes/icon-button.ts +36 -5
- package/src/preset/recipes/input-bool.ts +19 -3
- package/src/preset/recipes/input-radio.ts +11 -3
- package/src/preset/recipes/input-surface.ts +33 -6
- package/src/preset/recipes/list.ts +7 -1
- package/src/preset/recipes/tooltip.ts +30 -8
- package/src/preset/z-layers.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stonedogcode/style",
|
|
3
|
-
"version": "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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
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
|
-
|
|
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 && !
|
|
510
|
+
role={!isClick && !insideFocusable && needsFallbackName ? "button" : undefined}
|
|
336
511
|
aria-label={
|
|
337
|
-
isClick ||
|
|
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 && !
|
|
523
|
+
!isClick && !insideFocusable && visible ? tooltipId : undefined
|
|
349
524
|
}
|
|
350
525
|
{...rest}
|
|
351
526
|
>
|
package/src/preset/index.ts
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
84
|
+
color: "buttonTextAccent",
|
|
70
85
|
_hover: {
|
|
71
86
|
bg: "buttonBgSecondary",
|
|
72
|
-
color: "
|
|
87
|
+
color: "buttonTextSecondary",
|
|
73
88
|
},
|
|
74
89
|
},
|
|
75
90
|
outline: {
|
|
76
91
|
bg: "buttonBgAccent",
|
|
77
|
-
color: "
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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: "
|
|
164
|
+
color: "buttonTextSecondary",
|
|
138
165
|
bg: "buttonBgSecondary",
|
|
139
166
|
borderColor: "borderBgPrimary",
|
|
140
167
|
"&::placeholder": {
|
|
141
|
-
color: "
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
61
|
+
color: "textPrimary",
|
|
40
62
|
borderColor: "borderBgSecondary",
|
|
41
63
|
border: "1px solid",
|
|
42
64
|
},
|
|
43
65
|
glass: {
|
|
44
66
|
bg: "boxBgPrimary",
|
|
45
|
-
color: "
|
|
67
|
+
color: "textPrimary",
|
|
46
68
|
border: "1px solid",
|
|
47
69
|
borderColor: "borderBgPrimary",
|
|
48
70
|
},
|
|
49
71
|
matte: {
|
|
50
72
|
bg: "boxBgPrimary",
|
|
51
|
-
color: "
|
|
73
|
+
color: "textPrimary",
|
|
52
74
|
borderColor: "borderBgSecondary",
|
|
53
75
|
border: "1px solid",
|
|
54
76
|
},
|
|
55
77
|
ghost: {
|
|
56
78
|
bg: "boxBgPrimary",
|
|
57
|
-
color: "
|
|
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: "
|
|
89
|
+
color: "textPrimary",
|
|
68
90
|
border: "1px solid",
|
|
69
91
|
borderColor: "borderBgPrimary",
|
|
70
92
|
},
|
|
71
93
|
unstyled: {
|
|
72
94
|
bg: "boxBgPrimary",
|
|
73
|
-
color: "
|
|
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
|
+
}
|