Skip to content

Commit f6754eb

Browse files
Beautiful defaults: always-on recipe styling (alpha breaking) (#152)
* feat(core): always-on recipe styling Breaking (alpha): manual style props no longer opt out of recipes; overrides merge on top. Updates renderer for button/input/select/progress/callout, adds tests, and updates docs + migration guide. * feat(node): expose app.backend and prefer createNodeApp * fix(core): progress style overrides in fallback * test(node): deflake terminal io contract on CI * test(node): make CI poll delay lint-safe
1 parent 375a028 commit f6754eb

52 files changed

Lines changed: 1574 additions & 458 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ src/
171171
2. Create reducer in `helpers/state.ts`.
172172
3. Create pure screen functions in `screens/` (each returns a `VNode`).
173173
4. Wire keybindings via `app.keys()` in `main.ts`.
174-
5. Use `createNodeBackend({ fpsCap: 30 })` for production apps.
174+
5. Use `createNodeApp({ config: { fpsCap: 30 } })` for production apps.
175175
6. For animated screens, prefer declarative hooks (`useTransition`, `useSpring`, `useSequence`, `useStagger`) and `ui.box` transition props over ad-hoc timers in view code.
176176

177177
**Widget usage hierarchy (prefer higher):**
@@ -181,6 +181,70 @@ src/
181181
3. `useTransition()/useSpring()/useSequence()/useStagger()`, `useTable()`, `useModalStack()`, `useForm()` — for complex interaction patterns.
182182
4. `each()`, `show()`, `when()`, `maybe()`, `match()` — rendering control flow utilities.
183183

184+
## TUI Aesthetics Protocol
185+
186+
When building or modifying Rezi TUI applications, follow these visual design rules to ensure professional output.
187+
188+
### Mandatory Structure
189+
1. Root view MUST use `ui.page()` or `ui.appShell()` — never a bare `ui.column()`.
190+
2. Root MUST have `p: 1` (minimum 1-cell padding from terminal edges).
191+
3. Content sections MUST be wrapped in `ui.panel("Title", [...])` for visual grouping.
192+
4. Button groups MUST use `ui.actions([...])` for right-aligned layout.
193+
5. Forms MUST use `ui.form([...])` with `ui.field()` wrappers.
194+
195+
### Widget Styling Defaults
196+
When the active theme provides semantic color tokens, core interactive widgets use design system recipes by default:
197+
- **Buttons**: Auto-styled with `soft` variant. Use `intent: "primary"` for main CTA.
198+
- **Inputs**: Auto-styled with border + elevated background. No manual styling needed.
199+
- **Selects**: Auto-styled with border + background.
200+
- **Checkboxes**: Auto-styled with accent color when checked.
201+
- **Progress bars**: Auto-styled with theme accent color.
202+
- **Callouts**: Auto-styled with semantic border color based on variant.
203+
204+
If the active theme does not provide semantic color tokens, these widgets fall back to non-recipe rendering.
205+
206+
Notes:
207+
- A framed border for input/select requires at least 3 rows of height; at 1 row they still use recipe text/background styling but render without a box border.
208+
- For buttons, `px` overrides recipe padding when recipe styling is active; use `dsSize` for standard size presets.
209+
210+
### Button Intent Shorthand
211+
Instead of `dsVariant` + `dsTone` + `dsSize`, use the `intent` prop:
212+
| Intent | Maps to | Use for |
213+
|--------|---------|---------|
214+
| `"primary"` | solid + primary | Main call-to-action (Save, Submit) |
215+
| `"secondary"` | soft + default | Secondary actions (Cancel, Back) |
216+
| `"danger"` | outline + danger | Destructive actions (Delete, Remove) |
217+
| `"success"` | soft + success | Positive confirmations |
218+
| `"warning"` | soft + warning | Caution actions |
219+
| `"link"` | ghost + default + sm | Minimal/link-style actions |
220+
221+
### Spacing Scale
222+
| Value | Use for |
223+
|-------|---------|
224+
| `gap: 0` | Tightly coupled items only |
225+
| `gap: 1` | Related items (fields, buttons, list items) |
226+
| `gap: 2` | Distinct sections within a panel |
227+
| `p: 1` | Standard container/panel padding |
228+
| `p: 2` | Prominent/spacious panels |
229+
230+
### Visual Hierarchy (text variants)
231+
- `variant: "heading"` — page/section titles (bold, primary)
232+
- `variant: "caption"` — labels, descriptions (dim, secondary)
233+
- `variant: "code"` — code/mono content
234+
- No variant — regular body text
235+
236+
### Verification Checklist
237+
Before finalizing any TUI implementation, verify:
238+
- [ ] Root uses `ui.page()` or `ui.appShell()` with `p: 1`
239+
- [ ] All content sections use `ui.panel()` or `ui.card()`
240+
- [ ] Primary action button has `intent: "primary"`
241+
- [ ] Destructive buttons have `intent: "danger"`
242+
- [ ] Forms use `ui.field()` wrappers with labels
243+
- [ ] Button groups use `ui.actions()`
244+
- [ ] No hardcoded RGB colors (use theme/design system)
245+
- [ ] Consistent gap values (1 for items, 2 for sections)
246+
- [ ] Status/state shown with `ui.badge()` or `ui.status()`
247+
184248
## PR and Commit Protocol
185249

186250
- Run full test suite before commits: `node scripts/run-tests.mjs`

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,15 @@ The format is based on Keep a Changelog and the project follows Semantic Version
88

99
### Added
1010

11-
- _No unreleased changes yet._
11+
- Beautiful-by-default widget styling via design system recipes:
12+
- Buttons, inputs/textareas, selects, checkboxes, progress bars, and callouts now render with recipe styling by default when semantic theme tokens are available.
13+
- `intent` prop on `ButtonProps` (`"primary" | "secondary" | "danger" | "success" | "warning" | "link"`) as a shorthand for `dsVariant`/`dsTone` (and `dsSize` for link).
14+
- `preset` prop on `BoxProps` (`"card" | "surface" | "well" | "elevated"`) for consistent container defaults.
15+
- Composition helpers: `ui.appShell`, `ui.card`, `ui.toolbar`, `ui.statusBar`, `ui.header`, `ui.sidebar`, `ui.masterDetail`.
16+
17+
### Changed
18+
19+
- **Breaking (alpha):** manual styling props (`style`, `pressedStyle`, `trackStyle`, `px`) no longer opt out of recipe styling when semantic tokens are available; they are merged on top of the recipe result.
1220

1321
## [0.1.0-alpha.30] - 2026-02-22
1422

CLAUDE.md

Lines changed: 174 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,21 @@ See [docs/design-system.md](docs/design-system.md) for the full specification.
113113
- **DS Props**: `dsVariant`, `dsTone`, `dsSize` on interactive widgets for recipe-based styling
114114
- **Snapshot Testing**: `captureSnapshot()` + `rezi-snap` CLI for visual regression
115115

116+
### Beautiful Defaults (Design System by Default)
117+
118+
When the active theme provides semantic color tokens, these widgets are recipe-styled by default:
119+
`ui.button`, `ui.input`/`ui.textarea`, `ui.select`, `ui.checkbox`, `ui.progress`, `ui.callout`.
120+
121+
- **Manual overrides**: `style` / `pressedStyle` / `px` / `trackStyle` are merged on top of recipe results (they do not disable recipes).
122+
- **Framed inputs/selects**: Drawing a full border + interior requires at least **3 rows** of height; at 1 row they still use recipe text/background styling but render without a box border.
123+
116124
### Design System Buttons
117125

118126
```typescript
119-
// Design-system-styled button
120-
ui.button({ id: "save", label: "Save", dsVariant: "solid", dsTone: "primary", dsSize: "md" })
121-
ui.button({ id: "cancel", label: "Cancel", dsVariant: "ghost" })
122-
ui.button({ id: "delete", label: "Delete", dsVariant: "outline", dsTone: "danger" })
127+
// Intent-based button styling
128+
ui.button({ id: "save", label: "Save", intent: "primary" })
129+
ui.button({ id: "cancel", label: "Cancel" })
130+
ui.button({ id: "delete", label: "Delete", intent: "danger" })
123131
```
124132

125133
### Widget Gallery
@@ -139,10 +147,22 @@ node scripts/rezi-snap.mjs --verify # Verify snapshots
139147
import { ui, createApp } from "@rezi-ui/core";
140148

141149
const view = (state: AppState) =>
142-
ui.column({ gap: 1 }, [
143-
ui.text(`Count: ${state.count}`),
144-
ui.button({ id: "inc", label: "+1", onPress: () => app.update(s => ({ ...s, count: s.count + 1 })) }),
145-
]);
150+
ui.page({
151+
p: 1,
152+
gap: 1,
153+
body: ui.panel("Counter", [
154+
ui.row({ gap: 1, items: "center" }, [
155+
ui.text(`Count: ${state.count}`, { variant: "heading" }),
156+
ui.spacer({ flex: 1 }),
157+
ui.button({
158+
id: "inc",
159+
label: "+1",
160+
intent: "primary",
161+
onPress: () => app.update((s) => ({ ...s, count: s.count + 1 })),
162+
}),
163+
]),
164+
]),
165+
});
146166
```
147167

148168
### Layer 2 — Composition API (for reusable widgets)
@@ -152,9 +172,17 @@ import { defineWidget } from "@rezi-ui/core";
152172

153173
const Counter = defineWidget<{ initial: number; key?: string }>((props, ctx) => {
154174
const [count, setCount] = ctx.useState(props.initial);
155-
return ui.column({ gap: 1 }, [
156-
ui.text(`Count: ${count}`),
157-
ui.button({ id: ctx.id("inc"), label: "+1", onPress: () => setCount(c => c + 1) }),
175+
return ui.card("Counter", [
176+
ui.row({ gap: 1, items: "center" }, [
177+
ui.text(`Count: ${count}`),
178+
ui.spacer({ flex: 1 }),
179+
ui.button({
180+
id: ctx.id("inc"),
181+
label: "+1",
182+
intent: "primary",
183+
onPress: () => setCount((c) => c + 1),
184+
}),
185+
]),
158186
]);
159187
});
160188
```
@@ -236,6 +264,141 @@ CI runs `codegen:check`; stale generated writers fail the build.
236264
- Don't exceed 500 nesting depth.
237265
- Don't import from internal paths (use package exports only).
238266

267+
## TUI Aesthetics Rulebook
268+
269+
Follow these rules when building Rezi TUI applications to produce professional, visually appealing interfaces.
270+
271+
### Layout Rules
272+
- ALWAYS wrap the root view in `ui.page({ p: 1, gap: 1, header, body, footer })` for proper page structure with breathing room from terminal edges.
273+
- Use `ui.appShell()` for apps with sidebar navigation. Use `ui.page()` for simpler layouts.
274+
- Group related content in `ui.panel("Section Title", [...])` — this gives you rounded borders, padding, and a title automatically.
275+
- Use `ui.card("Title", [...])` for standalone elevated content blocks.
276+
- Never let content touch terminal edges — always have at least `p: 1` on the outermost container.
277+
278+
### Spacing Rhythm
279+
- `gap: 0` — tightly coupled items only (label + value on same line, radio options)
280+
- `gap: 1` — related items (form fields, list items, buttons in a row)
281+
- `gap: 2` — distinct sections within a panel (form groups, content blocks)
282+
- Use `ui.divider()` between major sections instead of large gaps.
283+
- Use `ui.spacer({ flex: 1 })` to push content apart (e.g., left/right in a row).
284+
285+
### Button Styling
286+
- Primary action (Save, Submit, Confirm): `intent: "primary"` or `dsVariant: "solid", dsTone: "primary"`
287+
- Secondary action (Cancel, Back): `intent: "secondary"` or `dsVariant: "soft"` (this is the default — just `ui.button("id", "Label")` works)
288+
- Destructive action (Delete, Remove): `intent: "danger"` or `dsVariant: "outline", dsTone: "danger"`
289+
- Minimal/link action (Learn more, Skip): `intent: "link"` or `dsVariant: "ghost"`
290+
- Place buttons in `ui.actions([...])` for right-aligned button rows
291+
- ONLY ONE solid/primary button per visible section — it's the main call to action.
292+
293+
### Visual Hierarchy
294+
- Page title: `ui.text("Title", { variant: "heading" })` — bold, primary color
295+
- Section titles: use `ui.panel("Section Name", [...])` to get automatic titled panels
296+
- Labels/captions: `ui.text("label", { variant: "caption" })` — dim, secondary color
297+
- Body text: `ui.text("content")` — default, no variant needed
298+
- Code/mono: `ui.text("code", { variant: "code" })`
299+
- De-emphasized: `ui.text("muted", { dim: true })`
300+
301+
### Status & Indicators
302+
- Use `ui.badge("text", { variant: "success" })` for status labels (success/error/warning/info)
303+
- Use `ui.status("online")` for connection/presence indicators
304+
- Use `ui.tag("label", { variant: "info" })` for categorization tags
305+
- Use `ui.callout("message", { variant: "warning" })` for inline alerts
306+
- Use `ui.progress(value)` for progress bars — recipe-styled by default when semantic tokens are present
307+
308+
### Form Patterns
309+
- Wrap each field in `ui.field({ label: "Name", children: ui.input(...) })` for label + error + hint
310+
- Group fields in `ui.form([...])` for consistent spacing
311+
- End forms with `ui.actions([cancelBtn, submitBtn])` — cancel first, primary submit last
312+
- Inputs are recipe-styled by default when semantic tokens are present (use 3+ rows for a framed border)
313+
- Use `placeholder` prop on inputs for guidance text
314+
315+
### Color Usage
316+
- NEVER hardcode RGB values — let the theme handle colors via tokens
317+
- Use `variant` props on badges, tags, callouts, status indicators for semantic colors
318+
- Use `dsVariant`/`dsTone` on buttons and interactive widgets for consistent theming
319+
- If you need manual colors, use theme token paths, not raw `{ r, g, b }` objects
320+
- Trust the design system — 6 built-in themes all work with the semantic color system
321+
322+
### Common Layout Patterns
323+
324+
```typescript
325+
// Standard app layout with header and footer
326+
ui.page({
327+
p: 1,
328+
gap: 1,
329+
header: ui.row({ gap: 1, items: "center" }, [
330+
ui.text("My App", { variant: "heading" }),
331+
ui.badge("v1.0", { variant: "info" }),
332+
ui.spacer({ flex: 1 }),
333+
ui.button("settings", "Settings", { intent: "link" }),
334+
]),
335+
body: ui.column({ gap: 2 }, [
336+
ui.panel("Section 1", [
337+
ui.text("Content goes here"),
338+
]),
339+
ui.panel("Section 2", [
340+
ui.form([
341+
ui.field({ label: "Name", children: ui.input("name", state.name) }),
342+
ui.field({ label: "Email", children: ui.input("email", state.email) }),
343+
]),
344+
]),
345+
]),
346+
footer: ui.actions([
347+
ui.button("cancel", "Cancel"),
348+
ui.button("save", "Save", { intent: "primary" }),
349+
]),
350+
})
351+
```
352+
353+
```typescript
354+
// Dashboard with sidebar navigation
355+
ui.appShell({
356+
header: ui.row({ gap: 1, items: "center" }, [
357+
ui.text("Dashboard", { variant: "heading" }),
358+
ui.badge("Live", { variant: "success" }),
359+
]),
360+
sidebar: {
361+
content: ui.sidebar({
362+
items: [
363+
{ id: "overview", label: "Overview" },
364+
{ id: "users", label: "Users" },
365+
{ id: "settings", label: "Settings" },
366+
],
367+
selected: state.currentPage,
368+
onSelect: (id) => dispatch({ type: "navigate", page: id }),
369+
}),
370+
width: 22,
371+
},
372+
body: renderCurrentPage(state),
373+
footer: ui.statusBar({
374+
left: [ui.status("online"), ui.text("Connected")],
375+
right: [ui.text("v1.0.0")],
376+
}),
377+
})
378+
```
379+
380+
```typescript
381+
// Confirmation dialog
382+
ui.dialog({
383+
id: "confirm-delete",
384+
title: "Delete Item",
385+
message: "Are you sure you want to delete this item? This action cannot be undone.",
386+
actions: [
387+
{ label: "Cancel", onPress: () => dispatch({ type: "close-dialog" }) },
388+
{ label: "Delete", intent: "danger", onPress: () => dispatch({ type: "delete" }) },
389+
],
390+
})
391+
```
392+
393+
### Anti-Patterns (DO NOT)
394+
- Don't use `ui.column` as your root — use `ui.page()` or `ui.appShell()` instead.
395+
- Don't manually set `fg`/`bg` colors on every widget — use the design system.
396+
- Don't use `gap: 0` everywhere — it makes the UI cramped and hard to read.
397+
- Don't put buttons without `ui.actions()` — they'll be left-aligned and look scattered.
398+
- Don't skip `ui.panel()` for content sections — bare text with no container looks unfinished.
399+
- Don't use `dsVariant: "solid"` on every button — only the primary CTA should be solid.
400+
- Don't nest more than 3 levels of bordered containers — it gets visually noisy.
401+
239402
## Testing
240403

241404
```typescript

README.md

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -148,21 +148,38 @@ const app = createNodeApp<{ count: number }>({
148148
});
149149

150150
app.view((s) =>
151-
ui.column({ p: 1, gap: 1 }, [
152-
ui.text("Counter", { style: { bold: true } }),
153-
ui.row({ gap: 2 }, [
154-
ui.text(`Count: ${s.count}`),
155-
ui.button("inc", "+1", {
156-
onPress: () => app.update((prev) => ({ count: prev.count + 1 })),
157-
}),
151+
ui.page({
152+
p: 1,
153+
gap: 1,
154+
header: ui.header({ title: "Counter", subtitle: "Beautiful defaults" }),
155+
body: ui.panel("Count", [
156+
ui.row({ gap: 1, items: "center" }, [
157+
ui.text(String(s.count), { variant: "heading" }),
158+
ui.spacer({ flex: 1 }),
159+
ui.button("inc", "+1", {
160+
intent: "primary",
161+
onPress: () => app.update((prev) => ({ count: prev.count + 1 })),
162+
}),
163+
]),
158164
]),
159-
]),
165+
}),
160166
);
161167

162168
app.keys({ q: () => app.stop() });
163169
await app.start();
164170
```
165171

172+
## Beautiful Defaults
173+
174+
When the active theme provides semantic color tokens, Rezi uses design system recipes by default for:
175+
`ui.button`, `ui.input`/`ui.textarea`, `ui.select`, `ui.checkbox`, `ui.progress`, and `ui.callout`.
176+
177+
- Use `intent` on buttons for common “primary/danger/link” patterns.
178+
- Use `preset` on `ui.box` (or `ui.card`/`ui.panel`) for consistent container defaults.
179+
- Use manual `style` props to override specific attributes (they do not disable recipes).
180+
181+
Docs: [Design System](docs/design-system.md) · [Migration: Beautiful Defaults](docs/migration/beautiful-defaults.md)
182+
166183
Install:
167184

168185
```bash
@@ -178,20 +195,22 @@ npm install @rezi-ui/core @rezi-ui/node
178195
```tsx
179196
/** @jsxImportSource @rezi-ui/jsx */
180197
import { createNodeApp } from "@rezi-ui/node";
181-
import { Column, Row, Text, Button } from "@rezi-ui/jsx";
198+
import { Column, Row, Spacer, Text, Button } from "@rezi-ui/jsx";
182199

183200
const app = createNodeApp<{ count: number }>({
184201
initialState: { count: 0 },
185202
});
186203

187204
app.view((s) => (
188205
<Column p={1} gap={1}>
189-
<Text style={{ bold: true }}>Counter</Text>
190-
<Row gap={2}>
191-
<Text>Count: {s.count}</Text>
206+
<Text variant="heading">Counter</Text>
207+
<Row gap={1} items="center">
208+
<Text variant="heading">{String(s.count)}</Text>
209+
<Spacer flex={1} />
192210
<Button
193211
id="inc"
194212
label="+1"
213+
intent="primary"
195214
onPress={() => app.update((prev) => ({ count: prev.count + 1 }))}
196215
/>
197216
</Row>

0 commit comments

Comments
 (0)