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/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) { 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,