diff --git a/CHANGELOG.md b/CHANGELOG.md index a80b702a..bc301f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,6 @@ All notable changes to Rezi are documented in this file. The format is based on Keep a Changelog and the project follows Semantic Versioning. ## [Unreleased] -### Bug Fixes - ### Breaking Changes - **core/composition**: `WidgetContext.useViewport` is now required. Custom callers constructing widget contexts must provide `useViewport`, and `createWidgetContext(...)` now supplies it consistently. @@ -16,12 +14,14 @@ The format is based on Keep a Changelog and the project follows Semantic Version - **core/composition + hooks**: Composite widgets now use a layout-transparent default wrapper, animation hooks share a frame driver, transition/orchestration hooks stop relying on stringified config signatures, `useAnimatedValue` transition playback preserves progress across pause/resume, `useParallel` and `useChain` now read the latest callbacks without stale-closure behavior, `useStagger` restarts on same-length item replacement, and streaming hook reconnect delays clamp away tight-loop reconnects. - **core/runtime + perf**: Hardened lifecycle start/stop/fatal edges, sync frame follow-up scheduling, focus/layer callback failure handling, focus container metadata/state publication, and perf ring-buffer rollover stats. - **core/layout + constraints**: Constraint sibling aggregation is now same-parent scoped, hidden `display: false` layout widgets are removed from runtime interaction metadata even without an active constraint graph, deep parent-dependent chains settle fully in the first committed frame, box intrinsic sizing ignores absolute children, and unsupported absolute-position usage now emits deterministic dev warnings. +- **core/theme + renderer**: Removed the public legacy theme API, made semantic `ThemeDefinition` theming the only supported app/runtime path, completed scoped override inheritance for spacing, focus indicators, and color subtree inheritance, and extended semantic widget palettes into focus, chart, diff, logs, toast, and recipe-backed renderer defaults. ### Documentation - **docs/guide**: Synced composition, animation, and hook reference docs with the current hook surface, easing presets, callback semantics, viewport availability, and stable parser examples for streaming hooks. - **docs/lifecycle**: Corrected `onEvent(...)` examples, fatal payload fields, hot-reload state guarantees, and `run()` behavior when signal registration is unavailable. - **docs/layout + constraints**: Aligned recipes and guides with actual support boundaries for spacing, absolute positioning, `display`, and same-parent sibling aggregation semantics. +- **docs/styling**: Rewrote theme/design-system guidance around semantic-only theming, scoped overrides, packed `Rgb24` style props, recipe-backed defaults, and advanced widget palette coverage. ## [0.1.0-alpha.57] - 2026-03-06 diff --git a/docs/design-system.md b/docs/design-system.md index b92c9b17..46c41c98 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -1,592 +1,229 @@ # Rezi Design System -A cohesive design system for building polished, consistent TUI applications with modern "web-app" aesthetics (Tailwind/shadcn-level polish) while maintaining TUI semantics: keyboard-first, low-latency, diff-friendly, capability-tiered. - -## Philosophy - -- **Consistency beats novelty.** No per-widget special-snowflake styling. -- **Surfaces and contrast over borders.** Prefer layered contrast like modern web UI, not heavy terminal box drawing. -- **Focus/selection must be obvious, not harsh.** No full-line inversion; use underline+bold, subtle bg shifts, and focus rings. -- **Graceful degradation.** Every widget must look clean at every capability tier. -- **Performance first.** Drawlist size and renderer diffs stay efficient. No per-frame allocations. - -## Strategic Enforcement - -Design system adoption is not optional for core widgets. The following rules are enforced in code review and CI: - -- **Token-first styling:** Core widgets must use semantic `ColorTokens` and recipes, not ad-hoc RGB literals. -- **Recipe coverage:** New or updated visual primitives must include recipe tests in `packages/core/src/ui/__tests__/recipes.test.ts`. -- **Renderer compatibility:** Manual style overrides are merged on top of recipe results; recipe defaults should remain stable and deterministic. -- **Snapshot stability:** Gallery snapshots under `snapshots/` are the golden source for visual regressions. -- **Portability:** UI and snapshot helpers in `packages/core/src/ui/` and `packages/core/src/testing/snapshot.ts` must stay Node-agnostic. - -### Required Validation Gates - -Run these before merge: - -```bash -node scripts/run-tests.mjs -node scripts/rezi-snap.mjs --verify -node scripts/check-core-portability.mjs +The Rezi design system is the semantic styling layer on top of `ThemeDefinition`, +`ColorTokens`, and `recipe.*`. + +Its goals are: + +- consistent defaults across core widgets +- semantic token authoring instead of ad-hoc RGB literals +- predictable override behavior +- stable renderer output for tests and snapshots + +## Architecture + +The public model is: + +1. `ThemeDefinition` provides semantic theme tokens. +2. Renderer/widget code reads those tokens through shared helpers. +3. `recipe.*` turns tokens into widget-level styles. +4. Widget-specific manual props merge on top of recipe output. + +Advanced widget surfaces use dedicated widget token families: + +- `widget.syntax` +- `widget.diff` +- `widget.logs` +- `widget.toast` +- `widget.chart` + +## Renderer-backed defaults + +Recipe styling is enabled by default for the core design-system-backed widgets, +including: + +- buttons +- inputs and textareas +- checkboxes and radio groups +- selects +- sliders +- tables +- progress +- badges +- callouts +- tabs +- accordion +- breadcrumb +- pagination +- kbd +- dropdown +- tree +- modal + +This does not mean every visual primitive has a standalone `ui.*` wrapper. + +Important distinctions: + +- `recipe.surface(...)` exists, but there is no standalone `ui.surface(...)`. +- `recipe.text(...)` exists, but plain `ui.text(...)` is not globally recipe-driven. +- `recipe.scrollbar(...)` exists, but overflow scrollbars are not universally + rendered through that recipe path yet. +- `ui.divider(...)` is theme-aware, but it is not part of the same “full recipe + widget coverage” story as buttons/inputs/selects. + +## Overrides + +Manual widget props do not disable the design system. + +Common merge order: + +1. resolve theme tokens +2. compute recipe defaults +3. merge widget-level manual overrides such as `style`, `pressedStyle`, + `selectionStyle`, `trackStyle`, `px`, and similar props + +This keeps defaults stable while still allowing targeted changes. + +## Scoped theme overrides + +Use `ui.themed(...)` or a container `theme` prop to override a subtree. + +```ts +import { rgb, ui } from "@rezi-ui/core"; + +ui.row({ gap: 1 }, [ + ui.themed( + { + colors: { + accent: { + primary: rgb(255, 140, 90), + }, + }, + spacing: { + md: 3, + }, + }, + [ui.box({ p: 1 }, [ui.text("Scoped subtree")])], + ), + ui.box({ flex: 1, p: 1 }, [ui.text("Parent theme")]), +]); ``` ---- - -## Beautiful Defaults (No Hidden Styling) - -Core widgets are wired to the design system so they look professional without manual styling. - -### Default recipe styling - -When the active theme provides semantic color tokens (see [Color Semantic Slots](#color-semantic-slots)), these widgets use recipes by default: - -- `ui.button(...)` -- `ui.input(...)` -- `ui.checkbox(...)` -- `ui.select(...)` -- `ui.table(...)` -- `ui.progress(...)` -- `ui.badge(...)` -- `ui.callout(...)` -- `ui.scrollbar(...)` -- `ui.modal(...)` -- `ui.divider(...)` -- `ui.surface(...)` -- `ui.text(...)` -- `ui.tabs(...)` -- `ui.accordion(...)` -- `ui.breadcrumb(...)` -- `ui.pagination(...)` -- `ui.kbd(...)` -- `ui.dropdown(...)` -- `ui.tree(...)` -- `recipe.sidebar(...)` (shell helper styling) -- `recipe.toolbar(...)` (shell helper styling) - -This is the full “covered widgets” set for DS recipe integration. - -### Manual overrides - -Manual styling props do **not** disable recipe styling. - -When semantic color tokens are available, recipe styles are always applied, and manual props like `style`, `pressedStyle`, `px`, and `trackStyle` are merged on top to override specific attributes (for example `fg`, `bold`, `underline`). - -> Breaking (alpha): older builds treated some manual `style` props as an opt-out from recipe styling. That opt-out is removed to keep defaults consistent and avoid hidden behavior. - -### Activation path + shared token extraction - -Recipe styling activation follows a shared path: - -1. Resolve theme to semantic color tokens via `getColorTokens(...)`. -2. If tokens are available, use widget recipes for baseline styles. -3. Merge widget-level manual overrides (`style`, `pressedStyle`, etc.) on top. - -The shared `getColorTokens()` helper ensures all widgets map theme semantics -through one conversion path before recipe evaluation. - -### Scoped theme overrides - -Use `ui.themed(themeOverride, children)` to apply a partial theme override to a subtree without affecting siblings. This is the preferred pattern for mixed-theme layouts (for example, a lighter sidebar inside a dark app). - -### Unified focus indicators - -Focus visuals are token-driven across interactive widgets: - -- `focus.ring` controls focus accent color. -- `focus.bg` provides subtle focus background tinting where supported. -- Focus treatment remains non-color-only (underline + bold) for accessibility. - -### Height constraints for framed controls - -Some recipe-styled widgets can draw a framed control (border + interior). A framed border requires at least **3 rows** of height; in a 1-row layout, widgets still use recipe text/background styling, but they render without a box border. - -## Design Tokens - -All widgets consume tokens from the design system — never raw RGB/ANSI values directly. - -### Color Semantic Slots - -Every theme must define these semantic color slots: - -| Token Path | Purpose | Example (dark) | -|---|---|---| -| `bg.base` | Main app background | `#0a0e14` | -| `bg.elevated` | Cards, panels, modals (1 step up) | `#0f1419` | -| `bg.overlay` | Dropdowns, tooltips (2 steps up) | `#1a1f26` | -| `bg.subtle` | Hover/focus hint background | `#141920` | -| `fg.primary` | Primary text | `#e6e1cf` | -| `fg.secondary` | Secondary/label text | `#5c6773` | -| `fg.muted` | Placeholders, disabled text, hints | `#3e4b59` | -| `fg.inverse` | Text on accent backgrounds | `#0a0e14` | -| `accent.primary` | Primary actions, focus rings | `#ffb454` | -| `accent.secondary` | Links, secondary actions | `#59c2ff` | -| `accent.tertiary` | Subtle accents, decorations | `#95e6cb` | -| `success` | Success states | `#aad94c` | -| `warning` | Warning states | `#ffb454` | -| `error` | Error states | `#f07178` | -| `info` | Informational states | `#59c2ff` | -| `focus.ring` | Focus indicator color | `#ffb454` | -| `focus.bg` | Focus background hint | `#1a1f26` | -| `selected.bg` | Selected item background | `#273747` | -| `selected.fg` | Selected item foreground | `#e6e1cf` | -| `disabled.fg` | Disabled text | `#3e4b59` | -| `disabled.bg` | Disabled background | `#0f1419` | -| `border.subtle` | Dividers, faint separators | `#1a1f26` | -| `border.default` | Default borders | `#3e4b59` | -| `border.strong` | Emphasized borders | `#5c6773` | - -These are already defined via `ColorTokens` in `packages/core/src/theme/tokens.ts` and resolved via `resolveColorToken()` in `packages/core/src/theme/resolve.ts`. - -### Spacing Scale - -Terminal spacing is measured in cells (1 cell = 1 character width/height). - -| Name | Cells | Use | -|---|---|---| -| `none` | 0 | No spacing | -| `xs` | 1 | Tight internal padding (badges, tags) | -| `sm` | 1 | Standard internal padding (buttons, inputs) | -| `md` | 2 | Panel padding, form gaps | -| `lg` | 3 | Section spacing | -| `xl` | 4 | Large section spacing | -| `2xl` | 6 | Page-level spacing | - -**Rules:** -- Use `xs`/`sm` for internal component padding -- Use `md` for gaps between related elements (form fields) -- Use `lg`/`xl` between sections -- Maintain consistent gap within a layout (don't mix `sm` and `lg` gaps in the same column) - -### Spacing token consumption in recipes - -Recipe sizing now accepts theme spacing tokens directly. `resolveSize(size, spacingTokens?)` maps: - -- `sm` -> `{ px: spacing.xs, py: 0 }` -- `md` -> `{ px: spacing.sm, py: 0 }` -- `lg` -> `{ px: spacing.md, py: spacing.xs }` - -When spacing tokens are omitted, recipes keep legacy fallback spacing values for backward compatibility. - -### Typography Roles - -TUI typography maps to text attributes (bold/dim) + color tokens: - -| Role | Attributes | Color | Use | -|---|---|---|---| -| `title` | `bold` | `fg.primary` | Page/section titles | -| `subtitle` | `bold` | `fg.secondary` | Sub-headings | -| `body` | (none) | `fg.primary` | Body text | -| `caption` | `dim` | `fg.secondary` | Help text, descriptions | -| `code` | (none) | `accent.tertiary` | Code, monospace content | -| `label` | `bold` | `fg.primary` | Form labels, UI labels | -| `muted` | `dim` | `fg.muted` | Placeholders, disabled | - -### Border Styles - -| Name | Glyphs | Use | -|---|---|---| -| `single` | `┌─┐│└┘` | Default panels, cards | -| `rounded` | `╭─╮│╰╯` | Soft panels, buttons (web-like) | -| `double` | `╔═╗║╚╝` | Emphasis, active panels | -| `heavy` | `┏━┓┃┗┛` | Strong focus ring, headers | -| `dashed` | `┌╌┐╎└┘` | Draft/provisional elements | -| `none` | (no border) | Clean surface (preferred for modern look) | - -**Rules:** -- Prefer `rounded` for cards and panels (modern feel) -- Use `single` as a neutral default -- Reserve `heavy` for focused/active panel chrome -- Use `none` with bg contrast for cleanest surfaces - -### Radii (Border Corner Style) - -In TUI, "border radius" maps to glyph choice: - -| Name | Glyphs | Mapping | -|---|---|---| -| `square` | `┌┐└┘` | Single border | -| `soft` | `╭╮╰╯` | Rounded border (default for modern UI) | -| `round` | `╭╮╰╯` | Same as soft (TUI ceiling) | - -### Elevation / Surfaces - -TUI "depth" uses contrast layers, not drop shadows: - -| Level | Background | Border | Shadow | Use | -|---|---|---|---|---| -| 0 (base) | `bg.base` | none | none | App background | -| 1 (card) | `bg.elevated` | `border.subtle` | none | Cards, panels | -| 2 (overlay) | `bg.overlay` | `border.default` | optional | Dropdowns, menus | -| 3 (modal) | `bg.overlay` | `border.strong` | yes | Modals, dialogs | - -**Shadow effect:** 1-cell offset using shade characters (`░▒▓`) on bottom/right edges. - -### Focus Ring Language - -Focus indication across ALL interactive controls: - -| Widget Type | Focus Treatment | Description | -|---|---|---| -| Button | `underline` + `bold` + `fg: accent.primary` | Text becomes underlined bold in accent | -| Input/Textarea | `border: heavy` + `borderStyle: accent.primary` | Border upgrades to heavy in accent | -| Select | `underline` + `bold` | Same as button | -| Checkbox/Radio | `bold` + `fg: accent.primary` | Label becomes bold accent | -| Table row | `bg: selected.bg` + `bold` | Row highlighted | -| Tree node | `bg: selected.bg` | Node highlighted | -| Modal | `border: heavy` + `shadow` | Frame emphasized | - ---- - -## Variants - -### Size - -| Name | Padding | Height | Use | -|---|---|---|---| -| `sm` | `px: 1` | 1 row | Compact toolbars, dense lists | -| `md` | `px: 2` | 1 row | Default controls | -| `lg` | `px: 3` | 1 row (+ top/bottom padding for multi-line) | Hero actions, emphasis | - -### Visual Variant - -| Name | Background | Border | Text | Use | -|---|---|---|---|---| -| `solid` | `accent.primary` | none | `fg.inverse` | Primary CTA | -| `soft` | `bg.subtle` | none | `fg.primary` | Secondary actions | -| `outline` | `bg.base` | `border.default` | `fg.primary` | Tertiary actions | -| `ghost` | transparent | none | `fg.secondary` | Minimal UI (toolbars) | - -### Tone - -Tone modifies the accent color used by variants: - -| Name | Accent Source | Use | -|---|---|---| -| `default` | `accent.primary` | Standard | -| `primary` | `accent.primary` | Explicit primary | -| `danger` | `error` | Destructive actions | -| `success` | `success` | Positive actions | -| `warning` | `warning` | Cautionary actions | - -### Density +Scoped overrides: -| Name | Gap | Padding | Use | -|---|---|---|---| -| `compact` | 0 | minimal | Dense data tables, toolbars | -| `comfortable` | 1 | standard | Default | +- inherit unspecified values from the parent theme +- can override `colors`, `spacing`, `focusIndicator`, and `widget` palettes +- compose predictably when nested ---- +## Focus system -## Widget States +Focus styling is token-driven: -Every interactive widget supports these visual states: +- `colors.focus.ring` controls focus accent color +- `colors.focus.bg` provides subtle focused-surface tint where supported +- `focusIndicator.bold` and `focusIndicator.underline` define default text focus treatment -| State | Visual Treatment | Details | -|---|---|---| -| `default` | Base styling | Normal resting state | -| `active-item` | `bg: bg.subtle` | Hover equivalent for keyboard nav | -| `focus` | Focus ring (see above) | Keyboard focus indicator | -| `pressed` | `dim` attribute + slight bg shift | Momentary press feedback | -| `disabled` | `fg: disabled.fg`, `bg: disabled.bg` | Non-interactive | -| `loading` | Skeleton/spinner replacement | Content loading | -| `error` | `fg: error`, border/underline color swap | Validation error | -| `selected` | `bg: selected.bg`, `fg: selected.fg` | Multi-select checked | +Widgets may also accept `focusConfig` to change or suppress their local focus +presentation without changing keyboard focus behavior. ---- +## Spacing scale -## Capability Tiers +Theme spacing is semantic and required: -The design system adapts to terminal capabilities: - -### Tier A: Basic (16/256-color + Unicode box drawing) - -- Colors mapped to nearest 256-color palette entry -- All unicode box-drawing characters assumed supported -- No image protocols -- Shadows use `░▒▓` shade characters -- Sparklines use `▁▂▃▄▅▆▇█` block characters -- Focus ring uses heavy border glyphs - -### Tier B: Truecolor - -- Full RGB color support (24-bit) -- All Tier A features -- Smooth gradients possible in charts -- Richer contrast layering for surfaces - -### Tier C: Enhanced - -- Truecolor + one or more of: Kitty graphics, Sixel, iTerm2 images -- Sub-cell canvas rendering via blitters (braille, sextant, quadrant) -- Smooth progress bars, sparklines -- Image rendering -- **NOT required for legibility** — Tier B must look complete - -### Tier Detection - -```typescript -import { getCapabilityTier } from "@rezi-ui/core"; - -const tier = getCapabilityTier(terminalCaps); -// tier: "A" | "B" | "C" -``` - -The tier is derived from: -- Color mode (16 → A, 256 → A, RGB → B/C) -- Image protocol support (any → C) -- Extended capabilities (underline styles, colored underlines) +| Token | Cells | +|---|---| +| `xs` | 1 | +| `sm` | 1 | +| `md` | 2 | +| `lg` | 3 | +| `xl` | 4 | +| `2xl` | 6 | -### Tier Fallback Rules +Recipe sizing maps directly to that scale: -1. Never rely on color alone — always pair with bold/dim/underline -2. Tier A must be fully usable (no missing information) -3. Tier C features are enhancements, never requirements -4. Test all themes at Tier A and Tier B minimum +- `sm` -> `{ px: spacing.sm, py: 0 }` +- `md` -> `{ px: spacing.md, py: 0 }` +- `lg` -> `{ px: spacing.lg, py: 1 }` ---- +## Theme transitions -## Recipe System +`AppConfig.themeTransitionFrames` controls theme interpolation during +`app.setTheme(...)`. -Recipes are pure style functions that return `TextStyle`-compatible objects based on semantic `ColorTokens` plus widget state/variant/tone/size inputs. +- `0`: instant swap +- `> 0`: interpolate colors across the configured number of frames -### Usage +Spacing and focus-indicator structure are not tweened per cell; the transition is +primarily a color interpolation path. -```typescript -import { recipe, darkTheme } from "@rezi-ui/core"; +## Direct recipe use -const colors = darkTheme.colors; +Use `recipe.*` when building custom widgets with `defineWidget(...)`. -// Button recipe -const buttonStyle = recipe.button(colors, { - variant: "solid", - tone: "primary", - size: "md", - state: "focus", -}); +```ts +import { defineWidget, recipe, ui } from "@rezi-ui/core"; -// Surface recipe -const surfaceStyle = recipe.surface(colors, { - elevation: 1, - focused: false, -}); +const MetricTile = defineWidget<{ label: string; value: string; key?: string }>((props, ctx) => { + const tokens = ctx.useTheme(); + const surface = recipe.surface(tokens, { elevation: 1 }); + const labelStyle = recipe.text(tokens, { role: "caption" }); + const valueStyle = recipe.text(tokens, { role: "title" }); -// Input recipe -const inputStyle = recipe.input(colors, { - state: "error", - size: "md", + return ui.box({ border: surface.border, style: surface.bg, p: 1 }, [ + ui.text(props.label, { style: labelStyle }), + ui.text(props.value, { style: valueStyle }), + ]); }); ``` -### Available Recipes - -| Recipe | Parameters | Output | -|---|---|---| -| `recipe.button` | `variant`, `tone`, `size`, `state`, `density` | label/bg styles + border metadata + padding | -| `recipe.input` | `state`, `size`, `density` | text/placeholder/bg styles + border metadata + padding | -| `recipe.surface` | `elevation`, `focused` | surface bg style + border metadata + shadow flag | -| `recipe.select` | state, size | TextStyle + option styles | -| `recipe.table` | `state` (`header`/`row`/`selectedRow`/`focusedRow`/`stripe`) | cell/bg styles | -| `recipe.modal` | `focused` | frame/backdrop/title styles + border metadata + shadow flag | -| `recipe.badge` | `tone` (`WidgetTone` + `info`) | badge text style | -| `recipe.text` | `role` | typography style | -| `recipe.divider` | — | divider style | -| `recipe.checkbox` | `state`, `checked` | indicator/label styles | -| `recipe.progress` | `tone` | filled/track styles | -| `recipe.callout` | `tone` (`WidgetTone` + `info`) | text/border/bg styles | -| `recipe.scrollbar` | — | track/thumb styles | -| `recipe.tabs` | `variant`, `tone`, `size`, `state` | tab item/bg styles + border metadata + padding | -| `recipe.accordion` | `variant`, `tone`, `size`, `state` | header/content/bg styles + border metadata + padding | -| `recipe.breadcrumb` | `variant`, `tone`, `size`, `state` | item/separator/bg styles + padding | -| `recipe.pagination` | `variant`, `tone`, `size`, `state` | control/bg styles + border metadata + padding | -| `recipe.kbd` | `variant`, `tone`, `size`, `state` | keycap/bg styles + border metadata + padding | -| `recipe.dropdown` | `variant`, `tone`, `size`, `state` | item/shortcut/bg styles + border metadata + padding | -| `recipe.tree` | `variant`, `tone`, `size`, `state` | node/prefix/bg styles + border metadata + padding | -| `recipe.sidebar` | `variant`, `tone`, `size`, `state` | shell item/bg styles + border metadata + spacing | -| `recipe.toolbar` | `variant`, `tone`, `size`, `state` | shell item/bg styles + border metadata + spacing | - -### Theme transitions - -Set `AppConfig.themeTransitionFrames` to interpolate theme colors across multiple render frames when `app.setTheme(...)` is called. - -- `0` (default): instant theme swap (legacy behavior) -- `> 0`: frame-by-frame interpolation from previous to next theme -- Re-targeting during an active transition starts from the current interpolated frame and converges to the latest target - ---- - -## Rules & Guidelines - -### Surfaces Over Borders - -``` -PREFER: AVOID: -┌──────────────────┐ ╔══════════════════╗ -│ Clean card │ ║ Heavy border ║ -│ with subtle │ ║ everywhere ║ -│ border │ ╚══════════════════╝ -└──────────────────┘ -``` - -Use `bg.elevated` with `border.subtle` for cards. Reserve heavy/double borders for active focus states. - -### Muted Text & Whitespace Rhythm - -- Use `fg.secondary` for labels, `fg.muted` for hints/placeholders -- Maintain 1-cell gap between label and value in forms -- Use 2-cell gap between form sections -- Empty areas should use `bg.base` (no fill characters) - -### Alignment Rules (Terminal Grid) - -- All elements align to the cell grid (no sub-cell positioning in layout) -- Horizontal padding in even numbers preferred (2, 4) for visual balance -- Vertical spacing: 0 for tight lists, 1 for comfortable, 2+ for sections - -### Truncation & Ellipsis - -- Text overflow defaults to `"ellipsis"` (adds `…` at end) -- Middle truncation (`"middle"`) for file paths: `src/comp…/index.ts` -- Clip (`"clip"`) only for code/monospace where ellipsis misleads -- Wide characters (CJK, emoji) handled safely — never split a wide char - -### Handling Wide Characters / Emoji - -- All text measurement uses `measureTextCells()` which accounts for grapheme clusters -- Emoji width follows the configured policy (`wide` = 2 cells, `narrow` = 1 cell) -- Truncation respects grapheme boundaries — never cuts mid-cluster -- Layout engine treats each cell as the atomic unit - ---- - -## Theme Authoring - -### Creating a Custom Theme - -```typescript -import { createThemeDefinition, color } from "@rezi-ui/core"; - -export const myTheme = createThemeDefinition("my-theme", { - bg: { - base: color(25, 25, 30), - elevated: color(35, 35, 42), - overlay: color(45, 45, 55), - subtle: color(30, 30, 36), +## Theme authoring + +Use `createThemeDefinition(...)` for new themes and `extendTheme(...)` for +variants. + +```ts +import { createThemeDefinition, rgb } from "@rezi-ui/core"; + +export const myTheme = createThemeDefinition( + "my-theme", + { + bg: { + base: rgb(10, 14, 20), + elevated: rgb(15, 20, 28), + overlay: rgb(24, 30, 40), + subtle: rgb(20, 25, 34), + }, + fg: { + primary: rgb(231, 236, 242), + secondary: rgb(142, 155, 170), + muted: rgb(96, 107, 121), + inverse: rgb(10, 14, 20), + }, + accent: { + primary: rgb(255, 180, 84), + secondary: rgb(89, 194, 255), + tertiary: rgb(149, 230, 203), + }, + success: rgb(170, 217, 76), + warning: rgb(255, 180, 84), + error: rgb(240, 113, 120), + info: rgb(89, 194, 255), + focus: { ring: rgb(255, 180, 84), bg: rgb(26, 31, 38) }, + selected: { bg: rgb(39, 55, 71), fg: rgb(231, 236, 242) }, + disabled: { fg: rgb(96, 107, 121), bg: rgb(15, 20, 28) }, + diagnostic: { + error: rgb(240, 113, 120), + warning: rgb(255, 180, 84), + info: rgb(89, 194, 255), + hint: rgb(149, 230, 203), + }, + border: { + subtle: rgb(26, 31, 38), + default: rgb(96, 107, 121), + strong: rgb(142, 155, 170), + }, }, - fg: { - primary: color(220, 220, 230), - secondary: color(140, 140, 160), - muted: color(80, 80, 100), - inverse: color(25, 25, 30), - }, - accent: { - primary: color(100, 180, 255), - secondary: color(180, 130, 255), - tertiary: color(100, 220, 180), - }, - success: color(100, 200, 100), - warning: color(255, 200, 80), - error: color(255, 100, 100), - info: color(100, 180, 255), - focus: { - ring: color(100, 180, 255), - bg: color(35, 35, 42), - }, - selected: { - bg: color(50, 60, 80), - fg: color(220, 220, 230), - }, - disabled: { - fg: color(80, 80, 100), - bg: color(30, 30, 36), - }, - diagnostic: { - error: color(255, 100, 100), - warning: color(255, 200, 80), - info: color(100, 180, 255), - hint: color(180, 130, 255), - }, - border: { - subtle: color(40, 40, 50), - default: color(60, 60, 75), - strong: color(100, 100, 120), - }, -}); +); ``` -### Contrast Requirements +`createThemeDefinition(...)` fills default `spacing`, `focusIndicator`, and +`widget` palettes when they are not provided explicitly. -- `fg.primary` on `bg.base`: minimum 7:1 contrast ratio (WCAG AAA) -- `fg.secondary` on `bg.base`: minimum 4.5:1 (WCAG AA) -- `accent.primary` on `bg.base`: minimum 4.5:1 (WCAG AA) -- Use `contrastRatio(fg, bg)` from `@rezi-ui/core` to validate - ---- - -## Built-in Themes - -| Theme | Style | Good For | -|---|---|---| -| `darkTheme` | Ayu-inspired, orange accent | General use, warm aesthetic | -| `lightTheme` | Clean white, blue accent | Bright environments | -| `dimmedTheme` | Low contrast dark | Extended sessions | -| `highContrastTheme` | WCAG AAA, cyan/yellow | Accessibility | -| `nordTheme` | Arctic cool, frost blue | Nord ecosystem | -| `draculaTheme` | Vibrant dark, purple accent | Dracula ecosystem | - -All themes define the complete `ColorTokens` set and work at every capability tier. - ---- - -## Implementation Files - -| File | Purpose | -|---|---| -| `packages/core/src/theme/tokens.ts` | Token type definitions + helpers | -| `packages/core/src/theme/presets.ts` | 6 built-in theme definitions | -| `packages/core/src/theme/resolve.ts` | Token path → RGB resolution | -| `packages/core/src/theme/interop.ts` | ThemeDefinition ↔ runtime Theme conversion | -| `packages/core/src/ui/designTokens.ts` | Extended design tokens (typography, elevation) | -| `packages/core/src/ui/capabilities.ts` | Tier A/B/C detection and adaptation | -| `packages/core/src/ui/recipes.ts` | Style recipes for all widget families | - ---- - -## Widget Gallery - -The Widget Gallery (`examples/gallery/`) renders deterministic widget scenes for design and regression testing. It provides: - -- Interactive browsing of all widgets -- State matrix view (variants × states) -- Theme switching (all 6 built-in themes) -- Headless mode for CI snapshot capture - -Run interactively: -```bash -npx tsx examples/gallery/src/index.ts -``` - -Run headless for snapshots: -```bash -npx tsx examples/gallery/src/index.ts --headless --scene button-matrix -``` - ---- - -## Golden Snapshot Testing - -Visual regression testing captures deterministic cell-grid snapshots: - -```bash -# Update snapshots -node scripts/rezi-snap.mjs --update - -# Verify against existing snapshots -node scripts/rezi-snap.mjs --verify - -# Run specific scene -node scripts/rezi-snap.mjs --verify --scene button-matrix --theme dark -``` +## Verification -Snapshot format: metadata header (`scene`, `theme`, `viewport`, `version`, `capturedAt`) followed by rendered text content. +For design-system work, verify: -See [Developer Testing Guide](./dev/testing.md) for details. +- recipe unit tests +- renderer integration tests +- golden fixture updates when renderer bytes change +- at least one live PTY spot-check in a built-in theme diff --git a/docs/guide/animation.md b/docs/guide/animation.md index 3f758689..57acf419 100644 --- a/docs/guide/animation.md +++ b/docs/guide/animation.md @@ -186,10 +186,10 @@ playback: { Use animation utilities for RGB interpolation: ```typescript -import { interpolateRgb, interpolateRgbArray } from "@rezi-ui/core"; +import { interpolateRgb, interpolateRgbArray, rgb } from "@rezi-ui/core"; -const mid = interpolateRgb({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }, 0.5); -const ramp = interpolateRgbArray({ r: 0, g: 40, b: 80 }, { r: 220, g: 200, b: 40 }, 8); +const mid = interpolateRgb(rgb(0, 0, 0), rgb(255, 255, 255), 0.5); +const ramp = interpolateRgbArray(rgb(0, 40, 80), rgb(220, 200, 40), 8); ``` - Channels are linearly interpolated in RGB space. diff --git a/docs/guide/recommended-patterns.md b/docs/guide/recommended-patterns.md index 3f592819..aca58e8a 100644 --- a/docs/guide/recommended-patterns.md +++ b/docs/guide/recommended-patterns.md @@ -505,45 +505,72 @@ describe("keybindings", () => { ### Use Built-in Themes -Rezi ships with a default theme. Pass a custom theme or theme definition to `createNodeApp()`: +Pass a semantic `ThemeDefinition` to `createNodeApp()`: ```typescript import { createNodeApp } from "@rezi-ui/node"; -import type { ThemeDefinition } from "@rezi-ui/core"; - -const darkTheme: ThemeDefinition = { - colors: { - fg: { r: 220, g: 220, b: 220 }, - bg: { r: 20, g: 20, b: 30 }, - primary: { r: 100, g: 180, b: 255 }, - secondary: { r: 180, g: 100, b: 255 }, - success: { r: 100, g: 220, b: 100 }, - danger: { r: 255, g: 100, b: 100 }, - warning: { r: 255, g: 200, b: 50 }, - info: { r: 100, g: 200, b: 255 }, - muted: { r: 100, g: 100, b: 100 }, - border: { r: 60, g: 60, b: 80 }, +import { createThemeDefinition, rgb } from "@rezi-ui/core"; + +const appTheme = createThemeDefinition( + "app", + { + bg: { + base: rgb(20, 20, 30), + elevated: rgb(28, 28, 40), + overlay: rgb(36, 36, 52), + subtle: rgb(24, 24, 36), + }, + fg: { + primary: rgb(220, 220, 220), + secondary: rgb(170, 170, 190), + muted: rgb(120, 120, 140), + inverse: rgb(20, 20, 30), + }, + accent: { + primary: rgb(100, 180, 255), + secondary: rgb(180, 100, 255), + tertiary: rgb(120, 220, 180), + }, + success: rgb(100, 220, 100), + warning: rgb(255, 200, 50), + error: rgb(255, 100, 100), + info: rgb(100, 200, 255), + focus: { ring: rgb(100, 180, 255), bg: rgb(32, 36, 48) }, + selected: { bg: rgb(40, 52, 72), fg: rgb(220, 220, 220) }, + disabled: { fg: rgb(120, 120, 140), bg: rgb(28, 28, 40) }, + diagnostic: { + error: rgb(255, 100, 100), + warning: rgb(255, 200, 50), + info: rgb(100, 200, 255), + hint: rgb(120, 220, 180), + }, + border: { + subtle: rgb(36, 36, 52), + default: rgb(80, 80, 96), + strong: rgb(120, 120, 140), + }, }, -}; +); const app = createNodeApp({ initialState, - theme: darkTheme, + theme: appTheme, }); ``` ### Theme Switching -Store the active theme in state and recreate the theme object: +Store the active theme name in state and switch among stable prebuilt theme +objects: ```typescript // src/theme.ts -import type { ThemeDefinition } from "@rezi-ui/core"; +import { createThemeDefinition } from "@rezi-ui/core"; export const themes = { - dark: { colors: { /* ... */ } } satisfies ThemeDefinition, - light: { colors: { /* ... */ } } satisfies ThemeDefinition, - solarized: { colors: { /* ... */ } } satisfies ThemeDefinition, + dark: createThemeDefinition("dark", { /* colors */ }), + light: createThemeDefinition("light", { /* colors */ }), + solarized: createThemeDefinition("solarized", { /* colors */ }), } as const; export type ThemeName = keyof typeof themes; @@ -558,9 +585,9 @@ import { defineWidget, recipe, ui } from "@rezi-ui/core"; const MetricTile = defineWidget<{ label: string; value: string; key?: string }>((props, ctx) => { const tokens = ctx.useTheme(); - const surface = tokens ? recipe.surface(tokens, { elevation: 1 }) : null; + const surface = recipe.surface(tokens, { elevation: 1 }); - return ui.box({ border: surface?.border ?? "single", style: surface?.bg, p: 1 }, [ + return ui.box({ border: surface.border, style: surface.bg, p: 1 }, [ ui.text(props.label, { variant: "caption" }), ui.text(props.value, { variant: "heading" }), ]); @@ -593,18 +620,15 @@ ui.row({ gap: 1 }, [ ### Spacing scale in recipes -When calling recipes directly, pass `theme.spacing` to align component padding with the active theme: +When calling recipes directly, pass the spacing scale from the same +`ThemeDefinition` you install on the app so component padding stays aligned: ```typescript const tokens = ctx.useTheme(); -const appTheme = darkTheme; -const activeThemeSpacing = appTheme.spacing; -if (tokens) { - const button = recipe.button(tokens, { - size: "lg", - spacing: activeThemeSpacing, - }); -} +const button = recipe.button(tokens, { + size: "lg", + spacing: appTheme.spacing, +}); ``` ### NO_COLOR Support diff --git a/docs/guide/styling.md b/docs/guide/styling.md index 5b91518d..29dc344e 100644 --- a/docs/guide/styling.md +++ b/docs/guide/styling.md @@ -1,190 +1,141 @@ # Styling -Rezi styling is designed to be: +Rezi styling is: -- **explicit**: styles are passed as props -- **deterministic**: the same inputs produce the same frames -- **composable**: styles inherit through containers - -## Text attributes - -`TextStyle` supports these boolean text attributes: - -- `bold` -- `dim` -- `italic` -- `underline` -- `inverse` -- `strikethrough` -- `overline` -- `blink` - -Extended underline fields: - -- `underlineStyle?: "none" | "straight" | "double" | "curly" | "dotted" | "dashed"` -- `underlineColor?: string | ThemeColor` - -New attribute SGR target mappings: - -- `strikethrough` -> SGR `9` -- `overline` -> SGR `53` -- `blink` -> SGR `5` - -These codes are the terminal mapping used by the backend emitter. Drawlist encoding carries all three attrs, and backend emission now supports `strikethrough`, `overline`, and `blink` end-to-end (terminal rendering still depends on terminal support). Underline variants and underline color are encoded in ZRDL v1 extended style fields. +- explicit +- deterministic +- theme-aware +- composable through inheritance and scoped overrides ## Inline styles -Most visual widgets accept a `style` prop: - -```typescript -import { ui, rgb } from "@rezi-ui/core"; - -ui.text("Warning", { style: { fg: rgb(255, 180, 0), bold: true } }); -ui.box({ border: "rounded", p: 1, style: { bg: rgb(20, 20, 24) } }, [ - ui.text("Panel content"), -]); +Use inline `style` props for one-off presentation. + +```ts +import { resolveColorToken, type ThemeDefinition, ui } from "@rezi-ui/core"; + +function warningPanel(activeTheme: ThemeDefinition) { + return ui.box( + { + border: "rounded", + p: 1, + style: { bg: resolveColorToken(activeTheme, "bg.elevated") }, + }, + [ui.text("Warning", { style: { fg: resolveColorToken(activeTheme, "warning"), bold: true } })], + ); +} ``` -When a container (`row`, `column`, `box`) has a `style`, that style is inherited by its children and can be overridden per-widget. +Container style inherits to descendants unless a child overrides it. ## Theme-based styling -Themes provide consistent defaults (background/foreground, widget chrome, etc.) and are applied at the app level: +Themes are semantic `ThemeDefinition` objects. -```typescript -import { ui, darkTheme } from "@rezi-ui/core"; +```ts +import { ui, darkTheme, lightTheme } from "@rezi-ui/core"; import { createNodeApp } from "@rezi-ui/node"; const app = createNodeApp({ initialState: {}, theme: darkTheme }); app.view(() => ui.text("Hello")); + await app.start(); ``` Switching themes at runtime: -```typescript -app.setTheme(darkTheme); +```ts +app.setTheme(lightTheme); ``` -## Beautiful Defaults - -When using a `ThemeDefinition` preset (`darkTheme`, `lightTheme`, etc.), -interactive widgets automatically receive recipe-based styling. - -- No `dsVariant`/`dsTone` is required for baseline polished styling. -- Widgets opt into DS defaults automatically when semantic color tokens are present. -- Manual `style` overrides merge on top of recipe output (they do not disable recipes). - -Runtime guarantees for `setTheme`: - -- it can be called before `start()` and while running -- it throws if called during render/commit -- it is a no-op when the effective theme identity is unchanged -- a theme change triggers a full redraw path - -## Theme validation and extension +## Design-system defaults -Theme hardening APIs are available from `@rezi-ui/core`: +Built-in semantic themes automatically enable recipe styling for core widgets. -- `validateTheme(theme)` for strict token validation -- `extendTheme(base, overrides)` for deep-merge inheritance + validation -- `contrastRatio(fg, bg)` for WCAG contrast calculations +- You do not need `dsVariant` or `dsTone` for baseline polished styling. +- Manual widget styles merge on top of recipe output. +- `app.setTheme(...)` and scoped overrides use the same semantic token model. -Theme tokens include a diagnostic palette: +## Validation and extension -- `diagnostic.error` -- `diagnostic.warning` -- `diagnostic.info` -- `diagnostic.hint` +Theme hardening APIs: -Example: +- `validateTheme(theme)` +- `extendTheme(base, overrides)` +- `contrastRatio(fg, bg)` -```typescript -import { darkTheme, extendTheme, validateTheme } from "@rezi-ui/core"; +```ts +import { darkTheme, extendTheme, rgb, validateTheme } from "@rezi-ui/core"; const brandTheme = extendTheme(darkTheme, { - colors: { accent: { primary: { r: 255, g: 180, b: 84 } } }, + colors: { + accent: { + primary: rgb(255, 180, 84), + }, + }, + focusIndicator: { + bold: true, + underline: false, + }, }); validateTheme(brandTheme); ``` +Theme colors use packed `Rgb24` values, so author them with `rgb(...)` or +`color(...)`, not `{ r, g, b }` objects. + ## Scoped theme overrides -`box`, `row`, and `column` accept a scoped `theme` override prop: +Use `ui.themed(...)` for subtree-specific theme changes: -```typescript -import { ui } from "@rezi-ui/core"; +```ts +import { rgb, ui } from "@rezi-ui/core"; ui.column({}, [ ui.text("parent"), - ui.box({ theme: { colors: { primary: { r: 90, g: 200, b: 140 } } } }, [ui.text("scoped")]), + ui.themed( + { + colors: { + accent: { + primary: rgb(255, 140, 90), + }, + }, + }, + [ui.text("scoped")], + ), ui.text("parent restored"), ]); ``` -Behavior: - -- nested scopes compose (inner override wins) -- exiting a scoped subtree restores parent theme -- partial overrides inherit unspecified parent tokens - -See: [Theme](../styling/theme.md). - -## Decision guide - -Use **inline styles** when: - -- you need one-off emphasis (errors, highlights) -- a widget needs a custom color not tied to semantics - -Use **themes** when: - -- you want consistent styling across many widgets -- you support light/dark/high-contrast variants -- you want to centralize visual decisions - -In practice, most apps use both: a theme for defaults + inline styles for local emphasis. +Scoped overrides: -## Style inheritance +- compose predictably +- inherit unspecified values +- can override `colors`, `spacing`, `focusIndicator`, and `widget` palettes -Style is merged from parent → child: - -- containers pass their resolved style to children -- leaf widgets merge their own `style` on top -- boolean attrs use tri-state semantics: `undefined` inherits, `false` disables, `true` enables -- `box`/`row`/`column` can also apply scoped `theme` overrides to descendants -- when container `style.bg` is set, that container rect is filled - -Example: - -```typescript -import { ui, rgb } from "@rezi-ui/core"; - -ui.box({ p: 1, style: { fg: rgb(200, 200, 255) } }, [ - ui.text("Inherits fg"), - ui.text("Overrides", { style: { fg: rgb(255, 200, 120), bold: true } }), -]); -``` +`box`, `row`, `column`, and `grid` also accept a `theme` prop for scoped +overrides when that is more convenient than wrapping with `ui.themed(...)`. ## Dynamic styles -Compute styles from state, but keep `view(state)` pure (no timers, no I/O): +Compute styles from state, but keep `view(state)` pure. -```typescript -import { ui, rgb } from "@rezi-ui/core"; +```ts +import { resolveColorToken, type ThemeDefinition, ui } from "@rezi-ui/core"; -ui.text(state.connected ? "Online" : "Offline", { - style: { fg: state.connected ? rgb(80, 220, 120) : rgb(255, 100, 100) }, -}); +function connectionStatus(activeTheme: ThemeDefinition, state: { connected: boolean }) { + return ui.text(state.connected ? "Online" : "Offline", { + style: { + fg: resolveColorToken(activeTheme, state.connected ? "success" : "error"), + }, + }); +} ``` ## Related -- [Style props](../styling/style-props.md) - `TextStyle`, spacing props, helpers -- [Theme](../styling/theme.md) - Theme structure and built-ins -- [Icons](../styling/icons.md) - Icon registry and fallback rules -- [Focus styles](../styling/focus-styles.md) - Focus and disabled visuals -- [Text style internals](text-style-internals.md) - Drawlist bit layout and merge/cache internals - -Next: [Performance](performance.md). +- [Theme](../styling/theme.md) +- [Focus styles](../styling/focus-styles.md) +- [Style props](../styling/style-props.md) +- [Design system](../design-system.md) diff --git a/docs/styling/index.md b/docs/styling/index.md index d597acd1..e976939e 100644 --- a/docs/styling/index.md +++ b/docs/styling/index.md @@ -14,7 +14,10 @@ Rezi styling works at two levels: ## Beautiful Defaults -When the active theme provides semantic color tokens, core interactive widgets are recipe-styled by default (buttons, inputs, selects, checkboxes, progress, callouts). Use `intent` on buttons for common “primary/danger/link” patterns, and use manual `style` props to override specific attributes (they do not disable recipes). +Core interactive widgets are recipe-styled by default (buttons, inputs, +textareas, selects, checkboxes, progress, callouts). Use `intent` on buttons for common +“primary/danger/link” patterns, and use manual `style` props to override +specific attributes (they do not disable recipes). ## Quick Example @@ -56,8 +59,8 @@ Every widget that displays text supports a `style` prop: ```typescript type TextStyle = Readonly<{ - fg?: Rgb; // Foreground (text) color - bg?: Rgb; // Background color + fg?: Rgb24; // Foreground (text) color + bg?: Rgb24; // Background color bold?: boolean; // Bold text dim?: boolean; // Dim/faint text italic?: boolean; // Italic text @@ -67,7 +70,7 @@ type TextStyle = Readonly<{ overline?: boolean; // Overline text blink?: boolean; // Blinking text underlineStyle?: "none" | "straight" | "double" | "curly" | "dotted" | "dashed"; - underlineColor?: string | ThemeColor; + underlineColor?: Rgb24 | ThemeColor; }>; ``` @@ -92,7 +95,7 @@ ui.text("Bold text", { style: { bold: true } }); ui.text("Italic text", { style: { italic: true } }); ui.text("Underlined", { style: { underline: true } }); ui.text("Curly underline", { - style: { underlineStyle: "curly", underlineColor: "#ff6b6b" }, + style: { underlineStyle: "curly", underlineColor: rgb(255, 107, 107) }, }); ui.text("Dim text", { style: { dim: true } }); ui.text("Struck through", { style: { strikethrough: true } }); @@ -226,7 +229,6 @@ Use the spacing scale for consistent layouts: | Key | Value | Use Case | |-----|-------|----------| -| `"none"` | 0 | No spacing | | `"xs"` | 1 | Tight spacing | | `"sm"` | 1 | Compact elements | | `"md"` | 2 | Default spacing | diff --git a/docs/styling/style-props.md b/docs/styling/style-props.md index 08ae30ea..7c6b2b8e 100644 --- a/docs/styling/style-props.md +++ b/docs/styling/style-props.md @@ -2,9 +2,10 @@ This page is the reference for the styling-related props used across Rezi widgets. -## `Rgb` and `rgb()` +## `Rgb24` and `rgb()` -Colors are expressed as RGB triples with components in `0..255`: +Colors are expressed as packed `Rgb24` integers. Use `rgb(...)` or `color(...)` +to create them: ```typescript import { rgb } from "@rezi-ui/core"; @@ -16,7 +17,7 @@ const slate = rgb(20, 24, 32); Type: ```typescript -type Rgb = Readonly<{ r: number; g: number; b: number }>; +type Rgb24 = number; ``` ## `TextStyle` @@ -25,8 +26,8 @@ Most widgets that render text accept a `style` prop of type `TextStyle`: ```typescript type TextStyle = Readonly<{ - fg?: Rgb; - bg?: Rgb; + fg?: Rgb24; + bg?: Rgb24; bold?: boolean; dim?: boolean; italic?: boolean; @@ -36,12 +37,13 @@ type TextStyle = Readonly<{ overline?: boolean; blink?: boolean; underlineStyle?: "none" | "straight" | "double" | "curly" | "dotted" | "dashed"; - underlineColor?: Rgb | string; + underlineColor?: Rgb24 | string; }>; ``` `underlineStyle` controls underline variant where supported by the renderer/terminal. -`underlineColor` accepts either direct RGB values or a theme token string (for example `"accent.primary"`). +`fg` and `bg` use direct `Rgb24` values. `underlineColor` accepts either an +`Rgb24` value or a theme token string (for example `"accent.primary"`). Example: diff --git a/docs/styling/theme.md b/docs/styling/theme.md index 383d5e0d..8b18da68 100644 --- a/docs/styling/theme.md +++ b/docs/styling/theme.md @@ -1,19 +1,14 @@ # Theme -Rezi supports two related theme shapes: +Rezi theming is semantic-only. -- `ThemeDefinition`: semantic tokens (`bg.base`, `fg.primary`, `accent.primary`, etc.) -- `Theme`: runtime flat palette used by the renderer - -`app.setTheme(...)` accepts either shape. - -At app init/runtime, the interop bridge transparently normalizes -`ThemeDefinition` presets to the renderer-ready legacy `Theme` shape. -You do not need manual conversion in application code. +The public theme contract is `ThemeDefinition`. Applications pass a +`ThemeDefinition` to `createApp(...)`, `createNodeApp(...)`, and `app.setTheme(...)`. +There is no separate public legacy `Theme` shape anymore. ## Built-in presets -Rezi ships six semantic presets: +Rezi ships six built-in presets: - `darkTheme` - `lightTheme` @@ -22,115 +17,175 @@ Rezi ships six semantic presets: - `nordTheme` - `draculaTheme` -All six presets define explicit `focus.ring` colors used by unified DS focus -indicators. - -```typescript +```ts import { darkTheme, nordTheme } from "@rezi-ui/core"; app.setTheme(darkTheme); app.setTheme(nordTheme); ``` -## Validation - -Use `validateTheme(theme)` to enforce required theme structure before use: +## ThemeDefinition shape -```typescript -import { validateTheme } from "@rezi-ui/core"; +`ThemeDefinition` contains: -validateTheme(myTheme); -``` +- `name` +- `colors` +- `spacing` +- `focusIndicator` +- `widget` -Validation checks: +`colors` holds the semantic app palette (`bg.*`, `fg.*`, `accent.*`, `focus.*`, +`selected.*`, `disabled.*`, `diagnostic.*`, `border.*`, plus `success`, +`warning`, `error`, and `info`). -- All required semantic color tokens exist -- Every color token is valid RGB (`r/g/b` integer in `0..255`) -- Required spacing entries exist: `xs`, `sm`, `md`, `lg`, `xl`, `2xl` -- Focus indicator style tokens are present and valid +`widget` holds advanced surface palettes: -Error messages are path-specific, for example: +- `widget.syntax` +- `widget.diff` +- `widget.logs` +- `widget.toast` +- `widget.chart` -- `Theme validation failed at colors.accent.primary.r: ...` -- `Theme validation failed: missing required token path(s): colors.error, spacing.md` +All color values are packed `Rgb24` integers. Use `rgb(...)` or `color(...)` to +author them. -## Extension / inheritance +## Creating a theme -Use `extendTheme(base, overrides)` to derive variants without cloning full objects: +`createThemeDefinition(name, colors, options?)` creates a complete frozen theme. +If `spacing`, `focusIndicator`, or `widget` are omitted, Rezi fills sensible +defaults. -```typescript -import { darkTheme, extendTheme } from "@rezi-ui/core"; +```ts +import { createThemeDefinition, rgb } from "@rezi-ui/core"; -const brandDark = extendTheme(darkTheme, { - colors: { +export const brandTheme = createThemeDefinition( + "brand", + { + bg: { + base: rgb(10, 14, 20), + elevated: rgb(15, 20, 28), + overlay: rgb(24, 30, 40), + subtle: rgb(20, 25, 34), + }, + fg: { + primary: rgb(231, 236, 242), + secondary: rgb(142, 155, 170), + muted: rgb(96, 107, 121), + inverse: rgb(10, 14, 20), + }, accent: { - primary: { r: 255, g: 180, b: 84 }, + primary: rgb(255, 180, 84), + secondary: rgb(89, 194, 255), + tertiary: rgb(149, 230, 203), + }, + success: rgb(170, 217, 76), + warning: rgb(255, 180, 84), + error: rgb(240, 113, 120), + info: rgb(89, 194, 255), + focus: { + ring: rgb(255, 180, 84), + bg: rgb(26, 31, 38), + }, + selected: { + bg: rgb(39, 55, 71), + fg: rgb(231, 236, 242), + }, + disabled: { + fg: rgb(96, 107, 121), + bg: rgb(15, 20, 28), + }, + diagnostic: { + error: rgb(240, 113, 120), + warning: rgb(255, 180, 84), + info: rgb(89, 194, 255), + hint: rgb(149, 230, 203), + }, + border: { + subtle: rgb(26, 31, 38), + default: rgb(96, 107, 121), + strong: rgb(142, 155, 170), }, }, -}); +); ``` -Guarantees: - -- deep merge (override wins, other tokens inherited) -- returns a new theme object -- does not mutate `base` -- validates merged output - -## Contrast utility and WCAG checks +## Validation -Use `contrastRatio(fg, bg)` for WCAG 2.1 contrast calculations: +Use `validateTheme(theme)` to enforce the hardened contract. -```typescript -import { contrastRatio } from "@rezi-ui/core"; +```ts +import { validateTheme } from "@rezi-ui/core"; -const ratio = contrastRatio({ r: 0, g: 0, b: 0 }, { r: 255, g: 255, b: 255 }); // 21 +validateTheme(brandTheme); ``` -Built-in preset verification in tests: +Validation checks: -- all six presets pass WCAG AA (`>= 4.5:1`) for primary `fg/bg` -- `highContrastTheme` passes WCAG AAA (`>= 7:1`) for primary `fg/bg` +- every required semantic color token +- every required widget palette token +- `spacing.xs`, `spacing.sm`, `spacing.md`, `spacing.lg`, `spacing.xl`, `spacing.2xl` +- `focusIndicator.bold` and `focusIndicator.underline` +- packed `Rgb24` color values -## Runtime switching guarantees +Validation errors are path-specific. For example: -`app.setTheme(nextTheme)` behavior: +- `Theme validation failed at colors.accent.primary: expected packed Rgb24 integer ...` +- `Theme validation failed: missing required token path(s): widget.chart.primary, spacing.md` -- allowed before `start()` and while running -- throws on re-entrant render/commit calls (`ZRUI_UPDATE_DURING_RENDER`, `ZRUI_REENTRANT_CALL`) -- no-op when effective theme identity is unchanged -- theme changes trigger a full redraw (incremental reuse is bypassed on theme ref change) +## Extending and scoped overrides -## Component-level scoped overrides +Use `extendTheme(base, overrides)` to derive a new full theme definition. -`box`, `row`, and `column` support a scoped `theme` prop: +```ts +import { darkTheme, extendTheme, rgb } from "@rezi-ui/core"; -```typescript -import { ui } from "@rezi-ui/core"; +const brandDark = extendTheme(darkTheme, { + colors: { + accent: { + primary: rgb(255, 140, 90), + }, + }, + focusIndicator: { + bold: true, + underline: false, + }, +}); +``` -ui.column({}, [ - ui.text("parent"), - ui.box({ theme: { colors: { primary: { r: 80, g: 200, b: 120 } } } }, [ - ui.text("scoped"), - ]), - ui.text("parent again"), -]); +Scoped subtree overrides use the same override shape: + +```ts +ui.themed( + { + colors: { accent: { primary: rgb(255, 140, 90) } }, + spacing: { md: 3 }, + }, + [ui.text("Only this subtree changes")], +); ``` -Rules: +Scoped overrides inherit unspecified values from the parent theme and can +override `colors`, `spacing`, `focusIndicator`, and `widget` palettes. -- scope applies to container subtree -- nested overrides compose (inner scope wins) -- leaving a scoped container restores parent theme -- partial overrides inherit unspecified parent tokens +## Runtime switching + +`app.setTheme(nextTheme)` accepts a `ThemeDefinition`. + +Behavior: + +- allowed before `start()` and while running +- throws on re-entrant render/commit updates +- no-ops when the exact same theme object is passed again +- triggers a full redraw, with optional interpolation when + `themeTransitionFrames > 0` -## Color token helpers +## Color helpers -```typescript -import { darkTheme, resolveColorToken, tryResolveColorToken } from "@rezi-ui/core"; +```ts +import { resolveColorToken, tryResolveColorToken } from "@rezi-ui/core"; const fg = resolveColorToken(darkTheme, "fg.primary"); -const result = tryResolveColorToken(darkTheme, "accent.primary"); +const accent = tryResolveColorToken(darkTheme, "accent.primary"); ``` Related helpers: diff --git a/docs/widgets/checkbox.md b/docs/widgets/checkbox.md index 4eef01b7..c04694ca 100644 --- a/docs/widgets/checkbox.md +++ b/docs/widgets/checkbox.md @@ -30,11 +30,10 @@ ui.checkbox({ ## Design System Styling -Checkboxes are design-system styled by default when a `ThemeDefinition` preset is active. +Checkboxes are design-system styled by default under the active +`ThemeDefinition`. The indicator and label use `checkboxRecipe()` for checked/focus/disabled states. -If the active theme does not provide semantic color tokens, checkboxes fall back to non-recipe rendering. - ## Behavior - Focusable when enabled. diff --git a/docs/widgets/index.md b/docs/widgets/index.md index 11544b18..445966b4 100644 --- a/docs/widgets/index.md +++ b/docs/widgets/index.md @@ -36,7 +36,10 @@ Benefits of `ui.*` factories: ## Beautiful Defaults -When the active theme provides semantic color tokens, core interactive widgets are recipe-styled by default (buttons, inputs, selects, checkboxes, progress bars, callouts). Use `intent` on buttons for common patterns (primary/danger/link), and use manual `style` props to override specific attributes (they do not disable recipes). +Core interactive widgets are recipe-styled by default (buttons, inputs, +selects, checkboxes, progress bars, callouts). Use `intent` on buttons for +common patterns (primary/danger/link), and use manual `style` props to +override specific attributes (they do not disable recipes). ## Quick-Reference Table @@ -90,9 +93,11 @@ Content rendering, labels, and informational widgets. **Quick example:** ```typescript +import { rgb, ui } from "@rezi-ui/core"; + ui.column({ gap: 1 }, [ ui.richText([ - { text: "Error: ", style: { fg: { r: 255, g: 0, b: 0 }, bold: true } }, + { text: "Error: ", style: { fg: rgb(255, 0, 0), bold: true } }, { text: "File not found" }, ]), ui.callout("This action cannot be undone", { variant: "warning" }), diff --git a/docs/widgets/input.md b/docs/widgets/input.md index 77dd0581..8baef6c2 100644 --- a/docs/widgets/input.md +++ b/docs/widgets/input.md @@ -28,9 +28,8 @@ ui.input({ ## Design System Styling -Inputs are design-system styled by default when semantic color tokens are -available (for example via a `ThemeDefinition` preset). In that path, the input -renderer applies `inputRecipe()` output automatically. +Inputs are design-system styled by default. The input renderer applies +`inputRecipe()` output automatically from the active `ThemeDefinition`. | Prop | Type | Default | Description | |------|------|---------|-------------| @@ -40,10 +39,6 @@ renderer applies `inputRecipe()` output automatically. Manual `style` overrides are merged on top of recipe output via `mergeTextStyle(baseStyle, ownStyle)` (they do not disable recipes). -If semantic color tokens are unavailable, the renderer uses the non-recipe path: -it merges parent/own style with `getButtonLabelStyle({ focused, disabled })`, -then applies focus-aware content styling. - Framed chrome requires both `width >= 3` and `height >= 3`. At `height = 1`, the recipe still applies text/background styling, but no box border is drawn. diff --git a/docs/widgets/table.md b/docs/widgets/table.md index 6f9ccef9..7970cd04 100644 --- a/docs/widgets/table.md +++ b/docs/widgets/table.md @@ -5,6 +5,8 @@ Renders tabular data with column definitions, optional sorting, and row selectio ## Usage ```ts +import { rgb, ui } from "@rezi-ui/core"; + ui.table({ id: "users", columns: [ @@ -16,8 +18,8 @@ ui.table({ selection: state.selection, selectionMode: "multi", onSelectionChange: (keys) => app.update((s) => ({ ...s, selection: keys })), - stripeStyle: { odd: { r: 34, g: 37, b: 45 } }, - borderStyle: { variant: "double", color: { r: 120, g: 130, b: 145 } }, + stripeStyle: { odd: rgb(34, 37, 45) }, + borderStyle: { variant: "double", color: rgb(120, 130, 145) }, }) ``` @@ -84,7 +86,7 @@ const FilesTable = defineWidget<{ rows: readonly { id: string; name: string; siz ## Design System Styling -Tables are design-system styled by default when a `ThemeDefinition` preset is active. +Tables are design-system styled by default under the active `ThemeDefinition`. `tableRecipe()` provides consistent colors for: ```typescript diff --git a/examples/gallery/src/index.ts b/examples/gallery/src/index.ts index 72bf9334..bdfa9788 100644 --- a/examples/gallery/src/index.ts +++ b/examples/gallery/src/index.ts @@ -59,7 +59,7 @@ const sceneArg = args.find((_, i) => args[i - 1] === "--scene"); if (isHeadless) { // Headless: render scene(s) and exit - const { createTestRenderer, coerceToLegacyTheme } = await import("@rezi-ui/core"); + const { createTestRenderer } = await import("@rezi-ui/core"); const scenesToRender = sceneArg ? [getScene(sceneArg)].filter(Boolean) : scenes; @@ -74,8 +74,10 @@ if (isHeadless) { console.log(`\n=== ${scene.title} (${scene.name}) ===\n`); for (const themeName of THEME_NAMES) { - const theme = coerceToLegacyTheme(THEMES[themeName]); - const renderer = createTestRenderer({ viewport: { cols: 80, rows: 40 }, theme }); + const renderer = createTestRenderer({ + viewport: { cols: 80, rows: 40 }, + theme: THEMES[themeName], + }); const result = renderer.render(scene.render()); console.log(`--- Theme: ${themeName} ---`); console.log(result.toText()); diff --git a/packages/core/src/app/__tests__/keybindings.api.test.ts b/packages/core/src/app/__tests__/keybindings.api.test.ts index bbd87254..2ddf3e31 100644 --- a/packages/core/src/app/__tests__/keybindings.api.test.ts +++ b/packages/core/src/app/__tests__/keybindings.api.test.ts @@ -94,7 +94,7 @@ test("app.keys rejects invalid keybinding strings", () => { ); }); -test("app.modes rejects invalid bindings and invalid parent graphs", () => { +test("app.modes rejects invalid bindings and unknown parent graphs", () => { const backend = new StubBackend(); const app = createApp({ backend, initialState: 0 }); @@ -118,20 +118,23 @@ test("app.modes rejects invalid bindings and invalid parent graphs", () => { }), /unknown parent mode/, ); +}); - assert.throws( - () => - app.modes({ - a: { - parent: "b", - bindings: { x: () => {} }, - }, - b: { - parent: "a", - bindings: { y: () => {} }, - }, - }), - /cyclic keybinding mode parent chain/, +test("app.modes allows cyclic parent graphs and leaves cycle handling to routing", () => { + const backend = new StubBackend(); + const app = createApp({ backend, initialState: 0 }); + + assert.doesNotThrow(() => + app.modes({ + a: { + parent: "b", + bindings: { x: () => {} }, + }, + b: { + parent: "a", + bindings: { y: () => {} }, + }, + }), ); }); diff --git a/packages/core/src/app/createApp.ts b/packages/core/src/app/createApp.ts index f49bcd9e..ad7eeb0f 100644 --- a/packages/core/src/app/createApp.ts +++ b/packages/core/src/app/createApp.ts @@ -67,10 +67,8 @@ import { type TerminalProfile, terminalProfileFromCaps, } from "../terminalProfile.js"; -import { blendRgb } from "../theme/blend.js"; import { defaultTheme } from "../theme/defaultTheme.js"; -import { coerceToLegacyTheme } from "../theme/interop.js"; -import type { Theme } from "../theme/theme.js"; +import { type Theme, blendTheme, compileTheme } from "../theme/theme.js"; import type { ThemeDefinition } from "../theme/tokens.js"; import type { VNode } from "../widgets/types.js"; import { ui } from "../widgets/ui.js"; @@ -418,7 +416,7 @@ function codepointToCtrlKeyCode(codepoint: number): number | null { type CreateAppBaseOptions = Readonly<{ backend: RuntimeBackend; config?: AppConfig; - theme?: Theme | ThemeDefinition; + theme?: ThemeDefinition; }>; type CreateAppStateOptions = CreateAppBaseOptions & @@ -445,28 +443,7 @@ type ThemeTransitionState = Readonly<{ }>; function blendThemeColors(from: Theme, to: Theme, t: number): Theme { - const clampedT = Math.max(0, Math.min(1, t)); - if (clampedT <= 0) return from; - if (clampedT >= 1) return to; - - const colors: Record = {}; - const keys = new Set([...Object.keys(from.colors), ...Object.keys(to.colors)]); - for (const key of keys) { - const fromColor = from.colors[key]; - const toColor = to.colors[key]; - if (fromColor && toColor) { - colors[key] = blendRgb(fromColor, toColor, clampedT); - } else if (toColor) { - colors[key] = toColor; - } else if (fromColor) { - colors[key] = fromColor; - } - } - - return Object.freeze({ - colors: Object.freeze(colors) as Theme["colors"], - spacing: to.spacing, - }); + return blendTheme(from, to, t); } /** @@ -525,7 +502,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl ); } - let theme = coerceToLegacyTheme(opts.theme ?? defaultTheme); + let theme = compileTheme(opts.theme ?? defaultTheme.definition); let themeTransition: ThemeTransitionState | null = null; let terminalProfile: TerminalProfile = DEFAULT_TERMINAL_PROFILE; @@ -784,10 +761,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl try { result = registerModes(keybindingState, modes); } catch (error: unknown) { - throwCode( - "ZRUI_INVALID_PROPS", - `modes: ${error instanceof Error ? error.message : String(error)}`, - ); + throwCode("ZRUI_INVALID_PROPS", `modes: ${describeThrown(error)}`); } if (result.invalidKeys.length > 0) { throwCode( @@ -944,6 +918,12 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } } + function assertLifecycleIdle(method: string): void { + if (lifecycleBusy !== null) { + throwCode("ZRUI_INVALID_STATE", `${method}: lifecycle operation already in flight`); + } + } + function assertNotReentrant(method: string): void { if (inCommit || inRender || inEventHandlerDepth > 0) { throwCode("ZRUI_REENTRANT_CALL", `${method}: re-entrant call`); @@ -1706,6 +1686,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl const app: App = { view(fn: ViewFn): void { assertOperational("view"); + assertLifecycleIdle("view"); sm.assertOneOf(["Created", "Stopped"], "view: must be Created or Stopped"); assertNotReentrant("view"); if (routes !== undefined) { @@ -1721,6 +1702,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl replaceView(fn: ViewFn): void { assertOperational("replaceView"); + assertLifecycleIdle("replaceView"); assertNotReentrant("replaceView"); if (routes !== undefined) { throwCode( @@ -1735,12 +1717,14 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl viewFn = fn; topLevelViewError = null; if (sm.state === "Running") { + widgetRenderer.forceFullRenderNextFrame(); markDirty(DIRTY_VIEW); } }, replaceRoutes(nextRoutes: readonly RouteDefinition[]): void { assertOperational("replaceRoutes"); + assertLifecycleIdle("replaceRoutes"); assertNotReentrant("replaceRoutes"); if (!routerIntegration || routes === undefined) { throwCode( @@ -1755,12 +1739,14 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl replaceRouteBindings(nextRouteKeybindings); topLevelViewError = null; if (sm.state === "Running") { + widgetRenderer.forceFullRenderNextFrame(); markDirty(DIRTY_VIEW); } }, draw(fn: DrawFn): void { assertOperational("draw"); + assertLifecycleIdle("draw"); sm.assertOneOf(["Created", "Stopped"], "draw: must be Created or Stopped"); assertNotReentrant("draw"); if (mode === "widget") throwCode("ZRUI_MODE_CONFLICT", "draw: view mode already selected"); @@ -1789,6 +1775,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl update(updater: StateUpdater): void { assertOperational("update"); + assertLifecycleIdle("update"); if (inCommit) throwCode("ZRUI_REENTRANT_CALL", "update: called during commit"); if (inRender) throwCode("ZRUI_UPDATE_DURING_RENDER", updateDuringRenderDetail("update")); @@ -1799,11 +1786,12 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl } }, - setTheme(next: Theme | ThemeDefinition): void { + setTheme(next: ThemeDefinition): void { assertOperational("setTheme"); + assertLifecycleIdle("setTheme"); if (inCommit) throwCode("ZRUI_REENTRANT_CALL", "setTheme: called during commit"); if (inRender) throwCode("ZRUI_UPDATE_DURING_RENDER", updateDuringRenderDetail("setTheme")); - const nextTheme = coerceToLegacyTheme(next); + const nextTheme = compileTheme(next); if (nextTheme === themeTransition?.to) return; if (nextTheme === theme) { themeTransition = null; @@ -1815,6 +1803,7 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl debugLayout(enabled?: boolean): boolean { assertOperational("debugLayout"); + assertLifecycleIdle("debugLayout"); if (mode === "raw") { throwCode("ZRUI_MODE_CONFLICT", "debugLayout: not available in draw mode"); } @@ -2014,17 +2003,21 @@ export function createApp(opts: CreateAppStateOptions | CreateAppRoutesOnl keys(bindings: BindingMap>): void { assertKeybindingMutationAllowed("keys"); + assertLifecycleIdle("keys"); registerAppBindings(bindings); }, modes(modes: ModeBindingMap>): void { assertKeybindingMutationAllowed("modes"); + assertLifecycleIdle("modes"); registerAppModes(modes); }, setMode(modeName: string): void { assertKeybindingMutationAllowed("setMode"); + assertLifecycleIdle("setMode"); keybindingState = setMode(keybindingState, modeName); + keybindingsEnabled = computeKeybindingsEnabled(keybindingState); }, getMode(): string { diff --git a/packages/core/src/app/inspectorOverlayHelper.ts b/packages/core/src/app/inspectorOverlayHelper.ts index 6b94db67..f3b57293 100644 --- a/packages/core/src/app/inspectorOverlayHelper.ts +++ b/packages/core/src/app/inspectorOverlayHelper.ts @@ -11,7 +11,6 @@ import type { RuntimeBackend } from "../backend.js"; import type { FrameSnapshot } from "../debug/frameInspector.js"; import type { RouteDefinition } from "../router/types.js"; import { defaultTheme } from "../theme/defaultTheme.js"; -import type { Theme } from "../theme/theme.js"; import type { ThemeDefinition } from "../theme/tokens.js"; import { type InspectorOverlayFrameTiming, @@ -40,7 +39,7 @@ type AppCreateOptions = Readonly<{ backend: RuntimeBackend; initialState: S; config?: AppConfig; - theme?: Theme | ThemeDefinition; + theme?: ThemeDefinition; }>; export type InspectorOverlayHelperOptions = Readonly<{ @@ -126,7 +125,7 @@ export function createAppWithInspectorOverlay( let latestSnapshot: RuntimeBreadcrumbSnapshot | null = null; let overlayEnabled = inspectorOpts.enabled === true; - let lastThemeInput: Theme | ThemeDefinition = opts.theme ?? defaultTheme; + let lastThemeInput: ThemeDefinition = opts.theme ?? defaultTheme.definition; const app = createApp({ backend: opts.backend, @@ -260,7 +259,7 @@ export function createAppWithInspectorOverlay( update(updater: S | ((prev: Readonly) => S)): void { app.update(updater); }, - setTheme(theme: Theme | ThemeDefinition): void { + setTheme(theme: ThemeDefinition): void { lastThemeInput = theme; app.setTheme(theme); }, diff --git a/packages/core/src/app/types.ts b/packages/core/src/app/types.ts index c7ac34d0..9f2eb27e 100644 --- a/packages/core/src/app/types.ts +++ b/packages/core/src/app/types.ts @@ -10,7 +10,6 @@ import type { Rect } from "../layout/types.js"; import type { RouteDefinition, RouterApi } from "../router/types.js"; import type { FocusInfo } from "../runtime/widgetMeta.js"; import type { TerminalProfile } from "../terminalProfile.js"; -import type { Theme } from "../theme/theme.js"; import type { ThemeDefinition } from "../theme/tokens.js"; import type { VNode } from "../widgets/types.js"; @@ -49,7 +48,7 @@ export interface App { onEvent(handler: EventHandler): () => void; onFocusChange(handler: FocusChangeHandler): () => void; update(updater: S | ((prev: Readonly) => S)): void; - setTheme(theme: Theme | ThemeDefinition): void; + setTheme(theme: ThemeDefinition): void; debugLayout(enabled?: boolean): boolean; start(): Promise; run(): Promise; diff --git a/packages/core/src/app/widgetRenderer.ts b/packages/core/src/app/widgetRenderer.ts index 4ea76aa9..8ce10524 100644 --- a/packages/core/src/app/widgetRenderer.ts +++ b/packages/core/src/app/widgetRenderer.ts @@ -1244,6 +1244,7 @@ export class WidgetRenderer { private readonly _pooledActiveExitKeys = new Set(); private readonly _pooledPrevTreeIds = new Set(); private _runtimeBreadcrumbs: WidgetRuntimeBreadcrumbSnapshot = EMPTY_WIDGET_RUNTIME_BREADCRUMBS; + private forceFullRenderOnNextSubmit = false; private _constraintBreadcrumbs: RuntimeBreadcrumbConstraintsSummary | null = null; private _constraintExprIndexByInstanceId: ReadonlyMap< InstanceId, @@ -1337,6 +1338,10 @@ export class WidgetRenderer { } } + forceFullRenderNextFrame(): void { + this.forceFullRenderOnNextSubmit = true; + } + private describeLayoutNode(node: LayoutTree): string { return describeLayoutNodeImpl(node); } @@ -3273,6 +3278,7 @@ export class WidgetRenderer { viewport: Viewport, theme: Theme, ): boolean { + if (this.forceFullRenderOnNextSubmit) return false; return shouldAttemptIncrementalRenderImpl({ hasRenderedFrame: this._hasRenderedFrame, doLayout, @@ -3735,6 +3741,7 @@ export class WidgetRenderer { activePaths: this.committedErrorBoundaryPathsScratch, requestRetry: (retryPath: string) => { this.retryErrorBoundaryPaths.add(retryPath); + this.forceFullRenderNextFrame(); this.requestView(); }, }, @@ -5073,7 +5080,12 @@ export class WidgetRenderer { let runtimeDamageMode: RuntimeBreadcrumbDamageMode = "none"; let runtimeDamageRectCount = 0; let runtimeDamageArea = 0; - if (this.shouldAttemptIncrementalRender(doLayout, viewport, theme)) { + const forceFullRenderThisSubmit = this.forceFullRenderOnNextSubmit; + this.forceFullRenderOnNextSubmit = false; + if ( + !forceFullRenderThisSubmit && + this.shouldAttemptIncrementalRender(doLayout, viewport, theme) + ) { if (!doCommit) { this.markTransientDirtyNodes( this.committedRoot, diff --git a/packages/core/src/forms/__tests__/form.disabled.test.ts b/packages/core/src/forms/__tests__/form.disabled.test.ts index ca7e0846..c4330af2 100644 --- a/packages/core/src/forms/__tests__/form.disabled.test.ts +++ b/packages/core/src/forms/__tests__/form.disabled.test.ts @@ -10,6 +10,7 @@ import { createHookContext, runPendingEffects, } from "../../runtime/instances.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; import type { WidgetContext } from "../../widgets/composition.js"; import type { UseFormOptions, UseFormReturn } from "../types.js"; import { useForm } from "../useForm.js"; @@ -50,7 +51,7 @@ function createTestContext(): { useMemo: hookCtx.useMemo, useCallback: hookCtx.useCallback, useAppState: (_selector: (s: State) => U): U => undefined as U, - useTheme: () => null, + useTheme: () => defaultTheme.definition.colors, useViewport: () => ({ width: 80, height: 24, breakpoint: "md" as const }), invalidate: () => { registry.invalidate(instanceId); diff --git a/packages/core/src/forms/__tests__/form.wizard.test.ts b/packages/core/src/forms/__tests__/form.wizard.test.ts index 290521b0..e2f650b1 100644 --- a/packages/core/src/forms/__tests__/form.wizard.test.ts +++ b/packages/core/src/forms/__tests__/form.wizard.test.ts @@ -8,6 +8,7 @@ import { createHookContext, runPendingEffects, } from "../../runtime/instances.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; import type { WidgetContext } from "../../widgets/composition.js"; import type { UseFormOptions, UseFormReturn } from "../types.js"; import { useForm } from "../useForm.js"; @@ -48,7 +49,7 @@ function createTestContext(): { useMemo: hookCtx.useMemo, useCallback: hookCtx.useCallback, useAppState: (_selector: (s: State) => U): U => undefined as U, - useTheme: () => null, + useTheme: () => defaultTheme.definition.colors, useViewport: () => ({ width: 80, height: 24, breakpoint: "md" as const }), invalidate: () => { registry.invalidate(instanceId); diff --git a/packages/core/src/forms/__tests__/harness.ts b/packages/core/src/forms/__tests__/harness.ts index 084e0528..fa290a30 100644 --- a/packages/core/src/forms/__tests__/harness.ts +++ b/packages/core/src/forms/__tests__/harness.ts @@ -3,6 +3,7 @@ import { createHookContext, runPendingEffects, } from "../../runtime/instances.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; import type { WidgetContext } from "../../widgets/composition.js"; import type { UseFormOptions, UseFormReturn } from "../types.js"; import { useForm } from "../useForm.js"; @@ -41,7 +42,7 @@ export function createFormHarness(): { useMemo: hookCtx.useMemo, useCallback: hookCtx.useCallback, useAppState: (_selector: (s: State) => U): U => undefined as U, - useTheme: () => null, + useTheme: () => defaultTheme.definition.colors, useViewport: () => ({ width: 80, height: 24, breakpoint: "md" as const }), invalidate: () => { invalidateCount++; diff --git a/packages/core/src/forms/__tests__/useForm.test.ts b/packages/core/src/forms/__tests__/useForm.test.ts index 95ca67ef..9df5a627 100644 --- a/packages/core/src/forms/__tests__/useForm.test.ts +++ b/packages/core/src/forms/__tests__/useForm.test.ts @@ -10,6 +10,7 @@ import { createHookContext, runPendingEffects, } from "../../runtime/instances.js"; +import { defaultTheme } from "../../theme/defaultTheme.js"; import type { WidgetContext } from "../../widgets/composition.js"; import type { UseFormOptions, UseFormReturn } from "../types.js"; import { useForm } from "../useForm.js"; @@ -66,7 +67,7 @@ function createTestContext(): { useMemo: hookCtx.useMemo, useCallback: hookCtx.useCallback, useAppState: (_selector: (s: State) => U): U => undefined as U, - useTheme: () => null, + useTheme: () => defaultTheme.definition.colors, useViewport: () => ({ width: 80, height: 24, breakpoint: "md" as const }), invalidate: () => { invalidateCount++; diff --git a/packages/core/src/forms/useForm.ts b/packages/core/src/forms/useForm.ts index 1fd3fc42..14188043 100644 --- a/packages/core/src/forms/useForm.ts +++ b/packages/core/src/forms/useForm.ts @@ -845,7 +845,6 @@ export function useForm, State = void>( if (!textBindable) { warnUnsupportedTextBinding(field); } - return { id: options?.id ?? ctx.id(String(field)), value: toInputValue(snapshot.values[field]), @@ -1415,7 +1414,6 @@ export function useForm, State = void>( return; } const submitValues = cloneInitialValues(snapshot.values); - const failSubmit = (error: unknown): void => { if (typeof options.onSubmitError === "function") { try { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7f6cf55d..2e5a412d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -562,15 +562,6 @@ export type { // ============================================================================= export { - // Runtime theme (resolved theme shape) - createTheme, - defaultTheme, - resolveColor, - resolveSpacing, - type Theme, - type ThemeColors, - type ThemeSpacing, - // New semantic token system color, createColorTokens, createThemeDefinition, @@ -579,15 +570,21 @@ export { type AccentTokens, type BgTokens, type BorderTokens, + type ChartTokens, type ColorTokens, type DiagnosticTokens, + type DiffTokens, type DisabledTokens, type FocusIndicatorTokens, type FgTokens, type FocusTokens, + type LogsTokens, type SelectedTokens, + type SyntaxTokens, type ThemeSpacingTokens, type ThemeDefinition, + type ToastTokens, + type WidgetTokens, // Theme presets darkTheme, lightTheme, @@ -612,13 +609,6 @@ export { contrastRatio, } from "./theme/index.js"; -// Theme interop (ThemeDefinition <-> runtime Theme conversion) -export { - coerceToLegacyTheme, - mergeThemeOverride, - isThemeDefinition, -} from "./theme/interop.js"; - // ============================================================================= // Design System // ============================================================================= diff --git a/packages/core/src/keybindings/__tests__/keybinding.modes.test.ts b/packages/core/src/keybindings/__tests__/keybinding.modes.test.ts index 5f427022..837034c7 100644 --- a/packages/core/src/keybindings/__tests__/keybinding.modes.test.ts +++ b/packages/core/src/keybindings/__tests__/keybinding.modes.test.ts @@ -306,23 +306,32 @@ describe("keybinding modes", () => { assert.equal(defaultHits, 0); }); - test("mode-parent cycles are rejected during registration", () => { - const state = createManagerState(); + test("mode-parent cycles do not loop during matching", () => { + let hits = 0; + let state = createManagerState(); - assert.throws( - () => - registerModes(state, { - a: { - parent: "b", - bindings: {}, - }, - b: { - parent: "a", - bindings: {}, + state = registerModes(state, { + a: { + parent: "b", + bindings: { + a: () => { + hits++; }, - }), - /cyclic keybinding mode parent chain detected at "a"/, - ); + }, + }, + b: { + parent: "a", + bindings: {}, + }, + }).state; + state = setMode(state, "a"); + + const matched = routeKeyEvent(state, keyDown(KEY_A, 1), ctx()); + const unmatched = routeKeyEvent(state, keyDown(KEY_Q, 2), ctx()); + + assert.equal(matched.consumed, true); + assert.equal(unmatched.consumed, false); + assert.equal(hits, 1); }); test("inherited parent chord can start and complete in child mode", () => { diff --git a/packages/core/src/keybindings/manager.ts b/packages/core/src/keybindings/manager.ts index 64bf7982..6a7deec0 100644 --- a/packages/core/src/keybindings/manager.ts +++ b/packages/core/src/keybindings/manager.ts @@ -119,7 +119,7 @@ export type BindingMap = Readonly>>; export type RegisterBindingsOptions = Readonly<{ /** Mode to register bindings in (default: "default") */ mode?: string; - /** Internal ownership tag used to replace/remove binding groups safely. */ + /** Internal ownership tag used by the runtime to replace/remove binding groups safely. */ sourceTag?: string; }>; @@ -415,21 +415,6 @@ function validateModeGraph(modes: ReadonlyMap>): void ); } } - - for (const modeName of modes.keys()) { - const visited = new Set(); - let currentName: string | undefined = modeName; - - while (currentName !== undefined) { - if (visited.has(currentName)) { - throw new Error(`cyclic keybinding mode parent chain detected at "${currentName}"`); - } - visited.add(currentName); - const current = modes.get(currentName); - if (!current) break; - currentName = current.parent; - } - } } /** diff --git a/packages/core/src/layout/validateProps.ts b/packages/core/src/layout/validateProps.ts index c2ec54fe..c5ff8e0b 100644 --- a/packages/core/src/layout/validateProps.ts +++ b/packages/core/src/layout/validateProps.ts @@ -122,6 +122,7 @@ export type ValidatedInputProps = Readonly<{ id: string; value: string; disabled: boolean; + readOnly: boolean; multiline: boolean; rows: number; wordWrap: boolean; @@ -950,6 +951,7 @@ export function validateInputProps(props: InputProps | unknown): LayoutResult op.kind === "drawText" && op.text.includes(text)); diff --git a/packages/core/src/renderer/__tests__/focusIndicators.test.ts b/packages/core/src/renderer/__tests__/focusIndicators.test.ts index 04fd219e..32bee396 100644 --- a/packages/core/src/renderer/__tests__/focusIndicators.test.ts +++ b/packages/core/src/renderer/__tests__/focusIndicators.test.ts @@ -6,8 +6,8 @@ import { layout } from "../../layout/layout.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; +import { compileTheme } from "../../theme/theme.js"; import type { Theme } from "../../theme/theme.js"; import { renderToDrawlist } from "../renderToDrawlist.js"; @@ -148,7 +148,7 @@ describe("focus indicator rendering contracts", () => { name: "checkbox", vnode: ui.checkbox({ id: "cb", checked: false, label: "Choice" }), focusedId: "cb", - token: "[ ]", + token: "Choice", expectUnderline: true, }, { @@ -252,7 +252,7 @@ describe("focus indicator rendering contracts", () => { }); test("bracket and arrow indicators render focused decorations in ring color", () => { - const theme = coerceToLegacyTheme(darkTheme); + const theme = compileTheme(darkTheme); const buttonOps = drawTextOps(renderOps(ui.button({ id: "btn", label: "Save" }), "btn", theme)); const leftBracket = findDrawTextByToken(buttonOps, "["); @@ -277,7 +277,7 @@ describe("focus indicator rendering contracts", () => { }); test("dark theme focus uses focus.ring color", () => { - const theme = coerceToLegacyTheme(darkTheme); + const theme = compileTheme(darkTheme); const textOps = drawTextOps( renderOps(ui.link({ id: "lnk", url: "https://example.com" }), "lnk", theme), ); @@ -287,15 +287,15 @@ describe("focus indicator rendering contracts", () => { assert.deepEqual(op.style.fg, darkTheme.colors.focus.ring); }); - test("legacy theme falls back to underline + bold focus", () => { + test("default theme applies configured underline + bold focus styling", () => { const focusedOps = drawTextOps( renderOps(ui.checkbox({ id: "cb", checked: false, label: "Legacy" }), "cb", defaultTheme), ); const unfocusedOps = drawTextOps( renderOps(ui.checkbox({ id: "cb", checked: false, label: "Legacy" }), null, defaultTheme), ); - const focusedOp = findDrawTextByToken(focusedOps, "[ ]"); - const unfocusedOp = findDrawTextByToken(unfocusedOps, "[ ]"); + const focusedOp = findDrawTextByToken(focusedOps, "Legacy"); + const unfocusedOp = findDrawTextByToken(unfocusedOps, "Legacy"); assert.ok(focusedOp !== null, "focused checkbox should render"); assert.ok(unfocusedOp !== null, "unfocused checkbox should render"); if (!focusedOp?.style?.fg || typeof focusedOp.style.fg === "string") return; @@ -374,7 +374,7 @@ describe("focus indicator rendering contracts", () => { test("FocusConfig.style overrides design-system focus defaults", () => { const customRing = Object.freeze((255 << 16) | (0 << 8) | 128); - const theme = coerceToLegacyTheme(darkTheme); + const theme = compileTheme(darkTheme); const textOps = drawTextOps( renderOps( ui.table({ diff --git a/packages/core/src/renderer/__tests__/inputRecipeRendering.test.ts b/packages/core/src/renderer/__tests__/inputRecipeRendering.test.ts index 795c972b..28d917a4 100644 --- a/packages/core/src/renderer/__tests__/inputRecipeRendering.test.ts +++ b/packages/core/src/renderer/__tests__/inputRecipeRendering.test.ts @@ -1,11 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { defaultTheme } from "../../theme/defaultTheme.js"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; +import { compileTheme } from "../../theme/theme.js"; import { ui } from "../../widgets/ui.js"; import { type DrawOp, renderOps } from "./recipeRendering.test-utils.js"; -const dsTheme = coerceToLegacyTheme(darkTheme); +const dsTheme = compileTheme(darkTheme); function firstDrawText( ops: readonly DrawOp[], @@ -33,7 +33,7 @@ describe("input recipe rendering", () => { assert.deepEqual(border.style?.fg, dsTheme.colors["border.default"]); }); - test("keeps legacy fallback path for non-semantic themes", () => { + test("default theme uses recipe background and border by default", () => { const ops = renderOps( ui.row({ height: 3, items: "stretch" }, [ ui.input({ id: "legacy", value: "", placeholder: "Name" }), @@ -42,13 +42,11 @@ describe("input recipe rendering", () => { ); assert.equal( ops.some((op) => op.kind === "fillRect"), - false, - "legacy input should not fill recipe background", + true, ); assert.equal( ops.some((op) => op.kind === "drawText" && /[┌┐└┘]/.test(op.text)), - false, - "legacy input should not render recipe border", + true, ); }); diff --git a/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts b/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts index 63276939..385e893d 100644 --- a/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts +++ b/packages/core/src/renderer/__tests__/recipeRendering.test-utils.ts @@ -1,10 +1,11 @@ import { assert } from "@rezi-ui/testkit"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; -import type { DrawlistBuildResult, DrawlistBuilder, TextStyle, Theme, VNode } from "../../index.js"; +import type { DrawlistBuildResult, DrawlistBuilder, TextStyle, VNode } from "../../index.js"; import { layout } from "../../layout/layout.js"; import type { Axis } from "../../layout/types.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; +import type { Theme } from "../../theme/theme.js"; import { renderToDrawlist } from "../renderToDrawlist.js"; export type DrawOp = diff --git a/packages/core/src/renderer/__tests__/renderer.clip.test.ts b/packages/core/src/renderer/__tests__/renderer.clip.test.ts index b8d05f1a..25054ad9 100644 --- a/packages/core/src/renderer/__tests__/renderer.clip.test.ts +++ b/packages/core/src/renderer/__tests__/renderer.clip.test.ts @@ -295,6 +295,38 @@ describe("renderer clipping (deterministic)", () => { assert.equal(cellAt(frame, 3, 0), "B"); }); + test("fragment does not introduce a clip under overflow-visible parents", () => { + const viewport = { cols: 20, rows: 4 }; + const vnode: VNode = { + kind: "row", + props: { gap: 0, width: 4, height: 1, overflow: "visible" }, + children: [ + { + kind: "fragment", + props: {}, + children: [ + ui.row({ gap: 0, width: 4, height: 1, ml: 2, overflow: "hidden" }, [ui.text("ABCD")]), + ], + }, + ], + }; + + const ops = renderOps(vnode, viewport, "row"); + const frame = frameFromOps(ops, viewport); + const pushes = collectPushClips(ops); + + assert.equal( + pushes.some((clip) => clip.x === 0 && clip.y === 0 && clip.w === 4 && clip.h === 1), + false, + ); + assert.equal( + pushes.some((clip) => clip.x === 2 && clip.y === 0 && clip.w === 2 && clip.h === 1), + true, + ); + assert.equal(cellAt(frame, 2, 0), "A"); + assert.equal(cellAt(frame, 3, 0), "B"); + }); + test("two-level nested clips constrain overflow to overlap", () => { const viewport = { cols: 20, rows: 4 }; const vnode = ui.row({ gap: 0, width: 8, height: 1, overflow: "hidden" }, [ diff --git a/packages/core/src/renderer/__tests__/selectRecipeRendering.test.ts b/packages/core/src/renderer/__tests__/selectRecipeRendering.test.ts index 972d0d89..039ba950 100644 --- a/packages/core/src/renderer/__tests__/selectRecipeRendering.test.ts +++ b/packages/core/src/renderer/__tests__/selectRecipeRendering.test.ts @@ -1,11 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { defaultTheme } from "../../theme/defaultTheme.js"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; +import { compileTheme } from "../../theme/theme.js"; import { ui } from "../../widgets/ui.js"; import { type DrawOp, renderOps } from "./recipeRendering.test-utils.js"; -const dsTheme = coerceToLegacyTheme(darkTheme); +const dsTheme = compileTheme(darkTheme); const options = [{ value: "a", label: "Alpha" }] as const; function firstDrawText( @@ -59,30 +59,24 @@ describe("select recipe rendering", () => { assert.deepEqual(text.style?.fg, dsTheme.colors["disabled.fg"]); }); - test("keeps legacy fallback when semantic tokens are absent", () => { + test("default theme keeps semantic select recipe styling enabled", () => { const ops = renderOps( - ui.row({ height: 3, items: "stretch" }, [ui.select({ id: "legacy", value: "a", options })]), + ui.row({ height: 3, items: "stretch" }, [ui.select({ id: "default", value: "a", options })]), { viewport: { cols: 40, rows: 5 }, theme: defaultTheme }, ); - assert.equal( - ops.some((op) => op.kind === "fillRect"), - false, - "legacy select should not fill recipe background", - ); - assert.equal( - ops.some((op) => op.kind === "drawText" && /[┌┐└┘]/.test(op.text)), - false, - "legacy select should not draw recipe border", - ); + const fill = ops.find((op) => op.kind === "fillRect"); + assert.ok(fill !== undefined, "select should fill recipe background"); + if (!fill || fill.kind !== "fillRect") return; + assert.deepEqual(fill.style?.bg, defaultTheme.colors["bg.elevated"]); assert.equal( ops.some((op) => op.kind === "drawText" && op.text.includes("Alpha")), true, - "legacy select should render selected text", + "select should render selected text", ); assert.equal( ops.some((op) => op.kind === "drawText" && op.text.includes("▼")), true, - "legacy select should render inline caret", + "select should render inline caret", ); }); }); diff --git a/packages/core/src/renderer/__tests__/sliderRecipeRendering.test.ts b/packages/core/src/renderer/__tests__/sliderRecipeRendering.test.ts index 4daacdfb..d5ca7186 100644 --- a/packages/core/src/renderer/__tests__/sliderRecipeRendering.test.ts +++ b/packages/core/src/renderer/__tests__/sliderRecipeRendering.test.ts @@ -1,11 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; +import { compileTheme } from "../../theme/theme.js"; import { selectRecipe } from "../../ui/recipes.js"; import { ui } from "../../widgets/ui.js"; import { type DrawOp, renderOps } from "./recipeRendering.test-utils.js"; -const dsTheme = coerceToLegacyTheme(darkTheme); +const dsTheme = compileTheme(darkTheme); function firstDrawText( ops: readonly DrawOp[], match: (text: string) => boolean, diff --git a/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts b/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts index e7a60cc3..95d7a89e 100644 --- a/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts +++ b/packages/core/src/renderer/__tests__/tableRecipeRendering.test.ts @@ -1,11 +1,11 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { defaultTheme } from "../../theme/defaultTheme.js"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; +import { compileTheme } from "../../theme/theme.js"; import { ui } from "../../widgets/ui.js"; import { type DrawOp, renderOps } from "./recipeRendering.test-utils.js"; -const dsTheme = coerceToLegacyTheme(darkTheme); +const dsTheme = compileTheme(darkTheme); type Row = Readonly<{ id: string; name: string }>; @@ -83,10 +83,10 @@ describe("table recipe rendering", () => { ); }); - test("keeps legacy table fallback when semantic tokens are absent", () => { + test("default theme keeps semantic table recipe styling enabled", () => { const ops = renderOps( ui.table({ - id: "legacy-table", + id: "default-table", columns, data, getRowKey: (row) => row.id, @@ -101,14 +101,16 @@ describe("table recipe rendering", () => { const sortedHeader = firstDrawText(ops, (text) => text.includes("▲")); assert.ok(sortedHeader && sortedHeader.kind === "drawText"); if (!sortedHeader || sortedHeader.kind !== "drawText") return; - assert.deepEqual(sortedHeader.style?.fg, defaultTheme.colors.info); + assert.deepEqual(sortedHeader.style?.fg, defaultTheme.colors["fg.secondary"]); + assert.equal(sortedHeader.style?.bold, true); + assert.equal(sortedHeader.style?.underline, true); assert.equal( ops.some( - (op) => op.kind === "fillRect" && rgbEquals(op.style?.bg, defaultTheme.colors.border), + (op) => op.kind === "fillRect" && rgbEquals(op.style?.bg, defaultTheme.colors["bg.subtle"]), ), true, - "legacy striped rows should still use theme.colors.border", + "striped rows should use the semantic subtle background", ); }); }); diff --git a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts index a08cf754..463f0a15 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts @@ -143,6 +143,16 @@ function hashTheme(theme: Theme): number { for (let i = 0; i < theme.spacing.length; i++) { hash = mixHash(hash, (Math.trunc(theme.spacing[i] ?? 0) & HASH_MASK_32) >>> 0); } + hash = mixHash(hash, theme.focusIndicator.bold ? 1 : 2); + hash = mixHash(hash, theme.focusIndicator.underline ? 1 : 2); + const hasFocusRingColor = theme.focusIndicator.focusRingColor !== undefined; + hash = mixHash(hash, hasFocusRingColor ? 1 : 2); + if (hasFocusRingColor) { + hash = mixHash( + hash, + (Math.trunc(theme.focusIndicator.focusRingColor ?? 0) & HASH_MASK_32) >>> 0, + ); + } const out = hash === 0 ? 1 : hash >>> 0; themeHashCache.set(theme, out); return out; diff --git a/packages/core/src/renderer/renderToDrawlist/themeTokens.ts b/packages/core/src/renderer/renderToDrawlist/themeTokens.ts index 3c876e4c..1f5e5cc4 100644 --- a/packages/core/src/renderer/renderToDrawlist/themeTokens.ts +++ b/packages/core/src/renderer/renderToDrawlist/themeTokens.ts @@ -33,17 +33,15 @@ export function readWidgetSize(value: unknown): WidgetSize | undefined { } export function resolveWidgetFocusStyle( - colorTokens: ColorTokens | null, + colorTokens: ColorTokens, focused: boolean, disabled: boolean, + focusIndicator: Readonly<{ bold: boolean; underline: boolean; focusRingColor?: number }>, ): TextStyle | undefined { if (!focused || disabled) return undefined; - if (colorTokens !== null) { - return { - underline: true, - bold: true, - fg: colorTokens.focus.ring, - }; - } - return { underline: true, bold: true }; + return { + ...(focusIndicator.underline ? { underline: true } : {}), + ...(focusIndicator.bold ? { bold: true } : {}), + fg: focusIndicator.focusRingColor ?? colorTokens.focus.ring, + }; } diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index c5787984..f5289a0c 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -341,7 +341,12 @@ export function renderCollectionWidget( itemParentStyle = mergeTextStyle(itemParentStyle, selectionStyle); } if (focused) { - const widgetFocusStyle = resolveWidgetFocusStyle(colorTokens, true, false); + const widgetFocusStyle = resolveWidgetFocusStyle( + colorTokens, + true, + false, + theme.focusIndicator, + ); if (widgetFocusStyle) { itemParentStyle = mergeTextStyle(itemParentStyle, widgetFocusStyle); } @@ -666,7 +671,7 @@ export function renderCollectionWidget( colorTokens !== null ? mergeTextStyle(parentStyle, focusedRecipe?.cell) : mergeTextStyle(parentStyle, { inverse: true }), - resolveWidgetFocusStyle(colorTokens, true, false), + resolveWidgetFocusStyle(colorTokens, true, false, theme.focusIndicator), ); focusedRowStyle = resolveFocusedContentStyle( resolveFocusIndicatorStyle(parentStyle, theme, focusConfig, focusFallback), diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts index 27e82d6b..4599c8b6 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/containers.ts @@ -576,6 +576,25 @@ export function renderContainerWidget( const forceChildrenRender = forceSubtreeRender || node.selfDirty; switch (vnode.kind) { + case "fragment": { + const childClip = currentClip; + pushChildrenWithLayout( + node, + layoutNode, + parentStyle, + nodeStack, + styleStack, + layoutStack, + clipStack, + childClip, + damageRect, + skipCleanSubtrees, + forceChildrenRender, + undefined, + clipOnFirstQueuedChild(builder, nodeStack, currentClip, childClip), + ); + break; + } case "row": case "column": case "grid": { diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts b/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts index 95968a58..f63f5dd2 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/editors.ts @@ -52,14 +52,16 @@ function rectIntersects(a: Rect, b: Rect): boolean { function logLevelToThemeColor(theme: Theme, level: LogsConsoleProps["entries"][number]["level"]) { switch (level) { + case "debug": + return theme.colors["widget.logs.debug"] ?? theme.colors.info; case "warn": - return theme.colors.warning; + return theme.colors["widget.logs.warn"] ?? theme.colors.warning; case "error": - return theme.colors.danger; + return theme.colors["widget.logs.error"] ?? theme.colors.danger; case "info": - return theme.colors.fg; + return theme.colors["widget.logs.info"] ?? theme.colors.fg; default: - return theme.colors.muted; + return theme.colors["widget.logs.trace"] ?? theme.colors.muted; } } @@ -69,6 +71,10 @@ function resolveSyntaxThemeColor(theme: Theme, key: string, fallback: Rgb24) { return theme.colors[key] ?? fallback; } +function resolveDiffThemeColor(theme: Theme, key: string, fallback: Rgb24): Rgb24 { + return theme.colors[key] ?? fallback; +} + function createCodeEditorSyntaxStyleMap( parentStyle: ResolvedTextStyle, theme: Theme, @@ -76,35 +82,35 @@ function createCodeEditorSyntaxStyleMap( return Object.freeze({ plain: parentStyle, keyword: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.keyword", theme.colors.info), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.keyword", theme.colors.info), bold: true, }), type: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.type", theme.colors.warning), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.type", theme.colors.warning), bold: true, }), string: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.string", theme.colors.success), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.string", theme.colors.success), }), number: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.number", theme.colors.warning), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.number", theme.colors.warning), }), comment: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.comment", theme.colors.muted), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.comment", theme.colors.muted), italic: true, }), operator: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.operator", theme.colors.primary), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.operator", theme.colors.primary), }), punctuation: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.punctuation", theme.colors.fg), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.punctuation", theme.colors.fg), }), function: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.function", theme.colors.primary), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.function", theme.colors.primary), bold: true, }), variable: mergeTextStyle(parentStyle, { - fg: resolveSyntaxThemeColor(theme, "syntax.variable", theme.colors.secondary), + fg: resolveSyntaxThemeColor(theme, "widget.syntax.variable", theme.colors.secondary), bold: true, }), }); @@ -332,18 +338,12 @@ export function renderEditorWidget( if (localY >= 0 && localY < rect.h && localX >= 0 && localX < textW) { const cursorLine = lines[cursor.line] ?? ""; const cursorGlyph = cursorLine.slice(cursor.column, cursor.column + 1) || " "; - const widgetFocusStyle = resolveWidgetFocusStyle(colorTokens, true, false); const cursorCellStyle = resolveFocusedContentStyle( - resolveFocusIndicatorStyle( - parentStyle, - theme, - focusConfig, - mergeTextStyle(mergeTextStyle(parentStyle, widgetFocusStyle), { - fg: resolveSyntaxThemeColor(theme, "syntax.cursor.fg", theme.colors.bg), - bg: resolveSyntaxThemeColor(theme, "syntax.cursor.bg", theme.colors.primary), - bold: true, - }), - ), + mergeTextStyle(parentStyle, { + fg: resolveSyntaxThemeColor(theme, "widget.syntax.cursorFg", theme.colors.bg), + bg: resolveSyntaxThemeColor(theme, "widget.syntax.cursorBg", theme.colors.primary), + bold: true, + }), theme, focusConfig, ); @@ -384,14 +384,22 @@ export function renderEditorWidget( const focusedHunkStyle = asTextStyle(props.focusedHunkStyle, theme); const { diff } = props; const diffCache = diffRenderCacheById?.get(props.id); - const addBg = theme.colors.success; - const deleteBg = theme.colors.danger; - const addFg = theme.colors.bg; - const deleteFg = theme.colors.bg; - const hunkHeaderFg = theme.colors.info; - const lineNumberFg = theme.colors.muted; - const borderFg = theme.colors.border; - const collapsedStyle = mergeTextStyle(parentStyle, { fg: theme.colors.muted }); + const addBg = resolveDiffThemeColor(theme, "widget.diff.addBg", theme.colors.success); + const deleteBg = resolveDiffThemeColor(theme, "widget.diff.deleteBg", theme.colors.danger); + const addFg = resolveDiffThemeColor(theme, "widget.diff.addFg", theme.colors.bg); + const deleteFg = resolveDiffThemeColor(theme, "widget.diff.deleteFg", theme.colors.bg); + const hunkHeaderFg = resolveDiffThemeColor( + theme, + "widget.diff.hunkHeader", + theme.colors.info, + ); + const lineNumberFg = resolveDiffThemeColor( + theme, + "widget.diff.lineNumber", + theme.colors.muted, + ); + const borderFg = resolveDiffThemeColor(theme, "widget.diff.border", theme.colors.border); + const collapsedStyle = mergeTextStyle(parentStyle, { fg: lineNumberFg }); if (diff.isBinary === true) { builder.drawText( @@ -790,7 +798,10 @@ export function renderEditorWidget( parentStyle, theme, focusConfig, - mergeTextStyle(parentStyle, resolveWidgetFocusStyle(colorTokens, true, false)), + mergeTextStyle( + parentStyle, + resolveWidgetFocusStyle(colorTokens, true, false, theme.focusIndicator), + ), ); if (focusedStyleOverride) { ringStyle = mergeTextStyle(ringStyle, focusedStyleOverride); diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/focusConfig.ts b/packages/core/src/renderer/renderToDrawlist/widgets/focusConfig.ts index 6b0b4d5c..93502873 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/focusConfig.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/focusConfig.ts @@ -58,13 +58,14 @@ export function resolveFocusIndicatorStyle( out = mergeTextStyle(out, override); } else { const colorTokens = getColorTokens(theme); - if (colorTokens) { - out = mergeTextStyle(out, { - fg: colorTokens.focus.ring, - underline: true, - bold: true, - }); - } + const hadFallbackFg = fallback !== undefined && fallback.fg !== baseStyle.fg; + out = mergeTextStyle(out, { + ...(hadFallbackFg + ? {} + : { fg: theme.focusIndicator.focusRingColor ?? colorTokens.focus.ring }), + ...(theme.focusIndicator.underline ? { underline: true } : {}), + ...(theme.focusIndicator.bold ? { bold: true } : {}), + }); } return out; } @@ -76,6 +77,10 @@ export function resolveFocusedContentStyle( fallback: ResolvedTextStyle | undefined = undefined, ): ResolvedTextStyle { let out = fallback ? mergeTextStyle(baseStyle, fallback) : baseStyle; + out = mergeTextStyle(out, { + ...(theme.focusIndicator.underline ? { underline: true } : {}), + ...(theme.focusIndicator.bold ? { bold: true } : {}), + }); const override = asTextStyle((config as FocusConfigShape | undefined)?.contentStyle, theme); if (override) out = mergeTextStyle(out, override); return out; @@ -91,9 +96,10 @@ export function resolveFocusIndicatorDecoration( let style = baseStyle; const colorTokens = getColorTokens(theme); - if (colorTokens) { - style = mergeTextStyle(style, { fg: colorTokens.focus.ring, bold: true }); - } + style = mergeTextStyle(style, { + fg: theme.focusIndicator.focusRingColor ?? colorTokens.focus.ring, + ...(theme.focusIndicator.bold ? { bold: true } : {}), + }); const override = asTextStyle((config as FocusConfigShape | undefined)?.style, theme); if (override) { style = mergeTextStyle(style, override); diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts b/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts index e421e96b..4eab127e 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/overlays.ts @@ -182,13 +182,13 @@ function riskLevelToThemeColor(theme: Theme, riskLevel: "low" | "medium" | "high function toastTypeToThemeColor(theme: Theme, type: "info" | "success" | "warning" | "error") { switch (type) { case "success": - return theme.colors.success; + return theme.colors["widget.toast.success"] ?? theme.colors.success; case "warning": - return theme.colors.warning; + return theme.colors["widget.toast.warning"] ?? theme.colors.warning; case "error": - return theme.colors.danger; + return theme.colors["widget.toast.error"] ?? theme.colors.danger; default: - return theme.colors.info; + return theme.colors["widget.toast.info"] ?? theme.colors.info; } } @@ -306,7 +306,21 @@ export function renderOverlayWidget( const cx = dropdownRect.x + 1; let cy = dropdownRect.y + 1; const cw = clampNonNegative(dropdownRect.w - 2); - const contentPx = Math.min(itemPx, Math.floor(cw / 2)); + let maxLabelW = 0; + let maxShortcutW = 0; + for (const item of items) { + if (!item || item.divider) continue; + const itemLabelW = measureTextCells(readString(item.label)); + if (itemLabelW > maxLabelW) maxLabelW = itemLabelW; + const itemShortcutW = measureTextCells(readString(item.shortcut)); + if (itemShortcutW > maxShortcutW) maxShortcutW = itemShortcutW; + } + const maxShortcutSlotW = maxShortcutW > 0 ? maxShortcutW + 1 : 0; + const widestItemW = maxLabelW + maxShortcutSlotW; + const contentPx = Math.min( + itemPx, + Math.max(0, Math.floor((Math.max(0, cw) - widestItemW) / 2)), + ); const contentX = cx + contentPx; const contentW = clampNonNegative(cw - contentPx * 2); diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts index c95360fa..79527731 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderChartWidgets.ts @@ -109,6 +109,14 @@ function readGraphicsBlitter(v: unknown): GraphicsBlitter | undefined { } } +function chartThemeColor( + theme: Theme, + key: "primary" | "accent" | "muted" | "success" | "warning" | "danger", + fallback: number, +): number { + return theme.colors[`widget.chart.${key}`] ?? fallback; +} + function sparklineForData( data: readonly number[], width: number, @@ -169,7 +177,9 @@ export function renderChartWidgets( .map((value) => readNumber(value) ?? 0) .filter((value) => Number.isFinite(value)) : ([] as number[]), - color: readString(entry.color) ?? rgbToHex(theme.colors.primary), + color: + readString(entry.color) ?? + rgbToHex(chartThemeColor(theme, "primary", theme.colors.primary)), label: readString(entry.label), })); const range = getLineChartRange( @@ -264,7 +274,9 @@ export function renderChartWidgets( }, ); const mapped = mapScatterPointsToPixels(normalized, surface.widthPx, surface.heightPx, range); - const fallbackColor = readString(props.color) ?? rgbToHex(theme.colors.primary); + const fallbackColor = + readString(props.color) ?? + rgbToHex(chartThemeColor(theme, "primary", theme.colors.primary)); for (const point of mapped) { surface.ctx.setPixel(point.x, point.y, point.color ?? fallbackColor); } @@ -353,7 +365,7 @@ export function renderChartWidgets( const ownStyle = asTextStyle(props.style, theme); const style = mergeTextStyle(parentStyle, ownStyle); maybeFillOwnBackground(builder, rect, ownStyle, style); - const sparkColor = ownStyle?.fg ?? theme.colors.info; + const sparkColor = ownStyle?.fg ?? chartThemeColor(theme, "accent", theme.colors.info); const width = Math.max(1, Math.min(rect.w, readPositiveInt(props.width) ?? data.length)); const autoMin = Math.min(...data); @@ -507,7 +519,9 @@ export function renderChartWidgets( fg: variantToThemeColor(theme, item.variant, "primary"), bold: true, }); - const trackStyle = mergeTextStyle(style, { fg: theme.colors.muted }); + const trackStyle = mergeTextStyle(style, { + fg: chartThemeColor(theme, "muted", theme.colors.muted), + }); const segments: StyledSegment[] = []; if (labelText.length > 0) segments.push({ text: labelText, style }); if (filled > 0) segments.push({ text: repeatCached("█", filled), style: barStyle }); @@ -537,7 +551,9 @@ export function renderChartWidgets( fg: variantToThemeColor(theme, item.variant, "primary"), bold: true, }); - const trackStyle = mergeTextStyle(style, { fg: theme.colors.muted }); + const trackStyle = mergeTextStyle(style, { + fg: chartThemeColor(theme, "muted", theme.colors.muted), + }); for (let y = 0; y < chartHeight; y++) { const cellY = rect.y + chartHeight - 1 - y; const filledCell = y < filled; @@ -573,7 +589,9 @@ export function renderChartWidgets( rect.x, y, truncateToWidth(valueLine, rect.w), - mergeTextStyle(style, { fg: theme.colors.muted }), + mergeTextStyle(style, { + fg: chartThemeColor(theme, "muted", theme.colors.muted), + }), ); } } @@ -600,8 +618,13 @@ export function renderChartWidgets( const fillGlyph = variant === "pills" ? "●" : "▓"; const emptyGlyph = variant === "pills" ? "○" : "░"; - const fillStyle = mergeTextStyle(style, { fg: theme.colors.primary, bold: true }); - const trackStyle = mergeTextStyle(style, { fg: theme.colors.muted }); + const fillStyle = mergeTextStyle(style, { + fg: chartThemeColor(theme, "primary", theme.colors.primary), + bold: true, + }); + const trackStyle = mergeTextStyle(style, { + fg: chartThemeColor(theme, "muted", theme.colors.muted), + }); const segments: StyledSegment[] = []; for (let i = 0; i < values.length; i++) { diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts index b26e4e01..d93a08a3 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts @@ -225,6 +225,7 @@ function resolveFocusFlags( id: string | null, focusConfigRaw: unknown, widgetKind: string, + focusEnabled = true, ): Readonly<{ focused: boolean; focusVisible: boolean; @@ -232,7 +233,7 @@ function resolveFocusFlags( }> { const focusConfig = readFocusConfig(focusConfigRaw) ?? getDefaultFocusConfig(widgetKind); const focused = id !== null && focusState.focusedId === id; - const focusVisible = focused && focusIndicatorEnabled(focusConfig); + const focusVisible = focused && focusEnabled && focusIndicatorEnabled(focusConfig); return { focused, focusVisible, focusConfig }; } @@ -278,7 +279,7 @@ export function renderFormWidgets( focused, focusVisible: effectiveFocused, focusConfig, - } = resolveFocusFlags(focusState, id, props.focusConfig, "button"); + } = resolveFocusFlags(focusState, id, props.focusConfig, "button", !disabled); const pressed = !disabled && id !== null && pressedId === id; // Design system recipe path @@ -467,6 +468,7 @@ export function renderFormWidgets( id, props.focusConfig, "input", + !disabled, ); const showPlaceholder = value.length === 0 && placeholder.length > 0; const colorTokens = getColorTokens(theme); @@ -530,10 +532,20 @@ export function renderFormWidgets( const recipePlaceholderStyle = recipeResult.bg.bg ? { ...recipeResult.placeholder, bg: recipeResult.bg.bg, dim: true } : { ...recipeResult.placeholder, dim: true }; + const compactFocusStyle = + focusVisible && !hasBorder + ? resolveWidgetFocusStyle(colorTokens, true, false, theme.focusIndicator) + : undefined; const ownStyle = asTextStyle(props.style, theme); - const baseStyle = mergeTextStyle(parentStyle, recipeTextStyle); - const basePlaceholderStyle = mergeTextStyle(parentStyle, recipePlaceholderStyle); + const baseStyle = mergeTextStyle( + mergeTextStyle(parentStyle, recipeTextStyle), + compactFocusStyle, + ); + const basePlaceholderStyle = mergeTextStyle( + mergeTextStyle(parentStyle, recipePlaceholderStyle), + compactFocusStyle, + ); style = ownStyle ? mergeTextStyle(baseStyle, ownStyle) : baseStyle; placeholderStyle = ownStyle ? mergeTextStyle(basePlaceholderStyle, ownStyle) @@ -628,13 +640,14 @@ export function renderFormWidgets( style?: unknown; }; const id = readString(props.id); + const disabled = props.disabled === true; const { focused, focusVisible, focusConfig } = resolveFocusFlags( focusState, id ?? null, props.focusConfig, "slider", + !disabled, ); - const disabled = props.disabled === true; const readOnly = props.readOnly === true; const label = readString(props.label) ?? ""; const showValue = props.showValue !== false; @@ -661,7 +674,12 @@ export function renderFormWidgets( const style = mergeTextStyle(parentStyle, ownStyle); maybeFillOwnBackground(builder, rect, ownStyle, style); - const focusStyle = resolveWidgetFocusStyle(colorTokens, focusVisible, disabled); + const focusStyle = resolveWidgetFocusStyle( + colorTokens, + focusVisible, + disabled, + theme.focusIndicator, + ); let stateStyle: TextStyle; if (disabled) { stateStyle = { fg: theme.colors.muted }; @@ -805,13 +823,14 @@ export function renderFormWidgets( dsSize?: unknown; }; const id = typeof props.id === "string" ? props.id : null; + const disabled = props.disabled === true; const { focusVisible, focusConfig } = resolveFocusFlags( focusState, id, props.focusConfig, "select", + !disabled, ); - const disabled = props.disabled === true; const value = typeof props.value === "string" ? props.value : ""; const placeholder = typeof props.placeholder === "string" ? props.placeholder : "Select…"; @@ -929,7 +948,7 @@ export function renderFormWidgets( parentStyle, disabled ? { fg: theme.colors.muted } - : resolveWidgetFocusStyle(colorTokens, focusVisible, false), + : resolveWidgetFocusStyle(colorTokens, focusVisible, false, theme.focusIndicator), ); const style = focusVisible ? resolveFocusedContentStyle(baseStyle, theme, focusConfig) @@ -964,13 +983,14 @@ export function renderFormWidgets( dsSize?: unknown; }; const id = typeof props.id === "string" ? props.id : null; + const disabled = props.disabled === true; const { focusVisible, focusConfig } = resolveFocusFlags( focusState, id, props.focusConfig, "checkbox", + !disabled, ); - const disabled = props.disabled === true; const checked = props.checked === true; const label = typeof props.label === "string" ? props.label : ""; const indicator = checked ? "[x]" : "[ ]"; @@ -1022,7 +1042,7 @@ export function renderFormWidgets( parentStyle, disabled ? { fg: theme.colors.muted } - : resolveWidgetFocusStyle(colorTokens, focusVisible, false), + : resolveWidgetFocusStyle(colorTokens, focusVisible, false, theme.focusIndicator), ); const style = focusVisible ? resolveFocusedContentStyle(baseStyle, theme, focusConfig) @@ -1053,13 +1073,14 @@ export function renderFormWidgets( dsSize?: unknown; }; const id = typeof props.id === "string" ? props.id : null; + const disabled = props.disabled === true; const { focusVisible, focusConfig } = resolveFocusFlags( focusState, id, props.focusConfig, "radioGroup", + !disabled, ); - const disabled = props.disabled === true; const value = typeof props.value === "string" ? props.value : ""; const direction = props.direction === "horizontal" ? "horizontal" : "vertical"; const options = Array.isArray(props.options) ? props.options : []; diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts b/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts index 198840c9..a06552bd 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/renderTextWidgets.ts @@ -1052,7 +1052,7 @@ export function renderTextWidgets( const focused = !disabled && id !== undefined && focusState.focusedId === id; const finalStyle = mergeTextStyle( styledLink, - resolveWidgetFocusStyle(getColorTokens(theme), focused, disabled), + resolveWidgetFocusStyle(getColorTokens(theme), focused, disabled, theme.focusIndicator), ); builder.pushClip(rect.x, rect.y, rect.w, rect.h); diff --git a/packages/core/src/repro/replay.ts b/packages/core/src/repro/replay.ts index 14af9684..14e31826 100644 --- a/packages/core/src/repro/replay.ts +++ b/packages/core/src/repro/replay.ts @@ -738,7 +738,7 @@ export async function runReproReplayHarness( ? { maxDrawlistBytes: opts.bundle.captureConfig.maxDrawlistBytes } : {}), }, - ...(opts.theme !== undefined ? { theme: opts.theme } : {}), + ...(opts.theme !== undefined ? { theme: opts.theme.definition } : {}), }); app.view(() => opts.view()); diff --git a/packages/core/src/runtime/__tests__/focus.layers.test.ts b/packages/core/src/runtime/__tests__/focus.layers.test.ts index 641a3d5a..42f4fe07 100644 --- a/packages/core/src/runtime/__tests__/focus.layers.test.ts +++ b/packages/core/src/runtime/__tests__/focus.layers.test.ts @@ -42,6 +42,7 @@ function trap(args: { }): CollectedTrap { return { id: args.id, + kind: "modal", active: args.active, focusableIds: args.focusableIds, initialFocus: args.initialFocus ?? null, diff --git a/packages/core/src/runtime/__tests__/focus.traps.test.ts b/packages/core/src/runtime/__tests__/focus.traps.test.ts index cb55c8cf..ef97a85c 100644 --- a/packages/core/src/runtime/__tests__/focus.traps.test.ts +++ b/packages/core/src/runtime/__tests__/focus.traps.test.ts @@ -47,6 +47,7 @@ function trap(args: { }): CollectedTrap { return { id: args.id, + kind: "focusTrap", active: args.active, returnFocusTo: args.returnFocusTo ?? null, initialFocus: args.initialFocus ?? null, @@ -159,7 +160,7 @@ describe("focus traps - finalizeFocusWithPreCollectedMetadata", () => { assert.equal(next.focusedId, "first"); }); - test("empty trap focusables clear focus so background widgets are not left active", () => { + test("empty focusTrap focusables preserve the current focus", () => { const next = finalizeWith( managerState({ focusedId: "outside" }), ["outside"], @@ -168,10 +169,10 @@ describe("focus traps - finalizeFocusWithPreCollectedMetadata", () => { ]), ); - assert.equal(next.focusedId, null); + assert.equal(next.focusedId, "outside"); }); - test("empty trap focusables ignore initialFocus outside the trap scope", () => { + test("empty focusTrap focusables can honor a valid nested initialFocus", () => { const next = finalizeWith( managerState({ focusedId: "outside" }), ["outside", "nested-id"], @@ -188,7 +189,7 @@ describe("focus traps - finalizeFocusWithPreCollectedMetadata", () => { ]), ); - assert.equal(next.focusedId, null); + assert.equal(next.focusedId, "nested-id"); }); test("deactivation restores returnFocusTo when valid", () => { @@ -456,7 +457,7 @@ describe("focus traps - finalizeFocusForCommittedTreeWithZones integration", () assert.deepEqual(next.trapStack, []); }); - test("active empty trap clears focus when no trap focusables exist", () => { + test("active empty focusTrap preserves focus when no trap focusables exist", () => { const tree: VNode = { kind: "column", props: {}, @@ -472,7 +473,7 @@ describe("focus traps - finalizeFocusForCommittedTreeWithZones integration", () const prev = managerState({ focusedId: "outside" }); const next = finalizeFocusForCommittedTreeWithZones(prev, commitTree(tree)); - assert.equal(next.focusedId, null); + assert.equal(next.focusedId, "outside"); assert.deepEqual(next.trapStack, ["modal"]); }); diff --git a/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts b/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts index 455e4556..a783d1c7 100644 --- a/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts +++ b/packages/core/src/runtime/__tests__/hooks.useTheme.test.ts @@ -1,9 +1,10 @@ import { assert, describe, test } from "@rezi-ui/testkit"; +import { defaultTheme } from "../../theme/defaultTheme.js"; import { extendTheme } from "../../theme/extend.js"; import { getColorTokens } from "../../theme/extract.js"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; +import { mergeThemeOverride } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; -import type { Theme } from "../../theme/theme.js"; +import { type Theme, compileTheme } from "../../theme/theme.js"; import type { ColorTokens } from "../../theme/tokens.js"; import { defineWidget } from "../../widgets/composition.js"; import type { VNode } from "../../widgets/types.js"; @@ -13,9 +14,9 @@ import { createInstanceIdAllocator } from "../instance.js"; import { createCompositeInstanceRegistry } from "../instances.js"; type CompositeCommitOptions = Readonly<{ - colorTokens?: ColorTokens | null; + colorTokens?: ColorTokens; theme?: Theme; - getColorTokens?: (theme: Theme) => ColorTokens | null; + getColorTokens?: (theme: Theme) => ColorTokens; }>; type CompositeHarness = Readonly<{ @@ -34,7 +35,7 @@ function createCompositeHarness(): CompositeHarness { composite: { registry, appState, - colorTokens: options.colorTokens ?? null, + colorTokens: options.colorTokens ?? defaultTheme.definition.colors, ...(options.theme ? { theme: options.theme } : {}), ...(options.getColorTokens ? { getColorTokens: options.getColorTokens } : {}), viewport: { width: 80, height: 24, breakpoint: "md" }, @@ -52,17 +53,29 @@ function createCompositeHarness(): CompositeHarness { }); } -function requireColorTokens(tokens: ColorTokens | null): ColorTokens { - if (!tokens) { - throw new Error("expected semantic color tokens"); - } - return tokens; +function commitCompositeOnce( + vnode: VNode, + appState: State, + options: CompositeCommitOptions = {}, +) { + return commitVNodeTree(null, vnode, { + allocator: createInstanceIdAllocator(1), + composite: { + registry: createCompositeInstanceRegistry(), + appState, + colorTokens: options.colorTokens ?? defaultTheme.definition.colors, + ...(options.theme ? { theme: options.theme } : {}), + ...(options.getColorTokens ? { getColorTokens: options.getColorTokens } : {}), + viewport: { width: 80, height: 24, breakpoint: "md" }, + onInvalidate: () => {}, + }, + }); } describe("runtime hooks - useTheme", () => { test("provides composite color tokens from commit context", () => { - const tokens = requireColorTokens(getColorTokens(coerceToLegacyTheme(darkTheme))); - let seenTokens: ColorTokens | null | undefined; + const tokens = getColorTokens(compileTheme(darkTheme)); + let seenTokens: ColorTokens | undefined; const Widget = defineWidget<{ key?: string }, Record>((_props, ctx) => { seenTokens = ctx.useTheme(); @@ -75,8 +88,8 @@ describe("runtime hooks - useTheme", () => { assert.equal(seenTokens, tokens); }); - test("returns null when semantic color tokens are unavailable", () => { - let seenTokens: ColorTokens | null | undefined = undefined; + test("falls back to default theme tokens when composite context does not provide one", () => { + let seenTokens: ColorTokens | undefined; const Widget = defineWidget<{ key?: string }, Record>((_props, ctx) => { seenTokens = ctx.useTheme(); @@ -86,26 +99,24 @@ describe("runtime hooks - useTheme", () => { const h = createCompositeHarness>(); h.commit(Widget({}), Object.freeze({})); - assert.equal(seenTokens, null); + assert.deepEqual(seenTokens, defaultTheme.definition.colors); }); test("reads latest tokens on rerender", () => { - const firstTokens = requireColorTokens(getColorTokens(coerceToLegacyTheme(darkTheme))); - const secondTokens = requireColorTokens( - getColorTokens( - coerceToLegacyTheme( - extendTheme(darkTheme, { - colors: { - accent: { - primary: (250 << 16) | (20 << 8) | 20, - }, + const firstTokens = getColorTokens(compileTheme(darkTheme)); + const secondTokens = getColorTokens( + compileTheme( + extendTheme(darkTheme, { + colors: { + accent: { + primary: (250 << 16) | (20 << 8) | 20, }, - }), - ), + }, + }), ), ); - const seen: Array = []; + const seen: ColorTokens[] = []; const Widget = defineWidget<{ key?: string }, Readonly<{ count: number }>>((_props, ctx) => { ctx.useAppState((state) => state.count); @@ -123,13 +134,13 @@ describe("runtime hooks - useTheme", () => { }); test("resolves scoped themed overrides for composites", () => { - const baseTheme = coerceToLegacyTheme(darkTheme); + const baseTheme = compileTheme(darkTheme); const override = Object.freeze({ colors: { accent: { primary: (18 << 16) | (164 << 8) | 245 } }, + focusIndicator: { bold: false }, }); - const scopedTheme = coerceToLegacyTheme(extendTheme(darkTheme, override)); - const expected = requireColorTokens(getColorTokens(scopedTheme)); - let seenTokens: ColorTokens | null | undefined; + const scopedTheme = mergeThemeOverride(baseTheme, override); + let seenTokens: ColorTokens | undefined; const Widget = defineWidget<{ key?: string }, Record>((_props, ctx) => { seenTokens = ctx.useTheme(); @@ -138,11 +149,48 @@ describe("runtime hooks - useTheme", () => { const h = createCompositeHarness>(); h.commit(ui.themed(override, [Widget({})]), Object.freeze({}), { - colorTokens: requireColorTokens(getColorTokens(baseTheme)), + colorTokens: getColorTokens(baseTheme), theme: baseTheme, getColorTokens, }); - assert.deepEqual(seenTokens, expected); + assert.deepEqual(seenTokens, scopedTheme.definition.colors); + }); + + test("falls back to theme colors when getColorTokens returns undefined", () => { + const baseTheme = compileTheme(darkTheme); + let seenTokens: ColorTokens | undefined; + + const Widget = defineWidget<{ key?: string }, Record>((_props, ctx) => { + seenTokens = ctx.useTheme(); + return ui.text("ok"); + }); + + const res = commitCompositeOnce(Widget({}), Object.freeze({}), { + theme: baseTheme, + getColorTokens: () => undefined as unknown as ColorTokens, + }); + + assert.equal(res.ok, true); + assert.deepEqual(seenTokens, baseTheme.definition.colors); + }); + + test("shapes getColorTokens throws as ZRUI_USER_CODE_THROW", () => { + const Widget = defineWidget<{ key?: string }, Record>((_props, ctx) => { + ctx.useTheme(); + return ui.text("ok"); + }); + + const res = commitCompositeOnce(Widget({}), Object.freeze({}), { + theme: compileTheme(darkTheme), + getColorTokens: () => { + throw new Error("boom"); + }, + }); + + assert.equal(res.ok, false); + if (res.ok) return; + assert.equal(res.fatal.code, "ZRUI_USER_CODE_THROW"); + assert.match(res.fatal.detail, /boom/); }); }); diff --git a/packages/core/src/runtime/commit.ts b/packages/core/src/runtime/commit.ts index 140f57b8..5002f822 100644 --- a/packages/core/src/runtime/commit.ts +++ b/packages/core/src/runtime/commit.ts @@ -541,13 +541,20 @@ function themedPropsEqual(a: unknown, b: unknown): boolean { return deepEqualUnknown(ao.theme, bo.theme); } +function fragmentPropsEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const ao = (a ?? {}) as { key?: unknown }; + const bo = (b ?? {}) as typeof ao; + return ao.key === bo.key; +} + function canFastReuseContainerSelf(prev: VNode, next: VNode): boolean { if (prev.kind !== next.kind) return false; switch (prev.kind) { + case "fragment": + return fragmentPropsEqual(prev.props, (next as typeof prev).props); case "box": return boxPropsEqual(prev.props, (next as typeof prev).props); - case "fragment": - return true; case "row": case "column": return stackPropsEqual(prev.props, (next as typeof prev).props); @@ -571,10 +578,14 @@ function diagWhichPropFails(prev: VNode, next: VNode): string | undefined { type ReuseDiagProps = { style?: unknown; inheritStyle?: unknown; + key?: unknown; [key: string]: unknown; }; const ap = (prev.props ?? {}) as ReuseDiagProps; const bp = (next.props ?? {}) as ReuseDiagProps; + if (prev.kind === "fragment" && ap.key !== bp.key) { + return "key"; + } if (prev.kind === "row" || prev.kind === "column") { for (const k of ["pad", "gap", "align", "justify", "items"] as const) { if (ap[k] !== bp[k]) return k; @@ -850,8 +861,8 @@ function isVNode(v: unknown): v is VNode { function commitChildrenForVNode(vnode: VNode): readonly VNode[] { if ( - vnode.kind === "box" || vnode.kind === "fragment" || + vnode.kind === "box" || vnode.kind === "row" || vnode.kind === "column" || vnode.kind === "themed" || @@ -1035,16 +1046,17 @@ function resolveCompositeChildTheme(parentTheme: Theme, vnode: VNode): Theme { return parentTheme; } -function readCompositeColorTokens(ctx: CommitCtx): ColorTokens | null { +function readCompositeColorTokens(ctx: CommitCtx): ColorTokens { const composite = ctx.composite; - if (!composite) return null; + if (!composite) return defaultTheme.definition.colors; const theme = currentCompositeTheme(ctx); - if (theme !== null && composite.getColorTokens) { - return composite.getColorTokens(theme); + if (theme !== null) { + if (!composite.getColorTokens) return theme.definition.colors; + return composite.getColorTokens(theme) ?? theme.definition.colors; } - return composite.colorTokens ?? null; + return composite.colorTokens ?? defaultTheme.definition.colors; } type CommitCtx = Readonly<{ @@ -1062,9 +1074,9 @@ type CommitCtx = Readonly<{ composite: Readonly<{ registry: CompositeInstanceRegistry; appState: unknown; - colorTokens?: ColorTokens | null; + colorTokens?: ColorTokens; theme?: Theme; - getColorTokens?: (theme: Theme) => ColorTokens | null; + getColorTokens?: (theme: Theme) => ColorTokens; viewport?: ResponsiveViewportSnapshot; onInvalidate: (instanceId: InstanceId) => void; onUseViewport?: () => void; @@ -1177,8 +1189,8 @@ function formatNodePath(nodePath: readonly string[]): string { function isContainerVNode(vnode: VNode): boolean { return ( - vnode.kind === "box" || vnode.kind === "fragment" || + vnode.kind === "box" || vnode.kind === "row" || vnode.kind === "column" || vnode.kind === "themed" || @@ -1231,8 +1243,8 @@ function rewriteCommittedVNode(next: VNode, committedChildren: readonly VNode[]) } if ( - next.kind === "box" || next.kind === "fragment" || + next.kind === "box" || next.kind === "row" || next.kind === "column" || next.kind === "themed" || @@ -1695,7 +1707,18 @@ function executeCompositeRender( if (canSkipCompositeRender && prevChild !== null) { compositeChild = prevChild.vnode; } else { - const colorTokens = readCompositeColorTokens(ctx); + let colorTokens: ColorTokens; + try { + colorTokens = readCompositeColorTokens(ctx); + } catch (e: unknown) { + return { + ok: false, + fatal: { + code: "ZRUI_USER_CODE_THROW", + detail: describeThrown(e), + }, + }; + } const compositeDepth = ctx.compositeRenderStack.length + 1; if (compositeDepth > MAX_COMPOSITE_RENDER_DEPTH) { const chain = ctx.compositeRenderStack @@ -2047,9 +2070,9 @@ export function commitVNodeTree( composite?: Readonly<{ registry: CompositeInstanceRegistry; appState: unknown; - colorTokens?: ColorTokens | null; + colorTokens?: ColorTokens; theme?: Theme; - getColorTokens?: (theme: Theme) => ColorTokens | null; + getColorTokens?: (theme: Theme) => ColorTokens; viewport?: ResponsiveViewportSnapshot; onInvalidate: (instanceId: InstanceId) => void; onUseViewport?: () => void; diff --git a/packages/core/src/runtime/focus.ts b/packages/core/src/runtime/focus.ts index 4ca63079..5ce8ead8 100644 --- a/packages/core/src/runtime/focus.ts +++ b/packages/core/src/runtime/focus.ts @@ -570,12 +570,16 @@ export function finalizeFocusWithPreCollectedMetadata( const activeTrap = collectedTraps.get(activeTrapId); const activeTrapFocusables = activeTrap?.focusableIds ?? []; const activeTrapFocusableSet = trapFocusableSets.get(activeTrapId); + const initialFocus = activeTrap?.initialFocus ?? null; if (activeTrap?.active === true) { if (activeTrapFocusables.length === 0) { - nextFocusedId = null; + if (initialFocus !== null && focusSet.has(initialFocus)) { + nextFocusedId = initialFocus; + } else if (activeTrap.kind === "modal") { + nextFocusedId = null; + } } else if (nextFocusedId === null || activeTrapFocusableSet?.has(nextFocusedId) !== true) { - const initialFocus = activeTrap.initialFocus; if ( initialFocus !== null && focusSet.has(initialFocus) && diff --git a/packages/core/src/runtime/router/types.ts b/packages/core/src/runtime/router/types.ts index a0766df3..0bc3e674 100644 --- a/packages/core/src/runtime/router/types.ts +++ b/packages/core/src/runtime/router/types.ts @@ -125,10 +125,10 @@ export type LayerRoutingCtx = Readonly<{ export type LayerRoutingResult = Readonly<{ /** Layer that was closed, if any. */ closedLayerId?: string; - /** Whether the event was consumed. */ - consumed: boolean; /** Error thrown by the close callback, if any. */ callbackError?: unknown; + /** Whether the event was consumed. */ + consumed: boolean; }>; /** Dropdown routing context. */ diff --git a/packages/core/src/runtime/widgetMeta.ts b/packages/core/src/runtime/widgetMeta.ts index d266a381..232c90a0 100644 --- a/packages/core/src/runtime/widgetMeta.ts +++ b/packages/core/src/runtime/widgetMeta.ts @@ -495,6 +495,7 @@ export type CollectedZone = Readonly<{ /** Collected focus trap metadata. */ export type CollectedTrap = Readonly<{ id: string; + kind?: "focusTrap" | "modal"; active: boolean; returnFocusTo: string | null; initialFocus: string | null; @@ -698,6 +699,7 @@ export function collectFocusTraps(tree: RuntimeInstance): ReadonlyMap { describe("captureSnapshot", () => { it("captures a simple text widget", () => { - const theme = coerceToLegacyTheme(darkTheme); + const theme = compileTheme(darkTheme); const snapshot = captureSnapshot( "simple-text", ui.text("Hello"), @@ -126,7 +126,7 @@ describe("captureSnapshot", () => { }); it("produces deterministic output for same input", () => { - const theme = coerceToLegacyTheme(darkTheme); + const theme = compileTheme(darkTheme); const vnode = ui.column({ gap: 1 }, [ ui.text("Title", { style: { bold: true } }), ui.text("Body text"), diff --git a/packages/core/src/testing/renderer.ts b/packages/core/src/testing/renderer.ts index 2460779c..9f586cf9 100644 --- a/packages/core/src/testing/renderer.ts +++ b/packages/core/src/testing/renderer.ts @@ -18,7 +18,8 @@ import { renderToDrawlist } from "../renderer/renderToDrawlist.js"; import { type RuntimeInstance, commitVNodeTree } from "../runtime/commit.js"; import { type InstanceIdAllocator, createInstanceIdAllocator } from "../runtime/instance.js"; import { defaultTheme as coreDefaultTheme } from "../theme/defaultTheme.js"; -import type { Theme } from "../theme/theme.js"; +import { type Theme, compileTheme } from "../theme/theme.js"; +import type { ThemeDefinition } from "../theme/tokens.js"; import type { TextStyle } from "../widgets/style.js"; import type { VNode } from "../widgets/types.js"; @@ -28,7 +29,7 @@ export type TestRendererMode = "test" | "runtime"; export type TestRendererOptions = Readonly<{ viewport?: TestViewport; - theme?: Theme; + theme?: Theme | ThemeDefinition; focusedId?: string | null; tick?: number; mode?: TestRendererMode; @@ -38,7 +39,7 @@ export type TestRendererOptions = Readonly<{ export type TestRenderOptions = Readonly<{ viewport?: TestViewport; - theme?: Theme; + theme?: Theme | ThemeDefinition; focusedId?: string | null; tick?: number; mode?: TestRendererMode; @@ -121,6 +122,11 @@ function normalizeViewport(viewport: TestViewport | undefined): TestViewport { return Object.freeze({ cols: safeCols, rows: safeRows }); } +function normalizeTheme(theme: Theme | ThemeDefinition | undefined): Theme { + if (theme === undefined) return coreDefaultTheme; + return "definition" in theme ? theme : compileTheme(theme); +} + const EMPTY_PROPS: TestNodeProps = Object.freeze({}); const EMPTY_PATH: readonly number[] = Object.freeze([]); @@ -488,7 +494,7 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer let prevRoot: RuntimeInstance | null = null; let allocator: InstanceIdAllocator = createInstanceIdAllocator(1); const defaultViewport = normalizeViewport(opts.viewport); - const rendererTheme = opts.theme ?? coreDefaultTheme; + const rendererTheme = normalizeTheme(opts.theme); const defaultFocusedId = opts.focusedId ?? null; const defaultTick = opts.tick ?? 0; const defaultMode = opts.mode ?? "test"; @@ -504,7 +510,7 @@ export function createTestRenderer(opts: TestRendererOptions = {}): TestRenderer const viewport = normalizeViewport(renderOpts.viewport ?? defaultViewport); const focusedId = renderOpts.focusedId === undefined ? defaultFocusedId : renderOpts.focusedId; const tick = renderOpts.tick ?? defaultTick; - const theme = renderOpts.theme ?? rendererTheme; + const theme = normalizeTheme(renderOpts.theme ?? rendererTheme); const traceDetail = renderOpts.traceDetail ?? defaultTraceDetail; const mode = renderOpts.mode ?? defaultMode; if (traceDetail && !trace && !warnedTraceDetailWithoutTrace) { diff --git a/packages/core/src/theme/__tests__/theme.interop.test.ts b/packages/core/src/theme/__tests__/theme.interop.test.ts index cecee3af..e33971ae 100644 --- a/packages/core/src/theme/__tests__/theme.interop.test.ts +++ b/packages/core/src/theme/__tests__/theme.interop.test.ts @@ -1,9 +1,9 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { defaultTheme } from "../defaultTheme.js"; import { extendTheme } from "../extend.js"; -import { coerceToLegacyTheme, mergeThemeOverride } from "../interop.js"; +import { mergeThemeOverride } from "../interop.js"; import { darkTheme } from "../presets.js"; -import { createTheme } from "../theme.js"; +import { compileTheme } from "../theme.js"; import type { ThemeDefinition } from "../tokens.js"; function cloneTheme(theme: ThemeDefinition): ThemeDefinition { @@ -12,12 +12,12 @@ function cloneTheme(theme: ThemeDefinition): ThemeDefinition { function withoutSpacing(theme: ThemeDefinition): ThemeDefinition { const clone = cloneTheme(theme) as { spacing?: unknown }; - clone.spacing = undefined; + clone.spacing = defaultTheme.definition.spacing; return clone as ThemeDefinition; } -describe("theme.interop spacing", () => { - test("coerceToLegacyTheme preserves ThemeDefinition spacing tokens", () => { +describe("theme overrides", () => { + test("compileTheme preserves semantic spacing tokens", () => { const semanticTheme = extendTheme(darkTheme, { spacing: { xs: 2, @@ -29,22 +29,13 @@ describe("theme.interop spacing", () => { }, }); - const legacyTheme = coerceToLegacyTheme(semanticTheme); - assert.deepEqual(legacyTheme.spacing, [0, 2, 3, 4, 5, 6, 7]); + const compiledTheme = compileTheme(semanticTheme); + assert.deepEqual(compiledTheme.spacing, [0, 2, 3, 4, 5, 6, 7]); }); - test("coerceToLegacyTheme falls back to default spacing when semantic spacing is absent", () => { - const semanticThemeWithoutSpacing = withoutSpacing(darkTheme); - const legacyTheme = coerceToLegacyTheme(semanticThemeWithoutSpacing); - - assert.equal(legacyTheme.spacing, defaultTheme.spacing); - }); - - test("mergeThemeOverride applies spacing from ThemeDefinition overrides", () => { - const parentTheme = createTheme({ - spacing: [0, 10, 20, 30, 40, 50, 60], - }); - const override = extendTheme(darkTheme, { + test("mergeThemeOverride applies spacing and focusIndicator overrides", () => { + const parentTheme = compileTheme(darkTheme); + const merged = mergeThemeOverride(parentTheme, { spacing: { xs: 9, sm: 8, @@ -53,60 +44,68 @@ describe("theme.interop spacing", () => { xl: 5, "2xl": 4, }, + focusIndicator: { + bold: false, + underline: true, + }, }); - const merged = mergeThemeOverride(parentTheme, override); assert.deepEqual(merged.spacing, [0, 9, 8, 7, 6, 5, 4]); - assert.notEqual(merged.spacing, parentTheme.spacing); + assert.equal(merged.focusIndicator.bold, false); + assert.equal(merged.focusIndicator.underline, true); }); - test("mergeThemeOverride preserves parent spacing when ThemeDefinition spacing is absent", () => { - const parentTheme = createTheme({ - spacing: [0, 11, 22, 33, 44, 55, 66], - }); - const override = withoutSpacing( - extendTheme(darkTheme, { - colors: { - accent: { - primary: (1 << 16) | (2 << 8) | 3, - }, + test("full ThemeDefinition overrides replace subtree theme", () => { + const override = extendTheme(darkTheme, { + colors: { + accent: { + primary: (1 << 16) | (2 << 8) | 3, }, - }), - ); + }, + }); - const merged = mergeThemeOverride(parentTheme, override); - assert.equal(merged.spacing, parentTheme.spacing); - assert.notEqual(merged, parentTheme); + const merged = mergeThemeOverride(compileTheme(darkTheme), override); + assert.equal(merged.definition, override); + assert.deepEqual(merged.colors["accent.primary"], (1 << 16) | (2 << 8) | 3); }); - test("coerceToLegacyTheme carries diagnostic semantic colors", () => { - const semanticTheme = extendTheme(darkTheme, { + test("mergeThemeOverride carries diagnostic colors through compiled aliases", () => { + const merged = mergeThemeOverride(compileTheme(darkTheme), { colors: { diagnostic: { - warning: (1 << 16) | (2 << 8) | 3, + warning: (7 << 16) | (8 << 8) | 9, }, }, }); - const legacyTheme = coerceToLegacyTheme(semanticTheme); - assert.deepEqual(legacyTheme.colors["diagnostic.warning"], (1 << 16) | (2 << 8) | 3); + assert.deepEqual(merged.colors["diagnostic.warning"], (7 << 16) | (8 << 8) | 9); }); - test("mergeThemeOverride accepts nested legacy diagnostic overrides", () => { - const parentTheme = createTheme({ - colors: { - "diagnostic.error": (9 << 16) | (9 << 8) | 9, - }, - }); - - const merged = mergeThemeOverride(parentTheme, { - colors: { - diagnostic: { - error: (7 << 16) | (8 << 8) | 9, + test("mergeThemeOverride preserves unrelated parent tokens", () => { + const parentTheme = compileTheme( + extendTheme(darkTheme, { + spacing: { + xs: 4, + sm: 4, + md: 4, + lg: 4, + xl: 4, + "2xl": 4, }, - }, - }); + }), + ); + const override = withoutSpacing( + extendTheme(darkTheme, { + colors: { + accent: { + primary: (1 << 16) | (2 << 8) | 3, + }, + }, + }), + ); - assert.deepEqual(merged.colors["diagnostic.error"], (7 << 16) | (8 << 8) | 9); + const merged = mergeThemeOverride(parentTheme, override); + assert.deepEqual(merged.spacing, compileTheme(defaultTheme.definition).spacing); + assert.deepEqual(merged.colors["accent.primary"], (1 << 16) | (2 << 8) | 3); }); }); diff --git a/packages/core/src/theme/__tests__/theme.switch.test.ts b/packages/core/src/theme/__tests__/theme.switch.test.ts index a7c142a5..2f8f6440 100644 --- a/packages/core/src/theme/__tests__/theme.switch.test.ts +++ b/packages/core/src/theme/__tests__/theme.switch.test.ts @@ -8,13 +8,15 @@ import { StubBackend } from "../../app/__tests__/stubBackend.js"; import { createApp } from "../../app/createApp.js"; import type { DrawlistTextRunSegment } from "../../drawlist/types.js"; import type { App, DrawlistBuildResult, DrawlistBuilder, TextStyle, VNode } from "../../index.js"; -import { createTheme, ui } from "../../index.js"; +import { ui } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; -import { darkTheme } from "../presets.js"; -import type { Theme } from "../theme.js"; +import { extendTheme } from "../extend.js"; +import { darkTheme, lightTheme } from "../presets.js"; +import { type Theme, compileTheme } from "../theme.js"; +import type { ThemeDefinition } from "../tokens.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; @@ -46,10 +48,12 @@ async function resolveNextFrame(backend: StubBackend): Promise { await flushMicrotasks(8); } -function themeWithPrimary(r: number, g: number, b: number): Theme { - return createTheme({ +function themeWithPrimary(r: number, g: number, b: number): ThemeDefinition { + return extendTheme(darkTheme, { colors: { - primary: (r << 16) | (g << 8) | b, + accent: { + primary: (r << 16) | (g << 8) | b, + }, }, }); } @@ -149,7 +153,7 @@ describe("theme runtime switching", () => { test("focused widget is preserved across theme switch", async () => { const backend = new StubBackend(); const presses: string[] = []; - const app = createApp({ backend, initialState: 0 }); + const app = createApp({ backend, initialState: 0, theme: lightTheme }); app.view(() => ui.column({}, [ui.button({ id: "a", label: "A" }), ui.button({ id: "b", label: "B" })]), @@ -313,7 +317,7 @@ describe("theme runtime switching", () => { assert.equal(bytesEqual(inFlightFrame, latestFrame), false, "latest switch updates output"); }); - test("setTheme no-ops for identical Theme object identity", async () => { + test("setTheme no-ops for identical initial ThemeDefinition identity", async () => { const backend = new StubBackend(); const sharedTheme = themeWithPrimary(180, 30, 30); const app = createApp({ @@ -335,7 +339,7 @@ describe("theme runtime switching", () => { test("setTheme no-ops for identical ThemeDefinition identity", async () => { const backend = new StubBackend(); - const app = createApp({ backend, initialState: 0 }); + const app = createApp({ backend, initialState: 0, theme: lightTheme }); app.view(() => ui.divider({ label: "DEF", color: "primary" })); @@ -358,17 +362,21 @@ describe("theme scoped container overrides", () => { const GREEN = Object.freeze((40 << 16) | (190 << 8) | 80); const BLUE = Object.freeze((40 << 16) | (100 << 8) | 210); const CYAN = Object.freeze((20 << 16) | (180 << 8) | 200); - const baseTheme = createTheme({ - colors: { - primary: RED, - info: CYAN, - }, - }); + const baseTheme = compileTheme( + extendTheme(darkTheme, { + colors: { + accent: { + primary: RED, + }, + info: CYAN, + }, + }), + ); test("box scoped override applies to subtree and restores parent theme", () => { const vnode = ui.column({}, [ ui.divider({ char: "R", color: "primary" }), - ui.box({ border: "none", theme: { colors: { primary: GREEN } } }, [ + ui.box({ border: "none", theme: { colors: { accent: { primary: GREEN } } } }, [ ui.divider({ char: "I", color: "primary" }), ]), ui.divider({ char: "A", color: "primary" }), @@ -383,9 +391,9 @@ describe("theme scoped container overrides", () => { test("nested container overrides compose and restore parent scopes", () => { const vnode = ui.column({}, [ ui.divider({ char: "R", color: "primary" }), - ui.column({ theme: { colors: { primary: BLUE } } }, [ + ui.column({ theme: { colors: { accent: { primary: BLUE } } } }, [ ui.divider({ char: "W", color: "primary" }), - ui.box({ border: "none", theme: { colors: { primary: GREEN } } }, [ + ui.box({ border: "none", theme: { colors: { accent: { primary: GREEN } } } }, [ ui.divider({ char: "B", color: "primary" }), ]), ui.divider({ char: "X", color: "primary" }), @@ -402,7 +410,7 @@ describe("theme scoped container overrides", () => { }); test("partial overrides inherit unspecified parent theme values", () => { - const vnode = ui.box({ border: "none", theme: { colors: { primary: GREEN } } }, [ + const vnode = ui.box({ border: "none", theme: { colors: { accent: { primary: GREEN } } } }, [ ui.divider({ char: "P", color: "primary" }), ui.divider({ char: "N", color: "info" }), ]); diff --git a/packages/core/src/theme/__tests__/theme.test.ts b/packages/core/src/theme/__tests__/theme.test.ts index defdfff8..9206d82c 100644 --- a/packages/core/src/theme/__tests__/theme.test.ts +++ b/packages/core/src/theme/__tests__/theme.test.ts @@ -1,35 +1,41 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import { rgbB, rgbG, rgbR } from "../../widgets/style.js"; -import { createTheme, defaultTheme, resolveColor, resolveSpacing } from "../index.js"; +import { darkTheme } from "../presets.js"; +import { resolveColorToken } from "../resolve.js"; +import { compileTheme, resolveColor, resolveSpacing } from "../theme.js"; -describe("theme", () => { - test("createTheme merges colors and spacing", () => { - const t = createTheme({ - colors: { primary: (1 << 16) | (2 << 8) | 3 }, - spacing: [0, 2, 4], - }); +describe("theme runtime compilation", () => { + test("compileTheme exposes semantic aliases and spacing", () => { + const theme = compileTheme(darkTheme); - assert.equal(rgbR(t.colors.primary), 1); - assert.equal(rgbG(t.colors.primary), 2); - assert.equal(rgbB(t.colors.primary), 3); - assert.equal(rgbR(t.colors.fg), rgbR(defaultTheme.colors.fg)); - assert.deepEqual(t.spacing, [0, 2, 4]); + assert.equal(theme.colors.primary, darkTheme.colors.accent.primary); + assert.equal(theme.colors["accent.primary"], darkTheme.colors.accent.primary); + assert.deepEqual(theme.spacing, [0, 1, 1, 2, 3, 4, 6]); }); - test("resolveColor returns theme color or fg fallback", () => { - assert.deepEqual(resolveColor(defaultTheme, "primary"), defaultTheme.colors.primary); - assert.deepEqual(resolveColor(defaultTheme, "missing"), defaultTheme.colors.fg); - assert.deepEqual( - resolveColor(defaultTheme, (9 << 16) | (8 << 8) | 7), - (9 << 16) | (8 << 8) | 7, - ); + test("resolveColor returns indexed theme color or fg fallback", () => { + const theme = compileTheme(darkTheme); + assert.deepEqual(resolveColor(theme, "primary"), theme.colors.primary); + assert.deepEqual(resolveColor(theme, "missing"), theme.colors.fg); + assert.deepEqual(resolveColor(theme, (9 << 16) | (8 << 8) | 7), (9 << 16) | (8 << 8) | 7); }); test("resolveSpacing maps indices and allows raw values", () => { - const t = createTheme({ spacing: [0, 10, 20] }); - assert.equal(resolveSpacing(t, 0), 0); - assert.equal(resolveSpacing(t, 1), 10); - assert.equal(resolveSpacing(t, 2), 20); - assert.equal(resolveSpacing(t, 5), 5); + const theme = compileTheme(darkTheme); + assert.equal(resolveSpacing(theme, 0), 0); + assert.equal(resolveSpacing(theme, 1), 1); + assert.equal(resolveSpacing(theme, 6), 6); + assert.equal(resolveSpacing(theme, 9), 9); + assert.equal(resolveSpacing(theme, Number.NaN), 0); + assert.equal(resolveSpacing(theme, Number.POSITIVE_INFINITY), 0); + assert.equal(resolveSpacing(theme, Number.NEGATIVE_INFINITY), 0); + }); + + test("resolveColorToken covers widget extension paths", () => { + assert.equal(resolveColorToken(darkTheme, "widget.toast.info"), darkTheme.widget.toast.info); + assert.equal( + resolveColorToken(darkTheme, "widget.syntax.keyword"), + darkTheme.widget.syntax.keyword, + ); + assert.equal(resolveColorToken(darkTheme, "not.a.valid.token.path"), null); }); }); diff --git a/packages/core/src/theme/__tests__/theme.transition.test.ts b/packages/core/src/theme/__tests__/theme.transition.test.ts index 06dc025d..3f39aaf8 100644 --- a/packages/core/src/theme/__tests__/theme.transition.test.ts +++ b/packages/core/src/theme/__tests__/theme.transition.test.ts @@ -10,8 +10,7 @@ import { defineWidget } from "../../widgets/composition.js"; import { ui } from "../../widgets/ui.js"; import { extendTheme } from "../extend.js"; import { darkTheme } from "../presets.js"; -import type { Theme } from "../theme.js"; -import { createTheme } from "../theme.js"; +import type { ThemeDefinition } from "../tokens.js"; type EncodedEvent = NonNullable[0]["events"]>[number]; @@ -31,7 +30,7 @@ function pushEvents(backend: StubBackend, events: readonly EncodedEvent[]): void ); } -async function bootstrap(backend: StubBackend, appTheme: Theme) { +async function bootstrap(backend: StubBackend, appTheme: ThemeDefinition) { const app = createApp({ backend, initialState: 0, @@ -61,10 +60,12 @@ async function drainPendingFrames(backend: StubBackend, maxRounds = 24): Promise } } -function themeWithPrimary(r: number, g: number, b: number): Theme { - return createTheme({ +function themeWithPrimary(r: number, g: number, b: number): ThemeDefinition { + return extendTheme(darkTheme, { colors: { - primary: (r << 16) | (g << 8) | b, + accent: { + primary: (r << 16) | (g << 8) | b, + }, }, }); } diff --git a/packages/core/src/theme/__tests__/theme.validation.test.ts b/packages/core/src/theme/__tests__/theme.validation.test.ts index 06458347..8f0fccde 100644 --- a/packages/core/src/theme/__tests__/theme.validation.test.ts +++ b/packages/core/src/theme/__tests__/theme.validation.test.ts @@ -2,45 +2,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; import { darkTheme, themePresets } from "../presets.js"; import { validateTheme } from "../validate.js"; -const REQUIRED_PATHS = [ - "colors.bg.base", - "colors.bg.elevated", - "colors.bg.overlay", - "colors.bg.subtle", - "colors.fg.primary", - "colors.fg.secondary", - "colors.fg.muted", - "colors.fg.inverse", - "colors.accent.primary", - "colors.accent.secondary", - "colors.accent.tertiary", - "colors.success", - "colors.warning", - "colors.error", - "colors.info", - "colors.focus.ring", - "colors.focus.bg", - "colors.selected.bg", - "colors.selected.fg", - "colors.disabled.fg", - "colors.disabled.bg", - "colors.diagnostic.error", - "colors.diagnostic.warning", - "colors.diagnostic.info", - "colors.diagnostic.hint", - "colors.border.subtle", - "colors.border.default", - "colors.border.strong", - "spacing.xs", - "spacing.sm", - "spacing.md", - "spacing.lg", - "spacing.xl", - "spacing.2xl", - "focusIndicator.bold", - "focusIndicator.underline", -] as const; - function cloneDarkTheme(): Record { return JSON.parse(JSON.stringify(darkTheme)) as Record; } @@ -63,6 +24,13 @@ function setPath(root: Record, path: readonly string[], value: parent[key] = value; } +function deletePath(root: Record, path: readonly string[]): void { + const parent = getRecord(root, path.slice(0, -1)); + const key = path[path.length - 1]; + if (key === undefined) return; + delete parent[key]; +} + function expectValidationError(input: unknown, expectedMessage: string): void { assert.throws( () => validateTheme(input), @@ -82,129 +50,28 @@ describe("theme.validateTheme", () => { } }); - test("accepts darkTheme", () => { - const validated = validateTheme(darkTheme); - assert.equal(validated, darkTheme); - }); - - test("empty object lists all missing required token paths", () => { - expectValidationError( - {}, - `Theme validation failed: missing required token path(s): ${REQUIRED_PATHS.join(", ")}`, - ); - }); - - test("throws when a required semantic token is missing", () => { + test("rejects missing widget token paths", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "error"], undefined); + deletePath(theme, ["widget", "toast", "info"]); expectValidationError( theme, - "Theme validation failed: missing required token path(s): colors.error", + "Theme validation failed: missing required token path(s): widget.toast.info", ); }); - test("throws when a required nested semantic token is missing", () => { + test("rejects invalid name", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "bg", "base"], undefined); + const namedTheme = theme as Record<"name", unknown>; + namedTheme.name = ""; expectValidationError( theme, - "Theme validation failed: missing required token path(s): colors.bg.base", + 'Theme validation failed at name: expected non-empty string (received "")', ); }); - test("throws when a required diagnostic semantic token is missing", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "diagnostic", "error"], undefined); - - expectValidationError( - theme, - "Theme validation failed: missing required token path(s): colors.diagnostic.error", - ); - }); - - test("throws when spacing.xs is missing", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["spacing", "xs"], undefined); - - expectValidationError( - theme, - "Theme validation failed: missing required token path(s): spacing.xs", - ); - }); - - test("throws when spacing.2xl is missing", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["spacing", "2xl"], undefined); - - expectValidationError( - theme, - "Theme validation failed: missing required token path(s): spacing.2xl", - ); - }); - - test("throws when focusIndicator.bold is missing", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["focusIndicator", "bold"], undefined); - - expectValidationError( - theme, - "Theme validation failed: missing required token path(s): focusIndicator.bold", - ); - }); - - test("throws when focusIndicator.underline is missing", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["focusIndicator", "underline"], undefined); - - expectValidationError( - theme, - "Theme validation failed: missing required token path(s): focusIndicator.underline", - ); - }); - - test("throws when colors.diagnostic.error is missing", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "diagnostic", "error"], undefined); - - expectValidationError( - theme, - "Theme validation failed: missing required token path(s): colors.diagnostic.error", - ); - }); - - test("throws when color value exceeds 0x00FFFFFF", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary"], 0x01000000); - - expectValidationError( - theme, - "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received 16777216)", - ); - }); - - test("throws when color value is negative", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary"], -1); - - expectValidationError( - theme, - "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received -1)", - ); - }); - - test("throws when color value is non-integer", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["colors", "accent", "primary"], 1.5); - - expectValidationError( - theme, - "Theme validation failed at colors.accent.primary: expected packed Rgb24 integer 0..0x00FFFFFF (received 1.5)", - ); - }); - - test("throws when color value is not a number", () => { + test("rejects invalid semantic color value", () => { const theme = cloneDarkTheme(); setPath(theme, ["colors", "accent", "primary"], "255"); @@ -214,17 +81,17 @@ describe("theme.validateTheme", () => { ); }); - test("throws when color value is an object", () => { + test("rejects invalid widget color value", () => { const theme = cloneDarkTheme(); - setPath(theme, ["colors", "info"], { r: 0, g: 0, b: 255 }); + setPath(theme, ["widget", "syntax", "keyword"], -1); expectValidationError( theme, - "Theme validation failed at colors.info: expected packed Rgb24 integer 0..0x00FFFFFF (received [object])", + "Theme validation failed at widget.syntax.keyword: expected packed Rgb24 integer 0..0x00FFFFFF (received -1)", ); }); - test("throws when spacing token is non-integer", () => { + test("rejects invalid spacing token", () => { const theme = cloneDarkTheme(); setPath(theme, ["spacing", "md"], 2.5); @@ -234,17 +101,7 @@ describe("theme.validateTheme", () => { ); }); - test("throws when spacing token is negative", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["spacing", "lg"], -1); - - expectValidationError( - theme, - "Theme validation failed at spacing.lg: spacing token must be a non-negative integer (received -1)", - ); - }); - - test("throws when focus indicator bold is not boolean", () => { + test("rejects invalid focus indicator style token", () => { const theme = cloneDarkTheme(); setPath(theme, ["focusIndicator", "bold"], "yes"); @@ -253,14 +110,4 @@ describe("theme.validateTheme", () => { 'Theme validation failed at focusIndicator.bold: focus indicator style must be a boolean (received "yes")', ); }); - - test("throws when focus indicator underline is not boolean", () => { - const theme = cloneDarkTheme(); - setPath(theme, ["focusIndicator", "underline"], 1); - - expectValidationError( - theme, - "Theme validation failed at focusIndicator.underline: focus indicator style must be a boolean (received 1)", - ); - }); }); diff --git a/packages/core/src/theme/defaultTheme.ts b/packages/core/src/theme/defaultTheme.ts index c1ddc2ea..3038daf2 100644 --- a/packages/core/src/theme/defaultTheme.ts +++ b/packages/core/src/theme/defaultTheme.ts @@ -1,56 +1,8 @@ /** - * packages/core/src/theme/defaultTheme.ts — Default theme values. - * - * Why: Provides the baseline theme used when the app does not supply one. - * Kept separate from theme helpers to avoid accidental circular imports. + * packages/core/src/theme/defaultTheme.ts — Internal default compiled theme. */ -import { rgb } from "../widgets/style.js"; -import type { Theme } from "./types.js"; +import { darkTheme } from "./presets.js"; +import { compileTheme } from "./theme.js"; -export const defaultTheme: Theme = Object.freeze({ - colors: Object.freeze({ - primary: rgb(0, 120, 215), - secondary: rgb(108, 117, 125), - success: rgb(40, 167, 69), - danger: rgb(220, 53, 69), - warning: rgb(255, 193, 7), - info: rgb(23, 162, 184), - muted: rgb(128, 128, 128), - bg: rgb(30, 30, 30), - fg: rgb(255, 255, 255), - border: rgb(60, 60, 60), - "diagnostic.error": rgb(220, 53, 69), - "diagnostic.warning": rgb(255, 193, 7), - "diagnostic.info": rgb(23, 162, 184), - "diagnostic.hint": rgb(40, 167, 69), - "syntax.keyword": rgb(255, 121, 198), - "syntax.type": rgb(189, 147, 249), - "syntax.string": rgb(241, 250, 140), - "syntax.number": rgb(189, 147, 249), - "syntax.comment": rgb(98, 114, 164), - "syntax.operator": rgb(255, 121, 198), - "syntax.punctuation": rgb(248, 248, 242), - "syntax.function": rgb(80, 250, 123), - "syntax.variable": rgb(139, 233, 253), - "syntax.cursor.fg": rgb(40, 42, 54), - "syntax.cursor.bg": rgb(139, 233, 253), - "widget.diff.add.bg": rgb(35, 65, 35), - "widget.diff.delete.bg": rgb(65, 35, 35), - "widget.diff.add.fg": rgb(150, 255, 150), - "widget.diff.delete.fg": rgb(255, 150, 150), - "widget.diff.hunkHeader": rgb(100, 149, 237), - "widget.diff.lineNumber": rgb(100, 100, 100), - "widget.diff.border": rgb(80, 80, 80), - "widget.logs.level.trace": rgb(100, 100, 100), - "widget.logs.level.debug": rgb(150, 150, 150), - "widget.logs.level.info": rgb(255, 255, 255), - "widget.logs.level.warn": rgb(255, 200, 50), - "widget.logs.level.error": rgb(255, 80, 80), - "widget.toast.info": rgb(50, 150, 255), - "widget.toast.success": rgb(50, 200, 100), - "widget.toast.warning": rgb(255, 200, 50), - "widget.toast.error": rgb(255, 80, 80), - }), - spacing: Object.freeze([0, 1, 2, 4, 8, 16]), -}); +export const defaultTheme = compileTheme(darkTheme); diff --git a/packages/core/src/theme/extract.ts b/packages/core/src/theme/extract.ts index 514ed46b..de35270b 100644 --- a/packages/core/src/theme/extract.ts +++ b/packages/core/src/theme/extract.ts @@ -1,76 +1,6 @@ -import type { Rgb24 } from "../widgets/style.js"; import type { Theme } from "./theme.js"; import type { ColorTokens } from "./tokens.js"; -function extractColorTokens(theme: Theme): ColorTokens | null { - const c = theme.colors; - const bgBase = c["bg.base"] as Rgb24 | undefined; - if (bgBase === undefined) return null; - - return { - bg: { - base: bgBase, - elevated: (c["bg.elevated"] as Rgb24) ?? bgBase, - overlay: (c["bg.overlay"] as Rgb24) ?? bgBase, - subtle: (c["bg.subtle"] as Rgb24) ?? bgBase, - }, - fg: { - primary: (c["fg.primary"] as Rgb24) ?? c.fg, - secondary: (c["fg.secondary"] as Rgb24) ?? c.muted, - muted: (c["fg.muted"] as Rgb24) ?? c.muted, - inverse: (c["fg.inverse"] as Rgb24) ?? c.bg, - }, - accent: { - primary: (c["accent.primary"] as Rgb24) ?? c.primary, - secondary: (c["accent.secondary"] as Rgb24) ?? c.secondary, - tertiary: (c["accent.tertiary"] as Rgb24) ?? c.info, - }, - success: c.success, - warning: c.warning, - error: c.danger ?? (c as { error?: Rgb24 }).error ?? c.primary ?? c.fg ?? bgBase, - info: c.info, - focus: { - ring: (c["focus.ring"] as Rgb24) ?? c.primary, - bg: (c["focus.bg"] as Rgb24) ?? c.bg, - }, - selected: { - bg: (c["selected.bg"] as Rgb24) ?? c.primary, - fg: (c["selected.fg"] as Rgb24) ?? c.fg, - }, - disabled: { - fg: (c["disabled.fg"] as Rgb24) ?? c.muted, - bg: (c["disabled.bg"] as Rgb24) ?? c.bg, - }, - diagnostic: { - error: - (c["diagnostic.error"] as Rgb24) ?? - c.danger ?? - (c as { error?: Rgb24 }).error ?? - c.primary ?? - c.fg ?? - bgBase, - warning: (c["diagnostic.warning"] as Rgb24) ?? c.warning, - info: (c["diagnostic.info"] as Rgb24) ?? c.info, - hint: (c["diagnostic.hint"] as Rgb24) ?? c.success, - }, - border: { - subtle: (c["border.subtle"] as Rgb24) ?? c.border, - default: (c["border.default"] as Rgb24) ?? c.border, - strong: (c["border.strong"] as Rgb24) ?? c.border, - }, - }; -} - -const colorTokensCache = new WeakMap(); - -/** - * Extract structured ColorTokens from a legacy Theme. - * Returns null if the theme lacks semantic token paths (pure legacy theme). - */ -export function getColorTokens(theme: Theme): ColorTokens | null { - const cached = colorTokensCache.get(theme.colors); - if (cached !== undefined) return cached; - const tokens = extractColorTokens(theme); - colorTokensCache.set(theme.colors, tokens); - return tokens; +export function getColorTokens(theme: Theme): ColorTokens { + return theme.definition.colors; } diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index e78fc23f..596b60a1 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -1,22 +1,7 @@ /** * packages/core/src/theme/index.ts — Theme public exports. - * - * Exports both the runtime theme system (Theme, createTheme) and the new - * semantic token system (ThemeDefinition, ColorTokens, etc.). */ -// Runtime theme system (resolved theme shape) -export { defaultTheme } from "./defaultTheme.js"; -export { - createTheme, - resolveColor, - resolveSpacing, - type Theme, - type ThemeColors, - type ThemeSpacing, -} from "./theme.js"; - -// New semantic token system export { color, createColorTokens, @@ -26,18 +11,22 @@ export { type AccentTokens, type BgTokens, type BorderTokens, + type ChartTokens, type ColorTokens, type DiagnosticTokens, + type DiffTokens, type DisabledTokens, type FocusIndicatorTokens, type FgTokens, type FocusTokens, + type LogsTokens, type SelectedTokens, - type ThemeSpacingTokens, + type SyntaxTokens, type ThemeDefinition, + type ThemeSpacingTokens, + type ToastTokens, + type WidgetTokens, } from "./tokens.js"; - -// Theme presets export { darkTheme, lightTheme, @@ -48,8 +37,6 @@ export { themePresets, type ThemePresetName, } from "./presets.js"; - -// Resolution utilities export { resolveColorToken, tryResolveColorToken, @@ -58,10 +45,6 @@ export { type ColorPath, type ResolveColorResult, } from "./resolve.js"; - -// Validation and extension utilities export { validateTheme } from "./validate.js"; export { extendTheme, type ThemeOverrides } from "./extend.js"; - -// Accessibility utilities export { contrastRatio } from "./contrast.js"; diff --git a/packages/core/src/theme/interop.ts b/packages/core/src/theme/interop.ts index 0fb485e0..c3e61ba3 100644 --- a/packages/core/src/theme/interop.ts +++ b/packages/core/src/theme/interop.ts @@ -1,343 +1,39 @@ /** - * packages/core/src/theme/interop.ts — Interop between theme systems. - * - * Why: The app runtime and renderer operate on the legacy `Theme` shape - * (flat color map + spacing array). Public docs and presets use the new - * semantic token `ThemeDefinition`. This module provides a deterministic - * conversion to keep the public API ergonomic. + * packages/core/src/theme/interop.ts — Scoped theme override helpers. */ -import type { Rgb24 } from "../widgets/style.js"; -import { defaultTheme } from "./defaultTheme.js"; -import type { Theme } from "./theme.js"; +import { type ThemeOverrides, extendTheme } from "./extend.js"; +import { type Theme, compileTheme } from "./theme.js"; import type { ThemeDefinition } from "./tokens.js"; -type BgOverride = { - base?: unknown; - elevated?: unknown; - overlay?: unknown; - subtle?: unknown; -}; - -type FgOverride = { - primary?: unknown; - secondary?: unknown; - muted?: unknown; - inverse?: unknown; -}; - -type AccentOverride = { - primary?: unknown; - secondary?: unknown; - tertiary?: unknown; -}; - -type FocusOverride = { - ring?: unknown; - bg?: unknown; -}; - -type SelectedOverride = { - bg?: unknown; - fg?: unknown; -}; - -type DisabledOverride = { - fg?: unknown; - bg?: unknown; -}; - -type BorderOverride = { - subtle?: unknown; - default?: unknown; - strong?: unknown; -}; - -type DiagnosticOverride = { - error?: unknown; - warning?: unknown; - info?: unknown; - hint?: unknown; -}; - -type ThemeDefinitionSpacingOverride = { - xs?: unknown; - sm?: unknown; - md?: unknown; - lg?: unknown; - xl?: unknown; - "2xl"?: unknown; -}; - -type LegacyColorOverrideSource = { - bg?: unknown; - fg?: unknown; - accent?: unknown; - error?: unknown; - success?: unknown; - warning?: unknown; - info?: unknown; - focus?: unknown; - selected?: unknown; - disabled?: unknown; - border?: unknown; - diagnostic?: unknown; - [key: string]: unknown; -}; - -function isObject(v: unknown): v is Record { - return typeof v === "object" && v !== null; -} - -function isRgb(v: unknown): v is Rgb24 { - return typeof v === "number" && Number.isFinite(v); -} - -function readSpacingOverride(raw: unknown): Theme["spacing"] | undefined { - if (!Array.isArray(raw)) return undefined; - const spacing: number[] = []; - for (const item of raw) { - if (typeof item !== "number" || !Number.isFinite(item)) return undefined; - spacing.push(item); - } - return Object.freeze(spacing); +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; } -function isSpacingToken(value: unknown): value is number { - return ( - typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 - ); -} +type ThemeDefinitionCandidate = Readonly<{ + name?: unknown; + colors?: unknown; + spacing?: unknown; + focusIndicator?: unknown; + widget?: unknown; +}>; -function readThemeDefinitionSpacing(raw: unknown): Theme["spacing"] | undefined { - if (!isObject(raw)) return undefined; - const spacing = raw as ThemeDefinitionSpacingOverride; - const xs = spacing.xs; - const sm = spacing.sm; - const md = spacing.md; - const lg = spacing.lg; - const xl = spacing.xl; - const x2xl = spacing["2xl"]; +function isThemeDefinition(value: unknown): value is ThemeDefinition { + if (!isObject(value)) return false; + const candidate = value as ThemeDefinitionCandidate; + if (typeof candidate.name !== "string") return false; if ( - !isSpacingToken(xs) || - !isSpacingToken(sm) || - !isSpacingToken(md) || - !isSpacingToken(lg) || - !isSpacingToken(xl) || - !isSpacingToken(x2xl) + !isObject(candidate.colors) || + !isObject(candidate.spacing) || + !isObject(candidate.focusIndicator) ) { - return undefined; - } - return Object.freeze([0, xs, sm, md, lg, xl, x2xl]); -} - -function spacingEquals(a: Theme["spacing"], b: Theme["spacing"]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -function setColor(out: Record, key: string, value: unknown): Rgb24 | undefined { - if (!isRgb(value)) return undefined; - const packed = (Math.round(value) >>> 0) & 0x00ff_ffff; - out[key] = packed; - return packed; -} - -function extractLegacyColorOverrides(raw: unknown): Partial { - if (!isObject(raw)) return {}; - - const source = raw as Readonly; - const out: Record = {}; - - const bg = isObject(source.bg) ? (source.bg as BgOverride) : null; - if (bg) { - const base = setColor(out, "bg.base", bg.base); - setColor(out, "bg.elevated", bg.elevated); - setColor(out, "bg.overlay", bg.overlay); - setColor(out, "bg.subtle", bg.subtle); - if (base) setColor(out, "bg", base); - } - - const fg = isObject(source.fg) ? (source.fg as FgOverride) : null; - if (fg) { - const primary = setColor(out, "fg.primary", fg.primary); - setColor(out, "fg.secondary", fg.secondary); - const muted = setColor(out, "fg.muted", fg.muted); - setColor(out, "fg.inverse", fg.inverse); - if (primary) setColor(out, "fg", primary); - if (muted) setColor(out, "muted", muted); - } - - const accent = isObject(source.accent) ? (source.accent as AccentOverride) : null; - if (accent) { - const primary = setColor(out, "accent.primary", accent.primary); - const secondary = setColor(out, "accent.secondary", accent.secondary); - setColor(out, "accent.tertiary", accent.tertiary); - if (primary) setColor(out, "primary", primary); - if (secondary) setColor(out, "secondary", secondary); - } - - const error = setColor(out, "error", source.error); - if (error) setColor(out, "danger", error); - setColor(out, "success", source.success); - setColor(out, "warning", source.warning); - setColor(out, "info", source.info); - - const focus = isObject(source.focus) ? (source.focus as FocusOverride) : null; - if (focus) { - setColor(out, "focus.ring", focus.ring); - setColor(out, "focus.bg", focus.bg); - } - - const selected = isObject(source.selected) ? (source.selected as SelectedOverride) : null; - if (selected) { - setColor(out, "selected.bg", selected.bg); - setColor(out, "selected.fg", selected.fg); - } - - const disabled = isObject(source.disabled) ? (source.disabled as DisabledOverride) : null; - if (disabled) { - setColor(out, "disabled.fg", disabled.fg); - setColor(out, "disabled.bg", disabled.bg); - } - - const border = isObject(source.border) ? (source.border as BorderOverride) : null; - if (border) { - setColor(out, "border.subtle", border.subtle); - const borderDefault = setColor(out, "border.default", border.default); - setColor(out, "border.strong", border.strong); - if (borderDefault) setColor(out, "border", borderDefault); - } - - const diagnostic = isObject(source.diagnostic) ? (source.diagnostic as DiagnosticOverride) : null; - if (diagnostic) { - setColor(out, "diagnostic.error", diagnostic.error); - setColor(out, "diagnostic.warning", diagnostic.warning); - setColor(out, "diagnostic.info", diagnostic.info); - setColor(out, "diagnostic.hint", diagnostic.hint); + return false; } - - // Flat legacy colors and custom token keys override derived aliases. - for (const [key, value] of Object.entries(source)) { - if (isRgb(value)) out[key] = value; - } - - return out; -} - -function mergeLegacyTheme( - parent: Theme, - colorsOverride: Partial, - spacingOverride: Theme["spacing"] | undefined, -): Theme { - const colorEntries = Object.entries(colorsOverride) as Array<[string, Rgb24]>; - let colors = parent.colors; - if (colorEntries.length > 0) { - let colorChanged = false; - for (const [key, value] of colorEntries) { - if (parent.colors[key] !== value) { - colorChanged = true; - break; - } - } - if (colorChanged) { - colors = Object.freeze({ ...parent.colors, ...colorsOverride }) as Theme["colors"]; - } - } - - let spacing = parent.spacing; - if (spacingOverride !== undefined && !spacingEquals(parent.spacing, spacingOverride)) { - spacing = spacingOverride; - } - - if (colors === parent.colors && spacing === parent.spacing) { - return parent; - } - return Object.freeze({ colors, spacing }); -} - -export function isThemeDefinition(v: Theme | ThemeDefinition): v is ThemeDefinition { - if (!isObject(v)) return false; - const candidate = v as unknown as { name?: unknown; colors?: unknown }; - if (typeof candidate.name !== "string") return false; - if (!isObject(candidate.colors)) return false; - const colors = candidate.colors as { bg?: unknown; fg?: unknown; accent?: unknown }; - return isObject(colors.bg) && isObject(colors.fg) && isObject(colors.accent); -} - -const legacyThemeDefinitionCache = new WeakMap(); - -export function coerceToLegacyTheme(theme: Theme | ThemeDefinition): Theme { - if (!isThemeDefinition(theme)) return theme; - const cached = legacyThemeDefinitionCache.get(theme); - if (cached) return cached; - - const c = theme.colors; - const spacing = readThemeDefinitionSpacing(theme.spacing) ?? defaultTheme.spacing; - - const colors: Theme["colors"] = Object.freeze({ - // Legacy keys used by resolveColor(theme, key) - primary: c.accent.primary, - secondary: c.accent.secondary, - success: c.success, - danger: c.error, - warning: c.warning, - info: c.info, - muted: c.fg.muted, - bg: c.bg.base, - fg: c.fg.primary, - border: c.border.default, - - // Semantic token paths (so widgets can use dot paths like "fg.primary") - "bg.base": c.bg.base, - "bg.elevated": c.bg.elevated, - "bg.overlay": c.bg.overlay, - "bg.subtle": c.bg.subtle, - "fg.primary": c.fg.primary, - "fg.secondary": c.fg.secondary, - "fg.muted": c.fg.muted, - "fg.inverse": c.fg.inverse, - "accent.primary": c.accent.primary, - "accent.secondary": c.accent.secondary, - "accent.tertiary": c.accent.tertiary, - error: c.error, - "focus.ring": c.focus.ring, - "focus.bg": c.focus.bg, - "selected.bg": c.selected.bg, - "selected.fg": c.selected.fg, - "disabled.fg": c.disabled.fg, - "disabled.bg": c.disabled.bg, - "border.subtle": c.border.subtle, - "border.default": c.border.default, - "border.strong": c.border.strong, - "diagnostic.error": c.diagnostic?.error ?? c.error, - "diagnostic.warning": c.diagnostic?.warning ?? c.warning, - "diagnostic.info": c.diagnostic?.info ?? c.info, - "diagnostic.hint": c.diagnostic?.hint ?? c.accent.tertiary, - }); - - const legacyTheme = Object.freeze({ colors, spacing }); - legacyThemeDefinitionCache.set(theme, legacyTheme); - return legacyTheme; + return isObject(candidate.widget); } export function mergeThemeOverride(parentTheme: Theme, override: unknown): Theme { if (!isObject(override)) return parentTheme; - - if (isThemeDefinition(override as Theme | ThemeDefinition)) { - const definition = override as ThemeDefinition; - const colors = coerceToLegacyTheme(definition).colors; - const spacing = readThemeDefinitionSpacing(definition.spacing); - return mergeLegacyTheme(parentTheme, colors, spacing); - } - - const candidate = override as { colors?: unknown; spacing?: unknown }; - const colors = extractLegacyColorOverrides(candidate.colors ?? override); - const spacing = readSpacingOverride(candidate.spacing); - if (Object.keys(colors).length === 0 && spacing === undefined) return parentTheme; - - return mergeLegacyTheme(parentTheme, colors, spacing); + if (isThemeDefinition(override)) return compileTheme(override); + return compileTheme(extendTheme(parentTheme.definition, override as ThemeOverrides)); } diff --git a/packages/core/src/theme/resolve.ts b/packages/core/src/theme/resolve.ts index 5d7a665a..8c165db1 100644 --- a/packages/core/src/theme/resolve.ts +++ b/packages/core/src/theme/resolve.ts @@ -1,160 +1,214 @@ /** * packages/core/src/theme/resolve.ts — Theme resolution system. - * - * Why: Resolves color token paths to RGB values using dot notation. - * Supports paths like "fg.primary", "accent.secondary", "error". - * - * @see docs/styling/theme.md */ import type { Rgb24 } from "../widgets/style.js"; -import type { ColorTokens, ThemeDefinition } from "./tokens.js"; +import type { ThemeDefinition } from "./tokens.js"; -/** - * Type-safe color path union. - * Supports all valid paths into the ColorTokens structure. - */ -export type ColorPath = - // Background paths - | "bg.base" - | "bg.elevated" - | "bg.overlay" - | "bg.subtle" - // Foreground paths - | "fg.primary" - | "fg.secondary" - | "fg.muted" - | "fg.inverse" - // Accent paths - | "accent.primary" - | "accent.secondary" - | "accent.tertiary" - // Semantic (top-level) - | "success" - | "warning" - | "error" - | "info" - // Focus paths - | "focus.ring" - | "focus.bg" - // Selected paths - | "selected.bg" - | "selected.fg" - // Disabled paths - | "disabled.fg" - | "disabled.bg" - // Diagnostic paths - | "diagnostic.error" - | "diagnostic.warning" - | "diagnostic.info" - | "diagnostic.hint" - // Border paths - | "border.subtle" - | "border.default" - | "border.strong"; +const COLOR_PATHS = [ + "bg.base", + "bg.elevated", + "bg.overlay", + "bg.subtle", + "fg.primary", + "fg.secondary", + "fg.muted", + "fg.inverse", + "accent.primary", + "accent.secondary", + "accent.tertiary", + "success", + "warning", + "error", + "info", + "focus.ring", + "focus.bg", + "selected.bg", + "selected.fg", + "disabled.fg", + "disabled.bg", + "diagnostic.error", + "diagnostic.warning", + "diagnostic.info", + "diagnostic.hint", + "border.subtle", + "border.default", + "border.strong", + "widget.syntax.keyword", + "widget.syntax.type", + "widget.syntax.string", + "widget.syntax.number", + "widget.syntax.comment", + "widget.syntax.operator", + "widget.syntax.punctuation", + "widget.syntax.function", + "widget.syntax.variable", + "widget.syntax.cursorFg", + "widget.syntax.cursorBg", + "widget.diff.addBg", + "widget.diff.deleteBg", + "widget.diff.addFg", + "widget.diff.deleteFg", + "widget.diff.hunkHeader", + "widget.diff.lineNumber", + "widget.diff.border", + "widget.logs.trace", + "widget.logs.debug", + "widget.logs.info", + "widget.logs.warn", + "widget.logs.error", + "widget.toast.info", + "widget.toast.success", + "widget.toast.warning", + "widget.toast.error", + "widget.chart.primary", + "widget.chart.accent", + "widget.chart.muted", + "widget.chart.success", + "widget.chart.warning", + "widget.chart.danger", +] as const; -/** - * Result of color resolution. - */ -export type ResolveColorResult = { ok: true; value: Rgb24 } | { ok: false; error: string }; +const VALID_COLOR_PATHS: ReadonlySet = new Set(COLOR_PATHS); -/** - * Resolve a color token path to an RGB value. - * - * @param theme - The theme definition containing color tokens - * @param path - Dot-notation path (e.g., "fg.primary", "error") - * @returns The resolved RGB color, or null if path is invalid - * - * @example - * ```typescript - * const color = resolveColorToken(darkTheme, "fg.primary"); - * // 0xe6e1cf (packed Rgb24) - * - * const error = resolveColorToken(darkTheme, "error"); - * // 0xf07178 (packed Rgb24) - * ``` - */ -export function resolveColorToken(theme: ThemeDefinition, path: ColorPath): Rgb24; -export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb24 | null; -export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb24 | null { - const colors = theme.colors; - const parts = path.split("."); +export type ColorPath = (typeof COLOR_PATHS)[number]; - if (parts.length === 1) { - // Top-level semantic colors - const key = parts[0]; - if (key === "success") return colors.success; - if (key === "warning") return colors.warning; - if (key === "error") return colors.error; - if (key === "info") return colors.info; - return null; - } +export type ResolveColorResult = { ok: true; value: Rgb24 } | { ok: false; error: string }; - if (parts.length === 2) { - const [group, key] = parts as [string, string]; - return resolveNestedToken(colors, group, key); +function getPathValue(theme: ThemeDefinition, path: ColorPath): Rgb24 { + switch (path) { + case "bg.base": + return theme.colors.bg.base; + case "bg.elevated": + return theme.colors.bg.elevated; + case "bg.overlay": + return theme.colors.bg.overlay; + case "bg.subtle": + return theme.colors.bg.subtle; + case "fg.primary": + return theme.colors.fg.primary; + case "fg.secondary": + return theme.colors.fg.secondary; + case "fg.muted": + return theme.colors.fg.muted; + case "fg.inverse": + return theme.colors.fg.inverse; + case "accent.primary": + return theme.colors.accent.primary; + case "accent.secondary": + return theme.colors.accent.secondary; + case "accent.tertiary": + return theme.colors.accent.tertiary; + case "success": + return theme.colors.success; + case "warning": + return theme.colors.warning; + case "error": + return theme.colors.error; + case "info": + return theme.colors.info; + case "focus.ring": + return theme.colors.focus.ring; + case "focus.bg": + return theme.colors.focus.bg; + case "selected.bg": + return theme.colors.selected.bg; + case "selected.fg": + return theme.colors.selected.fg; + case "disabled.fg": + return theme.colors.disabled.fg; + case "disabled.bg": + return theme.colors.disabled.bg; + case "diagnostic.error": + return theme.colors.diagnostic.error; + case "diagnostic.warning": + return theme.colors.diagnostic.warning; + case "diagnostic.info": + return theme.colors.diagnostic.info; + case "diagnostic.hint": + return theme.colors.diagnostic.hint; + case "border.subtle": + return theme.colors.border.subtle; + case "border.default": + return theme.colors.border.default; + case "border.strong": + return theme.colors.border.strong; + case "widget.syntax.keyword": + return theme.widget.syntax.keyword; + case "widget.syntax.type": + return theme.widget.syntax.type; + case "widget.syntax.string": + return theme.widget.syntax.string; + case "widget.syntax.number": + return theme.widget.syntax.number; + case "widget.syntax.comment": + return theme.widget.syntax.comment; + case "widget.syntax.operator": + return theme.widget.syntax.operator; + case "widget.syntax.punctuation": + return theme.widget.syntax.punctuation; + case "widget.syntax.function": + return theme.widget.syntax.function; + case "widget.syntax.variable": + return theme.widget.syntax.variable; + case "widget.syntax.cursorFg": + return theme.widget.syntax.cursorFg; + case "widget.syntax.cursorBg": + return theme.widget.syntax.cursorBg; + case "widget.diff.addBg": + return theme.widget.diff.addBg; + case "widget.diff.deleteBg": + return theme.widget.diff.deleteBg; + case "widget.diff.addFg": + return theme.widget.diff.addFg; + case "widget.diff.deleteFg": + return theme.widget.diff.deleteFg; + case "widget.diff.hunkHeader": + return theme.widget.diff.hunkHeader; + case "widget.diff.lineNumber": + return theme.widget.diff.lineNumber; + case "widget.diff.border": + return theme.widget.diff.border; + case "widget.logs.trace": + return theme.widget.logs.trace; + case "widget.logs.debug": + return theme.widget.logs.debug; + case "widget.logs.info": + return theme.widget.logs.info; + case "widget.logs.warn": + return theme.widget.logs.warn; + case "widget.logs.error": + return theme.widget.logs.error; + case "widget.toast.info": + return theme.widget.toast.info; + case "widget.toast.success": + return theme.widget.toast.success; + case "widget.toast.warning": + return theme.widget.toast.warning; + case "widget.toast.error": + return theme.widget.toast.error; + case "widget.chart.primary": + return theme.widget.chart.primary; + case "widget.chart.accent": + return theme.widget.chart.accent; + case "widget.chart.muted": + return theme.widget.chart.muted; + case "widget.chart.success": + return theme.widget.chart.success; + case "widget.chart.warning": + return theme.widget.chart.warning; + case "widget.chart.danger": + return theme.widget.chart.danger; } - - return null; } -/** - * Resolve a nested color token (two-level path). - * @internal - */ -function resolveNestedToken(colors: ColorTokens, group: string, key: string): Rgb24 | null { - switch (group) { - case "bg": - if (key === "base") return colors.bg.base; - if (key === "elevated") return colors.bg.elevated; - if (key === "overlay") return colors.bg.overlay; - if (key === "subtle") return colors.bg.subtle; - break; - case "fg": - if (key === "primary") return colors.fg.primary; - if (key === "secondary") return colors.fg.secondary; - if (key === "muted") return colors.fg.muted; - if (key === "inverse") return colors.fg.inverse; - break; - case "accent": - if (key === "primary") return colors.accent.primary; - if (key === "secondary") return colors.accent.secondary; - if (key === "tertiary") return colors.accent.tertiary; - break; - case "focus": - if (key === "ring") return colors.focus.ring; - if (key === "bg") return colors.focus.bg; - break; - case "selected": - if (key === "bg") return colors.selected.bg; - if (key === "fg") return colors.selected.fg; - break; - case "disabled": - if (key === "fg") return colors.disabled.fg; - if (key === "bg") return colors.disabled.bg; - break; - case "diagnostic": - if (key === "error") return colors.diagnostic.error; - if (key === "warning") return colors.diagnostic.warning; - if (key === "info") return colors.diagnostic.info; - if (key === "hint") return colors.diagnostic.hint; - break; - case "border": - if (key === "subtle") return colors.border.subtle; - if (key === "default") return colors.border.default; - if (key === "strong") return colors.border.strong; - break; - } - return null; +export function resolveColorToken(theme: ThemeDefinition, path: ColorPath): Rgb24; +export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb24 | null; +export function resolveColorToken(theme: ThemeDefinition, path: string): Rgb24 | null { + if (!isValidColorPath(path)) return null; + return getPathValue(theme, path); } -/** - * Try to resolve a color token path, returning a Result type. - * - * @param theme - The theme definition - * @param path - Color path to resolve - * @returns Result with resolved color or error message - */ export function tryResolveColorToken(theme: ThemeDefinition, path: string): ResolveColorResult { const result = resolveColorToken(theme, path); if (result === null) { @@ -163,15 +217,6 @@ export function tryResolveColorToken(theme: ThemeDefinition, path: string): Reso return { ok: true, value: result }; } -/** - * Resolve a color value that may be either a path string or direct RGB. - * Falls back to a default color if resolution fails. - * - * @param theme - The theme definition - * @param color - Either a color path string or direct RGB value - * @param fallback - Fallback color if resolution fails - * @returns Resolved RGB color - */ export function resolveColorOrRgb( theme: ThemeDefinition, color: string | Rgb24 | undefined, @@ -182,42 +227,6 @@ export function resolveColorOrRgb( return resolveColorToken(theme, color) ?? fallback; } -/** - * Check if a string is a valid color path. - * - * @param path - String to check - * @returns True if path is a valid ColorPath - */ export function isValidColorPath(path: string): path is ColorPath { - const validPaths: ReadonlySet = new Set([ - "bg.base", - "bg.elevated", - "bg.overlay", - "bg.subtle", - "fg.primary", - "fg.secondary", - "fg.muted", - "fg.inverse", - "accent.primary", - "accent.secondary", - "accent.tertiary", - "success", - "warning", - "error", - "info", - "focus.ring", - "focus.bg", - "selected.bg", - "selected.fg", - "disabled.fg", - "disabled.bg", - "diagnostic.error", - "diagnostic.warning", - "diagnostic.info", - "diagnostic.hint", - "border.subtle", - "border.default", - "border.strong", - ]); - return validPaths.has(path); + return VALID_COLOR_PATHS.has(path); } diff --git a/packages/core/src/theme/theme.ts b/packages/core/src/theme/theme.ts index 0198a945..2e063ecc 100644 --- a/packages/core/src/theme/theme.ts +++ b/packages/core/src/theme/theme.ts @@ -1,32 +1,249 @@ /** - * packages/core/src/theme/theme.ts — Theme types and helpers. + * packages/core/src/theme/theme.ts — Internal compiled runtime theme helpers. * - * Why: Provides a minimal theming system for color and spacing tokens that - * can be resolved deterministically at render time. + * Why: The public contract is `ThemeDefinition`, but the renderer benefits from + * a precompiled color index and resolved spacing array. */ -import type { Rgb24 } from "../widgets/style.js"; -import { defaultTheme } from "./defaultTheme.js"; +import { blendRgb } from "./blend.js"; +import type { + ColorTokens, + FocusIndicatorTokens, + ThemeDefinition, + ThemeSpacingTokens, + WidgetTokens, +} from "./tokens.js"; import type { Theme, ThemeColors, ThemeSpacing } from "./types.js"; +import { validateTheme } from "./validate.js"; export type { Theme, ThemeColors, ThemeSpacing } from "./types.js"; -export function createTheme(overrides: Partial): Theme; -export function createTheme( - overrides: Readonly<{ colors?: Partial; spacing?: ThemeSpacing }>, -): Theme; -export function createTheme( - overrides: Partial | Readonly<{ colors?: Partial; spacing?: ThemeSpacing }>, -): Theme { - const base = defaultTheme; - const raw = overrides as { colors?: Partial; spacing?: ThemeSpacing }; - const colors = Object.freeze({ ...base.colors, ...(raw.colors ?? {}) }) as ThemeColors; - const spacing = Object.freeze([...(raw.spacing ?? base.spacing)]); - return Object.freeze({ colors, spacing }); -} - -export function resolveColor(theme: Theme, color: string | Rgb24): Rgb24 { +const compiledThemeCache = new WeakMap(); + +function buildSpacing(spacing: ThemeSpacingTokens): ThemeSpacing { + return Object.freeze([ + 0, + spacing.xs, + spacing.sm, + spacing.md, + spacing.lg, + spacing.xl, + spacing["2xl"], + ]); +} + +function buildColorIndex(theme: ThemeDefinition): ThemeColors { + const c = theme.colors; + const widget = theme.widget; + + return Object.freeze({ + primary: c.accent.primary, + secondary: c.accent.secondary, + success: c.success, + danger: c.error, + warning: c.warning, + info: c.info, + muted: c.fg.muted, + bg: c.bg.base, + fg: c.fg.primary, + border: c.border.default, + error: c.error, + "bg.base": c.bg.base, + "bg.elevated": c.bg.elevated, + "bg.overlay": c.bg.overlay, + "bg.subtle": c.bg.subtle, + "fg.primary": c.fg.primary, + "fg.secondary": c.fg.secondary, + "fg.muted": c.fg.muted, + "fg.inverse": c.fg.inverse, + "accent.primary": c.accent.primary, + "accent.secondary": c.accent.secondary, + "accent.tertiary": c.accent.tertiary, + "focus.ring": c.focus.ring, + "focus.bg": c.focus.bg, + "selected.bg": c.selected.bg, + "selected.fg": c.selected.fg, + "disabled.fg": c.disabled.fg, + "disabled.bg": c.disabled.bg, + "diagnostic.error": c.diagnostic.error, + "diagnostic.warning": c.diagnostic.warning, + "diagnostic.info": c.diagnostic.info, + "diagnostic.hint": c.diagnostic.hint, + "border.subtle": c.border.subtle, + "border.default": c.border.default, + "border.strong": c.border.strong, + "widget.syntax.keyword": widget.syntax.keyword, + "widget.syntax.type": widget.syntax.type, + "widget.syntax.string": widget.syntax.string, + "widget.syntax.number": widget.syntax.number, + "widget.syntax.comment": widget.syntax.comment, + "widget.syntax.operator": widget.syntax.operator, + "widget.syntax.punctuation": widget.syntax.punctuation, + "widget.syntax.function": widget.syntax.function, + "widget.syntax.variable": widget.syntax.variable, + "widget.syntax.cursorFg": widget.syntax.cursorFg, + "widget.syntax.cursorBg": widget.syntax.cursorBg, + "widget.diff.addBg": widget.diff.addBg, + "widget.diff.deleteBg": widget.diff.deleteBg, + "widget.diff.addFg": widget.diff.addFg, + "widget.diff.deleteFg": widget.diff.deleteFg, + "widget.diff.hunkHeader": widget.diff.hunkHeader, + "widget.diff.lineNumber": widget.diff.lineNumber, + "widget.diff.border": widget.diff.border, + "widget.logs.trace": widget.logs.trace, + "widget.logs.debug": widget.logs.debug, + "widget.logs.info": widget.logs.info, + "widget.logs.warn": widget.logs.warn, + "widget.logs.error": widget.logs.error, + "widget.toast.info": widget.toast.info, + "widget.toast.success": widget.toast.success, + "widget.toast.warning": widget.toast.warning, + "widget.toast.error": widget.toast.error, + "widget.chart.primary": widget.chart.primary, + "widget.chart.accent": widget.chart.accent, + "widget.chart.muted": widget.chart.muted, + "widget.chart.success": widget.chart.success, + "widget.chart.warning": widget.chart.warning, + "widget.chart.danger": widget.chart.danger, + }); +} + +function blendColorTokens(from: ColorTokens, to: ColorTokens, t: number): ColorTokens { + return Object.freeze({ + bg: Object.freeze({ + base: blendRgb(from.bg.base, to.bg.base, t), + elevated: blendRgb(from.bg.elevated, to.bg.elevated, t), + overlay: blendRgb(from.bg.overlay, to.bg.overlay, t), + subtle: blendRgb(from.bg.subtle, to.bg.subtle, t), + }), + fg: Object.freeze({ + primary: blendRgb(from.fg.primary, to.fg.primary, t), + secondary: blendRgb(from.fg.secondary, to.fg.secondary, t), + muted: blendRgb(from.fg.muted, to.fg.muted, t), + inverse: blendRgb(from.fg.inverse, to.fg.inverse, t), + }), + accent: Object.freeze({ + primary: blendRgb(from.accent.primary, to.accent.primary, t), + secondary: blendRgb(from.accent.secondary, to.accent.secondary, t), + tertiary: blendRgb(from.accent.tertiary, to.accent.tertiary, t), + }), + success: blendRgb(from.success, to.success, t), + warning: blendRgb(from.warning, to.warning, t), + error: blendRgb(from.error, to.error, t), + info: blendRgb(from.info, to.info, t), + focus: Object.freeze({ + ring: blendRgb(from.focus.ring, to.focus.ring, t), + bg: blendRgb(from.focus.bg, to.focus.bg, t), + }), + selected: Object.freeze({ + bg: blendRgb(from.selected.bg, to.selected.bg, t), + fg: blendRgb(from.selected.fg, to.selected.fg, t), + }), + disabled: Object.freeze({ + fg: blendRgb(from.disabled.fg, to.disabled.fg, t), + bg: blendRgb(from.disabled.bg, to.disabled.bg, t), + }), + diagnostic: Object.freeze({ + error: blendRgb(from.diagnostic.error, to.diagnostic.error, t), + warning: blendRgb(from.diagnostic.warning, to.diagnostic.warning, t), + info: blendRgb(from.diagnostic.info, to.diagnostic.info, t), + hint: blendRgb(from.diagnostic.hint, to.diagnostic.hint, t), + }), + border: Object.freeze({ + subtle: blendRgb(from.border.subtle, to.border.subtle, t), + default: blendRgb(from.border.default, to.border.default, t), + strong: blendRgb(from.border.strong, to.border.strong, t), + }), + }); +} + +function blendWidgetTokens(from: WidgetTokens, to: WidgetTokens, t: number): WidgetTokens { + return Object.freeze({ + syntax: Object.freeze({ + keyword: blendRgb(from.syntax.keyword, to.syntax.keyword, t), + type: blendRgb(from.syntax.type, to.syntax.type, t), + string: blendRgb(from.syntax.string, to.syntax.string, t), + number: blendRgb(from.syntax.number, to.syntax.number, t), + comment: blendRgb(from.syntax.comment, to.syntax.comment, t), + operator: blendRgb(from.syntax.operator, to.syntax.operator, t), + punctuation: blendRgb(from.syntax.punctuation, to.syntax.punctuation, t), + function: blendRgb(from.syntax.function, to.syntax.function, t), + variable: blendRgb(from.syntax.variable, to.syntax.variable, t), + cursorFg: blendRgb(from.syntax.cursorFg, to.syntax.cursorFg, t), + cursorBg: blendRgb(from.syntax.cursorBg, to.syntax.cursorBg, t), + }), + diff: Object.freeze({ + addBg: blendRgb(from.diff.addBg, to.diff.addBg, t), + deleteBg: blendRgb(from.diff.deleteBg, to.diff.deleteBg, t), + addFg: blendRgb(from.diff.addFg, to.diff.addFg, t), + deleteFg: blendRgb(from.diff.deleteFg, to.diff.deleteFg, t), + hunkHeader: blendRgb(from.diff.hunkHeader, to.diff.hunkHeader, t), + lineNumber: blendRgb(from.diff.lineNumber, to.diff.lineNumber, t), + border: blendRgb(from.diff.border, to.diff.border, t), + }), + logs: Object.freeze({ + trace: blendRgb(from.logs.trace, to.logs.trace, t), + debug: blendRgb(from.logs.debug, to.logs.debug, t), + info: blendRgb(from.logs.info, to.logs.info, t), + warn: blendRgb(from.logs.warn, to.logs.warn, t), + error: blendRgb(from.logs.error, to.logs.error, t), + }), + toast: Object.freeze({ + info: blendRgb(from.toast.info, to.toast.info, t), + success: blendRgb(from.toast.success, to.toast.success, t), + warning: blendRgb(from.toast.warning, to.toast.warning, t), + error: blendRgb(from.toast.error, to.toast.error, t), + }), + chart: Object.freeze({ + primary: blendRgb(from.chart.primary, to.chart.primary, t), + accent: blendRgb(from.chart.accent, to.chart.accent, t), + muted: blendRgb(from.chart.muted, to.chart.muted, t), + success: blendRgb(from.chart.success, to.chart.success, t), + warning: blendRgb(from.chart.warning, to.chart.warning, t), + danger: blendRgb(from.chart.danger, to.chart.danger, t), + }), + }); +} + +export function compileTheme(themeDefinition: ThemeDefinition): Theme { + const validated = validateTheme(themeDefinition); + const cached = compiledThemeCache.get(validated); + if (cached) return cached; + + const compiled = Object.freeze({ + definition: validated, + colors: buildColorIndex(validated), + spacing: buildSpacing(validated.spacing), + focusIndicator: Object.freeze({ ...validated.focusIndicator }) as FocusIndicatorTokens, + }); + compiledThemeCache.set(validated, compiled); + return compiled; +} + +export function blendTheme(from: Theme, to: Theme, t: number): Theme { + const clampedT = Math.max(0, Math.min(1, t)); + if (clampedT <= 0) return from; + if (clampedT >= 1) return to; + + const blended = validateTheme( + Object.freeze({ + name: to.definition.name, + colors: blendColorTokens(from.definition.colors, to.definition.colors, clampedT), + spacing: Object.freeze({ ...to.definition.spacing }), + focusIndicator: Object.freeze({ ...to.definition.focusIndicator }), + widget: blendWidgetTokens(from.definition.widget, to.definition.widget, clampedT), + }), + ); + return Object.freeze({ + definition: blended, + colors: buildColorIndex(blended), + spacing: buildSpacing(blended.spacing), + focusIndicator: Object.freeze({ ...blended.focusIndicator }) as FocusIndicatorTokens, + }); +} + +export function resolveColor(theme: Theme, color: string | number): number { if (typeof color !== "string") return color; - return theme.colors[color] ?? theme.colors.fg; + return theme.colors[color] ?? theme.colors.fg ?? theme.definition.colors.fg.primary; } export function resolveSpacing(theme: Theme, space: number): number { diff --git a/packages/core/src/theme/tokens.ts b/packages/core/src/theme/tokens.ts index 9583493e..37d60ba9 100644 --- a/packages/core/src/theme/tokens.ts +++ b/packages/core/src/theme/tokens.ts @@ -113,6 +113,80 @@ export type DiagnosticTokens = Readonly<{ hint: Rgb24; }>; +/** + * Syntax highlighting token set for code-oriented widgets. + */ +export type SyntaxTokens = Readonly<{ + keyword: Rgb24; + type: Rgb24; + string: Rgb24; + number: Rgb24; + comment: Rgb24; + operator: Rgb24; + punctuation: Rgb24; + function: Rgb24; + variable: Rgb24; + cursorFg: Rgb24; + cursorBg: Rgb24; +}>; + +/** + * Diff viewer token set. + */ +export type DiffTokens = Readonly<{ + addBg: Rgb24; + deleteBg: Rgb24; + addFg: Rgb24; + deleteFg: Rgb24; + hunkHeader: Rgb24; + lineNumber: Rgb24; + border: Rgb24; +}>; + +/** + * Log console level token set. + */ +export type LogsTokens = Readonly<{ + trace: Rgb24; + debug: Rgb24; + info: Rgb24; + warn: Rgb24; + error: Rgb24; +}>; + +/** + * Toast style token set. + */ +export type ToastTokens = Readonly<{ + info: Rgb24; + success: Rgb24; + warning: Rgb24; + error: Rgb24; +}>; + +/** + * Chart palette token set. + */ +export type ChartTokens = Readonly<{ + primary: Rgb24; + accent: Rgb24; + muted: Rgb24; + success: Rgb24; + warning: Rgb24; + danger: Rgb24; +}>; + +/** + * Extended widget-specific theme tokens. + */ +export type WidgetTokens = Readonly<{ + syntax: SyntaxTokens; + diff: DiffTokens; + logs: LogsTokens; + toast: ToastTokens; + chart: ChartTokens; +}>; + /** * Complete semantic color token set. */ @@ -177,16 +251,12 @@ export type ThemeDefinition = Readonly<{ name: string; /** Complete color token set */ colors: ColorTokens; - /** - * Optional spacing scale for forward compatibility. - * Added by createThemeDefinition for built-in presets. - */ - spacing?: ThemeSpacingTokens; - /** - * Optional focus indicator style tokens for forward compatibility. - * Added by createThemeDefinition for built-in presets. - */ - focusIndicator?: FocusIndicatorTokens; + /** Required spacing scale used by design-system recipes. */ + spacing: ThemeSpacingTokens; + /** Required default focus indicator styling. */ + focusIndicator: FocusIndicatorTokens; + /** Widget-specific palettes for advanced surfaces. */ + widget: WidgetTokens; }>; /** @@ -236,14 +306,81 @@ export function createColorTokens(tokens: ColorTokens): ColorTokens { }); } +function createWidgetTokens(tokens: WidgetTokens): WidgetTokens { + return Object.freeze({ + syntax: Object.freeze({ ...tokens.syntax }), + diff: Object.freeze({ ...tokens.diff }), + logs: Object.freeze({ ...tokens.logs }), + toast: Object.freeze({ ...tokens.toast }), + chart: Object.freeze({ ...tokens.chart }), + }); +} + +function createDefaultWidgetTokens(colors: ColorTokens): WidgetTokens { + return Object.freeze({ + syntax: Object.freeze({ + keyword: colors.accent.secondary, + type: colors.warning, + string: colors.success, + number: colors.warning, + comment: colors.fg.muted, + operator: colors.accent.primary, + punctuation: colors.fg.primary, + function: colors.accent.primary, + variable: colors.accent.tertiary, + cursorFg: colors.bg.base, + cursorBg: colors.accent.primary, + }), + diff: Object.freeze({ + addBg: colors.success, + deleteBg: colors.error, + addFg: colors.fg.inverse, + deleteFg: colors.fg.inverse, + hunkHeader: colors.info, + lineNumber: colors.fg.muted, + border: colors.border.default, + }), + logs: Object.freeze({ + trace: colors.fg.muted, + debug: colors.fg.secondary, + info: colors.fg.primary, + warn: colors.warning, + error: colors.error, + }), + toast: Object.freeze({ + info: colors.info, + success: colors.success, + warning: colors.warning, + error: colors.error, + }), + chart: Object.freeze({ + primary: colors.accent.primary, + accent: colors.info, + muted: colors.fg.muted, + success: colors.success, + warning: colors.warning, + danger: colors.error, + }), + }); +} + /** * Helper to create a complete theme definition. */ -export function createThemeDefinition(name: string, colors: ColorTokens): ThemeDefinition { +export function createThemeDefinition( + name: string, + colors: ColorTokens, + options: Readonly<{ + spacing?: ThemeSpacingTokens; + focusIndicator?: FocusIndicatorTokens; + widget?: WidgetTokens; + }> = {}, +): ThemeDefinition { return Object.freeze({ name, colors: createColorTokens(colors), - spacing: DEFAULT_THEME_SPACING, - focusIndicator: DEFAULT_FOCUS_INDICATOR, + spacing: Object.freeze({ ...(options.spacing ?? DEFAULT_THEME_SPACING) }), + focusIndicator: Object.freeze({ ...(options.focusIndicator ?? DEFAULT_FOCUS_INDICATOR) }), + widget: createWidgetTokens(options.widget ?? createDefaultWidgetTokens(colors)), }); } diff --git a/packages/core/src/theme/types.ts b/packages/core/src/theme/types.ts index 96536416..0e7945c4 100644 --- a/packages/core/src/theme/types.ts +++ b/packages/core/src/theme/types.ts @@ -1,4 +1,5 @@ import type { Rgb24 } from "../widgets/style.js"; +import type { FocusIndicatorTokens, ThemeDefinition } from "./tokens.js"; export type ThemeColors = Readonly<{ primary: Rgb24; @@ -11,12 +12,15 @@ export type ThemeColors = Readonly<{ bg: Rgb24; fg: Rgb24; border: Rgb24; + error: Rgb24; [key: string]: Rgb24; }>; export type ThemeSpacing = readonly number[]; export type Theme = Readonly<{ + definition: ThemeDefinition; colors: ThemeColors; spacing: ThemeSpacing; + focusIndicator: FocusIndicatorTokens; }>; diff --git a/packages/core/src/theme/validate.ts b/packages/core/src/theme/validate.ts index cfe4169f..e6102e4a 100644 --- a/packages/core/src/theme/validate.ts +++ b/packages/core/src/theme/validate.ts @@ -10,6 +10,7 @@ import type { ThemeDefinition } from "./tokens.js"; type UnknownRecord = Record; const REQUIRED_COLOR_PATHS = [ + "name", "colors.bg.base", "colors.bg.elevated", "colors.bg.overlay", @@ -40,6 +41,42 @@ const REQUIRED_COLOR_PATHS = [ "colors.border.strong", ] as const; +const REQUIRED_WIDGET_COLOR_PATHS = [ + "widget.syntax.keyword", + "widget.syntax.type", + "widget.syntax.string", + "widget.syntax.number", + "widget.syntax.comment", + "widget.syntax.operator", + "widget.syntax.punctuation", + "widget.syntax.function", + "widget.syntax.variable", + "widget.syntax.cursorFg", + "widget.syntax.cursorBg", + "widget.diff.addBg", + "widget.diff.deleteBg", + "widget.diff.addFg", + "widget.diff.deleteFg", + "widget.diff.hunkHeader", + "widget.diff.lineNumber", + "widget.diff.border", + "widget.logs.trace", + "widget.logs.debug", + "widget.logs.info", + "widget.logs.warn", + "widget.logs.error", + "widget.toast.info", + "widget.toast.success", + "widget.toast.warning", + "widget.toast.error", + "widget.chart.primary", + "widget.chart.accent", + "widget.chart.muted", + "widget.chart.success", + "widget.chart.warning", + "widget.chart.danger", +] as const; + const REQUIRED_SPACING_PATHS = [ "spacing.xs", "spacing.sm", @@ -53,6 +90,7 @@ const REQUIRED_FOCUS_STYLE_PATHS = ["focusIndicator.bold", "focusIndicator.under const REQUIRED_THEME_PATHS = [ ...REQUIRED_COLOR_PATHS, + ...REQUIRED_WIDGET_COLOR_PATHS, ...REQUIRED_SPACING_PATHS, ...REQUIRED_FOCUS_STYLE_PATHS, ] as const; @@ -91,6 +129,14 @@ function throwMissingPaths(theme: unknown): void { throw new Error(`Theme validation failed: missing required token path(s): ${missing.join(", ")}`); } +function validateName(path: string, value: unknown): void { + if (typeof value !== "string" || value.length === 0) { + throw new Error( + `Theme validation failed at ${path}: expected non-empty string (received ${formatValue(value)})`, + ); + } +} + function validateRgb(path: string, value: unknown): void { if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 0x00ffffff) { throw new Error( @@ -120,14 +166,18 @@ function validateFocusStyle(path: string, value: unknown): void { * * Throws on first invalid value with deterministic path-specific errors. * Missing required paths are reported together in deterministic order. - * Note: spacing/focusIndicator are optional on the type for backward compatibility - * with pre-hardening definitions, but this validator intentionally treats them as - * required for hardened theme contracts. */ export function validateTheme(theme: unknown): ThemeDefinition { throwMissingPaths(theme); + validateName("name", getPathValue(theme, "name")); + for (const path of REQUIRED_COLOR_PATHS) { + if (path === "name") continue; + validateRgb(path, getPathValue(theme, path)); + } + + for (const path of REQUIRED_WIDGET_COLOR_PATHS) { validateRgb(path, getPathValue(theme, path)); } diff --git a/packages/core/src/ui/__tests__/designSystem.renderer.test.ts b/packages/core/src/ui/__tests__/designSystem.renderer.test.ts index 9eb431ea..ac41e632 100644 --- a/packages/core/src/ui/__tests__/designSystem.renderer.test.ts +++ b/packages/core/src/ui/__tests__/designSystem.renderer.test.ts @@ -1,12 +1,11 @@ import * as assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createTestRenderer } from "../../testing/renderer.js"; -import { coerceToLegacyTheme } from "../../theme/interop.js"; import { darkTheme } from "../../theme/presets.js"; import type { VNode } from "../../widgets/types.js"; import { ui } from "../../widgets/ui.js"; -const theme = coerceToLegacyTheme(darkTheme); +const theme = darkTheme; const viewport = { cols: 40, rows: 6 }; function renderText(vnode: VNode): string { @@ -21,7 +20,7 @@ describe("design system rendering", () => { ui.button({ id: "btn", label: "Save", - style: { fg: theme.colors.fg }, + style: { fg: theme.colors.fg.primary }, }), ); const node = result.findById("btn"); @@ -74,7 +73,12 @@ describe("design system rendering", () => { const renderer = createTestRenderer({ viewport: { cols: 40, rows: 5 }, theme }); const result = renderer.render( ui.row({ height: 3, items: "stretch" }, [ - ui.input({ id: "name", value: "", placeholder: "Name", style: { fg: theme.colors.fg } }), + ui.input({ + id: "name", + value: "", + placeholder: "Name", + style: { fg: theme.colors.fg.primary }, + }), ]), ); const input = result.findById("name"); diff --git a/packages/core/src/ui/__tests__/themed.test.ts b/packages/core/src/ui/__tests__/themed.test.ts index a1b54056..bac0fef8 100644 --- a/packages/core/src/ui/__tests__/themed.test.ts +++ b/packages/core/src/ui/__tests__/themed.test.ts @@ -9,7 +9,9 @@ import type { LayoutTree } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; -import { createTheme } from "../../theme/theme.js"; +import { extendTheme } from "../../theme/extend.js"; +import { darkTheme } from "../../theme/presets.js"; +import { type Theme, compileTheme } from "../../theme/theme.js"; import type { TextStyle } from "../../widgets/style.js"; import type { VNode } from "../../widgets/types.js"; import { ui } from "../../widgets/ui.js"; @@ -59,7 +61,7 @@ function commitAndLayout(vnode: VNode): LayoutTree { function renderTextOps( vnode: VNode, - theme: ReturnType, + theme: Theme, ): readonly Readonly<{ text: string; style?: TextStyle }>[] { const committed = commitVNodeTree(null, vnode, { allocator: createInstanceIdAllocator(1) }); assert.equal(committed.ok, true, "commit should succeed"); @@ -108,11 +110,15 @@ describe("ui.themed", () => { }); test("applies theme override to subtree without leaking to siblings", () => { - const baseTheme = createTheme({ - colors: { - primary: (200 << 16) | (40 << 8) | 40, - }, - }); + const baseTheme = compileTheme( + extendTheme(darkTheme, { + colors: { + accent: { + primary: (200 << 16) | (40 << 8) | 40, + }, + }, + }), + ); const scopedPrimary = (30 << 16) | (210 << 8) | 40; const vnode = ui.column({}, [ diff --git a/packages/core/src/ui/recipes.ts b/packages/core/src/ui/recipes.ts index 1ec6f0c9..3405ac35 100644 --- a/packages/core/src/ui/recipes.ts +++ b/packages/core/src/ui/recipes.ts @@ -1402,10 +1402,12 @@ export function checkboxRecipe( indicator: { fg: selected ? selectedColor : colors.fg.secondary, bold: isFocused || large, + ...(isFocused ? { underline: true } : {}), }, label: { fg: colors.fg.primary, bold: isFocused || large, + ...(isFocused ? { underline: true } : {}), }, }; } diff --git a/packages/core/src/widgets/__tests__/accordion.test.ts b/packages/core/src/widgets/__tests__/accordion.test.ts index f9cd1f13..3ff363c2 100644 --- a/packages/core/src/widgets/__tests__/accordion.test.ts +++ b/packages/core/src/widgets/__tests__/accordion.test.ts @@ -139,7 +139,7 @@ describe("accordion vnode construction", () => { assert.equal(vnode.children.length, 2); }); - test("ui.accordion returns a composite wrapper vnode", () => { + test("ui.accordion returns a layout-transparent composite wrapper vnode", () => { const vnode = ui.accordion(baseProps); assert.equal(vnode.kind, "fragment"); }); diff --git a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts index 6487b51e..636f7bc3 100644 --- a/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts +++ b/packages/core/src/widgets/__tests__/basicWidgets.render.test.ts @@ -3,18 +3,14 @@ import { parseDrawTextCommands as parseDecodedDrawTextCommands, parseInternedStrings, } from "../../__tests__/drawlistDecode.js"; -import { - type DrawlistBuilder, - type Theme, - type VNode, - createDrawlistBuilder, - createTheme, -} from "../../index.js"; +import { type DrawlistBuilder, type VNode, createDrawlistBuilder } from "../../index.js"; import { layout } from "../../layout/layout.js"; import { renderToDrawlist } from "../../renderer/renderToDrawlist.js"; import { commitVNodeTree } from "../../runtime/commit.js"; import { createInstanceIdAllocator } from "../../runtime/instance.js"; import { defaultTheme } from "../../theme/defaultTheme.js"; +import { extendTheme } from "../../theme/extend.js"; +import { type Theme, compileTheme } from "../../theme/theme.js"; import { ui } from "../ui.js"; function u16(bytes: Uint8Array, off: number): number { @@ -293,7 +289,7 @@ describe("basic widgets render to drawlist", () => { }); test("design-system solid button label keeps filled background under text", () => { - const theme = createTheme(defaultTheme); + const theme = defaultTheme; const bytes = renderBytes( ui.button({ id: "scene-tab", @@ -345,7 +341,7 @@ describe("basic widgets render to drawlist", () => { style: { fg: (1 << 16) | (2 << 8) | 3 }, }), { cols: 24, rows: 3 }, - { theme: createTheme(defaultTheme) }, + { theme: defaultTheme }, ); const drawText = parseDrawTextCommands(bytes).find((cmd) => cmd.text.includes("━")); assert.ok(drawText, "expected drawText for filled progress glyphs"); @@ -523,11 +519,15 @@ describe("basic widgets render to drawlist", () => { }); test("link underlineColor theme token resolves on v3", () => { - const theme = createTheme({ - colors: { - "diagnostic.info": (1 << 16) | (2 << 8) | 3, - }, - }); + const theme = compileTheme( + extendTheme(defaultTheme.definition, { + colors: { + diagnostic: { + info: (1 << 16) | (2 << 8) | 3, + }, + }, + }), + ); const bytes = renderBytesV3( ui.link({ id: "docs-link", @@ -561,11 +561,15 @@ describe("basic widgets render to drawlist", () => { }); test("codeEditor diagnostics use curly underline + token color", () => { - const theme = createTheme({ - colors: { - "diagnostic.warning": (1 << 16) | (2 << 8) | 3, - }, - }); + const theme = compileTheme( + extendTheme(defaultTheme.definition, { + colors: { + diagnostic: { + warning: (1 << 16) | (2 << 8) | 3, + }, + }, + }), + ); const vnode = ui.codeEditor({ id: "editor", lines: ["warn"], @@ -594,13 +598,17 @@ describe("basic widgets render to drawlist", () => { }); test("codeEditor applies syntax token colors for mainstream language presets", () => { - const theme = createTheme({ - colors: { - "syntax.keyword": (10 << 16) | (20 << 8) | 30, - "syntax.function": (30 << 16) | (40 << 8) | 50, - "syntax.string": (60 << 16) | (70 << 8) | 80, - }, - }); + const theme = compileTheme( + extendTheme(defaultTheme.definition, { + widget: { + syntax: { + keyword: (10 << 16) | (20 << 8) | 30, + function: (30 << 16) | (40 << 8) | 50, + string: (60 << 16) | (70 << 8) | 80, + }, + }, + }), + ); const vnode = ui.codeEditor({ id: "editor", lines: ['func greet(name string) { return "ok"; }'], @@ -629,12 +637,16 @@ describe("basic widgets render to drawlist", () => { }); test("codeEditor draws a highlighted cursor cell for focused editor", () => { - const theme = createTheme({ - colors: { - "syntax.cursor.bg": (1 << 16) | (2 << 8) | 3, - "syntax.cursor.fg": (4 << 16) | (5 << 8) | 6, - }, - }); + const theme = compileTheme( + extendTheme(defaultTheme.definition, { + widget: { + syntax: { + cursorBg: (1 << 16) | (2 << 8) | 3, + cursorFg: (4 << 16) | (5 << 8) | 6, + }, + }, + }), + ); const vnode = ui.codeEditor({ id: "editor", lines: ["abc"], diff --git a/packages/core/src/widgets/__tests__/breadcrumb.test.ts b/packages/core/src/widgets/__tests__/breadcrumb.test.ts index 5f9d32b2..643639cc 100644 --- a/packages/core/src/widgets/__tests__/breadcrumb.test.ts +++ b/packages/core/src/widgets/__tests__/breadcrumb.test.ts @@ -105,7 +105,7 @@ describe("breadcrumb vnode construction", () => { } }); - test("ui.breadcrumb returns a composite wrapper vnode", () => { + test("ui.breadcrumb returns a layout-transparent composite wrapper vnode", () => { const vnode = ui.breadcrumb({ items: props.items }); assert.equal(vnode.kind, "fragment"); }); diff --git a/packages/core/src/widgets/__tests__/composition.animationOrchestration.test.ts b/packages/core/src/widgets/__tests__/composition.animationOrchestration.test.ts index 03a88a8b..43e91c63 100644 --- a/packages/core/src/widgets/__tests__/composition.animationOrchestration.test.ts +++ b/packages/core/src/widgets/__tests__/composition.animationOrchestration.test.ts @@ -124,29 +124,18 @@ describe("composition animation hooks - orchestration", () => { test("useParallel uses the latest onComplete callback without restarting", async () => { const h = createHarness(); const calls: string[] = []; + try { + let render = h.render((hooks) => + useParallel(hooks, [ + { + target: 8, + config: { duration: 80, easing: "linear", onComplete: () => calls.push("A") }, + }, + ]), + ); + h.runPending(render.pendingEffects); - let render = h.render((hooks) => - useParallel(hooks, [ - { - target: 8, - config: { duration: 80, easing: "linear", onComplete: () => calls.push("A") }, - }, - ]), - ); - h.runPending(render.pendingEffects); - - render = h.render((hooks) => - useParallel(hooks, [ - { - target: 8, - config: { duration: 80, easing: "linear", onComplete: () => calls.push("B") }, - }, - ]), - ); - h.runPending(render.pendingEffects); - - await waitFor(() => { - const next = h.render((hooks) => + render = h.render((hooks) => useParallel(hooks, [ { target: 8, @@ -154,112 +143,129 @@ describe("composition animation hooks - orchestration", () => { }, ]), ); - h.runPending(next.pendingEffects); - return calls.length >= 1; - }); - - assert.deepEqual(calls, ["B"]); - assert.equal(h.unmount(), true); + h.runPending(render.pendingEffects); + + await waitFor(() => { + const next = h.render((hooks) => + useParallel(hooks, [ + { + target: 8, + config: { duration: 80, easing: "linear", onComplete: () => calls.push("B") }, + }, + ]), + ); + h.runPending(next.pendingEffects); + return calls.length >= 1; + }); + + assert.deepEqual(calls, ["B"]); + } finally { + assert.equal(h.unmount(), true); + } }); test("useParallel playback pause and resume preserves elapsed progress", async () => { const h = createHarness(); - const running = [ - { - target: 10, - config: { duration: 140, easing: "linear" as const, playback: { paused: false } }, - }, - ] as const; - const paused = [ - { - target: 10, - config: { duration: 140, easing: "linear" as const, playback: { paused: true } }, - }, - ] as const; - - let render = h.render((hooks) => - useParallel(hooks, [{ target: 0, config: { duration: 140, easing: "linear" as const } }]), - ); - h.runPending(render.pendingEffects); - - render = h.render((hooks) => useParallel(hooks, running)); - h.runPending(render.pendingEffects); - - let pausedValue = 0; - await waitFor(() => { - const next = h.render((hooks) => useParallel(hooks, running)); - pausedValue = next.result[0]?.value ?? 0; - h.runPending(next.pendingEffects); - return pausedValue > 2 && pausedValue < 9; - }); - - render = h.render((hooks) => useParallel(hooks, paused)); - pausedValue = render.result[0]?.value ?? 0; - h.runPending(render.pendingEffects); - - await sleep(80); - render = h.render((hooks) => useParallel(hooks, paused)); - h.runPending(render.pendingEffects); - assert.ok(Math.abs((render.result[0]?.value ?? 0) - pausedValue) <= 0.1); - assert.equal(render.result[0]?.isAnimating, false); - - render = h.render((hooks) => useParallel(hooks, running)); - h.runPending(render.pendingEffects); - - await waitFor(() => { - const next = h.render((hooks) => useParallel(hooks, running)); - render = next; - h.runPending(next.pendingEffects); - return Math.abs((next.result[0]?.value ?? 0) - 10) <= 0.2; - }); + try { + const running = [ + { + target: 10, + config: { duration: 140, easing: "linear" as const, playback: { paused: false } }, + }, + ] as const; + const paused = [ + { + target: 10, + config: { duration: 140, easing: "linear" as const, playback: { paused: true } }, + }, + ] as const; - assert.equal(render.result[0]?.isAnimating, false); - assert.equal(h.unmount(), true); + let render = h.render((hooks) => + useParallel(hooks, [{ target: 0, config: { duration: 140, easing: "linear" as const } }]), + ); + h.runPending(render.pendingEffects); + + render = h.render((hooks) => useParallel(hooks, running)); + h.runPending(render.pendingEffects); + + let pausedValue = 0; + await waitFor(() => { + const next = h.render((hooks) => useParallel(hooks, running)); + pausedValue = next.result[0]?.value ?? 0; + h.runPending(next.pendingEffects); + return pausedValue > 2 && pausedValue < 9; + }); + + render = h.render((hooks) => useParallel(hooks, paused)); + pausedValue = render.result[0]?.value ?? 0; + h.runPending(render.pendingEffects); + + await sleep(80); + render = h.render((hooks) => useParallel(hooks, paused)); + h.runPending(render.pendingEffects); + assert.ok(Math.abs((render.result[0]?.value ?? 0) - pausedValue) <= 0.1); + assert.equal(render.result[0]?.isAnimating, false); + + render = h.render((hooks) => useParallel(hooks, running)); + h.runPending(render.pendingEffects); + + await waitFor(() => { + const next = h.render((hooks) => useParallel(hooks, running)); + render = next; + h.runPending(next.pendingEffects); + return Math.abs((next.result[0]?.value ?? 0) - 10) <= 0.2; + }); + + assert.equal(render.result[0]?.isAnimating, false); + } finally { + assert.equal(h.unmount(), true); + } }); test("useParallel reapplies delay when delay changes mid-flight", async () => { const h = createHarness(); - const immediate = [ - { target: 10, config: { duration: 140, easing: "linear" as const } }, - ] as const; - const delayed = [ - { target: 10, config: { duration: 140, delay: 90, easing: "linear" as const } }, - ] as const; - - let render = h.render((hooks) => - useParallel(hooks, [{ target: 0, config: { duration: 140, easing: "linear" as const } }]), - ); - h.runPending(render.pendingEffects); - - render = h.render((hooks) => useParallel(hooks, immediate)); - h.runPending(render.pendingEffects); - - let midValue = 0; - await waitFor(() => { - const next = h.render((hooks) => useParallel(hooks, immediate)); - midValue = next.result[0]?.value ?? 0; - h.runPending(next.pendingEffects); - return midValue > 2 && midValue < 9; - }); - - render = h.render((hooks) => useParallel(hooks, delayed)); - h.runPending(render.pendingEffects); - - await sleep(60); - render = h.render((hooks) => useParallel(hooks, delayed)); - h.runPending(render.pendingEffects); - assert.ok(Math.abs((render.result[0]?.value ?? 0) - midValue) <= 0.2); - - await waitFor(() => { - const next = h.render((hooks) => useParallel(hooks, delayed)); - render = next; - h.runPending(next.pendingEffects); - return (next.result[0]?.value ?? 0) > midValue + 0.25; - }); - - assert.equal(h.unmount(), true); + try { + const immediate = [ + { target: 10, config: { duration: 140, easing: "linear" as const } }, + ] as const; + const delayed = [ + { target: 10, config: { duration: 140, delay: 90, easing: "linear" as const } }, + ] as const; + + let render = h.render((hooks) => + useParallel(hooks, [{ target: 0, config: { duration: 140, easing: "linear" as const } }]), + ); + h.runPending(render.pendingEffects); + + render = h.render((hooks) => useParallel(hooks, immediate)); + h.runPending(render.pendingEffects); + + let midValue = 0; + await waitFor(() => { + const next = h.render((hooks) => useParallel(hooks, immediate)); + midValue = next.result[0]?.value ?? 0; + h.runPending(next.pendingEffects); + return midValue > 2 && midValue < 9; + }); + + render = h.render((hooks) => useParallel(hooks, delayed)); + h.runPending(render.pendingEffects); + + await sleep(60); + render = h.render((hooks) => useParallel(hooks, delayed)); + h.runPending(render.pendingEffects); + assert.ok(Math.abs((render.result[0]?.value ?? 0) - midValue) <= 0.2); + + await waitFor(() => { + const next = h.render((hooks) => useParallel(hooks, delayed)); + render = next; + h.runPending(next.pendingEffects); + return (next.result[0]?.value ?? 0) > midValue + 0.25; + }); + } finally { + assert.equal(h.unmount(), true); + } }); - test("useChain advances step-by-step and reports completion", async () => { const h = createHarness(); const steps = [ @@ -311,31 +317,19 @@ describe("composition animation hooks - orchestration", () => { test("useChain uses latest step callbacks while preserving step order", async () => { const h = createHarness(); const calls: string[] = []; + try { + let render = h.render((hooks) => + useChain(hooks, [ + { + target: 4, + config: { duration: 50, easing: "linear", onComplete: () => calls.push("A") }, + }, + { target: 8, config: { duration: 50, easing: "linear" } }, + ]), + ); + h.runPending(render.pendingEffects); - let render = h.render((hooks) => - useChain(hooks, [ - { - target: 4, - config: { duration: 50, easing: "linear", onComplete: () => calls.push("A") }, - }, - { target: 8, config: { duration: 50, easing: "linear" } }, - ]), - ); - h.runPending(render.pendingEffects); - - render = h.render((hooks) => - useChain(hooks, [ - { - target: 4, - config: { duration: 50, easing: "linear", onComplete: () => calls.push("B") }, - }, - { target: 8, config: { duration: 50, easing: "linear" } }, - ]), - ); - h.runPending(render.pendingEffects); - - await waitFor(() => { - const next = h.render((hooks) => + render = h.render((hooks) => useChain(hooks, [ { target: 4, @@ -344,67 +338,84 @@ describe("composition animation hooks - orchestration", () => { { target: 8, config: { duration: 50, easing: "linear" } }, ]), ); - h.runPending(next.pendingEffects); - return calls.length >= 1 && next.result.currentStep >= 1; - }); - - assert.deepEqual(calls, ["B"]); - assert.equal(h.unmount(), true); + h.runPending(render.pendingEffects); + + await waitFor(() => { + const next = h.render((hooks) => + useChain(hooks, [ + { + target: 4, + config: { duration: 50, easing: "linear", onComplete: () => calls.push("B") }, + }, + { target: 8, config: { duration: 50, easing: "linear" } }, + ]), + ); + h.runPending(next.pendingEffects); + return calls.length >= 1 && next.result.currentStep >= 1; + }); + + assert.deepEqual(calls, ["B"]); + } finally { + assert.equal(h.unmount(), true); + } }); test("useChain playback changes do not reset the active step", async () => { const h = createHarness(); - const running = [ - { - target: 4, - config: { duration: 90, easing: "linear" as const, playback: { paused: false } }, - }, - { - target: 8, - config: { duration: 90, easing: "linear" as const, playback: { paused: false } }, - }, - ] as const; - const paused = [ - { - target: 4, - config: { duration: 90, easing: "linear" as const, playback: { paused: true } }, - }, - { - target: 8, - config: { duration: 90, easing: "linear" as const, playback: { paused: false } }, - }, - ] as const; - - let render = h.render((hooks) => useChain(hooks, running)); - h.runPending(render.pendingEffects); - - await waitFor(() => { - const next = h.render((hooks) => useChain(hooks, running)); - render = next; - h.runPending(next.pendingEffects); - return (next.result.value ?? 0) > 1; - }); - - render = h.render((hooks) => useChain(hooks, paused)); - h.runPending(render.pendingEffects); - assert.equal(render.result.currentStep, 0); - - await sleep(80); - render = h.render((hooks) => useChain(hooks, paused)); - h.runPending(render.pendingEffects); - assert.equal(render.result.currentStep, 0); - - render = h.render((hooks) => useChain(hooks, running)); - h.runPending(render.pendingEffects); - - await waitFor(() => { - const next = h.render((hooks) => useChain(hooks, running)); - render = next; - h.runPending(next.pendingEffects); - return next.result.currentStep >= 1; - }); - - assert.equal(render.result.currentStep >= 1, true); - assert.equal(h.unmount(), true); + try { + const running = [ + { + target: 4, + config: { duration: 90, easing: "linear" as const, playback: { paused: false } }, + }, + { + target: 8, + config: { duration: 90, easing: "linear" as const, playback: { paused: false } }, + }, + ] as const; + const paused = [ + { + target: 4, + config: { duration: 90, easing: "linear" as const, playback: { paused: true } }, + }, + { + target: 8, + config: { duration: 90, easing: "linear" as const, playback: { paused: false } }, + }, + ] as const; + + let render = h.render((hooks) => useChain(hooks, running)); + h.runPending(render.pendingEffects); + + await waitFor(() => { + const next = h.render((hooks) => useChain(hooks, running)); + render = next; + h.runPending(next.pendingEffects); + return (next.result.value ?? 0) > 1; + }); + + render = h.render((hooks) => useChain(hooks, paused)); + h.runPending(render.pendingEffects); + assert.equal(render.result.currentStep, 0); + + await sleep(80); + render = h.render((hooks) => useChain(hooks, paused)); + h.runPending(render.pendingEffects); + assert.equal(render.result.currentStep, 0); + + render = h.render((hooks) => useChain(hooks, running)); + h.runPending(render.pendingEffects); + + await waitFor(() => { + const next = h.render((hooks) => useChain(hooks, running)); + render = next; + h.runPending(next.pendingEffects); + return next.result.currentStep >= 1; + }); + + assert.equal(render.result.currentStep >= 1, true); + } finally { + assert.equal(h.unmount(), true); + } }); }); diff --git a/packages/core/src/widgets/__tests__/composition.animationPlayback.test.ts b/packages/core/src/widgets/__tests__/composition.animationPlayback.test.ts index 5f7943cf..d321a348 100644 --- a/packages/core/src/widgets/__tests__/composition.animationPlayback.test.ts +++ b/packages/core/src/widgets/__tests__/composition.animationPlayback.test.ts @@ -89,8 +89,8 @@ describe("composition animation hooks - playback controls", () => { render = h.render((hooks) => useTransition(hooks, 10, { duration: 200, easing: "linear", playback: { paused: true } }), ); - const pausedValue = render.result; h.runPending(render.pendingEffects); + const pausedValue = render.result; await sleep(80); render = h.render((hooks) => diff --git a/packages/core/src/widgets/__tests__/composition.useAnimatedValue.test.ts b/packages/core/src/widgets/__tests__/composition.useAnimatedValue.test.ts index 0aa3756b..06de1e97 100644 --- a/packages/core/src/widgets/__tests__/composition.useAnimatedValue.test.ts +++ b/packages/core/src/widgets/__tests__/composition.useAnimatedValue.test.ts @@ -328,7 +328,6 @@ describe("composition animation hooks - useAnimatedValue", () => { assert.equal(h.unmount(), true); }); - test("onComplete fires on settlement", async () => { const h = createHarness(); let transitionCompleteCount = 0; diff --git a/packages/core/src/widgets/__tests__/graphics.golden.test.ts b/packages/core/src/widgets/__tests__/graphics.golden.test.ts index 2a043e6d..ac0cb673 100644 --- a/packages/core/src/widgets/__tests__/graphics.golden.test.ts +++ b/packages/core/src/widgets/__tests__/graphics.golden.test.ts @@ -314,7 +314,7 @@ describe("graphics/widgets/style (locked) - zrdl-v1 graphics fixtures", () => { bold: true, underline: true, underlineStyle: "curly", - underlineColor: "#ff3366", + underlineColor: packRgb(0xff, 0x33, 0x66), }, }, { text: " -> " }, @@ -322,7 +322,7 @@ describe("graphics/widgets/style (locked) - zrdl-v1 graphics fixtures", () => { text: "warn", style: { underlineStyle: "dashed", - underlineColor: (0 << 16) | (170 << 8) | 255, + underlineColor: packRgb(0, 170, 255), }, }, ]), @@ -345,7 +345,7 @@ describe("graphics/widgets/style (locked) - zrdl-v1 graphics fixtures", () => { const thirdDecoded = decodeStyleV3(thirdSegmentReserved, thirdSegmentUnderlineRgb); assert.deepEqual(firstDecoded, { underlineStyle: 3, - underlineColorRgb: 0xffffff, + underlineColorRgb: 0xff3366, }); assert.deepEqual(thirdDecoded, { underlineStyle: 5, diff --git a/packages/core/src/widgets/__tests__/modal.focus.test.ts b/packages/core/src/widgets/__tests__/modal.focus.test.ts index 1d6e0144..a92e2f0f 100644 --- a/packages/core/src/widgets/__tests__/modal.focus.test.ts +++ b/packages/core/src/widgets/__tests__/modal.focus.test.ts @@ -158,7 +158,7 @@ describe("modal.focus - layer escape routing", () => { assert.deepEqual(closed, []); }); - test("swallows onClose callback errors and still consumes", () => { + test("callback errors do not report a successful close", () => { const result = routeLayerEscape(keyEvent(ZR_KEY_ESCAPE), { layerStack: ["modal"], closeOnEscape: new Map([["modal", true]]), diff --git a/packages/core/src/widgets/__tests__/pagination.test.ts b/packages/core/src/widgets/__tests__/pagination.test.ts index e0eea22f..e44b7959 100644 --- a/packages/core/src/widgets/__tests__/pagination.test.ts +++ b/packages/core/src/widgets/__tests__/pagination.test.ts @@ -138,7 +138,7 @@ describe("pagination ids and vnode", () => { assert.equal(ids.includes(getPaginationControlId("pages", "last")), true); }); - test("ui.pagination returns a composite wrapper vnode", () => { + test("ui.pagination returns a layout-transparent composite wrapper vnode", () => { const vnode = ui.pagination({ id: "pages", page: 1, diff --git a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts index 456e08ab..43cca999 100644 --- a/packages/core/src/widgets/__tests__/renderer.regressions.test.ts +++ b/packages/core/src/widgets/__tests__/renderer.regressions.test.ts @@ -165,6 +165,8 @@ function renderBytes( describe("renderer regressions", () => { const noop = (..._args: readonly unknown[]) => undefined; const ATTR_INVERSE = 1 << 3; + const ATTR_BOLD = 1 << 0; + const ATTR_UNDERLINE = 1 << 2; test("box shadow renders shade glyphs when enabled", () => { const withShadow = parseInternedStrings( @@ -222,16 +224,20 @@ describe("renderer regressions", () => { selectionMode: "none", } as const; - const withStripedOpcodes = parseOpcodes( + const withStripedStyles = parseCommandStyles( renderBytes(ui.table({ ...tableProps, stripedRows: true }), { cols: 40, rows: 8 }), ); - const withoutStripedOpcodes = parseOpcodes( + const withoutStripedStyles = parseCommandStyles( renderBytes(ui.table({ ...tableProps, stripedRows: false }), { cols: 40, rows: 8 }), ); - const withFillCount = withStripedOpcodes.filter((op) => op === 2).length; - const withoutFillCount = withoutStripedOpcodes.filter((op) => op === 2).length; - assert.equal(withFillCount > withoutFillCount, true); + const withStripedBgs = new Set( + withStripedStyles.filter((style) => style.opcode === 2).map((style) => style.bg), + ); + const withoutStripedBgs = new Set( + withoutStripedStyles.filter((style) => style.opcode === 2).map((style) => style.bg), + ); + assert.equal(withStripedBgs.size > withoutStripedBgs.size, true); }); test("table single-selection suppresses focused inverse style when focused row is not selected", () => { @@ -260,7 +266,7 @@ describe("renderer regressions", () => { assert.equal(hasInverse, false); }); - test("table keeps focused inverse style when focused row is selected", () => { + test("table keeps focused emphasis style when focused row is selected", () => { const tableStore = createTableStateStore(); tableStore.set("tbl-single-active-selected", { focusedRowIndex: 1 }); @@ -282,8 +288,16 @@ describe("renderer regressions", () => { ); const styles = parseCommandStyles(bytes); - const hasInverse = styles.some((s) => (s.attrs & ATTR_INVERSE) !== 0); - assert.equal(hasInverse, true); + const focusedSelectedRow = styles.find( + (style) => + style.opcode === 3 && + style.bg !== 0 && + (style.attrs & ATTR_BOLD) !== 0 && + (style.attrs & ATTR_UNDERLINE) !== 0, + ); + assert.ok(focusedSelectedRow); + if (!focusedSelectedRow) return; + assert.equal((focusedSelectedRow.attrs & ATTR_INVERSE) !== 0, false); }); test("table column overflow policies render ellipsis, middle, and clip deterministically", () => { diff --git a/packages/core/src/widgets/__tests__/tabs.test.ts b/packages/core/src/widgets/__tests__/tabs.test.ts index 04e1ebe6..1e20f5aa 100644 --- a/packages/core/src/widgets/__tests__/tabs.test.ts +++ b/packages/core/src/widgets/__tests__/tabs.test.ts @@ -157,7 +157,7 @@ describe("tabs vnode construction", () => { assert.equal(vnode.children.length, 2); }); - test("ui.tabs returns a composite wrapper vnode", () => { + test("ui.tabs returns a layout-transparent composite wrapper vnode", () => { const vnode = ui.tabs(props); assert.equal(vnode.kind, "fragment"); }); diff --git a/packages/core/src/widgets/__tests__/vnode.prop-validation.test.ts b/packages/core/src/widgets/__tests__/vnode.prop-validation.test.ts index d25cf02a..e2d33a1e 100644 --- a/packages/core/src/widgets/__tests__/vnode.prop-validation.test.ts +++ b/packages/core/src/widgets/__tests__/vnode.prop-validation.test.ts @@ -71,6 +71,7 @@ describe("vnode interactive prop validation - button/input", () => { id: "query", value: "", disabled: false, + readOnly: false, multiline: false, rows: 1, wordWrap: false, diff --git a/packages/core/src/widgets/composition.ts b/packages/core/src/widgets/composition.ts index fcf89d41..d5e8c426 100644 --- a/packages/core/src/widgets/composition.ts +++ b/packages/core/src/widgets/composition.ts @@ -15,6 +15,7 @@ */ import type { ResponsiveViewportSnapshot } from "../layout/responsive.js"; +import { defaultTheme } from "../theme/defaultTheme.js"; import type { ColorTokens } from "../theme/tokens.js"; import type { VNode } from "./types.js"; @@ -119,7 +120,7 @@ export type WidgetContext = Readonly<{ /** * Read the currently-resolved semantic color tokens for this render. */ - useTheme: () => ColorTokens | null; + useTheme: () => ColorTokens; /** * Read current viewport size and responsive breakpoint. @@ -367,7 +368,7 @@ export function createWidgetContext( appState: State, viewport: ResponsiveViewportSnapshot, onInvalidate: () => void, - colorTokens: ColorTokens | null = null, + colorTokens: ColorTokens = defaultTheme.definition.colors, ): WidgetContext { return Object.freeze({ id: (suffix: string) => scopedId(widgetKey, instanceIndex, suffix), diff --git a/packages/core/src/widgets/diffViewer.ts b/packages/core/src/widgets/diffViewer.ts index 4af8ebeb..c453e56a 100644 --- a/packages/core/src/widgets/diffViewer.ts +++ b/packages/core/src/widgets/diffViewer.ts @@ -231,10 +231,10 @@ export function getHunkScrollPosition(hunkIndex: number, hunks: readonly DiffHun const WIDGET_PALETTE = defaultTheme.colors; export const DIFF_COLORS = Object.freeze({ - addBg: WIDGET_PALETTE["widget.diff.add.bg"] ?? WIDGET_PALETTE.success, - deleteBg: WIDGET_PALETTE["widget.diff.delete.bg"] ?? WIDGET_PALETTE.danger, - addFg: WIDGET_PALETTE["widget.diff.add.fg"] ?? WIDGET_PALETTE.fg, - deleteFg: WIDGET_PALETTE["widget.diff.delete.fg"] ?? WIDGET_PALETTE.fg, + addBg: WIDGET_PALETTE["widget.diff.addBg"] ?? WIDGET_PALETTE.success, + deleteBg: WIDGET_PALETTE["widget.diff.deleteBg"] ?? WIDGET_PALETTE.danger, + addFg: WIDGET_PALETTE["widget.diff.addFg"] ?? WIDGET_PALETTE.fg, + deleteFg: WIDGET_PALETTE["widget.diff.deleteFg"] ?? WIDGET_PALETTE.fg, hunkHeader: WIDGET_PALETTE["widget.diff.hunkHeader"] ?? WIDGET_PALETTE.info, lineNumber: WIDGET_PALETTE["widget.diff.lineNumber"] ?? WIDGET_PALETTE.muted, border: WIDGET_PALETTE["widget.diff.border"] ?? WIDGET_PALETTE.border, diff --git a/packages/core/src/widgets/hooks/data.ts b/packages/core/src/widgets/hooks/data.ts index b0292767..ef4bb89e 100644 --- a/packages/core/src/widgets/hooks/data.ts +++ b/packages/core/src/widgets/hooks/data.ts @@ -263,7 +263,7 @@ const MIN_STREAM_RECONNECT_MS = 10; function normalizeReconnectDelayMs(value: number | undefined, fallback: number): number { const normalized = normalizeNonNegativeInteger(value, fallback); - return normalized <= 0 ? MIN_STREAM_RECONNECT_MS : normalized; + return Math.max(MIN_STREAM_RECONNECT_MS, normalized); } type EventSourceCtor = new ( diff --git a/packages/core/src/widgets/logsConsole.ts b/packages/core/src/widgets/logsConsole.ts index ebb57276..f087ce7f 100644 --- a/packages/core/src/widgets/logsConsole.ts +++ b/packages/core/src/widgets/logsConsole.ts @@ -211,11 +211,11 @@ export function formatCost(costCents: number): string { const WIDGET_PALETTE = defaultTheme.colors; export const LEVEL_COLORS: Record = { - trace: WIDGET_PALETTE["widget.logs.level.trace"] ?? WIDGET_PALETTE.muted, - debug: WIDGET_PALETTE["widget.logs.level.debug"] ?? WIDGET_PALETTE.secondary, - info: WIDGET_PALETTE["widget.logs.level.info"] ?? WIDGET_PALETTE.fg, - warn: WIDGET_PALETTE["widget.logs.level.warn"] ?? WIDGET_PALETTE.warning, - error: WIDGET_PALETTE["widget.logs.level.error"] ?? WIDGET_PALETTE.danger, + trace: WIDGET_PALETTE["widget.logs.trace"] ?? WIDGET_PALETTE.muted, + debug: WIDGET_PALETTE["widget.logs.debug"] ?? WIDGET_PALETTE.secondary, + info: WIDGET_PALETTE["widget.logs.info"] ?? WIDGET_PALETTE.fg, + warn: WIDGET_PALETTE["widget.logs.warn"] ?? WIDGET_PALETTE.warning, + error: WIDGET_PALETTE["widget.logs.error"] ?? WIDGET_PALETTE.danger, }; /** Level priority for filtering. */ diff --git a/packages/core/src/widgets/types.ts b/packages/core/src/widgets/types.ts index 599c413a..7575e3cf 100644 --- a/packages/core/src/widgets/types.ts +++ b/packages/core/src/widgets/types.ts @@ -29,7 +29,6 @@ import type { } from "../layout/types.js"; import type { InstanceId } from "../runtime/instance.js"; import type { ThemeOverrides } from "../theme/extend.js"; -import type { Theme } from "../theme/theme.js"; import type { ThemeDefinition } from "../theme/tokens.js"; import type { WidgetSize, WidgetTone, WidgetVariant } from "../ui/designTokens.js"; import type { TextStyle } from "./style.js"; @@ -73,21 +72,7 @@ export type SpacingProps = Readonly<{ ml?: SpacingValue; }>; -type DeepPartial = { - [K in keyof T]?: T[K] extends readonly (infer U)[] - ? readonly U[] - : T[K] extends object - ? DeepPartial - : T[K]; -}; - -export type ScopedThemeOverride = - | Theme - | ThemeDefinition - | Readonly<{ - colors?: DeepPartial | DeepPartial; - spacing?: Theme["spacing"]; - }>; +export type ScopedThemeOverride = ThemeDefinition | ThemeOverrides; export type ThemedProps = Readonly<{ key?: string; @@ -95,6 +80,10 @@ export type ThemedProps = Readonly<{ theme: ThemeOverrides; }>; +export type FragmentProps = Readonly<{ + key?: string; +}>; + type DisplayableProps = Readonly<{ /** Conditional layout visibility. false (or expr <= 0) hides this node. */ display?: DisplayConstraint; @@ -314,6 +303,7 @@ export type GridProps = Readonly< gap?: number; rowGap?: number; columnGap?: number; + theme?: ScopedThemeOverride; transition?: TransitionSpec; exitTransition?: TransitionSpec; } & LayoutConstraints @@ -2426,6 +2416,7 @@ export type TreeProps = Readonly<{ export type VNode = | Readonly<{ kind: "text"; text: string; props: TextProps }> + | Readonly<{ kind: "fragment"; props: FragmentProps; children: readonly VNode[] }> | Readonly<{ kind: "box"; props: BoxProps; children: readonly VNode[] }> | Readonly<{ kind: "fragment"; props: Readonly<{ key?: string }>; children: readonly VNode[] }> | Readonly<{ kind: "row"; props: RowProps; children: readonly VNode[] }> diff --git a/packages/core/src/widgets/ui.ts b/packages/core/src/widgets/ui.ts index da89d60d..4ac2f4fc 100644 --- a/packages/core/src/widgets/ui.ts +++ b/packages/core/src/widgets/ui.ts @@ -16,7 +16,6 @@ import { routerTabs as buildRouterTabs, } from "../router/helpers.js"; import type { RouteDefinition, RouterApi } from "../router/types.js"; -import type { ThemeOverrides } from "../theme/extend.js"; import { createAccordionWidgetVNode } from "./accordion.js"; import { createBreadcrumbWidgetVNode } from "./breadcrumb.js"; import { createPaginationWidgetVNode } from "./pagination.js"; @@ -77,6 +76,7 @@ import type { RichTextSpan, RowProps, ScatterProps, + ScopedThemeOverride, SelectProps, SidebarOptions, SkeletonProps, @@ -245,7 +245,7 @@ function column(props: ColumnProps = {}, children: readonly UiChild[] = []): VNo }; } -function themed(themeOverride: ThemeOverrides, children: readonly UiChild[] = []): VNode { +function themed(themeOverride: ScopedThemeOverride, children: readonly UiChild[] = []): VNode { return { kind: "themed", props: { theme: themeOverride }, children: filterChildren(children) }; } diff --git a/packages/ink-compat/src/runtime/createInkRenderer.ts b/packages/ink-compat/src/runtime/createInkRenderer.ts index d1504ce7..7e0edcc3 100644 --- a/packages/ink-compat/src/runtime/createInkRenderer.ts +++ b/packages/ink-compat/src/runtime/createInkRenderer.ts @@ -10,9 +10,7 @@ import { type DrawlistBuildResult, type DrawlistBuilder, type TextStyle, - type Theme, type VNode, - defaultTheme as coreDefaultTheme, measureTextCells, } from "@rezi-ui/core"; @@ -23,9 +21,11 @@ import { type InstanceIdAllocator, type LayoutTree, type RuntimeInstance, + type Theme, collectSelfDirtyInstanceIds, commitVNodeTree, computeDirtyLayoutSet, + defaultTheme as coreDefaultTheme, createInstanceIdAllocator, instanceDirtySetToVNodeDirtySet, layout, diff --git a/packages/node/src/__tests__/config_guards.test.ts b/packages/node/src/__tests__/config_guards.test.ts index 420c4284..30dd9904 100644 --- a/packages/node/src/__tests__/config_guards.test.ts +++ b/packages/node/src/__tests__/config_guards.test.ts @@ -3,7 +3,14 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { BACKEND_DRAWLIST_VERSION_MARKER, createApp, rgb, ui } from "@rezi-ui/core"; +import { + BACKEND_DRAWLIST_VERSION_MARKER, + createApp, + darkTheme, + extendTheme, + rgb, + ui, +} from "@rezi-ui/core"; import { ZrUiError } from "@rezi-ui/core"; import { selectNodeBackendExecutionMode } from "../backend/nodeBackend.js"; import { createNodeApp, createNodeBackend } from "../index.js"; @@ -301,25 +308,37 @@ test("createNodeApp exposes isNoColor=true when NO_COLOR is present", () => { withNoColor("1", () => { const app = createNodeApp({ initialState: { value: 0 }, - theme: { + theme: extendTheme(darkTheme, { + name: "no-color-test", colors: { - primary: rgb(255, 0, 0), - secondary: rgb(0, 255, 0), + accent: { + primary: rgb(255, 0, 0), + secondary: rgb(0, 255, 0), + tertiary: rgb(255, 0, 255), + }, success: rgb(0, 0, 255), - danger: rgb(255, 0, 255), + error: rgb(255, 0, 255), warning: rgb(255, 255, 0), info: rgb(0, 255, 255), - muted: rgb(120, 120, 120), - bg: rgb(10, 10, 10), - fg: rgb(240, 240, 240), - border: rgb(64, 64, 64), - "diagnostic.error": rgb(255, 90, 90), - "diagnostic.warning": rgb(255, 200, 90), - "diagnostic.info": rgb(90, 180, 255), - "diagnostic.hint": rgb(140, 255, 120), + bg: { + base: rgb(10, 10, 10), + }, + fg: { + primary: rgb(240, 240, 240), + muted: rgb(120, 120, 120), + }, + border: { + default: rgb(64, 64, 64), + }, + diagnostic: { + error: rgb(255, 90, 90), + warning: rgb(255, 200, 90), + info: rgb(90, 180, 255), + hint: rgb(140, 255, 120), + }, }, - spacing: [0, 1, 2, 4, 8, 16], - }, + spacing: { xs: 1, sm: 2, md: 4, lg: 8, xl: 16, "2xl": 24 }, + }), }); assert.equal(app.isNoColor, true); app.dispose(); diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index c2f7d858..6c59ba19 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -2,10 +2,9 @@ import { type App, type AppConfig, type RouteDefinition, - type Theme, type ThemeDefinition, createApp, - defaultTheme, + darkTheme, setDefaultTailSourceFactory, } from "@rezi-ui/core"; import { @@ -60,7 +59,7 @@ export type CreateNodeAppOptions = Readonly<{ routes?: readonly RouteDefinition[]; initialRoute?: string; config?: NodeAppConfig; - theme?: Theme | ThemeDefinition; + theme?: ThemeDefinition; /** * Development-only hot state-preserving reload wiring. * @@ -85,88 +84,91 @@ export type NodeApp = App & type ProcessEnv = Readonly>; -type ThemeSpacingTokensLike = Readonly<{ - xs?: unknown; - sm?: unknown; - md?: unknown; - lg?: unknown; - xl?: unknown; - "2xl"?: unknown; -}>; - -function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && Number.isFinite(value); -} - -function isRgb24(value: unknown): value is number { - return isFiniteNumber(value) && value >= 0 && value <= 0x00ff_ffff; -} - -function isSpacingToken(value: unknown): value is number { - return isFiniteNumber(value) && Number.isInteger(value) && value >= 0; -} - -function readLegacyThemeColors(theme: Theme | ThemeDefinition | undefined): Theme["colors"] | null { - if (!theme || typeof theme !== "object") return null; - const colors = (theme as { colors?: unknown }).colors; - if (!colors || typeof colors !== "object") return null; - const candidate = colors as { fg?: unknown; bg?: unknown } & Record; - if (!isRgb24(candidate.fg) || !isRgb24(candidate.bg)) return null; - return candidate as Theme["colors"]; -} - -function readThemeSpacing(theme: Theme | ThemeDefinition | undefined): Theme["spacing"] { - if (!theme || typeof theme !== "object") return defaultTheme.spacing; - const spacing = (theme as { spacing?: unknown }).spacing; - if (Array.isArray(spacing)) { - for (const value of spacing) { - if (!isFiniteNumber(value)) return defaultTheme.spacing; - } - return Object.freeze([...spacing]); - } - if (spacing && typeof spacing === "object") { - const tokens = spacing as ThemeSpacingTokensLike; - const xs = tokens.xs; - const sm = tokens.sm; - const md = tokens.md; - const lg = tokens.lg; - const xl = tokens.xl; - const x2xl = tokens["2xl"]; - if ( - isSpacingToken(xs) && - isSpacingToken(sm) && - isSpacingToken(md) && - isSpacingToken(lg) && - isSpacingToken(xl) && - isSpacingToken(x2xl) - ) { - return Object.freeze([0, xs, sm, md, lg, xl, x2xl]); - } - } - return defaultTheme.spacing; -} - -function createNoColorTheme(theme: Theme | ThemeDefinition | undefined): Theme { - const baseColors = readLegacyThemeColors(theme) ?? defaultTheme.colors; - const spacing = readThemeSpacing(theme); - const mono = baseColors.fg; +function createNoColorTheme(theme: ThemeDefinition | undefined): ThemeDefinition { + const base = theme ?? darkTheme; + const mono = base.colors.fg.primary; return Object.freeze({ + ...base, colors: Object.freeze({ - ...baseColors, - primary: mono, - secondary: mono, + ...base.colors, + accent: Object.freeze({ + primary: mono, + secondary: mono, + tertiary: mono, + }), success: mono, - danger: mono, warning: mono, + error: mono, info: mono, - muted: mono, - border: mono, - "diagnostic.error": mono, - "diagnostic.warning": mono, - "diagnostic.info": mono, - "diagnostic.hint": mono, + focus: Object.freeze({ + ring: mono, + bg: base.colors.bg.base, + }), + selected: Object.freeze({ + bg: base.colors.bg.base, + fg: mono, + }), + disabled: Object.freeze({ + fg: mono, + bg: base.colors.bg.base, + }), + diagnostic: Object.freeze({ + error: mono, + warning: mono, + info: mono, + hint: mono, + }), + border: Object.freeze({ + subtle: mono, + default: mono, + strong: mono, + }), + }), + widget: Object.freeze({ + syntax: Object.freeze({ + keyword: mono, + type: mono, + string: mono, + number: mono, + comment: mono, + operator: mono, + punctuation: mono, + function: mono, + variable: mono, + cursorFg: base.colors.bg.base, + cursorBg: mono, + }), + diff: Object.freeze({ + addBg: base.colors.bg.base, + deleteBg: base.colors.bg.base, + addFg: mono, + deleteFg: mono, + hunkHeader: mono, + lineNumber: mono, + border: mono, + }), + logs: Object.freeze({ + trace: mono, + debug: mono, + info: mono, + warn: mono, + error: mono, + }), + toast: Object.freeze({ + info: mono, + success: mono, + warning: mono, + error: mono, + }), + chart: Object.freeze({ + primary: mono, + accent: mono, + muted: mono, + success: mono, + warning: mono, + danger: mono, + }), }), - spacing, }); } diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin index 16d25887..0f9fa78d 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/barchart_highres.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin index 61c7e173..a25258dc 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/link_docs.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin index bef6d42a..c81299f9 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/richtext_underline_ext.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin index b9ac2074..be250198 100644 Binary files a/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin and b/packages/testkit/fixtures/zrdl-v1-graphics/widgets/sparkline_highres.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin b/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin index beee8773..59ca40f8 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/button_focus_states.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin b/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin index 92eb5e29..cfcf438b 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/divider_with_label.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin index 2013050c..a4c17a3b 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/input_basic.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin index bbc5da31..a486fe0c 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/input_disabled.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin b/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin index c05c69a3..53cad5a6 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/input_focused_inverse.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin b/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin index 5ec07ce8..26f2e7aa 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/layer_backdrop_opaque.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin b/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin index 56723e00..633e7b88 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/modal_backdrop_dim.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin index 537471a4..fbaeb548 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_0.bin differ diff --git a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin index 0da30e73..dba2dce1 100644 Binary files a/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin and b/packages/testkit/fixtures/zrdl-v1/widgets/spinner_tick_1.bin differ