Move the console onto ReUI PRO

Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
+117
View File
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
+117
View File
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"reui": {
"url": "https://mcp.reui.io/api/mcp",
"headers": {
"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
---
description: ReUI - read the reui skill before any UI work
alwaysApply: true
---
## ReUI design rules (every UI edit, follow-ups included)
This project uses an existing design system: ReUI on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here, and other design skills lose where they disagree.
1. Use installed components first (`components/ui`, `components/reui`): tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` or `AlertDialog`, toasts `sonner`. Never set height, radius, padding, shadow or motion on them; use `size`, `variant`, `spacing`.
2. Color: semantic tokens. State: `<Badge variant="success-light">` from `@/components/reui/badge` at default size, or Alert variants. Raw palette only for categorical identity, with its `dark:` pair.
3. Spacing: `gap-*` on the parent: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections. Type: `text-sm` body, `text-xs` meta, nothing smaller; never resize FrameTitle or CardTitle.
4. Motion: installed parts already animate; never retime them. Yours: hover `transition-colors`; reveal `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]`; transform and opacity only; never `transition-all`, bounce, or a stagger on table rows or lists of 20+; at most 1 entrance per view; `MotionConfig reducedMotion="user"` plus `motion-reduce:`.
5. Every data region: empty state with an action, `Skeleton` loading, inline error with Retry.
6. Before UI code, write the plan from `.agents/skills/reui/SKILL.md` in your reply. Before you finish, run its self-check on the files you changed and fix every hit (2 rounds at most). Full rules: `.agents/skills/reui/SKILL.md`. If that folder is missing, call the `reui` MCP tool `get_agent_skill`.
Managed by the ReUI installer. This file is replaced on the next run.
+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
+117
View File
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"reui": {
"httpUrl": "https://mcp.reui.io/api/mcp",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Guards every commit against leaked secrets.
#
# It lives HERE on purpose: `technitium-console` is its own repository with its
# own remote, so pointing `core.hooksPath` at a hooks folder outside it would
# leave the guard broken as soon as someone cloned this repository on its own.
#
# Per machine: git config core.hooksPath .githooks
set -euo pipefail
if ! command -v gitleaks >/dev/null 2>&1; then
echo "pre-commit: gitleaks is not installed. Commit aborted for safety" >&2
exit 1
fi
# gitleaks v8.19+: `protect` was replaced by `git --staged` (pre-commit mode).
gitleaks git --staged --redact -v
+4
View File
@@ -0,0 +1,4 @@
github: byGarcia
ko_fi: bygarcia
liberapay: bygarcia
custom: ["https://www.paypal.com/paypalme/adriangmolina"]
+45
View File
@@ -0,0 +1,45 @@
name: Bug report
description: Something in the console looks or works wrong.
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time. This console aims to look different from the stock one and behave exactly like it, so anything that behaves differently is a bug too.
- type: textarea
id: what
attributes:
label: What happened
description: Which screen, what you did, what you expected and what you got. A screenshot helps a lot.
validations:
required: true
- type: input
id: console
attributes:
label: Console version
description: Shown at the bottom of the sidebar, next to Web Console.
placeholder: "1.2.0"
validations:
required: true
- type: input
id: server
attributes:
label: Technitium DNS Server version
description: Shown at the bottom of the sidebar, next to DNS Server.
placeholder: "15.6.2"
validations:
required: true
- type: dropdown
id: install
attributes:
label: How it is installed
options:
- One-line installer
- Docker image
- By hand
- Other
- type: input
id: browser
attributes:
label: Browser and window width
placeholder: "Chrome 141, 1440 px"
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/byGarcia/technitium-console/discussions
about: How to install it, how something works, or anything that is not a bug or a request.
- name: Technitium DNS Server
url: https://github.com/TechnitiumSoftware/DnsServer/issues
about: Problems with the DNS server itself, not with this console.
+20
View File
@@ -0,0 +1,20 @@
name: Feature request
description: Something the console could do or show better.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
The console changes how Technitium looks, not what it does: it calls the same server API as the stock console, with the same actions and wording. A request that needs the server to do something new belongs in [Technitium DNS Server](https://github.com/TechnitiumSoftware/DnsServer/issues).
- type: textarea
id: problem
attributes:
label: What you are missing
description: What you are trying to do and what gets in the way.
validations:
required: true
- type: textarea
id: idea
attributes:
label: What you would like
description: How you picture it. Screenshots or examples from other tools are welcome.
@@ -0,0 +1,37 @@
# The console's tarball and its .sha256, built from the checked-out commit.
# The release job and the develop image both use this, so a develop image
# carries exactly what a release built from the same commit would carry.
name: Console tarball
description: Checks and builds the console, then packs dist/ as technitium-console.tar.gz with its .sha256.
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- shell: bash
run: npm ci
# The console goes in front of a DNS server, so a build that fails its own
# checks must not exist.
- shell: bash
run: npm run typecheck
- shell: bash
run: npm run lint
- shell: bash
run: npm test
- shell: bash
run: npm run build
# Flat archive: install.sh unpacks it straight into the web root, so the
# files have to be at the root of the tarball and not inside dist/.
- name: Pack
shell: bash
run: |
tar -czf technitium-console.tar.gz -C dist .
sha256sum technitium-console.tar.gz > technitium-console.tar.gz.sha256
echo "Routes emitted: $(find dist -name index.html | wc -l)"
+154
View File
@@ -0,0 +1,154 @@
# The init image for Docker (README, "Docker"): busybox, the install.sh of the
# checked-out commit and a console tarball, checked against its own .sha256
# before building and again inside every platform after pushing (contract D4).
# Only then do the moving tags (`floating`) point at it, so a failed check
# leaves them where they were. For a release, which of them move is decided in
# that same step, from the repo's tags as they are then (floating-tags.sh).
# Shared by the release and develop workflows, so both publish the same image
# the same way.
name: Init image
description: Builds docker/Dockerfile around a console tarball, pushes it for three platforms, checks the bytes in each, then moves the floating tags.
inputs:
image:
description: The image name, without a tag.
required: true
tarball-dir:
description: The folder holding technitium-console.tar.gz and technitium-console.tar.gz.sha256.
required: true
tags:
description: The tags pushed with the build, as docker/metadata-action `tags` lines. Never floating ones.
required: true
floating:
description: Space-separated tag names moved to the build once its bytes are checked. Empty moves none.
required: false
default: ''
release-tag:
description: A release's git tag (vX.Y.Z). Its X.Y and latest are added to `floating` when floating-tags.sh says so, decided from origin's tags right before they move.
required: false
default: ''
token:
description: The token that pushes to GHCR.
required: false
default: ${{ github.token }}
outputs:
version:
description: The version docker/metadata-action read from the first tag.
value: ${{ steps.meta.outputs.version }}
digest:
description: The digest of the manifest list that was pushed and checked.
value: ${{ steps.build.outputs.digest }}
runs:
using: composite
steps:
- name: The build context, with the tarball checked against its checksum
id: context
shell: bash
env:
TARBALLS: ${{ inputs.tarball-dir }}
run: |
ctx="$RUNNER_TEMP/init-image"
rm -rf "$ctx"; mkdir -p "$ctx"
cp install.sh docker/Dockerfile "$ctx/"
cp "$TARBALLS/technitium-console.tar.gz" "$TARBALLS/technitium-console.tar.gz.sha256" "$ctx/"
(cd "$ctx" && sha256sum -c technitium-console.tar.gz.sha256)
echo "dir=$ctx" >> "$GITHUB_OUTPUT"
# latest is never this step's: it moves, with the other floating tags, at the end.
- id: meta
uses: docker/metadata-action@v6
with:
images: ${{ inputs.image }}
flavor: latest=false
tags: ${{ inputs.tags }}
labels: |
org.opencontainers.image.title=technitium-console
org.opencontainers.image.description=Copies the technitium-console web console into a volume shared with Technitium DNS Server, then exits.
org.opencontainers.image.licenses=GPL-3.0-or-later
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.token }}
# No QEMU: the Dockerfile has no RUN, so nothing executes for the foreign
# platforms. No attestations: they show up as unknown/unknown entries in
# the manifest list, and older engines on NAS boxes trip over them.
- id: build
uses: docker/build-push-action@v7
with:
context: ${{ steps.context.outputs.dir }}
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
provenance: false
sbom: false
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# By digest: what was pushed, whatever a tag points at by now. Each
# platform is pulled by ITS OWN manifest digest, not the index's: the
# runner's containerd store refuses a second platform under the same
# index digest ("cannot overwrite digest", run 36910489933).
- name: Every platform carries the tarball, byte for byte (D4)
shell: bash
env:
IMAGE: ${{ inputs.image }}
DIGEST: ${{ steps.build.outputs.digest }}
CONTEXT: ${{ steps.context.outputs.dir }}
run: |
ref="$IMAGE@$DIGEST"
want="$(cut -d' ' -f1 "$CONTEXT/technitium-console.tar.gz.sha256")"
index="$(docker buildx imagetools inspect --raw "$ref")"
jq -r '.manifests[].platform | "\(.os)/\(.architecture)\(if .variant then "/" + .variant else "" end)"' <<<"$index" \
| tee "$RUNNER_TEMP/platforms"
if ! grep -qx 'linux/amd64' "$RUNNER_TEMP/platforms" || ! grep -Eqx 'linux/arm64(/v8)?' "$RUNNER_TEMP/platforms" \
|| ! grep -qx 'linux/arm/v7' "$RUNNER_TEMP/platforms"; then
echo "::error::the manifest list does not hold the three platforms"; exit 1
fi
for p in linux/amd64 linux/arm64 linux/arm/v7; do
d="$(jq -r --arg p "$p" 'first(.manifests[]
| select((.platform.os + "/" + .platform.architecture + (if .platform.variant then "/" + .platform.variant else "" end)) as $q
| $q == $p or ($p == "linux/arm64" and $q == "linux/arm64/v8"))
| .digest) // empty' <<<"$index")"
[ -n "$d" ] || { echo "::error::no manifest for $p"; exit 1; }
docker pull -q --platform "$p" "$IMAGE@$d" >/dev/null
cid="$(docker create --platform "$p" "$IMAGE@$d")"
got="$(docker cp "$cid:/usr/share/technitium-console/technitium-console.tar.gz" - | tar -xO | sha256sum | cut -d' ' -f1)"
docker rm "$cid" >/dev/null
echo "$p $d $got"
[ "$got" = "$want" ] || { echo "::error::$p does not carry the tarball"; exit 1; }
done
# A release's are decided here and not by an earlier job: "Re-run failed
# jobs" hands an earlier job's outputs back as they were, and a release
# tagged since then must keep latest and its X.Y. Anonymous ls-remote: the
# repo is public and checkout kept no token; a failure fails the step.
- name: The floating tags, now that the bytes are checked
if: inputs.floating != '' || inputs.release-tag != ''
shell: bash
env:
IMAGE: ${{ inputs.image }}
DIGEST: ${{ steps.build.outputs.digest }}
FLOATING: ${{ inputs.floating }}
RELEASE_TAG: ${{ inputs.release-tag }}
run: |
if [ -n "$RELEASE_TAG" ]; then
decided="$(git ls-remote --tags --refs origin 'v*' | sh "$GITHUB_ACTION_PATH/floating-tags.sh" "$RELEASE_TAG")"
FLOATING="${FLOATING:+$FLOATING }$decided"
fi
read -r -a names <<< "$FLOATING"
if [ "${#names[@]}" -eq 0 ]; then echo "No floating tags to move."; exit 0; fi
args=()
for t in "${names[@]}"; do args+=(--tag "$IMAGE:$t"); done
docker buildx imagetools create "${args[@]}" "$IMAGE@$DIGEST"
want="$(docker buildx imagetools inspect --raw "$IMAGE@$DIGEST" | jq -c '[.manifests[].digest]')"
for t in "${names[@]}"; do
got="$(docker buildx imagetools inspect --raw "$IMAGE:$t" | jq -c '[.manifests[].digest]')"
echo "$t $got"
[ "$got" = "$want" ] || { echo "::error::$t does not point at the images just checked"; exit 1; }
done
@@ -0,0 +1,36 @@
#!/bin/sh
# Which of Docker's floating tags the release TAG moves, printed on one line
# (`X.Y latest`, `X.Y`, or nothing). The repo's tags come on stdin, as
# `git ls-remote --tags --refs origin 'v*'` prints them or one name a line.
#
# Floating tags only ever move forward: latest goes to the highest release in
# the repo and X.Y to the highest of its own line, so re-running an old tag or
# tagging a backport points neither back in time. Prereleases are not releases
# here: they move neither. Called right before the tags move, never from an
# earlier job's outputs, which "Re-run failed jobs" would hand back unchanged.
set -eu
[ $# -eq 1 ] || { echo "usage: git ls-remote --tags --refs origin 'v*' | $0 vX.Y.Z" >&2; exit 2; }
tag="$1"
releases="$(sed 's|.*refs/tags/||' | { grep -Ex 'v[0-9]+\.[0-9]+\.[0-9]+' || true; } | sort -V)"
if ! printf '%s\n' "$tag" | grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "$tag is not a release (vX.Y.Z): no floating tags" >&2
exit 0
fi
# A release that cannot find itself among the tags has not read them.
if ! printf '%s\n' "$releases" | grep -Fqx "$tag"; then
echo "::error::$tag is not among the tags read from origin" >&2
exit 1
fi
line="$(printf '%s\n' "$tag" | cut -d. -f1-2)."
top="$(printf '%s\n' "$releases" | tail -n1)"
top_of_line="$(printf '%s\n' "$releases" | awk -v p="$line" 'index($0, p) == 1' | tail -n1)"
floating=""
if [ "$tag" = "$top_of_line" ]; then floating="$(printf '%s\n' "${tag#v}" | cut -d. -f1-2)"; fi
if [ "$tag" = "$top" ]; then floating="${floating:+$floating }latest"; fi
echo "$tag: highest release $top, highest of its line $top_of_line -> floating tags: ${floating:-none}" >&2
printf '%s\n' "$floating"
+85
View File
@@ -0,0 +1,85 @@
# An init image of whatever is on develop, to pull and try before a release:
# ghcr.io/bygarcia/technitium-console:develop and :develop-<sha7>. It is built
# exactly as a release is (the same tarball action and the same image action)
# and never touches latest, X.Y or X.Y.Z. No release, no notes. Not for
# production: develop moves with every push.
#
# Two jobs, so that the one holding the package token never runs npm: the
# dependencies' install scripts, the tests and the build run in `tarball`,
# which can read the repo and nothing else; `image` only gets their output.
name: Develop image
on:
push:
branches: [develop]
workflow_dispatch:
permissions: {}
# The newest push to develop wins; an older run still going is dropped. Keyed
# on the ref, so a manual run from another branch (which builds nothing)
# cannot cancel a develop build.
concurrency:
group: develop-image-${{ github.ref }}
cancel-in-progress: true
jobs:
tarball:
# develop of this repository only: a manual run from another branch, or a
# fork's own develop, builds nothing.
if: github.repository == 'byGarcia/technitium-console' && github.ref == 'refs/heads/develop'
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: ./.github/actions/console-tarball
- uses: actions/upload-artifact@v7
with:
name: console-tarball
path: |
technitium-console.tar.gz
technitium-console.tar.gz.sha256
if-no-files-found: error
# A day: "Re-run failed jobs" of `image` after that fails at the
# download; "Re-run all jobs" builds the tarball again.
retention-days: 1
image:
needs: tarball
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# bash -eo pipefail: a failing command in a pipe fails the step.
defaults:
run:
shell: bash
steps:
# For install.sh, docker/Dockerfile and the image action; nothing here
# installs or runs the repo's dependencies.
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v8
with:
name: console-tarball
path: ${{ runner.temp }}/tarball
# init-image checks the tarball against its .sha256 before building.
# develop-<sha7> is pushed with the build; develop moves to it once
# every platform has been checked.
- uses: ./.github/actions/init-image
with:
image: ghcr.io/bygarcia/technitium-console
tarball-dir: ${{ runner.temp }}/tarball
tags: type=sha,prefix=develop-,format=short
floating: develop
+200
View File
@@ -0,0 +1,200 @@
# Builds the console and attaches it to the release, because that tarball is
# what install.sh downloads. Without this there is nothing to install.
#
# npm (the dependencies' install scripts, the tests, the build) runs only in
# `tarball`, which can read the repo and nothing else. The job that can write
# to the repo, `release`, gets just the tarball and its checksum.
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
permissions: {}
jobs:
tarball:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# The same tarball the develop image is built from (develop-image.yml).
- uses: ./.github/actions/console-tarball
- uses: actions/upload-artifact@v7
with:
name: console-tarball
path: |
technitium-console.tar.gz
technitium-console.tar.gz.sha256
if-no-files-found: error
# A day: "Re-run failed jobs" of `release` after that fails at the
# download; "Re-run all jobs" is the way.
retention-days: 1
release:
needs: tarball
# Tags only: a manual run from a branch must not publish a release named
# after it. The image job below has the same guard.
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# For CHANGELOG.md and the tags; no npm here. The token is not left in
# .git/config: only the release step below gets it, as an input.
- uses: actions/checkout@v4
with:
persist-credentials: false
# Outside the workspace: the artifact comes from the job that runs npm,
# and nothing in it may land over the checkout whose script and
# CHANGELOG.md the steps below use. Same place as develop-image.yml.
- uses: actions/download-artifact@v8
with:
name: console-tarball
path: ${{ runner.temp }}/tarball
# Exactly the two files the tarball job packs, and the bytes checked
# against the checksum, before anything is published.
- name: The tarball, checked against its checksum
working-directory: ${{ runner.temp }}/tarball
run: |
set -eu
found="$(find . -mindepth 1 | LC_ALL=C sort | tr '\n' ' ')"
expected="./technitium-console.tar.gz ./technitium-console.tar.gz.sha256 "
if [ "$found" != "$expected" ] || [ -n "$(find . -mindepth 1 ! -type f)" ]; then
echo "The artifact holds something other than the tarball and its checksum: $found" >&2
exit 1
fi
sha256sum -c technitium-console.tar.gz.sha256
# Docker's floating tags only ever move forward (latest to the highest
# release, X.Y to the highest of its line; prereleases move neither), as
# .github/actions/init-image/floating-tags.sh decides. The notes below
# need to know whether latest will be this release; the image job does not
# take it from here, it decides again right before moving the tags.
- name: Which floating tags this release moves
id: floating
shell: bash
run: |
# Anonymous: the repo is public and checkout kept no token. If it ever
# cannot read the tags, pipefail or the script's guard fails the release.
floating="$(git ls-remote --tags --refs origin 'v*' | sh .github/actions/init-image/floating-tags.sh "$GITHUB_REF_NAME")"
latest=false
case " $floating " in *" latest "*) latest=true ;; esac
echo "latest=$latest" >> "$GITHUB_OUTPUT"
# The release page is what people read, so it carries this version's own
# section of CHANGELOG.md. A tag with no section there fails here instead of
# publishing a release that says nothing.
- name: Release notes
id: notes
shell: bash
env:
LATEST: ${{ steps.floating.outputs.latest }}
run: |
tag="${GITHUB_REF_NAME}"
# A prerelease (vX.Y.Z-rc.N) reads the section of the version it previews.
base="${tag%%-*}"
awk -v head="## ${base} " 'index($0, head) == 1 { on = 1; next } on && /^## v/ { exit } on' CHANGELOG.md \
| sed '/^---$/d' > section.md
# Release pages keep every newline, so the wrapped paragraphs of
# CHANGELOG.md are joined back into one line each.
python3 - <<'PY'
out = []
for line in open('section.md').read().split('\n'):
cont = line.startswith(' ') and not line.lstrip().startswith(('- ', '|', '#'))
plain = line and not line.startswith(('- ', '#', '|', '```')) and not line.startswith(' ')
if out and out[-1] and not out[-1].startswith(('#', '|')) and (cont or plain):
out[-1] += ' ' + line.strip()
else:
out.append(line)
open('notes.md', 'w').write('\n'.join(out))
PY
[ -s notes.md ] || { echo "CHANGELOG.md has no section for ${base}"; exit 1; }
# The installer's default is the latest release, which a prerelease
# never is: it is installed by naming it.
# shellcheck disable=SC2016 # the backticks are Markdown, not a command
if [ "$base" != "$tag" ]; then
printf '\n**A prerelease, for testing:** the one-line installer from the [README](https://github.com/byGarcia/technitium-console#installing) installs it with `sh -s -- --version %s`, and goes back with `--version` and the release you had. Which console goes with which server: [CHANGELOG](https://github.com/byGarcia/technitium-console/blob/main/CHANGELOG.md#which-version-for-which-server).\n' "$tag"
else
printf '\n**Install or update:** run the one-line installer from the [README](https://github.com/byGarcia/technitium-console#installing) again. Which console goes with which server: [CHANGELOG](https://github.com/byGarcia/technitium-console/blob/main/CHANGELOG.md#which-version-for-which-server).\n'
fi >> notes.md
echo "prerelease=$([ "$base" != "$tag" ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
# The image job below publishes this same release for Docker. Pulling
# only brings it when it is the one latest points at.
# shellcheck disable=SC2016 # the backticks are Markdown, not a command
if [ "$LATEST" = true ]; then
printf '\n**On Docker:** `docker compose pull technitium-console && docker compose up -d technitium-console`, or pin `ghcr.io/bygarcia/technitium-console:%s`. See [Docker](https://github.com/byGarcia/technitium-console#docker).\n' "${tag#v}"
else
printf '\n**On Docker:** pin `ghcr.io/bygarcia/technitium-console:%s`. See [Docker](https://github.com/byGarcia/technitium-console#docker).\n' "${tag#v}"
fi >> notes.md
# The title is the tag and the section's opening bold sentence.
lead="$({ grep -m1 -o '^\*\*[^*]*\*\*' notes.md || true; } | sed 's/^\*\*//; s/\*\*$//; s/\.$//')"
echo "title=${tag}${lead:+: ${lead}}" >> "$GITHUB_OUTPUT"
cat notes.md
- uses: softprops/action-gh-release@v2
with:
token: ${{ github.token }}
files: |
${{ runner.temp }}/tarball/technitium-console.tar.gz
${{ runner.temp }}/tarball/technitium-console.tar.gz.sha256
name: ${{ steps.notes.outputs.title }}
body_path: notes.md
generate_release_notes: true
# A prerelease is never the one releases/latest, and so install.sh's
# default, points at. A release leaves it unset, as before: an explicit
# true would also move latest back when an older tag is re-run.
prerelease: ${{ steps.notes.outputs.prerelease }}
make_latest: ${{ steps.notes.outputs.prerelease == 'true' && 'false' || '' }}
# The init image for Docker (README, "Docker"), from the tarball the job
# above has just published: X.Y.Z is pushed and checked in every platform
# (contract D4), and only then do X.Y and latest move, decided in that same
# step from the tags as they are then, so a re-run of this job alone never
# moves them back. The steps live in .github/actions/init-image, shared with
# the develop image. GITHUB_TOKEN is the only credential. A package's first
# publication is private: it is made public by hand, once.
image:
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Two runs of one tag never push over each other.
concurrency:
group: image-${{ github.ref }}
cancel-in-progress: false
# bash -eo pipefail: a failing command in a pipe fails the step.
defaults:
run:
shell: bash
steps:
# For install.sh, docker/Dockerfile and the image action; no npm here.
- uses: actions/checkout@v4
with:
persist-credentials: false
# The bytes people download, not a second build of them.
- name: The release's own tarball
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --dir "$RUNNER_TEMP/release" \
--pattern technitium-console.tar.gz --pattern technitium-console.tar.gz.sha256
# vX.Y.Z is pushed as X.Y.Z alone; a prerelease as its own version only.
- uses: ./.github/actions/init-image
with:
image: ghcr.io/bygarcia/technitium-console
tarball-dir: ${{ runner.temp }}/release
tags: type=semver,pattern={{version}}
release-tag: ${{ github.ref_name }}
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist-check/
dist/
.env.local
.claude/settings.local.json
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"reui": {
"type": "http",
"url": "https://mcp.reui.io/api/mcp",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
}
}
+83
View File
@@ -0,0 +1,83 @@
---
name: reui
description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind. Use for any UI work in a project with @reui in components.json or a components/reui folder, and in any shadcn project that asks for data grids, kanban boards, dashboards or full pages: building or restyling pages, forms and tables; colors, theme tokens and dark mode; spacing, typography and layout; animation and hover motion; installing ReUI components, examples, Pro blocks or Motion Icons. Works with the ReUI MCP."
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
metadata:
user-invocable: "false"
---
> **ReUI skill version `68f0e6fa91`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer in the mode it was installed with to update this skill: `get_agent_skill` -> `install.recommended` from the project root, with `REUI_GLOBAL=1` before `node` when this file is `~/.claude/skills/reui/SKILL.md` or `~/.agents/skills/reui/SKILL.md` (a global install). Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI
ReUI is an existing design system on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here.
It ships free components, free `c-*` examples, Pro blocks and Ultimate Motion Icons through the shadcn CLI; the ReUI MCP finds them and serves their APIs.
## Order of authority
1. The installed style (`components.json` `style`, baked into `components/ui` and `components/reui`): it owns radius, height, padding, shadow and overlay motion.
2. The installed component's variants and props (`size`, `variant`, `spacing`).
3. These rules and the topic files.
4. Other design skills, then your taste: where they disagree with 1 to 3, ReUI wins. Never redefine `--ease-out` or any Tailwind built-in.
## Before you write UI code, write this plan in your reply
1. Surface: operational, marketing or flow.
2. Archetype: 1 from [compose.md](./rules/compose.md).
3. Container family: Frame-led, Card-led, or Cards for the KPIs and a Frame for the table.
4. Density: dense (`size="sm"` on controls that take it) or default.
5. Regions: top to bottom, each with the item that fills it.
6. States: empty, loading and error for each data region.
7. Motion: none, or the 1 entrance.
Then build exactly that plan; if you deviate, say where.
## The loop
1. **Find**: `compose_page` for a full page, else `search` with `type` and `component` hints.
2. **Install**: `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a tool returned it (shadcn/ui parts install by bare name: `npx shadcn@latest add button --yes`).
3. **Read the API on your base**: `components.json` style `base-nova` is Base UI, `radix-nova` is Radix. Call `get_component([...componentsUsed])` once, then install 1 `get_examples` example and read its files.
4. **Adapt**: change data, copy and props; keep the structure and classes.
5. **Check**, after every UI edit, follow-ups included: `validate_usage` on any name you did not read, the self-checks C1 to C4 in [quality.md](./rules/quality.md), [anti-patterns.md](./rules/anti-patterns.md), `get_audit_checklist`, typecheck, lint. Hand back in the 5 lines quality.md gives.
Show every item's `previewUrl` whenever you list, recommend or install it.
## Rules that always apply
- **Owners**: tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` (destructive confirms `AlertDialog`), toasts `sonner`; never hand-roll.
- **Color**: semantic tokens for meaning; state colors through Badge or Alert variants (`@reui/badge` and `@reui/alert`, not shadcn's); raw palette only for categorical identity, always with its `dark:` pair.
- **Contrast**: never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
- **Shape**: no `rounded-*` except `rounded-full`, and no `shadow-*`, `h-*` or `px-*` on Button, Input, SelectTrigger, Card or Frame; pick `size` and `variant`. A panel is a Card or Frame, never a div with `rounded-lg border`.
- **Spacing**: `gap-*` on the parent, not `space-y-*`; `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections; title to description `gap-0.5`.
- **Type**: `text-sm` body, `text-xs` meta (controls keep their style's size), `font-medium` or `font-semibold`, headings `tracking-tight`, numbers `tabular-nums`; nothing below `text-xs` except illustrative mini-UI.
- **Motion**: installed components own their motion. Your own code uses `transition-colors` for hover and press, `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` to reveal, `duration-300` with the same curve at most (media hover zoom: 500), and `SPRING` (`{ type: "spring", visualDuration: 0.25, bounce: 0 }`) for drag and reorder. At most 1 entrance animation per view (a stagger counts as 1). Animate only `transform` and `opacity`, CSS before motion/react, never set state per frame; `<MotionConfig reducedMotion="user">` once at the root plus `motion-reduce:` on CSS, where reduced motion means a calm fade, not nothing.
- **Surfaces**: prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card. Icons come from 1 library, the `iconLibrary` in `components.json` (on Ultimate, 1 Motion Icon on the primary action is the exception).
- **States**: each data region has empty (1 action), loading (a `Skeleton` in the real layout) and error (Retry) states; light and dark work.
- **Never invent**: never write a prop, variant or `@reui/` name you did not read in an `api`, an installed file or a tool result.
## Topics
Always read `compose` before building a screen, and `quality` plus `anti-patterns` at the Check step. Besides those, read only the topic files the task touches, at most 4 (for example `color`, `motion`, `layout` and `components`).
Installed skill: open the file. MCP only: call `get_agent_skill` with the topic, or fetch `https://mcp.reui.io/skill/reui/<file>`.
| Topic | File | Read when |
| --------------- | -------------------------------------------------- | --------------------------------------------- |
| `compose` | [rules/compose.md](./rules/compose.md) | before building any screen or section |
| `workflow` | [rules/workflow.md](./rules/workflow.md) | `compose_page`, search hints, installing |
| `adapting` | [rules/adapting.md](./rules/adapting.md) | changing an installed block or example |
| `components` | [rules/components.md](./rules/components.md) | `data-grid`, `filters`, `kanban` and the rest |
| `color` | [rules/color.md](./rules/color.md) | any color, status, chart or dark mode choice |
| `theming` | [rules/theming.md](./rules/theming.md) | the styles, radius, density and sizes |
| `motion` | [rules/motion.md](./rules/motion.md) | any transition, animation or motion/react |
| `layout` | [rules/layout.md](./rules/layout.md) | house defaults, hierarchy, surfaces, states |
| `icons` | [rules/icons.md](./rules/icons.md) | icon imports, Motion Icons |
| `quality` | [rules/quality.md](./rules/quality.md) | the done gate: self-checks, a11y, hand-back |
| `anti-patterns` | [rules/anti-patterns.md](./rules/anti-patterns.md) | the Check step: drift pairs, stale habits |
| `registry` | [rules/registry.md](./rules/registry.md) | free vs Pro vs Ultimate, base vs radix |
| `cli` | [rules/cli.md](./rules/cli.md) | `components.json`, license key, install flags |
| `tools` | [tools.md](./tools.md) | MCP result shapes, errors, quota, commands |
## Plans
Free covers components, `c-*` examples, this skill and the MCP (a ReUI account and a daily allowance). Pro adds blocks. Ultimate adds Motion Icons. A plan miss is a normal answer with `locked: true`; keep building with what the plan covers.
+4
View File
@@ -0,0 +1,4 @@
interface:
display_name: "ReUI"
short_description: "ReUI design and component rules for React apps on shadcn/ui and Tailwind."
default_prompt: "Use ReUI to build this UI with installed components and the ReUI motion and color rules."
+40
View File
@@ -0,0 +1,40 @@
# Adapting installed ReUI code (reuse-first, no AI slop)
ReUI items ship production-quality. Your job is to **adapt by reuse** - wire real data and fit the app - not to redesign or hand-roll. The output should look like ReUI built it for this product.
## Preserve the design - don't over-customize
The design IS the product. A ReUI block/component encodes senior-designer decisions: spacing, hierarchy, density, color treatment, and component choices. The fastest way to turn a premium block back into generic AI slop is to "improve" its look - so don't.
- Change **data, copy, and props**; keep the **structure and styling** it ships with. Make the **smallest** change that wires the real data. If your diff touches `className` / JSX structure more than data / props, you are over-customizing - stop and reuse.
- Don't swap ReUI components for hand-rolled ones, restructure the layout, re-skin spacing / radius / colors, or add decorative chrome. Let the installed components carry the default spacing, radius, sizing, icon rhythm, density, and state styling; add custom Tailwind only when a component genuinely lacks a contract you need.
- Want a different look? `search` for a block whose design already fits and reuse that - don't restyle this one into a new design.
## Reuse the parts: examples and the block's own elements
- **Examples are building parts.** A free `c-*` example is a correct, single-pattern composition you can reuse. Before composing from scratch, `get_examples(component)`, install the closest one, and reuse its wiring - assemble UI from examples instead of hand-rolling what an example already shows.
- **Reuse a block's own elements.** Need more rows, cards, items, or sections than ship by default? Repeat the block's **existing** element by mapping real data through the same markup - never invent parallel markup that drifts from its design. Need a variant (empty / loading / expanded)? Derive it from an element the block already has.
## Don't invent (read, don't guess)
- Never write a prop, variant value, import path, or `@reui/...` name you didn't read in a component's inline `api`, an installed example, or a `search` result. If you didn't see it, treat it as nonexistent - call `get_component` / `get_examples` / `search` first, or run the MCP `validate_usage` tool to check planned names + props against the docs before writing code.
- If a getter returns `found: false` or `search` returns nothing, say so and fall back (plain shadcn, or ask) - never fabricate an install command or an API.
## What to change vs leave alone
- **Change:** the item's own data, copy, props, and layout to fit the app.
- **Leave alone:** installed component files, hooks, and the shared theme - do not edit vendored ReUI internals; change behavior through props and the documented API.
- Blocks are **portable React** - no `next/link`, `next/image`, or other framework-runtime imports inside them. Keep them portable.
## Demo data -> real data
- Replace every placeholder with the user's real data. Model it as **typed data structures** and **map over arrays** - never duplicate JSX per row/card. Keep small block-specific formatters next to the data.
- Wire the real source (columns, fields, fetch). For `data-grid`, implement the server fetch contract if the user needs server-side data.
- Never read `window`, `localStorage`, `location`, `Date.now()` or `Math.random()` in render or in a `useState` initializer; read them in an effect.
- **Type from the component API, derive during render.** Type domain state through the component's own types - e.g. map status to `BadgeProps["variant"]` via a typed `Record<Status, …>` - instead of stringly-typed values. Compute view state during render; don't mirror derived data into `useState`/`useEffect`.
- **Adapt on the right base.** Use the API for the project's base (Base UI vs Radix - see [components.md](./components.md)); the installed files are already base-correct, so reuse their shape rather than translating from memory.
## Believable content (no AI tells)
- Use realistic labels, counts, timestamps, and statuses that map to a real workflow.
- No decorative buttons, fake tabs, meaningless toggles, equal-weight card walls, empty gradients, ornamental icons, or generic SaaS filler. Every element should do something, and the layout must still hold with long names, empty values and crowded data.
@@ -0,0 +1,117 @@
# Anti-patterns
Read this at the Check step, after the self-checks in [quality.md](./quality.md). Each entry: an `Incorrect:` line, then its `Correct:` fix. Detector: the self-check that prints it (C1 to C4), or `visual` when reading finds it. Source: the pilot run that wrote it, or `default` (a training-data habit).
## Pairs
### 1. Stagger on every row (Detector: C2; source: run-08)
Incorrect: `transition={{ delay: i * 0.05 }}` on all 50 rows of a table
Correct: 1 stagger of at most 6 items, `staggerChildren: 0.05`, never on table rows
### 2. Transition everything on a card (Detector: C1; source: runs 10, 15)
Incorrect: `className="rounded-xl border transition-all duration-300 hover:shadow-lg"`
Correct: `transition-colors` for a hover color; a lift is `transform` over a static shadow
### 3. Raw palette for a state (Detector: C3; source: runs 08, 15)
Incorrect: `<span className="text-green-600">Active</span>`
Correct: `<Badge variant="success-light">Active</Badge>`
### 4. Animated table rows (Detector: C1; source: runs 01, 08, 17)
Incorrect: `<motion.tr whileHover={{ backgroundColor: "var(--muted)" }}>`
Correct: `data-grid` rows, which already hover; never animate rows with motion/react
### 5. Hover scale on the default spring (Detector: C1; source: runs 01, 10)
Incorrect: `<motion.div whileHover={{ scale: 1.03 }}>` with no `transition`
Correct: CSS first: `transition-colors` for hover, `transition-transform active:scale-[0.98] motion-reduce:transition-none` for press
### 6. Entrance from scale 0 (Detector: C1; source: runs 10, 15)
Incorrect: `<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }}>`
Correct: `initial={{ opacity: 0, scale: 0.95 }}`: start at scale 0.95 or more and move 8px or less
### 7. Hand-built modal or toast (Detector: C4; source: run-15)
Incorrect: `<div className="fixed inset-0 z-50 bg-black/50">` as a modal
Correct: `Dialog`, `AlertDialog` for a destructive confirm, `sonner` for toasts; a palette: `<CommandDialog><Command><CommandInput /><CommandList>`, as `CommandDialog` holds no `Command`
### 8. Native select (Detector: C4; source: run-15)
Incorrect: `<select>` with `<option>` children
Correct: the shadcn `Select`, or `NativeSelect` when the native control is wanted
### 9. Hand-rolled table (Detector: C4; source: run-17)
Incorrect: `<table>` with mapped `<tr>` rows
Correct: `data-grid`: a `useTable({ features: dataGridFeatures })` instance passed as `table={table}`, columns typed `ColumnDef<DataGridFeatures, Order>[]`
### 10. Button inside a clickable wrapper (Detector: visual; source: run-02)
Incorrect: `<motion.button><Button>Save</Button></motion.button>`
Correct: the `Button` alone: 1 interactive element, and it already has a press state
### 11. Radix asChild on Base UI (Detector: C4; source: run-17, judge)
Incorrect: `<DialogTrigger asChild><Button>Open</Button></DialogTrigger>`
Correct: `<DialogTrigger render={<Button variant="outline" />}>Open</DialogTrigger>` on base-nova; a link: `<Button nativeButton={false} render={<Link href="/x" />}>`
### 12. Component defined inside render (Detector: visual; source: Haiku runs, judge)
Incorrect: `const Row = () => <div />` inside the page component
Correct: declare `Row` at module scope and pass it props, so React keeps its state
### 13. A panel inside a panel (Detector: visual; source: run-05)
Incorrect: `<FramePanel><Card>...</Card></FramePanel>`, or a Card inside a Card
Correct: FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame
### 14. Small badges (Detector: visual; source: runs 03, 04, 06, 07, 10, 13, 16, 18)
Incorrect: `<Badge variant="success-light" size="sm">Paid</Badge>`
Correct: `<Badge variant="success-light">Paid</Badge>` with `size` left off
### 15. A colored circle behind every KPI icon (Detector: visual; source: default)
Incorrect: `<div className="rounded-full bg-blue-100 p-2">` around each KPI icon
Correct: the number leads; at most 1 `text-muted-foreground` icon
### 16. Gradient text (Detector: visual; source: default)
Incorrect: `bg-linear-to-r from-violet-500 to-pink-500 bg-clip-text text-transparent` on a heading
Correct: `text-foreground` and `tracking-tight`; color carries meaning, not decoration
### 17. A dot or check for the current item (Detector: visual; source: default)
Incorrect: a dot or a `Check` icon beside the active nav item
Correct: `bg-muted` (`dark:bg-muted/50`) plus `aria-current`
### 18. framer-motion (Detector: C4; source: default)
Incorrect: `import { motion } from "framer-motion"`
Correct: `import { motion } from "motion/react"`
### 19. Clock or randomness in render (Detector: C4; source: default, near run-05)
Incorrect: `const [id] = useState(() => Math.random())`
Correct: `useId()` for ids; read the clock in an effect or an event handler
### 20. Placeholder data (Detector: visual; source: default)
Incorrect: "John Doe", "Acme Inc" and round figures like `$1,000`
Correct: specific, plausible data: "Mara Lind", `$1,284.50`, 37 open orders
## Your training data is out of date
| Old habit | In this stack |
| ----------------------------- | --------------------------------------------------------------- |
| `framer-motion` | `motion/react` |
| `<DataGrid data columns>` | `useTable({ features: dataGridFeatures })` plus `table={table}` |
| A fixed div modal | `Dialog` or `AlertDialog` |
| A hand-built toast | `sonner` |
| `w-5 h-5` | `size-5` |
| never `space-y-4` in new code | `flex flex-col gap-4` |
| `asChild` | `render` on base-nova |
+77
View File
@@ -0,0 +1,77 @@
# CLI: registry setup, license, non-interactive install
## Registry setup (one-time, per project)
Free items (the ReUI components and all `c-*` examples) need only the plain string registry in `components.json`:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium items (blocks and Motion Icons) require a ReUI license at install:
1. Add the key to `.env.local`:
```bash
REUI_LICENSE_KEY=your-license-key
```
2. Switch `components.json` to the authenticated object form:
```json
{
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": { "Authorization": "Bearer ${REUI_LICENSE_KEY}" }
}
}
}
```
The shadcn CLI expands `${REUI_LICENSE_KEY}` from `.env.local` inside `components.json`. MCP client configs expand environment variables too, but each client has its OWN syntax, so wire the ReUI MCP server with the form that client understands:
- Claude Code (`.mcp.json`, `~/.claude.json`): `"Authorization": "Bearer ${REUI_LICENSE_KEY}"`
- Cursor (`.cursor/mcp.json`) and VS Code: `"Authorization": "Bearer ${env:REUI_LICENSE_KEY}"`
- OpenCode (`opencode.json`): `"Authorization": "Bearer {env:REUI_LICENSE_KEY}"`
- Codex (`~/.codex/config.toml`): `bearer_token_env_var = "REUI_LICENSE_KEY"` - Codex reads the variable itself, so there is no header to write
Only a client with no interpolation at all needs the raw `reui_pat_...` token, and then only in a file that is never committed. Copying the `components.json` form into a client that does not expand it is the common failure: the literal `${REUI_LICENSE_KEY}` placeholder is sent to the server as the credential and every call comes back 401.
The MCP `get_project_context` tool returns the right config. Full guide: https://reui.io/docs/registry?ref=skill
## Installing
Use the project's package runner (check `packageManager`):
```bash
npx shadcn@latest add @reui/<name> --yes # npm
pnpm dlx shadcn@latest add @reui/<name> --yes # pnpm
bunx --bun shadcn@latest add @reui/<name> --yes # bun
```
`--yes` skips confirmation prompts. The CLI auto-detects the package manager from the lockfile (there is no `--package-manager` flag). It also resolves the correct base+style variant from `components.json`, so do not pass a style.
`<name>` is the item name exactly as a ReUI tool returned it. The `@reui/` prefix is for ReUI items only:
- The shadcn/ui components ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. Install them from shadcn by bare name: `npx shadcn@latest add button --yes`.
- `badge` and `alert` exist in both registries: `@reui/badge` and `@reui/alert` are ReUI's components, bare `badge` and `alert` are shadcn's.
- In an item's `registryDependencies`, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both, so you never add them by hand.
- Templates are not registry items. Download a template from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs one.
## Handling prompts and conflicts
- **Always pass `--yes`** so the CLI does not block on confirmation prompts.
- **Do NOT pass `--overwrite` by default.** If the CLI reports an existing file, read the output and resolve deliberately: install under a different name, adjust the path, or ask the user. Only use `--overwrite` when the user explicitly wants to replace a file.
- **Preview first when touching an existing project**: `npx shadcn@latest add @reui/<name> --dry-run` shows what would change; `--diff <file>` shows a specific file's diff. Use these before overwriting.
- Run from the **project root** so `components.json` and `.env.local` are found.
## Free vs premium boundary
- Public, no key: `c-*` examples and the ReUI components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) need Ultimate.
- Templates are downloads from their page on reui.io, not installs: free ones need only a ReUI account, and each template's page says what the others need.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons: Ultimate). Point the user to https://reui.io/account?ref=skill (their key) or https://reui.io/pricing?ref=skill (upgrade).
If an install 404s, the name is not a ReUI item. Read the CLI's error message, which names the right install command when there is one, or look the name up with `get_install_command` or `search`.
+74
View File
@@ -0,0 +1,74 @@
# Color
Semantic tokens carry meaning; raw palette carries identity. Check every color in light and dark.
## Tokens
Surfaces, text and borders use your theme tokens (`background`, `foreground`, `card`, `muted`, `muted-foreground`, `primary`, `accent`, `destructive`, `border`, `ring`), so a theme change re-tints them. ReUI adds state tokens: `--success`, `--info` and `--warning` with their `-foreground`, `--destructive-foreground` (beside shadcn's `--destructive`) and `--invert` with `--invert-foreground` for inverted surfaces.
They are not part of shadcn's base theme. Installing `@reui/badge`, `@reui/alert` or `@reui/code-block` adds them to your global CSS; until one of those is installed, classes such as `bg-success` generate nothing. Check for `--success:` in your global CSS. If it is missing, add these values to `:root` and `.dark`, plus a `--color-<name>: var(--<name>)` line for each under `@theme inline`:
<!-- gen:tokens:start -->
| Token | `:root` | `.dark` |
| -------------------------- | -------------------------- | -------------------------- |
| `--destructive-foreground` | `var(--color-red-800)` | `var(--color-red-600)` |
| `--success` | `var(--color-emerald-500)` | `var(--color-emerald-500)` |
| `--success-foreground` | `var(--color-emerald-900)` | `var(--color-emerald-600)` |
| `--info` | `var(--color-violet-500)` | `var(--color-violet-500)` |
| `--info-foreground` | `var(--color-violet-900)` | `var(--color-violet-600)` |
| `--warning` | `var(--color-yellow-500)` | `var(--color-yellow-500)` |
| `--warning-foreground` | `var(--color-yellow-900)` | `var(--color-yellow-600)` |
| `--invert` | `var(--color-zinc-900)` | `var(--color-zinc-700)` |
| `--invert-foreground` | `var(--color-zinc-50)` | `var(--color-zinc-50)` |
<!-- gen:tokens:end -->
## Meaning vs identity
- Meaning (a state, emphasis, a surface, text) takes semantic tokens, through the Badge or Alert variant that already carries them.
- Identity (a chart series, a tag color, a team, an avatar) may use raw palette: ReUI's own lint keeps it on purpose. Always hand-pair it for dark, as in `text-violet-600 dark:text-violet-400`.
- **Incorrect:** `<span className="text-green-600">Active</span>` (raw palette for a state). **Correct:** `<Badge variant="success-light">Active</Badge>`.
## State recipes
- State badge: `<Badge variant="success-light">` from `@/components/reui/badge` (`@reui/badge`; shadcn's `components/ui/badge` has no `-light` variants or `size`), also `info-light`, `warning-light`, `destructive-light`, and `primary-light` for brand or pending. Neutral badge: `variant="outline"`. A solid `<Badge variant="success">` (white text) only when the user asks for a solid fill.
- Inline status text: `text-success-foreground dark:text-success`, the pair the ReUI Badge uses.
- Tinted surface: `border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success` (the Badge's `success-light` recipe).
- A message block: `<Alert variant="success">` from `@/components/reui/alert` (`@reui/alert`), also `info`, `warning` and `destructive`.
- Never `text-success` or `text-warning` as body text in light mode, and never `text-<state>-foreground` on a solid `bg-<state>` fill.
## Contrast
<!-- gen:contrast:start -->
Light is `text-<tone>-foreground` on `background` and on the `bg-<tone>/10` tint; dark is `dark:text-<tone>` on `background` and on the `dark:bg-<tone>/15` tint. Ratios against the neutral theme, rounded down; body text needs 4.5:1.
| Tone | Light | Light on tint | Dark | Dark on tint |
| ------------- | ----- | ------------- | ---- | ------------ |
| `success` | 9.6 | 8.8 | 8.0 | 6.6 |
| `warning` | 8.6 | 8.1 | 10.3 | 8.1 |
| `info` | 11.0 | 9.7 | 4.4 | 3.9 |
| `destructive` | 8.3 | 7.0 | 6.8 | 5.7 |
Raw state color as light-mode text on `background`: `text-success` 2.4, `text-warning` 1.9, `text-info` 4.4.
<!-- gen:contrast:end -->
Info text in dark mode is under 4.5:1: where it must be read, write the words in `text-foreground` and color only the icon.
## Dark mode
- shadcn uses the class strategy: `@custom-variant dark (&:is(.dark *));` in your global CSS, and `.dark` on `<html>` turns it on.
- Tokens switch by themselves; raw palette needs its `dark:` pair.
- Check both themes before you hand back.
## Charts
Check your theme's `--chart-1` to `--chart-5` in both modes. The current shadcn themes keep the same chart colors in light and dark; some themes (reui.io's own among them) change hue. For a series whose identity must hold in both modes, use a palette var such as `var(--color-violet-500)`, as ReUI blocks do.
<!-- gen:charts:start -->
Same chart colors in light and dark: neutral, stone, zinc, mauve, olive, mist, taupe, amber, blue, cyan, emerald, fuchsia, green, indigo, lime, orange, pink, purple, red, rose, sky, teal, violet, yellow. Changes hue between light and dark: reui.io's own theme.
<!-- gen:charts:end -->
+521
View File
@@ -0,0 +1,521 @@
# ReUI components
The ReUI components: `alert`, `autocomplete`, `badge`, `cascader`, `code-block`, `color-picker`, `data-grid`, `date-selector`, `emoji-picker`, `event-calendar`, `file-type`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `signature-pad`, `sortable`, `stepper`, `time-picker`, `timeline`, `tree`. Examples and blocks are composed from these. `file-upload` is a ReUI component too, shipped as the `use-file-upload` hook rather than a primitive, so it is not in that list: install it as `@reui/use-file-upload`, which writes `hooks/use-file-upload.ts` (import `useFileUpload` from `@/hooks/use-file-upload`), call `get_component("file-upload")` for its API and `get_examples("file-upload")` for its free examples. There is no `@reui/file-upload`: the UI is a `c-file-upload-<n>` example, and one built on the hook installs it.
**Rule one: never guess a component's API.** Call **`get_component(name)`** for its inline `api` and share its `docsUrl` with the user, then **`get_examples(name)`** and copy a worked example. The contracts below are first-try orientation (required props, shape, the one gotcha); the `api` is the reference. A large `api` (`cascader`, `filters`, `data-grid`) is trimmed on heading boundaries: `sectionsOmitted` names what was dropped and the response's `next` hint pulls it back. `validate_usage` always checks the full API. No single block fits? Compose from components, 1 `get_examples` example each ([compose.md](./compose.md)).
## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table with `useTable({ features: dataGridFeatures, ... })`. `dataGridFeatures` already bundles sorting, filtering, pagination, selection, expanding, pinning, resizing and faceting: no row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data: `manualPagination: true` and `rowCount: total` in `useTable`, `recordCount={total}`, and fetch with the table's `pagination` state (`DataGridPagination` changes `pageSize`).
- Columns are TanStack v9, `ColumnDef<DataGridFeatures, Order>[]`, never v8's `ColumnDef<Order>[]`; a sortable column's `header` renders `DataGridColumnHeader` (a string header cannot sort).
- Imports, each from `@/components/reui/data-grid/<file>`: `DataGrid`, `DataGridFeatures` and `dataGridFeatures` from `data-grid`, `DataGridColumnHeader` from `data-grid-column-header`, `DataGridTable` from `data-grid-table`, `DataGridPagination` from `data-grid-pagination`, `DataGridScrollArea` from `data-grid-scroll-area`; `useTable` and `ColumnDef` from `@tanstack/react-table`.
- States: `isLoading` renders skeleton rows from each column's `meta.skeleton` (a `ReactNode`: `<Skeleton className="h-4 w-20" />`); `emptyMessage` (a `ReactNode`) fills a grid with no rows: after a failed fetch, pass it the error with a Retry.
- Numeric column: `meta: { headerClassName: "text-end [&>div]:justify-end", cellClassName: "text-end tabular-nums" }`.
- Rows edited in place: `autoResetPageIndex: false` in `useTable`, or each write jumps back to page 1.
- In a Frame: `<FramePanel className="p-0">` around `DataGridScrollArea` and `DataGridTable`, plus `tableClassNames={{ base: "min-w-[720px]" }}` so on a phone the table scrolls inside the panel instead of squeezing.
```tsx
const columns = useMemo<ColumnDef<DataGridFeatures, Order>[]>(() => [
{ accessorKey: "total", header: ({ column }) => <DataGridColumnHeader title="Total" column={column} /> },
], [])
const table = useTable({ features: dataGridFeatures, data, columns })
<DataGrid table={table} recordCount={data.length}>
<DataGridTable />
</DataGrid>
```
Never a raw `<table>` or hand-rolled pagination: the API covers sticky header, virtualization and row selection.
## event-calendar
**Required:** events via `events`/`onEventsChange` (controlled) or `defaultEvents` (uncontrolled), plus a height on the root.
**Shape:**
```tsx
<EventCalendar defaultEvents={events} defaultView="month" className="h-[560px]">
<EventCalendarNav />
<EventCalendarContent />
</EventCalendar>
```
**Gotcha:** headless-first: `EventCalendarContent` renders the active view (month/week/day/days/agenda; a resource view activates when `resources` is passed) - there is no per-view JSX to compose. Events are `{ id, title, start, end (exclusive), allDay?, color?, recurrence?, resourceId? }`. Mutations flow through `onEventUpdate`/`canDropEvent` (return `false` to reject); the root needs an explicit height because it is a min-h-0 flex column.
## gantt
**Required:** `resources` (the left tree) plus bars via `events`/`defaultEvents` attached by `resourceId`.
**Shape:**
```tsx
<Gantt defaultEvents={bars} resources={tasks} defaultScale="month" className="h-[480px]">
<GanttNav />
<GanttView />
</Gantt>
```
**Gotcha:** bars move along the time axis only (never across rows) and are all-day spans with exclusive `end`; `progress` is 0-100. Scales are `day | week | month | quarter | year`. Zoom control, infinite scroll, summary rollups, and row checkboxes are ON by default - turn off what you do not need. Same `onEventUpdate`/`canDropEvent` commit pipeline as `event-calendar`; the root needs an explicit height.
## kanban
**Required:** `value` (`Record<string, T[]>`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Kanban value={cols} onValueChange={setCols} getItemValue={(i) => i.id}>
<KanbanBoard>
{Object.entries(cols).map(([id, items]) => (
<KanbanColumn key={id} value={id}>
<div className="flex items-center justify-between">
<h3>{id}</h3>
<KanbanColumnHandle aria-label="Move column"><GripVertical /></KanbanColumnHandle>
</div>
<KanbanColumnContent value={id}>
{items.map((i) => (
<KanbanItem key={i.id} value={i.id}>
<KanbanItemHandle>{i.title}</KanbanItemHandle>
</KanbanItem>
))}
</KanbanColumnContent>
</KanbanColumn>
))}
</KanbanBoard>
<KanbanOverlay><div className="bg-muted size-full rounded-md" /></KanbanOverlay>
</Kanban>
```
**Gotcha:** state is `Record<columnId, T[]>`. Each `KanbanColumnContent value` must match its parent `KanbanColumn value`. Omit `KanbanOverlay` and the drag preview silently breaks. `KanbanColumnHandle` is hidden until its column is hovered or it has keyboard focus, so keep the column title outside it.
## sortable
**Required:** `value` (`T[]`), `onValueChange`, `getItemValue`
**Shape:**
```tsx
<Sortable value={items} onValueChange={setItems} getItemValue={(i) => i.id}>
{items.map((i) => (
<SortableItem key={i.id} value={i.id}>
<SortableItemHandle><GripVertical /></SortableItemHandle>
{i.label}
</SortableItem>
))}
</Sortable>
```
**Gotcha:** a flat 1D reorder list (not columns - that is `kanban`). `getItemValue` must return a stable, unique string. Pass `layout="grid"` or `layout="nested"` for non-list layouts.
## filters
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()`: ids come from `createFilterIdFactory(seed)` seeded off `useId` (never minted in render), and `createFilterRule({ id })` takes one, `createFilterQuery()` none. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string, and `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches the current level and everything under it, `searchScope="global"` the whole tree from any level, and both annotate results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `virtualize` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels (paging, retry, `prefetch`). `CascaderFooter` pins commands below the list and `CascaderSubmenu` opens one as a side flyout. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs.
## date-selector
**Required:** none, but wire `onChange` to capture the value.
**Shape:**
```tsx
const [value, setValue] = useState<DateSelectorValue | undefined>()
<DateSelector value={value} onChange={setValue} label="Due date" />
```
**Gotcha:** the value is a structured `DateSelectorValue` (period / operator / start+end dates), NOT a `Date` - never pass a raw `Date`. Use `allowRange={false}` to lock single-date picking. Read `get_component("date-selector")` for the value shape.
## tree
**Required:** `tree` (a `@headless-tree/core` instance you construct)
**Shape:**
```tsx
<Tree tree={tree}>
{tree.getItems().map((item) => (
<TreeItem key={item.getId()} item={item}>
<TreeItemLabel />
</TreeItem>
))}
</Tree>
```
**Gotcha:** `Tree` is a styled shell - it takes a headless-tree instance via `tree`, NOT `data`/`items` props. Build the instance with `@headless-tree/react`. External API: https://headless-tree.lukasbach.com/
## stepper
**Required:** `StepperItem step` (number), `StepperContent value` (number)
**Shape:**
```tsx
<Stepper defaultValue={1}>
<StepperNav>
<StepperItem step={1}>
<StepperTrigger><StepperIndicator>1</StepperIndicator></StepperTrigger>
<StepperSeparator />
</StepperItem>
<StepperItem step={2}>
<StepperTrigger><StepperIndicator>2</StepperIndicator></StepperTrigger>
</StepperItem>
</StepperNav>
<StepperPanel>
<StepperContent value={1}>Step 1 content</StepperContent>
<StepperContent value={2}>Step 2 content</StepperContent>
</StepperPanel>
</Stepper>
```
**Gotcha:** steps are 1-indexed. Without `StepperPanel` + `StepperContent` you render the nav trail but no body. Put `StepperSeparator` in every `StepperItem` except the last.
## timeline
**Required:** `TimelineItem step` (number)
**Shape:**
```tsx
<Timeline>
<TimelineItem step={1}>
<TimelineHeader>
<TimelineDate>March 2024</TimelineDate>
<TimelineTitle>Project initialized</TimelineTitle>
</TimelineHeader>
<TimelineIndicator />
<TimelineSeparator />
<TimelineContent>Repo and architecture set up.</TimelineContent>
</TimelineItem>
</Timeline>
```
**Gotcha:** each item needs a unique `step`. `orientation` is `"vertical"` (default) or `"horizontal"`. This is a static event display, not interactive like `stepper`.
## autocomplete
**Required:** `items` (array; each item has at least `value`)
**Shape:**
```tsx
<Autocomplete items={items}>
<AutocompleteInput placeholder="Search..." />
<AutocompleteContent>
<AutocompleteEmpty>No results found.</AutocompleteEmpty>
<AutocompleteList>
{(item) => (
<AutocompleteItem key={item.value} value={item}>{item.label}</AutocompleteItem>
)}
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
```
**Gotcha:** `AutocompleteList` takes a render-prop `(item) => ReactNode`, NOT a mapped array of children. External API: https://base-ui.com/react/components/autocomplete
## phone-input
**Required:** none, but wire `onChange`.
**Shape:**
```tsx
<PhoneInput placeholder="Enter phone number" defaultCountry="US" value={value} onChange={setValue} />
```
**Gotcha:** `value`/`onChange` use an E.164 string (e.g. `"+14155551234"`), not a display-formatted string; `onChange` can fire `undefined`. `defaultCountry` is a 2-letter ISO code. Wraps `react-phone-number-input`.
## number-field
**Required:** wrap the controls in `NumberFieldGroup`.
**Shape:**
```tsx
<NumberField defaultValue={0}>
<NumberFieldScrubArea label="Quantity" />
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
```
**Gotcha:** import from `@/components/reui/number-field`. The accessible label goes on `NumberFieldScrubArea`, not `NumberField`. External API: https://base-ui.com/react/components/number-field
## rating
**Required:** `rating` (number)
**Shape:**
```tsx
<Rating rating={4.5} showValue editable onRatingChange={setRating} />
```
**Gotcha:** supports decimals (partial stars). Pass `editable` + `onRatingChange` for interactive input; omit both for a read-only display.
## scrollspy
**Required:** `targetRef` (the scroll container ref)
**Shape:**
```tsx
<Scrollspy targetRef={containerRef}>
<a href="#s1" data-scrollspy-anchor="s1">Section 1</a>
<a href="#s2" data-scrollspy-anchor="s2">Section 2</a>
</Scrollspy>
<div ref={containerRef}>
<div id="s1">...</div>
<div id="s2">...</div>
</div>
```
**Gotcha:** each link's `data-scrollspy-anchor` must match a section `id`. `targetRef` is the scrollable container (defaults to the window). `smooth` defaults to true: pass `smooth={!reduceMotion}` from motion/react's `useReducedMotion()`. Mark the active link `aria-current="location"` (track it with `onUpdate`); `"page"` is for route links.
## frame
**Required:** `Frame` > `FramePanel`
**Shape:**
```tsx
<Frame>
<FrameHeader>
<FrameTitle>Title</FrameTitle>
<FrameDescription>Description</FrameDescription>
</FrameHeader>
<FramePanel>Content</FramePanel>
<FrameFooter>Footer</FrameFooter>
</Frame>
```
**Gotcha:** the shell for data and tool-like surfaces. `spacing` (`xs` | `sm` | `default` | `lg`) sets panel padding and the gap between panels, so pick it instead of padding classes (the 1 exception: `<FramePanel className="p-0">` around a `data-grid`); `variant` is `default` | `inverse` | `ghost`; `stacked` joins panels with shared borders; `dense` removes the frame's own inset and gap (panels sit flush), and panel padding stays; `--frame-radius` is the one radius knob (panels derive `--frame-panel-radius`).
## icon-stack
**Required:** one child icon
**Shape:**
```tsx
<IconStack aria-hidden="true">
<InboxIcon className="size-4" />
</IconStack>
```
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## code-block
**Required:** `code` + `language`, or pre-highlighted `lines`.
**Shape:**
```tsx
<CodeBlock code={code} language="tsx" />
<CodeBlock code={code} language="tsx" showLineNumbers maxLines={20}>
<CodeBlockHeader>
<CodeBlockTitle>use-totals.ts</CodeBlockTitle>
<CodeBlockLanguage />
<CodeBlockCopyButton className="ml-auto" />
</CodeBlockHeader>
<CodeBlockExpandButton />
</CodeBlock>
```
**Gotcha:** the one-liner is already a complete block; every child is optional chrome, at any depth. Shiki is the only npm dependency and loads lazily on first highlight; `lines` (from `highlightCode` in `code-block-highlight`, which is server-safe and has no `"use client"`) or `highlight={false}` loads nothing at all. `maxLines` caps the height AND marks the block collapsible, which is what makes `CodeBlockExpandButton` appear. Use `variant="ghost"` when the block sits inside a surface that already has a border.
## alert
**Required:** `Alert` > `AlertTitle`
**Shape:**
```tsx
<Alert variant="success">
<ShieldCheckIcon />
<AlertTitle>Security update</AlertTitle>
<AlertDescription>Enable two-factor authentication.</AlertDescription>
<AlertAction><Button size="xs">Update</Button></AlertAction>
</Alert>
```
**Gotcha:** shadcn-compatible API. `variant`: `default | destructive | info | success | warning | invert`. The non-default variants use ReUI extended color tokens (`--success`/`--info`/`--warning`/`--invert`), which the install adds.
## badge
**Required:** none (text child).
**Shape:**
```tsx
<Badge variant="success-light">Success</Badge>
<Badge variant="outline">Draft</Badge>
```
**Gotcha:** shadcn-compatible. Rich `variant` set (solid, `-outline`, `-light` per color), `size` `xs..xl` (keep the default), `radius` `default | full`. Like `alert`, the color variants rely on ReUI extended tokens. Prefer `Badge` variants over raw color classes: `<tone>-light` for a state, `outline` for a neutral label; an omitted `variant` is the solid primary fill.
## color-picker
**Required:** none (`<ColorPicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<ColorPicker value={color} onValueChange={setColor} alpha>
<ColorPickerTrigger />
<ColorPickerContent>
<ColorPickerPanel>
<ColorPickerArea />
<ColorPickerSlider channel="hue" />
<ColorPickerSlider channel="alpha" />
<ColorPickerFields />
</ColorPickerPanel>
</ColorPickerContent>
</ColorPicker>
```
**Gotcha:** the value is a CSS color string in `format` (`"hex"` default, `"rgb"`, `"hsl"`, `"hsb"` or `"oklch"`), `null` when empty, never an object. `onValueChange` fires on every drag move, so save in `onValueCommit`. Without `alpha` the value is always opaque. A bare `ColorPickerPanel` inside the root is an inline picker; `ColorPickerInput` replaces the trigger with a typable field.
## emoji-picker
**Required:** none; wire `onEmojiSelect` to insert a pick, or `value` + `onValueChange` to use it as a field.
**Shape:**
```tsx
<EmojiPicker onEmojiSelect={({ value }) => insert(value)}>
<EmojiPickerTrigger />
<EmojiPickerContent>
<EmojiPickerHeader>
<EmojiPickerSearch />
</EmojiPickerHeader>
<EmojiPickerCategories />
<EmojiPickerList />
<EmojiPickerFooter>
<EmojiPickerPreview />
<EmojiPickerSkinTone />
</EmojiPickerFooter>
</EmojiPickerContent>
</EmojiPicker>
```
**Gotcha:** the emoji data is fetched at runtime from a pinned jsDelivr path (`EMOJI_PICKER_DATA_URL`): under a strict Content Security Policy allow it in `connect-src`, self-host with `dataUrl`, or pass `data` (from `normalizeEmojibaseData`). Store the pick's `value` (the native emoji in its skin tone, or `:shortcode:` for a custom one). `multiple` makes the value a list, capped by `max`.
## file-type
**Required:** none; pass `type` (an extension, a family name or any string).
**Shape:**
```tsx
<FileType type="pdf" />
<FileType type={getFileType(file.name)} size="sm" aria-hidden="true" />
```
**Gotcha:** one `span` with `role="img"`, no children, no `"use client"` (fine in a Server Component). Resolve a file name, path, URL or MIME type with `getFileType`, never by splitting on the dot. `variant`: `outline` (default) | `solid` | `light` | `mono`; `size`: `mini` | `xs` | `sm` | `default` | `lg` | `xl` (18 to 56px); recolor with a `text-*` class. Pass `aria-hidden="true"` when the file name sits beside it.
## signature-pad
**Required:** `SignaturePad` > `SignaturePadArea`
**Shape:**
```tsx
<SignaturePad value={strokes} onValueChange={setStrokes}>
<SignaturePadArea>
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
```
**Gotcha:** the value is an array of strokes (`{ points: [x, y, size][], color? }`), not an image: store it as JSON, and export with `useSignaturePad()` or `apiRef` (`toSVG`, `toDataURL`, `toBlob`) or `SignaturePadSave` (`format`, `onSave`). `onValueChange` fires per finished stroke, never per pointer move. Size the area with a class (`h-40` by default); an overlay in it that is not a control needs `pointer-events-none`. Drawing needs a pointer, so offer a typed name where a signature is required.
## time-picker
**Required:** none (`<TimePicker />` alone is complete); wire `value` + `onValueChange`.
**Shape:**
```tsx
<TimePicker value={time} onValueChange={setTime}>
<TimePickerTrigger />
<TimePickerContent>
<TimePickerColumns />
<TimePickerFooter>
<TimePickerNow />
<TimePickerClear />
<TimePickerConfirm />
</TimePickerFooter>
</TimePickerContent>
</TimePicker>
```
**Gotcha:** the value is a 24-hour `"HH:mm"` string (`"HH:mm:ss"` with `granularity="second"`) or `null`, never a `Date`, whatever `hourCycle={12}` shows. `minuteStep`, `min`, `max` and `isTimeDisabled` narrow the options; keep `isTimeDisabled` stable (module scope or memoized). `requireConfirm` holds picks as a draft until OK. `TimePickerInput` replaces `TimePickerTrigger` for a typable field, never beside it.
## base vs radix - write for the project's base
Every component ships a `base` (Base UI) and a `radix` (Radix UI) build under 1 name, and the CLI installs the one the project uses. Read `components.json` `style`: the part before the first `-` is the base (`base-nova` is **Base UI**, `radix-nova` is **Radix UI**). Write against that base; the deltas mirror shadcn's:
- Slot/composition: Base UI `render={<… />}` vs Radix `asChild`.
- `Select`: Base UI takes `items`; Radix uses `<SelectItem>` children.
- `ToggleGroup`: Base UI `multiple` boolean vs Radix `type="single" | "multiple"`.
Installed files and `c-*` examples are already in your base: reuse their wiring. When an `api` or example shows the other base's shape, translate it (or `validate_usage` to confirm).
+62
View File
@@ -0,0 +1,62 @@
# Compose a screen
Read this before you build any screen or section, then write the router's 7-line plan in your reply (Surface, Archetype, Container family, Density, Regions, States, Motion) and build exactly that plan. Style it with the topics the screen touches: [layout.md](./layout.md) (house defaults), [color.md](./color.md), [motion.md](./motion.md).
If the brief names a layout or a motion (a left nav, 2 entrances), follow the brief and name the default you overrode. Several entrances run as 1 stagger: at most 6 items, 50ms apart, never on table rows or lists of 20 or more.
## Surface
- **Operational**: a screen people work in every day (dashboard, records, settings, admin tools). Frame-led, dense (`size="sm"` on controls that take it), no hero, no page-load motion.
- **Marketing**: a page a visitor reads once (landing, pricing, features). Card-led sections on the spine below, default density.
- **Flow**: a task people finish and leave (sign in, onboarding, checkout). 1 centered Card or a Dialog, 1 primary action per step.
## Archetypes: pick 1 per screen
- **Vertical rail settings** (3 to 6 sections): a section rail on the left, 1 content column, 1 section at a time; the rail turns horizontal below `md`.
- **Horizontal tabs** (5 to 8 views of 1 object): a full-width `Tabs` rail under the header that scrolls sideways when it overflows, 1 content column.
- **Frame stack**: stacked Frames, each 1 focused group of settings or records; a flat page split by `Separator` rows when it should feel lighter.
- **Table-backed management**: header, toolbar (search, `filters`), then a `data-grid` with row actions and pagination, all in 1 Frame.
- **Dashboard grid**: a KPI row, then 1 or 2 chart or list panels, then the table.
- **Board**: header and filters, then a `kanban` board; `KanbanBoard` stacks its columns below `sm`, and a wider board scrolls inside its own region.
- **Record detail**: header (title, status `Badge`, actions), a main column of sections, a side column of facts that stacks under it below `lg`.
- **Marketing spine**: optional `Badge` eyebrow, headline (the only loud element), 1-clause subhead, action row (1 primary `Button`, a second `variant="outline"`, a third a text link), specific proof beside it ("27 reviews"), media. Omit parts; never reorder them.
- **Auth card**: 1 centered `Card` at `max-w-sm`: title, fields, 1 full-width primary `Button`, a text link to the other flow.
- **Wizard dialog**: a `Dialog` holding a `stepper`, 1 step visible, Back (`variant="outline"`) and Next (primary) in its footer.
An app shell around any of them is the shadcn `Sidebar`, which turns into a `Sheet` below `md` by itself.
## Fill each region in this order
1. The ReUI component that owns the job: the router's Owners (`data-grid`, `filters`, `kanban`), `useFileUpload` from `@/hooks/use-file-upload` for file drop, or the one whose contract in [components.md](./components.md) matches.
2. A shadcn/ui component (`Tabs`, `Dialog`, `AlertDialog`, `Sheet`, `Empty`, `Skeleton`, `sonner`).
3. A free `c-*` example from `get_examples` that already composes it: install it and adapt.
4. Your own composition of those parts. Never a hand-rolled table, modal or tabs.
Import ReUI parts file by file: `data-grid`, `filters`, `cascader`, `event-calendar` and `gantt` are folders with no index file. `data-grid`: the import line in [components.md](./components.md). `filters`, each from `@/components/reui/filters/<file>`: `Filters` from `filters`, `createFilterQuery` and `flattenFilterConditions` (1 argument, the query) from `filters-query`, the types `FilterField` and `FilterQuery` from `filters-types`.
## Wiring that works
- `data-grid`: columns are `ColumnDef<DataGridFeatures, Order>[]`, a sortable header renders `DataGridColumnHeader`, and server paging needs `manualPagination: true` and `rowCount`.
- `filters`: apply each condition by its `operator`. Negate swaps a rule to its inverse (`is` and `is_not`, `contains` and `not_contains`, `is_any_of` and `is_none_of`, `empty` and `not_empty`) and sets `negated` only when there is none: honour both.
- A Base UI `Button` that navigates: `<Button nativeButton={false} render={<Link href="/x" />}>`.
- shadcn's `CommandDialog` has no `Command` inside, unlike older shadcn: wrap `CommandInput` and `CommandList` in one. `CommandItem` search matches its `value`: omit it (text is used) or use the label; ids in `onSelect`.
## Headers
A page or panel header puts the title (1 to 3 words) and a 1-clause description on the left and the actions on the right: 1 primary `Button`, the rest `variant="outline"`, row actions `variant="ghost"`.
## Responsive
- No horizontal page scroll at 390px: grids drop to 1 column, side columns stack, `min-w-0` on every shrinking parent.
- A KPI row is `grid-cols-1 sm:grid-cols-2 xl:grid-cols-4`.
- Every header and toolbar action stays reachable at every width: a control hidden below a breakpoint moves into a menu shown only there.
- A table scrolls sideways inside its Frame, never the page (the `data-grid` contract in [components.md](./components.md)).
## States and motion
- Each data region owns its states: empty (shadcn `Empty` with a title, 1 line and the primary action), loading (`Skeleton` in the real layout; `data-grid` takes `isLoading`) and error (inline in the region, with Retry). Never 1 spinner for the whole page.
- Motion: none, or the 1 entrance per view; installed parts keep their own motion, and hover and press follow [motion.md](./motion.md). An Alert, field error, notice or data that appears after an action or a load is a reveal, not an entrance: fade it in with the reveal row.
## Remove first
Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments. Add a region only when the brief needs it.
+43
View File
@@ -0,0 +1,43 @@
# Icons (ReUI delta over shadcn)
Use the project's configured `iconLibrary`, put `data-icon="inline-start"` on an icon before the label inside `Button` (`data-icon="inline-end"` on one after it), put no sizing classes on icons inside components, and pass icons as components, not string keys. ReUI adds the following.
## Portable icons (library-agnostic)
ReUI components, examples, and blocks are authored to be icon-library-agnostic. The shadcn CLI installs each item's icons in the library named by `iconLibrary` in `components.json` - you swap nothing. When `iconLibrary` is not set, the CLI falls back to `lucide`, so the icons arrive as `lucide-react` imports. If that is not the project's library, set `iconLibrary` before installing, or change the **import source and component name** to your library, keeping the same icon-name semantics (each key below is an `iconLibrary` value):
- `lucide` -> `lucide-react`
- `tabler` -> `@tabler/icons-react`
- `phosphor` -> `@phosphor-icons/react`
- `remixicon` -> `@remixicon/react`
- `hugeicons` -> icons from `@hugeicons/core-free-icons`, rendered through `HugeiconsIcon` from `@hugeicons/react`
Don't assume `lucide-react`: read `iconLibrary` from `components.json`, and when it is unset, check which icon package the project already uses.
## Keep icons purposeful
Icons support the hierarchy, they don't replace it: keep them small, matched to the surrounding density, and decorative ones `aria-hidden="true"` (an icon-only control still needs an accessible label on the control). Don't add ornamental icons that do no job.
## Motion Icons (the `@reui/icons/...` set)
ReUI ships its own icon set in 4 styles (outline, solid, duotone, filled). Every icon installs from the default path; some also have a real hover animation (motion/react) on the animated path:
```bash
npx shadcn@latest add @reui/icons/default/<style>/<name> --yes # static, every icon
npx shadcn@latest add @reui/icons/animated/<style>/<name> --yes # hover-animated, only when the icon has installAnimated
```
The animated path answers for every icon, but for an icon without a real animation it installs the same static drawing, so only use it when the icon carries `installAnimated`.
Motion Icons need an Ultimate license for **both discovery and install** (`REUI_LICENSE_KEY`, see [cli.md](./cli.md)). Without it `search_icons` comes back `locked` with no results and `search` returns no icons at all, so check the plan before you promise the user icons: on a free or Pro account, use the project's own `iconLibrary` instead. On Ultimate, a Motion Icon on the primary action is the 1 hover flourish (see [motion.md](./motion.md)).
Finding icons:
- Several icons (the common case): **`search_icons(concepts[])`** - up to 24 concepts in one call, the best icons per concept with install commands. Pass `animated: true` to get only icons with a real hover animation; a concept with none comes back empty and `animatedNote` says so.
- One icon: `search` with `type: "icon"`.
- Icon results and `get_icon` carry `animated: true` and `installAnimated` only when the icon really animates - use those install strings, do not construct paths by hand.
- Every icon result carries a `previewUrl` (the icon's own page) and a `thumbnail` (its 4 styles side by side) - **show or share them** so the user can SEE the icon before installing, and look at the thumbnail yourself before trusting a name.
- Several icons can fit one concept. Before installing, read `get_icon`'s `whenNotToUse` (each entry names the icon to use instead), and use its `label` as the `aria-label` of an icon-only button.
- Icon names were revised in the icons v2 update. An old name (`save-2`, `abstract-18`) still answers in `get_icon`, `get_install_command`, `validate_usage` and `get_thumbnail` with the icon it became and `renamedFrom`, and still installs that same drawing; write the current name in new code. That includes names renamed because they misdescribed the drawing (`watch` installs a sand timer, `chevrons-down` points up), so look at the `thumbnail` before trusting an old name. `search_icons` answers an old name the same way, unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word, and `oldNameNotes` names the icon the old name installs. `search` ranks by current names only, so look an old name up with `get_icon`, never `search`. Only `tag` (it drew a money bag) no longer answers: the reply names the icons to use instead.
The `icon-stack` component composes multiple icons into a stacked display.
+56
View File
@@ -0,0 +1,56 @@
# Layout: house defaults, hierarchy, spacing, surfaces and states
## Point of view
Pick an emotional register before you compose - calm, operational, premium, editorial, dense, energetic - and let layout, spacing, surface treatment, and icon behavior all reinforce it. One or two memorable decisions and restraint everywhere else beats ten generic ones. UI with no point of view reads as generated.
**The bar:** before you finish, ask: would a product team keep this instead of replacing it? Does it still feel strong after swapping in real content? If not, reuse the shipped ReUI design harder - don't restyle it into something new - then run the [quality.md](./quality.md) gates.
## House defaults
<!-- gen:house-defaults:start -->
- **Spacing ladder**: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections, `gap-0.5` title to description. Deviate when: skip a level if you like, never invert them.
- **Panel title**: no size class on `FrameTitle` or `CardTitle`. Deviate when: never; `FrameTitle` ships `text-sm font-semibold`, `CardTitle` follows the installed style.
- **Meta text**: `text-xs`. Deviate when: never smaller, except inside illustrative mini-UI artwork.
- **Frame spacing**: `spacing="default"`, written out. Deviate when: `sm` in a sheet, dialog, drawer or popover, or for a dense data grid in a panel; `xs` or `lg` only with a stated reason.
- **Leading row icon**: `size-4`. Deviate when: never in a glyph column; `Spinner` ships `size-4`, so a loading swap keeps the width.
- **Icon inside a control**: no size class; the control sizes it per style and size. Deviate when: never pin `size-*` on an svg inside `Button`, `InputGroupButton`, `InputGroupAddon`, `DropdownMenuItem`, `DropdownMenuCheckboxItem`, `DropdownMenuRadioItem`, `DropdownMenuSubTrigger`, `SelectItem`, `CommandItem`, `Toggle`, `ToggleGroupItem`, `IconTile` or `MarkerIcon`.
- **Badge size**: `default` (leave `size` off). Deviate when: `lg` or `xl` only when the user asks; never `sm` or `xs`.
- **Neutral badge**: `variant="outline"`. Deviate when: never `secondary`, `default` or `primary-light` for an id, tag, version, count or Draft/Beta label; inline after the title, never `ml-auto`.
- **State badge**: `<tone>-light`: `success-light`, `info-light`, `warning-light`, `destructive-light`; `primary-light` for brand or pending. Deviate when: never the solid tone, `secondary` or `outline` for a state; 1 state badge per row.
- **Solid badge**: never unless asked; always name a variant (an omitted one is the solid primary fill). Deviate when: only when the user asks for a solid fill (`default`, `secondary`, `success`, `info`, `warning`, `destructive`, `focus`, `invert`).
- **Slot padding**: the primitive's own inset; set density with `Frame spacing=` or `Card size="sm"`. Deviate when: never a padding class on `CardHeader`, `CardContent`, `CardFooter`, `FrameHeader`, `FramePanel`, `FrameFooter`, `SheetHeader`, `SheetContent`, `SheetFooter`, `DialogHeader`, `DialogContent`, `DialogFooter`, `PopoverContent`, `DropdownMenuContent`, `TabsContent` or `AccordionContent`.
- **Inline separator**: `<span aria-hidden className="bg-muted-foreground/40 size-1 shrink-0 rounded-full" />`. Deviate when: never a typed `·` between rendered segments; a literal dot only inside 1 data string.
- **List rows**: dense, auto height; a row with no second line stays 1 tight line. Deviate when: never uniform height.
- **Current list item**: ghost hover fill (`bg-muted`, `dark:bg-muted/50`) plus `aria-current`. Deviate when: never a dot or a check mark.
<!-- gen:house-defaults:end -->
## Hierarchy
- 1 focal point per card or panel: the dominant metric or task, then its label, then detail. Borders, separators and surfaces make 2 to 3 bands, never equal weight everywhere.
- Overloaded? Remove, in order: decorative summaries, duplicate context, extra badges, long descriptions, secondary actions, mismatched icon treatments.
## Spacing and copy
- Siblings take the parent's `gap-*`, never their own margins; a heading sits nearer its own content than the section above it. Never pad an operational surface like a marketing page.
- Titles 1 to 3 words, a description 1 clause (about 8 words), a context line 2 segments at most, labels and buttons 1 to 2 words, a toast title 4 words at most. Copy that wraps at desktop width is over budget: cut it, never shrink the type.
## Surfaces
- `Frame` for data and operational surfaces (tables, lists, tool panels); `Card` for bounded sections and marketing.
- Prefer 1 container family per screen. With a KPI row and a table, use FramePanels for both, or Cards for the KPIs and a Frame for the table; never a Card inside a Frame or a Frame inside a Card.
- A `data-grid` inside a Frame is the one place for `<FramePanel className="p-0">`; elsewhere no padding on slots. This is the 1 exception to the Slot padding row above.
- 1 archetype per screen (operational ones and the marketing spine): [compose.md](./compose.md) lists each with its regions.
## Type and buttons
- A KPI value is `text-2xl font-semibold tracking-tight tabular-nums`.
- `size="sm"` on dense surfaces, `variant="outline"` for secondary actions, `ghost` for row actions; 1 primary per region, 1 variant per toolbar.
- A toolbar separator is `<Separator orientation="vertical" className="my-auto h-4" />`; at a 390px viewport a toolbar stays on 1 row.
## States and responsive
- Empty: a short message plus the primary action. Loading: a `Skeleton` in the real layout, never a centered spinner. Error: inline with a retry.
- No horizontal page scroll at 390px: reflow to 1 column instead of shrinking; `min-w-0` on the shrinking parent and `truncate` on single-line labels.
+80
View File
@@ -0,0 +1,80 @@
# Motion
## Installed motion: never retime it
<!-- gen:installed-motion:start -->
| Installed part | Duration |
| ------------------ | ----------------- |
| Overlays | 100ms |
| Tooltip | 150ms (default) |
| Sheet, sidebar | 200ms |
| Navigation menu | 300ms, exit 150ms |
| rhea control focus | 200ms |
<!-- gen:installed-motion:end -->
Never add or change `duration-*`, `ease-*`, `animate-*` or `transition-*` on installed parts (`components/ui`, `components/reui`); Button already presses with `active:not-aria-[haspopup]:translate-y-px`.
## House motion: your own code
<!-- gen:house-motion:start -->
| Use | Tailwind |
| ------------------------- | ------------------------------------------------------- |
| Hover, press, color | `transition-colors` (150ms, no duration or ease class) |
| Reveal or expand | `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]` |
| Large surface (ceiling) | `duration-300` with the same curve |
| An overlay you build | `duration-100` with the same curve |
| Media hover zoom | `duration-500` with the same curve, `scale-105` at most |
| Width tied to the sidebar | `duration-200 ease-linear` |
<!-- gen:house-motion:end -->
- Press on a custom surface (not Button): `transition-transform active:scale-[0.98] motion-reduce:transition-none`.
- Hover color plus press on 1 element: `transition-[color,background-color,border-color,scale]` (`transition-colors` and `transition-transform` override each other; `scale-*` sets `scale`).
- motion/react takes the same values in seconds: `{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }`; hover `{ duration: 0.15 }`.
- Drag, reorder and layout use `const SPRING = { type: "spring", visualDuration: 0.25, bounce: 0 }`. Define `SPRING` only when something drags, reorders or toggles layout.
## Hard rules
- Animate `transform` and `opacity`; color only for hover and press. Never `transition-all`; width, height, top or left only in the sidebar row.
- Never `animate-bounce` or a spring with bounce above 0. `ease-linear` only for progress, spinners, loops and the sidebar; never Tailwind's `ease-in`, `ease-out` or `ease-in-out`.
- `motion/react` only: never framer-motion, GSAP or react-spring.
- At most 1 entrance animation per view (a stagger counts as 1), and every animation has a reduced-motion path. Feedback that appears after an action or a load (an Alert, a field error, a saved notice, data replacing its `Skeleton`) is a reveal, not an entrance: fade it in with the reveal row.
- Never add named `duration-*` utilities or redefine `--ease-out`.
- Loading: a `Skeleton` (it already pulses) in the real layout; a spinner only inside a busy button.
- Motion Icons (Ultimate only): 1 on the primary action is the 1 hover flourish; install it with its `installAnimated` command; never retime it.
## Performance
Each line names its lint rule, or judgement (no check).
- Never animate blur, drop-shadow, backdrop-filter, box-shadow, border-radius, background-position, background-size or a custom property that feeds transform; clip-path and stroke-dashoffset only as a one-shot reveal of 300ms or less, or a loop on an element of 240x240px or less. Name the properties: never a bare `transition` (it also animates box-shadow and filter). (`reui/no-paint-animation`; 240px: judgement (no check))
- A hover lift is `transform` over a static shadow, or a shadow layer whose `opacity` fades; never a transitioned `hover:shadow-*`. (`reui/no-paint-animation`)
- CSS first: hover, press, reveals and ambient loops are Tailwind transitions, `tw-animate-css` or `@keyframes`. motion/react only for exit on unmount, drag, springs that follow input, `layout` and scroll-linked values. (judgement (no check))
- In motion/react, `x`, `y`, `scale`, `rotate`, `pathLength` and every SVG target run on the main thread: never `repeat: Infinity` on them (loop with CSS `@keyframes`), and keep 20 or fewer JS-driven animations running per view. An opacity loop leaves the main thread only on an HTML element without `repeatDelay`, `repeatType: "mirror"` or `onUpdate`. (`reui/no-main-thread-loop`)
- SVG geometry (a rect's `width`, a circle's `r`) does not reflow the page but runs in motion's JS loop: one-shot only. (`reui/no-main-thread-loop`)
- Never set React state per frame (requestAnimationFrame, `useAnimationFrame`, `onUpdate`, scroll or pointermove listeners): bind styles to `useMotionValue`, `useTransform` or a CSS variable on the element. (`reui/no-frame-setstate`)
- A JS-driven loop (motion `repeat`, requestAnimationFrame, canvas) pauses offscreen with `useInView` or IntersectionObserver; `content-visibility: auto` never stops script, and a setInterval loop also pauses on `visibilitychange`. (`reui/loop-offscreen`)
- No permanent `will-change`: no `will-change-*` class or `willChange` style, except during a drag, removed when it ends. (`reui/will-change`)
- Stagger 50ms or less per step (`staggerChildren: 0.05`), only the first 6 items, the whole entrance done in 500ms, first mount only, never on table rows or lists of 20 or more; scroll reveals use `whileInView` with `viewport={{ once: true }}`. (`reui/stagger-budget`; item count and tail: judgement (no check))
- A height reveal (`grid-template-rows`) starts only on a click or key press, runs 200ms or less, 1 at a time; never `initial={{ height: 0 }}`. (`reui/no-layout-transition`)
- The motion `layout` prop on 1 list at a time (about 20 elements), never in data-grid or virtualized rows; drag and drop goes through `@reui/sortable` or `@reui/kanban`. (judgement (no check))
- AnimatePresence `mode="wait"` only with an exit of 150ms or less; `initial={false}` around content present at first render. (`reui/stagger-budget`)
- Never hide the hero or the largest heading behind a motion/react `initial` at opacity 0 (hidden until JavaScript runs). (judgement (no check))
- At most 3 ambient loops per view: CSS `@keyframes` on transform or opacity, a period of 2s or more, exempt from the duration table; once 3 run in a view they pause offscreen (a `content-visibility: auto` wrapper with `contain-intrinsic-size: auto <h>` where nothing paints outside it, or IntersectionObserver toggling `animation-play-state`). At most 50 animating elements per view (Skeletons, loaders and CSS opacity loops not counted). A static blur above 12px, at most 1 per view, only on a surface that neither moves nor sits over motion. (judgement (no check))
## Reduced motion
- Reduced motion is a substitute, not an off switch: replace movement (translate, scale, rotate, slide, zoom, layout, parallax, auto-scroll) with an opacity fade of 150ms or less or an instant change. Keep color and opacity feedback, focus, progress bars, loaders, the Skeleton pulse, chevrons and the switch thumb. (`reui/reduced-motion`)
- The reduced state is the resting state: content never waits at opacity 0, hidden or offset for an animation that will not play; a count-up shows its final value. (`reui/reduced-motion`)
- In every mode an entrance moves 8px or less and starts at scale 0.95 or more; nothing moves a surface larger than half the viewport. (`reui/reduced-motion`; half the viewport: judgement (no check))
- `<MotionConfig reducedMotion="user">` once at the app root (its default ignores the OS) AND a `motion-reduce:` path on every CSS animation (MotionConfig never reaches CSS). An example or block wraps its own motion subtree. (`reui/reduced-motion`)
- MotionConfig makes only transform keys and width, height, top and left instant; opacity, filter, clipPath, pathLength, color and a `transform` string keep animating, so a loop or those keys branch on `useReducedMotion()` and rest on the final value. (`reui/reduced-motion`)
- `useReducedMotion()` is null on the server: never let it pick markup (JSX, className, `initial`) or reach `animate` under `initial={false}`; branch only animate, transition, `while*` or exit values. (`reui/reduced-motion-ssr`)
- No SMIL (`<animate>`, `<animateMotion>`, `<animateTransform>`); canvas and requestAnimationFrame loops draw 1 resting frame under `prefers-reduced-motion: reduce`. (`reui/no-smil`, `reui/reduced-motion`)
- Smooth scroll only under `motion-safe:scroll-smooth`. A focus ring appears at once: never `transition-all`, `transition-shadow` or a bare `transition` on it. (`reui/reduced-motion`, `reui/focus-transition`)
- Loaders and typing dots keep their opacity pulse; every decorative loop gets `motion-reduce:animate-none`, never an inline `style={{ animation }}` (it beats the class). (`reui/reduced-motion`)
- Anything that moves on its own for more than 5 seconds (autoplay, marquee, ticker) gets a visible Pause control, pauses on hover and focus, and never autoplays under reduced motion. (`reui/pausable-autoplay`)
- Installed overlays keep their 100ms fade, zoom and 8px slide under reduced motion: disclosed, not edited; an app that needs WCAG 2.3.3 adds its own override. (judgement (no check))
+57
View File
@@ -0,0 +1,57 @@
# Quality gates (security, accessibility, scroll, self-checks)
This is the **done gate**: before you call any ReUI work finished, call `get_audit_checklist` and pass every item below (plus the bar and the states in [layout.md](./layout.md)). Then run the self-checks, typecheck and lint, compare with [anti-patterns.md](./anti-patterns.md), and hand back in the 5 lines below.
## Security
- Never `dangerouslySetInnerHTML`. Render data as text/components.
- External links (`target="_blank"`) must always pair `rel="noopener noreferrer"`.
- No real PII, secrets, or tokens in demo or committed code. Remote media only from sources the project already allows.
## Accessibility
- Implicit list/card items that navigate get real anchors with a standard hover affordance. Hover on a display-only card: make the card a link to its detail, or give it no hover.
- `aria-current="page"` on the current route link, `"location"` on the current in-page section link.
- Destructive confirms use `AlertDialog`, not `Dialog`, and cannot be dismissed while pending.
- Icon-only or numeric buttons need an `aria-label`; decorative icons get `aria-hidden`.
- Every non-submit button is `type="button"`.
- Keyboard + focus: everything interactive is reachable in a sensible Tab order with a visible focus ring; layers (dialogs/sheets/menus) trap focus and close on `Escape`. ReUI components ship their keyboard behavior; never re-implement it.
- Announce async UI: loading and error messages use `role="status"` or `aria-live`.
## Scroll mechanics
- Make scroll regions with a parent-owned height: a `min-h-0` + flex chain down to the scroll container. Never guess a `max-h`.
- The scroll container owns `overflow-auto`; ancestors stay `min-h-0` so the height resolves.
## Self-checks
After every UI edit, run these with ripgrep on `<files>`: each file you wrote or changed, by path, never `components/ui` or `components/reui`. A clean check prints nothing; a hit prints `file:line:code`. The last filter drops comment lines.
```sh
# C1 banned motion: transition-all, bare or paint transitions, layout keys, bounce, overshoot, scale 0, hover and tap gestures, will-change
rg -nH 'transition-all|transition(-property)?: *[^;]*\b(all|width|height|top|left|margin|padding|box-shadow|filter)\b|[\x27\x60" ]transition( +(duration|ease|delay|hover|focus|active|group|motion)|[\x27\x60"])|transition-shadow|transition-\[[^\]]*\b(width|height|top|left|right|bottom|inset|margin|padding|filter|radius|background-position|background-size|all)\b|animate-bounce|will-change|willChange|bounce: *(0?\.0*[1-9]|[1-9])|[\x27"](backIn|backOut|backInOut|anticipate)[\x27"]|(cubic-bezier\(|ease: *\[)([^,]*,|([^,]*,){3})[ _]*(-|1\.[0-9]*[1-9]|[2-9])|scale: *0 *[,}]|\bscale-0\b|while(Hover|Tap|Focus)=|(initial|animate|exit)=\{\{[^}]*\b(width|height|maxHeight|top|left|margin|padding|boxShadow|filter|backdropFilter|borderRadius)[A-Za-z]*:' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 off-scale motion: a duration off 0, 100, 150, 200 and 300 (duration-500 only on a media hover zoom), ease-in or ease-out, a stagger step above 0.05
rg -nH 'duration-([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})\b|duration-\[|\b([1-9]|[1-9][0-9]|1(0[1-9]|[1-46-9][0-9]|5[1-9])|[23](0[1-9]|[1-9][0-9])|[4-9][0-9]{2}|[1-9][0-9]{3,})ms\b|(^|[^0-9.])(0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)|1\.[0-9]+)s\b|duration: *([1-9]\b|0?\.(0[0-9]*|1[0-46-9][0-9]*|15[0-9]+|[23][0-9]+|[4-9][0-9]*)\b)|\bease-(in|out|in-out)\b|(staggerChildren: *|stagger\( *)(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *[^,}]*\b(i|index|idx)\b *\* *(0?\.0[6-9]|0?\.[1-9]|[1-9])|delay: *(0?\.0[6-9]|0?\.[1-9]|[1-9])[0-9.]* *\* *\b(i|index|idx)\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C2 read by hand: a duration or delay that is not a number, every index delay (allowed: 0.05 or less on the first 6 items, as Math.min(i, 5) * 0.05, never on table rows)
rg -nH '([dD]uration|[dD]elay): *[^0-9 .]|delay: *[0-9.]+ *\*' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C3 state color: raw palette on a line that names a state
rg -nH '\b(text|bg|border|ring|fill|stroke)-(red|rose|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet)-[0-9]{2,3}\b' <files> | rg -i ':[0-9]+:.*\b(success|paid|active|completed?|done|approved|online|healthy|live|error|failed|critical|overdue|declined|rejected|cancell?ed|blocked|expired|warning|pending|degraded|due|info|processing|running|scheduled|queued)\b' | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 structure: a raw table, select or fixed overlay, framer-motion, Math.random() or Date.now()
rg -nH '<table\b|<select\b|fixed inset-0|from ["\x27]framer-motion|Math\.random\(|Date\.now\(' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
# C4 on a base-* style only (skip on radix-*): Radix asChild
rg -nH '\basChild\b' <files> | rg -v ':[0-9]+:\s*(//|/\*|\*(\s|/|$)|\{/\*)'
```
Fix every hit, run again, stop after the second run and list what is left with file and line. A C4 hit stays only when a comment in the file says why. These also stay, named on hand-back line 2: C1 on the motion.md sidebar row or on will-change set in a drag handler and removed when it ends; C2 on a media hover zoom; `Math.min(i, 5) * 0.05`.
No `rg` (a missing one also prints nothing): run each first pattern with your own search tool, skipping comment lines. No shell: read each changed file for what each `#` line names.
## Hand-back
End your reply with these 5 lines:
1. Installed: each item with its `previewUrl`.
2. Motion: each animated element and its [motion.md](./motion.md) table row, or "none".
3. States: covered, or which are missing.
4. Checks: C1 to C4, screenshots or "not visually verified", the audit checklist, typecheck, lint; each one run, or "not run" and why.
5. Deviations: any rule broken or check hit left, with file:line and why.
+44
View File
@@ -0,0 +1,44 @@
# ReUI registry structure
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the ReUI components with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (a full dashboard or settings page). Pro or Ultimate license at install. Its surfaces, text and borders use your theme tokens; its category accents (chart series, tags, avatars) are fixed palette colors paired for light and dark.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`), plus a hover-animated variant (`@reui/icons/animated/<style>/<name>`) only for icons that carry `installAnimated`. Ultimate license at install. See [icons.md](./icons.md).
## The @reui registry
ReUI items install through the shadcn CLI as `npx shadcn@latest add @reui/<name> --yes`, with `<name>` exactly as a ReUI tool returned it (`search`, a getter, `get_install_command`). The CLI reads the `@reui` registry from the project's `components.json`. Free items need only the plain string form:
```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
```
Premium installs need the authenticated form + `REUI_LICENSE_KEY` in `.env.local` - see [cli.md](./cli.md). The MCP `get_project_context` tool returns the right config.
## ReUI items vs shadcn/ui components
- **ReUI items** (the components, `c-*` examples, blocks, Motion Icons) install as `@reui/<name>`.
- **shadcn/ui components** that ReUI builds on (`button`, `dialog`, `select`, `card`, ...) are not ReUI items. They install from shadcn by bare name: `npx shadcn@latest add button --yes`.
- **`badge` and `alert` exist in both registries.** `@reui/badge` and `@reui/alert` are ReUI's components (the extra variants in [components.md](./components.md)); bare `badge` and `alert` are shadcn's.
- **In an item's `registryDependencies`**, a `@reui/<name>` entry is a ReUI item and a bare name is a shadcn/ui item. `add` installs both for you.
- **Templates are not registry items.** Download one from its page on reui.io (https://reui.io/templates?ref=skill); no `shadcn add` command installs a template.
## Know your base: base or radix
ReUI ships every item in two builds - `base` (Base UI) and `radix` (Radix UI) - with mirrored names. The CLI installs the build matching your project automatically, but **you must write code against the right base's API**. Detect it from `components.json` -> `style`: the segment before the first `-` is the base (`base-nova` -> Base UI, `radix-nova` -> Radix UI). The installed files and `c-*` examples are already in your base - read them and adapt on that base. See [components.md](./components.md) for the API deltas.
**So the MCP's own `docsUrl` and `previewUrl` match your base**, send your `style` to the MCP: add `?style=<your components.json style>` to the ReUI MCP server URL (or set an `X-Reui-Style` header) in your MCP client config - set once, applies to every call. The MCP then resolves docs/preview links to YOUR library (`/docs/components/radix/...`, `/preview/radix/...` for a radix project) instead of the default base; `get_project_context` echoes back the style it currently sees so you can confirm it. Install commands are unaffected (the CLI already installs the right variant). If you notice the MCP returning `base` links for a `radix` project, tell the user to add `?style=` to the server URL.
Blocks take surfaces, text and borders from your theme tokens, so a theme change re-tints them; category accents stay fixed by design.
## Free vs premium
- **Free, no license key:** the ReUI components, all `c-*` examples, and this skill. The MCP is free too but still needs a free ReUI account (the agent signs in on first use); a license key is only for premium installs.
- **Premium, license required at install:** blocks (Pro or Ultimate) and Motion Icons (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
- **Templates** are downloads, not installs: free ones need only a ReUI account, and each template's page on reui.io says what the others need.
## Component API index
The canonical index of every component's API docs is **https://reui.io/llms.txt** (returned as `componentsApiUrl` in MCP results). Prefer the inline `api` from `get_component`; use the index/docs as the fallback.
+37
View File
@@ -0,0 +1,37 @@
# Theming: styles, radius, density and sizes
## Read your style
`components.json` `style` is `<base>-<style>`: the part after the first `-` names the style (`base-nova` is nova on Base UI). The style is baked into `components/ui` and `components/reui` at install, so the installed files hold your real values: read your installed `button.tsx` (usually `components/ui/button.tsx`) for heights, radius and padding before you build anything that sits beside a control.
## The style ladder
<!-- gen:style-ladder:start -->
| Style | Button `sm` / default / `lg` | Button radius | Card radius, shadow | Card spacing | Button text |
| ----- | ---------------------------- | -------------- | ---------------------------------------------------- | ------------ | -------------------- |
| vega | `h-8` / `h-9` / `h-10` | `rounded-md` | `rounded-xl`, `shadow-xs` | 6, sm 4 | `text-sm` |
| nova | `h-7` / `h-8` / `h-9` | `rounded-lg` | `rounded-xl` | 4, sm 3 | `text-sm` |
| maia | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-2xl` | 6, sm 4 | `text-sm` |
| lyra | `h-7` / `h-8` / `h-9` | `rounded-none` | `rounded-none` | 4, sm 3 | `text-xs` |
| mira | `h-6` / `h-7` / `h-8` | `rounded-md` | `rounded-lg` | 4, sm 3 | `text-xs/relaxed` |
| luma | `h-8` / `h-9` / `h-10` | `rounded-4xl` | `rounded-4xl`, `shadow-md` | 6, sm 4 | `text-sm` |
| sera | `h-9` / `h-10` / `h-11` | `rounded-none` | square, `shadow-sm` | 8, sm 5 | `text-xs`, uppercase |
| rhea | `h-7` / `h-8` / `h-9` | `rounded-2xl` | `rounded-[min(var(--radius-4xl),24px)]`, `shadow-sm` | 5, sm 4 | `text-sm` |
<!-- gen:style-ladder:end -->
Card spacing is the `--card-spacing` step (`--spacing(n)`) a Card pads with; `Card size="sm"` uses the sm step. The default Button text weight is `font-medium` (sera: `font-semibold`).
## What the style owns
- Never set what the style owns on its parts: radius (except `rounded-full`), height, horizontal padding, shadow, ring or font size. Pick `size` and `variant` instead.
- A panel is a `Card` or a `Frame`, never a div with `rounded-*`, `border` and `bg-card`; set density with `Card size="sm"` or Frame `spacing`.
- A bespoke element beside a control (a chip, a custom trigger) takes its height and radius from that control's row above.
- A focusable control you build copies the focus classes of your installed `input.tsx`.
- A variant before a class: `className` is for layout (placement, width, gap). `size-*` when width equals height, `cn()` for conditional classes (never a template literal), a scale step before an arbitrary value (`w-60`, not `w-[240px]`), no inline `style` for color or spacing, and no `z-index` on dialogs, sheets or popovers.
- Change the look through the theme CSS variables in `:root` and `.dark` (`--radius`, `--primary` and the rest) or with a style switch (a new `style` in `components.json`, which means reinstalling the components; ask the user first). Never edit installed files to restyle them.
## Components with no per-style values
`event-calendar`, `gantt`, `icon-stack`, `kanban`, `rating`, `scrollspy`, `sortable` and `timeline` install the same file in every style. Their look comes from your theme tokens and the parts you put inside them (a `Card` in a kanban column follows the style), so never copy another style's radius onto them.
+66
View File
@@ -0,0 +1,66 @@
# Workflow: find -> install -> read API -> adapt
The core ReUI loop. The MCP tells you what to install and gives you the API; the shadcn CLI installs it; you turn the installed files into correct, themed, data-wired code by **reuse**, not redesign.
## When to reach for ReUI vs plain shadcn
- A full page or section (dashboard, billing, auth, pricing, settings): `compose_page` first (step 1).
- A data table with sorting, filtering, pagination or virtualization: the `data-grid` component, never a hand-rolled `<table>`.
- A drag-and-drop board: the `kanban` component.
- Column filters, a date range, a tree, a stepper and the like: the matching ReUI component.
- A single generic control shadcn already has (Button, Dialog, Select): plain shadcn.
## 1. Find (ReUI MCP `search` / `compose_page`)
**Full multi-section page ask?** Call `compose_page(intent)` FIRST (`sections`, 1 to 10, caps how many sections, default 8), before searching block-by-block. It returns ordered sections, each with the best block for the intent (top pick + alternates); on a free account it answers `mode: "free"` and fills the same sections with free `c-*` examples instead, so the plan is always buildable. Sections listed in `unavailableSections` have no real inventory - compose those from components, do not force a bad block.
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (a ReUI component), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `free`.
Example: "build a users management page with filters" -> `search({ query: "users management page with filters", type: "block", component: "data-grid" })` (on a free account use `type: "example"`).
Each result has `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `termCoverage`, and `whyMatch`. `score` is relative to the top hit (the top is ~100 by construction), not an absolute quality - compare results to each other, and show the user the top options if several score closely; do not silently guess. A low `termCoverage` means a weak match even with a high score - rephrase or widen.
**Pick with the guidance, not the title.** Look-alikes are common (dialog, sheet and drawer; dozens of data-grid examples and app shells). Before installing, read the item's guidance: `get_component` returns `whenToUse` and `whenNotToUse`, `get_example` and `get_block` return `bestFor` (what sets the item apart from its siblings), `agentNotes` and `whenNotToUse`, and a block adds `categoryGuidance` (its category's key note), and `get_examples` rows carry `bestFor`. Each `whenNotToUse` entry names the item to use instead and its `type` (`component` -> `get_component`, `example` -> `get_example`, `block` -> `get_block`, `block-category` "<group>/<category>" -> `search({ query, type: "block", category: "<category>" })`); when the user's situation matches its `when`, switch. Apply every `agentNotes` line before you ship: it names the real identifiers to replace or wire (sample data, stubbed actions, required props). An item with no guidance yet has none of these keys; fall back to its description and thumbnail.
Results are scoped to the account's plan, so you are never shown an item the user could not install: blocks need Pro, Motion Icons need Ultimate. A `type` hint the plan cannot use (or a query whose only matches are premium) comes back as a normal HTTP 200 answer with `locked: true` and `requiredPlan` rather than results - that is a paywall, not a missing item, so switch to free components and `c-*` examples and mention https://reui.io/pricing?ref=skill.
**Show the preview.** List every item with its `previewUrl` (a live preview page). Where a result carries `thumbnail` (blocks, `c-*` examples and icons), show the image as markdown linked to `previewUrl`, so the user sees it before you install.
## 2. Install (shadcn CLI)
Run the result's `install` command from the project root, non-interactively:
```bash
npx shadcn@latest add @reui/<name> --yes
```
The CLI reads `components.json`, installs the correct base+style variant, resolves `registryDependencies` (a block pulls in its components), installs npm deps, and rewrites aliases. Do not pass the base/style. See [cli.md](./cli.md).
## 3. Read the API (do not guess props)
Before writing code against any component an item uses:
1. The item's `componentDigests` already give a 1-line contract per component - often enough to wire it. For the deeper API, call **`get_component(names)`** with ALL of `componentsUsed` in ONE call (it accepts an array) and read each inline `api` - no web fetch. A very large API is trimmed on heading boundaries to fit your context and lists what it dropped in `sectionsOmitted`; when the part you need is in that list, re-read it as the response's `next` hint says rather than guessing. `validate_usage` always checks the FULL API, so it still catches a prop you invented from a trimmed capsule. **Share the component's `docsUrl`** (its API documentation page) with the user whenever you work with that component's API, so they have the full reference; the `/llms.txt` index is a further fallback.
2. Call **`get_examples(name)`** for the free `c-*` examples of that component; install one and **read the added files** to copy the exact composition. This is the fastest correct path - the example shows real wiring you adapt, not invent.
3. About to write a prop you did not see in an `api` or installed file? Run **`validate_usage`** BEFORE writing the code - per-prop documented / notDocumented verdicts plus did-you-mean suggestions. notDocumented means read the API, not push on.
## 4. Adapt (reuse-first) - do not skip
Installing files is not the end, and redesigning them defeats the point. Write against your project's base (see [components.md](./components.md)). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to the active theme: semantic tokens for anything that carries meaning, raw palette only for categorical identity and always with its `dark:` pair (see [color.md](./color.md)).
5. **Validate before finalizing**: if your adaptation introduced components or props you did not read in an `api` or example, run `validate_usage` on them.
6. **Hit the craft bar** - clear hierarchy, deliberate density, the empty / loading / error states, motion that leaves installed components alone, and mobile-first responsiveness (see [layout.md](./layout.md) and [motion.md](./motion.md)). Generic-looking output means you under-reused the design, not that it needs restyling.
7. **Pass the quality gates** (security, a11y, scroll) - call the MCP `get_audit_checklist` tool and clear every item (see [quality.md](./quality.md)).
8. **Typecheck / lint**.
## If no single block fits
Compose from components (`compose_page` tells you which sections have no block inventory via `unavailableSections`). `search` the components you need, read each `get_component` API, install a worked `get_examples` example for each, and assemble by adapting those examples. A block in the same category is a useful reference - install it and read its files to see how ReUI composes those components, then adapt.
+74
View File
@@ -0,0 +1,74 @@
# ReUI MCP: full reference
The ReUI MCP (`https://mcp.reui.io`, Streamable HTTP) is free to use but needs a ReUI account: on first use the agent signs in with ReUI (a free account is created if the user has none), so every request is tied to an account. Free covers components and examples; Pro unlocks premium blocks, Ultimate adds Motion Icons, and either removes the daily request limit by default. It does **discovery + guidance** (search, inline APIs, page planning, validation) and never serves source; the shadcn CLI does **installation**, and the license key lives there (the `@reui` entry in `components.json`, backed by `.env.local`). Goal: from the user's intent to correct, themed, data-wired ReUI code in the **fewest tokens and calls**, with **no guessing**.
## Golden path (token-optimal - follow this order)
Most tasks need 2-4 MCP calls and ZERO web fetches:
1. **`search(query, ...hints)`** -> pick the top 1-3 results. Each result already carries `install`, `previewUrl`, `docsUrl`, `componentsUsed`, `score`, `whyMatch`. The payload is complete - do not call another tool just to "confirm" a result.
2. **`get_component([...componentsUsed])`** in ONE batched call (one name or an array of up to 20) -> read each inline `api`. This **replaces** fetching docs pages. Often skippable: search responses carry `componentDigests`, a compact API contract per referenced component.
3. **`get_examples(component)`** -> install ONE returned `c-*` example, read the added files, copy the composition.
4. **`get_install_command(item)`** only to validate a name you are unsure of (results already include `install`). Run the install with the shadcn CLI (`--yes`).
5. **`get_audit_checklist()`** before declaring done.
If you already know the exact item name, skip `search`. Everything else is situational.
## The 5 task-specific tools (when to reach for each)
- **`compose_page`** - BEFORE building any full page (dashboard, settings, billing, landing). Pass the intent (`sections` is a number, 1 to 10: the most sections to return, default 8); it returns ordered sections, each with the best premium block for the intent (top pick + alternates). On a free account (or in free-only mode) it answers with `mode: "free"` and composes the same sections from free `c-*` examples instead, so the plan is always buildable. Sections with no real inventory are listed honestly in `unavailableSections` - compose those from components instead of forcing a bad block.
- **`search_icons`** - whenever you need icons, especially several. Batch up to 24 concepts in one call; each concept returns its best icons with install commands. Only some icons have a real hover animation: `animated: true` returns only those, and a concept with none comes back empty with an `animatedNote`. Each icon carries a `thumbnail` of its 4 styles; look at it before trusting a name. An old icon name still resolves (`renamedFrom` names it), unless it is also an ordinary word icons are named for today (`watch`): then it ranks today's icons for the word and `oldNameNotes` names the icon the old name installs. Motion Icons are Ultimate for **discovery as well as install**: without an Ultimate credential this returns `locked: true` and zero concepts, so check the plan before promising the user icons.
- **`validate_usage`** - BEFORE writing code with component names or props you have not read in an inline `api` or an installed example. It checks planned names + props against the indexed API docs and registry item names; returns did-you-mean suggestions and per-prop documented / notDocumented verdicts. Deterministic, no inference - a notDocumented prop means stop and read the API, not push on.
- **`get_thumbnail`** - when the choice is VISUAL and the ranking has not already made it: 2 or more candidates within about 15 score points. When the top hit leads by a wide margin, or `missedTerms` already rules the others out, skip it; an image costs roughly 10 times a result row. It returns the hosted preview image as image content so you can look yourself; pass up to 4 names in one call to compare `compose_page` alternates side by side before installing any of them. To let the USER see something, share the item's `thumbnail` and `previewUrl` instead - this tool is for your own eyes, and a client that cannot render images gets the URLs in the text block.
- **`get_agent_skill`** - when the agent has no local ReUI skill (a cloud or tools-only client), or to check whether the installed one is stale. With no `topic` it returns the core workflow, the topic index and the installer; `topic` (for example `motion`) returns that one file in full. Compare its `version` with the one stamped atop your local `SKILL.md`.
## All 19 tools
`search`, `get_block`, `get_example`, `get_icon`, `get_thumbnail`, `list_block_groups`, `list_block_categories`, `list_example_categories`, `list_icon_categories`, `list_components`, `get_component`, `get_examples`, `search_icons`, `compose_page`, `validate_usage`, `get_install_command`, `get_project_context`, `get_agent_skill`, `get_audit_checklist`. The MCP serves the full parameter schemas; do not guess parameters beyond them.
## Commands (MCP prompts)
In Claude Code the ReUI MCP adds these prompts; other agents show MCP prompts under their own names, or not at all. Plain language runs the same loop.
- `/mcp__reui__build <what>`: compose a page, section or feature (plan, install, read the API, adapt, audit).
- `/mcp__reui__add <item>`: find, install and wire in 1 component, example, block or icon.
- `/mcp__reui__fix [target]`: fix ReUI usage (undocumented props, a base or radix mismatch, missing states, accessibility, scroll).
- `/mcp__reui__improve [target]`: refine existing ReUI UI (hierarchy, density, states, responsive, motion).
## Token + speed rules
- **Batch `get_component`** - ONE call with the whole `componentsUsed` array, never N calls. Skip it entirely when `componentDigests` already answers the question.
- **Read source by installing** - the MCP serves no source. To read or analyze an item's real code, install it with the shadcn CLI and open the local files. Learn an API from the inline `api` / `componentDigests`, never by reading raw source.
- **Infer `search` hints yourself** (`type`, `component`, `category`, `free`) - hints shrink the result set and the tokens. Keep `limit` low; one right result beats ten.
- Run independent calls (and the shadcn install) concurrently in one turn - serial tool calls are the main source of slowness.
- Don't repeat a search for the same intent; don't call `list_*` to "see everything" - `search` is the entry point, `list_*` is only for browsing a taxonomy the user explicitly wants to explore.
- Prefer `get_component`'s inline `api` over `docsUrl` / `/llms.txt`. Fetch a web page only as a last resort.
## Result shapes (so you don't re-fetch)
- `score` is 0-100 RELATIVE to the top hit (the top is ~100 by construction), not absolute - compare results to each other.
- `termCoverage` (0-1) is the share of the query the item matched - low means a weak match even if the score looks high; rephrase or widen the search.
- Each result carries `whyMatch`, `install`, docs/preview URLs, and a `free` flag; premium items carry `requiredPlan` (`"pro"` for blocks, `"ultimate"` for icons).
- `componentDigests` is a top-level map: a compact API contract per referenced component - often enough to wire an item without a `get_component` call.
- An inline `api` is not always the whole API: a very large one is trimmed on heading boundaries to fit your context, and the response names every dropped heading in `sectionsOmitted` plus a `next` hint for re-reading one. Never treat a trimmed capsule as complete; `validate_usage` still checks the FULL API.
- Icon results and `get_icon` include `animated: true` and `installAnimated` only when the icon has a real hover animation (animated: `@reui/icons/animated/<style>/<name>`; static: `@reui/icons/default/<style>/<name>`). Use the strings given; never build an animated path by hand, it installs a static copy for every other icon.
- Blocks, `c-*` examples and icons carry `thumbnail`, an absolute URL to a hosted preview image (light theme; blocks 1080x720, examples 900x600, icons 640x200 with the 4 styles side by side). Show the top 2 or 3 as markdown images linked to `previewUrl` so the user picks by eye; use `get_thumbnail` when YOU need to see them. Components have no thumbnail.
- Each result carries `missedTerms`, the words from YOUR query it does not mention (omitted when it matches them all). A high score with 3 missed words is a near miss wearing a good score: read this before `get_thumbnail`, it is far cheaper.
- `weakMatch: true` means the top result misses more of the query's words than it matches; `unmatchedTerms` lists the words none of the top results mention and `weakMatchNote` says what to do. Stop and re-search with the registry's own words, or compose from components, instead of installing the top hit.
- On a free account `premiumPicks` (a sibling of `results`, never inside it) lists up to 3 premium blocks that fit the query more closely than the free answer, as previews with no install command, plus `unlock` with the plan and a pricing link. Mention the upgrade at most once per conversation, then keep building with what the plan covers. `weakMatch` and `premiumPicks` can appear together: the free answer is weak AND a premium block fits; `weakMatchNote` says so.
- Guidance is getter-only, never on search rows: `get_component` adds `whenToUse`, `whenNotToUse` and `agentNotes`; `get_example` and `get_block` add `bestFor`, `agentNotes` and `whenNotToUse`, and `get_block` also `categoryGuidance`; `get_examples` rows add `bestFor`. Answers carry the key lines; the item page's `.md` twin has every line. Each `whenNotToUse` entry is `{ when, instead, type }`: fetch `instead` with the getter its `type` names.
- Results are scoped to the caller's plan, so a free account never sees a block or icon it could not install. When a search matches only hidden premium items, the answer says so (`locked`, `requiredPlan`, `premiumMatches`) instead of pretending the registry is empty.
## Error playbook
- **401** - the MCP requires a signed-in ReUI account, on every request (there is no anonymous access). The client prompts "Sign in with ReUI" (OAuth) on first use; a free account is created if needed. For headless/CI, pass a personal token (`reui_pat_...`, created at https://reui.io/account/mcp?ref=skill) as `Authorization: Bearer`.
- **locked result** - a valid account whose plan does not cover the item. This is NOT an error and NOT a 403: it comes back as a normal HTTP 200 result carrying `locked: true` and `requiredPlan` (`"pro"` for premium blocks, `"ultimate"` for Motion Icons). Keep working with the free components and `c-*` examples, and point the user at https://reui.io/pricing?ref=skill if they want the locked item.
- **daily allowance reached** - a free account has a per-account, per-UTC-day allowance on tool calls (ReUI sets the number and can change it, so read it from the message and never assume one). It comes back as a normal tool RESULT with `isError: true` at HTTP 200: not an HTTP error status, no back-off header to honor, and no per-minute limit to wait out. Retrying the same call just spends the next unit, so surface the message (it carries the reset and the upgrade link) to the user instead of looping. The count resets at UTC midnight, and using a Pro or Ultimate license as the MCP credential removes the limit.
- **not found** (`found: false`) - use the returned `suggestions`, or `search`. Never run a fabricated install command.
## Fallbacks
- No ReUI MCP: `npx shadcn@latest search @reui -q "..."` then `add` (generic, no scoring / inline API).
- The shadcn project's own MCP also works over the `@reui` registry: https://ui.shadcn.com/docs/mcp.
Per-agent MCP setup: https://reui.io/docs/mcp?ref=skill
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+14
View File
@@ -0,0 +1,14 @@
<!-- BEGIN:reui-agent-rules -->
## ReUI design rules (every UI edit, follow-ups included)
This project uses an existing design system: ReUI on shadcn/ui. Keep its look. Generic frontend advice (vary fonts or themes, gradients, page-load or staggered reveals, "2-3 intentional motions", Framer Motion) does not apply here, and other design skills lose where they disagree.
1. Use installed components first (`components/ui`, `components/reui`): tables `data-grid`, filtering `filters`, boards `kanban`, modals `Dialog` or `AlertDialog`, toasts `sonner`. Never set height, radius, padding, shadow or motion on them; use `size`, `variant`, `spacing`.
2. Color: semantic tokens. State: `<Badge variant="success-light">` from `@/components/reui/badge` at default size, or Alert variants. Raw palette only for categorical identity, with its `dark:` pair.
3. Spacing: `gap-*` on the parent: `gap-1` to `gap-2` in a row, `gap-3` to `gap-4` between groups, `gap-6` to `gap-8` between sections. Type: `text-sm` body, `text-xs` meta, nothing smaller; never resize FrameTitle or CardTitle.
4. Motion: installed parts already animate; never retime them. Yours: hover `transition-colors`; reveal `duration-200 ease-[cubic-bezier(0.22,1,0.36,1)]`; transform and opacity only; never `transition-all`, bounce, or a stagger on table rows or lists of 20+; at most 1 entrance per view; `MotionConfig reducedMotion="user"` plus `motion-reduce:`.
5. Every data region: empty state with an action, `Skeleton` loading, inline error with Retry.
6. Before UI code, write the plan from `.agents/skills/reui/SKILL.md` in your reply. Before you finish, run its self-check on the files you changed and fix every hit (2 rounds at most). Full rules: `.agents/skills/reui/SKILL.md`. If that folder is missing, call the `reui` MCP tool `get_agent_skill`.
Managed by the ReUI installer. Edits between the markers are replaced on the next run.
<!-- END:reui-agent-rules -->
+571
View File
@@ -0,0 +1,571 @@
# Changelog
What changed in each release, newest first. Every release is also on the
[Releases page](https://github.com/byGarcia/technitium-console/releases), with the files the
installer downloads.
## Which version for which server
A console release is built and checked against one Technitium DNS Server release: every action it
sends, every text it shows and every control it offers, compared with that server's own console.
**Update the console together with the server.** The oldest server each release is made for is
also `technitiumServerMinimum` in its `package.json`, which is what the update notice reads.
| Console | Technitium DNS Server | Notes |
|---|---|---|
| **1.3.x** | **15.5.x, 15.6.x** (1.3.4 checked against 15.6.2) | Current. Every screen redesigned for the phone, a Dashboard that compares periods, Cache in one panel. |
| 1.2.x | 15.5.x, 15.6.x | Superseded by 1.3.x on the same server. Allowed and Blocked become one Blocking section, a light theme, and the console comes as an image for Docker. Supports a console folder of its own (`DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH`), so server updates leave it alone. |
| 1.1.x | 15.5.x | Superseded by 1.2.x on the same server. If you stay on it, use 1.1.3: earlier ones flood the server from Cache, Allowed and Blocked. |
| 1.0.0 | 15.4 | Cannot save Settings on a 15.5 server. Upgrade. |
To update, run the installer again (the same one-line command). It needs no restart of the DNS
service. On Docker, pull the image again: see [Docker](README.md#docker).
---
## v1.3.4 (2026-10-21)
**Two display fixes, both reported by users.** Still Technitium DNS Server 15.6.2: no API changes.
- **View Logs: a log file reads from the top again.** A file that starts with a run of zero bytes,
which an unclean shutdown can leave, showed an empty viewer until you scrolled down. That run now
takes no room; Download still gives you the file exactly as it is. Reported by @Colossus5000 in
[#20](https://github.com/byGarcia/technitium-console/issues/20).
- **Top Clients: no stray dot under an address, and the count on its line.** A client with no
resolved name, which is most IPv6 clients, showed a `.` under its address. The stock console
writes it there; this one now leaves that line out, in the Dashboard's card and in the Top 1000
list. The Queries count and the row menu sit on the client's line again. Reported by @armgenio in
[#22](https://github.com/byGarcia/technitium-console/issues/22).
## v1.3.3 (2026-10-19)
**Behaves like the stock console in every place a census found it did not.** After a report that
the DNS Client server list did not open, every change and click handler of the stock console was
compared with this one, and each difference found was fixed. Still Technitium DNS Server 15.6.2:
no API changes.
- **DNS Client: the server list opens again.** The arrow next to *Server* showed nothing, because
the browser filtered the list by the text already in the field. It now always lists every
server, and choosing one also sets the protocol, as the stock console does. Reported by @JF62 in
[#15](https://github.com/byGarcia/technitium-console/issues/15).
- **DNSSEC: Add Key works with its defaults.** It opened as a Key Signing Key with an automatic
rollover of 30 days, which the server refuses. The rollover now exists only for a Zone Signing
Key and follows how the key is generated, in Add Key and in Sign Zone.
- **Cluster: the node selects act on the node chosen.** Zones reloads its list from the node (and
stays on its page), Cache lists, flushes and deletes on it, View Logs and Query Logs follow it,
and DHCP locks the node while a scope is being edited. With no node chosen they show this server
instead of a dash.
- **Records: the add and edit form does what the stock one does on its own.** Changing the type
empties the previous type's fields and leaves the TTL empty so the server applies its default
(it was sent as 3600, wrong for NS). FWD records get *Use "This Server"*, APP records fill
*Record Data* from the class's template, and SVCB parameters offer the known keys again. The
records filters apply on *Go*, with a *Page Number* field, as in the stock console.
- **Zones:** *Zones Per Page* and *Records Per Page* are remembered, and up to 500 records per page
are offered again. Zone Options' tabs and override boxes follow the catalog chosen. Add Zone
preselects the cluster's catalog, reads the TSIG keys again for a secondary, and keeps a chosen
zone file when the zone type changes, as Import Zone does when switching to the text editor.
- **Quick lists.** *None* in the TSIG Quick Add empties the list instead of adding a key named
`none`; *None* in the forwarders' Quick Select also goes back to UDP; and picking the line
already chosen in any list does nothing, as in a native select.
- **Login:** the 30 second OTP timeout starts the form over, and the automatic login of a fresh
install no longer opens the OTP panel. **SSO and LDAP:** a group map to a local group that no
longer exists shows and saves the first group, as the stock console does. **Dashboard:**
*Custom* reloads at once when both dates are already set.
- **The update dialog has a Copy button** beside each command.
## v1.3.2 (2026-10-18)
**An update notice, and a hardened installer.** The console now tells you when there is a newer
one, and an audit of the whole product tightened the installer and the release workflow. Still
Technitium DNS Server 15.6.2: no API changes, all 132 endpoints are covered.
- **The console tells you when a newer console is out**, the way the server does for itself: an
*Update* mark next to *Web Console*, and a dialog with the release notes and the two commands
to update. It asks at most once a day, through your own DNS server, and only announces releases
made for the server you run. It follows *Check for update* in *Settings › General*. How it works
and what leaves your network: [README](README.md#keeping-it-up-to-date).
**Security**, from an audit of the whole product:
- The installer no longer trusts a group-writable root folder that carries a POSIX access control
list: there the group bits can stand for another account that may write, and that account
could race root's copy and make it write or delete outside the console folder. Where neither
`ls` nor `getfacl` can show lists (BusyBox without the acl package), group write is not trusted
at all, and the installer says how to fix it.
- The installer no longer creates a folder just because the running server's environment names
it. Only `/opt/technitium-console` is created that way; any other new folder has to be created
by the administrator first.
- On a Docker host, the commands the installer prints quote the Compose service names it reads,
and strip control characters from every name it shows, so a label set in an image cannot add a
command to the line you paste or rewrite the screen.
- `--version` accepts only a release tag, so it can no longer point the download at another
repository's release.
- The release workflow now unpacks the build job's artifact outside its checkout and accepts only
the tarball and its checksum, so nothing from the job that runs npm can replace a script the
release job runs.
- Links that come from the server, such as the update notice's, are followed only when they are
http or https. The zone records filter no longer blanks the console on a pattern that is not a
valid expression. The update check refuses oversized queries and names longer than DNS allows.
## v1.3.1 (2026-10-17)
**Technitium DNS Server 15.6.2.** The server release makes the Dashboard's figures links to Query
Logs and rewords one warning, and this console now does the same. No API changes: all 132
endpoints are still covered.
- Dashboard: clicking Total Queries, a response code (No Error, Server Failure, NX Domain, Refused)
or a response type (Authoritative, Recursive, Cached, Blocked) opens Query Logs filtered to it,
as in 15.6.2. Clients and Dropped are not links.
- Edit Zone File and Import Zone: the SOA serial warning now says that secondary zones will also
need a manual resync.
- The note parity check now also compares the end of each note. It missed the reworded warning
because the old text still matched at the start.
## v1.3.0 (2026-10-16)
**A Dashboard that answers at a glance, Blocking that explains itself, and every screen at home on
a phone.** Same API and same behaviour as the stock console: checked against Technitium DNS
Server 15.6.1.
- Dashboard: every figure, the Queries chart, a week by hour, Server, the three top lists and the
three breakdowns (now stacked bars) fit in the first screen. Each figure shows its trend and its
change against the previous period of the same length; Clients has no change for Last Week and
Last Year, where the server cannot give a comparable number. Clicking a chart point, a slice, a
bar or an hour opens Query Logs filtered to it.
- About: two panels for the console itself under Server: its version, license, source, release
notes, where to report a problem or share an idea, and how to support it.
- Cache: one panel instead of two. The Domain field on top, the path of the current name with
Refresh and Delete, the names under it as chips that open them, and each record on one line with
its details underneath.
- Blocking › Overview: Check a domain says whether this server blocks a name and why (which block
lists, which of your rules, or which allow rule lets it through), next to Block and Allow.
- Blocking › Lists: comment lines act as group headings. Add, rename and ungroup groups, add lists
into a group and move lists up and down. The save bar says what changed and the tab is marked
while changes are unsaved.
- Blocking › Rules: deleting from the table no longer asks and offers Undo for a few seconds. The
tree view and Flush still ask.
- Settings: since Save Settings sends all nine panes, the save bar names the panes holding a change
it would send, with how many fields each, and their tabs are marked. A field put back as it was
does not count.
- Settings › General, Web Service and Optional Protocols, and Administration › SSO, have a section
index: a strip that stays at the top while you scroll and marks where you are, or a column beside
the form on wide screens. Permissions' strip now stays at the top too.
- The save bar of Settings and the other long forms no longer disappears behind a section title
scrolling under it.
- The sidebar marks Blocking when blocking is off or paused, with the time it comes back.
- Blocking's three tabs have a one-line subtitle saying what each is for.
- Login: a cover beside the sign-in form, in both themes and at phone width. The form, its steps
and its messages are unchanged.
- Query Logs: the filters fit in two bands, Live Update is a switch in the header, rows stay on one
line with the full value on hover, and the response colour is a pill instead of a tinted row.
Below 960 px the filters fold under their title and say which ones are set, so the first results
are on the first screen of a phone.
- Tables never scroll sideways: when one does not fit, each row becomes a block with its name and
actions first and the other columns labelled underneath (Zones, records, Cache, Users, Sessions,
Query Logs, DHCP, Cluster, Recently Blocked).
- View Logs: the log fills the window and long lines wrap, lined up under the message, instead of
being cut at the right edge. Copying gives the original text.
- Panel titles sit one step above table headers.
- Fields, dropdowns and buttons share one height. Domain names and list URLs no longer break in the
middle of a word, and a new check (`npm run lint:breaks`) keeps it that way.
- Session rows keep the user agent to two lines.
- The installer accepts a web root whose files root's own group can write. Technitium's install
script leaves every file of its console as root:root 0664, so since 1.2.3 upgrading or
uninstalling over a stock web root, or over a console a server update had written into, stopped
with "root-owned, non-shared regular file required". Write by any other group or account is
still refused.
## v1.2.4 (2026-10-12)
**Technitium DNS Server 15.6.1.** The server release changes one note in its own console, and
this one now says the same. No API changes: all 132 endpoints are still covered.
- LDAP Authentication: the note on how the LDAP server name is resolved is now two notes, one for
Windows and one for Linux, as in 15.6.1. The Linux one asks for `127.0.0.1` in
`/etc/resolv.conf`.
- The note parity check no longer counts a note as present on a run of words it shares with
another note. That is how the new Linux note passed before it was here.
## v1.2.3 (2026-10-04)
**Security hardening and live figures that survive minute reloads.** The root installer
rejects filesystem paths that non-root accounts can change, session cleanup preserves
newer logins, and private DNSSEC fields travel in POST bodies. Last Hour retains the
queries the server has not consolidated yet, and Recently Blocked follows its count.
- Last Hour keeps pending live readings across minute reloads on the Dashboard
and Blocking Overview, discarding them only when the server's UTC minute
boundary includes them. Quiet intervals retain their place in the chart.
- Recently Blocked follows the Overview's Blocked count, keeps the last good
rows during refreshes, and discards late responses after context changes.
- Dashboard Top five lists show their position and a subtle proportional bar.
- Adding a rule keeps a closed tree's selection and only opens an added domain
in a list the current user can view.
- The sortable-column parity check fails when its upstream snapshot is absent
and accepts an explicit snapshot path.
- Root installer publication, restore and custom-list migration reject writable
service-owned trees, links, special files and ambiguous pathnames. State and
staging use protected root-owned storage; state and systemd changes use unique
temporary files. Unsafe trees are refused without changing their ownership.
- DNSSEC signing and private-key import send PEM fields in POST form bodies.
- Logout revokes the displayed session, and expiry responses preserve tokens
belonging to newer sessions or other tabs.
- Update the development-only jsdom dependency undici from 8.10.0 to 8.10.2 to
include its published security fixes.
**Installer migration:** existing service-owned or group-writable console, backup or
state trees are refused. Use a fresh root-owned console folder or a dedicated console
volume mounted read-only in the DNS server. The installer does not repair ownership
in place. See [Installing](README.md#installing) and [Security](SECURITY.md).
---
## v1.2.2 (2026-10-03)
**Blocking › Overview moves in real time too, and the live charts stay still.** The live Last Hour
of 1.2.1 left two things to finish. Blocking's Overview, which reads the same figures as the
Dashboard, now follows the server as queries arrive and reloads every minute like the Dashboard.
And the charts no longer replay their entry animation on every live update, nor switch back on a
series you had switched off in the legend.
- **Blocking › Overview, Last Hour:** Total Queries, Blocked, their sparklines, the queries over
time bars and the blocked share ring follow the server every 2 seconds, on the node or the whole
cluster you are viewing. The page also reloads once a minute, and not while its tab is hidden.
- **The charts take new figures in place.** On the Dashboard every chart was rebuilt, and animated
again from scratch, every 2 seconds, even with no new queries. Now a reading that brings nothing
draws nothing, and one that does moves the chart without animating it.
- **A series switched off in the legend stays off** until the next minute reload, as in the stock
console. With 1.2.1 it came back within 2 seconds.
---
## v1.2.1 (2026-10-03)
**Last Hour moves in real time, the row menus are back, and Technitium 15.6 is supported.** With
Last Hour selected, the query counts and the Queries chart follow the server as queries arrive,
and in a cluster they now add up every node. Query Logs entries and the Dashboard's top lists have
their row menus again, to query the DNS server, open the matching logs, or allow and block a
domain. On Technitium 15.5 or later with systemd, the installer now sets up the dedicated console
folder by itself.
### Technitium 15.6
- **Settings › Cache › Prefetch has the new Enable Cache Prefetch switch.** Since 15.6 a Prefetch
Trigger of 0 no longer turns prefetching off, so its "set 0 to disable prefetching" hint is gone
too. On a 15.5 server the switch shows unchecked and has no effect, as the server ignores it.
- **Administration › Cluster shows each node's version under its name**, once the server reports
it. A remote node gets one after its first heartbeat.
- The 15.6 fixes in the stock console's DHCP screen did not apply here: this console never builds
markup from those values.
- Console 1.2.0 keeps working on a 15.6 server. It just does not offer the new switch, and saving
Settings leaves it as it was.
### Fixes
- **Logs › Query Logs fits narrower windows**
([#4](https://github.com/byGarcia/technitium-console/issues/4)). Under about 1366 pixels, Domain
and Answer were squeezed to a character wide and the rest of the table hid past the right edge.
They now keep a readable width, and the table fits down to small laptop windows.
- **The Dashboard's Cluster view adds every node up.** It showed the node serving the console
alone, because the console never told the server it wanted the whole cluster. Blocking ›
Overview and the More dialogs had the same fault and are fixed too.
- **Blocking › Overview: each Your Rules number sits over its own word**
([#5](https://github.com/byGarcia/technitium-console/issues/5)), so the card reads like the other
three.
### Dashboard
- **Last Hour now moves in real time.** Between the minute refreshes, the query counts, the
Queries chart and the response types follow the server's own counters every 2 seconds, on the
node or the whole cluster you are viewing. Clients, query and protocol types and the top lists
still update once a minute, as the server only counts them that way.
- While the browser tab is hidden the Dashboard asks for nothing, and it catches up as soon as you
come back.
- **Last Hour refreshes every 60 seconds.** The selected period and cluster node stay in place.
- Other periods do not poll: their data does not change as the current minute advances.
- Requests never overlap, and the current figures stay visible during a refresh.
### Row menus
- **Query Logs entries open in DNS Client again**
([#4](https://github.com/byGarcia/technitium-console/issues/4)). Each row's menu offers Query
DNS Server, then Allow Domain or Block Domain, as in the stock console.
- **The Dashboard's top lists and their More dialogs have their row menus back**: Show Query
Logs, Query DNS Server, and Block Domain or Allow Domain. Jumping to DNS Client or Query Logs
fills the form and runs the query, on the cluster node the Dashboard is showing.
- **Administration › Sessions: each row's details button is now View User Details**, the stock
console's wording for it.
- DNS Client now sends its cluster node with the query, and the More dialogs ask the node the
Dashboard is showing. Both used to answer from the node serving the console.
### Installer
- On Technitium 15.5 or later with systemd, the installer now creates and owns
the dedicated console folder and service drop-in itself. It publishes before
the single restart, verifies the running service, and rolls back to the stock
console if activation fails.
- Reinstalling or updating the console in that folder does not restart the DNS
service. Uninstall switches back to the stock console before removing the
folder, and refuses to remove a drop-in changed by the administrator.
- `--no-configure-service` keeps the previous replacement mode available.
---
## v1.2.0 (2026-10-02)
**Allowed and Blocked are now one Blocking section, and Docker gets an image.** Overview, Rules
and Lists: what blocking is doing, your own rules and your lists, each in one place. It is a deliberate exception
to "design only", written down with its limits: it calls only endpoints the server already has,
with the stock console's own actions and wording, and Settings › Blocking is left exactly as it
was. If Technitium runs in Docker, a small image now copies the console into a volume that the
official server serves, and updating it restarts nothing: see
[Docker](https://github.com/byGarcia/technitium-console#docker). There is a light theme now, and
it follows your system unless you pick one. On the way, the Dashboard's chart learned to tell the
time the way the stock console does.
### 🌗 Light theme
- **Change Theme is back** in the account menu, as in the stock console: Use System Theme
(default), Light Theme and Dark Theme, applied the moment you click. Asked for by @scgf in
[#1](https://github.com/byGarcia/technitium-console/issues/1), and by @armgenio in
[#2](https://github.com/byGarcia/technitium-console/issues/2).
- **It remembers what the stock console remembers.** The choice is kept under the same browser
key, so if you picked Light in the stock console you get Light here without doing anything, and
the other way round. Follow the system and it changes when your system does, without a reload.
- **The light palette is measured, not inverted.** Every text colour reaches 4.5:1 on every
surface it is drawn on, field borders and the focus ring reach 3:1, and the chart colours were
checked against each other again on white, including for red-green colour blindness. Technitium's
amber stays the fill; where amber is text on white it darkens so it can be read.
- **Amber is not offered.** The stock console's fourth theme is not drawn; a browser that has it
stored gets Dark, and its choice is left as it was.
- The charts repaint when the theme changes, and series you switched off stay off.
### ✨ The Blocking section
- **Overview.** Total Queries and Blocked with a trend line under each and Blocked's share of the
total, Block List Domains and Your Rules. Blocking's status, with Disable for the stock console's
eight durations and, while paused, the time it comes back. Allowed against blocked over time as
stacked bars, and a ring with the blocked share, for the Dashboard's five fixed periods and with
its node selector and memory. Top Blocked Domains with **Allow Domain** and Top Domains with
**Block Domain**, in a menu on each row, as on the stock Dashboard. And Recently Blocked: the last
ten blocked queries, from the app that logs them.
- **Rules.** Every domain you blocked or allowed by hand in one table, filtered by All, Blocked or
Allowed with their counts, searchable, and paged 50 at a time however many there are. Delete
asks as the stock console asks. **Tree** switches to the domain tree you already know. Import,
Export and Flush sit at the foot, each for Blocked or Allowed.
- **Block or allow a domain.** One field with Block and Allow, on Overview and on Rules. Enter
blocks, as the stock console's form does.
- **Lists.** Each block list URL is a row: its readable name when it is in the Quick Add
catalogue, an Enabled switch, its type and Remove. Add a block list or an allow list by URL or
with Quick Add. Changes wait in a bar with Save and Discard, so even Quick Add › None can be
undone, and Save sends only the list URLs, cleaned as Settings cleans them. Block List Domains,
Allow List Domains and the next update come with **Update Now**, and the figures say they are
updating until the server has finished.
- **More lists in Quick Add.** On Lists, Quick Add has a search field that filters by name or by
the list's host, and after Technitium's catalogue it offers more lists chosen by this console,
under More lists. Settings › Blocking offers Technitium's catalogue as before.
- **Old addresses still land.** `/allowed/` and `/blocked/` open Rules filtered to that list, and
the filter stays in the address, so it survives a reload.
- **Nothing hides for want of a permission.** A panel you cannot view shows a padlock and the
permission it needs; a control you cannot use is disabled and says why. The section appears for
anyone who can view Blocked or Allowed.
### 🧰 Installer
- **`ghcr.io/bygarcia/technitium-console`**, for amd64, arm64 and arm/v7, on Technitium 15.5 or
later. It carries this release's tarball, byte for byte, checks it against its checksum again
every time it runs, copies the console into the volume and exits. Pin `:1.2.0`, follow `:1.2`,
or take `:latest`.
- **On a Docker host the one-line installer prints the exact steps** for your containers, with
their own container, service, file and volume names: in, and with `--uninstall`, out. It used to
print a command that did not work (`sudo sh --dir …`) and a layout that 15.5 no longer needs.
- **Installing into a container's own files is called out**: they are gone the next time the
container is recreated.
- **A folder holding only your `json/*-custom.json` lists counts as empty**, so they can be in
place before the first install.
- **`--dir /` is refused.** It used to fall back to the server's own web root and install there.
A folder holding a DNS server's configuration (`dns.config`, `zones/*.zone`…) is refused too,
whatever else is in it.
- **Stopping the installer stops it.** A Ctrl-C or a `TERM` in the middle of a run used to clean
up its working files and carry on without them; now the run ends there, and the next one picks
up from a clean slate. A run it refuses leaves nothing behind, not even an empty
`/var/lib/technitium-console`.
### 🛠 Fixes
- **The Dashboard's chart tells the time as the stock console does.** The console never asked the
server for UTC (`utc=true`), so the hours on the axis were the server's clock and not yours, and
shifted whenever the two sit in different time zones. It now asks for UTC and labels the hours in
your local time, as the stock console does.
- **The Allowed and Blocked tree, now Rules › Tree, has no cluster node selector**, which the stock
console only has on Cache, and after a change it reads the tree back from the primary node, as
the stock console does.
- **No stray scrollbar beside the section tabs.** Chromium on Linux drew one next to the tabs of
Settings, Logs, Administration and DHCP.
- **Drop-down lists show their options whole.** A list is now as wide as its longest option,
within the window, and scrolling it no longer closes it.
- **Big figures stay inside their cards on the Dashboard.** A Block List of millions of domains
ran out of its card in Server. Server now has two columns while it sits beside Queries, so nine
characters fit at full size, and any figure that is still too long for its card is drawn
smaller instead of spilling out. Reported by @armgenio in
[#2](https://github.com/byGarcia/technitium-console/issues/2).
### 🧪 Under the hood
- Overview, Rules and Lists are also tested against responses recorded from a real server, and
the rules table against 10,000 rules.
- `CONVENTIONS.md` writes the Blocking section down as a deliberate deviation, with its four
limits and the two behaviours a maintainer would not guess.
- Every image is checked on each of its three platforms to carry exactly the release's tarball
before `latest` and `1.2` move to it.
- Every push to `develop` builds `ghcr.io/bygarcia/technitium-console:develop`, the same way a
release is built, for trying changes before they are released. It is not for production, and it
never moves `latest` or a version.
- New issues start from a Bug report or Feature request form, which asks for the console and
server versions and labels the issue.
- Everything in the repository is in English, and `npm run lint:language` now reads every file,
not only `src/`. The notes from the design process, which were in Spanish, are no longer part
of the repository.
---
## v1.1.3 (2026-09-30)
**Cache, Allowed and Blocked stop hammering the server.** Please update: these three screens had a
bug since 1.0.0 that kept asking the server for the root of the tree, hundreds of times a second,
for as long as the screen was open.
### 🛠 Fixes
- **Cache, Allowed and Blocked load once.** The screen re-ran its own first load after every
answer, so it flooded the server's API while open and jumped back to the root whenever you
opened a node in the tree. Opening a node now stays on it.
### 🧪 Under the hood
- The tests for those screens now answer by domain, as the server does. The old ones answered the
same thing to every request, which is why the loop was invisible to them.
- Every section was opened against a server with real data and its requests counted: none asks
for anything more than twice.
- A new check, `dev/check-endpoints.mjs`, confirms that every API endpoint the stock console of
15.5.1 uses is covered here too: 132 of 132.
---
## v1.1.2 (2026-09-30)
**Sign in with SSO works from any page.** A one-line fix found on the first real install, the
evening 1.1.1 came out.
### 🔑 Single Sign-On
- **"Sign in with SSO" no longer gives a 404.** If your session expired while you were on, say, the
Dashboard, the login appeared right there and its SSO link pointed at `/dashboard/sso/login`,
which does not exist. It now always goes to the server's `/sso/login`.
### 🧪 Under the hood
- A test now fails on any link or image in the interface that would resolve against the page you
happen to be on. This is the second path of that kind found in one day, so it gets a guard.
---
## v1.1.1 (2026-09-30)
**A clean uninstall on Docker, and the header keeps up with the server.** Found by installing 1.1.0
from its own release, exactly as the README says to.
### 🧰 Installer
- **`--uninstall` finishes when the console's folder is a mount point**, which it is in the
Docker layout. It used to empty the folder and then stop with `Device or resource busy`. Now it
empties it, removes it if it can, and if it cannot, tells you to unset the variable before
restarting instead of offering a restart that would serve an empty folder.
### 🖥 Interface
- **The tab title, the header and About follow the server.** Rename the server or update it, and
they change the next time Settings loads, as the stock console does. They used to stay as they
were when you logged in.
- **The highlighted option of a dropdown stays in view** while you move through it with the
keyboard.
### 🧪 Under the hood
- The last Spanish names left in the code are gone, and the check that keeps the code in English
now knows them.
---
## v1.1.0 (2026-09-30)
**Ready for Technitium DNS Server 15.5.** Version 1.0.0 was built for 15.4 and, on a 15.5 server,
could not save Settings at all: 15.5 removed Auto Prefetch, and 1.0.0 still required its two
fields. Everything 15.5 and 15.5.1 changed in the stock console is here, and the installer can now
keep the console in a folder of its own, so a server update no longer puts the stock one back.
### ✨ New in Technitium 15.5, now in the console
- **Administration › LDAP Authentication**: every field, the group map, Test Connection, and the
bind password kept masked.
- **Edit Zone File** for Primary and Forwarder zones, from the zone's Options menu and from the
zone list. Other zone types show **View Zone** instead.
- **LDAP users**: Local, Remote/SSO and Remote/LDAP accounts each get the right menu entries,
profile fields and 2FA options.
- Blocking's new rules, the Docker note under Web Service, and the refreshed block list catalogue.
### 🧰 Installer
- **A folder of its own.** On 15.5 or later, point the server at a folder with
`DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH`, a variable contributed to Technitium for this very
purpose ([#2138](https://github.com/TechnitiumSoftware/DnsServer/pull/2138)), and the console
lives there, untouched by server updates. See *Installing* in the README.
- **It only ever writes into a console.** It refuses a folder that is neither Technitium's console
nor its own, trusts only the real server process, does not follow symbolic links, uninstalls
only what it installed, and checks every download against the release's published checksum.
### 🛠 Fixes
- **Settings saves again** on 15.5.
- **Settings follows the node you pick**, on a cluster and on a single server alike, and after you
move the web service to another port or protocol the console follows it there.
- **Single Sign-On**: the Redirect URI shown in Administration › SSO is the right one again.
- **Your account**: Configure 2FA asks before turning 2FA off, Change Password shows your username
and the code's help, success messages close the dialog and appear on the page, and Create API
Token shows the new token on its own with its warning. Logging in with the factory password
opens Change Password straight away, as it should.
- **Nineteen explanations and notes** that had gone missing are back, most of them under the Zone
Options choices.
### 🔒 Security
- Opening the console at an address with a doubled slash (`https://host//`) could have sent the
session token to another host; the server's security headers blocked it, and now the console
never gets that far.
- A saved session that fails when the console starts is cleared.
- None of the cross-site scripting issues fixed in Technitium's own console in 15.5 and 15.5.1
applied here.
---
## v1.0.0 (2026-09-07)
**First public release.** The whole Technitium DNS Server 15.4 console, rebuilt: twelve sections,
real addresses you can bookmark and reload, a layout that works on a phone, and a one-line
installer that puts it in place and takes it out again.
### ✨ Highlights
- **Same console, new interface.** Every screen, dialog, text and step of the stock console, against
the same API. Nothing changes on the server.
- **Works on a phone.** No section overflows at 390 px; tables reflow and the navigation collapses.
- **Cache, Allowed and Blocked as a real tree**, full height beside the records, with the path you
are on written out.
- **An installer you can trust with your DNS server.** It keeps your custom lists, survives being
interrupted, never restarts the DNS service, and uninstalls back to the original.
### 🛠 Fixes on the way to 1.0
- Sortable columns restored in the App Store and the installed apps list.
- Loading no longer looks like an empty list.
- The App Store is far shorter to scroll: descriptions show two lines, with the rest one click away.
+83
View File
@@ -0,0 +1,83 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement through this repository's private report form: the *Security* tab → *Advisories* → [**Report a vulnerability**](https://github.com/byGarcia/technitium-console/security/advisories/new). It is the same private channel [SECURITY.md](SECURITY.md) uses, and it does not have to be about a vulnerability: the form is simply the one place on this repository where a message stays between you and the maintainer instead of being public. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of actions.
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+103
View File
@@ -0,0 +1,103 @@
# Contributing
Thanks for being here. Before proposing anything, read [PRODUCT.md](PRODUCT.md) and then the first
section of [CONVENTIONS.md](CONVENTIONS.md), because this project has one rule that decides most
questions before they are asked:
> **Design only. Zero functionality.** Any behavioural difference from the stock Technitium console
> is a bug, **even when it looks like an improvement.**
If your idea makes the console *do* something new, it is a change to
[Technitium DNS Server](https://github.com/TechnitiumSoftware/DnsServer) and not to this. If it
makes the console *look* or *behave as an interface* better (clearer, faster to read, reachable
from a keyboard, correct at 390 px), it belongs here and it is welcome.
Bug fixes, documentation and accessibility fixes need no permission. Open the pull request.
---
## Running it
**You need Node 22 or newer and Docker with the Compose plugin.** The package manager is **npm**, on
purpose: building this must need nothing installed beyond Node.
```bash
npm install
npm run dev # Vite, on http://localhost:5173
```
The development server needs a real DNS server to talk to. The harness brings up two:
```bash
cd dev && docker compose up -d
```
- `http://127.0.0.1:5380`: the official image, serving **this** console out of `dist/`
- `http://127.0.0.1:5381`: the official image, untouched, serving the **stock** console
Log into either with `admin` / `technitium-ui-dev`. Having both side by side is the whole method:
when you are not sure what the console should do, you go and look at the one next door.
A fresh harness has no traffic, so the Dashboard is empty until you make some. `dig` from inside the
containers, wait about twenty seconds for the stats to settle, and it fills up.
## Before you open the pull request
```bash
npm run typecheck # tsc -b, NOT `npx tsc --noEmit`, which checks nothing here
npm run lint
npm test
npm run build
node dev/check-language.mjs # the repository is English: names, comments, tests, docs
node dev/check-colour-tokens.mjs # every colour is a token, declared in a theme block
node dev/css-dead.mjs # dead classes, unresolved composes, classes that do not exist
node dev/check-parity-sort.mjs # upstream's sortable columns against ours
node dev/check-sort-fields.mjs # every <Th field> names a key that exists
node dev/check-parity-controls.mjs
```
`npm run typecheck` and not `npx tsc --noEmit`: the root `tsconfig.json` is references-only, so the
bare command type-checks zero files and passes happily. That cost a real bug once.
## What a good change looks like
**It cites upstream.** The `www/js/*.js` and `index.html` of the DNS server are the specification.
A comment saying `apps.js:130-131` is worth more than a paragraph of reasoning, and it is how the
next person checks you.
**It measures instead of asserting.** This repository has a habit: when a defect is found by hand,
the next commit is the tool that finds the rest of them. If you fix one dead sort field, the
interesting question is how many others there are.
**It says what it did not do.** A drawing, a plan or an upstream behaviour you decided *not* to
follow is worth writing down with the reason, in the comment next to the code it concerns, or in
[CONVENTIONS.md](CONVENTIONS.md) when it is an upstream behaviour or a deliberate deviation.
**Its comments explain why.** Not what the code does, but what was tried, what broke, what the
numbers were. If a value was chosen by measurement, the measurement goes in the comment.
## Tests
Vitest and Testing Library. Query by what a person sees (the label, the role, the text) and not by
a class name, because a class name is exactly what a redesign changes.
Test descriptions are English, like everything else in the repository, and there is a gate that
enforces it.
## Style
- Comments, identifiers, file names and test descriptions in **English**.
- No new dependency without a reason in the pull request. The kit is deliberately small: React,
Chart.js and Radix's dialog and select.
- CSS Modules with the tokens in `src/theme/tokens.css`. A raw colour or a raw pixel value in a
component is a defect the tools will find.
- Before adding a primitive to `src/ui/`, check whether one already does it. Most of them exist
because the same thing had been written four times with four looks.
## Reporting a bug
Say which version (the About screen has both the console's and the server's), what you did, what
happened, and what the stock console does instead. That last one is usually the whole report: if the
two differ, this one is wrong by definition.
Security problems do **not** go in an issue. See [SECURITY.md](SECURITY.md).
+863
View File
@@ -0,0 +1,863 @@
# How this console is built
Read this through before touching anything. It is short and it saves you the
mistakes that have already been made.
## The rule that rules them all: design only, zero functionality
This console **replaces the Technitium DNS Server interface without changing what
it does**. Same controls, same steps, **same texts**, same validation order. Any
behavioural difference from the upstream console is a bug, even when it looks
like an improvement.
If you catch yourself thinking "while I am here, this could be better": no. That
is a different job.
The exceptions are few, decided and written down: see
[Deliberate deviations from upstream behaviour](#deliberate-deviations-from-upstream-behaviour).
The largest is the Blocking section, which replaces Allowed and Blocked. Those that
add something carry written limits of their own.
## Where the reference is
The console this one replaces is not in this repository. You read it from the
server's own repository, which has to be added as a remote once:
```bash
git remote add upstream https://github.com/TechnitiumSoftware/DnsServer.git
git fetch upstream master
git show upstream/master:DnsServerCore/www/js/zone.js
git show upstream/master:DnsServerCore/www/index.html
```
**The alert texts are contract.** Pull them out of there with
`grep -o 'showAlert("[^"]*", "[^"]*", "[^"]*"'` and copy them literally, without
rewording.
## Do not assume the shape of the responses: check it
There are two disposable instances in `dev/`:
- `dev` at <http://127.0.0.1:5380>: serves our build
- `ref` at <http://127.0.0.1:5381>: the upstream console, untouched
User `admin`, password `technitium-ui-dev`. Bring them up with
`docker compose up -d` from `dev/`.
```bash
T=$(curl -s "http://127.0.0.1:5381/api/user/login?user=admin&pass=technitium-ui-dev&includeInfo=false" | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")
curl -s "http://127.0.0.1:5381/api/zones/list?token=$T" | python3 -m json.tool | head -40
```
Three assumptions that have already turned out false, so you do not repeat them:
1. **Not everything comes wrapped in `response`.** `user/login`,
`user/session/get` and `status` return the payload **flat**. Everything else
does wrap it. That is why `apiRequest` **unwraps nothing**: it hands over the
JSON as it came.
2. The second-factor literal is **`2fa-required`**, not `two-factor-auth-required`.
3. The statistics are flushed **by the minute**. A `dig` you just ran does not
show on the Dashboard until the next flush; do not conclude something is broken.
## Upstream behaviours discovered along the way
Write down here whatever you find. What is already known:
- **Optional fields that look required.** In `apps/list`, the `updateVersion`,
`updateUrl` and `updateAvailable` fields **only exist** if the app is in the
store catalog and there is a compatible version there; an app installed from
your own zip never brings them, and if the catalog query exhausts its 5 s
timeout the list arrives whole but without them on any app. Typing them as
required is a guaranteed bug. Be suspicious of every field you have only seen
once.
- **A text field can come back as `null`.** `apps/config/get` returns
`config: null` as soon as someone saves an empty configuration.
- **The upload endpoints are POST-only**: by GET the server answers **404**, not
a JSON error.
- **On uploads, the file field name does not matter**: the server takes
`Form.Files[0]`. But **do not set `Content-Type` by hand** or the `boundary`
is lost and the server says the file is missing.
- **Deleting something that does not exist usually answers `ok`**, not an error.
- **There are two different paginations.** `zones/list` paginates on the server
(`pageNumber`, `zonesPerPage`) and its paging fields **only appear if you send
`pageNumber`**. `zones/records/get`, on the other hand, **does not paginate**:
it is asked for with `listZone=true` and paginated on the client.
- **Where an alert comes out is not cosmetic**: in upstream, a modal's alerts
come out inside the modal and a screen's come out on the screen. Honour it.
- **The server can return a domain different from the one you asked for.** When
navigating the cache/allowed/blocked tree, `WebServiceOtherZonesApi.cs`
**walks down on its own** while the node has no records and has exactly one
child. Always draw `response.domain`, **never** the domain you asked for, or
the tree and the table fall out of sync.
- **The same field can have two types depending on the endpoint.** In
`cache/list` the `ttl` is an already-composed string (`"218 (3m38s)"`); in
`allowed/list` and `blocked/list` it is a number with `ttlString` apart. Same
with a SOA's `refresh`, `retry`, `expire` and `minimum`. Typing the records
with a single shape is a guaranteed bug.
- **`0001-01-01T00:00:00` is .NET's `default(DateTime)`**: it means "never", not
year 1.
- **Visibility rules that look the same and are not**: deleting a node is
offered in cache if the node is not the root, and in allowed/blocked if the
node has records. Do not make them uniform without looking.
- **A count may not be where you draw it.** `blocked/list` only reads the
manually blocked zones; the downloaded block lists are counted **only** in
`dashboard/stats/get` (`blockListZones`).
- **Careful with replicating the intent instead of the behaviour.** In
`other-zones.js` there are three `domain.toLowerCase();` **without assigning
the result**: they do nothing. What the code does is replicated, not what it
looks like it wants to do.
- **`settings/get` OMITS the null keys**, it does not send them as `null`:
fields like `temporaryDisableBlockingTill` or `blockListNextUpdatedOn` simply
do not appear on a freshly installed server. Others do arrive as an explicit
`null`. Declaring them required fails against a new install. And
`blockListNextUpdatedOn` is not only a fresh-install gap: it is written only
while a block list update is scheduled (WebServiceSettingsApi.cs:382-386), so
saving an empty list makes it vanish from the answer. Settings › Blocking and
the Lists tab of Blocking both read its absence as "Not Scheduled".
- **`logs/query` filters by ONE `responseType`** (WebServiceLogsApi.cs:182-185),
but the Dashboard's `totalBlocked` adds up three: `Blocked`, `UpstreamBlocked`
and `UpstreamBlockedCached` (StatsManager.cs:329-341). "The latest blocked
queries" is therefore three queries, merged by time: that is what Recently
Blocked does (`api/blocking.ts`).
- **Careful with `\r\n` when replicating a list textarea.** Upstream builds its
textareas with `\r\n`, but the browser normalises to `\n` when reading the
value of a `<textarea>` from the DOM, and its cleanup only substitutes `\n`.
In React there is no intermediate DOM to normalise: copying the literal
`\r\n` sends `forwarders=1.1.1.1%0D,8.8.8.8%0D` to the server. **It bites any
screen with lists in a textarea.**
- **An empty list travels as the string `"false"`**, it is not omitted: it comes
out of concatenating a boolean into the query. With three exceptions that fall
to their default value.
- **A screen with sub-tabs can be ONE SINGLE form.** In Settings, "Save" sends
the fields of all nine sub-tabs wherever you are. Chopping it up per tab would
change what gets saved.
- **An action bar can mix different permissions**: in Settings, saving requires
`Settings.canModify`, flushing the cache `Cache.canDelete` and the backup
`Settings.canDelete`.
- **Asymmetric permissions**: some actions ask for `Delete` where you would
expect `Modify`, and `apps/list` is allowed with read permission on Apps,
Zones **or** Logs. Do not deduce the permission: look at it.
- **Not every endpoint returns JSON.** `logs/download` answers `text/plain` with
the file when it goes well and `application/json` with the usual envelope when
it fails. It cannot go through `apiRequest`: that would do `res.json()` and
turn any log into a network failure. Upstream asks for it with
`isTextResponse` and, if what arrives carries `status`, draws it **formatted
inside the viewer itself**, as if it were the file's content
(logs.js:170-172). The error does not come out as an alert.
- **Two endpoints of the same family can call the same datum differently**: the
log file is asked for with `fileName` in `logs/download` and with `log` in
`logs/delete`. And `logs/list` returns the name **without extension**, which
is the one to send in both.
- **Confirming or not confirming is not symmetric.** In DHCP, disabling a scope
and deleting it both ask; **enabling it asks nothing** (dhcp.js:583 vs 615).
Only the path that cuts the service asks.
- **`dhcp/scopes/set` is a PARTIAL update**: each field is applied only if it
comes in the request (`WebServiceDhcpApi.cs:390-650`), so a body with only
`name` and `newName` renames without touching anything else (checked against a
v15.4 instance). Upstream always sends all 36. And **a scope created with this
endpoint is born enabled**, even though nothing says so.
- **`dhcp/scopes/get` OMITS fifteen optional keys** instead of sending them
`null`: `domainName`, `domainSearchList`, `serverAddress`, `serverHostName`,
`bootFileName`, `routerAddress`, `dnsServers`, `winsServers`, `ntpServers`,
`ntpServerDomainNames`, `staticRoutes`, `vendorInfo`, `capwapAcIpAddresses`,
`tftpServerAddresses`, `genericOptions` and `exclusions`. `reservedLeases` is
the exception: it is always written, even as `[]`. Same for `interfaceAddress`
in `scopes/list`.
- **A field can be omitted on purpose when saving**: with "Use This DNS Server"
checked, upstream **does not send `dnsServers`** (dhcp.js:565). The server
keeps the stored ones and `scopes/get` still returns them, so the list you see
on screen is not the one that was just sent.
- **"Deleting something that does not exist answers `ok`" has exceptions.**
`dhcp/scopes/delete` on a non-existent scope answers `ok`, but
`dhcp/leases/remove` on a non-existent lease answers **error**
(`No lease was found for client identifier: …`).
- **Two alerts that look like the same one and are not.** In Query Logs, the
"the app is missing" one ends in "…from the Apps section." when "Query" fires
it and **does not** when "Export" does (logs.js:391 vs 614). Copying one into
both places changes a text.
- **The last page is asked for with `pageNumber=-1`**: the server resolves it.
That is what the "Last" link of Query Logs does (logs.js:589), verified
against the reference instance.
- **The `ts` cache-buster goes on only two of the six downloads**: the settings
backup (main.js:3100) and `logs/download` (logs.js:196). `logs/export`,
`zones/export`, `allowed/export` and `blocked/export` **do not carry it**. The
server ignores it, but the URL that gets opened is not the same.
- **A form filter can live in `localStorage`**: "Logs Per Page" is stored under
the key `optQueryLogsEntriesPerPage` and re-read on every reset (logs.js:23-26
and 63-65). Careful: the form's default value is **10** and the server's is
**25** (`WebServiceLogsApi.cs:162`); the form's wins because upstream always
sends the parameter.
- **`serializeTableData` decodes TWICE.** It applies `htmlDecode` to a value the
browser had already decoded when parsing the HTML, so typing `&amp;` in a cell
sends `&`. In React there is no intermediate HTML: replicating it would mean
introducing the bug by hand, and that is not done.
- **Quick Select forgets the Bypass List.** Picking a forwarder that carries a
SOCKS5 or HTTP proxy (Tor) ticks the proxy type and enables its address, port,
username and password, but not the Bypass List (main.js:594-603): it stays
disabled until the proxy radio is clicked again. That is an omission, not a
rule, and it is not replicated: here the five fields follow the proxy type.
- **Picking the line already chosen does nothing.** A native `select` fires
`change` only when the value changes, and several stock lists rely on it
(Group Details' "None" empties Members; Quick Add would add a deleted key
back). `ui/Select` does the same, and the Quick Add and Quick Select lists
keep the line chosen, as the stock ones do, until their form loads again.
- **A `set` can return less than its `get`, and what is missing has to be kept.**
`admin/sso/set` does NOT bring `localGroups`: `WriteSsoConfig` only writes them
with `includeGroups` and the `set` calls it with `false`
(WebServiceAuthApi.cs:1790). Upstream survives because it stored them in a
global variable when doing the `get`. Reloading the form with the save
response without keeping them leaves the group-map dropdowns empty.
- **A secret can come back MASKED and has to be sent back that way.**
`admin/sso/get` returns `ssoClientSecret: "************"` as soon as one is
stored, and `SetSsoConfig` ignores that exact value
(WebServiceAuthApi.cs:1738). It is what allows saving the form without typing
the secret again: clearing the field "because it looks like filler" would
delete the real secret.
- **The same action can send different parameters depending on where it is
fired from.** `admin/sessions/delete` ALWAYS travels with `node` from the
Sessions tab (the primary node if the session is an API token, the chosen node
in any other case) and **with no `node` at all** from the user details modal
unless it is an API token (auth.js:1050 vs 1382).
- **A checkbox can change the ENDPOINT, not a parameter.** The cluster's "Force
Remove Node" picks between `primary/deleteSecondary` and
`primary/removeSecondary`; "Force Leave" and "Force Delete" in the same block
really are parameters. They cannot be made uniform.
- **Deleting something that does not exist does NOT always answer `ok`.**
`admin/sessions/delete` with an invented partial token answers `error` with
"No such active session was found for partial token: …". Checked live against
a v15.4; it is the exception to the rule noted above.
- **The group list depends on the endpoint that serves it.**
`admin/permissions/get?includeUsersAndGroups=true` includes `Everyone` and
`admin/groups/list` does not; `admin/sso/get?includeGroups=true` excludes it on
purpose (WebServiceAuthApi.cs:383). Three group lists, three contents.
- **Two sibling endpoints can return different shapes of the same object.**
`admin/groups/create` answers `{name, description}` and `admin/groups/set`
also answers `members`. Same with the user: `users/get` brings `groups` (all
the server's), `users/set` does not bring it even though it does bring
`memberOfGroups` and `sessions`, and `users/list` and `users/create` bring
none of the three. Checked live.
- **Half the response disappears when the cluster is not initialised.**
`admin/cluster/state` on a standalone server is THREE fields: `version`,
`dnsServerDomain` and `clusterInitialized`. `clusterDomain`, the four
intervals and `clusterNodes` only exist with a cluster
(WebServiceClusterApi.cs:60-75). And within a node, `upSince`, `lastSeen` and
`configLastSynced` are **omitted** when they hold `default`, they do not
arrive as `null`.
- **The cluster's "Quick Add" compares by SUBSTRING.** `cluster.js:30` uses
`existingList.indexOf(ip) < 0`, so with `10.0.0.10` already in the list the IP
`10.0.0.1` is never added. It is a bug of theirs and it is replicated: what
the code does is copied, not what it looks like it wants to do.
- **A success alert dismisses itself after 5 seconds.** `showAlert`
(common.js:212) schedules a `hideAlert` for `success` alerts and only for
those. It is replicated here.
- **A whole section may filter NOTHING by permission.** Inside Administration,
upstream checks `Administration.canView` to show or hide the section
(main.js:165 and 240) and from there shows every button, letting the server
reject. Adding client-side gating there would be adding behaviour, not
protecting it.
- **Asymmetric permissions, the concrete Administration case**:
`permissions/set` and `sso/set` ask for `Administration.canDelete`, not
`canModify` (WebServiceAuthApi.cs:1533 and 1692). In the cluster, nearly
everything asks for `canDelete` (including `init`, `initJoin` and `promote`),
but `setOptions`, `resync`, `updatePrimary` and `updateIpAddress` ask for
`canModify`.
- **`zones/list` OMITS `dnssecStatus` and `hasDnssecPrivateKeys` on Catalog and
Forwarder zones**, and the Catalog also omits `catalog`. They are types that
cannot be signed, so the server does not even write the fields. Declaring them
required lies about half the list and in TypeScript it shows late.
- **The same table calls the same column differently.** In
`zones/permissions/get`, a user permission brings `username` and a group one
brings `name`. Treating them as the same shape leaves half the table blank.
- **`records/delete` has no branch for CNAME, DNAME, SOA or APP**: all four fall
to the `default`, which only sends `rdata` if it exists, and it exists for
none of them. The server receives zone+domain+type and nothing else. And
**deleting an NS does not send `glue`, but disabling it does**: same pair of
actions, different set of parameters.
- **Disabling a record reads the expiry TTL FROM THE MODAL, not from the row**
(`updateRecordState`, zone.js:6236). If the modal has never been opened, it
sends the empty string; if it was opened, it sends whatever was left inside.
It is an upstream bug: it is replicated, and that is why the records screen
drags that value along.
- **A record filter starting with `*` looks for the literal wildcard**, it does
not list everything: after converting the glob to a regex, `showEditZonePage`
rewrites a leading `.*\.` to `\*\.`. It serves to find the `*.zone` record,
which in DNS really is called that. It looks like a bug and it is not.
- **The name filter without a wildcard is EXACT**, not "contains": typing `www`
does not find `www.sub`. And it lowercases what you typed but **not** the zone
name.
- **`zones/create` is a POST with the parameters in the QUERY**: the body is
reserved for the optional zone file (`fileImportZone`). Without a file,
upstream sends a POST with no body at all.
- **`zones/import` has TWO ways of sending the file**: uploading it goes as
multipart and pasting it into the textarea goes as **raw plain text** with
`Content-Type: text/plain`. The server tells them apart by that type.
- **The bulk zone delete uses the SAME endpoint** with the parameter in plural
(`zones=`, comma-separated) and returns `deleted` and `failed`. When some
fail, the alert is NOT an error: it is a `warning` counting how many.
- **The pagination window slides backwards on reaching the end**: on the last
page the last ten are visible, not just one. And the last is asked for with
`pageNumber=-1`: the server resolves it.
- **In the zone options, six empty lists travel as the string `"false"` and two
do NOT**: `primaryNameServerAddresses` and `queryAccessNetworkACL` travel
empty as they are. It is not symmetry; it is what `saveZoneOptions` does.
- **A zone that is a member of a catalog inherits its options**, and that
governs the whole interface of `modalZoneOptions`: if the catalog does not let
it override a section, that tab DISAPPEARS; if it does, it appears editable;
and if a secondary catalog administers it as well, it appears read-only.
- **The tab that comes up open in the zone options is not the first**: on a
Catalog it is "Query Access", and on a Primary it depends on whether there are
catalogs available.
- **`convertZone` offers only three destinations** (Primary, Forwarder and
Catalog), and which of them are enabled depends on the source through a table
that follows from nothing: a Primary can only go to Forwarder.
- **The year has to be padded to four digits.** `0001-01-01T00:00:00` is .NET's
`default(DateTime)` and turns up on every unused record; without padding,
`getFullYear()` gives "1-01-01", which is not what moment writes.
- **In "Add Zone", the Catalog type shows NOTHING**: it has no branch in the
visibility `switch`, so only the name and the type remain.
- **"Secondary ROOT Zone" is not a type**: it is a Secondary with the root
server addresses preloaded, `zoneTransferProtocol=Tcp` and `validateZone=true`.
The type that travels is `Secondary`.
- **The labels and the values of the DNSSEC dropdowns do not match**: you see
"SHA256 (default)" and `SHA256` travels; you see "Ed25519 (default)" and
`ED25519` travels in UPPERCASE.
- **In the DNSSEC properties, `isRetiring` switches off every action** of a key,
and the automatic rollover only exists for ZSKs.
- **Since v15.5 a user has a `type`, and a remote user LOSES a field.** `user/login`,
`user/session/get`, `user/profile/get` and every `admin/users/*` bring `type`
(`Local`, `RemoteSSO`, `RemoteLDAP`) next to the obsolete `isSsoUser`, and
**`totpEnabled` is omitted for a `RemoteSSO` user**, not sent as `false`
(WebServiceAuthApi.cs:77-82 and 140-146). In the details, `ssoManagedGroups` is
now written ONLY for SSO users and `remotelyManagedGroups` for every type
(`false` for `Local`, lines 158-172). A screen still reading `ssoManagedGroups`
unlocks the groups of every LDAP user. Checked live against v15.5.1.
- **`admin/ldap/get` on a fresh install is half `null`**: `ldapServer`,
`ldapBindUsername`, `ldapBindPassword`, `ldapSearchBase`, `ldapUserSearchFilter`
and `ldapGroupAttribute` arrive as `null`, `ldapPort` as `389`, and
`ldapAllowSignupOnlyForMappedUsers` is **`true` while `ldapAllowSignup` is
`false`**: the box comes up checked and disabled. `ldap/set` repeats SSO's
lesson and drops `localGroups` (line 2136). Checked live against v15.5.1.
- **"Test Connection" is not a dry run of "Save".** It validates server and port
even with LDAP disabled, ignores the group map and never asks the "Ignore SSL"
confirmation (auth.js:2526-2541). The server fills any missing parameter from
the STORED config and swaps the masked password for the stored one
(WebServiceAuthApi.cs:2146-2160). Its error answers with no `response` key at
all: `{"server", "status": "error", "errorMessage"}`.
- **`zones/export` answers an error with HTTP 200 and JSON**, the file with
`text/plain`: a missing zone gives `{"status":"error","errorMessage":"No such
zone was found: …"}` and a bad token `invalid-token`, both 200. The v15.5 "Edit
Zone File" dialog reads it as text and, like `logs/download`, puts any answer
carrying `status` FORMATTED into its textarea (zone.js:1262-1263 in v15.5.1): no
alert, not even for `invalid-token`. Checked against v15.5.1.
- **`zones/import?overwriteZone=true` replaces the WHOLE zone, NS included.** A
file with only SOA+NS+one A leaves exactly that; an EMPTY `text/plain` body
answers `ok` and leaves the SOA alone. An unparsable file fails before
touching anything, with `errorMessage` ("The zone file parser failed to parse
'rdata' field on line # 1.") AND `innerErrorMessage` ("An invalid IP address was
specified."); upstream shows only the first. With `overwriteSoaSerial=false`
the serial is not kept either: the server bumps it. Checked against v15.5.1.
- **"Edit Zone File" never sends `overwrite`** (zone.js:1293): the server
defaults it to `true` (WebServiceZonesApi.cs:1930). And its textarea is never
cleared on opening, only overwritten when the read succeeds. After a read that
never arrives, "Save" (always enabled) would send what the previous opening
left, possibly another zone's file. Replicated; clearing it instead would send
an empty file, which with `overwriteZone=true` empties the zone.
- **Saving a zone file reloads the zone, never the list** (zone.js:1302-1303):
from the list's row menu nothing is refreshed afterwards.
- **What "Save Settings" sends depends on the node selector** (main.js:1639-1644).
`node=""` sends every block, `node=cluster` only the cluster-wide ones (default
values, EDNS/QPM/advanced, TSIG, Recursion, Blocking, Proxy & Forwarders) and a
node name only that node's own (local parameters, IPv6/socket pool, Web Service,
Optional Protocols, Cache, Logging). A skipped block skips its VALIDATIONS too.
On a standalone server the selector is hidden and holds an empty `<option>`
(cluster.js:1047-1049), so the stock console loads `settings/get?node=` and
saves `node=&…`, never `cluster`, which there would drop every node
parameter. Checked on the stock v15.5.1 of the harness. Flush, backup and
restore take the same selector's node.
- **The envelope's `server` is the domain of the server that ANSWERED**, written
after the handler runs (DnsWebService.cs:2478): after a rename it already
carries the new name, and on a proxied request it is the chosen node's. Upstream
follows the web console to its new address only when it equals the session's
domain, which `updateDnsSettingsDataAndGui` has just rewritten for `node=""` or
the own node (main.js:2208-2217, 3177-3188).
- **After a save or a restore the console may navigate on its own**: 2.5 s later
it opens the new HTTP/HTTPS address in the same tab (`checkForWebConsoleRedirection`,
main.js:2293), unless the LAST LOAD decided a reverse proxy is in front
because the page's port is not the web service's (`checkForReverseProxy`,
main.js:2275). In the harness `ref` is published on 5381 while its web service
listens on 5380, so there the proxy is always "detected" and nothing redirects;
`dev` is 5380 on both sides and does follow a port change.
- **Some notes quote the LOADED settings, not the fields.** The DoH/DoT/DoQ/DoH(S)
addresses and both real-IP header notes are written by `loadDnsSettings`
(main.js:1303-1304, 1356-1357, 1369-1372): typing a new port or header does not
change them until the next load or save. Their initial HTML text
(`localhost:8053`, `tls-certificate-domain:853`) is a placeholder, not contract.
- **Since v15.6 prefetching has its own switch, `enableCachePrefetch`**, true on a
fresh install. A Prefetch Trigger of 0 no longer turns it off; instead, a config
saved by an older server with the trigger below 1 loads with the switch off
(DnsServer.cs, config version 7). A v15.5 server neither sends nor reads the key,
so this console shows it unchecked there and the save is harmless. Checked live
against v15.6.0 and v15.5.1.
- **A cluster node's `version` (v15.6) is not always there.** The node itself
always carries it; another node only once a heartbeat has brought it. Right
after a join the secondary appears without it and in state `Unknown`, and about
30 s later as `Connected` with `15.6`. Checked live on a two-node v15.6.0 cluster.
- **The row menus' jumps carry their arguments as TEXT.** Query Logs writes
`queryDnsServer(…, '<qtype>', '<node>')` into the `onclick` (logs.js:525), so a
row without a type sends the string `"null"` and DNS Client does NOT default it
to "A"; the Dashboard passes a real `null` and does get "A" (main.js:2824,
dnsclient.js:232). Both jumps pick the target's node only when given one that is
not `cluster` (dnsclient.js:243, logs.js:634); from the Dashboard's aggregate
the target keeps the node it had. `allowDomain`/`blockDomain` send no `node` at
all: they always act on the server the console is on.
- **The Dashboard's count cards are links to Query Logs since v15.6.2**
(index.html:205-257, logs.js:21-24). Nine of the eleven: Total Queries with no
filter, the four response codes filling `RCODE` and the four response types
filling `Response Type`; Clients and Dropped are not links. The jump passes no
node and no period, so Query Logs keeps its node and its empty `From`/`To`, even
with the Dashboard on another node or period. Here each card carries the jump
in `dashboard/metrics.ts` (`open`).
- **The Top Stats modal follows the Dashboard's node and keeps its own alerts.**
It reads `optDashboardClusterNode` for `getTop` (main.js:2932), and allowing or
blocking from it reports in `divTopStatsAlert`, inside the modal, while the same
action from the Dashboard's panels reports on the page (main.js:2986 vs 2825).
- **The same action can be worded differently per table.** Opening a user is
"View User Details" in the Sessions row menu (auth.js:938) and "View Details" in
the Users one (auth.js:1191).
- **A blocked answer says why, but only in one setting and never for "allowed".**
With `allowTxtBlockingReport` on (the default), a blocked query carries an
Extended DNS Error with `InfoCode: "Blocked"` and `ExtraText`
`source=blocked-zone; domain=<rule>` (DnsServer.cs:4672) or
`source=block-list-zone; blockListUrl=<url>; domain=<entry>`
(BlockListZoneManager.cs:893), one option per list that carries the name, the
same for every blocking type; `dnsClient/resolve` returns it in
`result.EDNS.Options` and again in the OPT record of `Additional`. `domain` is
the entry that matched, a PARENT of the name asked when a rule or a list covers a
whole zone. With the setting off the answer carries nothing. Nothing marks an
answer as allowed by an allow rule, and an unrelated `Synthesized` option ("Result
from negative cache") can sit in the same list. The client library adds its own
`DnsClientExtendedErrors` (`BlockedByUpstreamDnsServer`), which names no reason.
A name the server refuses (`not a domain`) is an `error` envelope with no
`response` at all. Checked live against v15.6.1 on 2026-10-15
(`screens/blocking/check-domain.real.json`).
## How the code is written
- **Client**: always `apiRequest` from `src/api/client.ts`. Paths **relative and
without a leading slash** (`'zones/list'`), because the server honours
`X-Forwarded-Prefix`.
- **One `src/api/<family>.ts` file per endpoint family**, with its types. It
should return data that is already usable, and `null` or an empty list on
failure, but only where an empty result and a failure cannot be confused. If
the screen would draw them the same, return the whole `ApiOutcome`: saying
"no queries for this period" when the call never arrived is worse than an
error.
- **Primitives** in `src/ui/`: `Button`, `Alert`, `Field`/`LabeledInput`,
`Dialog`. Do not invent loose buttons or fields.
- **Colours always by token** (`var(--acc)`, `var(--ink)`…). Not one `#hex`,
`rgb()`, named colour or colour inside an inline SVG outside
`src/theme/tokens.css`, and inside it only in a theme block
(`:root, [data-theme='dark']`, `[data-theme='light']`), so every colour is one a
theme can redeclare. `color-scheme` too: only a theme block declares it, and
every element inherits it. **`npm run lint:colours` (`dev/check-colour-tokens.mjs`)
enforces it**, and `npm test` runs it too. What genuinely cannot be a token (the
white behind the 2FA QR code) is allowed by file and literal in that script, with
its reason.
- **One CSS module per component** (`X.module.css`).
- **Spacing, type and radii by token too.** In a `*.module.css` you do not write
a px that is not one of the tokens in `theme/tokens.css`. A loose value is
future drift: that is how the console reached 13 text sizes and 25 paddings.
- **Upstream's themes, without Amber.** System (the default), Light and Dark,
picked in `Change Theme` (deviation 1 below). Each theme declares its colours in
its own block of `src/theme/tokens.css`, and `npm run lint:colours` fails if the
light block leaves out a colour the dark one declares. A theme is measured before
it ships: contrast and chart distance against each one, not only against dark,
with `node dev/theme-contrast.mjs` (every text and control token on every
surface it is drawn on) and `node dev/palette-distance.mjs` (the chart series).
- **Everything is in ENGLISH**: the interface, the code, the comments and the
tests. The console is `lang="en"` and the destination is a pull request
upstream. **`npm run lint:language` is what enforces it**, and it exists because
the rule was broken for three weeks without anybody noticing: the visual
redesign left 1,098 pieces of Spanish across 67 files (comments, identifiers and
nineteen file names) while the interface stayed English and every other gate
stayed green. A rule nothing measures is a preference.
- **A name is never cut letter by letter.** `word-break: break-all`,
`word-break: break-word` and `overflow-wrap: anywhere` are refused by
`npm run lint:breaks` (`dev/check-word-breaks.mjs`, also run by `npm test`) outside a short list
of things with no words (keys, signatures, raw output), each with its reason. A
DNS name goes through `ui/DomainName`, a URL through `ui/UrlText`, other text
through `overflow-wrap: break-word`.
- **No `BrowserRouter`**: the server's only `MapFallback` is `/api/{*path}`.
- **No CDN and no fonts in `data:`**: the server's CSP does not declare
`font-src`. Images in `data:` are fine (`img-src 'self' data:`).
## Tests
- `npm test`: vitest. **Do not run `npm run build`** if someone else is
working in the same checkout: it writes into `dist/` and you would step on each other.
- Every screen needs tests for: **the literal alert texts**, the **validation
order**, which endpoint is called and with what body, and the behaviour with
empty data.
- Query by label (`getByLabelText`), not by class.
- **Do not assume a call is the first one**: find it.
`spy.mock.calls.find(c => c[0] === 'zones/list')`.
- With fake clocks, `findBy*` does not work: use
`vi.useFakeTimers({ shouldAdvanceTime: true })` and
`userEvent.setup({ delay: null })`.
- **No apostrophes in a test description written with single quotes.** "the
server's failure" inside `it('…')` does not parse, and it takes the whole file
with it. Reword it: "the failure from the server".
## Deliberate deviations from upstream behaviour
The rule is "zero functionality", with the exceptions below **decided and
written down**. Additional exceptions need the maintainer's authorization.
Security fixes authorized on 2026-10-04 add one transport exception: DNSSEC
signing and private-key import use POST form bodies instead of GET query
parameters. Upstream accepts both methods and reads these fields with
`GetQueryOrForm`. The endpoint, fields and user actions remain the same. Session
revocation and expiry cleanup are bound to the affected token rather than
mutable shared storage. Installer filesystem requirements are recorded in its
contract and SECURITY.md.
1. **Amber is not offered** (Adrián's decision). `Change Theme` is back as
upstream has it: in the account menu, under the same `localStorage` key,
`theme`, as the stock console (same origin, so a choice made in one holds in
the other), with `system`, `light` or `dark`. System is the default and follows
`prefers-color-scheme` live; a radio applies on click; nothing is written on
load. Only the Amber radio is missing: a stored `amber` draws dark, is not
overwritten, and the dialog shows no radio checked until the user picks. It is
still the only one that *removes* something. The rules are in
`src/theme/theme.ts`.
2. **The Blocking section** (`src/screens/blocking/`; what it offers is listed
in the CHANGELOG, v1.2.0). It replaces Allowed and Blocked
with Overview, Rules and Lists. Its Quick Add also offers the console's own
catalogue (`extra-lists.json`) after Technitium's, and a search field.
Its Overview's "Check, block or allow a domain" panel also has **Check**
(`check-domain.ts`, `DomainCheck.tsx`): it tells whether this server blocks a
name and why (which block lists, with the name the Lists tab gives each; which
of your blocked rules; which of your allow rules lets it through; or not
blocked). It asks what DNS Client asks, `dnsClient/resolve` against
`this-server` (type A, UDP, no DNSSEC validation, no import), and reads the
server's own blocking report from the answer; beside it, `settings/get` and
`allowed/export`, each skipped when its permission is missing. It sends no
`node`, as the Block and Allow beside it. Its query is a real one, counted and
logged by the server like any DNS Client query. When the answer cannot prove
the verdict it says so instead of guessing: "Cannot tell" when `Allow TXT
Blocking Report` is off or when the `Blocking Bypass List` covers the server's
own address, and a "Not blocked" lists the reads it did not have. Its words,
all OURS: `Check`, the `Check, ` of the panel title, `Please enter a domain name
to check.`, the verdict titles (`Blocked by a block list`, `Blocked by N block
lists`, `Blocked by your rule`, `Blocked`, `Allowed by your rule`, `Not
blocked`, `Not blocked: blocking is disabled`, `Not blocked: blocking is
paused`, `Cannot tell`), `Listed as`, `Rule`, `Open Lists`, `Open Rules`, the
two "Cannot tell" sentences and the four "not checked" / "Could not read"
lines. Its next step is the panel's own `Block` and `Allow` and the tab that
holds the rule or the list: it adds no action.
The section is the only one that *adds* a screen. It stays inside four limits, and a
change that crosses one is a bug:
- **only endpoints the server already has**, and only ones upstream's console
already calls, save `dashboard/metrics/json` for its live Last Hour
(exception 5). `dev/check-endpoints.mjs` measures the other direction
(upstream's endpoints this console covers), so a new path is checked by
hand against upstream's `www/js`;
- **upstream's actions, sentences and call sequences**: `Allow Domain` is
still `blocked/delete` then `allowed/add` (`api/blocking.ts`);
- **what is ours is in English and says so** in the header comment of its
file;
- **Settings › Blocking is untouched**: the Lists tab edits the same
`blockListUrls`, through the same cleaning (`list-lines.ts` calls
Settings' `cleanList`).
Two behaviours a maintainer would not guess:
- **`/allowed/` and `/blocked/` still exist as folders.** They are legacy
routes (`LEGACY_ROUTES` in `app/static-routes.ts`): `translateLegacyRoute()`
in `app/route.ts` rewrites them, with `replaceState`, to
`blocking/rules/?rule=allowed|blocked` before the route is read, so old
bookmarks land on the filtered Rules tab.
- **`?rule=` belongs to Rules alone.** `Blocking.tsx` strips it on switching to
Overview or Lists and on leaving the section, by replacing the current
history entry, which is the Rules one. So Back after leaving Rules returns
to Rules showing All, not the filter the user had. That is deliberate:
keeping it would mean teaching `writeRoute` about one section's query.
Three additions approved on 2026-10-15, inside the same four limits:
- **Groups in Lists.** A comment line of `blockListUrls` is a group heading:
the lists after it belong to it until the next heading, and the lists before
the first one have no group. Headings can be added, renamed and removed (their
lists stay, joining the group above), a new list can be put into a group, and
a list moves up or down, across headings too. All of it is a change to the
same lines, kept until Save. A heading is saved as `# Name` and refused when
the server would not keep it as one comment line: a comma (the field travels
comma-separated), a newline, more than 253 characters, or a name that reads as
a list URL. Settings › Blocking still shows the headings as the comments they
are. Order changes nothing that is blocked (`list-lines.ts`, GROUPS, with the
server lines that say so).
- **Rules' table deletes without asking, and offers Undo.** Its Delete sends
`blocked/delete` or `allowed/delete` at once and leaves a notice (`ui/Toast`)
whose Undo sends `blocked/add` or `allowed/add` for exactly the domains
deleted, each to its list. Further deletes while it is up join it. Without
`Modify` on the rule's list Undo could not work, so that Delete still asks.
Upstream's confirmation stays everywhere else: the Tree view, Flush and every
other section. This is the only place in the console where a confirmation was
replaced.
- **The sidebar marks Blocking when blocking is off or paused**, with the time
it comes back in the entry's accessible name and title. It reads what the
Overview's status block reads (`settings/get`, on the connected node), never
without `Settings.canView`, and again after any settings write made from this
console, when the browser tab is shown again and a second after a pause ends.
No other sidebar entry carries a mark or a count.
And words of its own in three places, approved on 2026-10-15, all OURS and
declared in the header comment of the file that writes them:
- **a subtitle under each tab's title**, one plain sentence saying what the tab
is for (`OVERVIEW_SUBTITLE`, `RULES_SUBTITLE`, `LISTS_SUBTITLE`, drawn by
`ui/SectionHeader`'s `subtitle`). No other section has one: they keep upstream's
texts only. A sentence that stops being true of its tab changes in the same
commit;
- **Lists' save bar says what changed**: `Unsaved changes to the block list URLs:`
followed by the counts `changesOf` computes (`list-lines.ts`, WHAT CHANGED):
added, removed, switched on, switched off, changed, moved, and groups added,
renamed and removed. Only the non-zero ones are written;
- **the Lists tab carries a dot while there are unsaved changes**, read as
`Lists, unsaved changes` (`ui/SubTabs`'s `marks`). The edits still live only
while the tab is open, as before: leaving it leaves them, and the dot with them.
3. **Settings jumps to the sub-tab of the invalid field.** Upstream focuses a
hidden input and the user sees nothing; with one panel mounted at a time,
without that jump the alert would be impossible to resolve.
And for the same reason (one save sends the fields of all nine panes, wherever
you are), **Settings says what the save would change** (approved on
2026-10-16, `screens/settings/pending.ts`). While a field would be sent with a
value other than the one the server returned, the save bar reads
`Unsaved changes:` followed by the panes that hold one and how many fields each,
`General (2), Cache (1)`, in tab order, and each of those sub-tabs carries the
dot Lists carries, read as `<Tab>, unsaved changes` (`ui/SubTabs`'s `marks`).
Those two phrases are the only new words; the pane names are upstream's tab
labels. It compares what the save would send, with the save's own cleaning, so
a field put back as it was, a blank line in a list and an untouched field never
count; nor does a field the save would not send (outside the chosen node's
scope, or a proxy detail with no proxy type), nor the minutes of `Temporary
Disable Now`, which is not a setting. It only displays: saving, Backup,
Restore, Flush Cache and the blocking buttons behave exactly as before.
4. **The "Enable DNS-over-HTTP/3" checkbox re-enables itself.** In upstream it
stays dead until the page is reloaded because nothing re-evaluates its state:
that is a bug of theirs, and replicating it would mean introducing the fault
on purpose.
5. **Last Hour moves in real time** (Adrián's decision, 2026-10-03), on the
Dashboard and on Blocking › Overview, which read the same `stats/get`.
Between the 60-second reloads the count tiles, the Queries chart and the
Query Response Types doughnut (and on Overview its figures, sparklines, bars
and ring) follow the server's lifetime counters (`dashboard/metrics/json`),
read every 2 seconds (`dashboard/live.ts`, `dashboard/useLive.ts`). Overview
gained the minute reload with it. Live readings keep their timestamps across
reloads: only readings after the last server UTC label are added. Labels name
the end of each closed minute (StatsManager.cs:675-704), so a response that has
not consolidated a pending reading cannot erase it. The history is bounded
to the displayed hour; gaps between measured minutes occupy chart slots.
A polling interval that straddles the server boundary is assigned to its end
until a later consolidated response replaces that approximation.
A chart takes new figures in place and
without animation (`Chart.tsx`), and a legend's switched-off series only come
back with a new response, as upstream's rebuilt chart does. Its limits, and
crossing one is a bug:
- **only that endpoint**, which upstream documents in APIDOCS.md and its
console does not call; `check-endpoints.mjs` counts upstream's endpoints, so
it does not object;
- **Clients, Query Types, Protocol Types, the tops and the Server panel never
move**: the counters do not carry them, and distinct clients do not add up;
- **if the endpoint fails three times in a row, live mode stops** until the
next reload, and the screen is exactly what it was without it;
- **Recently Blocked follows the displayed Blocked count** through its existing
`logs/query` reads. Background refreshes keep the previous rows, serialize
requests and invalidate queued work when token, node or permissions change;
- **a hidden tab reads nothing**, the minute refresh included, and coming
back reloads at once. That pause is the only change to an upstream request.
6. **The login screen has a cover** (Adrián's decision, 2026-10-15): a brand
panel beside the stock form above 960 px, and a header above it below,
compact under 560 px (`screens/LoginCover.tsx`). It changes the layout and
nothing else. Its limits, and crossing one is a bug:
- **the form is upstream's, whole**: every field, text, step (the OTP one
included), validation, alert and behaviour of `Login.tsx` is untouched, and
it stays alone inside `<main>`; the cover is an `<aside>` outside it, and
upstream's `Technitium DNS Server` is still the only `<h1>`;
- **its words are ours and say so** in the header comment of its file: the
name, the headline and the four TXT records. They must not age (no "new")
and each must be true of the console as it ships; a line that stops being
true changes in the same commit. It shows the console's version from the
build and nothing else: no screenshot and no figure, since nobody has
logged in yet and anything that looks like data would be invented;
- **it calls nothing**: no endpoint, no asset beyond the fonts the console
already ships, and its colours are theme tokens measured on `--field` by
`dev/theme-contrast.mjs`. Its only motion is the answer rows settling in,
and `prefers-reduced-motion` removes it.
7. **The Dashboard compares and jumps** (Adrián's decision, 2026-10-15). It adds
two things to the stock Dashboard (`screens/dashboard/compare.ts`, `jump.ts`):
- **each figure's change against the previous period of the same length**, as
an arrow and a percentage on its card, with what it is measured against
written once, in the `Total Queries` card ("vs previous hour"). The
previous period is a second `dashboard/stats/get` with `type=Custom`, whose
instants are worked out from the labels of the answer on screen, so they follow
the server's clock, its node and its grouping. Measured against v15.6.1 and
v15.5.1 on 2026-10-15: Custom over a fixed period's own window gives the same
ten counters, bucket by bucket, for all five fixed periods, and the same
Clients wherever the request is exactly the window. Clients gets no change for
Last Week (seven days by day cannot be asked; eight are, and the counters are
summed over seven), for Last Year (asked by day; the server reaches a distinct
count of months and of days through different merges) and for a minute that
kept only part of its clients (more than 1,000). The other ten compare in
every period;
- **a jump from a chart to Query Logs**: a point of `Queries` opens its minute,
hour, day or month as `From`/`To`; a segment of the `Query Response Types`,
`Query Types` or `Protocol Types` bar opens that value over the period, where
Query Logs has a field for it (`Dropped` and `Others` do not). It is the jump
of the Top lists' "Show Query Logs" (`app/handoff.ts`), with the same node
rule, the same arrival and the same alert when no logging app is installed.
The three breakdowns are stacked bars in one card (the cockpit layout,
Adrián's choice of 2026-10-15, `Breakdown.tsx`). Query Response Types keeps
the stock legend's click-to-hide; on Query Types and Protocol Types there is
no slice to hide, so their legend entries are the jump instead;
- **a week by hour** (`Heatmap.tsx`, `heatmap.ts`): seven days of the browser's
calendar by its 24 hours, each cell the queries of that hour, opening Query
Logs for that hour on click. Custom groups by hour only up to 157 hours, so
the week is asked in two back-to-back pieces and stitched; an hour that comes
twice or not at all leaves the panel saying it could not load, never a double
count or a quiet hole. Checked on 2026-10-15 against v15.6.1: the stitched
hours equal Last Day's 24 and a single 157-hour request, hour by hour. The hour
in progress is left out, as Last Day leaves it. It is asked once per node and
per hour, not on each minute reload.
Its limits, and crossing one is a bug:
- **only `dashboard/stats/get`**, which the stock Dashboard already calls, and
Query Logs' existing fields. A Last Hour reload waits for both reads so the
changes do not blink; other periods ask once more per load; the week asks one
or two more per hour;
- **no change is drawn unless it is exact and current**: never while another
period or node is loading, never after a failed read (of either period),
never for a figure whose previous value is zero, never when the previous
period has no queries at all (a server keeping statistics in memory only, or
one that was not running yet). A previous period the server only partly
recorded (it started inside it, or its stats files were deleted) is compared
as recorded: the API cannot tell a quiet hour from a missing one;
- **upstream's texts are untouched.** What is ours is in English, declared in the
header comment of each file that writes it: "vs", "previous hour",
"previous 24 hours", "previous 7 days", "previous 31 days", "previous 12
months", "previous N minutes/hours/days" (`compare.ts`, `Figures.tsx`,
`Dashboard.tsx`), the Queries hint "Click a point to open Query Logs for that
minute/hour/day/month" (`Dashboard.tsx`), the week's title "Queries per Hour,
Last 7 Days", its weekday names, each cell's "HH:00 to HH:00: N queries", its
hint "Click a cell for that hour's Query Logs" and its scale's "Quiet" and
"Busy" (`Heatmap.tsx`, `heatmap.ts`), and the legend entries' "Show Query Logs"
title, which is upstream's own menu text. The two hints show only when the
click can land;
- **pointer only on the canvas and the bars.** A point of `Queries` and a
segment of a breakdown bar take a click but no keyboard focus; the Query Types
and Protocol Types legend entries are buttons, and the week is one keyboard
stop whose arrows move between its cells.
8. **Query Logs folds its filters below 960 px** (approved on 2026-10-16;
`FOLD_QUERY` in `screens/logs/QueryLogs.tsx`). At 390x844 the fourteen filters
were one column 1,173 px tall and the first result row ended at y=1,905; at 768
the table still started at y=1,001. Below 960 the panel closes to its title,
which becomes the toggle, and `Query` closes it again; the legend keeps its seven
pills and puts their conditions behind its title. Above 960 nothing of this
exists. Its limits, and crossing one is a bug:
- **a folded panel says what is set**: every field that differs from what
`Reset` gives, by its field's own label and with the value the field shows
(`activeFilters`), so a filtered table never reads as the whole log;
- **nothing it sends changes**: the fields stay mounted while folded, and Query,
Export, Reset, Live Update and the pager send exactly what they sent;
- **a validation still lands on its field**: a folded panel opens before
upstream's focus (logs.js:389-424 and 612-625) moves to the field that failed.
Its words: none new. The toggles are the panel's existing title, `Filters`, and
the legend's, `Row colours`; the list is the fields' own labels, each followed by
a colon and its value.
9. **About has two panels for this console** (Adrián's decision, 2026-10-16;
`screens/about/About.tsx`). Upstream's About speaks only of the server, and its
card stays literal. Under the Server panel, `Web Console` gives this console's
version (`__CONSOLE_VERSION__`), its license, one sentence saying it is not
Technitium's, and four destinations as rows: `Source code`, `Release notes`,
`Report a problem` (so a console fault does not go to Technitium's support) and
`Ideas and questions`. `Support the Console` has one sentence, `Star on GitHub`
and the four destinations of `.github/FUNDING.yml` as buttons. Its words are all
ours and follow the README's `Support` and `Licence`.
10. **The console tells you when a newer console is out** (Adrián's decision,
2026-10-18; `app/console-update.ts`, `app/Versions.tsx`, `update-check/`). The
browser cannot ask GitHub (the CSP below has no `connect-src`), so the console
asks its own server, with `dnsClient/resolve`, for the TXT of
`latest.technitium-console.pages.dev` against
`https://technitium-console.pages.dev/dns-query` (`protocol=HTTPS`, no DNSSEC,
no import). That endpoint is this repo's `update-check/`, a Cloudflare Pages
Function that answers `v=1 latest=X.Y.Z server=A.B`. When a newer release is
made for the running server, the *Web Console* row gets the same `Update`
mark as the server's, and a dialog with the two commands from the README,
each with a Copy button (the console cannot run them: no call of the server's
API replaces its files). Its limits, and a change that crosses one is a bug:
- **only that call**, and **at most once a day per browser**: the result,
failures included, is kept in `localStorage` under `consoleUpdateCheck`. A
failed call writes a line in the server's log (`DnsWebService.cs:2556`), so
one a day is the ceiling;
- **only when `user/checkForUpdate` answers `dnsServerEnableCheckForUpdate:
true`** and the session has *DNS Client: View*. The server's *Check for
update* and a silenced notice turn both notices off; there is no setting of
its own;
- **never with `node`**: every node of a cluster serves its own console;
- **silent on any failure**, and nothing from the answer is shown but the
version numbers, each checked against `^\d+\.\d+(\.\d+)?$`;
- **the record's format is frozen**: consoles already installed read `v=1`,
`latest` and `server`, and ignore keys they do not know. Grow it, never
change it;
- **`server` is the oldest Technitium a release is made for**: it is
`technitiumServerMinimum` in `package.json`, read by the endpoint at the
release's tag, and it is bumped together with the CHANGELOG's table. A
console never announces a release made for a newer server than the one
running;
- the endpoint announces a release only once `latest` on GHCR is that
release's image, so that Docker users who pull get it; and a GitHub rate
limit is a failure, not "nothing to announce": the last good record stands.
Its words, all OURS: `Web Console update available` and `DNS Server update
available` (the two marks' accessible names), `New Web Console Version
Available!`, `To update, run the installer again:`, `On Docker:`, `Release
Notes` and the dialog's `Note!`.
11. **A client with no domain shows no "."** (Adrián's decision, 2026-10-21;
issue #22). Upstream writes `.` under a top client whose `domain` comes
empty (`main.js:2799`, `main.js:2955`), the root, which is what every client
with no PTR gets: most IPv6 clients. Users read it as a stray trailing dot on
the address. The Dashboard's Top Clients card and the Top 1000 modal draw no
domain line for it instead; a client with a domain still shows it. Nothing
else changes: the same call, the same rows, the same name and menu.
## Four constraints the server imposes
These are not preferences: break them and the console works in development and
fails in production.
- **`base: './'`.** The server honours `X-Forwarded-Prefix` and mounts a
`PathBase`. With absolute paths the console works locally and 404s behind a
reverse proxy with a prefix. `dev/check-prefix.sh` checks it.
- **Real routes, one folder per route.** The server's only `MapFallback` is
`/api/{*path}`, so a deep route with no file on disk would 404. The build emits
one folder with its own `index.html` for each of the console's 34 routes (11
sections and 23 sub-sections, from `SECTIONS`), plus the two legacy folders
`/allowed/` and `/blocked/`: 36 in all. The URL is real (no `#/`) and F5 brings
you back where you were, without touching a line of C#. See `vite.config.ts` and
`app/static-routes.ts`.
- **Content-Security-Policy**: `default-src 'self'; script-src 'self'
'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self'
data:`. There is no `font-src`, so **the fonts have to be files served from the
same origin**: a font embedded as a `data:` URI inherits `default-src 'self'`
and does not load. No CDN is possible either.
- **`public/` holds the assets the server or the console still need**:
`favicon.ico`, `robots.txt`, `img/` (including `oidc.png`, which the login uses)
and `json/*-builtin.json`. The build emits them again.
## Closing
When you finish, write down which endpoints you covered and **any upstream
behaviour you discovered that was not already noted**. That last part is the
most valuable thing you can contribute.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
{one line to give the program's name and a brief idea of what it does.}
Copyright (C) {year} {name of author}
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
{project} Copyright (C) {year} {fullname}
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+70
View File
@@ -0,0 +1,70 @@
# Product
## Purpose
An alternative administration console for [Technitium DNS Server](https://github.com/TechnitiumSoftware/DnsServer):
the same API, the same behaviour and the same texts, with the interface rebuilt.
It exists because the server is excellent and its console is the part people meet first. Rebuilding
it is a contained problem: the API is documented and stable, and the console is a static bundle the
server serves from a folder, so it can be replaced without touching anything that resolves.
## The rule the whole thing is built on
> **Design only. Zero functionality.** Any behavioural difference from the stock console is a bug,
> **even when it looks like an improvement.**
Same controls, same steps, same wording, same validation order. A feature that was missing is still
missing. A confirmation that asked twice still asks twice. A literal upstream writes in lower case
stays in lower case.
This is not modesty, it is the only reason it is safe to put a third-party interface in front of
infrastructure: you are changing how the console looks, not what your DNS server does. It is also
what makes the project reviewable: every question of the form "should it do X?" has an answer that
is not an opinion.
## What this project owns
- The **interface**: layout, typography, colour, states, keyboard and screen-reader behaviour, and
how the same object is drawn on every screen.
- **Real URLs.** Thirty-two of them, one per destination, so a section can be bookmarked and the
back button walks the console instead of leaving it.
- **Responsiveness.** The console works at 390 px. The stock one overflows horizontally in twelve of
its sections; this one does not overflow in any.
- **The installer.** On Technitium 15.5+ with systemd, `install.sh` gives the console its own folder
and manages a reversible service drop-in. The first install and uninstall restart once to change
folders; updates do not. Other installs use the server's web root without restarting. Custom
lists survive every path in and out.
## What it refuses to become
- **A second way to configure DNS.** No setting, endpoint, step or validation that the stock console
does not have. If you want the server to do something new, that is a change to the server.
- **A server.** There is no backend here, no database, no account, and nothing is stored beyond the
session token the DNS server issues.
- **A telemetry surface.** Nothing is reported anywhere. The only host this console talks to is the
DNS server it is installed on. Once a day it asks that server to look up whether a newer console
is out (README, *Keeping it up to date*); the request carries nothing about you or your server.
- **A fork of the server.** The
[pull request](https://github.com/TechnitiumSoftware/DnsServer/pull/2128) that proposed merging it
was closed, and that was the right call: the maintainer is not a frontend developer and could not
carry the stack. It ships as an alternative console instead, which is what he suggested.
## Left for later, on purpose
- **Windows.** The installer is Linux only. Windows installs are laid out differently and have their
own installer.
- **The Amber theme.** `Change Theme` offers System, Light and Dark; the stock console's fourth,
Amber, is not drawn. A browser that has it stored gets Dark.
- **Translations.** Every literal is upstream's, in English, and translating them would be the first
behavioural difference.
## How "the same" is checked
Parity is measured, not asserted. `dev/` brings up **two instances of the official Technitium image
side by side** (one serving this console, one untouched) and compares them: the controls on each
screen, the state the server is left in after real actions, the sortable columns, the help texts,
the dialogs one by one.
The tools live in the repository and fail with a non-zero exit code, which is the only reason to
trust them. Each one was proved by breaking something on purpose and watching it complain.
+480
View File
@@ -0,0 +1,480 @@
<div align="center">
# technitium-console
**A modern administration console for [Technitium DNS Server](https://github.com/TechnitiumSoftware/DnsServer).**
Everything the stock console does, with blocking in one place, light and dark themes, and a layout
that works on your phone.
[![License](https://img.shields.io/github/license/byGarcia/technitium-console)](LICENSE)
[![Release](https://img.shields.io/github/v/release/byGarcia/technitium-console?include_prereleases)](https://github.com/byGarcia/technitium-console/releases)
[![Stars](https://img.shields.io/github/stars/byGarcia/technitium-console)](https://github.com/byGarcia/technitium-console/stargazers)
[![Issues](https://img.shields.io/github/issues/byGarcia/technitium-console)](https://github.com/byGarcia/technitium-console/issues)
[![Last commit](https://img.shields.io/github/last-commit/byGarcia/technitium-console)](https://github.com/byGarcia/technitium-console/commits)
</div>
| Dashboard | Blocking |
|---|---|
| ![The dashboard](docs/screenshots/dashboard.png) | ![Blocking › Overview](docs/screenshots/blocking-overview.png) |
**Latest: [v1.3.4](https://github.com/byGarcia/technitium-console/releases/latest) for Technitium DNS
Server 15.5.x and 15.6.x**, with a Dashboard that moves in real time and compares periods, Allowed and Blocked as one
Blocking section, a light theme, plus LDAP, the zone file editor and everything else 15.5 and 15.6
brought.
[What's new](CHANGELOG.md) · [Which console for which server](CHANGELOG.md#which-version-for-which-server) · [Installing](#installing) · [Technitium in Docker](#docker)
It replaces the console the server ships with. Install it and the DNS service
behaves exactly as before; remove it and you are back to the original. Nothing on
the server side changes: this console talks to it only through its documented
`/api` endpoints.
- **Every feature of the stock console**: zones, settings, DHCP, apps, logs and administration,
through the same 132 API endpoints, with Technitium's own users and permissions.
- **A Dashboard that answers at a glance**: every figure with its trend and its change against the
previous period, a week by hour, and charts that open Query Logs on what you click.
- **Blocking in one place**: what it is doing, your own rules and your lists, and a check that tells
you why a domain is blocked, [below](#blocking-in-one-place).
- **Light, dark or your system's theme**, measured for contrast in both.
- **Works on a phone**: no section scrolls sideways at 390 px.
- **Nothing extra to run**: no backend, no database, no second login. It installs into the server's
web folder or a Docker volume, and removing it brings the stock console back.
## Blocking, in one place
The stock console spreads blocking over three places: Allowed and Blocked are two separate domain
trees, and the block lists sit in Settings › Blocking. This console brings them together in one
**Blocking** section, with what blocking is doing, your own rules and your lists each in one place.
It is the one part of the console that goes beyond a new look:
- **Overview** shows what blocking is doing: Total Queries and Blocked with their trend, the blocked
share, allowed against blocked over time, the top blocked domains and the last ten blocked
queries. Disable blocking for one of the stock console's eight durations and see when it comes back.
- **Rules** puts every domain you blocked or allowed by hand in one table, filtered, searchable and
paged however many there are. The domain tree you already know is one switch away.
- **Lists** turns your block and allow lists into rows, each with its readable name, an Enabled
switch and Remove. Add one by URL or with Quick Add, update them all with Update Now, and nothing
changes until you Save.
- **Check a domain** and see whether this server blocks it and why: which block lists, which of your
rules, or which allow rule lets it through.
- **Group your lists** under named headings, saved as plain comment lines, so the stock console still
reads them.
- **Undo** a rule you deleted from the Rules table, and see in the sidebar when blocking is off or
paused.
- **Block or allow a domain** from Overview or Rules, or from the menu on any row of Top Domains and
Top Blocked Domains.
| Rules | Lists |
|---|---|
| ![Blocking › Rules](docs/screenshots/blocking-rules.png) | ![Blocking › Lists](docs/screenshots/blocking-lists.png) |
## What it looks like
| Zones | Settings |
|---|---|
| ![Zones](docs/screenshots/zones.png) | ![Settings](docs/screenshots/settings.png) |
| Cache, and the tree in Blocking › Rules | Apps |
|---|---|
| ![The domain tree](docs/screenshots/cache.png) | ![Apps](docs/screenshots/apps.png) |
| Query Logs | Login |
|---|---|
| ![Query Logs](docs/screenshots/query-logs.png) | ![The login screen](docs/screenshots/login.png) |
![Blocking › Overview in the light theme](docs/screenshots/blocking-overview-light.png)
**Light or dark, or whatever your system uses**, from Change Theme in the account menu, as in the
stock console. Both themes are measured: every text colour reaches 4.5:1 on every surface it is
drawn on.
The domain tree is one component, shared by Cache and by the Tree view of Blocking › Rules, and the
screens it served used to be indistinguishable. The tree is the object you came to look at, so it is
a column and not a box; the path says where you are; and the colour on the records panel is the one
that already means *cached* and *blocked* in the Dashboard chart and in the Logs rows.
<img src="docs/screenshots/mobile.png" alt="The console at 390 px" width="320" align="right">
**It works on a phone.** The stock console overflows horizontally on a 390 px
screen in twelve of its sections. This one does not overflow in any: the tables
reflow, the navigation collapses, and the actions stay reachable. That was not a
side effect. It was a defect found by measuring every section at 390 px and
fixed one by one.
<br clear="right">
## Safe to put in front of your DNS
**Design only. Zero functionality.** That is the rule the whole project is built on, and it is
worth stating plainly because it is unusual:
> Any behavioural difference from the upstream console is a bug, **even when it
> looks like an improvement**.
Same controls, same steps, same wording, same validation order. If a feature was
missing before, it is still missing. If a confirmation asked twice, it still asks
twice. You are changing how the console looks, not what your DNS server does.
That is the only reason it is safe to put a third-party interface in front of
infrastructure.
A few deliberate exceptions are written down rather than slipped in: **Blocking**,
above; the **Dashboard's** comparisons with the previous period, its week by hour and
its jumps to Query Logs; and the **login cover** beside the stock form. They call only
endpoints the server already has, and each of their files declares the words that are
its own; `/allowed/` and `/blocked/` still land. Their limits, and the other
deliberate deviations, are in
[CONVENTIONS.md](CONVENTIONS.md#deliberate-deviations-from-upstream-behaviour).
## How thoroughly
| | |
|---|---|
| Checked against | Technitium DNS Server 15.6.2 |
| Sections | 11 |
| Routes, each a real URL | 34 |
| API endpoints the stock console calls, and this one calls too | 132 of 132 |
| Sortable columns kept | 64 of 66, and the two missing are declared |
| Dialogs, checked one by one | 44 |
| Tests | 1,838 |
Every figure in that table comes from a script in `dev/` (`check-endpoints.mjs`,
`check-parity-sort.mjs`, `dialog-inventory.sh`) and not from this paragraph.
Parity is not asserted, it is measured. `dev/` brings up **two instances of the
official Technitium image side by side** (one serving this console, one
untouched), and the scripts in there compare them: the controls present on each
screen, the state the server is left in after fourteen real actions, the sortable
columns, the dialogs, the CSS classes nobody uses.
```bash
cd dev && docker compose up -d # this console on :5380, the stock one on :5381
```
## Installing
One command, on the machine where the DNS server runs:
```sh
curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh
```
**Technitium in Docker?** See [Docker](#docker): a small image puts the console in a volume, and
this command, run on a Docker host, prints the exact steps for your containers.
On Technitium 15.5 or later with systemd, it gives the console its own folder
and configures the detected service automatically. **The first install restarts
the DNS service once** to switch folders; updates do not restart it. On older
versions or with `--no-configure-service`, it asks the running server for its
web root, saves the console you have now, and replaces it without a restart.
To go back at any point:
```sh
curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh -s -- --uninstall
```
The managed systemd install switches back to the server's stock console before
removing its folder. Replacement installs restore the copy the installer kept.
Nothing is downloaded to undo either one.
**It works with whichever install you have**, because it does not guess: the
running server is found by what it executes, and a systemd unit is accepted only
when it names that same `DnsServerApp.dll`. If it cannot identify one exact
unit, it leaves service configuration alone and uses the web root it discovered.
**Your custom lists are kept**, on the way in and on the way out. Any
`json/*-custom.json` you wrote by hand is left exactly where it is, including
one you write months after installing, which no backup could contain.
**Nothing else on the server is touched.** Configuration, zones, users, logs and
`/etc/resolv.conf` come out of an install and an uninstall byte for byte the
same. On systemd with Technitium 15.5 or later, it also owns one clearly named
drop-in under `/etc/systemd/system`; uninstall removes it again.
**The installer requires root-owned files and folders.** The console, backup,
installer state and every parent folder must be writable only by root (the root
group counts as root, so a stock web root left 0664 by Technitium's own archive
is accepted, unless an access control list lets another account in; on BusyBox
systems that needs the acl or coreutils package to be checked). Symbolic
links, hard links, special files and newline-containing names are refused before
publication. A service-owned stock console can still be used with a dedicated
`/opt/technitium-console` folder when no custom lists need copying. If custom
lists need migrating from an unsafe tree, copy them yourself after inspecting
them into a fresh root-owned tree. Changing ownership on an existing tree cannot
revoke files another account already has open. For Docker, mount the console
volume read-only in the DNS server.
**An interrupted run cannot leave you without a console.** Files go in before
any are taken out, and every page is published after the assets it names, so at
every moment the server has a whole console to serve: the old one or the new
one. Anything a killed run left behind is cleaned up by the next one.
<details>
<summary>Options</summary>
`--version <tag>` to pin a release, `--from <path>` to install
from a tarball you already downloaded, `--dir <path>` to say where the web root
is, `--url <base>` if your web console does not answer on
`http://127.0.0.1:5380`, `--yes` to skip the confirmation, and
`--no-configure-service` to leave systemd untouched and use the server's own
web root unless you selected another folder yourself.
If the backup was taken from a different version of the DNS server than the one
now running (which happens when the server was updated in between), the
uninstall stops and says so rather than putting an old console in front of a new
server. `--restore-mismatched-backup` overrides that, and `--yes` deliberately
does not.
**Linux only.** Windows installs are laid out differently and have their own
installer; this script does not try to handle them.
</details>
### On Technitium v15.5 or later: give the console a folder of its own
Since v15.5 the server can be told to serve its web console from another folder,
with `DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH`. Do that, and a server update no
longer puts the stock console back: its own `www/` is left untouched and this one
lives somewhere the update does not write.
On a systemd install, the one-line installer does this itself. It identifies the
unit from the running `DnsServerApp.dll`, publishes the complete console into
`/opt/technitium-console`, adds
`/etc/systemd/system/<unit>.d/technitium-console.conf`, and then restarts the
unit once. It verifies that the restarted process has the expected environment
and serves the new assets. If that fails, it removes the drop-in and restarts
back onto the stock console.
Updates publish into the same folder without restarting the DNS service.
`--uninstall` carries custom lists back, removes only an unchanged drop-in,
restarts onto the stock console, verifies it, and only then removes the folder.
If you edited the drop-in after installation, uninstall leaves it and the
console untouched rather than deleting administrator-owned configuration.
**On Proxmox VE with the community script** (`technitiumdns` from
[community-scripts](https://github.com/community-scripts/ProxmoxVE)): run the
one-line installer inside the container (`pct enter <id>`). It detects
`technitium.service`; the script's own *Update* no longer touches the console,
since it lives in its own folder.
For Docker, see [Docker](#docker): the image does all of this with a volume.
> **Without the variable** (before v15.5, or if you prefer not to set it), the
> console replaces the files in the server's own `www/`. Technitium restores its
> console when it updates, so run the installer again afterwards.
### Keeping it up to date
Each console release is checked against one Technitium release: every action it sends, every text
and every control, compared with that server's own console. When you update the server, update the
console with it: run the installer again (on Docker,
`docker compose pull technitium-console && docker compose up -d technitium-console`). It needs no restart, and
[the changelog](CHANGELOG.md#which-version-for-which-server) says which console goes with which
server.
**The console tells you when there is a new one.** An *Update* mark appears next to its version,
at the foot of the side panel, with the commands to run. Only releases made for the server you run
are announced. Your browser cannot ask GitHub (the server does not allow it), so the console asks
your DNS server to look it up: at most once a day per browser, your server sends one HTTPS request
to `technitium-console.pages.dev`, a small endpoint whose code is in
[`update-check/`](update-check/). It sees your server's IP address and nothing else, and the request
does not show in Query Logs. To turn it off, turn off *Check for update* in *Settings › General*,
which also silences the server's own notice, or block `technitium-console.pages.dev`.
## Docker
If Technitium runs in Docker, the console comes as a small image that copies it into a volume and
exits. Your DNS server keeps running the official image: it mounts that volume and is told to
serve it. **It needs Technitium DNS Server 15.5 or later**, the first version that can serve a
folder of its own.
Only the `technitium-console` lines below are new. Add them to your compose file: the variable
and the volume mount on your server, the `technitium-console` service, and the `technitium-console`
volume at the end. The rest stands for what you already have: the `config` lines are your own
`/etc/dns` mount, so keep it exactly as you have it.
```yaml
services:
dns-server:
image: technitium/dns-server:latest
# ports, hostname, restart… as you have them
environment:
- DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console
volumes:
- config:/etc/dns
- technitium-console:/opt/technitium-console:ro
technitium-console: # copies the console into the volume, then exits
image: ghcr.io/bygarcia/technitium-console:latest
volumes:
- technitium-console:/target
restart: "no"
volumes:
config:
technitium-console:
```
```sh
docker compose up -d
```
That restarts the DNS server once, because its environment changed. The console shows up a
second or two later, when the copy is done; until then the page is empty. If the copy ever fails,
the server starts anyway: nothing waits on it.
**Updating restarts nothing:**
```sh
docker compose pull technitium-console && docker compose up -d technitium-console
```
Only the copier runs again, and the server picks the new files up by itself. A plain
`docker compose pull && docker compose up -d` also updates the DNS server when there is a new image
of it, and that does restart it.
**Pinning a version:** use a release number instead of `latest`: `X.Y.Z` for exactly that
release (`ghcr.io/bygarcia/technitium-console:1.2.0`), `X.Y` to follow its fixes (`:1.2`). To go
back, pin the older one and update. Images start at 1.2.0.
`ghcr.io/bygarcia/technitium-console:develop` is built from every push to the `develop` branch,
for trying changes before they are released; it is not for production. Pin a release instead.
If publishing an image fails or is cancelled halfway, `X.Y.Z` (or `develop-<sha7>`) can be left
published before the check that every platform carries exactly the console it was built from.
`latest`, `X.Y` and `develop` move only after that check, so they never point at such an image.
**Custom lists** (`json/*-custom.json`, the files upstream's `www/json/readme.txt` describes) are
kept in the volume across updates. To edit them by hand, use a folder on the host instead of the
volume (`./technitium-console:/opt/technitium-console:ro` on the server and
`./technitium-console:/target` on the copier) and write them in `./technitium-console/json/`.
The folder can hold your lists before the first run. The copied files belong to root, so editing
them takes `sudo`. Removing it is the last of the steps below.
**Without Compose:**
```sh
docker volume create technitium-console
docker run --rm -v technitium-console:/target ghcr.io/bygarcia/technitium-console:latest
```
then add `-e DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console` and
`-v technitium-console:/opt/technitium-console:ro` to your server's `docker run`, and re-create it
once. To update, run `docker pull ghcr.io/bygarcia/technitium-console:latest` and then the second
command again.
**Removing it:**
1. Keep any `json/*-custom.json` you edited: they are in the volume, or in the folder if you use
one.
2. Take the variable and the `technitium-console` volume line out of your server, remove the
`technitium-console` service, and remove the `technitium-console:` entry under the top-level
`volumes:`.
3. `docker compose up -d --remove-orphans`. The server restarts once, back on the console its
image ships, which was never touched.
4. `docker volume rm <project>_technitium-console` (`docker volume ls` shows the exact name).
With a folder on the host instead, delete it now, once the server no longer mounts it; its
files belong to root: `sudo rm -rf ./technitium-console`.
Without Compose: re-create your server without the
`-e DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH=/opt/technitium-console` and
`-v technitium-console:/opt/technitium-console:ro` you added (it restarts once, back on its own
console), and then `docker volume rm technitium-console`.
**The one-line installer on a Docker host, without `--dir`,** does not install anything. It reads
your containers and prints these steps with your own container, service, file and volume names,
and with `--uninstall`, the way out. It does not write into a container because a container's own files are
replaced every time it is recreated: a console copied into one with `docker exec` is gone after
the next image update, and the installer says so if you try.
<details>
<summary>Servers before 15.5, and why not an image mount</summary>
**Before 15.5** the server cannot serve another folder, so the console goes into a folder on the
host mounted over the container's own web root:
```sh
curl -sSL https://raw.githubusercontent.com/byGarcia/technitium-console/main/install.sh | sudo sh -s -- --dir /opt/technitium-console
```
```yaml
volumes:
- /opt/technitium-console:/opt/technitium/dns/www:ro
```
then re-create the container once. To update, run the same command again. To remove it, take the
mount out and re-create the container first, and only then delete the folder. Emptied while still
mounted, it would leave the server serving nothing. The same command with `--uninstall` prints
these steps for your container. Moving to 15.5 or later and the image is the better way.
**An image mount** (`type: image`, Docker Engine 28 and Compose 2.35 or later) is not offered: it
is read-only, so no custom lists; every update would recreate the DNS container; older engines,
like those on many NAS boxes, do not have it; and this image carries the release's tarball, not an
unpacked folder.
</details>
## Building
```bash
npm install
npm run build # emits into dist/
npm run dev # Vite development server
npm test # 1,647 tests
npm run typecheck
npm run lint
```
React 19.2, TypeScript 6.0, Vite 8.2. **npm**, not pnpm or yarn, so building this
needs nothing installed beyond Node.
Before changing anything, read [CONVENTIONS.md](CONVENTIONS.md). It holds the rule
above, the upstream behaviours discovered along the way, and four constraints the
server imposes that will let you break production while development looks fine.
[PRODUCT.md](PRODUCT.md) says what this owns and what it refuses to become, and
[CONTRIBUTING.md](CONTRIBUTING.md) how to run the two-instance harness.
## Status
Feature-complete against Technitium DNS Server v15.5.1 and verified against it:
the LDAP Authentication tab, the zone file editor and the removal of Auto Prefetch
that v15.5 brought are all here. Version 1.0.0 was built against v15.4 and cannot
save Settings on a v15.5 server; use 1.1.0 or later there. Not
merged upstream: the maintainer
[closed the pull request](https://github.com/TechnitiumSoftware/DnsServer/pull/2128)
because he is not a frontend developer and could not maintain the stack through
future releases, and suggested shipping it as an installable alternative console
instead. This repository is that.
## Contributing
[CONTRIBUTING.md](CONTRIBUTING.md) has how to run the two-instance harness and what a good change
looks like here. A vulnerability is not a bug report: [SECURITY.md](SECURITY.md) has its own private
route, and it also says which parts belong upstream instead. Everybody taking part is held to the
[Code of Conduct](CODE_OF_CONDUCT.md). Releases are listed in [CHANGELOG.md](CHANGELOG.md).
---
## ❤️ Support
This console is free and stays free, and it will never ask your DNS server for anything it does not
already answer. If it made the machine your whole network resolves through nicer to look at, there
is [GitHub Sponsors](https://github.com/sponsors/byGarcia),
[Ko-fi](https://ko-fi.com/bygarcia),
[Liberapay](https://liberapay.com/bygarcia) and
[PayPal](https://www.paypal.com/paypalme/adriangmolina). A star or a good bug report is worth just
as much.
And if you have not already: **[Technitium DNS Server](https://github.com/TechnitiumSoftware/DnsServer)
is the thing doing the actual work here**, written and maintained by one person. It takes donations
too, and that is the more useful place to start.
---
## Licence
GPL-3.0-or-later, the same licence as Technitium DNS Server, whose console this
replaces. See [LICENSE](LICENSE).
This is an independent project, not affiliated with or endorsed by Technitium.
The Technitium name and logo belong to Technitium and are used here only to
identify the software this console is built for.
+105
View File
@@ -0,0 +1,105 @@
# Security policy
This console is installed **in front of a DNS server**, which is usually the piece of
infrastructure everything else on a network depends on. It holds an administrator session, it can
change resolution for every client behind it, and `install.sh` runs as root on the machine the
server runs on. Reports are welcome and taken seriously.
## What this project is, and what it is not
The console is a **static front end**. It has no server of its own, no database and no account: it
talks to Technitium DNS Server through its documented `/api` endpoints, with the token the server
issues at login, and it stores nothing but that token in the browser.
That means a whole class of report belongs upstream and not here:
- anything about how the DNS server authenticates, authorises or resolves;
- anything about the `/api` endpoints themselves;
- anything reachable without this console installed.
Those go to
[TechnitiumSoftware/DnsServer](https://github.com/TechnitiumSoftware/DnsServer/security). What
belongs here is anything this console does that the stock console does not.
## Supported versions
There are releases, and `install.sh` fetches the latest by default. **The supported version is the
latest release, plus `main`.** There is no back-porting: a fix lands on `main` and goes out in the
next release.
## Reporting a vulnerability
**Do not open a public issue, a pull request or a discussion.**
Use GitHub's private vulnerability reporting, which is enabled on this repository:
> **[Report a vulnerability](https://github.com/byGarcia/technitium-console/security/advisories/new)**
> (or the *Security* tab → *Advisories* → *Report a vulnerability*).
The thread is private between you and the maintainer, and it becomes the advisory if the report is
confirmed. If you would rather not use GitHub, say so in a public issue **without any detail** and a
private channel will be arranged.
### What helps
- The version: a release tag, or the commit SHA from the About screen.
- The Technitium DNS Server version, which the same screen shows.
- Whether the console was installed with `install.sh` or by hand, and to which path.
- Steps, and what an attacker gets out of it.
### What to expect
- **Acknowledgement within a week.** This is a single-maintainer project, not a company.
- An assessment, and a fix or a reasoned refusal.
- Credit in the advisory if you want it.
There is no bounty.
## Things worth knowing before you report
- **The token lives in `localStorage`.** So does the stock console's. That is a deliberate parity
decision, not an oversight: this console is not allowed to change how sessions behave.
- **`install.sh` runs as root.** It writes only to the selected console folder, a replacement-mode
backup, `/var/lib/technitium-console`, and, on a managed systemd install, its own
`/etc/systemd/system/<unit>.d/technitium-console.conf`. The first managed install and its
uninstall restart the detected unit once to change folders; updates do not. It will only write
into a folder that is empty, holds a Technitium console, or is the one it recorded installing
into. It only believes a running server started by root or by the service's own account, does not
write through symbolic links it found by itself, and removes only an unchanged drop-in it owns.
`--uninstall` only removes the recorded folder while it still holds this console. A release
download is checked against the `.sha256` the release publishes; `--from` is not, and says so.
`--version` takes only a release tag. On a Docker host it changes nothing and only prints
commands; names read from containers are quoted in them and stripped of control characters. If
you find it writing anywhere else, that is a bug and a serious one.
- **Piping a script from the internet into `sudo sh`** is the documented install path and it is a
real trade-off. Download it, read it, run it. The file is POSIX shell and is meant to be read.
- **The console is served by the DNS server's own web service**, so its TLS, its bind address and
its authentication are the server's. Nothing here changes them.
## Installer filesystem boundary
The installer accepts only root-owned console, backup and state trees, with no
other write bit on files or directories, including every ancestor, and a group
write bit only when the group is root (gid 0), as sudo does for sudoers.
Technitium's own archive ships its files 0664, so a stock web root extracted by
root is root:root 0664 and is accepted.
That group bit is trusted only when no object in the tree carries a POSIX access
control list: on one that does, the group bits are the list's mask, and can stand
for another account that setfacl let write. The installer reads lists from the
`+` that GNU or uutils `ls -l` shows, or from `getfacl` where ls cannot show them
(BusyBox); with neither, group write is not trusted at all.
A folder named by the running server's `DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH`
is created only when it is `/opt/technitium-console`, where the README puts it:
the service's own account can set that variable, so any other folder that does
not exist yet has to be created by the administrator first.
It rejects links, special files and newline-containing names before root copies
or cleanup. It refuses unsafe ownership instead of trying to repair it while
another account may still hold writable files open. Use a dedicated root-owned
folder created afresh or a console volume mounted read-only in the DNS server. Custom-list
migration has the same source and destination requirements.
These checks assume local filesystem ownership and mode enforcement. A remote
filesystem whose server can change those objects is a separate trust boundary.
DNSSEC private-key submissions use POST form bodies, keeping PEM values out of
request URLs. Logout and expiry cleanup use the affected session's token, even
when another tab has replaced the token in shared browser storage.
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/theme/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+51
View File
@@ -0,0 +1,51 @@
# Parity checks
## `check-parity-actions.sh`: the one that actually counts
Runs **the same action on both instances** and compares the state the server is
left in, not the bytes of the page. This is the check that makes the project's
governing constraint enforceable: since behaviour does not change, if the body
the new console sends differs by one parameter, the record left behind differs
and it shows up here.
```bash
docker compose up -d
./check-parity-actions.sh
```
It covers fourteen actions: seven creates (including the awkward ones: split TXT,
SVCB with flattened `svcParams`, CAA with its default values), three edits
(among them disabling a record, which upstream does as a `records/update`
resending the whole record), three deletes (including CNAME, which falls to the
`default` branch and sends no identity parameter at all) and the `options/set`
with all six empty lists travelling as the string `"false"`.
It normalises three things before comparing, and all three because they are not
parity: `lastModified`, the SOA serial and **the server's own name**, which is
embedded in the NS and SOA of every zone and is deliberately different in each
container.
## `check-parity.sh`: only for the preserved files
Compares the content of a path between the two instances. The console is ours,
so **for `/` it reports `DIFFERENT` and that is the point of the project**. It
still serves for what the build preserves untouched:
```bash
./check-parity.sh /robots.txt # IDENTICAL
./check-parity.sh /favicon.ico # IDENTICAL
```
## `parity-login.mjs`
Compares the alerts on the login screen between the new console (5380) and
upstream's (5381). Needs Playwright:
```bash
npm i -D playwright && npx playwright install chromium
node parity-login.mjs
```
It found two real divergences the first time it ran: the "×" button to dismiss
the alert was missing, upstream has it, so its absence was a behavioural
difference, and so was the space between the title and the text.
+142
View File
@@ -0,0 +1,142 @@
# Development harness
Two Technitium instances in Docker, to develop and verify the console redesign
without touching any real DNS server.
| Instance | Port | What it serves |
|---|---|---|
| `dev` | http://127.0.0.1:5380 | `dist/` mounted from the repo |
| `ref` | http://127.0.0.1:5381 | the `www` from the official image, untouched |
| `node2` | http://127.0.0.1:5382 | `dist/` too: the second node of the cluster |
User `admin`, password `technitium-ui-dev` on all three.
`node2` mounts the same build as `dev` on purpose: it is the only way to see a
screen **from the other side of a cluster**, and that is where half of `Cluster`'s
behaviour lives.
## The installer has its own bench
`sh dev/installer-probe.sh` measures `install.sh` against
`docs/2026-09-07-installer-contract.md`: thirty-one cases, one throwaway
container each off the same official image, exit code = cases failed. It does not
use the three containers below and does not touch them. C26 to C30 (2026-10-01)
also build the Docker init image from `dist/` and bring up a compose project of
their own, `installer-probe-<pid>`; they pull `busybox:stable` and `docker:cli` the
first time, and C30 mounts the Docker socket into `docker:cli`. `INSTALLER=<file>`
measures another `install.sh`: that is how the cases added on 2026-09-30 were
seen failing on the old script before they were seen passing on the new one.
The mode B cases need a server that honours
`DNS_SERVER_WEB_SERVICE_WWW_FOLDER_PATH`. **Since v15.5 (2026-09-19) the official
image does**, so they run against `technitium/dns-server:latest` with no build:
30 met, 1 not applicable (C15, which needs a server that ignores the variable).
They are not skipped by decree: the bench asks the image and believes the answer.
Before v15.5 the only server that honoured it was one built from the fork's
`feat/configurable-www-folder` branch, and later from `upstream/develop`. How that
was built (`TechnitiumLibrary` from its `develop` branch beside the server, five
`dotnet build` and a `dotnet publish` in `mcr.microsoft.com/dotnet/sdk:10.0`) is
in this file's history (`git log -p dev/README.md`); it is not needed any more.
## The cluster is DOWN: it was up on purpose, and it did its job
`dev` and `node2` formed a real two-node cluster: domain `cluster.test`, `dev`
primary and `node2` secondary from 2026-09-04 until Administration was built.
**It was not incidental.** `screens/admin/Cluster.tsx` is 1,629 lines, the largest
screen in the console, and without a cluster the harness only ever shows "Cluster
Not Initialized" and two buttons. Building against that would have meant building
only its empty state. With it, the screen was read **from both sides**: and that is how the
`Edit Node` rule turned out to depend on *who is looking*, not on the row:
```
isPrimary ? n.state === 'Self' : n.state === 'Self' || n.type === 'Primary'
```
### Taken down on 2026-09-04, and how
The condition it was kept for (**Administration built, recaptured and the gate
green**) was met: the section was built from both sides, `Edit Node` was checked on
the primary (one row) and on a secondary (two, its own and the primary's), and the
four `Force` dialogs were opened against a real cluster.
It came down **through the product's own path and without forcing**, which is also
the last check those two endpoints get:
```bash
# 1 · the secondary leaves, coordinating with the primary
T2=$(curl -s 'http://127.0.0.1:5382/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s "http://127.0.0.1:5382/api/admin/cluster/secondary/leave?token=$T2&forceLeave=false&node="
# 2 · with no secondaries left, the primary deletes the cluster
TOK=$(curl -s 'http://127.0.0.1:5380/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s "http://127.0.0.1:5380/api/admin/cluster/primary/delete?token=$TOK&forceDelete=false&node="
# 3 · and the zone the initialisation created, which upstream leaves behind
# ("no data loss except for the Cluster configuration")
curl -s "http://127.0.0.1:5380/api/zones/delete?token=$TOK&zone=cluster.test"
```
**Order matters and `force` is not needed**: unforced, `primary/delete` only works
once there are no secondaries, which is exactly why step 1 goes first. Doing it
this way is the only end-to-end exercise those two endpoints have had against a
real cluster.
The harness is back to its usual state: **six zones on `dev`, no cluster**, the
three containers up and the server domains back to `dev` and `node2`.
```bash
# how it was created, if it ever has to be rebuilt
TOK=$(curl -s 'http://127.0.0.1:5380/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s -G http://127.0.0.1:5380/api/admin/cluster/init \
--data-urlencode "token=$TOK" \
--data-urlencode 'clusterDomain=cluster.test' \
--data-urlencode 'primaryNodeIpAddresses=172.23.0.4' # the docker IP of `dev`
T2=$(curl -s 'http://127.0.0.1:5382/api/user/login?user=admin&pass=technitium-ui-dev' | jq -r .token)
curl -s -X POST http://127.0.0.1:5382/api/admin/cluster/initJoin \
--data-urlencode "token=$T2" \
--data-urlencode 'secondaryNodeIpAddresses=172.23.0.3' \
--data-urlencode 'primaryNodeUrl=https://dev.cluster.test:53443/' \
--data-urlencode 'primaryNodeIpAddress=172.23.0.4' \
--data-urlencode 'ignoreCertificateErrors=true' \
--data-urlencode 'primaryNodeUsername=admin' \
--data-urlencode 'primaryNodePassword=technitium-ui-dev' \
--data-urlencode 'primaryNodeTotp='
```
Two things that cost a try each: the domain cannot be one that already exists as a
non-primary zone (`lab.test` was rejected) and **the join needs the primary's
DOMAIN NAME, not its IP**, even though it takes the IP as a separate parameter.
The IPs are the container ones and change if the network is recreated:
`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' technitium-ui-dev`.
```bash
docker compose up -d # start
./check-parity.sh # compare dev's front page against ref
./check-parity.sh /api/ # compare another path
docker compose down -v # tear everything down, config included
```
`ref` is the reference truth: the project's constraint is that behaviour must not
change, so any non-visual divergence between the two is a bug.
Port 53 is not mapped (`systemd-resolved` holds it on WSL) and DHCP is not
enabled: only the web console is exercised here.
## Why the comparison normalises line endings
`check-parity.sh` strips `\r` before hashing, so it compares content and not
bytes. This is necessary, not cosmetic:
Upstream's `.gitattributes` declares `* text=auto`. Git stores **LF** in the
repository blobs and converts on checkout according to the platform. Our tree on
Linux has LF (`git status` sees it clean, it is byte-correct with respect to
the repository), while **the official Docker image ships CRLF**, because it was
built on Windows.
Without normalising, two files with identical content come out different by one
byte per line: in `index.html` that is 7,426 bytes of difference out of 619,718.
Found when running the first parity check.
+281
View File
@@ -0,0 +1,281 @@
/**
* The guard that keeps every colour in `src/theme/tokens.css`.
*
* `CONVENTIONS.md` has said "colours always by token, not one `#hex` outside
* `tokens.css`" from the start, and on 2026-10-01 there were 44 colour
* literals outside it, in 14 files: the hover border of a button, the veil behind
* a dialog, the shine on a ticked checkbox, eight fallbacks for the chart palette.
* None of them was wrong for the one theme the console had. Every one of them is
* a place a second theme cannot reach, which is why the rule stopped being a
* preference the day a light theme was decided. This is what measures it.
*
* It looks at the `.css`, `.ts` and `.tsx` files in `src/`, with their comments
* stripped (a comment that quotes a value is quoting, not painting), and finds:
*
* - `#hex`, 3, 4, 6 or 8 digits, and its URL-encoded form `%23hex`, which is how
* a colour hides inside an inline SVG in a `data:` URI.
* - The colour functions: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `lab()`,
* `lch()`, `oklab()`, `oklch()`, `color()`.
* - The CSS named colours (`white`, `black`, `red`…): in a stylesheet, as a word
* in a declaration's value; in TypeScript, as a whole string literal, which is
* how an inline style writes one.
*
* And inside `tokens.css` itself it checks the other half of the rule: a colour
* literal is only allowed in a THEME block (`:root, [data-theme='dark']` or
* `[data-theme='light']`). A colour in the plain `:root` would be one no theme
* can override. And the light block declares every property the dark block
* does: one it forgets falls back to `:root`, which carries dark, and paints a
* dark value on a light page without anything else noticing.
*
* `color-scheme` follows the same rule, because it is colour too: it decides the
* browser's own parts (scrollbars, date pickers, form controls), and an element
* that pins it draws them dark on a light page or the other way round. So a
* `color-scheme` declaration is only allowed in a theme block of `tokens.css`;
* anywhere else, a stylesheet declaration of it, or an inline style that sets it
* to a fixed string, is a finding. Setting it from the resolved theme
* (`root.style.colorScheme = resolved` in `theme/theme.ts`) is not pinning it, and
* `prefers-color-scheme` is a media query, not a declaration. There is no
* allow-list for it: an element that needs its own scheme needs a theme to say so.
*
* What it deliberately does NOT look at:
*
* - Tests (`*.test.*`, `src/test/`). A test may write a colour as data.
* - `color-mix()` and `transparent`: they compose tokens, they are not colours of
* their own. A `color-mix()` with a literal inside is still caught, by the
* literal.
* - Named colours inside a longer TypeScript string (`'1px solid white'`). None
* exists today; a parser would be needed to tell them from interface text.
*
* A literal that has to stay where it is goes in `ALLOWED`, by file AND literal,
* with its reason. An entry that no longer matches anything is a finding too: an
* allow-list that only grows stops meaning anything.
*
* Exit code is the number of findings, so it can sit in the gate.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
export const TOKENS = 'src/theme/tokens.css'
/* The selectors a colour token may be declared under, whitespace removed. */
export const THEME_BLOCKS = [":root,[data-theme='dark']", "[data-theme='light']"]
export const ALLOWED = [
{
file: 'src/screens/modals/Configure2FA.tsx',
literal: '#fff',
reason:
'The quiet zone around the QR code. A scanner needs dark modules on a light ground, '
+ 'so it is white in every theme: a token would invite a theme to change it.',
},
]
const NAMED = new Set(
(
'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue '
+ 'blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk '
+ 'crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki '
+ 'darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen '
+ 'darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue '
+ 'dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite '
+ 'gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki '
+ 'lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan '
+ 'lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen '
+ 'lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen '
+ 'magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen '
+ 'mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream '
+ 'mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid '
+ 'palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum '
+ 'powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown '
+ 'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen '
+ 'steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen'
).split(' '),
)
const HEX = /(?<![\w&%#-])#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/gi
const ENCODED = /%23(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/gi
const FUNCTION = /(?<![\w.$-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([^)]*\)?/gi
const DECLARATION = /[a-z-]+\s*:\s*([^;{}]*)(?=[;}])/gi
const WORD = /(?<![\w-])[a-z]+(?![\w-])/gi
const STRING = /(['"])([a-z]+)\1/gi
const SCHEME_CSS = /(?<![\w-])color-scheme\s*:\s*[^;{}]*[^;{}\s]/gi
const SCHEME_TS = /(?<![\w$-])colorScheme\s*[:=]\s*(['"`])[^'"`]*\1|setProperty\(\s*(['"`])color-scheme\2/g
/*
Comments out, everything else in place: each comment character becomes a space
and each newline stays, so a finding keeps its line number. Strings are walked
so that a `//` inside one (`http://www.w3.org/2000/svg` in every inline SVG) is
not taken for a comment. `'` and `"` cannot span a line, so a quote inside a
regex literal can derail at most the rest of its own line.
*/
export function stripComments(text, kind) {
let out = ''
let i = 0
let quote = null
const blank = (s) => s.replace(/[^\n]/g, ' ')
while (i < text.length) {
const c = text[i]
if (quote) {
out += c
if (c === '\\') { out += text[i + 1] ?? ''; i += 2; continue }
if (c === quote || (c === '\n' && quote !== '`')) quote = null
i++
continue
}
if (c === '/' && text[i + 1] === '*') {
const end = text.indexOf('*/', i + 2)
const stop = end < 0 ? text.length : end + 2
out += blank(text.slice(i, stop))
i = stop
continue
}
if (kind === 'ts' && c === '/' && text[i + 1] === '/') {
const end = text.indexOf('\n', i)
const stop = end < 0 ? text.length : end
out += blank(text.slice(i, stop))
i = stop
continue
}
if (c === "'" || c === '"' || (kind === 'ts' && c === '`')) quote = c
out += c
i++
}
return out
}
const lineAt = (text, index) => text.slice(0, index).split('\n').length
/** The colour literals in a file's text, with their line. `kind` is `css` or `ts`. */
export function findColours(text, kind) {
const code = stripComments(text, kind)
const found = []
const add = (index, literal) => found.push({ line: lineAt(code, index), literal })
for (const re of [HEX, ENCODED, FUNCTION]) for (const m of code.matchAll(re)) add(m.index, m[0])
if (kind === 'css') {
for (const d of code.matchAll(DECLARATION)) {
const at = d.index + d[0].length - d[1].length
for (const w of d[1].matchAll(WORD)) if (NAMED.has(w[0].toLowerCase())) add(at + w.index, w[0])
}
} else {
for (const s of code.matchAll(STRING)) if (NAMED.has(s[2].toLowerCase())) add(s.index, s[0])
}
return found.sort((a, b) => a.line - b.line)
}
/** The `color-scheme` declarations in a file's text, with their line. */
export function findColourSchemes(text, kind) {
const code = stripComments(text, kind)
const re = kind === 'css' ? SCHEME_CSS : SCHEME_TS
return [...code.matchAll(re)].map((m) => ({ line: lineAt(code, m.index), literal: m[0] }))
}
/*
The top-level blocks of a stylesheet, with the selector each one hangs from.
`tokens.css` has no nesting except inside a block's own value, so counting
braces is enough.
*/
function blocks(code) {
const out = []
let depth = 0
let start = 0
for (let i = 0; i < code.length; i++) {
if (code[i] === '{') {
if (depth === 0) out.push({ selector: code.slice(start, i), from: i })
depth++
} else if (code[i] === '}') {
depth--
if (depth === 0) { out[out.length - 1].to = i; start = i + 1 }
}
}
return out.map((b) => ({ ...b, selector: b.selector.replace(/\s+/g, '') }))
}
/*
What each theme block declares, as `{ dark, light }`, each a `Map` from the
property (`--bg`, `color-scheme`) to its value as written. Shared with the tools
that measure a theme (`palette-distance.mjs`, `theme-contrast.mjs`), so the three
read the file the same way.
*/
export function themeTokens(text) {
const code = stripComments(text, 'css')
const out = {}
for (const b of blocks(code)) {
const theme = b.selector === THEME_BLOCKS[0] ? 'dark' : b.selector === THEME_BLOCKS[1] ? 'light' : null
if (!theme) continue
const decls = new Map()
for (const [, name, value] of code.slice(b.from + 1, b.to).matchAll(/(--[\w-]+|color-scheme)\s*:\s*([^;]+);/g))
decls.set(name, value.trim().replace(/\s+/g, ' '))
out[theme] = decls
}
return out
}
/*
Every property the dark block declares, the light block declares too. A token
light forgot is not an error anybody would see: `:root` carries the dark value,
so it quietly paints dark on a light page.
*/
export function findMissingInLight(text) {
const { dark = new Map(), light = new Map() } = themeTokens(text)
return [...dark.keys()].filter((name) => !light.has(name))
}
/** The colour literals and `color-scheme` declarations in `tokens.css` that are outside every theme block. */
export function findOutsideThemes(text) {
const code = stripComments(text, 'css')
const inside = blocks(code).filter((b) => THEME_BLOCKS.includes(b.selector))
return [...findColours(text, 'css'), ...findColourSchemes(text, 'css')]
.filter(({ line }) => !inside.some((b) => line >= lineAt(code, b.from) && line <= lineAt(code, b.to)))
.sort((a, b) => a.line - b.line)
}
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name)
/* `src/components/` holds the ReUI registry code exactly as shipped (shadcn add);
it is not ours to rewrite, so the token guard does not read it. */
if (e.isDirectory()) return e.name === 'test' || p.split(path.sep).slice(-2).join('/') === 'src/components' ? [] : walk(p)
return /\.(css|tsx?)$/.test(e.name) && !/\.test\./.test(e.name) ? [p] : []
})
}
/** Every finding under `root`, and the allow-list entries that matched nothing. */
export function check(root = ROOT, allowed = ALLOWED) {
const findings = []
const used = new Set()
for (const file of walk(path.join(root, 'src'))) {
const rel = path.relative(root, file).split(path.sep).join('/')
const text = fs.readFileSync(file, 'utf8')
if (rel === TOKENS) {
for (const f of findOutsideThemes(text))
findings.push(`${rel}:${f.line} ${f.literal} is outside every theme block`)
for (const name of findMissingInLight(text))
findings.push(`${rel} ${name} is declared for dark and not for light`)
continue
}
const kind = file.endsWith('.css') ? 'css' : 'ts'
for (const f of findColourSchemes(text, kind))
findings.push(`${rel}:${f.line} ${f.literal} pins color-scheme outside the theme blocks of ${TOKENS}`)
for (const f of findColours(text, kind)) {
const entry = allowed.find((a) => a.file === rel && a.literal.toLowerCase() === f.literal.toLowerCase())
if (entry) { used.add(entry); continue }
findings.push(`${rel}:${f.line} ${f.literal} is a colour outside ${TOKENS}`)
}
}
for (const a of allowed) if (!used.has(a)) findings.push(`${a.file} ${a.literal} is allowed but no longer found: remove the entry`)
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const findings = check()
if (findings.length === 0) {
console.log(`\n Every colour is a token: no colour literal and no color-scheme in src/ outside the theme blocks of ${TOKENS} (${ALLOWED.length} allowed, each with its reason).\n`)
} else {
for (const f of findings) console.log(' ' + f)
console.log(`\n ${findings.length} findings. A colour, and color-scheme, belong in a theme block of ${TOKENS}; if a colour literal really cannot, ALLOWED in dev/check-colour-tokens.mjs takes it with its reason.\n`)
}
process.exit(Math.min(findings.length, 250))
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { ALLOWED, check, findColourSchemes, findColours, findMissingInLight, findOutsideThemes, stripComments, themeTokens } from './check-colour-tokens.mjs'
/*
The gate behind "every colour is a token". It lives in `dev/` for the reason
`relative-paths.test.mjs` gives (`src/` compiles without node's types), and it
runs inside `npm test`, so the rule is checked wherever the suite is.
*/
const literals = (text, kind) => findColours(text, kind).map((f) => f.literal)
describe('colour tokens', () => {
it('src/ has no colour literal outside the theme blocks of tokens.css', () => {
expect(check()).toEqual([])
})
it('every allowed literal says why', () => {
for (const a of ALLOWED) expect(a.reason.length).toBeGreaterThan(40)
})
it('finds a hex, a colour function and a named colour in a stylesheet', () => {
const css = '.a { color: #3a465b; background: rgba(5, 7, 10, 0.6); border-color: White; }'
expect(literals(css, 'css')).toEqual(['#3a465b', 'rgba(5, 7, 10, 0.6)', 'White'])
})
it('finds the colour baked into an inline SVG', () => {
const css = `.a { background-image: url("data:image/svg+xml,%3Csvg stroke='%238b95a7'%3E"); }`
expect(literals(css, 'css')).toEqual(['%238b95a7'])
})
it('does not take a property, a selector or a token for a colour', () => {
const css = '#root .tan { white-space: nowrap; color: var(--ink); background: color-mix(in srgb, var(--acc) 14%, transparent); }'
expect(literals(css, 'css')).toEqual([])
})
it('ignores a colour quoted in a comment, in either language', () => {
expect(literals('/* white over `#f5a524` gives 2.04:1 */ .a { color: var(--ink); }', 'css')).toEqual([])
expect(literals("// was '#94a3b8'\nconst a = 1 /* rgb(33,37,41) */", 'ts')).toEqual([])
})
it('finds a colour in an inline style, and a named one as a whole string', () => {
expect(literals("<img style={{ background: '#fff' }} />", 'ts')).toEqual(['#fff'])
expect(literals("const c = { color: 'black' }", 'ts')).toEqual(["'black'"])
})
it('keeps line numbers through a stripped comment and a URL in a string', () => {
const ts = "const u = 'http://www.w3.org/2000/svg'\n/* one\n two */\nconst c = '#123456'"
expect(stripComments(ts, 'ts').split('\n')).toHaveLength(4)
expect(findColours(ts, 'ts')).toEqual([{ line: 4, literal: '#123456' }])
})
it('in tokens.css, a colour is only allowed inside a theme block', () => {
const tokens = [
":root,\n[data-theme='dark'] {\n --bg: #111315;\n}",
"[data-theme='light'] {\n --bg: #ffffff;\n}",
':root {\n --s-1: 2px;\n --stray: #222222;\n}',
].join('\n')
expect(findOutsideThemes(tokens)).toEqual([{ line: 10, literal: '#222222' }])
})
it('finds a color-scheme pinned in a stylesheet or a fixed inline style', () => {
const css = '.ownRange input {\n color: var(--ink);\n color-scheme: dark;\n}'
expect(findColourSchemes(css, 'css')).toEqual([{ line: 3, literal: 'color-scheme: dark' }])
expect(findColourSchemes("<input style={{ colorScheme: 'light' }} />", 'ts')).toHaveLength(1)
expect(findColourSchemes("el.style.setProperty('color-scheme', 'dark')", 'ts')).toHaveLength(1)
})
it('does not take a media query, a comment or the resolved theme for a pinned color-scheme', () => {
expect(findColourSchemes('@media (prefers-color-scheme: dark) { .a { color: var(--ink); } }', 'css')).toEqual([])
expect(findColourSchemes('/* color-scheme: dark; */ .a { color: var(--ink); }', 'css')).toEqual([])
expect(findColourSchemes("root.style.colorScheme = resolved\nconst q = '(prefers-color-scheme: dark)'", 'ts')).toEqual([])
})
it('in tokens.css, color-scheme is only allowed inside a theme block', () => {
const tokens = [
":root,\n[data-theme='dark'] {\n color-scheme: dark;\n}",
"[data-theme='light'] {\n color-scheme: light;\n}",
':root {\n --s-1: 2px;\n color-scheme: dark;\n}',
].join('\n')
expect(findOutsideThemes(tokens)).toEqual([{ line: 10, literal: 'color-scheme: dark' }])
})
it('reads each theme block into its own map, comments out', () => {
const tokens = [
":root,\n[data-theme='dark'] {\n color-scheme: dark;\n /* --ink: #000000; */\n --ink: #e8eaec;\n --shadow: 0 1px 2px\n rgba(0, 0, 0, 0.4);\n}",
"[data-theme='light'] {\n color-scheme: light;\n --ink: #1a1d21;\n}",
':root {\n --s-1: 2px;\n}',
].join('\n')
const { dark, light } = themeTokens(tokens)
expect([...dark]).toEqual([['color-scheme', 'dark'], ['--ink', '#e8eaec'], ['--shadow', '0 1px 2px rgba(0, 0, 0, 0.4)']])
expect([...light]).toEqual([['color-scheme', 'light'], ['--ink', '#1a1d21']])
})
it('a token the dark block declares and the light one does not is a finding', () => {
const tokens = ":root,\n[data-theme='dark'] {\n --ink: #e8eaec;\n --mute: #9aa1a8;\n}\n[data-theme='light'] {\n --ink: #1a1d21;\n}"
expect(findMissingInLight(tokens)).toEqual(['--mute'])
})
it('an allowed literal that is no longer there is a finding', () => {
const stale = { file: 'src/ui/Button.module.css', literal: '#2f3846', reason: 'moved to a token' }
expect(check(undefined, [...ALLOWED, stale])).toEqual([
'src/ui/Button.module.css #2f3846 is allowed but no longer found: remove the entry',
])
})
})
+111
View File
@@ -0,0 +1,111 @@
/**
* The guard that keeps the em dash (U+2014) out of the repository. The owner's
* call, on 2026-10-01: in prose it reads as machine-written. A period, a colon, a
* comma or parentheses do the same job. On 2026-10-02 the rule grew from the
* Markdown and the GitHub-facing files to every text file in the repository: the
* code, its comments, the strings it prints and the titles of its tests.
*
* Only tracked files are read (`git ls-files`), so a local, untracked file (a
* note at the root, a symlink to an agent's instructions) is never part of the
* check. A tracked file deleted in the working tree is skipped too.
*
* Not read at all:
*
* - Binaries: a file with a NUL byte in it (fonts, images, the favicon).
* - `package-lock.json`, which npm writes.
* - `public/json/`, upstream's data files, shipped as they come.
*
* One use stays: the interface's "no value" placeholder is an em dash on purpose
* (the stock console draws the same). In the code under `src/` a dash is let
* through when it is the whole of a string literal (`'\u2014'`, in any of the three
* quotes) or the whole of a JSX text (`>\u2014<`, or alone on its line), which also
* covers a test asserting on it (`getByText('\u2014')`). Comments are not exempt:
* they are found and blanked first, so a comment that quotes the placeholder is a
* finding like any other. Anywhere else there is no exemption, so a tool that has
* to match the dash as data writes it as the escape, as `DASH` does below.
*
* Run by `npm test` through `check-dashes.test.mjs`, and on its own with
* `npm run lint:dashes`, which takes optional paths to narrow it
* (`npm run lint:dashes -- dev .github`).
*/
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { stripComments } from './check-colour-tokens.mjs'
export const DASH = '\u2014'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
/** Tracked files that are never read. */
export const SKIPPED = [/^package-lock\.json$/, /^public\/json\//]
/** Where the placeholder may appear, and how each kind of file writes a comment. */
const PLACEHOLDER_DIR = 'src/'
const KINDS = { '.ts': 'ts', '.tsx': 'ts', '.js': 'ts', '.mjs': 'ts', '.css': 'css' }
const PLACEHOLDER = [
/(['"`])\u2014\1/g,
/>\u2014</g,
/^\s*\u2014\s*$/g,
]
/** The line with every placeholder use taken out. */
export function withoutPlaceholders(line) {
return PLACEHOLDER.reduce((l, re) => l.replace(re, ''), line)
}
const count = (s) => s.split(DASH).length - 1
/**
* The lines with an em dash. With `kind` ('ts' or 'css'), the text is code: a dash
* in a comment is always a finding, and one in the code only when it is not the
* placeholder.
*/
export function findDashes(text, { kind } = {}) {
const lines = text.split('\n')
const code = kind ? stripComments(text, kind).split('\n') : lines
const found = []
lines.forEach((line, i) => {
if (!line.includes(DASH)) return
const inComment = count(line) > count(code[i])
if (!kind || inComment || withoutPlaceholders(code[i]).includes(DASH))
found.push({ line: i + 1, text: line.trim() })
})
return found
}
/** The comment syntax of a file where the placeholder may appear; `undefined` elsewhere. */
export function kindOf(rel) {
if (!rel.startsWith(PLACEHOLDER_DIR)) return undefined
return KINDS[path.extname(rel)]
}
export function checkedFiles({ root = ROOT, paths = [] } = {}) {
return execFileSync('git', ['ls-files', '-z', '--', ...paths], { cwd: root, encoding: 'utf8' })
.split('\0')
.filter((rel) => rel && !SKIPPED.some((re) => re.test(rel)) && existsSync(path.join(root, rel)))
}
export function check({ root = ROOT, paths = [] } = {}) {
const findings = []
for (const rel of checkedFiles({ root, paths })) {
const bytes = readFileSync(path.join(root, rel))
if (bytes.includes(0)) continue
for (const f of findDashes(bytes.toString('utf8'), { kind: kindOf(rel) }))
findings.push(`${rel}:${f.line} ${f.text}`)
}
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const paths = process.argv.slice(2)
const findings = check({ paths })
if (findings.length === 0) {
console.log(`\n No em dash in the ${checkedFiles({ paths }).length} tracked files, apart from the "no value" placeholder.\n`)
} else {
for (const f of findings) console.log(' ' + f)
console.log(`\n ${findings.length} lines with an em dash. Use a period, a colon, a comma or parentheses.\n`)
}
process.exit(Math.min(findings.length, 250))
}
+110
View File
@@ -0,0 +1,110 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { DASH, check, checkedFiles, findDashes, withoutPlaceholders } from './check-dashes.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
describe('em dashes', () => {
it('none in any tracked text file, apart from the "no value" placeholder', () => {
expect(check()).toEqual([])
})
it('looks at every tracked text file, code included, but not the lock file or upstream data', () => {
const files = checkedFiles()
for (const f of [
'README.md',
'install.sh',
'.github/workflows/release.yml',
'package.json',
'vite.config.ts',
'dev/check-dashes.mjs',
'src/main.tsx',
])
expect(files).toContain(f)
expect(files).not.toContain('package-lock.json')
expect(files.some((f) => f.startsWith('public/json/'))).toBe(false)
})
it('reads only tracked files, so it needs no file that is not in the repository', () => {
const files = checkedFiles()
expect(files.length).toBeGreaterThan(0)
for (const f of files) expect(existsSync(join(ROOT, f))).toBe(true)
})
it('lets the placeholder through as a whole literal or a whole JSX text, and nothing between words', () => {
const ok = [`'${DASH}'`, `"${DASH}"`, `\`${DASH}\``, `>${DASH}<`]
for (const p of ok) expect(withoutPlaceholders(`x ${p} y`)).not.toContain(DASH)
expect(withoutPlaceholders(` ${DASH}`)).not.toContain(DASH)
expect(withoutPlaceholders(`one ${DASH} two`)).toContain(DASH)
expect(withoutPlaceholders(`a${DASH}b`)).toContain(DASH)
expect(withoutPlaceholders(`'Version ${DASH}'`)).toContain(DASH)
expect(withoutPlaceholders(`'${DASH}"`)).toContain(DASH)
})
it('in code, catches a dash in a comment and lets the placeholder literal through', () => {
const ts = [
`// one ${DASH} two`,
`const v = x ?? '${DASH}'`,
`/* the '${DASH}' placeholder */`,
`return <span>${DASH}</span>`,
`expect(screen.getByText('${DASH}')).toBeInTheDocument()`,
`const s = 'a ${DASH} b'`,
`const t = '${DASH}' // and ${DASH} here`,
].join('\n')
expect(findDashes(ts, { kind: 'ts' }).map((f) => f.line)).toEqual([1, 3, 6, 7])
expect(findDashes(`.a { content: '${DASH}'; } /* x ${DASH} y */`, { kind: 'css' }).map((f) => f.line)).toEqual([1])
expect(findDashes(`.a { content: '${DASH}'; }`, { kind: 'css' })).toEqual([])
})
it('outside code there is no placeholder: every dash is a finding', () => {
expect(findDashes(`'${DASH}'`)).toEqual([{ line: 1, text: `'${DASH}'` }])
})
it('finds the line of each one, and not an en dash or a hyphen', () => {
expect(findDashes(`one\ntwo ${DASH} three\n9\u201310 and a-b`)).toEqual([{ line: 2, text: `two ${DASH} three` }])
})
describe('in a repository of its own', () => {
let root
const write = (rel, text) => {
mkdirSync(dirname(join(root, rel)), { recursive: true })
writeFileSync(join(root, rel), text)
}
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'check-dashes-'))
execFileSync('git', ['init', '-q'], { cwd: root })
write('src/a.ts', `// a comment ${DASH} with a dash\nexport const none = '${DASH}'\n`)
write('src/b.tsx', `export const B = () => <td>${DASH}</td>\n`)
write('dev/tool.mjs', `const placeholder = '${DASH}'\n`)
write('README.md', `| a | ${DASH} |\n`)
write('package-lock.json', `{ "x": "${DASH}" }\n`)
write('public/json/list.json', `{ "x": "${DASH}" }\n`)
write('public/img.bin', Buffer.from([0, 1, 2, 0xe2, 0x80, 0x94]))
execFileSync('git', ['add', '.'], { cwd: root })
write('AGENTS.md', `untracked ${DASH} never read\n`)
})
afterAll(() => rmSync(root, { recursive: true, force: true }))
it('reports the comment in src, the literal outside src and the Markdown, and nothing else', () => {
expect(check({ root })).toEqual([
`README.md:1 | a | ${DASH} |`,
`dev/tool.mjs:1 const placeholder = '${DASH}'`,
`src/a.ts:1 // a comment ${DASH} with a dash`,
])
})
it('never reads an untracked file', () => {
expect(checkedFiles({ root })).not.toContain('AGENTS.md')
expect(check({ root }).some((f) => f.startsWith('AGENTS.md'))).toBe(false)
})
it('can be narrowed to some paths', () => {
expect(check({ root, paths: ['src'] })).toEqual([`src/a.ts:1 // a comment ${DASH} with a dash`])
})
})
})
+84
View File
@@ -0,0 +1,84 @@
/*
Which of the API endpoints the stock console calls does this one call too?
The README carried a figure for it ("114 of 114") that no script produced, and a
figure nobody can reproduce is an opinion. This produces it.
Upstream's side is every `"api/…"` literal in its `www/js/*.js` at the release
being matched (`UPSTREAM_REF`, default v15.6.2), read from the fork checked out
beside this repository (`../technitium-ui`).
This console's side is every string or template literal under `src/` that looks
like an API path. A template's `${…}` becomes a wildcard for ONE path segment,
which is how the three list screens share one component (`${kind}/list` serves
`allowed/list`, `blocked/list` and `cache/list`). A wildcard alone is not
proof, so a covered endpoint whose only match is a wildcard is printed as such:
look at those by eye.
node dev/check-endpoints.mjs
UPSTREAM_REF=v15.4.0 node dev/check-endpoints.mjs
Exit code is the number of upstream endpoints not covered.
*/
import { execFileSync } from 'node:child_process'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const SRC = resolve(here, '../src')
const FORK = resolve(here, '../../technitium-ui')
const REF = process.env.UPSTREAM_REF ?? 'v15.6.2'
const git = (...args) => execFileSync('git', ['-C', FORK, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 })
const theirs = new Set()
for (const f of git('ls-tree', '--name-only', REF, 'DnsServerCore/www/js/').split('\n').filter(Boolean)) {
for (const m of git('show', `${REF}:${f}`).matchAll(/["']api\/([A-Za-z0-9/]+)/g)) {
theirs.add(m[1].replace(/\/$/, ''))
}
}
function files(dir) {
return readdirSync(dir).flatMap((n) => {
const p = join(dir, n)
if (statSync(p).isDirectory()) return files(p)
return /\.tsx?$/.test(n) && !/\.test\.|fixture/.test(n) ? [p] : []
})
}
/* Literal paths, and templates turned into one-segment wildcards. */
const exact = new Set()
const patterns = []
for (const f of files(SRC)) {
const text = readFileSync(f, 'utf8')
for (const m of text.matchAll(/(['"])((?:api\/)?[a-z][A-Za-z0-9]*(?:\/[A-Za-z0-9]+)+)\1/g)) {
exact.add(m[2].replace(/^api\//, ''))
}
for (const m of text.matchAll(/`((?:api\/)?(?:[A-Za-z0-9]+|\$\{[^}]+\})(?:\/(?:[A-Za-z0-9]+|\$\{[^}]+\}))+)[`?]/g)) {
const path = m[1].replace(/^api\//, '')
if (!path.includes('${')) {
exact.add(path)
continue
}
const rx = '^' + path.split('/').map((s) => (s.startsWith('${') ? '[A-Za-z0-9]+' : s)).join('/') + '$'
patterns.push({ rx: new RegExp(rx), source: path, file: f.slice(SRC.length + 1) })
}
}
const missing = []
const byWildcard = []
for (const e of [...theirs].sort()) {
if (exact.has(e)) continue
const p = patterns.find((q) => q.rx.test(e))
if (p) byWildcard.push(`${e} ← ${p.source} (${p.file})`)
else missing.push(e)
}
for (const w of byWildcard) console.log(` WILDCARD ${w}`)
for (const m of missing) console.log(` MISSING ${m}`)
console.log(
`\nENDPOINTS (${REF}): ${theirs.size - missing.length} of ${theirs.size} covered` +
(byWildcard.length ? `, ${byWildcard.length} of them only through a wildcard; check those by eye.` : '.'),
)
process.exit(Math.min(missing.length, 250))
+379
View File
@@ -0,0 +1,379 @@
/**
* The guard that keeps the repository in English.
*
* `CONVENTIONS.md` has said since the first day that "everything is in ENGLISH:
* the interface, the code, the comments and the tests", and the visual redesign
* broke it from the inside over three weeks: the interface stayed English and the
* code did not. By 2026-09-07 there were 60 files with Spanish comments, 19 with
* Spanish names and eighteen Spanish identifiers, and **nothing was measuring
* it**. This is what measures it.
*
* It started with `src/` and, since 2026-10-02, it reads every tracked text file:
* the Markdown at the root and in `docs/`, the tools in `dev/`, the workflows,
* the Docker files, the hooks, `install.sh` and the config files. It looks at:
*
* - **File names.** A path segment that is not a word this project writes in
* English. It is a blacklist of Spanish stems and not a dictionary: it cannot
* catch a name nobody has thought of yet.
* - **Comments** in code (`.ts`, `.tsx`, `.css`, `.js`, `.mjs`): any `/* *\/`,
* `//` or `{/* *\/}` that carries Spanish. Two signals: the characters
* `á é í ó ú ü ñ ¿ ¡`, which Spanish prose cannot avoid, and a list of
* accent-free words that are unambiguously Spanish: `porque`, `cuando`, `hay`,
* `del`… None of them is an English word, which is why the list can be short
* and still catch prose.
* - **Identifiers** in code: the same word list, outside comments and outside
* string literals, so a Spanish variable or a Spanish `describe()` is caught
* too.
* - **Every line** of every other text file (Markdown, shell, YAML, JSON,
* HTML, the Dockerfile, the hooks), read as prose: the same two signals, with
* only inline code (between backticks) taken out.
*
* What it deliberately does NOT look at:
*
* - Its own word lists, between the `language-data` markers below. They are
* the data that detects Spanish, and they would flag themselves.
* - Anything quoted inside a code comment. A comment that quotes a value is
* quoting data and not writing prose. Quoted text between a matched pair of
* backticks or `"` is stripped before the check.
* - Files that are not ours to write: `package-lock.json` (generated by npm)
* and `public/json/` (upstream's built-in lists, copied as they are). Binary
* files (images, fonts) are skipped.
*
* Exit code is the number of findings, so it can sit in the gate. Run by
* `npm test` through `check-language.test.mjs`, and on its own with
* `npm run lint:language`.
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
/* language-data:start */
const ACCENTS = /[áéíóúüñ¿¡]/i
/* Every one of these is a word Spanish prose can hardly avoid and English never
uses. Short ones that ARE English (`no`, `es`, `la`, `se`) are left out on
purpose: they would flag English paragraphs and a guard that cries wolf gets
switched off. */
const WORDS = /\b(porque|cuando|pero|sino|para|hay|una|unos|unas|los|las|del|que|con|por|sin|entre|desde|cada|donde|como|aunque|mientras|siempre|nunca|tambien|entonces|segun|asi|solo|mas|esta|estan|habia|era|fue|tiene|hace|puede|debe|esto|este|eso|esa|ese|sus|nada|todo|toda|algo|otra|otro|misma|mismo|aqui|alli|ademas|ahora|antes|despues|dentro|fuera|arriba|abajo|encima|debajo)\b/i
const NAMES = /(ranura|caducado|crudo|senal|avisador|aviso|externo|boton|maestro|bloque|fallo|leyenda|interaccion|rotulo|pantalla|tarjeta|hueco|prueba|nodo|vacio|columna|fila|ayuda|derecha|izquierda)/i
/* Spanish that hides inside an identifier rather than in prose: `setTexto`,
`compacto`, `nombreRelativo`, `.caja`. The word list above does not see these
(they are not words on their own), so the stems go in a list of their own, and
it is checked against identifiers and class names, never against comments,
where an English sentence may legitimately quote one. */
const STEMS = /\b[A-Za-z]*(?:ranura|caducad|senal|avisad|externo|boton|maestro|bloque|fallo|leyenda|interaccion|rotulo|pantalla|tarjeta|hueco|vacio|columna|fila|ayuda|derecha|izquierda|texto|abiert|compacto|compacta|guardar|cargar|borrar|elegid|marcad|cerrad|nombre|caja|pastilla|salto|ultimo|arbol|secciones|orden(?![a-z])|prueba)[A-Za-z]*\b/
/*
Spanish that hides INSIDE an identifier: `esCache`, `pideTotp`, `firmaBase`.
Substring stems get this wrong in both directions (`firma` sits inside
`confirmation`, which is English, and `orden` misses `ordenar`), so the line is
broken into its camelCase pieces first and each piece is looked up WHOLE. That is
what catches `esCache` without flagging `confirmation`, and it is why `es` can be
on the list at all: as a whole word it is Spanish, as a substring it is in half
the English dictionary.
It runs alongside the stem list, not instead of it: the stems still catch the
inflections a whole-word lookup cannot see.
*/
/*
Two lists in one: the whole Spanish words of `WORDS` (which as camelCase pieces
are just as Spanish as they are in prose, and `setPorDesinstalar` proved it: `por`
is on that list and the regex missed it because there is no word boundary inside
an identifier) plus the nouns this codebase actually reached for.
It grows by finding, and that is the honest way to keep it: every entry below was
added because something got through. `por`, `desinstalar`, `hay`, `nota`, `tienda`
and `anotacion` are the batch of 2026-09-07, from Apps.
*/
const PIECES = new Set(
(
'porque cuando pero sino para hay una unos unas los las del que con por sin entre desde cada ' +
'donde como aunque mientras siempre nunca tambien entonces segun asi solo mas esta estan habia ' +
'era fue tiene hace puede debe esto este eso esa ese sus nada todo toda algo otra otro misma ' +
'mismo aqui alli ademas ahora antes despues dentro fuera arriba abajo encima debajo ' +
'es firma firmas firmar pedir pide piden confirmar desinstalar instalar texto nodo nodos arbol ' +
'caja cajas vacio vacia senal senial aviso avisos rotulo rotulos pantalla pantallas prueba ' +
'pruebas orden ultimo ultima salto hueco huecos fila filas columna columnas ayuda derecha ' +
'izquierda abierto abierta compacto compacta guardar cargar borrar marcado cerrado nombre ' +
'nombres pastilla boton maestro bloque fallo fallos leyenda caducado ranura externo nota notas ' +
'tienda anotacion reemplazar reemplaza tabla ficha fichas ' +
'fecha fechas hora horas dia dias mes meses tiempo valor valores clave claves llave lista listas ' +
'listar buscar busca filtro filtros filtrar crear editar mostrar ocultar enviar recibir contar ' +
'cuenta cuentas ancho anchos alto largo corto nuevo nueva viejo antiguo activo activa apagado ' +
'encendido cerrar abrir volver seguir contiene devuelve llama espera ' +
'carga cargas bloques cero dos tres cuatro cinco seis siete ocho nueve diez digito digitos ' +
'usuario usuarios conserva pegado golpe flotante foco hueco fuerte medio cajon curso ajustes enlaces seccion contenido iteraciones mirar estado ' +
/*
2026-09-30, the fifth hole. The gate was green and a census (every identifier
split into its camelCase pieces, each piece looked up in an English corpus and
the leftovers read by hand) found about 140 Spanish identifiers in 90 files:
`PropiedadesDnssec`, `EnElCromo`, `servidoresPrimariosObligatorios`, `setOlvido`,
`.testigo`, `data-testid="hueco"`, a dozen Spanish test descriptions. None of
them used a word on this list, and that is the whole lesson: a blacklist only
sees the words someone has already met. It cannot be completed by thinking
harder; the census is what finds the next batch, and its words come here.
These are the pieces of that sweep, renamed and now refused. Left out on
purpose, although they were Spanish where they appeared: `el` and `en`, which
this code uses in English as the usual shorthand for an element and for the
enablement rules, and the one-letter `a` / `o` of `aSlug` and `.oLogin`, which
as whole pieces are in every English identifier.
*/
'agente alta anadiendo apagada aplanar archivo automatico cambiar campo casilla casos catalogo ' +
'catalogos celda clases coincidencias conteo cromo crudas cruda cuerpo cuerpos curvas datos depende ' +
'dependiente descarte destino detalle dialogo dibujo disponibles ejemplo elemento elementos entero ' +
'entrada escapar eventos explicadas fallar fallido fijo forma formulario hacer hola huellas icono ' +
'ignorar inicial inicializar inicio instaladas interfaz inverso lanzar llamada llegar mandar manda ' +
'marca miembros modo muestra obligatorio obligatorios observador ocultas ocultos olvido otras ' +
'partido pestana pintadas pintar preparar previo previa primario primarios propiedades protocolo ' +
'protocolos punto raiz recuento refrescar respuesta resultado roto salida secciones sectores ' +
'selectores separados serie servidor servidores sobrescribir sujetos tamanos testigo tipo tira ' +
'todos tono unico usos visibles visto fin ir ya de al par fase grafica ninguna lecturas transporte ' +
'completos propaga tal cual vaciar importar exportar dominios unirse uno existente construir ' +
'piezas puras sesiones activas modales sobra esperaba falta'
).split(' '),
)
/* language-data:end */
/*
The description of a test is prose, and it was the one piece of prose this file
could not see: code lines have their single-quoted strings stripped (that is what
keeps a Spanish DATA literal from being reported), and a test description is a
single-quoted string like any other.
So it is read back on purpose, and only from the three functions that take one.
`CONVENTIONS.md` says the tests are in English too; this is what enforces it.
*/
const DESCRIPTION = /\b(?:it|test|describe)(?:\.\w+)?\(\s*(?:'([^']*)'|`([^`]*)`|"([^"]*)")/g
/*
Names that live inside a string and are still names: a test id and a `data-`
attribute. Stripping string literals is what keeps Spanish DATA quiet, and it also
hid `data-testid="hueco"` and a selector for `[data-activa="true"]` that matched
nothing because the attribute had been renamed to `data-active` everywhere else
(2026-09-30, the fifth hole). These are read back from the raw line.
*/
const NAMED_IN_STRING = /(?:data-testid=\{?\s*|ByTestId\(\s*)['"`]([^'"`]*)['"`]|\bdata-([a-z][a-z-]*)/g
/** Every camelCase piece of every identifier on the line, lower-cased. */
function pieces(line) {
const out = []
for (const id of line.match(/[A-Za-z][A-Za-z0-9]*/g) ?? []) {
for (const w of id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/\s+/)) {
if (w) out.push(w.toLowerCase())
}
}
return out
}
/* Proper names are not prose: `Adrián` carries an accent and appears in a dozen
decisions that are written in English around it. `Archivo` is the typeface the
interface is set in (`theme/base.css`), capitalised as its foundry writes it; the
lower-case `archivo` is Spanish and stays refused. */
/* language-data:start */
const NAMES_ALLOWED = /Adri[áa]n|\bArchivo\b/g
/* language-data:end */
function stripQuoted(s) {
return s
.replace(NAMES_ALLOWED, ' ')
.replace(/`[^`\n]*`/g, ' ')
.replace(/"[^"\n]*"/g, ' ')
.replace(/«[^»\n]*»/g, ' ')
}
/* Comments and code, told apart the cheap way: this is a linter for prose, not a
parser. A `//` inside a string literal is the known false positive, and the
quoted-text strip above is what keeps it quiet. */
function split(text) {
const comments = []
const code = []
let i = 0
const lines = text.split('\n')
while (i < lines.length) {
const l = lines[i]
if (l.includes('/*')) {
let j = i
while (j < lines.length && !lines[j].includes('*/')) j++
for (let k = i; k <= Math.min(j, lines.length - 1); k++) comments.push([k + 1, lines[k]])
i = j + 1
} else if (l.trim().startsWith('//') || l.trim().startsWith('*')) {
comments.push([i + 1, l]); i++
} else {
const at = l.indexOf('//')
if (at >= 0) { comments.push([i + 1, l.slice(at)]); code.push([i + 1, l.slice(0, at)]) }
else code.push([i + 1, l])
i++
}
}
return { comments, code }
}
/*
The files it reads: every tracked file, so a new folder is covered the day it is
committed. `git ls-files` and not a walk of the disk, so `node_modules/`, `dist/`
and an untracked local file are never read.
*/
const NOT_OURS = [
/^package-lock\.json$/,
/^public\/json\//,
/*
Responses captured from a real server and kept byte for byte as test fixtures:
the domains and records in them are data the server returned (a block list's
`t15-lista.com`, a base64 signature), not text anyone here wrote.
*/
/(^|\/)real-sample\.json$/,
/*
The ReUI registry code, exactly as `shadcn add` wrote it. It is vendored, so its
comments are not ours to reword (one of them trips the word lists).
*/
/^src\/components\//,
]
const BINARY = /\.(png|gif|ico|jpe?g|webp|woff2?|ttf|otf|zip|gz)$/i
const CODE = /\.(ts|tsx|css|js|mjs|cjs)$/
export function checkedFiles(root = ROOT) {
return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
.split('\0')
.filter(Boolean)
.filter((f) => !NOT_OURS.some((re) => re.test(f)) && !BINARY.test(f))
}
/*
The lines of its own word lists, which are data and would flag themselves, and
the Spanish samples its test feeds it. Only those two files carry the markers,
and only the lines between them are dropped.
*/
function withoutLanguageData(text) {
const lines = text.split('\n')
let inside = false
return lines
.map((l) => {
if (l.includes('/* language-data:start */')) inside = true
const out = inside ? '' : l
if (l.includes('/* language-data:end */')) inside = false
return out
})
.join('\n')
}
/* A line of prose with its inline code and the allowed names taken out. Quoted
text stays: in a document, a quotation is still something a reader reads. */
function proseOf(line) {
return line.replace(NAMES_ALLOWED, ' ').replace(/`[^`\n]*`/g, ' ')
}
/*
In a document a word is only a word between word boundaries: `es2023` in a
tsconfig is a target and not the Spanish `es`. Code comments keep the looser
lookup they always had.
*/
const isSpanishProse = (clean, { whole = false } = {}) =>
ACCENTS.test(clean) ||
WORDS.test(clean) ||
(clean.toLowerCase().match(whole ? /\b[a-z]+\b/g : /[a-z]+/g) ?? []).some((w) => PIECES.has(w))
/*
`Math.sin` is the sine, not the Spanish `sin`: the colour tools in `dev/` compute
CIEDE2000 with it. The standard library's own members are taken out of a code
line before the word lists see it.
*/
const STDLIB = /\bMath\.\w+/g
/* The string literals of a code line: in `dev/` they are what a tool prints. */
const STRINGS = /'([^'\n]*)'|"([^"\n]*)"|`([^`\n]*)`/g
/** The findings of one file, given its path relative to the root and its text. */
export function checkText(rel, raw) {
const findings = []
const segments = rel.split('/')
if (segments.some((seg) => NAMES.test(seg))) findings.push(`${rel} file name is not English`)
const text = withoutLanguageData(raw)
if (!CODE.test(rel)) {
text.split('\n').forEach((line, i) => {
if (isSpanishProse(proseOf(line), { whole: true })) findings.push(`${rel}:${i + 1} Spanish in the text: ${line.trim().slice(0, 70)}`)
})
return findings
}
const { comments, code } = split(text)
for (const [n, line] of comments) {
const clean = stripQuoted(line)
/*
The comments were checked against WORDS only, and prose without an accent or
one of those connectives walked through: «Cuatro bloques.», «Tabla»,
«/* Fechas *\/», «cerrar no avisa» (2026-09-30, the fourth hole). The whole-
word PIECES lookup is applied here too; on the day it was added it flagged
nothing that was not Spanish.
*/
if (isSpanishProse(clean)) findings.push(`${rel}:${n} Spanish in a comment: ${line.trim().slice(0, 70)}`)
}
/*
Outside `src/` the string literals are read too. In `src/` a string is mostly
upstream's data and stays quiet; in a tool it is the message the tool prints,
and a Spanish one walked through for weeks (`dev/row-colours.mjs` reporting
"COLISIÓN", `dev/parity-login.mjs` reporting "PARIDAD DE MENSAJES").
*/
const readStrings = !rel.startsWith('src/')
for (const [n, line] of code) {
const clean = stripQuoted(line).replace(/'[^'\n]*'/g, ' ').replace(STDLIB, ' ')
if (readStrings)
for (const m of line.matchAll(STRINGS)) {
const str = (m[1] ?? m[2] ?? m[3] ?? '').replace(NAMES_ALLOWED, ' ')
if (isSpanishProse(str, { whole: true })) findings.push(`${rel}:${n} Spanish in a string: ${str.slice(0, 70)}`)
}
const spanishPiece = pieces(clean).find((w) => PIECES.has(w))
if (ACCENTS.test(clean) || WORDS.test(clean) || STEMS.test(clean) || spanishPiece != null)
findings.push(`${rel}:${n} Spanish in code: ${line.trim().slice(0, 70)}`)
for (const m of line.matchAll(DESCRIPTION)) {
const what = m[1] ?? m[2] ?? m[3] ?? ''
const piece = pieces(what).find((w) => PIECES.has(w))
if (ACCENTS.test(what) || WORDS.test(what) || piece != null)
findings.push(`${rel}:${n} Spanish in a test description: ${what.slice(0, 70)}`)
}
for (const m of line.matchAll(NAMED_IN_STRING)) {
const name = m[1] ?? m[2] ?? ''
const piece = pieces(name.replace(/-/g, ' ')).find((w) => PIECES.has(w))
if (ACCENTS.test(name) || piece != null)
findings.push(`${rel}:${n} Spanish in a test id or data attribute: ${name.slice(0, 70)}`)
}
}
return findings
}
export function check(root = ROOT) {
const findings = []
for (const rel of checkedFiles(root)) {
const file = path.join(root, rel)
if (!fs.existsSync(file)) continue
const buf = fs.readFileSync(file)
if (buf.includes(0)) continue
findings.push(...checkText(rel, buf.toString('utf8')))
}
return findings
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const findings = check()
if (findings.length === 0) {
console.log(`\n The repository is in English: no Spanish in the ${checkedFiles().length} tracked text files.\n`)
} else {
const byFile = new Map()
for (const f of findings) {
const k = f.split(':')[0].split(' ')[0]
byFile.set(k, (byFile.get(k) ?? 0) + 1)
}
const sorted = [...byFile].sort((a, b) => b[1] - a[1])
for (const [f, n] of sorted) console.log(` ${String(n).padStart(4)} ${f}`)
console.log(`\n ${findings.length} findings in ${byFile.size} files\n`)
if (process.env.DETAIL) for (const f of findings) console.log(' ' + f)
}
process.exit(Math.min(findings.length, 250))
}
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest'
import { check, checkText, checkedFiles } from './check-language.mjs'
/*
The gate behind "everything is in English". It lives in `dev/` for the reason
`relative-paths.test.mjs` gives (`src/` compiles without node's types) and it runs
inside `npm test`, so the rule is checked wherever the suite is.
*/
/*
The Spanish this test feeds the checker. It is data, so it sits between the same
markers that keep the checker's own word lists out of the check.
*/
/* language-data:start */
const SAMPLE = {
prose: 'Esta consola es una alternativa para el servidor.',
quotedTitle: 'The section is called "Qué se refresca".',
devComment: '// Comprueba los endpoints que la consola usa',
shellComment: '# Copiado del repositorio vecino',
yamlComment: ' # sin esto no arranca',
printed: "console.log('== LÍNEA sobre el panel ==')",
identifier: 'const filaVacia = 1',
description: "it('una tabla vacía no aporta firma', () => {})",
words: 'const W = /porque|cuando/',
comment: '// porque sí',
fileName: 'dev/prueba.mjs',
/* And two lines that only look Spanish to a word list. */
sine: 'const dH = 2 * Math.sqrt(c) * Math.sin(h / 2)',
idn: 'An IDN such as `mañana.test` is shown decoded.',
}
/* language-data:end */
const START = '/* language-' + 'data:start */'
const END = '/* language-' + 'data:end */'
describe('language', () => {
it('no Spanish in any tracked text file', () => {
expect(check()).toEqual([])
})
it('reads the whole repository, not only src/', () => {
const files = checkedFiles()
for (const f of [
'README.md',
'CONVENTIONS.md',
'install.sh',
'.githooks/pre-commit',
'.github/workflows/release.yml',
'docker/Dockerfile',
'docs/2026-09-07-installer-contract.md',
'dev/uniformity.js',
'dev/compose.yaml',
'vite.config.ts',
'src/app/sections.ts',
])
expect(files).toContain(f)
})
it('leaves out only what is not ours to write, and binaries', () => {
const files = checkedFiles()
expect(files).not.toContain('package-lock.json')
expect(files.some((f) => f.startsWith('public/json/'))).toBe(false)
expect(files.some((f) => f.endsWith('real-sample.json'))).toBe(false)
expect(files.some((f) => /\.(png|gif|ico|woff2)$/.test(f))).toBe(false)
})
it('finds Spanish prose in a Markdown file', () => {
expect(checkText('README.md', `One line.\n${SAMPLE.prose}\n`)).toEqual([`README.md:2 Spanish in the text: ${SAMPLE.prose}`])
expect(checkText('docs/x.md', SAMPLE.quotedTitle)).toHaveLength(1)
})
it('finds Spanish in a comment of a dev tool, a shell script and a workflow', () => {
expect(checkText('dev/tool.mjs', `${SAMPLE.devComment}\nconst a = 1\n`)).toHaveLength(1)
expect(checkText('install.sh', `${SAMPLE.shellComment}\nset -eu\n`)).toHaveLength(1)
expect(checkText('.github/workflows/x.yml', `${SAMPLE.yamlComment}\n`)).toHaveLength(1)
})
it('reads what a dev tool prints, and leaves the strings of src/ alone', () => {
const line = `${SAMPLE.printed}\n`
expect(checkText('dev/tool.mjs', line)).toHaveLength(1)
expect(checkText('src/x.ts', line)).toEqual([])
})
it('finds a Spanish identifier and a Spanish test description in dev/', () => {
expect(checkText('dev/tool.mjs', `${SAMPLE.identifier}\n`)).toHaveLength(1)
expect(checkText('dev/tool.test.mjs', `${SAMPLE.description}\n`).length).toBeGreaterThan(0)
})
it('does not take English for Spanish', () => {
expect(checkText('README.md', 'Run `npm run lint:language` before the pull request.\n')).toEqual([])
expect(checkText('tsconfig.json', '{ "target": "es2023", "lib": ["ES2023"] }\n')).toEqual([])
expect(checkText('dev/colour.mjs', `${SAMPLE.sine}\n`)).toEqual([])
expect(checkText('CONVENTIONS.md', 'Amber is not offered (Adrián\'s decision).\n')).toEqual([])
expect(checkText('README.md', `${SAMPLE.idn}\n`)).toEqual([])
})
it('does not read the data between the markers, and only that', () => {
const data = `${START}\n${SAMPLE.words}\n${END}\n`
expect(checkText('dev/check-language.mjs', data)).toEqual([])
expect(checkText('dev/check-language.mjs', `${data}${SAMPLE.comment}\n`)).toHaveLength(1)
})
it('flags a Spanish file name anywhere in the tree', () => {
expect(checkText(SAMPLE.fileName, '')).toEqual([`${SAMPLE.fileName} file name is not English`])
})
})
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Side-by-side comparison by ACTION, not by bytes of the page.
#
# The project's governing constraint is "design only, zero functionality", and
# that enables the check that actually counts: run the same action on both
# instances and verify the RESULTING SERVER STATE is the same. If the body the
# new console sends differs by one parameter, the record left behind differs and
# it shows up here.
#
# The bodies are the ones upstream builds, copied from its JS. The new console
# sends the same ones: that is what is being verified.
#
# Usage: ./check-parity-actions.sh
set -uo pipefail
DEV=http://127.0.0.1:5380
REF=http://127.0.0.1:5381
USERNAME=admin
PASSWORD=technitium-ui-dev
ZONE=paridad.test
failures=0
token() {
curl -s "$1/api/user/login?user=$USERNAME&pass=$PASSWORD&includeInfo=false" |
python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])'
}
TD=$(token "$DEV")
TR=$(token "$REF")
# Leaves the zone identical on both before each comparison block.
reset_zone() {
for pair in "$DEV $TD" "$REF $TR"; do
set -- $pair
curl -s "$1/api/zones/delete?token=$2&zone=$ZONE" >/dev/null
curl -s "$1/api/zones/create?token=$2&zone=$ZONE&type=Primary" >/dev/null
done
}
# What is compared: the zone's records, minus whatever changes with the
# environment.
#
# Three things are normalised, all three for the same reason: they are not parity.
# · `lastModified` and the SOA serial change with the clock.
# · The SERVER NAME is embedded in the NS and the SOA of every zone, and the
# two containers deliberately have a different `DNS_SERVER_DOMAIN`
# (dev.technitium-ui.test / ref.technitium-ui.test). Without substituting it,
# all fourteen comparisons come out different over the hostname and not over
# the action.
fingerprint() {
curl -s "$1/api/zones/records/get?token=$2&domain=$ZONE&zone=$ZONE&listZone=true" |
python3 -c '
import sys, json, re
d = json.load(sys.stdin)["response"]
out = []
for r in sorted(d["records"], key=lambda x: (x["type"], x["name"])):
r.pop("lastModified", None)
if r["type"] == "SOA":
r["rData"].pop("serial", None)
out.append(r)
text = json.dumps(out, sort_keys=True)
print(re.sub(r"(dev|ref)\.technitium-ui\.test", "THIS-SERVER", text))'
}
# compare <name> <body-or-query> [method]
compare() {
local name=$1 path=$2 body=${3:-} method=${4:-GET}
for pair in "$DEV $TD" "$REF $TR"; do
set -- $pair
if [ "$method" = POST ]; then
curl -s -X POST "$1/api/$path?token=$2&node=" --data "$body" >/dev/null
else
curl -s "$1/api/$path?token=$2&$body" >/dev/null
fi
done
local d r
d=$(fingerprint "$DEV" "$TD")
r=$(fingerprint "$REF" "$TR")
if [ "$d" = "$r" ]; then
echo " OK $name"
else
echo " DIFFERENT $name"
diff <(echo "$r" | python3 -m json.tool) <(echo "$d" | python3 -m json.tool) | head -20
failures=$((failures + 1))
fi
}
echo "== record creates =="
reset_zone
compare "A with PTR" "zones/records/add" \
"zone=$ZONE&domain=www.$ZONE&type=A&ttl=3600&overwrite=false&comments=&expiryTtl=&ipAddress=10.0.0.1&ptr=false&createPtrZone=false&updateSvcbHints=false" POST
compare "MX" "zones/records/add" \
"zone=$ZONE&domain=$ZONE&type=MX&ttl=3600&overwrite=false&comments=&expiryTtl=&preference=10&exchange=mail.$ZONE" POST
compare "split TXT" "zones/records/add" \
"zone=$ZONE&domain=$ZONE&type=TXT&ttl=3600&overwrite=false&comments=note&expiryTtl=&text=v=spf1 -all&splitText=true" POST
compare "SRV" "zones/records/add" \
"zone=$ZONE&domain=_s._tcp.$ZONE&type=SRV&ttl=3600&overwrite=false&comments=&expiryTtl=&priority=1&weight=2&port=443&target=www.$ZONE" POST
compare "CAA with default values" "zones/records/add" \
"zone=$ZONE&domain=$ZONE&type=CAA&ttl=3600&overwrite=false&comments=&expiryTtl=&flags=0&tag=issue&value=letsencrypt.org" POST
compare "SVCB with params and automatic hint" "zones/records/add" \
"zone=$ZONE&domain=svc.$ZONE&type=SVCB&ttl=3600&overwrite=false&comments=&expiryTtl=&svcPriority=1&svcTargetName=www.$ZONE&svcParams=alpn|h2&autoIpv4Hint=true&autoIpv6Hint=false" POST
compare "CNAME" "zones/records/add" \
"zone=$ZONE&domain=alias.$ZONE&type=CNAME&ttl=3600&overwrite=false&comments=&expiryTtl=&cname=www.$ZONE" POST
echo "== edits =="
compare "edit the A: old and new" "zones/records/update" \
"zone=$ZONE&type=A&domain=www.$ZONE&newDomain=www.$ZONE&ttl=1800&disable=false&comments=&expiryTtl=&ipAddress=10.0.0.1&newIpAddress=10.0.0.2&ptr=false&createPtrZone=false&updateSvcbHints=false" POST
compare "disable the MX by resending it whole" "zones/records/update" \
"zone=$ZONE&type=MX&domain=$ZONE&ttl=3600&disable=true&comments=&expiryTtl=&preference=10&exchange=mail.$ZONE" POST
compare "edit the SOA" "zones/records/update" \
"zone=$ZONE&type=SOA&domain=$ZONE&newDomain=$ZONE&ttl=900&disable=false&comments=&expiryTtl=&primaryNameServer=ns1.$ZONE&responsiblePerson=hostadmin@$ZONE&serial=42&refresh=900&retry=300&expire=604800&minimum=900&useSerialDateScheme=false" POST
echo "== deletes =="
compare "delete the CNAME (no parameters: falls to default)" "zones/records/delete" \
"zone=$ZONE&domain=alias.$ZONE&type=CNAME" POST
compare "delete the SVCB by its flattened svcParams" "zones/records/delete" \
"zone=$ZONE&domain=svc.$ZONE&type=SVCB&svcPriority=1&svcTargetName=www.$ZONE&svcParams=alpn|h2" POST
compare "delete the SRV by its four fields" "zones/records/delete" \
"zone=$ZONE&domain=_s._tcp.$ZONE&type=SRV&priority=1&weight=2&port=443&target=www.$ZONE" POST
echo "== zone options =="
compare "options/set with the empty lists as \"false\"" "zones/options/set" \
"zone=$ZONE&catalog=&overrideCatalogQueryAccess=false&overrideCatalogZoneTransfer=false&overrideCatalogNotify=false&primaryNameServerAddresses=&primaryZoneTransferProtocol=Tcp&primaryZoneTransferTsigKeyName=&validateZone=false&queryAccess=Allow&queryAccessNetworkACL=&zoneTransfer=AllowOnlyZoneNameServers&zoneTransferNetworkACL=false&zoneTransferTsigKeyNames=false&notify=ZoneNameServers&notifyNameServers=false&notifySecondaryCatalogsNameServers=false&update=Deny&updateNetworkACL=false&updateSecurityPolicies=false"
echo
if [ "$failures" -eq 0 ]; then
echo "PARITY: every action leaves the same state"
exit 0
fi
echo "PARITY: $failures actions diverge"
exit 2
Loaded 100 of 680 files, more files were not shown because too many files have changed in this diff. Show more