Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -318,7 +318,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### DrawerContent

`children placement scrollBehavior`
`children material placement scrollBehavior`

### DrawerDialog

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -534,7 +534,7 @@ HTML attributes and `UIBaseProps`; that is an assertion, not a gap.

### Navbar

`as dataTheme`
`as dataTheme material`

### NoiseBackground

Expand Down
38 changes: 36 additions & 2 deletions docs/ui-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pathscale/ui",
"version": "2.5.0",
"version": "2.6.0",
"author": "pathscale",
"repository": {
"type": "git",
Expand Down
27 changes: 27 additions & 0 deletions scripts/check-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
115 changes: 115 additions & 0 deletions src/components/_shared/material.css
Original file line number Diff line number Diff line change
@@ -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
* `<component>--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;
}
}
}
13 changes: 10 additions & 3 deletions src/components/card/Card.layout.tsx
Original file line number Diff line number Diff line change
@@ -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<JSX.HTMLAttributes<HTMLDivElement>, "children"> &
UIBaseProps & {
variant?: Variant;
material?: CardMaterial;
material?: Material;
elevation?: CardElevation;
flavor?: Flavor;
state?: State;
Expand Down Expand Up @@ -66,6 +72,7 @@ export const CardLayout: Layout<typeof card, CardProps> = () => {
tabIndex={local.tabIndex ?? (local.isInteractive ? 0 : undefined)}
onKeyDown={handleKeyDown}
data-flavor={local.flavor ?? "neutral"}
data-material={local.material ?? "solid"}
>
<Show when={local.header}>
<CardHeaderLayout>{local.header}</CardHeaderLayout>
Expand Down
15 changes: 13 additions & 2 deletions src/components/dialog/Dialog.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -189,6 +190,14 @@ export type DialogContentProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, "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<JSX.HTMLAttributes<HTMLDivElement>, "children"> &
Expand Down Expand Up @@ -242,7 +251,7 @@ const DialogRoot: Layout<typeof componentRecipe, DialogRootProps> = () => {

const [internalOpen, setInternalOpen] = createSignal(Boolean(local.defaultOpen));
const [animState, setAnimState] = createSignal<DialogAnimState>(
Boolean(local.open ?? local.defaultOpen) ? "open" : "closed",
local.open ?? local.defaultOpen ? "open" : "closed",
);
const [contentRef, setContentRef] = createSignal<HTMLDivElement | undefined>(undefined);
const [labelledBy, setLabelledBy] = createSignal<string | undefined>(undefined);
Expand Down Expand Up @@ -510,6 +519,7 @@ const DialogContent: Layout<typeof componentRecipe, DialogContentProps> = () =>
"scrollBehavior",
"isDismissable",
"shouldCloseOnBackdropClick",
"material",
"role",
"tabIndex",
"aria-labelledby",
Expand Down Expand Up @@ -574,6 +584,7 @@ const DialogContent: Layout<typeof componentRecipe, DialogContentProps> = () =>
local.class,
) }}
data-slot="dialog-content"
data-material={local.material ?? "solid"}
data-placement={placement()}
data-size={size()}
data-scroll={scrollBehavior()}
Expand Down
9 changes: 7 additions & 2 deletions src/components/drawer/Drawer.layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -108,6 +109,8 @@ export type DrawerContentProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, "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";
Expand Down Expand Up @@ -189,7 +192,7 @@ const DrawerRoot: Layout<typeof componentRecipe, DrawerRootProps> = () => {

const [internalOpen, setInternalOpen] = createSignal(Boolean(local.defaultOpen));
const [animState, setAnimState] = createSignal<DrawerAnimState>(
Boolean(local.open ?? local.defaultOpen) ? "open" : "closed",
local.open ?? local.defaultOpen ? "open" : "closed",
);

const [dialogRef, setDialogRef] = createSignal<HTMLDivElement | undefined>();
Expand Down Expand Up @@ -410,6 +413,7 @@ const DrawerContent: Layout<typeof componentRecipe, DrawerContentProps> = () =>
"style",
"placement",
"scrollBehavior",
"material",
]);

const ctx = useDrawerContext();
Expand All @@ -434,6 +438,7 @@ const DrawerContent: Layout<typeof componentRecipe, DrawerContentProps> = () =>
local.class,
)}
data-slot="drawer-content"
data-material={local.material ?? "solid"}
data-placement={placement()}
data-scroll={scrollBehavior()}
data-entering={ctx.animState() === "entering" ? "true" : undefined}
Expand Down
Loading
Loading