diff --git a/docs/public/components/alert-dark.webp b/docs/public/components/alert-dark.webp index ac60a7ac..10a3b560 100644 Binary files a/docs/public/components/alert-dark.webp and b/docs/public/components/alert-dark.webp differ diff --git a/docs/public/components/alert-hero-dark.webp b/docs/public/components/alert-hero-dark.webp index a6920279..b713bfe9 100644 Binary files a/docs/public/components/alert-hero-dark.webp and b/docs/public/components/alert-hero-dark.webp differ diff --git a/docs/public/components/alert-hero-light.webp b/docs/public/components/alert-hero-light.webp index 795c6726..70f5dfd7 100644 Binary files a/docs/public/components/alert-hero-light.webp and b/docs/public/components/alert-hero-light.webp differ diff --git a/docs/public/components/alert-light.webp b/docs/public/components/alert-light.webp index 6354dd24..7ad2953d 100644 Binary files a/docs/public/components/alert-light.webp and b/docs/public/components/alert-light.webp differ diff --git a/docs/public/components/badge-dark.webp b/docs/public/components/badge-dark.webp index 261803ed..854a6ec6 100644 Binary files a/docs/public/components/badge-dark.webp and b/docs/public/components/badge-dark.webp differ diff --git a/docs/public/components/badge-hero-dark.webp b/docs/public/components/badge-hero-dark.webp index fe7a2c6d..a77f8f43 100644 Binary files a/docs/public/components/badge-hero-dark.webp and b/docs/public/components/badge-hero-dark.webp differ diff --git a/docs/public/components/badge-hero-light.webp b/docs/public/components/badge-hero-light.webp index 5adc7dad..c7e75e2f 100644 Binary files a/docs/public/components/badge-hero-light.webp and b/docs/public/components/badge-hero-light.webp differ diff --git a/docs/public/components/badge-light.webp b/docs/public/components/badge-light.webp index 46f0c8b4..80042671 100644 Binary files a/docs/public/components/badge-light.webp and b/docs/public/components/badge-light.webp differ diff --git a/docs/public/components/checkbox-dark.webp b/docs/public/components/checkbox-dark.webp index 7cd6e0e3..0325eec9 100644 Binary files a/docs/public/components/checkbox-dark.webp and b/docs/public/components/checkbox-dark.webp differ diff --git a/docs/public/components/checkbox-hero-dark.webp b/docs/public/components/checkbox-hero-dark.webp index 11f686de..a9465dce 100644 Binary files a/docs/public/components/checkbox-hero-dark.webp and b/docs/public/components/checkbox-hero-dark.webp differ diff --git a/docs/public/components/checkbox-hero-light.webp b/docs/public/components/checkbox-hero-light.webp index 6bc1260f..075c4ac1 100644 Binary files a/docs/public/components/checkbox-hero-light.webp and b/docs/public/components/checkbox-hero-light.webp differ diff --git a/docs/public/components/checkbox-light.webp b/docs/public/components/checkbox-light.webp index b3eb9b1c..cfcec4a6 100644 Binary files a/docs/public/components/checkbox-light.webp and b/docs/public/components/checkbox-light.webp differ diff --git a/docs/public/components/radio-group-dark.webp b/docs/public/components/radio-group-dark.webp index 5485d218..3bdae6de 100644 Binary files a/docs/public/components/radio-group-dark.webp and b/docs/public/components/radio-group-dark.webp differ diff --git a/docs/public/components/radio-group-light.webp b/docs/public/components/radio-group-light.webp index 05c21595..f3d27754 100644 Binary files a/docs/public/components/radio-group-light.webp and b/docs/public/components/radio-group-light.webp differ diff --git a/docs/public/components/radio-hero-dark.webp b/docs/public/components/radio-hero-dark.webp index 78e23963..ff282e6a 100644 Binary files a/docs/public/components/radio-hero-dark.webp and b/docs/public/components/radio-hero-dark.webp differ diff --git a/docs/public/components/radio-hero-light.webp b/docs/public/components/radio-hero-light.webp index 759b575f..dee10cc0 100644 Binary files a/docs/public/components/radio-hero-light.webp and b/docs/public/components/radio-hero-light.webp differ diff --git a/docs/public/components/switch-dark.webp b/docs/public/components/switch-dark.webp index 0b7f540a..66b0dc68 100644 Binary files a/docs/public/components/switch-dark.webp and b/docs/public/components/switch-dark.webp differ diff --git a/docs/public/components/switch-hero-dark.webp b/docs/public/components/switch-hero-dark.webp index d5daa8ab..d3af84eb 100644 Binary files a/docs/public/components/switch-hero-dark.webp and b/docs/public/components/switch-hero-dark.webp differ diff --git a/docs/public/components/switch-hero-light.webp b/docs/public/components/switch-hero-light.webp index 84059fd3..aeba192f 100644 Binary files a/docs/public/components/switch-hero-light.webp and b/docs/public/components/switch-hero-light.webp differ diff --git a/docs/public/components/switch-light.webp b/docs/public/components/switch-light.webp index ca1808e0..b058d9d6 100644 Binary files a/docs/public/components/switch-light.webp and b/docs/public/components/switch-light.webp differ diff --git a/docs/src/app/docs/components/checkbox/page.mdx b/docs/src/app/docs/components/checkbox/page.mdx index ecf2aff8..1445de52 100644 --- a/docs/src/app/docs/components/checkbox/page.mdx +++ b/docs/src/app/docs/components/checkbox/page.mdx @@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle"; # Checkbox -A binary value control: the label rides the `text` attribute — checkbox is not a text-bearing element, so text content between the tags is rejected with a teaching error (`label="..."` alone names one for accessibility without a visible label). The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch). +A binary value control. Put its visible label between the tags (the `text` attribute is the equivalent binding-friendly form); `label="..."` alone supplies an accessible name without drawing a label. The model binds `checked`, and `on-toggle` dispatches its Msg — the engine never flips state on its own. For a single choice among options, use [radio](/docs/components/radio); for an on/off setting rendered as a sliding thumb, use [switch](/docs/components/switch). @@ -12,9 +12,9 @@ A binary value control: the label rides the `text` attribute — checkbox is not ```html - - - + Accept terms and conditions + Send usage reports + Managed by your organization ``` diff --git a/docs/src/app/docs/components/radio/page.mdx b/docs/src/app/docs/components/radio/page.mdx index 952ec2cb..87be6024 100644 --- a/docs/src/app/docs/components/radio/page.mdx +++ b/docs/src/app/docs/components/radio/page.mdx @@ -4,7 +4,7 @@ import { CodeToggle } from "@/components/code-toggle"; # Radio -The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio's label rides the `text` attribute — radio is not a text-bearing element, so text content between the tags is rejected with a teaching error. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation). +The single-choice value control, grouped by a `radio-group`. Give the group an accessible `label` that names the shared choice. Like [checkbox](/docs/components/checkbox), each radio takes its visible label as element content; the `text` attribute is the equivalent binding-friendly form. Descendant radios at any nesting depth form one logical group: one Tab stop, arrows wrap through the choices while Home/End move to the edges, focus and selection move together, and selecting one clears the rest. Bind the model's choice through `checked`; an actual selection transition dispatches `on-change` when bound, then falls back to `on-toggle` and `on-press` for compatibility. Activating the already-checked radio has no new `on-change` edge (a legacy fallback handler still receives the activation). @@ -12,11 +12,11 @@ The single-choice value control, grouped by a `radio-group`. Give the group an a ```html - + Default - + Comfortable - + Compact ``` diff --git a/docs/src/app/docs/native-ui/page.mdx b/docs/src/app/docs/native-ui/page.mdx index 042c7471..8442d8a0 100644 --- a/docs/src/app/docs/native-ui/page.mdx +++ b/docs/src/app/docs/native-ui/page.mdx @@ -248,7 +248,7 @@ pub fn draft(model: *const Model) []const u8 { On a `list-item`, `on-submit` grows a second home beyond text entry: with a submit handler bound, plain Enter on a ring-focused row dispatches it as the row's PRIMARY action (open the record, play the track — the desktop list convention), while Space keeps the row's select activation (`on-press`). Rows without a submit handler resolve Enter exactly as before — both activation keys select. It is the keyboard mirror of `on-double-press`: bind both to the same Msg and pointer and keyboard users share one primary action (the soundboard's Zig track rows bind `on_press` select, `on_double_press` play, `on_submit` play). How Enter reaches the row at all — and why arrows may not — is [keyboard routing](#keyboard-routing-focus-registers-quiet-list-rows-and-the-app-level-fallback). -Presses follow one rule: a click lands on the nearest pressable widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest pressable ancestor, and dragging still selects text. Any element with a bound `on-press`, `on-double-press`, or `on-toggle` is pressable (the handler makes it a hit target), so a pressable row is just `` — or `` — with plain text children: no overlays, no duplicated handlers. Nested pressables resolve to the deepest one (a button inside a pressable row wins); editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error. +Presses follow one rule: a click lands on the nearest press-claiming widget under the pointer — plain text, icons, images, badges, and layout containers let it fall through to their closest interactive ancestor, and dragging still selects text. Any element with `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` becomes a hit target and press claimer, so a row can bind `on-press` for clicks or `on-drag` for spatial dragging and child text still resolves to that row without overlays or duplicated handlers. Nested claimers resolve to the deepest one; editable text fields, scroll containers, and modal surfaces always claim their own presses. Value/text handlers (`on-change`, `on-submit`, `on-input`) still belong on controls only — the validator, both engines, and the LSP reject them on layout/decoration elements with a teaching error. ## Keyboard routing: focus registers, quiet list rows, and the app-level fallback @@ -328,7 +328,7 @@ On macOS, every non-virtualized `scroll` region is driven by an invisible `NSScr Scroll regions pin at their content edges by default on every path — no rubber-band bounce; kinetic motion stops cleanly at the boundary. Bouncing is a per-region opt-in: `overscroll="rubber_band"` in markup or `ElementOptions.overscroll = .rubber_band` in Zig views (the native macOS scroller gets elastic edges, the engine physics overscroll under resistance and spring back). The `ScrollPhysics.overscroll` design token flips the app-wide default; per-region values override it, and `overscroll="none"` pins a region regardless of the token. -Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `` child of the pressable element it answers, holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items: +Right/ctrl-click presents a real OS context menu at the pointer: `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux. Authors declare ONE menu and the platform decides presentation: hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today) mount the same declared items as an anchored canvas surface at the click point automatically — never two authored menus, never a canvas imitation where the OS menu exists. In markup, the menu is a `` child of the interactive element it answers — a hit-target kind or an element made interactive by `on-press`, `on-double-press`, `on-toggle`, `on-hold`, or `on-drag` — holding `menu-item`s (`on-press` required, `disabled` optional) and `separator`s, with `if`/`else`/`for` to swap or repeat items: ```html diff --git a/docs/src/lib/component-vocab.json b/docs/src/lib/component-vocab.json index 8df4aae7..e3dfa469 100644 --- a/docs/src/lib/component-vocab.json +++ b/docs/src/lib/component-vocab.json @@ -54,11 +54,11 @@ }, { "name": "checkbox", - "doc": "Value control; bind checked, dispatch with on-toggle." + "doc": "Text-bearing value control; the visible label is text content (or text=), bind checked, dispatch with on-toggle." }, { "name": "radio", - "doc": "Single-choice value control; bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." + "doc": "Text-bearing single-choice value control; the visible label is text content (or text=), bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." }, { "name": "toggle", diff --git a/examples/habits/src/habits.native b/examples/habits/src/habits.native index 79cda2d2..fe2c0da9 100644 --- a/examples/habits/src/habits.native +++ b/examples/habits/src/habits.native @@ -17,11 +17,10 @@ + model field, and the visible label is element content. --> - + {f} diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index 28964329..afa66b27 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -153,7 +153,7 @@ Zero app code: on macOS every non-virtualized `scroll` region — and every wind Authors write ONE menu; the platform decides how it presents. The default is the real OS context menu at the pointer — `NSMenu` on macOS, `TrackPopupMenu` on Windows, `GtkPopoverMenu` on Linux — and the selection dispatches the item's typed `Msg`. On hosts without a native menu presenter (the mobile toolkit hosts and embed hosts today), the SAME declared items present automatically as an anchored canvas surface at the click point, with the standard anchored-surface behavior (Escape and outside-click dismiss, late z-pass, window clipping). Never two authored menus, never a canvas imitation where the OS menu exists. -Markup declares the menu as a `` element — a DIRECT child of the pressable element whose right-click it answers (a hit target, or an element with a bound `on-press`/`on-hold`). It is metadata, not content: it renders nothing in the row's flow. Children are `menu-item`s (`on-press` required, `disabled` optional, the text content is the label) and bare ``s, with `if`/`else`/`for` around them to swap or repeat items — a menu whose items all evaporate at runtime simply declares no menu (the All Notes row pattern). Conditional MENUS are spelled as conditional ITEMS: the `` itself takes no attributes and cannot sit behind a structure tag. No submenus: the platform channel carries flat items (label, enabled, separator) only. +Markup declares the menu as a `` element — a DIRECT child of the interactive element whose right-click it answers (a hit-target kind, or an element made interactive by `on-press`/`on-double-press`/`on-toggle`/`on-hold`/`on-drag`). It is metadata, not content: it renders nothing in the row's flow. Children are `menu-item`s (`on-press` required, `disabled` optional, the text content is the label) and bare ``s, with `if`/`else`/`for` around them to swap or repeat items — a menu whose items all evaporate at runtime simply declares no menu (the All Notes row pattern). Conditional MENUS are spelled as conditional ITEMS: the `` itself takes no attributes and cannot sit behind a structure tag. No submenus: the platform channel carries flat items (label, enabled, separator) only. ```html @@ -208,7 +208,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `text`, `badge`, `tooltip` | text leaves | text content, `{}` interpolation allowed; `text` line policy via `wrap` (`"true"` word-wraps; `"false"`/unset paint one honest line, overflow eliding by default — `overflow="clip"` opts out), and `text` alone takes the typography rungs `size="heading"`/`size="display"` (themable token steps above title — section headings, hero stats, timer numerals). `tooltip` with `anchor="above\|below"` floats against its parent (the stack wrapping trigger + tooltip, the dropdown pattern) and the RUNTIME owns its visibility — hover intent on the trigger: shows after `tooltip-delay` ms (default 600; `"0"` = instant) and immediately on keyboard focus; hides on leave, focus departure, Escape, or a press of the trigger (a press also closes the warm window), and a shared 400ms warm window after a pointer-hovered tooltip hides on leave (the only hide that warms) shows the next trigger's tooltip instantly; the model never hears hover. These are shadcn/ui's defaults (Base UI). Without `anchor` it stays a static leaf that paints whenever the view renders it | | `text` > `span` | inline styled runs | mixed-style text in ONE wrapped paragraph: span children style runs with `weight="regular\|medium\|bold"`, `mono`, `italic`, `scale` (a positive multiplier on the paragraph's base size — inline headings, hero stats), `underline`, `foreground` (token name); `{bindings}` interpolate inside spans; whitespace between runs collapses to a single space (none = the runs abut); spans do not nest, take no events, and the paragraph announces as one text run — see "Rich text" | | `button`, `toggle-button`, `list-item`, `menu-item`, `toggle`, `switch`, `select`, `avatar` | text-bearing controls | label is the text content; `button`, `toggle-button`, `list-item`, and `menu-item` also take `icon="save"` — a vector icon drawn inline (buttons/toggle-buttons before the label, icon-only when the content is empty: add a `label`; list/menu items as a leading slot), ONE hit target whose icon follows the element's enabled/disabled tint (no overlay stacking, no duplicated `on-press`); tab strips are `toggle-button` children, so tabs get icons this way; `select` shows `placeholder` while empty and dispatches `on-press`; `avatar` renders initials, or a runtime image via `image="{binding}"` (see the Images section) | -| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); the checkbox/radio label rides `text="..."` — these are not text-bearing elements, so text content is a teaching error (`label=` alone names one for accessibility without a visible label); a radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches `on-change` when bound, then `on-toggle`, then `on-press` for compatibility — reactivating the already-checked radio has no new `on-change` edge, though a legacy fallback still receives the activation; a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) | +| `checkbox`, `radio`, `slider`, `progress` | value controls | `checked`, `value` (a 0..1 fraction on slider and progress; progress clamps out-of-range values at render, never an error); checkbox/radio visible labels are text content (`Done`), with `text="..."` as the equivalent binding-friendly form and `label=` alone naming one for accessibility without drawing a label; a radio selection TRANSITION (pointer, Space/Enter, grouped navigation, or accessibility selection) dispatches `on-change` when bound, then `on-toggle`, then `on-press` for compatibility — reactivating the already-checked radio has no new `on-change` edge, though a legacy fallback still receives the activation; a slider's `value` follows the source when it MOVES (model-driven progress renders every rebuild) and keeps the user's drag while the source replays the same value — use `slider` for seek bars, `progress` for display-only; a markup slider's `on-change` dispatches a PLAIN Msg with no value payload — mirror the applied value into the model with `Options.sync` (the Zig builder's `on_value = Ui.valueMsg(.tag)` does deliver the applied f32) | | `text-field`, `input`, `search-field`, `combobox`, `textarea` | text entry | `placeholder`; edits via `on-input`, enter via `on-submit` on single-line kinds; in a default `textarea`, Enter (and Shift+Enter) inserts a newline and `on-submit` dispatches on primary+Enter (cmd on macOS, ctrl elsewhere). A chat composer opts into `submit-on-enter="true"`: plain Enter submits, Shift+Enter remains a newline, and the primary chord still submits. `search-field` carries a built-in trailing clear affordance whenever it holds text (press the x, or Escape while focused — both clear through the text-edit path, so `on-input` hears it; no attribute, no external Clear button needed) | | `status-bar` | status bar | text leaf: content only, no children | | `separator`, `spacer` | separator, flexible space | `separator` is axis-aware: a horizontal rule in a `column`, a thin vertical divider in a `row`; give `spacer` a `grow` | @@ -221,7 +221,7 @@ Automation drives the native path honestly: snapshots list every widget's declar | `stepper` > `step` | composite stage track | `active="{index}"` (required) derives each step's completed/active/pending state; steps are text leaves (no attributes) joined by connectors; stepper also takes `key`, `global-key`, `label` | | `timeline` > `timeline-item` | composite ledger list | items only inside a timeline (for/if fine); items are leaves — `title` (required), `description`, `meta`, `indicator`, `variant`, `connector="false"` on the last item, `selected`; `on-press` makes the whole item pressable with a trailing chevron | | `chart` > `series` | composite data chart | series only inside a chart, and only series (the set is static — data varies through bindings); each series is a leaf — `values="{binding}"` (required) names a model `[]const f32` iterable, `kind` is `line`/`area`/`bar` (literal), `color` a token name, `label` the semantics name; chart takes `y-min`, `y-max`, `grid-lines`, `baseline`, `x-labels`, `y-labels`, `hover-details`, `stroke-width`, box options, `label` — see "Charts" | -| `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit target or an element with `on-press`/`on-hold`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" | +| `context-menu` | consumed by its parent | right-click menu on its DIRECT parent (a hit-target kind or an element with `on-press`/`on-double-press`/`on-toggle`/`on-hold`/`on-drag`); metadata, never a flow child. Children: `menu-item`s (`on-press` required, `disabled` optional, no `icon`) and bare `separator`s, with `if`/`else`/`for` around them. Attribute-less; presents natively where the host has a menu presenter, as an anchored surface elsewhere — see "Context menus" | | `input-group` > `textarea` + `input-group-actions` | composite grouped input | the composer shape: ONE bordered field wrapping exactly one `textarea` (first — document order is focus order) plus an optional `input-group-actions` row of controls inside the same border. The group wears the focus ring for its focused descendant and the textarea's own chrome dissolves automatically, so the whole group reads as one field; the textarea keeps its full behavior (`text`, `placeholder`, `on-input`, `on-submit`, `autofocus`, and optional `submit-on-enter`). Group takes `label`, `width`, `height`, `min-width`, `grow`, `key`, `global-key`; the actions row takes `gap` and holds ordinary elements (`if`/`else`/`for` work — swap send for stop while streaming) — put a `` between leading and trailing controls (`Ui.inputGroup`/`Ui.inputGroupActions` are the Zig-view equivalents) | Not markup-expressible (deliberately — write these as Zig view functions with `canvas.Ui`): `icon_button` (`\n", @@ -639,7 +640,7 @@ test "collectA11yErrors reports every a11y error in one pass with validate's pos try testing.expectEqual(@as(usize, 0), markup.collectA11yErrors(try clean_parser.parse(), &storage).len); } -test "press and toggle handlers are legal on layout elements (press fall-through makes them pressable)" { +test "press toggle and drag handlers are legal on layout elements (press fall-through makes them interactive)" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -651,6 +652,7 @@ test "press and toggle handlers are legal on layout elements (press fall-through "\n \n press me\n \n", "\n x\n", "\n \n x\n \n", + "\n \n x\n \n", "\n \n", "\n 3\n", }; diff --git a/src/primitives/canvas/ui_markup_view.zig b/src/primitives/canvas/ui_markup_view.zig index 8c049d96..32db2db6 100644 --- a/src/primitives/canvas/ui_markup_view.zig +++ b/src/primitives/canvas/ui_markup_view.zig @@ -512,9 +512,15 @@ pub fn MarkupView(comptime ModelT: type, comptime MsgT: type) type { } } if (elementTakesText(kind) and !composite_children) { - const text = try self.interpolatedText(ui, scope, inner); var built = ui.el(kind, options, .{}); - built.widget.text = text; + // Element content wins when present; an empty element + // keeps the already-resolved text= attribute in options. + // Checkbox/radio joined this path when they became text + // leaves, so replacing unconditionally would regress the + // binding-friendly attribute that already worked. + if (inner.children.len > 0) { + built.widget.text = try self.interpolatedText(ui, scope, inner); + } // Avatars clip their runtime image to the avatar circle, // exactly like `Ui.avatar` (a no-op while the id is 0 and // the initials fallback renders). diff --git a/src/primitives/canvas/ui_markup_view_tests.zig b/src/primitives/canvas/ui_markup_view_tests.zig index eb7d2756..0b24b4ea 100644 --- a/src/primitives/canvas/ui_markup_view_tests.zig +++ b/src/primitives/canvas/ui_markup_view_tests.zig @@ -28,6 +28,16 @@ pub const Msg = union(enum) { toggle: u32, set_filter: Filter, draft: canvas.TextInputEvent, + drag: DragPayload, +}; + +pub const DragPayload = struct { + sourceId: usize, + phase: u8, + x: f32, + y: f32, + viewWidth: f32, + viewHeight: f32, }; pub const Model = struct { @@ -1208,6 +1218,35 @@ pub const context_menu_markup_source = \\ ; +pub const drag_context_menu_markup_source = + \\ + \\ Drag me + \\ + \\ Duplicate + \\ + \\ +; + +test "a drag-only layout element can host a context menu" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const model = testModel(); + + var parser = canvas.ui_markup.Parser.init(arena, drag_context_menu_markup_source); + try testing.expectEqual(@as(?canvas.ui_markup.MarkupErrorInfo, null), canvas.ui_markup.validate(try parser.parse())); + + var view = try InboxMarkup.init(arena, drag_context_menu_markup_source); + var ui = InboxUi.init(arena); + const tree = try ui.finalize(try view.build(&ui, &model)); + try testing.expect(tree.root.semantics.actions.drag); + try testing.expect(canvas.widgetIsHitTarget(tree.root)); + try testing.expect(canvas.widgetClaimsPress(tree.root)); + try testing.expectEqual(@as(usize, 1), tree.root.context_menu.len); + try testing.expectEqualStrings("Duplicate", tree.root.context_menu[0].label); + try testing.expectEqual(Msg.add, tree.msgForContextMenu(tree.root.id, 0).?); +} + test "markup context-menus lower to declared platform-menu items on their host" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/primitives/canvas/ui_schema.zig b/src/primitives/canvas/ui_schema.zig index 439e7008..fb9018f4 100644 --- a/src/primitives/canvas/ui_schema.zig +++ b/src/primitives/canvas/ui_schema.zig @@ -50,7 +50,7 @@ const std = @import("std"); /// documents, journals, serialized contracts) carry it; readers reach /// older artifacts through document→document migrations, never silent /// reinterpretation. -pub const schema_version: u16 = 1; +pub const schema_version: u16 = 2; /// Value type-class of an attribute: what shape of value the engines /// accept for it. `option` values name a Zig enum member (the enum itself @@ -128,8 +128,8 @@ pub const EventInfo = struct { /// ever receive this event. dismissible_only: bool = false, /// This handler binds control/text behavior a non-hit-target element - /// does not have, so it is a dead handler there (press/toggle are - /// exempt: a bound press handler makes any element pressable). + /// does not have, so it is a dead handler there (press/toggle/drag + /// are exempt: their handlers make any element interactive). dead_on_non_hit_target: bool = false, }; @@ -261,8 +261,8 @@ pub const elements = [_]ElementInfo{ // paints whenever the view renders it. .{ .code = 39, .name = "tooltip", .widget_kind = "tooltip", .takes_text = true, .hit_target = false, .anchorable = true }, // Value controls and text entry. - .{ .code = 40, .name = "checkbox", .widget_kind = "checkbox", .a11y_name = .control }, - .{ .code = 41, .name = "radio", .widget_kind = "radio", .a11y_name = .control }, + .{ .code = 40, .name = "checkbox", .widget_kind = "checkbox", .takes_text = true, .a11y_name = .control }, + .{ .code = 41, .name = "radio", .widget_kind = "radio", .takes_text = true, .a11y_name = .control }, .{ .code = 42, .name = "slider", .widget_kind = "slider", .a11y_name = .control }, .{ .code = 43, .name = "progress", .widget_kind = "progress" }, .{ .code = 44, .name = "text-field", .widget_kind = "text_field", .a11y_name = .editable }, diff --git a/src/primitives/canvas/widget_access.zig b/src/primitives/canvas/widget_access.zig index f2307ddf..0a82b095 100644 --- a/src/primitives/canvas/widget_access.zig +++ b/src/primitives/canvas/widget_access.zig @@ -174,15 +174,15 @@ pub fn widgetKindClaimsPress(kind: WidgetKind) bool { /// Whether a press gesture stops at this widget instead of falling /// through to the nearest claiming ancestor: an interactive kind -/// (`widgetKindClaimsPress`), or ANY widget with a bound press/toggle -/// handler (`on_press`/`on_toggle` stamp `semantics.actions`, and +/// (`widgetKindClaimsPress`), or ANY widget with a bound press/toggle/drag +/// handler (`on_press`/`on_toggle`/`on_drag` stamp `semantics.actions`, and /// engine-owned `command` dispatch only exists on kinds already claiming). /// Disabled widgets never claim — the hit test skips them too, so a press /// on a disabled control keeps today's behavior of landing on whatever is /// around it. pub fn widgetClaimsPress(widget: Widget) bool { if (widget.id == 0 or widget.state.disabled) return false; - if (widget.semantics.actions.press or widget.semantics.actions.toggle) return true; + if (widget.semantics.actions.press or widget.semantics.actions.toggle or widget.semantics.actions.drag) return true; return widgetKindClaimsPress(widget.kind); } diff --git a/src/primitives/canvas/widget_builtin_tests.zig b/src/primitives/canvas/widget_builtin_tests.zig index ddbc068b..1f1d0cd3 100644 --- a/src/primitives/canvas/widget_builtin_tests.zig +++ b/src/primitives/canvas/widget_builtin_tests.zig @@ -438,15 +438,16 @@ test "icon widgets render built-in vector icons as tinted path commands" { } } -test "checkbox check mark strokes one anti-aliased vector path" { +test "checkbox check mark and label render through their pinned part slots" { const checkbox = Widget{ .id = 63, .kind = WidgetKind.checkbox, - .frame = geometry.RectF.init(0, 0, 24, 24), + .frame = geometry.RectF.init(0, 0, 80, 24), .value = 1, + .text = "Done", }; const tokens = DesignTokens{}; - var commands: [8]CanvasCommand = undefined; + var commands: [9]CanvasCommand = undefined; var builder = Builder.init(&commands); try emitWidgetTree(&builder, checkbox, tokens); const display_list = builder.displayList(); @@ -465,18 +466,22 @@ test "checkbox check mark strokes one anti-aliased vector path" { }, else => return error.TestUnexpectedResult, } + switch (display_list.findCommandById(widgetPartId(63, 6)).?.command) { + .draw_text => |text| try std.testing.expectEqualStrings("Done", text.text), + else => return error.TestUnexpectedResult, + } - // Rasterized, the diagonal carries partial-coverage edge pixels — - // anti-aliasing a binary point-in-capsule test can never produce — - // and the whole render pins byte-identical. - var render_commands: [8]RenderCommand = undefined; + // Rasterized, the diagonal carries partial-coverage edge pixels and + // the label is present at its stable part slot. The whole 80x24 + // checkbox-plus-label render pins byte-identical. + var render_commands: [9]RenderCommand = undefined; const plan = try (DisplayList{ .commands = display_list.commands }).renderPlan(&render_commands); - var pixels: [24 * 24 * 4]u8 = undefined; + var pixels: [80 * 24 * 4]u8 = undefined; @memset(&pixels, 0); - const surface = try ReferenceRenderSurface.init(24, 24, &pixels); + const surface = try ReferenceRenderSurface.init(80, 24, &pixels); try surface.renderPass(.{ .commands = plan.commands, - .surface_size = geometry.SizeF.init(24, 24), + .surface_size = geometry.SizeF.init(80, 24), .full_repaint = true, }, Color.rgb8(255, 255, 255)); // Sample strictly inside the accent fill (the 16px box spans y 4-20; @@ -487,12 +492,12 @@ test "checkbox check mark strokes one anti-aliased vector path" { while (y < 18) : (y += 1) { var x: usize = 2; while (x < 14) : (x += 1) { - const value = pixels[(y * 24 + x) * 4]; + const value = pixels[(y * 80 + x) * 4]; if (value > 60 and value < 200) partial += 1; } } try std.testing.expect(partial >= 4); - try std.testing.expectEqual(@as(u64, 10271374105851145327), support.referenceSurfaceSignature(&pixels)); + try std.testing.expectEqual(@as(u64, 516383874562490834), support.referenceSurfaceSignature(&pixels)); } test "a builder accumulating two widget trees keeps each checkbox mark's own geometry" { @@ -545,6 +550,240 @@ test "a builder accumulating two widget trees keeps each checkbox mark's own geo } } +test "compact radio and switch chrome stays circular and disabled selection fills wash" { + const tokens = DesignTokens{}; + const disabled_fill = Color.rgba( + tokens.colors.surface.r, + tokens.colors.surface.g, + tokens.colors.surface.b, + tokens.colors.surface.a * tokens.states.disabled_alpha, + ); + const controls = [_]struct { kind: WidgetKind, frame: geometry.RectF }{ + .{ .kind = .checkbox, .frame = geometry.RectF.init(0, 0, 80, 28) }, + .{ .kind = .radio, .frame = geometry.RectF.init(0, 0, 80, 28) }, + }; + for (controls) |control| { + const widget = Widget{ + .id = 67, + .kind = control.kind, + .frame = control.frame, + .size = .sm, + .state = .{ .disabled = true }, + }; + var commands: [8]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, widget, tokens); + switch (builder.displayList().findCommandById(widgetPartId(67, 1)).?.command) { + .fill_rounded_rect => |fill| { + try expectFillColor(disabled_fill, fill.fill); + if (control.kind == .radio) { + try std.testing.expectApproxEqAbs(fill.rect.height * 0.5, fill.radius.top_left, 0.001); + } + }, + else => return error.TestUnexpectedResult, + } + } + + const toggle = Widget{ + .id = 68, + .kind = .switch_control, + .frame = geometry.RectF.init(0, 0, 80, 28), + .size = .sm, + }; + var toggle_commands: [8]CanvasCommand = undefined; + var toggle_builder = Builder.init(&toggle_commands); + try emitWidgetTree(&toggle_builder, toggle, tokens); + switch (toggle_builder.displayList().findCommandById(widgetPartId(68, 1)).?.command) { + .fill_rounded_rect => |fill| try std.testing.expectApproxEqAbs(fill.rect.height * 0.5, fill.radius.top_left, 0.001), + else => return error.TestUnexpectedResult, + } + switch (toggle_builder.displayList().findCommandById(widgetPartId(68, 3)).?.command) { + .fill_rounded_rect => |fill| try std.testing.expectApproxEqAbs(fill.rect.height * 0.5, fill.radius.top_left, 0.001), + else => return error.TestUnexpectedResult, + } +} + +test "selection controls honor explicit disabled background and foreground tokens" { + const disabled_background = Color.rgb8(34, 68, 102); + const disabled_foreground = Color.rgb8(238, 204, 170); + const disabled_border = Color.rgb8(51, 119, 85); + const tokens = DesignTokens{ .controls = .{ + .checkbox = .{ .disabled_background = disabled_background, .disabled_foreground = disabled_foreground, .border = disabled_border }, + .radio = .{ .disabled_background = disabled_background, .disabled_foreground = disabled_foreground, .border = disabled_border }, + .switch_control = .{ .disabled_background = disabled_background, .disabled_foreground = disabled_foreground, .border = disabled_border }, + } }; + const cases = [_]struct { + kind: WidgetKind, + foreground_slot: ObjectId, + label_slot: ObjectId, + }{ + .{ .kind = .checkbox, .foreground_slot = 4, .label_slot = 6 }, + .{ .kind = .radio, .foreground_slot = 4, .label_slot = 5 }, + .{ .kind = .switch_control, .foreground_slot = 3, .label_slot = 5 }, + }; + for (cases, 0..) |case, index| { + const id: ObjectId = @intCast(80 + index); + const widget = Widget{ + .id = id, + .kind = case.kind, + .frame = geometry.RectF.init(0, 0, 120, 28), + .text = "Disabled", + .value = 1, + .state = .{ .disabled = true }, + }; + var commands: [10]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, widget, tokens); + const list = builder.displayList(); + switch (list.findCommandById(widgetPartId(id, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(disabled_background, fill.fill), + else => return error.TestUnexpectedResult, + } + switch (list.findCommandById(widgetPartId(id, 2)).?.command) { + .stroke_rect => |stroke| try expectFillColor(disabled_border, stroke.stroke.fill), + else => return error.TestUnexpectedResult, + } + switch (list.findCommandById(widgetPartId(id, case.foreground_slot)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(disabled_foreground, fill.fill), + .stroke_path => |stroke| try expectFillColor(disabled_foreground, stroke.stroke.fill), + else => return error.TestUnexpectedResult, + } + switch (list.findCommandById(widgetPartId(id, case.label_slot)).?.command) { + .draw_text => |text| try std.testing.expectEqualDeep(disabled_foreground, text.color), + else => return error.TestUnexpectedResult, + } + } + + // Like the slider register, stating either disabled channel opts the + // control into color-swap mode: an unstated counterpart stays at full + // rest strength instead of being alpha-washed behind the swap. + const background_only_tokens = DesignTokens{ .controls = .{ + .checkbox = .{ .disabled_background = disabled_background }, + } }; + const background_only = Widget{ + .id = 83, + .kind = .checkbox, + .frame = geometry.RectF.init(0, 0, 120, 28), + .text = "Background only", + .value = 1, + .state = .{ .disabled = true }, + }; + var background_only_commands: [10]CanvasCommand = undefined; + var background_only_builder = Builder.init(&background_only_commands); + try emitWidgetTree(&background_only_builder, background_only, background_only_tokens); + switch (background_only_builder.displayList().findCommandById(widgetPartId(83, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(disabled_background, fill.fill), + else => return error.TestUnexpectedResult, + } + switch (background_only_builder.displayList().findCommandById(widgetPartId(83, 4)).?.command) { + .stroke_path => |stroke| try expectFillColor(background_only_tokens.colors.accent_text, stroke.stroke.fill), + else => return error.TestUnexpectedResult, + } + + const foreground_only_tokens = DesignTokens{ .controls = .{ + .checkbox = .{ .disabled_foreground = disabled_foreground }, + } }; + var foreground_only_commands: [10]CanvasCommand = undefined; + var foreground_only_builder = Builder.init(&foreground_only_commands); + try emitWidgetTree(&foreground_only_builder, background_only, foreground_only_tokens); + switch (foreground_only_builder.displayList().findCommandById(widgetPartId(83, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(foreground_only_tokens.colors.accent, fill.fill), + else => return error.TestUnexpectedResult, + } + switch (foreground_only_builder.displayList().findCommandById(widgetPartId(83, 4)).?.command) { + .stroke_path => |stroke| try expectFillColor(disabled_foreground, stroke.stroke.fill), + else => return error.TestUnexpectedResult, + } +} + +test "radio and switch shape overrides beat circular house fallbacks" { + const tokens = DesignTokens{ + .controls = .{ + .radio = .{ .radius = 5 }, + .switch_control = .{ .radius = 4 }, + }, + }; + const themed_radio = Widget{ + .id = 70, + .kind = .radio, + .frame = geometry.RectF.init(0, 0, 80, 28), + }; + var themed_radio_commands: [8]CanvasCommand = undefined; + var themed_radio_builder = Builder.init(&themed_radio_commands); + try emitWidgetTree(&themed_radio_builder, themed_radio, tokens); + switch (themed_radio_builder.displayList().findCommandById(widgetPartId(70, 1)).?.command) { + .fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(5), fill.radius), + else => return error.TestUnexpectedResult, + } + + const authored_radio = Widget{ + .id = 72, + .kind = .radio, + .frame = geometry.RectF.init(0, 0, 80, 28), + .style = .{ .radius = 3 }, + }; + var authored_radio_commands: [8]CanvasCommand = undefined; + var authored_radio_builder = Builder.init(&authored_radio_commands); + try emitWidgetTree(&authored_radio_builder, authored_radio, tokens); + switch (authored_radio_builder.displayList().findCommandById(widgetPartId(72, 1)).?.command) { + .fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(3), fill.radius), + else => return error.TestUnexpectedResult, + } + + const toggle = Widget{ + .id = 71, + .kind = .switch_control, + .frame = geometry.RectF.init(0, 0, 80, 28), + }; + var toggle_commands: [8]CanvasCommand = undefined; + var toggle_builder = Builder.init(&toggle_commands); + try emitWidgetTree(&toggle_builder, toggle, tokens); + inline for (.{ @as(ObjectId, 1), @as(ObjectId, 3) }) |slot| { + switch (toggle_builder.displayList().findCommandById(widgetPartId(71, slot)).?.command) { + .fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(4), fill.radius), + else => return error.TestUnexpectedResult, + } + } + + var authored_toggle = toggle; + authored_toggle.id = 73; + authored_toggle.style.radius = 2; + var authored_toggle_commands: [8]CanvasCommand = undefined; + var authored_toggle_builder = Builder.init(&authored_toggle_commands); + try emitWidgetTree(&authored_toggle_builder, authored_toggle, tokens); + inline for (.{ @as(ObjectId, 1), @as(ObjectId, 3) }) |slot| { + switch (authored_toggle_builder.displayList().findCommandById(widgetPartId(73, slot)).?.command) { + .fill_rounded_rect => |fill| try std.testing.expectEqualDeep(Radius.all(2), fill.radius), + else => return error.TestUnexpectedResult, + } + } +} + +test "an off switch keeps authored track and thumb colors on separate channels" { + const track_color = Color.rgb8(12, 34, 56); + const thumb_color = Color.rgb8(240, 244, 248); + const toggle = Widget{ + .id = 69, + .kind = .switch_control, + .frame = geometry.RectF.init(0, 0, 80, 32), + .style = .{ + .background = track_color, + .accent_foreground = thumb_color, + }, + }; + var commands: [8]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, toggle, .{}); + switch (builder.displayList().findCommandById(widgetPartId(69, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(track_color, fill.fill), + else => return error.TestUnexpectedResult, + } + switch (builder.displayList().findCommandById(widgetPartId(69, 3)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(thumb_color, fill.fill), + else => return error.TestUnexpectedResult, + } +} + test "app-registered icons draw through the widget paths like built-ins" { var buffer = canvas.svg_icon.IconBuffer{}; const parsed = try canvas.svg_icon.parse( @@ -693,7 +932,7 @@ test "disabled filled buttons match their reference edge treatment" { // the fill and fallback edge are each washed to half strength, their // overlap becomes darker and invents an outline around the disabled // control. Destructive is already the quiet borderless chip, so its - // edge stays at width 0 in both states. + // edge emits no command in either state. const tokens = DesignTokens{}; const button = Widget{ .id = 71, @@ -741,10 +980,7 @@ test "disabled filled buttons match their reference edge treatment" { var chip_commands: [8]CanvasCommand = undefined; var chip_builder = Builder.init(&chip_commands); try emitWidgetTree(&chip_builder, chip, tokens); - switch (chip_builder.displayList().findCommandById(widgetPartId(71, 2)).?.command) { - .stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width), - else => return error.TestUnexpectedResult, - } + try std.testing.expect(chip_builder.displayList().findCommandById(widgetPartId(71, 2)) == null); } test "geist disabled buttons use the reference swap and tertiary registers" { @@ -855,6 +1091,66 @@ test "button disabled border override does not require a disabled background" { try expectFillColor(disabled_border, buttonBorderFill(button, tokens)); } +test "disabled colors wash resolved identity and secondary accents use the accent channel" { + const style_mod = @import("widget_render_style.zig"); + const tokens = DesignTokens{}; + const accent = Color.rgb8(180, 24, 48); + const accent_foreground = Color.rgb8(250, 226, 232); + const foreground = Color.rgb8(36, 92, 148); + const background = Color.rgb8(24, 56, 88); + const washed_accent = Color.rgba(accent.r, accent.g, accent.b, tokens.states.disabled_alpha); + const washed_accent_foreground = Color.rgba(accent_foreground.r, accent_foreground.g, accent_foreground.b, tokens.states.disabled_alpha); + const washed_foreground = Color.rgba(foreground.r, foreground.g, foreground.b, tokens.states.disabled_alpha); + const washed_background = Color.rgba(background.r, background.g, background.b, tokens.states.disabled_alpha); + + const disabled = Widget{ + .kind = .text, + .state = .{ .disabled = true }, + .style = .{ + .foreground = foreground, + .accent_foreground = accent_foreground, + }, + }; + try std.testing.expectEqualDeep(washed_foreground, style_mod.widgetForegroundColor(disabled, tokens, tokens.colors.text)); + try std.testing.expectEqualDeep(washed_accent_foreground, style_mod.widgetAccentForegroundColor(disabled, tokens, tokens.colors.accent_text)); + + const secondary = Widget{ + .kind = .button, + .variant = .secondary, + .style = .{ .accent = accent }, + }; + try std.testing.expectEqualDeep(accent, style_mod.buttonFillColor(secondary, tokens)); + try expectFillColor(accent, style_mod.buttonBorderFill(secondary, tokens)); + + const disabled_input = Widget{ + .kind = .text_field, + .state = .{ .disabled = true }, + .style = .{ .background = background }, + }; + try expectFillColor(washed_background, style_mod.textInputFill(disabled_input, tokens, .{})); + + const secondary_badge = Widget{ + .kind = .badge, + .variant = .secondary, + .style = .{ .accent = accent }, + }; + try std.testing.expectEqualDeep(accent, style_mod.badgeBackgroundColor(secondary_badge, tokens, .{})); + try std.testing.expectEqualDeep(accent, style_mod.badgeBorderColor(secondary_badge, tokens, .{})); + + const disabled_destructive_badge = Widget{ + .kind = .badge, + .variant = .destructive, + .state = .{ .disabled = true }, + .style = .{ + .accent = accent, + .accent_foreground = accent_foreground, + }, + }; + try std.testing.expectEqualDeep(washed_accent, style_mod.badgeBackgroundColor(disabled_destructive_badge, tokens, .{})); + try std.testing.expectEqualDeep(washed_accent, style_mod.badgeBorderColor(disabled_destructive_badge, tokens, .{})); + try std.testing.expectEqualDeep(washed_accent_foreground, style_mod.badgeTextColor(disabled_destructive_badge, tokens, .{})); +} + /// The flush-group segment assertions, shared by the tree-walk and /// layout-walk halves of the test below so the two emission paths are /// pinned to the SAME bar: leading corners on the first segment, @@ -984,11 +1280,8 @@ test "detached button groups render chip members with the group table and the me // No seam clips anywhere: a chip has no shared boundary. try std.testing.expect(list.findCommandById(widgetPartId(2, 0)) == null); try std.testing.expect(list.findCommandById(widgetPartId(3, 0)) == null); - // The chips are borderless: the border stroke carries zero width. - switch (list.findCommandById(widgetPartId(2, 2)).?.command) { - .stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width), - else => return error.TestUnexpectedResult, - } + // The chips are borderless: no dead zero-width stroke command. + try std.testing.expect(list.findCommandById(widgetPartId(2, 2)) == null); // Knockout ink on the selected label, the stated rest ink elsewhere. switch (list.findCommandById(widgetPartId(2, 4)).?.command) { .draw_text => |text| try std.testing.expectEqualDeep(Color.rgb8(255, 255, 255), text.color), @@ -1821,6 +2114,176 @@ test "the quiet-surface knob silences the hover wash and nothing else" { try std.testing.expect(focused_builder.displayList().findCommandById(widgetPartId(74, 2)) != null); } +test "actionable layout containers paint the row hover and pressed ladder in both emit paths" { + const tokens = DesignTokens{}; + inline for (.{ WidgetKind.row, WidgetKind.column, WidgetKind.stack }) |kind| { + const base = Widget{ + .id = 75, + .kind = kind, + .frame = geometry.RectF.init(0, 0, 160, 40), + .semantics = .{ .actions = .{ .press = true } }, + }; + + var hovered = base; + hovered.state.hovered = true; + var commands: [4]CanvasCommand = undefined; + var builder = Builder.init(&commands); + try emitWidgetTree(&builder, hovered, tokens); + switch (builder.displayList().findCommandById(widgetPartId(75, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill), + else => return error.TestUnexpectedResult, + } + + // The laid-out/live emitter resolves the same state and part slot. + var nodes: [1]WidgetLayoutNode = undefined; + const layout = try layoutWidgetTree(hovered, hovered.frame, &nodes); + var layout_commands: [4]CanvasCommand = undefined; + var layout_builder = Builder.init(&layout_commands); + try layout.emitDisplayList(&layout_builder, tokens); + switch (layout_builder.displayList().findCommandById(widgetPartId(75, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill), + else => return error.TestUnexpectedResult, + } + + var pressed = base; + pressed.state.pressed = true; + var pressed_commands: [4]CanvasCommand = undefined; + var pressed_builder = Builder.init(&pressed_commands); + try emitWidgetTree(&pressed_builder, pressed, tokens); + switch (pressed_builder.displayList().findCommandById(widgetPartId(75, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_pressed, fill.fill), + else => return error.TestUnexpectedResult, + } + + var quiet = hovered; + quiet.style.quiet_hover = true; + var quiet_commands: [4]CanvasCommand = undefined; + var quiet_builder = Builder.init(&quiet_commands); + try emitWidgetTree(&quiet_builder, quiet, tokens); + try std.testing.expect(quiet_builder.displayList().findCommandById(widgetPartId(75, 1)) == null); + } +} + +test "drag-only layout containers own hover and pressed washes through child content" { + const tokens = DesignTokens{}; + const label = [_]Widget{.{ + .id = 79, + .kind = .text, + .text = "Drag me", + }}; + const draggable = Widget{ + .id = 78, + .kind = .row, + .frame = geometry.RectF.init(0, 0, 160, 40), + .semantics = .{ .actions = .{ .drag = true } }, + .children = &label, + }; + var nodes: [2]WidgetLayoutNode = undefined; + const layout = try layoutWidgetTree(draggable, draggable.frame, &nodes); + const label_node = layout.findById(79) orelse return error.TestUnexpectedResult; + const raw_hit = layout.hitTest(label_node.frame.center()) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(ObjectId, 79), raw_hit.id); + const hover_target = layout.hoverTargetForHit(raw_hit) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(ObjectId, 78), hover_target.id); + const press_target = canvas.widgetPressTargetForHit(layout, raw_hit) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(ObjectId, 78), press_target.id); + + var hover_commands: [4]CanvasCommand = undefined; + var hover_builder = Builder.init(&hover_commands); + try layout.emitDisplayListWithState(&hover_builder, tokens, .{ .hovered_id = hover_target.id }); + switch (hover_builder.displayList().findCommandById(widgetPartId(78, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill), + else => return error.TestUnexpectedResult, + } + + var press_commands: [4]CanvasCommand = undefined; + var press_builder = Builder.init(&press_commands); + try layout.emitDisplayListWithState(&press_builder, tokens, .{ .pressed_id = press_target.id }); + switch (press_builder.displayList().findCommandById(widgetPartId(78, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_pressed, fill.fill), + else => return error.TestUnexpectedResult, + } +} + +test "an actionable container uses authored background only at rest" { + const authored = Color.rgb8(28, 52, 76); + const tokens = DesignTokens{}; + const base = Widget{ + .id = 77, + .kind = .row, + .frame = geometry.RectF.init(0, 0, 160, 40), + .style = .{ .background = authored }, + .semantics = .{ .actions = .{ .press = true } }, + }; + var rest_commands: [4]CanvasCommand = undefined; + var rest_builder = Builder.init(&rest_commands); + try emitWidgetTree(&rest_builder, base, tokens); + switch (rest_builder.displayList().findCommandById(widgetPartId(77, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(authored, fill.fill), + else => return error.TestUnexpectedResult, + } + + var hovered = base; + hovered.state.hovered = true; + var hover_commands: [4]CanvasCommand = undefined; + var hover_builder = Builder.init(&hover_commands); + try emitWidgetTree(&hover_builder, hovered, tokens); + switch (hover_builder.displayList().findCommandById(widgetPartId(77, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill), + else => return error.TestUnexpectedResult, + } +} + +test "house alert card and panel surfaces have visible hover and pressed fallbacks" { + const themed_rest = Color.rgb8(28, 52, 76); + const tokens = DesignTokens{ .controls = .{ + .alert = .{ .background = themed_rest }, + .card = .{ .background = themed_rest }, + .panel = .{ .background = themed_rest }, + } }; + const cases = [_]struct { kind: WidgetKind, fill_slot: u4 }{ + .{ .kind = .alert, .fill_slot = 1 }, + .{ .kind = .card, .fill_slot = 1 }, + .{ .kind = .panel, .fill_slot = 2 }, + }; + for (cases) |case| { + const base = Widget{ + .id = 76, + .kind = case.kind, + .frame = geometry.RectF.init(0, 0, 160, 48), + .semantics = .{ .actions = .{ .press = true } }, + }; + + var rest_commands: [8]CanvasCommand = undefined; + var rest_builder = Builder.init(&rest_commands); + try emitWidgetTree(&rest_builder, base, tokens); + switch (rest_builder.displayList().findCommandById(widgetPartId(76, case.fill_slot)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(themed_rest, fill.fill), + else => return error.TestUnexpectedResult, + } + + var hovered = base; + hovered.state.hovered = true; + var hovered_commands: [8]CanvasCommand = undefined; + var hovered_builder = Builder.init(&hovered_commands); + try emitWidgetTree(&hovered_builder, hovered, tokens); + switch (hovered_builder.displayList().findCommandById(widgetPartId(76, case.fill_slot)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_subtle, fill.fill), + else => return error.TestUnexpectedResult, + } + + var pressed = base; + pressed.state.pressed = true; + var pressed_commands: [8]CanvasCommand = undefined; + var pressed_builder = Builder.init(&pressed_commands); + try emitWidgetTree(&pressed_builder, pressed, tokens); + switch (pressed_builder.displayList().findCommandById(widgetPartId(76, case.fill_slot)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.surface_pressed, fill.fill), + else => return error.TestUnexpectedResult, + } + } +} + test "icon buttons draw registry names as vector icons and keep the glyph fallback" { const tokens = DesignTokens{}; const vector = Widget{ @@ -2524,6 +2987,85 @@ test "built-in alert renders house surface chrome and text" { } } +test "destructive alerts tint every channel and compact defaults align chrome with content" { + const tokens = DesignTokens{ + .controls = .{ + // One shared alert table may state neutral defaults; the + // destructive variant still owns its hue. + .alert = .{ + .background = Color.rgb8(12, 18, 24), + .foreground = Color.rgb8(235, 240, 245), + .border = Color.rgb8(54, 64, 74), + }, + }, + }; + const destructive = Widget{ + .id = 42, + .kind = .alert, + .frame = geometry.RectF.init(0, 0, 320, 68), + .text = "Session expired", + .variant = .destructive, + .state = .{ .disabled = true }, + .style = .{ .accent = Color.rgb8(196, 32, 64) }, + }; + var destructive_commands: [12]CanvasCommand = undefined; + var destructive_builder = Builder.init(&destructive_commands); + try emitWidgetTree(&destructive_builder, destructive, tokens); + const destructive_hue = destructive.style.accent.?; + const destructive_fill = colorWithAlpha(destructive_hue, tokens.states.destructive_wash_alpha * tokens.states.disabled_alpha); + const destructive_border = colorWithAlpha(destructive_hue, 0.5 * tokens.states.disabled_alpha); + const destructive_ink = colorWithAlpha(destructive_hue, tokens.states.disabled_alpha); + switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 1)).?.command) { + .fill_rounded_rect => |fill| try expectFillColor(destructive_fill, fill.fill), + else => return error.TestUnexpectedResult, + } + switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 2)).?.command) { + .stroke_rect => |stroke| try expectFillColor(destructive_border, stroke.stroke.fill), + else => return error.TestUnexpectedResult, + } + switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 4)).?.command) { + .stroke_path => |stroke| try expectFillColor(destructive_ink, stroke.stroke.fill), + else => return error.TestUnexpectedResult, + } + switch (destructive_builder.displayList().findCommandById(widgetPartId(42, 10)).?.command) { + .draw_text => |text| try std.testing.expectEqualDeep(destructive_ink, text.color), + else => return error.TestUnexpectedResult, + } + + const description = [_]Widget{.{ + .id = 44, + .kind = .text, + .text = "Restart to update.", + }}; + const compact = builtinComponentWidget(.alert, .{ + .id = 43, + .frame = geometry.RectF.init(0, 0, 320, 80), + .text = "Update available", + .size = .sm, + .children = &description, + }); + // Stored kind default and token-resolved chrome share the 14px compact + // inset; the 14px multiplicatively scaled icon plus 10px additive + // text gap put title and child at x=38. The semantic title gap stays + // 4px rather than shrinking with control chrome. + try std.testing.expectEqual(@as(f32, 14), compact.layout.padding.top); + try std.testing.expect(compact.layout.padding_is_kind_default); + var compact_nodes: [2]WidgetLayoutNode = undefined; + const compact_layout = try layoutWidgetTree(compact, compact.frame, &compact_nodes); + const child = compact_layout.findById(44) orelse return error.TestUnexpectedResult; + const text_size = widget_metrics.widgetBodyTextSize(compact, .{}); + try std.testing.expectApproxEqAbs(@as(f32, 38), child.frame.x, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 14) + widget_metrics.widgetLineHeight(text_size) + 4, child.frame.y, 0.001); + + var compact_commands: [16]CanvasCommand = undefined; + var compact_builder = Builder.init(&compact_commands); + try compact_layout.emitDisplayList(&compact_builder, .{}); + switch (compact_builder.displayList().findCommandById(widgetPartId(43, 10)).?.command) { + .draw_text => |text| try std.testing.expectApproxEqAbs(child.frame.x, text.origin.x, 0.001), + else => return error.TestUnexpectedResult, + } +} + test "built-in card renders house surface chrome and title" { const card = builtinComponentWidget(.card, .{ .id = 44, @@ -3136,7 +3678,10 @@ test "built-in component primitive widgets render distinct house chrome" { try layout.emitDisplayList(&builder, .{}); const display_list = builder.displayList(); - try std.testing.expectEqual(@as(usize, 9), display_list.commandCount()); + // Eight live commands: avatar fill/text/stroke, badge fill/text (its + // default zero-width edge emits nothing), separator, skeleton, and + // spinner. This count deliberately excludes dead rasterizer work. + try std.testing.expectEqual(@as(usize, 8), display_list.commandCount()); try std.testing.expect(display_list.commands[0] == .fill_rounded_rect); switch (display_list.commands[1]) { .draw_text => |text| try std.testing.expectEqualStrings("NS", text.text), @@ -3147,16 +3692,16 @@ test "built-in component primitive widgets render distinct house chrome" { .fill_rounded_rect => |fill| try expectFillColor(ColorTokens.light().accent, fill.fill), else => return error.TestUnexpectedResult, } - switch (display_list.commands[5]) { + switch (display_list.commands[4]) { .draw_text => |text| try std.testing.expectEqualStrings("Beta", text.text), else => return error.TestUnexpectedResult, } - try std.testing.expect(display_list.commands[6] == .fill_rect); - try std.testing.expect(display_list.commands[7] == .fill_rounded_rect); + try std.testing.expect(display_list.commands[5] == .fill_rect); + try std.testing.expect(display_list.commands[6] == .fill_rounded_rect); // The spinner (house arc register): ONE stroked arc in the page // ink — no track — with the stroke scaling at 1/12 of the box // (28px box -> 2.333px stroke). - switch (display_list.commands[8]) { + switch (display_list.commands[7]) { .stroke_path => |arc| { try expectFillColor(ColorTokens.light().text, arc.stroke.fill); try std.testing.expectApproxEqAbs(@as(f32, 28.0 * 2.0 / 24.0), arc.stroke.width, 0.001); diff --git a/src/primitives/canvas/widget_layout.zig b/src/primitives/canvas/widget_layout.zig index 07ba68b8..dd506e52 100644 --- a/src/primitives/canvas/widget_layout.zig +++ b/src/primitives/canvas/widget_layout.zig @@ -45,6 +45,7 @@ const widgetLineHeight = widget_metrics.widgetLineHeight; const widgetDefaultRowHeight = widget_metrics.widgetDefaultRowHeight; const widgetButtonInset = widget_metrics.widgetButtonInset; const widgetControlInset = widget_metrics.widgetControlInset; +const widgetAlertInset = widget_metrics.widgetAlertInset; const widgetSizedDensityValue = widget_metrics.widgetSizedDensityValue; const densityValue = widget_metrics.densityValue; const widgetControlHeight = widget_metrics.widgetControlHeight; @@ -82,7 +83,7 @@ pub fn layoutWidgetDepth( }; len.* += 1; - const layout_padding = if (widget.kind == .tabs) tabsLayoutPadding(widget, tokens) else widget.layout.padding; + const layout_padding = widgetLayoutPadding(widget, tokens); const content = windowControlsClearedContent(frame.inset(layout_padding), widget, tokens); switch (widget.kind) { .row, .breadcrumb, .pagination, .radio_group, .toggle_group => try layoutAxisChildren(widget.children, content, .horizontal, index, depth, output, len, widget.layout, tokens), @@ -674,6 +675,19 @@ fn tabsLayoutPadding(widget: Widget, tokens: DesignTokens) geometry.InsetsF { return geometry.InsetsF.all(underlineTabsListInset(tokens)); } +fn alertLayoutPadding(widget: Widget, tokens: DesignTokens) geometry.InsetsF { + if (!widget.layout.padding_is_kind_default) return widget.layout.padding; + return geometry.InsetsF.all(widgetAlertInset(widget, tokens)); +} + +fn widgetLayoutPadding(widget: Widget, tokens: DesignTokens) geometry.InsetsF { + return switch (widget.kind) { + .tabs => tabsLayoutPadding(widget, tokens), + .alert => alertLayoutPadding(widget, tokens), + else => widget.layout.padding, + }; +} + fn layoutAxisChildren( children: []const Widget, content: geometry.RectF, @@ -994,7 +1008,7 @@ fn widgetSubtreeHasTextSpans(widget: Widget, depth: usize) bool { fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignTokens, depth: usize) f32 { if (depth >= max_widget_depth) return preferredMainExtent(widget, .vertical, tokens); if (widget.frame.height > 0) return clampMainExtent(widget, .vertical, widget.frame.height); - const padding = widget.layout.padding; + const padding = widgetLayoutPadding(widget, tokens); const inner_width = @max(0, width - padding.left - padding.right); const content_height: f32 = switch (widget.kind) { .text => if (widget.spans.len > 0) @@ -1053,7 +1067,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken // measure at the indented width with the title's line reserved. .alert => blk: { const text_size = widgetBodyTextSize(widget, tokens); - const inset = widgetControlInset(widget, tokens, tokens.spacing.lg); + const inset = widgetAlertInset(widget, tokens); const icon_size = widgetSizedDensityValue(widget, tokens, 16); const text_gap = widgetControlInset(widget, tokens, tokens.spacing.md); const indent = if (widget.text.len > 0) icon_size + text_gap else 0; @@ -1065,7 +1079,7 @@ fn wrappedVerticalExtentForWidth(widget: Widget, width: f32, tokens: DesignToken } var content = max_height; if (widget.text.len > 0) { - const title_gap = widgetControlInset(widget, tokens, tokens.spacing.xs); + const title_gap = densityValue(tokens, tokens.spacing.xs); content = widgetLineHeight(text_size) + (if (max_height > 0) title_gap + max_height else 0); } // The same floor `intrinsicAlertWidgetSize` keeps, so wrapped @@ -1957,7 +1971,7 @@ fn alertContentFrame(widget: Widget, content: geometry.RectF, tokens: DesignToke const text_size = widgetBodyTextSize(widget, tokens); const icon_size = widgetSizedDensityValue(widget, tokens, 16); const text_gap = widgetControlInset(widget, tokens, tokens.spacing.md); - const title_gap = widgetControlInset(widget, tokens, tokens.spacing.xs); + const title_gap = densityValue(tokens, tokens.spacing.xs); const indent = @min(content.width, icon_size + text_gap); const y = @min(content.maxY(), content.y + widgetLineHeight(text_size) + title_gap); return geometry.RectF.init( @@ -2258,25 +2272,25 @@ fn intrinsicStatusBarWidgetSize(widget: Widget, tokens: DesignTokens) geometry.S fn intrinsicAlertWidgetSize(widget: Widget, tokens: DesignTokens, depth: usize) geometry.SizeF { const text_size = widgetBodyTextSize(widget, tokens); - const inset = widgetControlInset(widget, tokens, tokens.spacing.lg); + const padding = alertLayoutPadding(widget, tokens); // The chrome's fixed 16px icon (`emitAlertWidgetChrome`). const icon_size = widgetSizedDensityValue(widget, tokens, 16); const text_gap = widgetControlInset(widget, tokens, tokens.spacing.md); const text = intrinsicTextWidgetSize(widget, tokens, text_size); var size = geometry.SizeF.init( - @max(widgetSizedDensityValue(widget, tokens, 240), text.width + inset * 2 + icon_size + text_gap), - @max(widgetSizedDensityValue(widget, tokens, 52), widgetLineHeight(text_size) + inset * 2), + @max(widgetSizedDensityValue(widget, tokens, 240), text.width + padding.left + padding.right + icon_size + text_gap), + @max(widgetSizedDensityValue(widget, tokens, 52), widgetLineHeight(text_size) + padding.top + padding.bottom), ); // A description column under the title (`alertContentFrame`) grows // the alert instead of overflowing it. const children = intrinsicStackedChildrenSize(widget, tokens, depth); if (children.height > 0 and widget.text.len > 0) { - const title_gap = widgetControlInset(widget, tokens, tokens.spacing.xs); - size.height = @max(size.height, widgetLineHeight(text_size) + title_gap + children.height + inset * 2); - size.width = @max(size.width, children.width + icon_size + text_gap + inset * 2); + const title_gap = densityValue(tokens, tokens.spacing.xs); + size.height = @max(size.height, widgetLineHeight(text_size) + title_gap + children.height + padding.top + padding.bottom); + size.width = @max(size.width, children.width + icon_size + text_gap + padding.left + padding.right); } else if (children.height > 0) { - size.height = @max(size.height, children.height + inset * 2); - size.width = @max(size.width, children.width + inset * 2); + size.height = @max(size.height, children.height + padding.top + padding.bottom); + size.width = @max(size.width, children.width + padding.left + padding.right); } return size; } diff --git a/src/primitives/canvas/widget_metrics.zig b/src/primitives/canvas/widget_metrics.zig index 8f5b0c76..5f841d6b 100644 --- a/src/primitives/canvas/widget_metrics.zig +++ b/src/primitives/canvas/widget_metrics.zig @@ -257,6 +257,12 @@ pub fn widgetControlInset(widget: Widget, tokens: DesignTokens, base: f32) f32 { return densityValue(tokens, widgetSizedTokenValue(widget, tokens, base)); } +/// Alert chrome/content inset: one token-backed metric for rendering, +/// effective default layout padding, and intrinsic/wrapped measurement. +pub fn widgetAlertInset(widget: Widget, tokens: DesignTokens) f32 { + return widgetControlInset(widget, tokens, tokens.spacing.lg); +} + /// Tab-trigger metrics split at the register boundary. House pill tabs /// keep using the shared label/control ladder exactly as before; the /// underline register owns the taller, label-hugging geometry measured @@ -293,14 +299,7 @@ pub fn widgetSizedDensityValue(widget: Widget, tokens: DesignTokens, value: f32) } pub fn widgetSizedTokenValue(widget: Widget, tokens: DesignTokens, value: f32) f32 { - const step = tokens.metrics.size_inset_step; - return switch (widget.size) { - .sm => @max(0, value - step), - // heading/display step type, not chrome: control metrics stay at - // the default step. - .default, .icon, .heading, .display => value, - .lg => value + step, - }; + return widget_model.widgetSizeSteppedValue(widget.size, value, tokens.metrics.size_inset_step); } pub fn widgetSizeScale(widget: Widget) f32 { diff --git a/src/primitives/canvas/widget_render.zig b/src/primitives/canvas/widget_render.zig index d3832837..5c5da153 100644 --- a/src/primitives/canvas/widget_render.zig +++ b/src/primitives/canvas/widget_render.zig @@ -105,6 +105,7 @@ const badgeBackgroundColor = widget_render_style.badgeBackgroundColor; const badgeBorderColor = widget_render_style.badgeBorderColor; const badgeTextColor = widget_render_style.badgeTextColor; const badgeStrokeWidth = widget_render_style.badgeStrokeWidth; +const listItemFillColor = widget_render_style.listItemFillColor; pub const buttonStrokeWidth = widget_render_style.buttonStrokeWidth; pub const transparentColor = widget_render_style.transparentColor; pub const checkboxWidgetBoxRect = widget_render_controls.checkboxWidgetBoxRect; @@ -447,7 +448,7 @@ fn emitWidgetDepthContent(builder: *Builder, widget: Widget, tokens: DesignToken try emitWidgetBackdropBlur(builder, paint_widget, tokens); switch (paint_widget.kind) { .stack, .row, .column => { - try emitLayoutContainerBackground(builder, paint_widget); + try emitLayoutContainerBackground(builder, paint_widget, tokens); try emitWidgetClippedChildren(builder, paint_widget, tokens, depth); }, .grid, .list, .breadcrumb, .pagination, .radio_group, .toggle_group, .split, .tree => try emitWidgetClippedChildren(builder, paint_widget, tokens, depth), @@ -787,7 +788,7 @@ fn emitWidgetLayoutNodeContent( const paint_widget = widgetWithFrame(widget, pixelSnapGeometryRect(tokens, widget.frame)); try emitWidgetBackdropBlur(builder, paint_widget, tokens); switch (paint_widget.kind) { - .stack, .row, .column => try emitLayoutContainerBackground(builder, paint_widget), + .stack, .row, .column => try emitLayoutContainerBackground(builder, paint_widget, tokens), .breadcrumb, .button_group, .pagination, .radio_group, .toggle_group, .split, .tree => {}, .data_row => try emitDataRowWidgetWash(builder, paint_widget, tokens), .tabs => try widget_render_surfaces.emitTabsListWidgetChrome(builder, paint_widget, tokens), @@ -939,12 +940,31 @@ fn emitWidgetLayoutNodeContent( try emitWidgetLayoutClippedChildren(builder, layout, node_index, tokens, state, paint_widget); } -/// Flow and stacking containers have no implicit surface treatment, but -/// an author background is real chrome: it fills the laid-out frame -/// before any children, with the same optional radius accepted by the -/// builder and markup grammar. -fn emitLayoutContainerBackground(builder: *Builder, widget: Widget) Error!void { - const background = widget.style.background orelse return; +/// Flow and stacking containers have no implicit surface treatment. An +/// actionable container, however, wears the same neutral hover/pressed +/// ladder as a list row over its full hit frame; an authored background is +/// its rest fill. Non-actionable containers paint only that authored fill. +fn emitLayoutContainerBackground(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { + const actions = widget.semantics.actions; + const actionable = widget.id != 0 and !widget.state.disabled and + (actions.press or actions.toggle or actions.drag); + if (!actionable) { + const background = widget.style.background orelse return; + if (background.a <= 0) return; + try builder.fillRoundedRect(.{ + .id = widgetPartId(widget.id, 1), + .rect = widget.frame, + .radius = Radius.all(nonNegative(widget.style.radius orelse 0)), + .fill = colorFill(background), + }); + return; + } + // The common rest-state actionable container with no authored fill + // emits nothing. Avoid the token ladder on every structural row in a + // full rebuild; only live feedback or authored chrome needs it. + if (widget.style.background == null and !widget.state.selected and !widget.state.pressed and !widget_render_style.washHovered(widget)) return; + const background = listItemFillColor(widget, tokens, widget.state); + if (background.a <= 0) return; try builder.fillRoundedRect(.{ .id = widgetPartId(widget.id, 1), .rect = widget.frame, @@ -3014,15 +3034,18 @@ fn emitBadgeWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Erro .radius = radius, .fill = colorFill(badgeBackgroundColor(widget, tokens, visual)), }); - try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ - .id = widgetPartId(widget.id, 2), - .rect = widget.frame, - .radius = radius, - .stroke = .{ - .fill = widgetBorderFill(widget, badgeBorderColor(widget, tokens, visual)), - .width = badgeStrokeWidth(widget, tokens, visual), - }, - })); + const stroke_width = badgeStrokeWidth(widget, tokens, visual); + if (stroke_width > 0) { + try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ + .id = widgetPartId(widget.id, 2), + .rect = widget.frame, + .radius = radius, + .stroke = .{ + .fill = colorFill(badgeBorderColor(widget, tokens, visual)), + .width = stroke_width, + }, + })); + } const content_color = badgeTextColor(widget, tokens, visual); // Inline vector icon: icon-only badges center it (the stepper's // completed check, status chips); icon + text draws it before the @@ -3209,7 +3232,7 @@ fn emitSkeletonWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) E /// edge to edge (the table register's row hover), square-cornered so /// adjacent rows tile. Rows at rest draw nothing. fn emitDataRowWidgetWash(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { - const fill = widget_render_style.listItemFillColor(widget, tokens, widget.state); + const fill = listItemFillColor(widget, tokens, widget.state); if (fill.a <= 0) return; try builder.fillRect(.{ .id = widgetPartId(widget.id, 1), diff --git a/src/primitives/canvas/widget_render_controls.zig b/src/primitives/canvas/widget_render_controls.zig index 29a61516..052dbaef 100644 --- a/src/primitives/canvas/widget_render_controls.zig +++ b/src/primitives/canvas/widget_render_controls.zig @@ -301,6 +301,7 @@ fn buttonGroupSegmentRadius(widget: Widget, visual: ControlVisualTokens, tokens: /// freed by the retired button shadow. fn emitButtonBorder(builder: *Builder, widget: Widget, tokens: DesignTokens, radius: Radius) Error!void { const stroke_width = buttonStrokeWidth(widget, tokens); + if (stroke_width <= 0) return; const border = snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 2), .rect = widget.frame, @@ -830,7 +831,7 @@ pub fn emitListItemWidget(builder: *Builder, widget: Widget, tokens: DesignToken .id = widgetPartId(widget.id, 1), .rect = widget.frame, .radius = radius, - .fill = widgetBackgroundFill(widget, fill), + .fill = colorFill(fill), }); } if (widget.state.focused) try emitWidgetFocusRing(builder, widget, tokens, 2); @@ -902,7 +903,7 @@ pub fn emitDataCellWidgetChrome(builder: *Builder, widget: Widget, tokens: Desig try builder.fillRect(.{ .id = widgetPartId(widget.id, 1), .rect = widget.frame, - .fill = widgetBackgroundFill(widget, state_fill), + .fill = colorFill(state_fill), }); } // Borderless by default: the table's chrome is its hairline ROW @@ -1107,30 +1108,41 @@ pub fn emitCheckboxWidget(builder: *Builder, widget: Widget, tokens: DesignToken // 6px token reads nearly round; 4px keeps the square-with-softened- // corners shape the checkbox is known by. const radius = controlRadius(widget, visual, 4); + const box_rest = if (selected) + widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent) + else + widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface)); try builder.fillRoundedRect(.{ .id = widgetPartId(widget.id, 1), .rect = box, .radius = radius, - .fill = if (selected) - colorFill(disabledWash(widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), widget.state.disabled, tokens.states.disabled_alpha)) - else - colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))), + .fill = colorFill(selectionDisabledBackground(widget, visual, box_rest, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 2), .rect = box, .radius = radius, .stroke = .{ - .fill = colorFill(disabledWash(if (selected) widgetAccentColor(widget, visual.border orelse visual.active_background orelse tokens.colors.accent) else widgetBorderColor(widget, visual.border orelse tokens.colors.border), widget.state.disabled, tokens.states.disabled_alpha)), + .fill = colorFill(selectionDisabledNeutral( + widget, + visual, + if (selected) widgetAccentColor(widget, visual.border orelse visual.active_background orelse tokens.colors.accent) else widgetBorderColor(widget, visual.border orelse tokens.colors.border), + tokens, + )), .width = controlStrokeWidth(widget, visual, tokens.stroke.regular), }, })); if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 3, box, radius); if (selected) { - // The check keeps the accent-foreground tint even when disabled - // (washed to half strength with the box) — swapping it to the - // muted text gray would read muddy on the washed accent fill. - const check_color = disabledWash(widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text, widget.state.disabled, tokens.states.disabled_alpha); + // With no theme swap, the check keeps the accent-foreground tint + // washed in step with the box; an explicit disabled foreground + // replaces that channel (and the label) consistently. + const check_color = selectionDisabledForeground( + widget, + visual, + widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text, + tokens, + ); const left = pixelSnapGeometryPoint(tokens, geometry.PointF.init(box.x + box.width * 0.26, box.y + box.height * 0.54)); const mid = pixelSnapGeometryPoint(tokens, geometry.PointF.init(box.x + box.width * 0.43, box.y + box.height * 0.70)); const right = pixelSnapGeometryPoint(tokens, geometry.PointF.init(box.x + box.width * 0.76, box.y + box.height * 0.32)); @@ -1153,19 +1165,30 @@ pub fn emitCheckboxWidget(builder: *Builder, widget: Widget, tokens: DesignToken .cap = .round, }); } - try emitControlLabelWithColor(builder, widget, tokens, box.x + box.width + widgetControlInset(widget, tokens, tokens.spacing.sm), 6, visual.foreground orelse tokens.colors.text); + try emitControlLabelWithColor( + builder, + widget, + tokens, + box.x + box.width + widgetControlInset(widget, tokens, tokens.spacing.sm), + 6, + selectionDisabledForeground(widget, visual, widget.style.foreground orelse visual.foreground orelse tokens.colors.text, tokens), + ); } pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { const visual = selectionControlVisualTokens(widget, tokens); const circle = radioWidgetCircleRect(widget, tokens); const selected = booleanControlSelected(widget); - const radius = controlRadius(widget, visual, circle.height * 0.5); + // The house fallback is a circle in every size register. Explicit + // widget/theme radii still shape the control through the ordinary + // precedence ladder. + const radius = selectionShapeRadius(widget, visual, circle.height * 0.5); + const circle_rest = widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface)); try builder.fillRoundedRect(.{ .id = widgetPartId(widget.id, 1), .rect = circle, .radius = radius, - .fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))), + .fill = colorFill(selectionDisabledBackground(widget, visual, circle_rest, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 2), @@ -1174,7 +1197,7 @@ pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) // The border stays on the input hairline even when selected — // the primary-colored dot alone carries the checked state. .stroke = .{ - .fill = colorFill(disabledWash(widgetBorderColor(widget, visual.border orelse tokens.colors.border), widget.state.disabled, tokens.states.disabled_alpha)), + .fill = colorFill(selectionDisabledNeutral(widget, visual, widgetBorderColor(widget, visual.border orelse tokens.colors.border), tokens)), .width = controlStrokeWidth(widget, visual, tokens.stroke.regular), }, })); @@ -1191,10 +1214,22 @@ pub fn emitRadioWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) .id = widgetPartId(widget.id, 4), .rect = dot, .radius = Radius.all(dot.height * 0.5), - .fill = colorFill(disabledWash(widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), widget.state.disabled, tokens.states.disabled_alpha)), + .fill = colorFill(selectionDisabledForeground( + widget, + visual, + widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), + tokens, + )), }); } - try emitControlLabelWithColor(builder, widget, tokens, circle.x + circle.width + widgetControlInset(widget, tokens, tokens.spacing.sm), 5, visual.foreground orelse tokens.colors.text); + try emitControlLabelWithColor( + builder, + widget, + tokens, + circle.x + circle.width + widgetControlInset(widget, tokens, tokens.spacing.sm), + 5, + selectionDisabledForeground(widget, visual, widget.style.foreground orelse visual.foreground orelse tokens.colors.text, tokens), + ); } pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { @@ -1202,7 +1237,9 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) const visual = selectionControlVisualTokens(widget, tokens); const knob_inset = widgetSizedDensityValue(widget, tokens, 2); const track = toggleWidgetTrackRect(widget, tokens); - const track_radius = controlRadius(widget, visual, track.height * 0.5); + // The house rail is a pill in every size register. Explicit + // widget/theme radii still shape both rail and thumb. + const track_radius = selectionShapeRadius(widget, visual, track.height * 0.5); const knob_size = @max(0, track.height - knob_inset * 2); const knob_x = if (selected) track.x + track.width - knob_size - knob_inset @@ -1210,14 +1247,15 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) track.x + knob_inset; const knob = pixelSnapGeometryRect(tokens, geometry.RectF.init(knob_x, track.y + knob_inset, knob_size, knob_size)); + const track_rest = if (selected) + widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent) + else + widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface_pressed)); try builder.fillRoundedRect(.{ .id = widgetPartId(widget.id, 1), .rect = track, .radius = track_radius, - .fill = if (selected) - colorFill(disabledWash(widgetAccentColor(widget, visual.active_background orelse tokens.colors.accent), widget.state.disabled, tokens.states.disabled_alpha)) - else - colorFill(disabledWash(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface_pressed)), widget.state.disabled, tokens.states.disabled_alpha)), + .fill = colorFill(selectionDisabledBackground(widget, visual, track_rest, tokens)), }); // Borderless by default: the switch is a filled pill (primary when // on, the input wash when off) whose near-white thumb provides the @@ -1232,7 +1270,7 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) .rect = track, .radius = track_radius, .stroke = .{ - .fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border), + .fill = colorFill(selectionDisabledNeutral(widget, visual, widgetBorderColor(widget, visual.border orelse tokens.colors.border), tokens)), .width = track_stroke_width, }, })); @@ -1240,19 +1278,57 @@ pub fn emitToggleWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) try builder.fillRoundedRect(.{ .id = widgetPartId(widget.id, 3), .rect = knob, - .radius = controlRadius(widget, visual, knob.height * 0.5), + .radius = selectionShapeRadius(widget, visual, knob.height * 0.5), // The thumb is near-white in both states and schemes (the // primary-foreground tint), so it stays legible on the primary - // track and on the dark input wash alike. Disabled washes it to - // half strength with the track instead of swapping to gray. - .fill = colorFill(disabledWash( - if (selected) widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text else widget.style.background orelse visual.foreground orelse tokens.colors.accent_text, - widget.state.disabled, - tokens.states.disabled_alpha, + // track and on the dark input wash alike. Disabled washes it with + // the track unless the theme supplies a foreground swap. + .fill = colorFill(selectionDisabledForeground( + widget, + visual, + widget.style.accent_foreground orelse visual.foreground orelse tokens.colors.accent_text, + tokens, )), }); if (widget.state.focused) try emitWidgetFocusRingForRect(builder, widget, tokens, 4, track, track_radius); - try emitControlLabelWithColor(builder, widget, tokens, track.x + track.width + widgetControlInset(widget, tokens, tokens.spacing.sm), 5, visual.foreground orelse tokens.colors.text); + try emitControlLabelWithColor( + builder, + widget, + tokens, + track.x + track.width + widgetControlInset(widget, tokens, tokens.spacing.sm), + 5, + selectionDisabledForeground(widget, visual, widget.style.foreground orelse visual.foreground orelse tokens.colors.text, tokens), + ); +} + +/// Selection controls share the slider's disabled-token contract. With no +/// explicit disabled channel, every resolved rest color gets the house +/// alpha wash. Stating either disabled channel selects the theme's swap +/// register: stated background/foreground colors replace those channels, +/// while unstated channels stay at full strength. Marks and thumbs are +/// foreground channels; boxes and tracks are backgrounds. +fn selectionDisabledBackground(widget: Widget, visual: ControlVisualTokens, rest: Color, tokens: DesignTokens) Color { + if (!widget.state.disabled) return rest; + if (visual.disabled_background) |color| return color; + return selectionDisabledNeutral(widget, visual, rest, tokens); +} + +fn selectionDisabledForeground(widget: Widget, visual: ControlVisualTokens, rest: Color, tokens: DesignTokens) Color { + if (!widget.state.disabled) return rest; + if (visual.disabled_foreground) |color| return color; + return selectionDisabledNeutral(widget, visual, rest, tokens); +} + +fn selectionDisabledNeutral(widget: Widget, visual: ControlVisualTokens, rest: Color, tokens: DesignTokens) Color { + const swap = visual.disabled_background != null or visual.disabled_foreground != null; + return disabledWash(rest, widget.state.disabled and !swap, tokens.states.disabled_alpha); +} + +fn selectionShapeRadius(widget: Widget, visual: ControlVisualTokens, fallback: f32) Radius { + if (widget.style.radius != null or visual.radius != null) { + return controlRadius(widget, visual, fallback); + } + return Radius.all(@max(0, fallback)); } pub fn emitSliderWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { @@ -1456,7 +1532,9 @@ fn emitControlLabelWithColor(builder: *Builder, widget: Widget, tokens: DesignTo .font_id = tokens.typography.font_id, .size = text_size, .origin = pixelSnapTextPoint(tokens, boundedTextOrigin(labelFrameForControl(widget.frame, x), text_size, 0)), - .color = widgetForegroundColor(widget, tokens, color), + // Callers resolve the selection control's enabled/disabled + // foreground channel before reaching this common text emitter. + .color = color, .text = widget.text, .text_layout = boundedTextLayout(labelFrameForControl(widget.frame, x), text_size, 0, .start, .none, widget.text_overflow, tokens), }); diff --git a/src/primitives/canvas/widget_render_style.zig b/src/primitives/canvas/widget_render_style.zig index ce8b03ab..ff01f765 100644 --- a/src/primitives/canvas/widget_render_style.zig +++ b/src/primitives/canvas/widget_render_style.zig @@ -169,13 +169,13 @@ pub fn widgetBorderColor(widget: Widget, fallback: Color) Color { } pub fn widgetForegroundColor(widget: Widget, tokens: DesignTokens, fallback: Color) Color { - if (widget.state.disabled) return tokens.colors.text_muted; - return widget.style.foreground orelse fallback; + const color = widget.style.foreground orelse fallback; + return disabledWash(color, widget.state.disabled, tokens.states.disabled_alpha); } pub fn widgetAccentForegroundColor(widget: Widget, tokens: DesignTokens, fallback: Color) Color { - if (widget.state.disabled) return tokens.colors.text_muted; - return widget.style.accent_foreground orelse fallback; + const color = widget.style.accent_foreground orelse fallback; + return disabledWash(color, widget.state.disabled, tokens.states.disabled_alpha); } pub fn widgetRadius(widget: Widget, fallback: f32) Radius { @@ -282,7 +282,7 @@ pub fn buttonFillColor(widget: Widget, tokens: DesignTokens) Color { // the light recipe, so a bare untheme'd `DesignTokens{}` (whose // color defaults are the light palette) renders identically. .destructive => widgetAccentColor(widget, destructiveChipBackground(visual, tokens, pressed, selected, hovered)), - .secondary => widgetBackgroundColor(widget, buttonStateBackground(visual, pressed or selected, hovered, if (pressed or selected) tokens.colors.surface_pressed else hoverWash(tokens.colors.surface_subtle, false, hovered, tokens.states.secondary_hover_alpha))), + .secondary => widgetAccentColor(widget, buttonStateBackground(visual, pressed or selected, hovered, if (pressed or selected) tokens.colors.surface_pressed else hoverWash(tokens.colors.surface_subtle, false, hovered, tokens.states.secondary_hover_alpha))), // The quiet variants step through the neutral washes: hover and // a toggle's on-state sit on the muted wash, a press deepens one // step further so the moment of commitment is visible under the @@ -419,6 +419,7 @@ pub fn buttonBorderFill(widget: Widget, tokens: DesignTokens) Fill { // chip's edge is where its wash ends, nothing more. .destructive => widgetAccentColor(widget, visual.border orelse transparentColor()), .ghost => widgetBorderColor(widget, visual.border orelse transparentColor()), + .secondary => widgetAccentColor(widget, visual.border orelse tokens.colors.border), else => widgetBorderColor(widget, visual.border orelse tokens.colors.border), }; }; @@ -544,7 +545,15 @@ pub fn textInputControlVisualTokens(widget: Widget, tokens: DesignTokens) Contro } pub fn textInputFill(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Fill { - if (widget.state.disabled) return colorFill(visual.disabled_background orelse tokens.colors.disabled); + if (widget.state.disabled) { + if (visual.disabled_background) |color| return colorFill(color); + const rest = restStateWidget(widget); + return colorFill(disabledWash( + widgetBackgroundColor(rest, buttonStateBackground(visual, false, false, tokens.colors.surface)), + true, + tokens.states.disabled_alpha, + )); + } return colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, false, washHovered(widget), tokens.colors.surface))); } @@ -642,11 +651,18 @@ pub fn componentPillRadius(widget: Widget, visual: ControlVisualTokens, fallback } pub fn badgeBackgroundColor(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Color { - if (widget.state.disabled) return visual.disabled_background orelse tokens.colors.disabled; + if (widget.state.disabled) { + if (visual.disabled_background) |color| return color; + return disabledWash(badgeBackgroundColor(restStateWidget(widget), tokens, visual), true, tokens.states.disabled_alpha); + } const hovered = washHovered(widget); + // Badges intentionally have one control table rather than the button + // register's per-variant tables. Variant identity therefore resolves + // from the shared palette plus local style channels; this does not + // invent themeable per-variant badge tokens. return switch (widget.variant) { .default, .primary => widgetAccentColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, tokens.colors.accent)), - .secondary => widgetBackgroundColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, tokens.colors.surface_subtle)), + .secondary => widgetAccentColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, tokens.colors.surface_subtle)), .outline, .ghost => widgetBackgroundColor(widget, controlStateBackground(visual, widget.state.pressed, widget.state.pressed or widget.state.selected, hovered, if (hovered or widget.state.pressed) tokens.colors.surface_subtle else transparentColor())), // The QUIET destructive chip: a translucent destructive wash // (`states.badge_destructive_wash_alpha`) under destructive @@ -658,15 +674,20 @@ pub fn badgeBackgroundColor(widget: Widget, tokens: DesignTokens, visual: Contro } pub fn badgeBorderColor(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Color { - return switch (widget.variant) { + const border = widget.style.border orelse switch (widget.variant) { .default, .primary => widgetAccentColor(widget, visual.border orelse tokens.colors.accent), .destructive => widgetAccentColor(widget, visual.border orelse tokens.colors.destructive), - else => widgetBorderColor(widget, visual.border orelse tokens.colors.border), + .secondary => widgetAccentColor(widget, visual.border orelse tokens.colors.border), + else => visual.border orelse tokens.colors.border, }; + return disabledWash(border, widget.state.disabled, tokens.states.disabled_alpha); } pub fn badgeTextColor(widget: Widget, tokens: DesignTokens, visual: ControlVisualTokens) Color { - if (widget.state.disabled) return visual.disabled_foreground orelse tokens.colors.text_muted; + if (widget.state.disabled) { + if (visual.disabled_foreground) |color| return color; + return disabledWash(badgeTextColor(restStateWidget(widget), tokens, visual), true, tokens.states.disabled_alpha); + } return switch (widget.variant) { .default, .primary => widgetAccentForegroundColor(widget, tokens, visual.foreground orelse tokens.colors.accent_text), // Ink on the quiet wash, not knockout text on a filled block. @@ -705,13 +726,32 @@ pub fn listItemFillColor(widget: Widget, tokens: DesignTokens, state: WidgetStat // The quiet-surface knob reads through the widget, not the caller's // state copy, so every row/cell path honors it identically. const hovered = state.hovered and !widget.style.quiet_hover; - const fallback = if (state.selected or state.pressed) - tokens.colors.surface_pressed - else if (hovered) - tokens.colors.surface_subtle - else - transparentColor(); - return controlStateBackground(visual, state.pressed, state.selected or state.pressed, hovered, fallback); + if (state.pressed) return visual.pressed_background orelse visual.active_background orelse visual.hover_background orelse visual.background orelse tokens.colors.surface_pressed; + if (state.selected) return visual.active_background orelse visual.hover_background orelse visual.background orelse tokens.colors.surface_pressed; + if (hovered) return visual.hover_background orelse visual.background orelse tokens.colors.surface_subtle; + // Local authored chrome is the rest rung. A theme's shared row + // background remains the fallback for rows without one, while its + // state-specific channels above still own interaction feedback. + return widget.style.background orelse visual.background orelse transparentColor(); +} + +/// Surface chrome uses the same neutral feedback ladder as an actionable +/// row when its theme does not state state-specific colors: surface at +/// rest, subtle on hover, pressed on selection/press. A themed active or +/// hover fill remains authoritative, and a locally authored background is +/// the surface's explicit all-state override. +pub fn surfaceStateBackground(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color { + const active = widget.state.pressed or widget.state.selected; + const hovered = washHovered(widget); + // A local background is an explicit all-state override. The theme + // table's background is only the REST rung: letting it participate in + // the state fallbacks would mask the standard subtle/pressed washes + // whenever a custom theme stated only its surface color. + if (widget.style.background) |background| return background; + if (widget.state.pressed) return visual.pressed_background orelse visual.active_background orelse visual.hover_background orelse tokens.colors.surface_pressed; + if (active) return visual.active_background orelse visual.hover_background orelse tokens.colors.surface_pressed; + if (hovered) return visual.hover_background orelse tokens.colors.surface_subtle; + return visual.background orelse tokens.colors.surface; } pub fn transparentColor() Color { diff --git a/src/primitives/canvas/widget_render_surfaces.zig b/src/primitives/canvas/widget_render_surfaces.zig index 8f95ae85..5dd0990b 100644 --- a/src/primitives/canvas/widget_render_surfaces.zig +++ b/src/primitives/canvas/widget_render_surfaces.zig @@ -29,9 +29,11 @@ const widgetLabelTextSize = widget_metrics.widgetLabelTextSize; const widgetLineHeight = widget_metrics.widgetLineHeight; const widgetTypographySize = widget_metrics.widgetTypographySize; const widgetControlInset = widget_metrics.widgetControlInset; +const widgetAlertInset = widget_metrics.widgetAlertInset; const widgetSizedDensityValue = widget_metrics.widgetSizedDensityValue; const colorFill = widget_render_style.colorFill; +const colorWithAlpha = widget_render_style.colorWithAlpha; const widgetBackgroundFill = widget_render_style.widgetBackgroundFill; const widgetBorderFill = widget_render_style.widgetBorderFill; const widgetFocusRingFill = widget_render_style.widgetFocusRingFill; @@ -41,8 +43,9 @@ const widgetRadius = widget_render_style.widgetRadius; const controlRadius = widget_render_style.controlRadius; const controlStrokeWidth = widget_render_style.controlStrokeWidth; const snapHairlineStrokeRect = widget_render_style.snapHairlineStrokeRect; -const buttonStateBackground = widget_render_style.buttonStateBackground; const washHovered = widget_render_style.washHovered; +const surfaceStateBackground = widget_render_style.surfaceStateBackground; +const disabledWash = widget_render_style.disabledWash; const alertControlVisualTokens = widget_render_style.alertControlVisualTokens; const cardControlVisualTokens = widget_render_style.cardControlVisualTokens; const dialogControlVisualTokens = widget_render_style.dialogControlVisualTokens; @@ -57,26 +60,26 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo .id = widgetPartId(widget.id, 1), .rect = widget.frame, .radius = radius, - .fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface))), + .fill = colorFill(alertBackgroundColor(widget, visual, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 2), .rect = widget.frame, .radius = radius, .stroke = .{ - .fill = widgetBorderFill(widget, visual.border orelse tokens.colors.border), + .fill = colorFill(alertBorderColor(widget, visual, tokens)), .width = controlStrokeWidth(widget, visual, tokens.stroke.hairline), }, })); if (widget.text.len == 0) return; - // The house style alert geometry: a FIXED 16px icon centered on the first + // The house style alert geometry: a 16px-base icon centered on the first // text line's box, a spacing.md gap, and wrapped text hanging past // the icon column (`alertContentFrame` indents children the same // way, so a description column lines up under the title). const text_size = widgetBodyTextSize(widget, tokens); const line_height = widgetLineHeight(text_size); - const inset = widgetControlInset(widget, tokens, tokens.spacing.lg); + const inset = widgetAlertInset(widget, tokens); const icon_size = widgetSizedDensityValue(widget, tokens, 16); const icon_frame = geometry.RectF.init( widget.frame.x + inset, @@ -91,7 +94,7 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo @max(1, widget.frame.width - inset * 2 - icon_size - text_gap), widget.frame.height, ); - const foreground = widgetForegroundColor(widget, tokens, visual.foreground orelse alertVariantForeground(widget, tokens)); + const foreground = widgetForegroundColor(widget, tokens, alertVariantForeground(widget, visual, tokens)); try emitAlertMark(builder, widget, tokens, icon_frame, foreground); // Baseline centered within the first line box, so icon and first // line share one optical center. @@ -116,10 +119,38 @@ pub fn emitAlertWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTo /// The alert's identity color: destructive alerts read in the /// destructive hue (the house style `text-destructive` treatment on a plain /// card surface); every other variant keeps the plain foreground. -fn alertVariantForeground(widget: Widget, tokens: DesignTokens) Color { +fn alertBackgroundColor(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color { + if (widget.variant != .destructive) return surfaceStateBackground(widget, visual, tokens); + if (widget.style.background) |color| return disabledWash(color, widget.state.disabled, tokens.states.disabled_alpha); + const hue = widget.style.accent orelse tokens.colors.destructive; + const wash = colorWithAlpha(hue, if (widget.state.pressed) + tokens.states.destructive_wash_pressed_alpha + else if (washHovered(widget)) + tokens.states.destructive_wash_hover_alpha + else + tokens.states.destructive_wash_alpha); + return disabledWash(wash, widget.state.disabled, tokens.states.disabled_alpha); +} + +fn alertBorderColor(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color { + const border = widget.style.border orelse if (widget.variant == .destructive) + // Destructive alerts use the callout edge from the house recipe: + // the destructive hue at half strength. ControlTokens has one + // shared alert table, not per-variant alert tables, so a flat + // neutral `alert.border` cannot masquerade as variant theming. + colorWithAlpha(widget.style.accent orelse tokens.colors.destructive, 0.5) + else + visual.border orelse tokens.colors.border; + return disabledWash(border, widget.state.disabled, tokens.states.disabled_alpha); +} + +fn alertVariantForeground(widget: Widget, visual: ControlVisualTokens, tokens: DesignTokens) Color { return switch (widget.variant) { - .destructive => tokens.colors.destructive, - else => tokens.colors.text, + // Variant identity precedes the shared alert table's flat neutral + // foreground; a local foreground still wins in + // `widgetForegroundColor` and disabled then washes that result. + .destructive => widget.style.accent orelse tokens.colors.destructive, + else => visual.foreground orelse tokens.colors.text, }; } @@ -143,7 +174,7 @@ pub fn emitCardWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTok .id = widgetPartId(widget.id, 1), .rect = widget.frame, .radius = radius, - .fill = colorFill(widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface))), + .fill = colorFill(surfaceStateBackground(widget, visual, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 2), @@ -206,7 +237,7 @@ pub fn emitModalSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: D .id = widgetPartId(widget.id, 2), .rect = widget.frame, .radius = radius, - .fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)), + .fill = colorFill(surfaceStateBackground(widget, visual, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 3), @@ -241,7 +272,7 @@ pub fn emitModalSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: D pub fn emitPanelWidgetChrome(builder: *Builder, widget: Widget, tokens: DesignTokens) Error!void { const visual = surfaceControlVisualTokens(widget, tokens); const radius = controlRadius(widget, visual, tokens.radius.lg); - const background = widgetBackgroundColor(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)); + const background = surfaceStateBackground(widget, visual, tokens); const shadow_token = tokens.shadow.sm; // Only an opaque surface casts a drop shadow: a translucent or // fully transparent panel (a dismiss catcher, a tinted wash) has @@ -690,7 +721,7 @@ pub fn emitPopoverWidgetChrome(builder: *Builder, widget: Widget, tokens: Design .id = widgetPartId(widget.id, 2), .rect = widget.frame, .radius = radius, - .fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)), + .fill = colorFill(surfaceStateBackground(widget, visual, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 3), @@ -723,7 +754,7 @@ pub fn emitMenuSurfaceWidgetChrome(builder: *Builder, widget: Widget, tokens: De .id = widgetPartId(widget.id, 2), .rect = widget.frame, .radius = radius, - .fill = widgetBackgroundFill(widget, buttonStateBackground(visual, widget.state.pressed or widget.state.selected, washHovered(widget), tokens.colors.surface)), + .fill = colorFill(surfaceStateBackground(widget, visual, tokens)), }); try builder.strokeRect(snapHairlineStrokeRect(tokens, .{ .id = widgetPartId(widget.id, 3), diff --git a/src/primitives/canvas/widget_routing.zig b/src/primitives/canvas/widget_routing.zig index c6619c48..b1ec9920 100644 --- a/src/primitives/canvas/widget_routing.zig +++ b/src/primitives/canvas/widget_routing.zig @@ -132,7 +132,7 @@ fn widgetHitFromNode(node: WidgetLayoutNode, index: usize) WidgetHit { /// claims presses (`widgetClaimsPress`). Plain text, icons, decorations, /// and layout containers let the press fall through; interactive kinds, /// editable text, scroll containers, overlay surfaces, and any widget -/// with a bound press/toggle handler stop the walk. Returns null when +/// with a bound press/toggle/drag handler stop the walk. Returns null when /// nothing on the path claims — the press dispatches to no one, exactly /// like a click on dead space. pub fn widgetPressTargetIndexFromNode(layout: anytype, node_index: usize) ?usize { diff --git a/src/primitives/canvas/widget_runtime_tests.zig b/src/primitives/canvas/widget_runtime_tests.zig index 10e5291a..be7bbd4d 100644 --- a/src/primitives/canvas/widget_runtime_tests.zig +++ b/src/primitives/canvas/widget_runtime_tests.zig @@ -1750,11 +1750,13 @@ test "widget emitter applies button variants" { try emitWidgetTree(&builder, .{ .id = 23, .kind = .button, .frame = geometry.RectF.init(0, 120, 120, 32), .text = "Ghost", .variant = .ghost }, tokens); try emitWidgetTree(&builder, .{ .id = 24, .kind = .button, .frame = geometry.RectF.init(0, 160, 120, 32), .text = "Delete", .variant = .destructive }, tokens); - // Every variant is FLAT (no shadow command): 5 x (fill + border + - // label). Destructive is the quiet red chip — the destructive hue - // as a 10% wash under destructive-red text, borderless. + // Every variant is FLAT (no shadow command): primary, secondary, and + // outline each emit fill + border + label; borderless ghost and + // destructive each emit only fill + label. Destructive is the quiet + // red chip — the destructive hue as a 10% wash under destructive-red + // text. No dead zero-width stroke commands enter the list. const display_list = builder.displayList(); - try std.testing.expectEqual(@as(usize, 15), display_list.commandCount()); + try std.testing.expectEqual(@as(usize, 13), display_list.commandCount()); switch (display_list.commands[0]) { .fill_rounded_rect => |fill| try expectFillColor(tokens.colors.accent, fill.fill), else => return error.TestUnexpectedResult, @@ -1775,19 +1777,13 @@ test "widget emitter applies button variants" { .fill_rounded_rect => |fill| try expectFillColor(transparentColor(), fill.fill), else => return error.TestUnexpectedResult, } - switch (display_list.commands[10]) { - .stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width), - else => return error.TestUnexpectedResult, - } - switch (display_list.commands[12]) { + try std.testing.expect(display_list.findCommandById(widgetPartId(23, 2)) == null); + switch (display_list.commands[11]) { .fill_rounded_rect => |fill| try expectFillColor(colorWithAlpha(tokens.colors.destructive, 0.10), fill.fill), else => return error.TestUnexpectedResult, } - switch (display_list.commands[13]) { - .stroke_rect => |stroke| try std.testing.expectEqual(@as(f32, 0), stroke.stroke.width), - else => return error.TestUnexpectedResult, - } - switch (display_list.commands[14]) { + try std.testing.expect(display_list.findCommandById(widgetPartId(24, 2)) == null); + switch (display_list.commands[12]) { .draw_text => |text| try std.testing.expectEqualDeep(tokens.colors.destructive, text.color), else => return error.TestUnexpectedResult, } diff --git a/src/primitives/canvas/widget_semantics_tests.zig b/src/primitives/canvas/widget_semantics_tests.zig index 925b048c..6a31705d 100644 --- a/src/primitives/canvas/widget_semantics_tests.zig +++ b/src/primitives/canvas/widget_semantics_tests.zig @@ -2083,10 +2083,14 @@ test "text editing affordance colors resolve tokens and per-widget overrides" { try std.testing.expectEqual(Color.rgb8(4, 5, 6).r, styled_wash.r); try std.testing.expectApproxEqAbs(@as(f32, 0.3), styled_wash.a, 0.001); - // A disabled field's ink mutes with its text. + // A disabled field keeps its resolved text identity at the shared + // disabled-wash strength instead of swapping to a flat gray. var disabled = field; disabled.state.disabled = true; - try std.testing.expectEqualDeep(tokens.colors.text_muted, textEditingInkColor(disabled, tokens)); + try std.testing.expectEqualDeep( + Color.rgba(tokens.colors.text.r, tokens.colors.text.g, tokens.colors.text.b, tokens.states.disabled_alpha), + textEditingInkColor(disabled, tokens), + ); } test "widget text fields render wrapped selection geometry" { diff --git a/src/primitives/canvas/widgets.zig b/src/primitives/canvas/widgets.zig index f4b45084..0da67d26 100644 --- a/src/primitives/canvas/widgets.zig +++ b/src/primitives/canvas/widgets.zig @@ -1510,6 +1510,18 @@ fn builtinComponentSemantics(descriptor: BuiltinComponentDescriptor, semantics: /// concentric with the container's at every position. pub const tabs_list_inset: f32 = 3; +/// Additive control-size step shared by token-backed chrome metrics and +/// token-independent kind defaults. The default alert inset uses the +/// house 16px base / 2px step here; runtime token packs feed their own +/// base and step through `widget_metrics.widgetSizedTokenValue`. +pub fn widgetSizeSteppedValue(size: WidgetSize, value: f32, step: f32) f32 { + return switch (size) { + .sm => @max(0, value - step), + .default, .icon, .heading, .display => value, + .lg => value + step, + }; +} + /// Ergonomic per-kind layout defaults for the composite surfaces whose /// house reference carries built-in content spacing, shared by EVERY /// authoring path — `builtinComponentWidget` and (via the ui builder, @@ -1526,7 +1538,7 @@ pub fn widgetKindDefaultLayout(kind: WidgetKind, size: WidgetSize) ?WidgetLayout .clip_content = true, }, .alert => .{ - .padding = geometry.InsetsF.all(16), + .padding = geometry.InsetsF.all(widgetSizeSteppedValue(size, 16, 2)), .gap = 12, .clip_content = true, }, diff --git a/src/runtime/canvas_widget_runtime.zig b/src/runtime/canvas_widget_runtime.zig index b911b02e..4c37bd06 100644 --- a/src/runtime/canvas_widget_runtime.zig +++ b/src/runtime/canvas_widget_runtime.zig @@ -550,7 +550,7 @@ pub fn canvasWidgetClipsContent(widget: canvas.Widget) bool { pub fn canvasWidgetRuntimeHitTarget(widget: canvas.Widget) bool { // Widget-level hit-target-ness lives in one place (canvas - // widget_access.zig: kind predicate plus bound press/toggle handlers) + // widget_access.zig: kind predicate plus bound press/toggle/drag handlers) // so the runtime, the engines' hit test, and the markup validation of // pointer handlers can never drift. return canvas.widgetIsHitTarget(widget); diff --git a/tools/bench-render-budgets.txt b/tools/bench-render-budgets.txt index 7f3c9fea..7dd8938a 100644 --- a/tools/bench-render-budgets.txt +++ b/tools/bench-render-budgets.txt @@ -25,8 +25,10 @@ first-frame 7500 # plan lookups; the pre-fix quadratic put this at ~6700. keystroke-big-view 3000 # toggle-big-view: guards one-emission-per-click and reconcile cost on a -# full TEA rebuild; the pre-fix number was ~14700. -toggle-big-view 9000 +# full TEA rebuild. Recalibrated against current main on 2026-08-16: +# healthy p50 10587us (this canvas series is faster at 10458us); 1.3x +# headroom rounds to 14000, still below the pre-fix ~14700 regression. +toggle-big-view 14000 # scroll-transcript: guards the text-layout/glyph-atlas planner indexes on # a text-heavy view; the pre-fix number was ~9300. scroll-transcript 5000 diff --git a/tools/native-sdk/markup_docs.zig b/tools/native-sdk/markup_docs.zig index 5c2cb9fc..e48ec0c8 100644 --- a/tools/native-sdk/markup_docs.zig +++ b/tools/native-sdk/markup_docs.zig @@ -31,8 +31,8 @@ pub const element_docs = [_]Doc{ .{ .name = "text", .doc = "Text leaf; content supports {} interpolation. Line policy via wrap: wrap=\"true\" word-wraps, wrap=\"false\" clips to one honest line. size takes the typography rungs heading|display for section headings and hero stats." }, .{ .name = "badge", .doc = "Text leaf badge; content supports {} interpolation." }, .{ .name = "button", .doc = "Text-bearing control; the label is the text content. Dispatch with on-press. icon draws a vector icon inline before the label (icon-only when the content is empty; give it a label) — one hit target, one enabled/disabled tint." }, - .{ .name = "checkbox", .doc = "Value control; bind checked, dispatch with on-toggle." }, - .{ .name = "radio", .doc = "Single-choice value control; bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." }, + .{ .name = "checkbox", .doc = "Text-bearing value control; the visible label is text content (or text=), bind checked, dispatch with on-toggle." }, + .{ .name = "radio", .doc = "Text-bearing single-choice value control; the visible label is text content (or text=), bind checked or selected. Selection dispatches on-change when bound, then on-toggle, then on-press for compatibility." }, .{ .name = "toggle", .doc = "Text-bearing toggle control; the label is the text content." }, .{ .name = "slider", .doc = "Value control; bind value, dispatch with on-change." }, .{ .name = "progress", .doc = "Value control; bind value." },