fix(design): the button and chip hover colours were never generated — an act: variant instead of a runtime prefix (E28)
buttonStyles/chipStyles glued the hover guard on at runtime (`${ACT}hover:text-fg`). Tailwind emits CSS
only for a class it finds whole in the source, so all 22 guarded hovers shipped with no rule: no text or
edge colour on hover for ghost/outline/danger buttons, no white layer on the player's control row, no
underline on a link button, no accent hover on token/suggest/ghost chips. The stylesheet had 0 rules
for `not-aria-disabled`.
- `@custom-variant act` (index.css): the same guard as the state layers; `:disabled` never matches an
<a>, so one variant serves buttons and links and the per-element variant maps go.
- ui/tailwindScan.contract.test.ts: every class the primitives can emit stands whole in their source.
- a lint rule refuses a variant right after an interpolation; the `enabled:hover:` rule now points at
`act:hover:`.
This commit is contained in:
@@ -372,6 +372,11 @@ const PALETTE =
|
|||||||
const PALETTE_MESSAGE =
|
const PALETTE_MESSAGE =
|
||||||
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
||||||
|
|
||||||
|
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`).
|
||||||
|
const SPLIT_CLASS = "/^[a-z][\\w-]*:[\\w\\[!-]/";
|
||||||
|
const SPLIT_CLASS_MESSAGE =
|
||||||
|
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||||
{
|
{
|
||||||
@@ -451,12 +456,19 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
||||||
message:
|
message:
|
||||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
||||||
message:
|
message:
|
||||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||||||
|
},
|
||||||
|
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
|
||||||
|
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
|
||||||
|
// hover colour of the button and chip primitives was dead that way (E28 audit).
|
||||||
|
{
|
||||||
|
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
|
||||||
|
message: SPLIT_CLASS_MESSAGE,
|
||||||
},
|
},
|
||||||
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
||||||
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ export function LinkButton({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a className={clsx(buttonClass({ variant, size, fullWidth }), className)} {...rest}>
|
||||||
className={clsx(buttonClass({ variant, size, fullWidth, element: "link" }), className)}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{icon}
|
{icon}
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
@@ -63,7 +60,7 @@ export function IconLink({
|
|||||||
<a
|
<a
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
className={clsx(iconButtonClass({ variant, size, element: "link" }), className)}
|
className={clsx(iconButtonClass({ variant, size }), className)}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ import clsx from "clsx";
|
|||||||
|
|
||||||
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
|
// The paint of the two button primitives (docs/DESIGN.md A1, A2, B1, B3), apart from the components so
|
||||||
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
|
// a LINK can wear exactly the same face (`LinkButton`): a download or a sign-in that navigates is an
|
||||||
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. The hover guard is
|
// <a>, and a hand-painted look-alike beside the real buttons is how the two drifted. Every hover carries
|
||||||
// per element: `enabled:` never matches an <a>, so a link's hovers drop it (it has no disabled state).
|
// the `act:` variant (index.css), which stands down on a disabled or busy control and fits a link too.
|
||||||
const ACT_BUTTON = "enabled:not-aria-disabled:";
|
|
||||||
const ACT_LINK = "not-aria-disabled:";
|
|
||||||
|
|
||||||
export type ButtonVariant =
|
export type ButtonVariant =
|
||||||
| "primary" // solid accent — the main affirmative action
|
| "primary" // solid accent — the main affirmative action
|
||||||
@@ -33,25 +31,27 @@ export type ButtonSize =
|
|||||||
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
|
// B3: every hover and press is a LAYER (the `state-*` utilities in index.css), never an opacity dim or
|
||||||
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
|
// a `bg-card` fill, which vanish on a light card. The utilities already stand down on a disabled, busy
|
||||||
// or pressed button; the text-colour hovers here carry the same guard as a variant.
|
// or pressed button; the text-colour hovers here carry the same guard as a variant.
|
||||||
const buttonVariants = (ACT: string): Record<ButtonVariant, string> => ({
|
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
||||||
primary: "font-medium state-filled",
|
primary: "font-medium state-filled",
|
||||||
danger: "font-medium state-danger",
|
danger: "font-medium state-danger",
|
||||||
warn: "font-medium state-warn",
|
warn: "font-medium state-warn",
|
||||||
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
outline: "font-medium border border-border text-muted state-layer act:hover:text-fg",
|
||||||
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
|
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
|
||||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||||
// is whether the button should advertise the destruction before the pointer arrives.
|
// is whether the button should advertise the destruction before the pointer arrives.
|
||||||
"outline-danger": `font-medium border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
"outline-danger":
|
||||||
|
"font-medium border border-border text-muted state-layer act:hover:text-danger act:hover:border-danger/40",
|
||||||
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
|
"outline-warn": "font-medium border border-warn/40 text-warn state-layer",
|
||||||
ghost: `font-medium text-muted state-layer ${ACT}hover:text-fg`,
|
ghost: "font-medium text-muted state-layer act:hover:text-fg",
|
||||||
glass: `font-medium glass-card glass-hover ${ACT}hover:text-accent`,
|
glass: "font-medium glass-card glass-hover act:hover:text-accent",
|
||||||
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
// The player chrome is dark in the light theme too: a theme-fg state layer would be a dark wash.
|
||||||
overlay: `font-medium text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
overlay: "font-medium text-white/80 act:hover:text-white act:hover:bg-white/15",
|
||||||
media: `font-medium bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
media: "font-medium bg-black/60 text-white act:hover:bg-black/75",
|
||||||
"danger-soft": `font-medium border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
"danger-soft":
|
||||||
link: `font-semibold text-accent ${ACT}hover:underline`,
|
"font-medium border border-danger/60 text-danger state-layer act:hover:border-danger",
|
||||||
"link-muted": `font-medium text-muted ${ACT}hover:text-fg`,
|
link: "font-semibold text-accent act:hover:underline",
|
||||||
});
|
"link-muted": "font-medium text-muted act:hover:text-fg",
|
||||||
|
};
|
||||||
|
|
||||||
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
|
// A pressed toggle (`pressed`) is the solid accent in every variant (B3: "selected stays the solid
|
||||||
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
|
// accent"); the layer utilities leave it alone, so it stays solid under the pointer too.
|
||||||
@@ -98,22 +98,23 @@ export type IconButtonSize =
|
|||||||
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
||||||
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
||||||
|
|
||||||
const iconVariants = (ACT: string): Record<IconButtonVariant, string> => ({
|
const ICON_VARIANTS: Record<IconButtonVariant, string> = {
|
||||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
ghost: "text-muted state-layer act:hover:text-fg",
|
||||||
"ghost-accent": "text-accent state-layer",
|
"ghost-accent": "text-accent state-layer",
|
||||||
primary: "state-filled",
|
primary: "state-filled",
|
||||||
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
outline: "border border-border text-muted state-layer act:hover:text-fg",
|
||||||
"outline-accent": "border border-accent/40 text-accent state-layer",
|
"outline-accent": "border border-accent/40 text-accent state-layer",
|
||||||
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
"outline-danger":
|
||||||
|
"border border-border text-muted state-layer act:hover:text-danger act:hover:border-danger/40",
|
||||||
"outline-warn": "border border-warn/40 text-warn state-layer",
|
"outline-warn": "border border-warn/40 text-warn state-layer",
|
||||||
danger: `text-muted state-layer ${ACT}hover:text-danger`,
|
danger: "text-muted state-layer act:hover:text-danger",
|
||||||
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
"danger-soft": "border border-danger/60 text-danger state-layer act:hover:border-danger",
|
||||||
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
|
glass: "glass-card glass-hover act:hover:text-accent",
|
||||||
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
||||||
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
||||||
media: `bg-black/60 text-white ${ACT}hover:bg-black/75`,
|
media: "bg-black/60 text-white act:hover:bg-black/75",
|
||||||
overlay: `text-white/80 ${ACT}hover:text-white ${ACT}hover:bg-white/15`,
|
overlay: "text-white/80 act:hover:text-white act:hover:bg-white/15",
|
||||||
});
|
};
|
||||||
|
|
||||||
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
// An icon toggle that is on shows it in the glyph (watched, saved, a selected track), not as a solid
|
||||||
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
|
// fill: a solid accent square in a row of quiet glyphs outshouts the row's primary action.
|
||||||
@@ -126,9 +127,6 @@ const ICON_SIZES: Record<IconButtonSize, string> = {
|
|||||||
player: "size-9 [&>svg]:size-5",
|
player: "size-9 [&>svg]:size-5",
|
||||||
};
|
};
|
||||||
|
|
||||||
const BUTTON_VARIANTS = { button: buttonVariants(ACT_BUTTON), link: buttonVariants(ACT_LINK) };
|
|
||||||
const ICON_VARIANTS = { button: iconVariants(ACT_BUTTON), link: iconVariants(ACT_LINK) };
|
|
||||||
|
|
||||||
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
export const isLinkVariant = (v: ButtonVariant) => v === "link" || v === "link-muted";
|
||||||
|
|
||||||
export function buttonClass({
|
export function buttonClass({
|
||||||
@@ -137,18 +135,16 @@ export function buttonClass({
|
|||||||
round = false,
|
round = false,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
pressed,
|
pressed,
|
||||||
element = "button",
|
|
||||||
}: {
|
}: {
|
||||||
variant: ButtonVariant;
|
variant: ButtonVariant;
|
||||||
size: ButtonSize;
|
size: ButtonSize;
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
element?: "button" | "link";
|
|
||||||
}) {
|
}) {
|
||||||
return clsx(
|
return clsx(
|
||||||
BASE,
|
BASE,
|
||||||
BUTTON_VARIANTS[element][variant],
|
BUTTON_VARIANTS[variant],
|
||||||
pressed !== undefined && PRESSED,
|
pressed !== undefined && PRESSED,
|
||||||
SIZES[size],
|
SIZES[size],
|
||||||
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
size !== "none" && (round || variant === "media" ? "rounded-full" : "rounded-lg"),
|
||||||
@@ -166,17 +162,15 @@ export function iconButtonClass({
|
|||||||
size,
|
size,
|
||||||
round = false,
|
round = false,
|
||||||
pressed,
|
pressed,
|
||||||
element = "button",
|
|
||||||
}: {
|
}: {
|
||||||
variant: IconButtonVariant;
|
variant: IconButtonVariant;
|
||||||
size: IconButtonSize;
|
size: IconButtonSize;
|
||||||
round?: boolean;
|
round?: boolean;
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
element?: "button" | "link";
|
|
||||||
}) {
|
}) {
|
||||||
return clsx(
|
return clsx(
|
||||||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||||
ICON_VARIANTS[element][variant],
|
ICON_VARIANTS[variant],
|
||||||
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
round || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||||
pressed !== undefined && ICON_PRESSED,
|
pressed !== undefined && ICON_PRESSED,
|
||||||
ICON_SIZES[size],
|
ICON_SIZES[size],
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ export type ChipSize =
|
|||||||
| "sm" // 24, text-xs — a filter row
|
| "sm" // 24, text-xs — a filter row
|
||||||
| "xs"; // 24, text-2xs, tight — a tag in a table row
|
| "xs"; // 24, text-2xs, tight — a tag in a table row
|
||||||
|
|
||||||
const ACT = "enabled:not-aria-disabled:";
|
|
||||||
const PAINT: Record<ChipVariant, string> = {
|
const PAINT: Record<ChipVariant, string> = {
|
||||||
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
|
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
|
||||||
// fill, so the open-popover state is the only one spelled out.
|
// fill, so the open-popover state is the only one spelled out.
|
||||||
filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
|
filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
|
||||||
token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`,
|
token: "chip act:hover:border-accent act:hover:bg-accent/20 aria-expanded:border-accent",
|
||||||
// B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
|
// B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
|
||||||
tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`,
|
tag: "chip-solid font-medium act:hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]",
|
||||||
suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`,
|
suggest:
|
||||||
ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`,
|
"border border-dashed border-accent/50 text-accent/90 act:hover:text-accent act:hover:border-accent",
|
||||||
|
ghost:
|
||||||
|
"border border-dashed border-border text-muted act:hover:text-accent act:hover:border-accent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const SIZES: Record<ChipSize, string> = {
|
const SIZES: Record<ChipSize, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
buttonClass,
|
||||||
|
type IconButtonSize,
|
||||||
|
type IconButtonVariant,
|
||||||
|
iconButtonClass,
|
||||||
|
} from "./buttonStyles";
|
||||||
|
import { type ChipSize, type ChipVariant, chipClass } from "./chipStyles";
|
||||||
|
|
||||||
|
// Tailwind generates CSS only for the class names it FINDS in the source, token by token. A class
|
||||||
|
// assembled at runtime (`${ACT}hover:text-fg`) is never found whole, so it ships with no rule at all:
|
||||||
|
// every hover colour of Button, IconButton and Chip was dead that way, found in the E28 audit by
|
||||||
|
// reading the stylesheet (0 rules for `not-aria-disabled`). So: every class a primitive can emit has
|
||||||
|
// to stand in its source file whole, between the delimiters the scanner splits on.
|
||||||
|
const tokens = (file: string) =>
|
||||||
|
new Set(readFileSync(new URL(file, import.meta.url), "utf8").split(/[\s"'`{}$]+/));
|
||||||
|
|
||||||
|
// Records, so a new variant or size that is not listed here fails to compile.
|
||||||
|
const keys = <K extends string>(r: Record<K, true>) => Object.keys(r) as K[];
|
||||||
|
const BV = keys<ButtonVariant>({
|
||||||
|
primary: true, danger: true, warn: true, outline: true, "outline-accent": true,
|
||||||
|
"outline-danger": true, "outline-warn": true, ghost: true, glass: true, overlay: true,
|
||||||
|
media: true, "danger-soft": true, link: true, "link-muted": true,
|
||||||
|
}); // prettier-ignore
|
||||||
|
const BS = keys<ButtonSize>({ xs: true, sm: true, md: true, lg: true, none: true });
|
||||||
|
const IV = keys<IconButtonVariant>({
|
||||||
|
ghost: true, "ghost-accent": true, primary: true, outline: true, "outline-accent": true,
|
||||||
|
"outline-danger": true, "outline-warn": true, danger: true, "danger-soft": true, glass: true,
|
||||||
|
media: true, overlay: true,
|
||||||
|
}); // prettier-ignore
|
||||||
|
const IS = keys<IconButtonSize>({ sm: true, md: true, lg: true, player: true });
|
||||||
|
const CV = keys<ChipVariant>({ filter: true, token: true, tag: true, suggest: true, ghost: true });
|
||||||
|
const CS = keys<ChipSize>({ md: true, sm: true, xs: true });
|
||||||
|
|
||||||
|
const missing = (emitted: string[], file: string) => {
|
||||||
|
const found = tokens(file);
|
||||||
|
return [...new Set(emitted.flatMap((c) => c.split(/\s+/)).filter(Boolean))].filter(
|
||||||
|
(c) => !found.has(c),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("every class a primitive emits is visible to the Tailwind scanner", () => {
|
||||||
|
it("Button and IconButton", () => {
|
||||||
|
const emitted: string[] = [];
|
||||||
|
for (const pressed of [undefined, true]) {
|
||||||
|
for (const variant of BV)
|
||||||
|
for (const size of BS) emitted.push(buttonClass({ variant, size, pressed, round: true }));
|
||||||
|
for (const variant of IV)
|
||||||
|
for (const size of IS) emitted.push(iconButtonClass({ variant, size, pressed }));
|
||||||
|
}
|
||||||
|
expect(missing(emitted, "./buttonStyles.ts")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Chip", () => {
|
||||||
|
const emitted = CV.flatMap((v) => CS.map((s) => chipClass(v, s)));
|
||||||
|
expect(missing(emitted, "./chipStyles.ts")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,13 @@
|
|||||||
/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
|
/* ===== E28 tokens as utilities (docs/DESIGN.md) — the ONE source the ui/ primitives and the design
|
||||||
lab both read, so a decided value is never restated as an arbitrary class. ===== */
|
lab both read, so a decided value is never restated as an arbitrary class. ===== */
|
||||||
|
|
||||||
|
/* B3: `act:` — a hover or press that answers only a control that can act: not a disabled one, not a busy
|
||||||
|
one (FocusSafeButton's aria-disabled). `:disabled` never matches an <a>, so one variant serves a
|
||||||
|
button and a link alike. A literal variant, never a prefix glued on at runtime: Tailwind emits CSS
|
||||||
|
only for a class it finds WHOLE in the source, and the `${ACT}hover:…` the primitives used to build
|
||||||
|
shipped with no rule at all (E28 audit; ui/tailwindScan.contract.test.ts). */
|
||||||
|
@custom-variant act (&:not(:disabled, [aria-disabled="true"]));
|
||||||
|
|
||||||
/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act:
|
/* B3: a transparent control's hover / pressed layer. The layer answers only a control that can act:
|
||||||
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose
|
not a disabled one, not a busy one (FocusSafeButton's aria-disabled), and not a pressed toggle, whose
|
||||||
solid accent is its state and must not turn grey under the pointer. */
|
solid accent is its state and must not turn grey under the pointer. */
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("a native select", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// `enabled:hover:` is blind to a focused FocusSafeButton (aria-disabled, not the native attribute),
|
// `enabled:hover:` is blind to a focused FocusSafeButton (aria-disabled, not the native attribute; `act:` is not),
|
||||||
// which then lit up on hover as if a click would work.
|
// which then lit up on hover as if a click would work.
|
||||||
describe("a hover gated on `enabled:` alone", () => {
|
describe("a hover gated on `enabled:` alone", () => {
|
||||||
it("is refused; the aria-aware gate is not", async () => {
|
it("is refused; the aria-aware gate is not", async () => {
|
||||||
@@ -33,6 +33,28 @@ describe("a hover gated on `enabled:` alone", () => {
|
|||||||
)
|
)
|
||||||
)[0]?.messages.filter((m) => m.message.startsWith("`enabled:hover:`")).length;
|
)[0]?.messages.filter((m) => m.message.startsWith("`enabled:hover:`")).length;
|
||||||
expect(await lint("enabled:hover")).toBe(1);
|
expect(await lint("enabled:hover")).toBe(1);
|
||||||
expect(await lint("enabled:not-aria-disabled:hover")).toBe(0);
|
expect(await lint("act:hover")).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// A variant glued onto an interpolated prefix is never seen whole by Tailwind's scanner, so it ships
|
||||||
|
// with no CSS (every button and chip hover colour died that way). A plain interpolated value is fine.
|
||||||
|
describe("a class split by an interpolation", () => {
|
||||||
|
const split = async (src: string) =>
|
||||||
|
(await eslint.lintText(src, { filePath: "src/components/__probe.tsx" }))[0]?.messages.filter(
|
||||||
|
(m) => m.message.startsWith("a class split by an interpolation"),
|
||||||
|
).length;
|
||||||
|
const act = "enabled:"; // assembled, as the offender was
|
||||||
|
it("is refused after an expression", async () => {
|
||||||
|
expect(await split("const A = 'x';\nexport const b = `${A}" + "hover:text-fg`;\n")).toBe(1);
|
||||||
|
expect(await split("const A = 'x';\nexport const c = `p-1 ${A}" + act + "hover:x`;\n")).toBe(1);
|
||||||
|
});
|
||||||
|
it("leaves a whole class, a leading variant and a plain value alone", async () => {
|
||||||
|
expect(await split("const n = 1;\nexport const d = `hover:text-fg w-${n} ${n}px`;\n")).toBe(0);
|
||||||
|
expect(
|
||||||
|
await split(
|
||||||
|
"const u = 'h';\nexport const e = `${u}://x:${n()}`;\nfunction n() { return 1; }\n",
|
||||||
|
),
|
||||||
|
).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user