UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

338 lines (249 loc) 19.1 kB
# ui A React component library for building Shelving apps — forms, content, layout, routing, dialogs, and the documentation-site components, all in one place. The `ui` module exists so an app never hand-rolls the same form field, card, or router twice. Every component picks up its look from shared CSS and exposes its variations as plain boolean props. You build a screen by composing these pieces, and reach for a custom-styled element only when nothing here fits. `ui` is consumed as source — it ships `.tsx` and `.module.css` files and needs a bundler that understands CSS Modules and JSX. It is not part of the root `shelving` package; import it from `shelving/ui`. ## How components work A few conventions run through every component (see also the React Components section of `AGENTS.md`): - **Variants, not CSS.** Visual options are boolean props — `<Button small primary>`, `<Section narrow>`. Each maps to a class in the component's CSS Module. You never pass `style` or raw `className`. - **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like `Card`, `Section`, `Button`, and `Tag` rather than shipping their own styling. - **Sentence case.** Titles, headings, and button labels capitalise only the first word. - **Theming via CSS variables.** Colour and spacing come from CSS custom properties with fallback chains, so a theme is a small set of variable overrides. ## Styling system The styling system has four moving parts, all defined in [style/](./style/). Most components compose them in a predictable shape; consumers theme by overriding CSS custom properties at `:root`. ### Design tokens [`style/base.css`](./style/base.css) defines every design-token constant at `:root` — colours, sizes (`--size-*`), spacing (`--space-*`), radii (`--radius-*`), strokes (`--stroke-*`), shadows (`--shadow-*`), durations (`--duration-*`). Components read these via `var(--token)`; themes override them at `:root` in their own CSS file. No class selectors needed. `base.css` is `@import`ed at the top of every `*.module.css` in the codebase — that's how the design tokens (and the cascade layer order) reach every component automatically, regardless of bundle order. #### The 5-step colour scale Colours are organised as a **5-step scale**: `--color-black`, `--color-dark`, `--color-vivid`, `--color-light`, `--color-white`. The inner three steps (`dark` / `vivid` / `light`) are saturated tones of the active hue and change per variant scope; the extremes (`black` / `white`) are the page foreground/background and stay put unless the theme deliberately inverts them (e.g. dark mode). A useful mental model: **step distance encodes contrast strength**. | Distance | Pairing | Use for | |---|---|---| | 2 steps | `vivid + white`, `light + dark` | Short text (button labels, tag labels, notices) | | 3 steps | `dark + white`, `light + black` | Body text (paragraphs, headings) | | 4 steps | `black + white` | Maximum-contrast surfaces (Inputs) | Components pick whichever pair fits their content; variants only ever set the inner three steps, so a component that renders `bg=light` and `text=dark` automatically inherits the right tint when wrapped in `.red`, `.success`, etc. The base palette underneath the scale defines three shades per hue: `--vivid-red`, `--light-red`, `--dark-red` (and the same for orange, yellow, green, aqua, blue, purple, pink, plus `--*-gray` for the default neutrals). The default `:root` value of `--color-vivid` is `var(--vivid-gray)` and so on — grey is just the variant you get when no colour variant is applied. **`--color-black` and `--color-white` are theme-scoped, not literal.** They're the extremes of the active scale. In a dark theme they'd be a deep navy and a soft cream. For literal black or white pixels (neutral hover blends, etc.) use the CSS keywords `black` / `white` directly — they sit outside the scale entirely. ### Cascade layers Order, lowest to highest priority: | Layer | What's in it | |---|---| | `defaults` | `:root` design tokens, body baseline typography, any low-priority opt-in defaults | | `components` | Component-defining CSS — the bulk of the codebase: `.card`, `.button`, `.notice`, `.heading`, etc. | | `variants` | Cross-cutting opt-in modifiers (Color, Status, Align, Spacing, Padding, Gap, Thickness, Width, Typography, Flex). Always beat components. | | `overrides` | Top-priority structural overrides — `:first-child` / `:last-child` margin collapses, which need to beat variant-set margins | **Unlayered rules beat all layered rules.** A consumer theme that wraps its overrides in `@layer theme { … }` or just sets tokens at `:root` is fine; one that writes raw class selectors without participating in the layer system will silently dominate variants. ### Variant utilities [`style/`](./style/) exports a set of opt-in class utilities. Each has the same shape: a `.module.css` with the variant classes inside `@layer variants`, and a `.tsx` exporting `getXxxClass(props)` + a `XxxVariants` interface that components extend. | Utility | Classes | Purpose | |---|---|---| | [`Color`](./style/Color.tsx) | `.primary`, `.secondary`, `.red`, `.blue`, `.green`, etc. | Raw colour overrides | | [`Status`](./style/Status.tsx) | `.info`, `.success`, `.warning`, `.danger`, `.error`, `.loading` | Semantic status colours | | [`Align`](./style/Align.tsx) | `.left`, `.center`, `.right` | `text-align` | | [`Spacing`](./style/Spacing.tsx) | `.space-none``.space-xxlarge` | `margin-block` (top + bottom) | | [`Padding`](./style/Padding.tsx) | `.padding-none``.padding-xxlarge` | `padding-block` (top + bottom) | | [`Gap`](./style/Gap.tsx) | `.gap-none``.gap-xxlarge` | `gap` | | [`Thickness`](./style/Thickness.tsx) | `.thickness-none``.thickness-xxthick` | Sets `--thickness` for components that paint borders | | [`Width`](./style/Width.tsx) | `.narrow`, `.wide`, `.full` | `max-width` | | [`Typography`](./style/Typography.tsx) | `.body`, `.monospace`, `.sans`, `.serif`, `.code` + `.size-xxsmall``.size-xxlarge` | `font-family` + `font-size` | | [`Flex`](./style/Flex.tsx) | `.flex` + `.column`, `.left`, `.wrap`, etc. | Flex layout (composes `Gap`) | A component using variants looks like: ```tsx export interface CardProps extends ColorVariants, PaddingVariants, ThicknessVariants, WidthVariants /* … */ { status?: Status | undefined; } export function Card({ children, status, ...props }: CardProps): ReactElement { return ( <article className={getClass( getModuleClass(CARD_CSS, "card"), status && getStatusClass(status), getColorClass(props), getPaddingClass(props), getThicknessClass(props), getWidthClass(props), )} > {children} </article> ); } ``` ### Component theme hooks Each component exposes per-component CSS custom properties for its overridable values. These are read with a `var(--component-hook, default)` fallback chain in the component's CSS, so a consumer can override the hook to retheme a single component without touching the rest of the design system. Naming follows the file-prefix rule from [AGENTS.md](/AGENTS.md): hooks owned by a specific module file start with that file's kebab-case name. So Card owns `--card-color-light`, `--card-color-dark`, `--card-padding`, `--card-radius`, etc. Button owns `--button-color-vivid`, `--button-color-light`, `--button-border`, and so on. Tokens declared at `:root` (in `base.css`, or in `Color`/`Status`'s token blocks) are exempt — they're the global palette, not file-owned. ### The colour rebind pattern Any component that paints a `background-color`, `border-color`, or `color` from the 5-step scale (Card, Button, Notice, Panel, Tag, Code, Mark, Modal, Popover, Preformatted, …) **rebinds all five scale steps on its own scope** before painting: ```css .card { /* Rebind: per-component theme hook wins; otherwise inherit from variant or page scope. */ --color-black: var(--card-color-black, inherit); --color-dark: var(--card-color-dark, inherit); --color-vivid: var(--card-color-vivid, inherit); --color-light: var(--card-color-light, inherit); --color-white: var(--card-color-white, inherit); /* bg=light + text=dark is a 2-step pair, fine for the short text inside a card body. */ background-color: var(--color-light); border-color: var(--color-vivid); color: var(--color-dark); } ``` The rebind serves three jobs at once: 1. **Per-component theme hook.** A consumer setting `--card-color-light: peachpuff` at `:root` repaints the card surface without touching Buttons or Notices. 2. **Variant inheritance.** When the card is `.red` (or `.success`, etc.), the variant has already set `--color-dark / --color-vivid / --color-light` at its scope. The rebind's `inherit` fallback picks those up — no explicit `.card.red` rule needed. 3. **Identity propagation.** Descendants (like a `<Code>` chip inside the card) inherit the rebound values, so they can compute their own surface relative to the card. For variants on appearance (`.strong`, `.outline`, `.plain` on Button) — just pick a different step pair from the already-rebound scale. No extra hook needed: ```css .button { background: var(--color-light); color: var(--color-dark); } .button.strong { background: var(--color-vivid); color: var(--color-white); } ``` **What about components that only paint one colour?** Text-only blocks (Paragraph, Heading, Title, etc.) skip the rebind and read the relevant scale step directly with a single theme-hook fallback: ```css .paragraph { color: var(--paragraph-color, var(--color-dark)); } .heading { color: var(--heading-color, var(--color-black)); } ``` **What about Inputs?** Inputs sit outside the variant scope's middle three steps — they always use `bg=white + text=black` (a 4-step pair, maximum contrast) regardless of the surrounding variant. Variant scope still tints their border and validity states, but never the field surface. Other tokens (`--*-padding`, `--*-spacing`, `--*-radius`, `--*-font`, `--*-size`, etc.) are **not** rebound: - `font-*` properties already inherit naturally via CSS, so just setting them is enough — children pick up the value automatically. - `padding`, `margin`, `gap`, `border-width`, `border-radius` are non-inheriting CSS properties. Each component sets its own; children should never read a parent's padding. This split is deliberate. The rebind is the right tool when an identity needs to propagate; for everything else, plain CSS inheritance (or no inheritance at all) is the right tool. ### Retheming via the global scale The rebind pattern has a powerful consequence: because every surface component rebinds the scale from `inherit`, the page-level `:root` scale is the **cascade root they all fall back to**. Retinting a step at `:root` repaints every surface component at once — and *identically*, so a standalone `<Preformatted>` matches one nested in a `<Card>`, and both match the `<Card>` itself. This is almost always preferable to overriding each component's own hook (`--card-color-light`, `--preformatted-color-light`, …) one by one, which only themes that single component and leaves its siblings on the grey defaults. **But retint one step at a time, and know what else reads it.** The global scale isn't surfaces-only — the page baseline reads from it too. In `base.css`: ```css body { color: var(--color-dark); background: var(--color-white); } ``` All body copy (Titles, Headings, Paragraphs, lists) has no `color` of its own; it inherits this baseline. So moving `--color-dark` at `:root` recolours **every word on the page**, not just text sitting on a card. Likewise `--color-vivid` tints borders and accents app-wide. Retint only the step whose reach you actually want: - `--color-light`**surfaces** (Card / Preformatted / Tag / Code backgrounds). Safe to retint broadly; nothing paints page text or the page background from it. - `--color-vivid` — borders and accents everywhere. Retint only if you want app-wide accent recolouring. - `--color-dark`**the page text colour**, via the `body` baseline above. Retinting this is a whole-page text recolour; usually not what a "themed surfaces" look wants. - `--color-black` / `--color-white` — the page extremes (max-contrast text, page background). Leave unless inverting (e.g. dark mode). The docs theme wants peach surfaces with normal near-black text, so it retints **only** `--color-light`: ```css :root { /* Surfaces go peach; text and the page background stay the library defaults. */ --color-light: color-mix(in srgb, #ff7a1a 14%, white); } ``` Two more rules keep a theme clean: - **If you do move a whole hue, move the anchor.** The `--light-<hue>` / `--dark-<hue>` tokens are defined in `base.css` as expressions over `--vivid-<hue>`, resolved lazily at use-time. Overriding `--vivid-orange` at `:root` re-tints the whole orange family for free, so `var(--light-orange)` / `var(--dark-orange)` stay coherent. - **Pin the exceptions back — and pin *every* step the component paints.** A component that should resist a global retint sets its own hooks. But a component rebinds the *whole* scale, and any step you leave unpinned still inherits the page colour. The docs site keeps Buttons purple by pinning both steps the default variant paints — `--button-color-light: var(--light-purple)` (background) and `--button-color-vivid: var(--vivid-purple)` (border/label) — plus `--button-color-white` for the `strong` label. Pinning only `vivid` would leave the default button's `bg=light` background inheriting the page peach: a purple-bordered peach button. Check which steps the variant in use actually paints (default = `light`+`vivid`, `strong` = `vivid`+`white`) and pin all of them. ### How `:first-child` / `:last-child` margin overrides work Every block-level component zeros its outer margins when it's the first or last child of its container — otherwise a Heading at the top of a Card would leave a strip of unwanted space. These rules live in `@layer overrides`, which beats every other layer including `variants`, so a `<Card space-large>` still collapses its abutting edges correctly. Pattern: ```css @layer components { .card { margin-block: var(--card-spacing, var(--spacing-paragraph)); } } @layer overrides { .card { &:first-child { margin-block-start: 0; } &:last-child { margin-block-end: 0; } } } ``` ### Writing a new component A typical new block-level component looks like: ```tsx // Address.tsx import { type AlignVariants, getAlignClass } from "../style/Align.js"; import { getSpacingClass, type SpacingVariants } from "../style/Spacing.js"; import { getTypographyClass, type TypographyVariants } from "../style/Typography.js"; export interface AddressProps extends AlignVariants, SpacingVariants, TypographyVariants, ChildProps {} export function Address({ children, ...variants }: AddressProps) { return ( <address className={getClass( getModuleClass(styles, "address"), getAlignClass(variants), getSpacingClass(variants), getTypographyClass(variants), )} > {children} </address> ); } ``` ```css /* Address.module.css */ @import "../style/base.css"; @layer components { .address { display: block; margin-inline: 0; margin-block: var(--address-spacing, var(--spacing-paragraph)); /* Single-colour text block — read --color-dark directly with a theme-hook fallback. */ color: var(--address-color, var(--color-dark)); font-family: var(--address-font, inherit); font-size: var(--address-size, inherit); text-align: var(--address-align, left); } } @layer overrides { .address { &:first-child { margin-block-start: 0; } &:last-child { margin-block-end: 0; } } } ``` Checklist: - [ ] `@import "../style/base.css";` at the top. - [ ] All rules inside `@layer components { … }`. - [ ] All custom properties owned by this file start with the file name (`--address-*`, etc.), per [AGENTS.md](/AGENTS.md). - [ ] If the component paints a surface (background + border + text), rebind all five scale steps at the top of the rule and pick a step pair for the painted properties. - [ ] If the component only paints one colour (a text-only block), skip the rebind and read the step directly with a single theme-hook fallback. - [ ] `:first-child` / `:last-child` overrides in a separate `@layer overrides { … }` block. - [ ] TSX extends the variant interfaces (`SpacingVariants`, `AlignVariants`, etc.) you want to expose; composes the matching `getXxxClass(props)` calls. ## Module map ### Content | Folder | What's inside | |---|---| | [block](/ui/block) | Block-level content — `Card`, `Section`, `Title`, `Heading`, `Table`, `List`, `Prose`, `Figure`, `Flex` | | [inline](/ui/inline) | Inline content — `Code`, `Strong`, `Emphasis`, `Link`, `Mark`, `Small` | | [misc](/ui/misc) | Cross-cutting pieces — `Markup`, `Tag`, `Status`, `Loading`, `Color`, `Catcher`, `Mapper` | ### Structure | Folder | What's inside | |---|---| | [app](/ui/app) | The `<App>` root component | | [page](/ui/page) | Document-level components — `<HTML>`, `<Head>`, `<Page>` | | [layout](/ui/layout) | Page layouts — `SidebarLayout`, `CenteredLayout` | | [router](/ui/router) | Client-side routing — `<Navigation>`, `<Router>` | ### Interaction | Folder | What's inside | |---|---| | [form](/ui/form) | Forms and inputs — `<Form>`, `<Field>`, typed inputs, `<Button>`, `FormStore` | | [dialog](/ui/dialog) | `<Dialog>` and `<Modal>` overlays | | [menu](/ui/menu) | `<Menu>` and `<MenuItem>` | | [notice](/ui/notice) | Inline and global notices | | [transition](/ui/transition) | CSS enter / leave transitions | ### Documentation site | Folder | What's inside | |---|---| | [tree](/ui/tree) | `<TreeApp>` and the components that turn a tree into a site | | [docs](/ui/docs) | Page and card renderers for directories, files, and code symbols | | [util](/ui/util) | UI helper functions — context, meta, CSS class composition | ## Quick start A minimal single-screen app: ```tsx import { App, CenteredLayout, Section, Title, Paragraph } from "shelving/ui"; function HelloApp() { return ( <App app="My app"> <CenteredLayout> <Section narrow> <Title>Hello</Title> <Paragraph>Welcome to the app.</Paragraph> </Section> </CenteredLayout> </App> ); } ``` For a routed, multi-page app, wrap the tree in [`<Navigation>` and `<Router>`](/ui/router). For a documentation site, hand an extracted tree to [`<TreeApp>`](/ui/tree) — see the [extract](/extract) guide. ## See also - [extract](/extract) — builds the tree that the documentation components render - [markup](/markup) — Markdown rendering used by `<Markup>` and `<Prose>` - [store](/store) — reactive state behind `FormStore`, `NavigationStore`, and notices - [react](/react) — store and provider hooks used alongside these components