ZenitUI — Roadmap
This document tracks the path from the current alpha state toward a beta release and, eventually, 1.0. It's both a plan and a snapshot of the current state: each section lists what's done, what's pending, and what's blocking.
It's derived from a full code + documentation review. The blocking items (bugs, missing API, critical test gaps) are prioritized. The nice-to-have items are collected at the end.
Last updated: review pass after the initial documentation.
Status
Current: 🗓️ beta — the core API is frozen, no known critical bugs, test coverage on every widget, documentation complete. Breaking changes are still possible for post-beta work (Phases 4–8) but the surface described in Phases 1–3 is stable.
Next milestone: 🚀 1.0 — feature-complete for a typical game / tool use case, stable API, tooling available. Requires Phases 4 and 5.
What "beta" means in this project:
- Phase 1 (bug fixes) — complete.
- Phase 2 (missing core API) — complete.
- Phase 3 (test coverage) — complete.
- Documentation — audited and updated.
Phases 4–8 are post-beta work. They are additive and don't require breaking the API.
Phase 1 — Bug fixes
Before anything else. These are known bugs or behaviors that don't match the documented intent. They should be fixed before adding new features.
1.1 onHoverExit fires on Hover → Pressed
Severity: high — affects every widget that uses hover callbacks (tooltips, highlights).
Location: src/Input.cpp, InputController::fireCallbacks.
Problem: when a node is hovered and then pressed, the state
transition is Hover → Pressed, and the current logic fires
onHoverExit because Pressed != Hover.
Expected: onHoverExit should only fire when the pointer actually
leaves the node. Pressed is a geometric subset of Hover.
Fix: treat both Hover and Pressed as "hovered" in the callback
condition:
bool wasHovered = (prevState == UIState::Hover || prevState == UIState::Pressed);
bool isHovered = (nextState == UIState::Hover || nextState == UIState::Pressed);
if (isHovered && !wasHovered && node.onHoverEnter) node.onHoverEnter();
else if (!isHovered && wasHovered && node.onHoverExit) node.onHoverExit();
Test: add a test in test_interaction.cpp that verifies
onHoverEnter fires once and onHoverExit doesn't fire on press.
- [x] Fix the condition in
InputController::fireCallbacks - [x] Add regression test
- [x] Update documentation (remove the "known issue" note in
internals/07-input-system.md)
1.2 resetIfOverflowChanged uses a stale snapshot
Severity: medium — causes spurious scroll resets in some sequences.
Location: src/ScrollController.cpp,
ScrollController::resetIfOverflowChanged; src/StyleResolver.cpp,
StyleResolver::propagateInheritance.
Problem: resetIfOverflowChanged compares the current overflow
against lastInherited.overflowX/Y. But propagateInheritance only
updates lastInherited when some other property changed — overflow
is not in the changed check. So the snapshot is updated
inconsistently, and resetIfOverflowChanged can see spurious changes.
Fix: either (a) add overflowX / overflowY to the changed
check in propagateInheritance, or (b) give the scroll controller its
own overflow snapshot, updated after resetIfOverflowChanged runs.
Option (b) is cleaner — it decouples the scroll system from the inheritance snapshot, which has a different purpose.
Test: add a test that changes overflow without changing anything
else and verifies that reset() is called exactly once.
- [x] Move the overflow snapshot into
ScrollController - [x] Update
resetIfOverflowChangedto use the local snapshot - [x] Add regression test in
test_interaction.cpp - [x] Remove the "known issue" note in
internals/08-scroll-system.md
1.3 translateSubtree translates portals
Severity: medium — affects portals inside scroll containers.
Location: src/Measure.cpp, Layout::translateSubtree.
Problem: the isPortal() check is commented out. Portal nodes and
their descendants get translated by the scroll offset. The portal's own
rect is usually overwritten in its onUpdate, but its children
are translated, which can move them unexpectedly.
Expected: portals should be opaque to translateSubtree — their
position is independent of the scroll container.
Fix: re-enable the check:
void Layout::translateSubtree(float dx, float dy) {
for (auto& c : children) {
if (c->isPortal())
continue;
c->rect.x += dx;
c->rect.y += dy;
c->translateSubtree(dx, dy);
}
}
Test: create a ScrollView with a Popup child, scroll it, verify
the popup's rect doesn't move.
- [x] Re-enable the
isPortal()check - [x] Add regression test
- [x] Verify
Dropdownstill works inside aScrollView - [x] Update
internals/08-scroll-system.md
1.4 Vec3 / Vec4 filter params scale to [0, 255]
Severity: medium — affects every filter that uses those types.
Location: src/FilterRegistry.cpp, setParams.
Problem: Vec3 and Vec4 interpret their float arguments as
[0, 1], clamp, and multiply by 255. The shader receives a vec4 in
[0, 255]. This is not what a shader author expects for a vec3 or
vec4 uniform.
Fix (preferred): make Vec3 / Vec4 pass their values as-is (in
[0, 1]) via a new setEffectVec3 / updated setEffectVec4. Keep
Color as the uint8_t-based type.
Fix (alternative): document the current behavior explicitly and require shaders to normalize. Worse, but cheaper.
Test: add a filter test that sets a Vec4 param and verifies the
uniform value. Requires a way to read back uniforms from the mock
renderer.
- [x] Decide between preferred and alternative
- [x] Implement
- [x] Add test
- [x] Update
user/09-effects.mdandapi/FilterRegistry.md
1.5 hitTest ignores the current clip
Severity: low-medium — causes clicks on invisible parts of clipped nodes.
Location: src/Measure.cpp, Layout::hitTest.
Problem: the final rect.contains(p) check doesn't account for the
active clip rect. A node clipped by an ancestor's overflow: hidden is
still hit-testable on its full rect.
Fix: intersect rect with renderer->getClipRect() before
contains(p). The clip is in screen coordinates, so this requires
careful transform handling.
Test: create a clipped node, hit-test outside the clip but inside the rect, verify no hit.
- [x] Implement clip-aware hit-testing
- [x] Add regression test
- [x] Consider performance (a
getClipRect()call per node per hit-test)
1.6 notifyFocusAncestors not called on click-focus
Severity: low — asymmetry between Tab and click focus.
Location: src/Layout.cpp, Layout::updateTree.
Problem: when focus is granted via click, notifyFocusAncestors is
not called. So a container's onDescendantFocused doesn't fire on
click, only on Tab.
Fix: call notifyFocusAncestors after the requestFocus in the
pointer-pressed branch.
Test: create a focusable node inside a container with
onDescendantFocused, click it, verify the callback fired.
- [x] Add the call
- [x] Add test
- [x] Update
internals/07-input-system.md
1.7 Dropdown hardcoda height: VH(3.0f) in inlineBase
Severity: medium — affects every dropdown whose trigger needs a different height than the default.
Location: src/ui/components/Dropdown.hpp, Dropdown::onBuild.
Problem: the trigger button's height (and most of its visual
properties) are set with getInlineBase(), which wins over any
.zstyle rule. A user can't change the trigger's height via CSS, and
if they add padding larger than the baked-in height, the text gets
clipped to invisibility.
Expected: visual defaults should live in inlineDefaults (the
"user agent" layer), so a .zstyle rule can override them. Only
structural properties — like width: 100%, which is what makes the
trigger fill its dropdown — should stay in inlineBase.
Fix: in onBuild, move height, background, radius,
borderColor, borderWidth, itemsH, itemsV from inlineBase to
inlineDefaults for both the trigger button and the list container.
Test: add a test that sets .dropdown-trigger { height: 60px; }
in a stylesheet and verifies the trigger's actual rect height.
- [x] Move visual properties to
inlineDefaults - [x] Add regression test
- [x] Update
user/03-widgets.md(Dropdown section)
1.8 Imperative animations don't progress
Severity: high — breaks every imperative animation that writes
into inlineBase.
Location: src/ui/layout/StyleResolver.cpp,
StyleResolver::resolvePendingTransition.
Problem: an imperative animation writes its interpolated value
into inlineBase on every frame. This sets pendingTransition = true,
which triggers resolvePendingTransition on the next frame. When the
new target differs from the previous one, the method resets
transitionTimer = 0.0f and transitionStartStyle = currentStyle.
Since the target changes every frame, the timer is reset every frame
and currentStyle never reaches it. As a result, imperative
animations produce no visible movement.
Expected: while an imperative animation is running, the CSS
transition system must step aside. currentStyle should follow the
target directly (snap), with no interpolation — the animation itself
provides the interpolation, and the resolver must not layer a second
transition on top.
Fix: added AnimationPlayer::hasActiveImperative(). In
resolvePendingTransition, when this returns true, currentStyle is
aligned immediately to the target instead of starting a transition.
Test: added Anim_imperative.drives_current_style_without_transition
in tests/test_animations.cpp.
- [x] Add
hasActiveImperativetoAnimationPlayer - [x] Update
resolvePendingTransition - [x] Add the regression test
- [x] Update
internals/03-style-system.md - [x] Update
internals/04-animation-system.md - [x] Update
user/07-animations.md
1.9 CanvasLayout draws with wrong coordinates inside the target
Severity: high — the canvas renders empty or clipped.
Location: src/ui/components/Canvas.hpp, CanvasLayout::draw.
Problem: pushTarget() resets the coordinate system to (0, 0) at
the target's top-left, but the node's rect (and its children's rects,
and renderChrome's use of rect) are still in screen coordinates.
Drawing at screen coordinates inside a target of a smaller size produces
nothing visible: everything is drawn outside the target bounds.
Expected: the subtree must be offset by -rect.x, -rect.y while
drawing inside the target, so it starts at the target's (0, 0).
Fix: push an offset transform around the subtree draw, exactly like
Layout::drawLayer does:
Transform2D offsetTr;
offsetTr.pivot = {0.0f, 0.0f};
offsetTr.translate = {-rect.x, -rect.y};
r->pushTarget(target);
r->pushTransform(offsetTr);
// ... renderChrome, renderContent, children
r->popTransform();
r->popTarget();
Test: render a canvas with a child that has a known color, then verify the target's pixels contain that color. Requires a framebuffer readback, so this test is easiest with the real Raylib backend rather than the mock.
- [x] Add the offset transform
- [x] Add a regression test (needs framebuffer readback)
- [x] Update
user/10-custom-widgets.mdif the canvas is mentioned
Phase 2 — Missing core API
Small, high-impact additions that unblock common use cases. No new features — just filling gaps.
2.1 Slider::setValue(float)
Add a public setter that clamps to [0, 1] and optionally fires
onValueChanged. A bool notify parameter (default true) lets
callers suppress the callback for programmatic updates.
- [x] Add method to
Slider - [x] Add test
- [x] Update
user/03-widgets.mdandapi/Widgets.md
2.2 Layout::removeClass / clearStyleClasses
Without these, CSS animations declared under a class can't be stopped without rebuilding the node. Also blocks dynamic styling use cases.
- [x] Add
removeClass(const std::string&) - [x] Add
clearStyleClasses() - [x] Add
hasClass(const std::string&) const - [x] Add tests
- [x] Update
api/Layout.md
2.3 TextInput cursor positioning
- [x]
setCursorPos(int)with clamping - [x]
getCursorPos() const - [x] Click-to-position: on click, compute the cursor index from the pointer's x position
- [x] Add tests
- [x] Update documentation
2.4 Layout::scrollIntoView
Scroll the nearest scroll-container ancestor so the node is visible.
Common pattern after requestFocus.
- [x] Implement
scrollIntoView()walking up to find the scroll ancestor - [x] Implement
scrollIntoView(const Rect& subRect)for partial visibility - [x] Add tests
- [x] Update documentation
2.5 Button text accessor
Btn(text, cb) creates a Text child but there's no accessor. Add:
- [x]
Button::text() constreturning the current label - [x]
Button::setText(std::string)updating the child
2.6 Text::getText()
setText exists, getText doesn't.
- [x] Add
const std::string& getText() const
2.7 Layout::removeAllChildren()
A safe bulk-clear for the children vector. Useful for rebuilding
lists.
- [x] Add method
- [x] Consider whether it should also clear
parentpointers of the removed children
2.8 Compound selectors in ZStyle
Severity: high — ZStyle doesn't support Tag.class,
.class1.class2, Tag#id, or Tag.class:pseudo. The parser reads
the whole token as a single name, so Button.btn-primary never
matches any node.
Location: src/ui/StyleParser.hpp, the selector-splitting step
inside parseRule; Style.hpp, SimpleSelector; Layout.hpp,
nodeMatchesSimple and ruleMatches.
Problem: a token like Button.btn-primary is parsed as
SimpleSelector{kind=Tag, name="Button.btn-primary"}, which matches
nothing. The parser only handles a single component per token:
Tag, .class, #id, and their :pseudo variants. It does not
distinguish a compound selector (Tag.class, one node) from a
descendant selector (Tag .class, two nodes).
Expected: the parser must recognise compound selectors and the matcher must require all their components to match the same node.
Design options:
- A. Extend
SimpleSelectorwith optional extra components (Opt<TagName>,std::vector<ClassName>,Opt<IdName>) and updatecomputeSpecificity,nodeMatchesSimple,Debug::selectorToString. - B. Introduce a
CompoundSelectorstruct and changeThemeRule::chaintostd::vector<CompoundSelector>. - C. Pre-process the selector string at parse time: emit one
SimpleSelectorper component and add abool compoundflag on theSimpleSelectorto indicate "same node as next".
Recommendation: option C is the smallest change and keeps the existing chain semantics for descendant matching.
Test: a stylesheet with Button.btn-primary { background: red; }
applied to a Button with class btn-primary must produce a red
button.
- [x] Extend the parser to split compound selectors
- [x] Update
nodeMatchesSimple/ruleMatches - [x] Update
computeSpecificity - [x] Add tests for
Tag.class,.class1.class2,Tag#id,Tag.class:hover - [x] Update
user/05-zstyle.md(add a section on compound selectors) - [x] Update
api/ZStyle.md
2.9 Modal intercepts clicks while hidden
Severity: high — a hidden Modal blocks input to any node under it.
Location: src/ui/components/Modal.hpp, show / hide / onBuild.
Problem: the Modal is full-screen (width: 100%; height: 100%) and
onBuild only sets setInteractive(false). Its content VStack and the
content's descendants remain enabled and interactive. The hitTest
doesn't check opacity, so the invisible content captures clicks on any
node that happens to be under its rect — including buttons in the page
behind it.
Expected: while the modal is hidden, it must not participate in hit-testing at all. While visible, it must capture input (that's the point of a modal).
Fix: setEnabled(false) in onBuild (and after hide()), and
setEnabled(true) in show(). Since hitTest returns early when
!isEnabled, disabling the modal prevents the recursion into its
content — the click falls through to the page.
Test: add a test that adds a Modal on the same tree as a
clickable button, positioned so that the button's rect overlaps the
modal's full-screen rect. Verify that clicking the button works while
the modal is hidden, and does not work while the modal is shown.
- [x] Disable the modal in
onBuild - [x] Enable it in
show(), disable it inhide() - [x] Add the regression test
- [x] Update
user/03-widgets.md(Modal section) - [x] Update
api/Widgets.md
Phase 3 — Test coverage
The current test suite covers the foundations well (parsing, layout basics, basic interaction) but has gaps on composite widgets and the scroll system.
3.1 Scroll system
No tests currently exist for the scroll system. Add:
- [x]
scrollTo,scrollToX/Y,scrollToTop/Bottom/Left/Right - [x]
getScrollX/Y,getMaxScrollX/Yafter arrange with overflow - [x] Wheel event increments velocity
- [x] Inertia decays velocity over time
- [x]
maxScrollis zero when content fits - [x] Scrollbar thumb hit-test and drag
- [x]
resetIfOverflowChanged(once 1.2 is fixed) - [x] Hover freeze during scroll
3.2 TextInput
- [x] Character insertion
- [x]
Backspace/Deletewith cursor position - [x]
Left/Rightcursor movement - [x]
Home/End - [x] Key repeat (initial delay + interval)
- [x]
onTextChangedfires on edit - [x]
onSubmitfires on Enter - [x]
Escapereleases focus without submitting - [x] Horizontal scroll of the cursor
- [x] Click focuses the input
3.3 Text wrapping
- [x]
setWrap(true)with a bounded parent width - [x] Multiple lines
- [x] Re-wrap on width change (
onLayout) - [x] No wrap when parent width is unbounded
3.4 Dropdown
- [x]
setSelectedfiresonChange - [x] Opening the list shows options
- [x] Clicking an option sets the selection and closes
- [x] Clicking outside closes
- [x] Flip above when no space below
- [x] List scrolls when options exceed the visible area
3.5 State transitions end-to-end
- [x]
:hoverwithtransition:interpolates over N frames - [x]
:checkedwith transition (Toggle knob) - [x]
:focusstyling applies on focus - [x]
:disabledstyling applies on disable - [x] Transition completes and snaps to target
3.6 CSS animations end-to-end
- [x]
@keyframeswithanimation:changes the render style - [x]
infiniteanimation keeps ticking - [x]
alternatereverses every other iteration - [x]
forwardskeeps the last keyframe - [x]
::partanimation ticks - [x]
onAnimationsFinishedfires
3.7 Focus scopes
- [x] Tab stays within a scope
- [x] Nested scopes: inner scope wins
- [x] Focus release on click outside
- [x] Focus release on disable
3.8 Portal hit-test delay
- [x] Verify that a newly-opened portal becomes hit-testable on the next frame, not the same one
- [x] Verify that a destroyed portal is skipped
3.9 Media queries on viewport change
- [x] Setting
Metrics::viewporttriggers a style re-resolve - [x]
@media (min-width: 600px)matches / doesn't match based on the viewport
Phase 4 — Performance
These are measurable wins. None are blocking, but they compound.
4.1 Value without heap allocation
Value uses std::vector<Term>, which allocates for any non-empty
value. Since a ComputedStyle has ~30 Value fields and is copied
every frame per node, this is the single largest allocation source.
Options:
- A:
SmallVector<Term, 2>— covers 1 and 2 terms without allocation. Requires a small custom container. - B:
std::variant<Term, std::pair<Term, Term>, std::vector<Term>>— variant covers 1 and 2 terms, vector covers 3+. More complex API. - C:
std::array<Term, N>with a size, and a fallback to vector when N is exceeded. Fixed-size array is small but wastes space for the common 1-term case.
Recommendation: A — it's the least invasive and gives the biggest win for the common case.
- [x] Implement
SmallVector<Term, 2> - [x] Replace
std::vector<Term>inValue - [x] Benchmark before/after
- [x] Verify no behavioral change
4.2 Cache renderStyle in draw
Currently every node copies its ComputedStyle in draw:
ComputedStyle renderStyle = style_.currentStyle;
Anim::overlayCssComputed(renderStyle, anim_.css);
The copy involves std::string, std::vector, and
std::unordered_map fields.
Options:
- A: A per-node cached
ComputedStyle renderStyle_reused each frame. Apply the overlay in place. - B: Skip the copy when there are no active animations (the common
case), and use
style_.currentStyledirectly.
Recommendation: B first (simple), then A if the cache needs to persist.
- [x] Implement option B (fast path with no copy)
- [x] Benchmark
The copy of ComputedStyle is only performed when the node has
active CSS keyframe animations (anim_.css non-empty). When there are
none — which is the case for the vast majority of nodes, since state
transitions are handled by StyleResolver and not by
AnimationPlayer::css — draw skips the copy and calls
drawWithStyle directly with style_.currentStyle.
This is a pure performance optimization: the two paths behave
identically. ComputedStyle is ~720 bytes with the current Value
layout, so skipping the copy saves ~0.15 us per drawn node per frame.
On a scene with 20 visible nodes, that's ~3 us per frame — a
measurable fraction of the frame budget.
4.3 Rule indexing by tag
StyleResolver::resolveFor scans all Theme::rules linearly. For
stylesheets with hundreds of rules and nodes that change state
frequently, this is a hotspot.
Fix: Theme now maintains two indices, updated incrementally by
addRule:
rulesByTag[tag]— rules whose rightmost selector is that tag.rulesNoTag— rules whose rightmost selector is a class or an id (they can match any node and must always be tested).
resolveFor scans only the bucket for the node's tag plus
rulesNoTag, instead of the full rule list. ::part rules are not
indexed; they are resolved by partFor, not by resolveFor.
Benchmark: system.frame.200rules — 100 nodes, 200 rules, style
re-resolve on every node per frame.
| Before 4.3 (estimated) | After 4.3 |
|---|---|
| ~2.3 ms per frame | ~1.63 ms per frame |
The estimated "before" is based on a local run with the index
disabled; see the analysis below. Because the cost of resolveFor
is dominated by ComputedStyle::from (cascade, var resolution,
inheritance) rather than by ruleMatches, the index helps but does
not eliminate the cost. The visible win is proportional to the rule
count: with 2 rules the index changes nothing, with 200 it halves
the matching cost.
- [x] Add the index to
Theme - [x] Update
resolveForto use it - [x] Benchmark with a large stylesheet
- [x] Handle invalidation correctly (
clearresets both indices)
4.4 Cache animation evaluation
Severity: low — measurable improvement, not blocking.
Location: src/ui/AnimPrimitives.hpp, src/ui/layout/AnimationPlayer.cpp.
Problem: sampleActive is called twice per animation per frame:
once in the tick phase (tickIncremental / tickAbsolute) to detect
whether the animation just finished, and once in the overlay phase
(overlayCssComputed / overlayCssStyle) to compute localT for the
keyframe evaluation. Between the two calls, elapsed doesn't change,
so the second call is guaranteed to return the same value as the first.
Expected: compute sampleActive once per animation per frame and
reuse the result in the overlay phase.
Fix (implemented): added sampleActiveCached, a thin wrapper around
sampleActive that caches localT and finished keyed on elapsed.
The cache fields (cachedSampleElapsed, cachedLocalT, cachedFinished)
are mutable on ActiveCssAnimation because sampleActive is logically
a read-only query. There's no frame counter: the elapsed time itself is
the cache key, so the cache is invalidated automatically whenever the
animation advances, and it survives across frames if dt == 0.
Benchmark: micro.tickCss.100 improved by 9.1% (10.06 → 9.14 µs).
system.frame.animated100 improved by 1.8% (275 → 270 µs).
- [x] Add
cachedSampleElapsed/cachedLocalT/cachedFinishedtoActiveCssAnimation - [x] Add
sampleActiveCachedwrapper inAnimPrimitives.hpp - [x] Use
sampleActiveCachedintickIncremental,tickAbsolute,overlayCssStyle,overlayCssComputed - [x] Add
micro.tickCssAndOverlay.100benchmark - [x] Add regression tests in
test_animations.cpp - [x] Update
internals/04-animation-system.md
4.5 Optimize isInheritedProp
Severity: low — measurable on style resolution, not blocking.
Location: src/ui/Style.hpp, isInheritedProp.
Problem: isInheritedProp is called once per non-set property per
call to ComputedStyle::from when a parent is present. For a typical
style resolution, that's ~40 calls per node per frame. Each call is a
chain of up to 5 string comparisons:
return name == "font" || name == "fontSize" || name == "color"
|| name == "letterSpacing" || name == "textAlign";
For the ~40 non-inherited properties, which is the vast majority, this walks the entire chain and returns false.
Expected: short-circuit on a property's string length, since the five inherited names have unique lengths (4, 5, 8, 9, 13).
Fix (implemented): dispatch on name.size() with a switch, then
do a single string comparison inside each case:
inline bool isInheritedProp(std::string_view name)
{
switch (name.size()) {
case 4: return name == "font";
case 5: return name == "color";
case 8: return name == "fontSize";
case 9: return name == "textAlign";
case 13: return name == "letterSpacing";
default: return false;
}
}
For a property with a length that doesn't match any of the five (e.g.
width → 5, padding → 7, background → 10), the switch returns false
after a single integer comparison. For a property with a matching
length (e.g. width → 5, minWidth → 8, minHeight → 9), the single
string comparison discriminates correctly.
Benchmark: micro.ComputedStyle.from_with_parent added to
isolate the cost of the parent branch (the pre-existing
micro.ComputedStyle.from passes nullptr as parent, so it never
exercised isInheritedProp).
- [x] Add
micro.ComputedStyle.from_with_parentbenchmark - [x] Rewrite
isInheritedPropwith a length-based switch - [x] Add unit tests for the length-collision cases
- [x] Benchmark (measure improvement)
- [x] Update
api/Style.md§"Inheritance"
4.6 Vector resize audit
Layout::drawChildren uses three member vectors (drawNegZ_,
drawNormal_, drawPosZ_) to partition children by stacking context
before drawing. They're reused across frames (cleared with .clear(),
which preserves capacity) and should reach a steady state after the
first frame with the maximum child count.
Verification: added micro.tree.drawChildren.500 — a root with
500 children, all with explicit z-index, so they land in the posZ /
negZ buckets. The benchmark measures pure draw() cost.
Result: 37.1 µs with sd 1.2 µs (3%). Stable timing indicates no
per-frame allocation. clear() + push_back reaches capacity within
the first run and stays there. No code change required.
- [x] Add
micro.tree.drawChildren.500benchmark - [x] Verify no per-frame allocation
- [x] Document the "grow-once, then reuse" pattern in
internals/06-render-pipeline.md
4.7 Build system: -O2 by default
Severity: critical — all previous benchmark numbers were measured against an unoptimized library.
Location: Makefile (repository root).
Problem: the library was compiled with -ggdb only, no -O2. The
benchmarks linked the debug library, so every measurement was inflated
by 3–10x. This was discovered when cf17e3dc (the commit that added
the 4.4 cache) showed every single benchmark jumping by ~2x compared
to the previous commit — a pattern too uniform to be a code
regression, and specifically impossible for benchmarks like
micro.tree.addChild.1000 that touch no animation code.
Fix (implemented): the default BUILD profile is now release
(-O2 -ggdb). make BUILD=debug restores the old behavior for
interactive debugging. The fix was verified by rebuilding locally
and observing 50–90% improvements across every benchmark dominated by
framework code.
Benchmark deltas after the fix (local):
| Benchmark | Before | After | Δ |
|---|---|---|---|
micro.tickCss.100 |
10.06 | 0.91 | -91% |
micro.tickImperative.100 |
198.8 | 41.5 | -79% |
system.frame.static100 |
164.4 | 42.96 | -74% |
system.frame.200rules |
1632.8 | 295.6 | -82% |
system.frame.animated100 |
275.0 | 114.9 | -58% |
system.frame.large1000 |
1653.1 | 500.8 | -70% |
- [x] Change
Makefiledefault to-O2 -ggdb - [x] Add
BUILD=debugopt-out - [x] Regenerate
benchmarks/baseline.json - [x] Update
internals/11-benchmarks.mdwith a note on build profiles
Phase 5 — Feature completion
Not blocking for beta, but needed for 1.0.
5.1 Text input (advanced)
- [ ] Selection (shift+arrow, shift+home/end)
- [ ] Cut / copy / paste (
Ctrl+C,Ctrl+V,Ctrl+X) - [ ] Select all (
Ctrl+A) - [ ] Undo / redo (
Ctrl+Z,Ctrl+Y) - [ ] Click-to-deselect
- [ ] Double-click to select word
- [ ] Triple-click to select all
5.2 Font handling
- [ ] Font fallback chain (e.g.
font: mont, calibri, sans) - [ ] Runtime font loading / registration
- [ ] Font metrics caching
5.3 calc() extensions
- [ ]
min(a, b)/max(a, b)/clamp(min, val, max) - [ ]
round(),floor(),ceil() - [ ]
mod()
5.4 Filter and effect chaining
Two separate limitations:
5.4a — Filter-to-filter chaining. ✅ Done.
Each filter in a filter: list now reads the previous filter's output
instead of the original layerTarget_. filter: blur(2px), sepia(0.6)
composes as sepia(blur(original)), matching CSS's order semantics.
The chain uses a ping-pong between layerTarget_ and layerScratch_:
each filter reads from one target and writes to the other, alternating
per step. The last filter writes to the framebuffer with the node's
opacity applied; intermediate writes use full opacity to avoid
compounding alpha.
A third target (layerSeparable_) was added to Layout for Separable
patterns (blur): their H-pass intermediate would otherwise overwrite
one of the two ping-pong targets mid-computation. It's allocated
lazily, with the same dimensions as the other two.
FilterContext gained a write_target field. A filter that receives
a valid write_target renders into it; a filter that receives an
invalid one is the last in the chain and writes to ctx.region with
ctx.opacity applied. Custom filters must honor this contract.
- [x] Add
write_targettoFilterContext - [x] Update
applySinglePass/applySeparable/applySilhouetteto write towrite_targetwhen valid - [x] Add
layerSeparable_toLayout(allocated lazily indrawLayer) - [x] Rewrite the chain loop in
drawLayerto ping-pong between the two main targets - [x] Extend
MockRendererto support effects and countpushTarget - [x] Add
Filter_chaining.two_filters_ping_pong_through_targetstest - [x] Verify visually in
examples/07_effects(three new cards: blur → sepia, blur → sepia → drop-shadow, drop-shadow → blur) - [x] Update
user/09-effects.md§1.5, §3.3, §7 - [x] Update
internals/06-render-pipeline.md§4.5 and Pitfalls - [x] Update
api/FilterRegistry.md§6 and §7
5.4b — effect: and filter: on the same node. ✅ Done (documentation).
The two properties operate at different pipeline levels:
effect:pushes a shader around the node's own draw calls. Each primitive is processed individually — the shader does not see the composited subtree.filter:renders the subtree into a target and processes the composited image.
When both are set, the effect runs first (during the subtree render into the layer target, once per draw call), then the filter chain runs on the composite. This is intentional: the two serve different purposes and combining them is valid but rare.
Decision: document rather than unify. Unifying would collapse the
two properties into one at the cost of a breaking change to the
.zstyle surface, and would lose the ability to express per-primitive
shaders. Not worth it for a rare use case.
- [x] Document the behavior in
user/09-effects.md§7.5 - [x] Explain the pipeline order, when each is appropriate, and how
to get "one shader on the composite" semantics via a
Customfilter - [x] Note the cost when both are used together
- [ ] (Deferred) If per-primitive shader needs grow, revisit and
consider making
effect:a shorthand for a single-passCustomfilter that operates on the composite
A future revisit could unify effect: as a shorthand for a
single-pass Custom filter, but the current split is preserved
because it covers the per-primitive use case cleanly.
5.5 Nested render targets
Currently the Raylib backend doesn't support nested targets, so a filtered node inside a filtered node falls back to inline. To lift this:
- [ ] Implement a target stack in the backend
- [ ] Update
isLayerinLayout::drawto checksupports(Feature::NestedTargets) - [ ] Verify in tests
- [ ] Update documentation
5.6 Rotation in drawLayer
The transformed bounding box used for the filter region now accounts
for rotation, not just scale and translate. The four corners of rect
are transformed by applyTransform (translate, scale, rotate around
the node's center), then the AABB is taken from the four results.
This matches the same transform used by transformClipToScreen (in
the backend) and by hitTest in its inverse form, so "the node's
transformed area" is computed consistently everywhere.
- [x] Apply
Transform2D::rotationDegto the four corners - [x] Compute the AABB
- [x] Use it for the region
- [x] Add test (
Filter_rotation.region_covers_rotated_bbox) - [x] Remove the "rotation ignored" pitfall from internals/06-render-pipeline.md
5.7 Popup / Dropdown open/close callbacks
- [x]
Popup::onOpen - [x]
Dropdown::onOpenChanged - [x]
Modal::onShow/onHide
5.8 Layout::hasClass, Layout::hasId
- [x] Simple accessors
5.9 Vector accessor for callbacks
Currently callbacks are individual std::function fields. A future
version could expose a vector for multi-listener patterns. Not critical
but sometimes needed.
- [ ] Decide if needed
- [ ] If yes, add
addOnClickListeneretc.
5.10 Live reload for .zstyle — delegated to the consumer
Moved out of the library scope.
Hot reload is a consumer concern, not a library concern. ZenitUI provides all the primitives a project needs to implement it externally:
Theme::get().clear()resets the loaded stylesheets.ZMarkup::loadStyleFile(path)reloads them.UIContext::get().invalidateStyles()(added in 5.10a, see below) forces every node to re-resolve on the next frame.
The decision of when to reload (file mtime polling, native file
watchers, editor integration) and what to reload (all stylesheets,
just the one that changed) belongs to the project. The project for example
implements its own in src/core/StyleHotReload.hpp.
This is the pattern for future proposals: if the feature only makes sense for one kind of consumer, it stays out of the library.
5.10a UIContext::invalidateStyles() — small helper
Currently the only way to force a full style re-resolve is to bump
UIContext::viewportGeneration manually:
UIContext::get().viewportGeneration++;
This works, but it's using a media-query counter for a purpose it wasn't designed for. The intent isn't obvious from the call site.
Add a void invalidateStyles() method that bumps the counter (and
does nothing else for now, but is the natural place for future
invalidation logic):
void invalidateStyles() { viewportGeneration++; }
Callers become self-documenting:
Theme::get().clear();
ZMarkup::loadStyleFile("assets/game.zstyle");
UIContext::get().invalidateStyles();
- [x] Add
UIContext::invalidateStyles() - [x] Update
api/UIContext.md - [x] Update the live-reload example in
user/05-zstyle.md(if it exists)
5.11 Layout.hpp restructuring
Layout.hpp is the largest single header in the project. It declares
~50 public methods, ~20 protected boolean flags, ~20 protected fields,
four friend struct declarations, and the entire CRTP helper
TLayout<Derived, Base>. Every widget includes it, so it's on the
critical path for compile time.
Four strategies have been identified, in increasing order of scope and risk. They're listed here for context — not all of them should be adopted.
5.11a — Group state flags into a bitfield struct
Today, the ~20 boolean flags on Layout are declared individually:
bool isInteractive{true}, blocksRaycast{false}, isHovered{false},
wantsRemoval{false}, pendingTransition{true}, isEnabled{true};
bool isFocused{false};
bool isFocusable_{false};
// ... etc
Grouping them into a single bitfield struct would:
- Cut ~20 bytes per node (marginal but real on large trees).
- Remove ~20 lines from the header.
- Make the state of a node readable at a glance.
The caveat: bitfield layout is implementation-defined by the ABI, and
you can't take the address of an individual bitfield member. The
current code reads flags by value (node.isHovered, node.getEnabled())
and only ever takes the address of the whole Layout (e.g.
ctx.hasFocus(&node)), so the conversion is technically compatible.
But it needs careful testing, especially for anything that relies on
memcmp / operator== on a Layout (unlikely, but worth checking).
Priority: medium. Not blocking, low risk if tested.
5.11b — Event listener array (5.9)
See 5.9. Already planned.
5.11c — Remove friend declarations
The four subsystems (StyleResolver, AnimationPlayer,
InputController, ScrollController) are friends of Layout so
they can write directly into its private state during the frame.
Removing the friends would force the subsystems to operate on an
explicit state struct, with Layout applying the result:
auto newState = input_.computeState(rect, ctx.pointer);
if (newState.isHovered != flags.isHovered) {
flags.isHovered = newState.isHovered;
flags.pendingTransition = true;
}
This is cleaner on paper. But it costs: one struct construction +
return + copy per subsystem per node per frame. On system.frame.large1000
(1000 nodes), that's measurable. The friend approach is what keeps
the per-node update path tight.
Decision: not recommended. The friend declarations are a
deliberate design choice, not a smell. If future profiling shows the
subsystems are hot spots, optimize them within the current structure
instead of paying the composition cost.
5.11d — Split into DOMNode / StyledNode / Layout
The "fat node" pattern is what every retained-mode UI framework ends up with (HTMLElement in the browser, Node/Node2D in Godot, etc.). A three-level hierarchy would separate:
DOMNode— parent, children,addChild,removeFromParent,nodeId, event listeners.StyledNode : DOMNode— classes, tag,StyleResolver,AnimationPlayer.Layout : StyledNode—rect,measure,arrange,draw,ScrollController.
This is the "correct" architecture for a large framework. But:
- It breaks the
TLayout<Derived, Base = Layout>CRTP pattern. Every widget would need to pick its base explicitly. - It breaks the public API. Code that does
node->size(...)would need to know whethersizelives onStyledNodeorLayout. - It changes
friendtargets and every existing include.
Decision: deferred to post-1.0. Not worth the churn while the framework is still in beta. Revisit when: - Compile time becomes a real problem (>30s incremental builds on a typical machine). - Multiple stakeholders need to work on style / layout / DOM in parallel without stepping on each other. - A plugin system needs to expose "just the tree" without the drawing half.
- [ ] 5.11a — Group state flags into a bitfield struct (low risk)
- [ ] 5.11b — Listener array (tracked in 5.9)
- [x] 5.11c — Remove
friend— declined, deliberate design choice - [ ] 5.11d — Split hierarchy — deferred to post-1.0
5.12 ZMarkup / ZStyle ergonomics
Four small gaps that made the DSL awkward for anything beyond the simplest markup. All resolved.
5.12a — Canvas / Modal / Popup in registerBuiltins. ✅
Previously, using these three widgets from ZMarkup required building a
custom Registry in user code, for no reason. They're regular widgets
with no constructor arguments, so they belong in the default registry.
Tooltip (needs an owner) and ImageContainer (needs a texture) stay
unregistered — those genuinely can't be expressed by the grammar.
5.12b — UINode::context(). ✅
UINode held a BuildContext internally but didn't expose it. Any
user who wanted to look up nodes by id from outside of find(), or
who wanted to extend a tree at runtime with the same registry, had to
re-run parse + Registry + create manually. context() returns
the shared pointer directly.
5.12c — rgb() / rgba() in .zstyle. ✅
The color parser handled named colors and hex only. rgb(r, g, b)
and rgba(r, g, b, a) are now recognized, with values clamped to
their valid ranges. Components follow the CSS convention: 0–255 for
the color channels, 0.0–1.0 for alpha.
5.12d — ZMarkup::loadMarkupFile. ✅
Symmetric to ZMarkup::loadStyleFile. Reads a .zmarkup source from
a file and returns a UINode. Missing file → null root + error log.
- [x] 5.12a — Register Canvas / Modal / Popup
- [x] 5.12b — Expose
UINode::context() - [x] 5.12c — Support
rgb()/rgba() - [x] 5.12d — Add
loadMarkupFile
5.13 Content slot for composite widgets
Done for Modal. Others left as default.
Composite widgets build internal structure in onBuild(). When ZMarkup
adds children to such a widget, those children used to become siblings
of the internal content wrapper instead of children of it. This broke
the wrapper's animations — the most visible case was Modal, whose
"Intro" slide didn't apply to markup children.
Design (implemented): a virtual Layout::contentTarget() returning
the node children should be attached to. Default: this. Modal
overrides it to return its internal content VStack. ZMarkup::create
now attaches children via contentTarget() instead of calling
addChild() directly.
| Widget | Overrides contentTarget()? |
Why |
|---|---|---|
Layout, Panel, VStack, HStack, Stack, Text, Button, Checkbox, Slider, ProgressBar, TextInput, ScrollView, Canvas |
No (default return this) |
No internal wrapper |
Modal |
Yes → content VStack |
Children need to live inside the animated wrapper |
Popup |
No | Content is the direct children; no wrapper |
Dropdown |
No | Internal structure (trigger + list) isn't a content slot |
Toggle |
No | Knob is decorative; children are unusual and default behavior is documented |
- [x] Add
Layout::contentTarget()virtual, default returnsthis - [x] Override in
Modal - [x] Update
ZMarkup::createto usecontentTarget() - [x] Add tests (3: Modal-with-children, Stack-with-children, nested layout)
- [x] Update
user/04-zmarkup.md§2.6 - [x] Update
api/Layout.md§5 - [x] Update
api/Widgets.md(Modalsection)
If a future widget needs its own content slot, the override is a one-liner. The pattern is settled.
5.14 CanvasLayout respects effect:
Done.
CanvasLayout is the only widget that overrides Layout::draw()
completely, because it renders its subtree into an offscreen target
and composites the result. The consequence was that effect: in
.zstyle never reached the canvas — the effect was resolved during
Layout::draw(), which the canvas bypasses. Binding a shader required
setShader() from C++, even though the docs mark that API as a
legacy path.
Fix: Layout gains a protected helper resolveActiveEffect() that
returns the effect to apply, with a documented priority:
effect:from the current style (if resolved to a valid handle).setShader()handle (if set).- Invalid handle (no effect).
CanvasLayout::draw() calls resolveActiveEffect() and pushes the
returned handle around the final drawTarget. Other widgets are
unaffected — they go through Layout::draw(), which already calls
resolveEffectIfNeeded and pushes currentEffect_ in the same
priority order.
- [x] Add
Layout::resolveActiveEffect()(protected) - [x] Use it in
CanvasLayout::draw() - [x] Add
pushEffectCounttoMockRenderer - [x] Add test (
Canvas.effect_property_pushes_shader) - [x] Update
api/Widgets.mdCanvasLayout section - [x] Update
api/Layout.md§14 with the new hook
Phase 6 — Internationalization
Needed for non-English UIs.
6.1 Unicode-aware text
Currently Text and TextInput are char-based. Unicode support
requires:
- [ ]
std::u32stringor UTF-8 aware iteration inText - [ ] Cluster-aware cursor movement (grapheme boundaries)
- [ ]
TextInputaccepting multi-byte characters - [ ] Text measurement per-glyph via the backend
6.2 RTL layout
- [ ] Direction detection (LTR / RTL) per text run
- [ ] Mirroring of
HStackwhen direction is RTL - [ ]
text-align: start/endrespecting direction - [ ] Bidi algorithm for mixed runs (via a library)
6.3 IME support
- [ ]
IPlatformextension for IME composition events - [ ]
TextInputrendering the composition string with an underline - [ ] Backend-specific IME integration (Raylib doesn't have it natively)
Phase 7 — Tooling
Not needed for the framework itself, but for adoption.
7.1 Documentation
- [ ]
docs/SUMMARY.md— a navigable table of contents - [ ] Review pass to remove redundancy between
user/andapi/ - [ ] Standardize examples (consistent output API, consistent style)
- [ ] Add a "cheatsheet" page for
.zstyleand ZMarkup - [ ] Tutorial: build a complete app in 30 minutes
7.2 Examples
- [ ]
examples/hello-world/— minimal - [ ]
examples/settings-screen/— a realistic settings panel - [ ]
examples/game-menu/— a game menu with animations - [ ]
examples/editor/— a text-heavy UI (demonstrates wrapping, scroll, TextInput)
7.3 Inspector
A runtime inspector (like React DevTools):
- [ ] Tree view with live rects and states
- [ ] Style inspector: matching rules, resolved properties
- [ ] Live editing of inline styles
- [ ] FPS / frame time graph
- [ ] Would likely require a separate render target and its own UI
7.4 Formatter
- [ ]
.zstyleformatter (indentation, blank lines, sorted properties) - [ ] ZMarkup formatter (indentation)
- [ ] Integration with common editors (VS Code extension)
7.5 Language server
- [ ] Syntax highlighting for
.zstyleand ZMarkup - [ ] Autocomplete for property names, tag names, class names
- [ ] Go-to-definition for keyframes, custom properties
- [ ] Diagnostics for unknown properties, typo'd names
Phase 8 — Beyond 1.0
Long-term ideas, not committed.
- [ ] Multi-window support (requires refactoring
UIContextaway from singleton) - [ ] GPU-accelerated transitions (currently CPU-interpolated)
- [ ] Retained-mode scene graph caching
- [ ] Accessibility tree (screen reader support)
- [ ] Native platform integration (macOS menu bar, Windows title bar)
- [ ] Web build (WASM) via an OpenGL ES backend
- [ ] Mobile-specific widgets (safe area layout, on-screen keyboard integration)
- [ ] A visual editor for
.zmarkup+.zstyle
Known issues not yet prioritized
These are documented in the code or in the documentation but haven't been turned into roadmap items. They're either rare, low-impact, or require a bigger design decision.
| Issue | Location | Notes |
|---|---|---|
collectFocusables doesn't skip disabled subtrees |
Layout.cpp |
Tab reaches disabled containers' children. |
Text is char-based, not Unicode |
Text.hpp |
Text input and rendering are ASCII-only. |
| Font fallback chain missing | IAssetProvider |
Missing glyphs use the default font. |
var() in transition: / animation: shorthand |
StyleParser.hpp |
unresolvedProps doesn't apply to those. |
Custom props not in ComputedStyle::operator== |
Style.hpp |
Two styles differing only in custom props compare equal. |
pointerCapture and focusedNode are weak, but topmostConsumer etc. are raw |
UIContext.hpp |
Raw pointers can dangle in theory. |
Style macro-driven is verbose |
Style.hpp |
Adding a property touches ~1 line but generates cryptic errors. |
renderFrame doesn't clear the framebuffer |
Layout.cpp |
User is responsible. |
Popup closeOnEscape doesn't reach the popup if focus is elsewhere |
Popup.hpp |
Known limitation. |
Dropdown doesn't close on Escape |
Dropdown.hpp |
Only closes on outside click. |
How to contribute
See CONTRIBUTING.md (to be written). In the meantime:
- Bug reports should include a minimal reproduction using the
Mocks.hppfixture when possible. - Feature requests should reference a concrete use case.
- Pull requests should include tests for new behavior.
- Documentation changes should follow the existing structure
(
user/,internals/,api/).
Version history
| Version | Date | Notes |
|---|---|---|
| alpha | — | Initial state. API unstable. |
| beta | 2026-10-11 | Phase 1–3 complete. API frozen for the core. |
| 1.0 | TBD | Phase 4–5 complete. Feature-complete for typical use. |
See also
- README — overview and quick start.
- Getting Started — first steps.
- Architecture — how the framework is built.
- Debugging — how to inspect runtime state.