From 974df83bd1ddd38f18fec9537da803465c5c791a Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 16 Aug 2026 14:33:27 +0700 Subject: [PATCH 1/2] feat(material): let every surface wear glass, not only Card Glass was a real material with one consumer. `styles/glass.ts` derives twenty-five `--glass-*` tokens from three numbers, and the only component that could read them was Card, so an app whose panels are dialogs, drawers, popovers or a navbar could set the tokens and see nothing change. `material` moves into the shared vocabulary and onto Dialog.Content, Drawer.Content, Popover.Content, Menu and Navbar. `solid` is the default on all of them, so this adds a capability without changing a single existing render. The rules live in `_shared/material.css`, keyed on `[data-material="glass"]` rather than a per-component class. Card had them inline, and copying that block five more times would be six places to update the next time the glass vocabulary moves - which it already did once, from thirty-one hand-declared properties to three numbers. Card now carries the attribute too and keeps its own classes, so its appearance is unchanged while the definition is shared. Three behaviours come along because they belong to the material rather than to any one component: nested glass is flattened to a single pane, since blurring already-blurred output is muddy rather than deeper and costs a second pass; and both `prefers-reduced-transparency` and a browser without `backdrop-filter` fall back to an opaque fill, because a tint with no blur behind it is a surface you cannot read text on. Every `--glass-*` read carries a fallback. An undefined custom property makes CSS drop the whole declaration rather than fall back to an initial value, which is how a partial token set used to produce a surface with no background at all. `CardMaterial` stays as a deprecated alias of `Material`. It is exported, it has always had exactly these two members, and removing it would break call sites for no gain. --- docs/api-contract.md | 8 +- docs/ui-usage.md | 38 ++++++- package.json | 2 +- src/components/_shared/material.css | 115 ++++++++++++++++++++++ src/components/card/Card.layout.tsx | 13 ++- src/components/dialog/Dialog.layout.tsx | 15 ++- src/components/drawer/Drawer.layout.tsx | 9 +- src/components/menu/Menu.layout.tsx | 7 +- src/components/navbar/Navbar.layout.tsx | 7 +- src/components/popover/Popover.layout.tsx | 7 +- src/components/vocabulary.ts | 19 ++++ src/index.ts | 1 + 12 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 src/components/_shared/material.css diff --git a/docs/api-contract.md b/docs/api-contract.md index 49a165f4..4a6af507 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -270,7 +270,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap. ### DialogContent -`backdrop children isDismissable placement scrollBehavior shouldCloseOnBackdropClick size` +`backdrop children isDismissable material placement scrollBehavior shouldCloseOnBackdropClick size` ### DialogFooter @@ -318,7 +318,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap. ### DrawerContent -`children placement scrollBehavior` +`children material placement scrollBehavior` ### DrawerDialog @@ -494,7 +494,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap. ### Menu -`children defaultSelectedKeys disabled disabledKeys disallowEmptySelection items onAction onSelectionChange renderEmpty selectedKeys selectionMode state` +`children defaultSelectedKeys disabled disabledKeys disallowEmptySelection items material onAction onSelectionChange renderEmpty selectedKeys selectionMode state` ### MenuItem @@ -534,7 +534,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap. ### Navbar -`as dataTheme` +`as dataTheme material` ### NoiseBackground diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 2bd6e860..71295963 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -31,10 +31,44 @@ Layout components require the application compiler before the normal Solid trans ## Theming - Two themes: `light` (default when no attribute) and `dark`. Switch: `document.documentElement.setAttribute("data-theme", "dark")`. -- Tokens are CSS vars: `--color-primary(/-content)`, `--color-secondary`, `--color-accent`, `--color-neutral`, `--color-info/success/warning/error`, `--color-danger`, surfaces `--color-base-100/200/300`, `--color-base-content`, HeroUI-style `--color-default(/-foreground/-hover)`, `--color-background/foreground`, daisy short aliases `--b1/--b2/--b3/--bc`, radii `--radius-selector/-field/-box`, and a ~35-var `--glass-*` set (runtime-tweakable on `documentElement.style`). +- Tokens are CSS vars: `--color-primary(/-content)`, `--color-secondary`, `--color-accent`, `--color-neutral`, `--color-info/success/warning/error`, `--color-danger`, surfaces `--color-base-100/200/300`, `--color-base-content`, HeroUI-style `--color-default(/-foreground/-hover)`, `--color-background/foreground`, daisy short aliases `--b1/--b2/--b3/--bc`, radii `--radius-selector/-field/-box`, and the `--glass-*` set: six colours the theme owns, plus the rest derived from three numbers by `applyGlassTokens` (see [Glass](#glass)). - `src/index.css` has a Tailwind v4 `@theme` block, so in a Tailwind v4 app `bg-primary`, `text-base-content`, `bg-base-100` etc. work. - Any component accepts `dataTheme` prop → rendered as `data-theme` attr (scoped theming). +### Glass + +`material="glass"` makes a surface out of what is behind it rather than a fill. +It is on `Card`, `Dialog.Content`, `Drawer.Content`, `Popover.Content`, `Menu` +and `Navbar`; `solid` is the default everywhere, so nothing changes until it is +asked for. + +The look is driven by the `--glass-*` custom properties, and you do not set +those by hand — `applyGlassTokens` derives all twenty-five from three numbers: + +```ts +import { applyGlassTokens, GLASS_DEFAULTS, GLASS_LIMITS } from "@pathscale/ui"; + +applyGlassTokens({ blur: 9, refraction: 0.31, depth: 24 }, "dark"); +``` + +- `blur` (0–50px) how far the backdrop is smeared +- `refraction` (0–0.4) how much the surface asserts its own tint, border and highlight +- `depth` (0–30) how far off the page it sits: glow, sheen, shadow + +`GLASS_LIMITS` carries those ranges and `GLASS_DEFAULTS` the per-mode defaults, +so a settings panel should read both from here rather than restating them. +`resolveGlassTokens` returns the same set as an object, and `glassTokensToCss` +as a declaration block for a theme that wants them baked in. + +Pass a **complete** tuning. Three of the derived tokens are read without a CSS +fallback, and an undefined custom property makes CSS drop the whole declaration +rather than fall back — so a partial set renders a surface with no background at +all rather than a plainer one. + +Nested glass is flattened to one pane on purpose, and both +`prefers-reduced-transparency` and a browser without `backdrop-filter` fall back +to a more opaque fill. + ## Component conventions (consumer-facing) - Booleans are HeroUI-style `is*`: `isDisabled`, `isOpen`, `isInvalid`, `isPending`, `isIconOnly`, `isHoverable`, `isPressable`. Native `disabled` also honored. @@ -92,7 +126,7 @@ the shared fallback. This works with PathScale Fonts and application-owned font ## Component inventory (by family) -- **Layout/primitives**: Flex, Grid, Join, Surface, Card, GlassPanel, Separator, ScrollShadow, Skeleton, EmptyState, Footer, Header, Navbar, Toolbar, FloatingDock +- **Layout/primitives**: Flex, Grid, Join, Card, Separator, ScrollArea, Skeleton, Empty, Footer, Header, Navbar, Toolbar, Dock - **Typography/misc**: Text, Link, Kbd, Badge, Chip, Tag/TagGroup, Avatar, Icon, Tooltip, Breadcrumbs, Pagination, Meter, ProgressBar, ProgressCircle, Spinner (alias: Loading) - **Inputs**: Input, InputGroup, InputOTP, TextField, TextArea (and textarea), NumberField, SearchField, PasswordField (+ password-requirements/rules, `passwordRules.ts`), ColorField, Checkbox(+Group), Radio(+Group), Toggle, Slider, Select, ComboBox, ListBox, SizePicker, Form pieces (Label, Description, ErrorMessage, FieldError, Fieldset) - **Dates**: Calendar, RangeCalendar, DatePicker, DateRangePicker (internal date engine); DateField, TimeField (separate segmented editors) diff --git a/package.json b/package.json index 5d0fbb2e..9461f423 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pathscale/ui", - "version": "2.5.0", + "version": "2.6.0", "author": "pathscale", "repository": { "type": "git", diff --git a/src/components/_shared/material.css b/src/components/_shared/material.css new file mode 100644 index 00000000..a970217d --- /dev/null +++ b/src/components/_shared/material.css @@ -0,0 +1,115 @@ +/* + * `material="glass"`, for every surface that can hold content. + * + * One definition rather than one per component. Card had these rules inline, + * and copying them to Dialog, Drawer, Popover, Menu, Dock and Navbar would be + * six places to update when the glass vocabulary moves — which it did once + * already, from thirty-one hand-declared properties to three numbers and + * twenty-five derived ones. + * + * A component opts in by importing this file and adding the `material` axis to + * its recipe. The classes are keyed on a data attribute rather than a + * `--material-glass` class so the rules are genuinely shared: a + * per-component class name would need this block duplicated for each one, which + * is the thing being avoided. + * + * Every `--glass-*` read carries a fallback, and that is load-bearing rather + * than tidy. An undefined custom property does not fall back to its initial + * value: CSS drops the whole declaration. Three of these used to be read bare, + * so a theme that defined twenty-eight of the thirty-one glass tokens got a + * surface with no background at all rather than a plainer one. The two shipped + * themes both use `white` for the three colours, so that is what the fallbacks + * say. `src/styles/glass.ts` derives the rest from `blur`, `refraction` and + * `depth`. + */ + +@layer components { + [data-material="solid"] { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + [data-material="glass"] { + background-color: color-mix( + in oklab, + var(--glass-background-color, white), + transparent calc(100% - var(--glass-background-opacity, 55%)) + ); + border-color: color-mix( + in oklab, + var(--glass-border-color, white), + transparent calc(100% - var(--glass-border-opacity, 12%)) + ); + backdrop-filter: blur(var(--glass-blur, 50px)) saturate(var(--glass-saturation, 1.2)) + brightness(var(--glass-brightness, 1)); + -webkit-backdrop-filter: blur(var(--glass-blur, 50px)) saturate(var(--glass-saturation, 1.2)) + brightness(var(--glass-brightness, 1)); + } + + /* + * The inner highlight: the lit top edge that makes a pane read as a pane + * rather than as a translucent rectangle. + * + * On a pseudo-element so it cannot be overwritten by a component's own + * `box-shadow`, which is how elevation is expressed. `inherit` on the radius + * keeps it aligned to whatever corner the component chose. + */ + [data-material="glass"]::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + box-shadow: inset 0 var(--border, 1px) 0 0 + color-mix( + in oklab, + var(--glass-highlight-color, white), + transparent calc(100% - var(--glass-highlight-opacity, 18%)) + ); + } + + /* + * Glass inside glass is one pane, not two. + * + * A backdrop filter reads the pixels behind it, so nesting one inside another + * blurs already-blurred output. That is muddy rather than deeper, and it + * costs a second render pass to get there. + */ + [data-material="glass"] [data-material="glass"] { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + /* + * No backdrop support, or a reader who asked for less transparency. + * + * Falls back to a more opaque fill rather than to a thin tint over whatever + * happens to be behind it, because the tint alone is unreadable. The + * highlight goes too: it reads as a stray line without the material. + */ + @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { + [data-material="glass"] { + background-color: color-mix( + in oklab, + var(--glass-background-color, white), + transparent calc(100% - var(--glass-fallback-background-opacity, 78%)) + ); + } + } + + @media (prefers-reduced-transparency: reduce) { + [data-material="glass"] { + background-color: color-mix( + in oklab, + var(--glass-background-color, white), + transparent calc(100% - var(--glass-fallback-background-opacity, 78%)) + ); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + [data-material="glass"]::before { + box-shadow: none; + } + } +} diff --git a/src/components/card/Card.layout.tsx b/src/components/card/Card.layout.tsx index 2bc1cdca..4257849b 100644 --- a/src/components/card/Card.layout.tsx +++ b/src/components/card/Card.layout.tsx @@ -1,19 +1,25 @@ +import "../_shared/material.css"; import "./Card.css"; import { Show, type JSX } from "solid-js"; -import type { Flavor, Radius, Space, State, UIBaseProps, Variant } from "../vocabulary"; +import type { Flavor, Material, Radius, Space, State, UIBaseProps, Variant } from "../vocabulary"; import type { Layout } from "../../lib/layouts"; import { card, cardBody, cardFooter, cardHeader } from "./Card.recipe"; /* ------------------------------------------------------------------------------------------------- * Types * -----------------------------------------------------------------------------------------------*/ -export type CardMaterial = "solid" | "glass"; +/** + * @deprecated Use `Material` from the shared vocabulary. Kept as an alias + * because it is exported publicly and removing it would break call sites; it + * has always had exactly these two members. + */ +export type CardMaterial = Material; export type CardElevation = "none" | "sm" | "md" | "lg"; export type CardProps = Omit, "children"> & UIBaseProps & { variant?: Variant; - material?: CardMaterial; + material?: Material; elevation?: CardElevation; flavor?: Flavor; state?: State; @@ -66,6 +72,7 @@ export const CardLayout: Layout = () => { tabIndex={local.tabIndex ?? (local.isInteractive ? 0 : undefined)} onKeyDown={handleKeyDown} data-flavor={local.flavor ?? "neutral"} + data-material={local.material ?? "solid"} > {local.header} diff --git a/src/components/dialog/Dialog.layout.tsx b/src/components/dialog/Dialog.layout.tsx index 99f6d8bf..f16e6c4d 100644 --- a/src/components/dialog/Dialog.layout.tsx +++ b/src/components/dialog/Dialog.layout.tsx @@ -15,7 +15,8 @@ import { import { Portal } from "solid-js/web"; import { twMerge } from "tailwind-merge"; -import type { UIBaseProps } from "../vocabulary"; +import "../_shared/material.css"; +import type { Material, UIBaseProps } from "../vocabulary"; import { CLASSES } from "./Dialog.recipe"; import type { Layout } from "../../lib/layouts"; import { componentRecipe } from "./Dialog.recipe"; @@ -189,6 +190,14 @@ export type DialogContentProps = Omit, "child scrollBehavior?: DialogScrollBehavior; isDismissable?: boolean; shouldCloseOnBackdropClick?: boolean; + /** + * What the panel is made of. `solid` by default. + * + * `glass` pairs naturally with `backdrop="transparent"`: the point of a + * glass dialog is seeing the page through it, and an opaque backdrop is + * exactly the thing that would hide it. + */ + material?: Material; }; export type DialogHeaderProps = Omit, "children"> & @@ -242,7 +251,7 @@ const DialogRoot: Layout = () => { const [internalOpen, setInternalOpen] = createSignal(Boolean(local.defaultOpen)); const [animState, setAnimState] = createSignal( - Boolean(local.open ?? local.defaultOpen) ? "open" : "closed", + local.open ?? local.defaultOpen ? "open" : "closed", ); const [contentRef, setContentRef] = createSignal(undefined); const [labelledBy, setLabelledBy] = createSignal(undefined); @@ -510,6 +519,7 @@ const DialogContent: Layout = () => "scrollBehavior", "isDismissable", "shouldCloseOnBackdropClick", + "material", "role", "tabIndex", "aria-labelledby", @@ -574,6 +584,7 @@ const DialogContent: Layout = () => local.class, ) }} data-slot="dialog-content" + data-material={local.material ?? "solid"} data-placement={placement()} data-size={size()} data-scroll={scrollBehavior()} diff --git a/src/components/drawer/Drawer.layout.tsx b/src/components/drawer/Drawer.layout.tsx index f174e55f..284918da 100644 --- a/src/components/drawer/Drawer.layout.tsx +++ b/src/components/drawer/Drawer.layout.tsx @@ -12,7 +12,8 @@ import { } from "solid-js"; import { Portal } from "solid-js/web"; import { twMerge } from "tailwind-merge"; -import type { UIBaseProps } from "../vocabulary"; +import "../_shared/material.css"; +import type { Material, UIBaseProps } from "../vocabulary"; import { focusFirst, isSidePlacement, @@ -108,6 +109,8 @@ export type DrawerContentProps = Omit, "child /** @deprecated Configure placement at Drawer.Root `placement` */ placement?: DrawerPlacement; scrollBehavior?: DrawerScrollBehavior; + /** What the panel is made of. `solid` by default. */ + material?: Material; }; export type DrawerDialogSide = "left" | "right"; @@ -189,7 +192,7 @@ const DrawerRoot: Layout = () => { const [internalOpen, setInternalOpen] = createSignal(Boolean(local.defaultOpen)); const [animState, setAnimState] = createSignal( - Boolean(local.open ?? local.defaultOpen) ? "open" : "closed", + local.open ?? local.defaultOpen ? "open" : "closed", ); const [dialogRef, setDialogRef] = createSignal(); @@ -410,6 +413,7 @@ const DrawerContent: Layout = () => "style", "placement", "scrollBehavior", + "material", ]); const ctx = useDrawerContext(); @@ -434,6 +438,7 @@ const DrawerContent: Layout = () => local.class, )} data-slot="drawer-content" + data-material={local.material ?? "solid"} data-placement={placement()} data-scroll={scrollBehavior()} data-entering={ctx.animState() === "entering" ? "true" : undefined} diff --git a/src/components/menu/Menu.layout.tsx b/src/components/menu/Menu.layout.tsx index 86690aa6..aa422670 100644 --- a/src/components/menu/Menu.layout.tsx +++ b/src/components/menu/Menu.layout.tsx @@ -9,7 +9,8 @@ import { } from "solid-js"; import { twMerge } from "tailwind-merge"; -import type { UIBaseProps, State } from "../vocabulary"; +import "../_shared/material.css"; +import type { Material, State, UIBaseProps } from "../vocabulary"; import { CLASSES } from "./Menu.recipe"; import { MenuContext, @@ -67,6 +68,8 @@ export type MenuRootProps = Omit< onAction?: (key: string) => void; state?: State; disabled?: boolean; + /** What the surface is made of. `solid` by default. */ + material?: Material; }; const MenuRoot: Layout = () => { @@ -84,6 +87,7 @@ const MenuRoot: Layout = () => { "disallowEmptySelection", "onSelectionChange", "onAction", + "material", "state", "disabled", "role", @@ -300,6 +304,7 @@ const MenuRoot: Layout = () => { role={local.role ?? "menu"} aria-disabled={isDisabled() ? "true" : undefined} data-slot="menu" + data-material={local.material ?? "solid"} data-theme={local.dataTheme} data-selection-mode={selectionMode()} data-disabled={isDisabled() ? "true" : "false"} diff --git a/src/components/navbar/Navbar.layout.tsx b/src/components/navbar/Navbar.layout.tsx index 81b4d5d7..6ed17d43 100644 --- a/src/components/navbar/Navbar.layout.tsx +++ b/src/components/navbar/Navbar.layout.tsx @@ -5,7 +5,8 @@ import { twMerge } from "tailwind-merge"; import NavbarSection from "./NavbarSection.generated"; import NavbarStack from "./NavbarStack.generated"; import NavbarRow from "./NavbarRow.generated"; -import type { UIBaseProps } from "../vocabulary"; +import "../_shared/material.css"; +import type { Material, UIBaseProps } from "../vocabulary"; import { CLASSES } from "./Navbar.recipe"; import type { Layout } from "../../lib/layouts"; import { componentRecipe } from "./Navbar.recipe"; @@ -14,6 +15,8 @@ export type NavbarProps = JSX.HTMLAttributes & UIBaseProps & { as?: keyof JSX.IntrinsicElements; dataTheme?: string; + /** What the bar is made of. `solid` by default. */ + material?: Material; }; const Navbar: Layout = () => { @@ -23,6 +26,7 @@ const Navbar: Layout = () => { "style", "children", "dataTheme", + "material", ]); const Tag = (local.as || "div") as keyof JSX.IntrinsicElements; @@ -35,6 +39,7 @@ const Navbar: Layout = () => { aria-label="Navbar" {...others} data-theme={local.dataTheme} + data-material={local.material ?? "solid"} {...{ class: classes() }} style={local.style} > diff --git a/src/components/popover/Popover.layout.tsx b/src/components/popover/Popover.layout.tsx index c59332ed..bdfd6fe2 100644 --- a/src/components/popover/Popover.layout.tsx +++ b/src/components/popover/Popover.layout.tsx @@ -16,7 +16,8 @@ import { import { Portal } from "solid-js/web"; import { twMerge } from "tailwind-merge"; -import type { UIBaseProps } from "../vocabulary"; +import "../_shared/material.css"; +import type { Material, UIBaseProps } from "../vocabulary"; import { createOverlayPosition, type OverlayAnchorRect, @@ -240,6 +241,8 @@ export type PopoverContentProps = UIBaseProps & Omit, "children"> & { children: JSX.Element; sideOffset?: number; + /** What the panel is made of. `solid` by default. */ + material?: Material; }; const PopoverContent: Layout = () => { @@ -249,6 +252,7 @@ const PopoverContent: Layout = () = "dataTheme", "style", "sideOffset", + "material", ]); const ctx = usePopoverContext(); @@ -297,6 +301,7 @@ const PopoverContent: Layout = () = role="dialog" {...{ class: twMerge(CLASSES.base, local.class) }} data-slot="popover-content" + data-material={local.material ?? "solid"} data-open={ctx.isOpen() ? "true" : "false"} data-placement={ctx.placement()} data-theme={local.dataTheme} diff --git a/src/components/vocabulary.ts b/src/components/vocabulary.ts index 2111345d..d1917386 100644 --- a/src/components/vocabulary.ts +++ b/src/components/vocabulary.ts @@ -87,6 +87,25 @@ export type State = "default" | "loading" | "error" | "invalid" | "disabled" | " /** How much emphasis, and what shape. */ export type Variant = "solid" | "soft" | "outline" | "ghost" | "plain"; +/** + * What a surface is made of. + * + * Orthogonal to `variant`, which is how much emphasis it carries. A glass card + * and a solid card can both be `outline`; the difference is whether the fill is + * a colour or a blurred view of whatever is behind it. + * + * `glass` reads the `--glass-*` family, which `src/styles/glass.ts` derives + * from three numbers. Every read carries a fallback, so a theme that sets none + * of them still renders — an undefined custom property makes CSS drop the whole + * declaration rather than fall back, which is how a partial set produced a card + * with no background at all. + * + * Only surfaces that can hold content take this. A `Button` is not made of + * anything: it is a control, and blurring what is behind a 32px pill reads as a + * smudge rather than as material. + */ +export type Material = "solid" | "glass"; + /** One scale, everywhere. */ export type Size = "xs" | "sm" | "md" | "lg" | "xl"; diff --git a/src/index.ts b/src/index.ts index 1b1304e1..974451a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -540,6 +540,7 @@ export type { State, Variant, + Material, Size, Radius, Space, From 28d6c5878f5bafe27a9436fab7b0bf922a086007 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 16 Aug 2026 14:34:14 +0700 Subject: [PATCH 2/2] fix(check): catch generated files whose component was deleted `*.generated.tsx` is gitignored and `layouts:generate` only ever adds, so removing a component leaves its generated file behind, still importing the `.recipe` and `.layout` that went with it. Nothing notices: the file is invisible to `git status`, and the failure surfaces much later as tsc errors inside a file nobody edited. 2.5.0 shipped with twenty-two of them. `bun run check` read them as components and reported "missing index.ts barrel export" for things that no longer exist, which is how the gate spent a release red for a reason that pointed nowhere, and `bun run build` failed generating declarations. A twenty-third under `table/` survived the first sweep because it sits in a directory that is still alive. Checked here because this script already walks every component directory, and because a gate that names the actual file and says it is deletable build output costs one line to act on. --- scripts/check-contracts.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/scripts/check-contracts.ts b/scripts/check-contracts.ts index 34ce978e..4a892f31 100644 --- a/scripts/check-contracts.ts +++ b/scripts/check-contracts.ts @@ -118,6 +118,33 @@ if ( ); } +// --- Stale generated output --- +// +// `*.generated.tsx` is gitignored and written by `layouts:generate`, which only +// ever adds. Deleting a component therefore leaves its generated file behind, +// still importing the `.recipe` and `.layout` that went with it, and every +// build from then on fails inside a file nobody can see in `git status`. +// +// That is exactly how 2.5.0 shipped: twenty-two deleted components left +// twenty-two orphans, `bun run check` reported them as "missing index.ts +// barrel export" for components that no longer exist, and `bun run build` died +// generating declarations. Caught here it names the real problem and the fix. +for (const entry of readdirSync(COMPONENTS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = join(COMPONENTS_DIR, entry.name); + for (const file of readdirSync(dir)) { + if (!file.endsWith(".generated.tsx")) continue; + const authored = join(dir, file.replace(/\.generated\.tsx$/, ".layout.tsx")); + if (existsSync(authored)) continue; + fail( + entry.name, + "stale-generated", + `${join(dir, file)} has no .layout.tsx source; delete it (it is gitignored build output from a component that was removed)`, + "Structure", + ); + } +} + // --- Report --- if (violations.length === 0) {