UI theming: a skin cascade from the UIRoot and style behind controlled setters (ME-57) - #631
Conversation
A theme is a skin, not a colour table: per role and per state it carries a background descriptor, a text style and insets. Insets are layout input, which is why a later style setter has to invalidate layout as well as paint. The background descriptor covers what the widgets draw today (a rounded fill with an optional border) and the nine-slice case the texture-based widget suite needs, so the value type does not have to change again when skins become textured. `defaultUITheme` reproduces the widgets' current look exactly and is frozen in development builds, so a caller who mutates a shared skin fails at the write instead of silently restyling every widget that inherited it.
…tion The theme travels the scene graph: `ThemedContainer` carries one for the subtree below it, `UIRoot` owns the layer's, and a widget resolves the nearest themed ancestor's theme with its own overrides applied on top. A widget without overrides reuses the inherited object by reference, so "did my theme change?" stays a pointer comparison, and reparenting re-resolves the same way `effectiveEnabled` already does. No global theme registry - two UI layers can carry different themes. Widgets now paint in `_repaint` and place content in `_relayout`, with `_invalidatePaint` and `_invalidateLayout` naming which of the two a change needs. A theme change takes the layout path: skin insets are layout input, so a paint-only refresh would be wrong as soon as a skin changes them.
Panel, Button, ProgressBar and Label read their look from the theme role they paint and keep only their own overrides. `WidgetBackground` owns the node a background descriptor needs - a `Graphics` for a fill, a `NineSliceSprite` for a texture - and swaps it in place at a fixed child slot, so the texture case is painted rather than merely declared. Style is no longer a mutable object handed out by a getter: `setFill`, `setBackground` and `setTextStyle` are the way in, and each one invalidates paint or layout as the change requires. Button carries one skin per state, so hover, pressed and disabled follow the theme instead of four hardcoded colours; label placement now respects the skin's insets. Serialization writes a widget's overrides instead of its resolved values, so a scene saved under one theme no longer replays that theme when it is loaded under another.
Adds the skin and theme types, `ThemedContainer`, and the widgets' new style accessors; refreshes the public export snapshots.
Adds a Theming section to the UI chapter: where a widget's skin comes from, what a background descriptor can be, and why style goes through a setter instead of a mutable style object.
A widget that paints a texture or nothing has no fill colour, so the colour getters are nullable now.
Bundle ReportChanges will increase total bundle size by 17.03MB (57.31%) ⬆️
Affected Assets, Files, and Routes:view changes for bundle: exo-iife-min-Exo-iifeAssets Changed:
Files in
view changes for bundle: site-server-esmAssets Changed:
Files in
App Routes Affected:
view changes for bundle: exo-full-iife-min-Exo-iifeAssets Changed:
view changes for bundle: exo-esm-esmAssets Changed:
Files in
view changes for bundle: exo-iife-Exo-iifeAssets Changed:
Files in
view changes for bundle: exo-esm-modules-esmAssets Changed:
Files in
Files in
Files in
view changes for bundle: exo-full-iife-Exo-iifeAssets Changed:
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Closes the UI group of the review backlog: the texture-suite design round (its output is a workspace spec, not code) and
ME-57Slice 1.ME-57is closed against its full ledger contract, not the narrowed slice: the theme cascade with local overrides is here, not deferred.What changed
A theme is a skin, not a colour table. Per role (
panel,button,label,progressBarTrack,progressBarFill) and per state (normal,hover,pressed,disabled,focused, falling back tonormal) it carries a background descriptor, a text style and insets. Insets are layout input, which is why style invalidation is paint and layout.The cascade runs through the scene tree.
ThemedContainercarries a theme for the subtree below it,UIRootowns the layer's, and a widget resolves the nearest themed ancestor's theme with its ownsetThemeoverrides on top. No global theme singleton — two UI layers can carry different themes. A widget without overrides passes the inherited object through by reference, so a cascade refresh is a pointer comparison; reparenting re-resolves the same wayeffectiveEnabledalready did.Style goes through setters.
setFill,setBackground,setTextStyle,setTheme— each invalidating what the change can actually move. The silently mutable style objects are gone:button.colorshands out values, not a live reference into the widget.The nine-slice case is painted, not just declared.
WidgetBackgroundowns whichever node a descriptor needs — aGraphicsfor a fill, aNineSliceSpritefor a texture — and swaps it at a fixed child slot, so a background that was dropped (zero size,'none'skin) comes back underneath the content rather than on top of it.Serialization writes overrides, not resolved values. A scene saved under one theme no longer replays that theme when it is loaded under another.
defaultUIThemereproduces the widgets' previous look exactly and is frozen in development builds.API notes (pre-1.0 break)
Panel.backgroundis now the background descriptor; the painted node isPanel.backgroundNode.Panel.color,ProgressBar.trackColor/fillColor,Button.colors[state]): a widget painting a texture or nothing has no fill colour.UITheme,UISkin,UISkinSet,UIBackgroundand friends,createUITheme,defaultUITheme,resolveUISkin,ThemedContainer,ButtonState.Validation
pnpm gates typecheck,typecheck:examples,typecheck:guides,docs:api:check,full-bundle:exports:check, source-hygiene, and the lint/format gates.test/ui(39 widget + 11 cascade + 8 theme cases) andtest/core/serialization.test.ts.verify:quickplus every selected lane, including the browser lanes.