l-lessons-active-frame-border
Lessons from the multi-wave active-frame border bug (#1425, #1463, #1473, #1489). Use when: (1) Planning any work that touches frame border, focus ring, or active-leaf visual state, (2) Adding a new w...
Lessons: Active-Frame Border Regressions
Summary
The active-frame border required four separate attempts (#1425, #1463, #1473, #1489) before it stayed fixed. The root cause was architectural, not cosmetic: too many disconnected layers between the page and the leaf, with two competing render mechanisms and no single owner.
Each Prior Attempt
1425 — First attempt
What it fixed: wired
--color-active-frameas a CSS custom property; basic active leaf highlight appeared in default-dark.What it missed: the property resolved correctly in one theme but fell back silently in tokyo-night and default-light because Tailwind's JIT included a utility that referenced an unverified
--theme-active-frame-borderalias. The alias was only set in default-dark'sapplyColors()output.Lesson: do not chase a Tailwind utility route without verifying the CSS var exists in every theme's
applyColors()call. Test all three themes before closing.
1463 — Second attempt
What it fixed: corrected the
applyColors()mapping for tokyo-night and default-light so all three themes emitted the var.What it missed: the frameset package's
LeafRendererwrapped the leaf with an additional<div>that had anoutline-based focus ring. Theoutlinepainted on top of the inset box-shadow in certain WebKit circumstances, making the border appear doubled in the active state. The team closed the issue believing both render paths were patched, but theoutlinepath was not noticed.Lesson: never use
outlinefor visual focus indication on a frame leaf. The inset box-shadow is the single mechanism; outline is off the table.
1473 — Third attempt
What it fixed: removed the
outlinefrom LeafRenderer's wrapper div; border regression looked resolved in spot checks.What it missed: by this point there were still two DOM nodes that could paint the active color: the frameset-level
LeafRendererwrapper and theFrameChromewrapper. Depending on which node was active, the result was inconsistent. Tests written against the Tailwind-utility path gave false green because they checked for a CSS class, not the resolved box-shadow value.Lesson: if a test only checks for a CSS class string and does not verify the resolved
style.boxShadow, it can give false confidence. Write tests that assert the canonical rendered property value.
1489 — Final structural fix (Frame Chrome Consolidation Wave 3)
What it fixed: designated a single DOM node as the active-border owner: the outermost
<div>insideFrameChrome(the[data-testid="frame-chrome"]element). This node always renders regardless of leaf state (normal, zoomed, collapsed, popped-out). The active border is expressed solely viaboxShadow: "inset 0 0 0 1px var(--color-active-frame)". The neutral 1px border (border border-edge) is always present; the inset shadow layered on top adds the accent without changing the bounding box.Why it held: one DOM node, one CSS var, one render mechanism. Guard tests in
packages/assert both the presence of the inset shadow and the absence of outer shadows (which would shift adjacent leaf bounding boxes).frameset/ src/ frame- chrome- active- border. test. tsx
Why Regressions Kept Happening
Multiple disconnected sources of truth. The CSS var was set in
applyColors(), consumed in Tailwind utilities, and also sometimes set inline. Any one path going stale broke only some themes.Wrong initial hypothesis. The team first chased the Tailwind JIT route (adding utility classes). The real fix required a DOM ownership decision — "which single node paints the border" — not a CSS-class tweak.
Two render mechanisms coexisting.
outline(keyboard-focus ring in LeafRenderer) andbox-shadow(active-frame indicator in FrameChrome) both painted around the same leaf. Each patch addressed one mechanism, leaving the other to regress later.Architectural cause: too many wrapper layers. Before Wave 3 (#1485 page-chrome elimination) and Wave 3 (#1489 single DOM owner), there were page-level wrappers, frameset-level
LeafRendererwrappers, and provider-level wrappers, any of which could add a visual border. The fix required collapsing the hierarchy soFrameChromeis the sole owner.
The Structural Fix
Visual model: the table-cell metaphor
The active border follows a "table-cell" mental model: every leaf is like a table cell with exactly one 1px border painted by its own chrome (border-edge), and adjacent leaves share that edge visually. The active leaf paints an inset box-shadow over its border, highlighting it from the inside — no leaf ever adds a 2px "selected" border that would push against its neighbor, so there is zero layout shift. This is why outline (which paints outside the box) and extra wrapper divs (which would double the edge) are forbidden. (This is the canonical home for the metaphor; l-lessons-frame-component-architecture cross-links here.)
Mechanism
One DOM node (
[data-testid="frame-chrome"]) owns the active border.CSS mechanism:
boxShadow: "inset 0 0 0 1px var(--color-active-frame)"inline style.The neutral border (
border border-edge) is always present; the inset shadow is conditional onisActive.var(--color-active-frame)is set byapplyColors()for every theme inpackages/color-themes/; verified in guard tests.Guard tests:
packages/asserts:frameset/ src/ frame- chrome- active- border. test. tsx Active state →
style.boxShadowcontains"inset"and referencesvar(--color-active-frame).Inactive state → no
boxShadow.Same mechanism for collapsed strip, popped-out strip, and empty-leaf states.
No outer (non-inset) shadow on the same node.
Page-chrome eliminated in #1485: route components (
write-page.tsx,archives-page.tsx,search-page.tsx) are thin<Frameset>hosts with no<PageBar />or page-level chrome layer.
Watch For Next Time
New wrapper inside
LeafRenderer. Any<div>added between<Frameset>and<FrameChrome>that carries a border or box-shadow will duplicate the active indicator.outlineon a leaf. TheoutlineCSS property is not allowed for visual focus in a frame leaf. Use the inset box-shadow mechanism on[data-testid="frame-chrome"]only.Tailwind utility based on an unverified theme var. Before shipping a Tailwind
border-*orring-*utility that references a CSS custom property, confirm the property is emitted byapplyColors()in all three themes (default-dark,tokyo-night,default-light).New page-level chrome in a route component. Route files (
*-page.tsx) must remain thin frameset hosts. If a toolbar or header is needed, it belongs inside the provider, not in the page.
Would-Skip-If-Redoing
The three prior attempts' incremental approach (patch the CSS var, then patch the theme, then remove the outline, then ...) each bought one week before the next regression. The correct first step would have been:
Audit every DOM node between
<Frameset>and the leaf that could paint a border.Designate a single owner.
Delete everything else.
Write guard tests before closing.
One structured audit + redesign session would have replaced four patch cycles.
Reference Files
packages/— single-owner implementation (look forframeset/ src/ frame- chrome. tsx boxShadow: "inset 0 0 0 1px var(--color-active-frame)").packages/— guard tests.frameset/ src/ frame- chrome- active- border. test. tsx doc/— decision record.src/ content/ docs/ architecture/ active- frame- border- strategy. mdx packages/color-themes/—applyColors()per theme (the var must be present here).
Fifth Attempt — Bug Fix 1512/1513 (Sub 4, #1512 §4 + #1513 fix1 + #1513 fix4)
Three additional bug reports that Attempt #4 did not cover
After the #1489 structural fix, three new failure modes appeared in the user's live workspace:
Bug 1 — Stale color.activeFrameBorder field in saved settings
Root cause: applyColors() in packages/ previously iterated Object.entries(colorKeyToCssVar) and wrote every stored field, including activeFrameBorder → --theme-active-frame-border. This let an old, user-chosen value persist in .zudotext.settings.json and survive every theme or accent change. The user's actual saved value was #5CAAE9 while their current accent was #AE8556.
Why #1489 didn't catch it: #1489 fixed the DOM ownership (a single node, one CSS var) but left applyColors() and the ColorSettings type unchanged. If the stored blob contained a non-accent hex it was still written to --theme-active-frame-border on every settings load. The team had no test exercising "what happens when the stored value differs from the accent".
Fix: two-step removal.
packages/— thecolor- themes/ src/ color- settings. ts colorKeyToCssVarmap no longer contains theactiveFrameBorderkey. Instead,applyColors()unconditionally writesroot.style.setProperty("--theme-active-frame-border", colors.accent)(line 377) after the loop, ensuring the var physically equals the accent on every call regardless of what is (or was) in the stored blob.packages/—app- defaults/ src/ validate- settings. ts validateSettings()now deletess.color.activeFrameBorderon load (lines 192-193) so the stale field is not re-persisted.
Guard tests: frame-chrome-active-border.test.tsx Row 12 — "Active-frame border tracks accent at runtime" — verifies that applyColors() writes colors.accent to --theme-active-frame-border across all three shipping themes, and that an explicit non-default accent like #AE8556 is what gets stored.
Bug 2 — Nested suppression: outer chrome accent border visible during timeline-card selection (fix1)
Root cause: when the user clicked a card in timeline-vertical or timeline-horizontal layout, the card painted its own selection highlight AND the outer [data-testid="frame-chrome"] accent border remained visible, creating a double-highlight. There was no mechanism to tell the chrome that the provider had its own sufficient selection signal.
Fix: opted-in attribute suppression contract (introduced in Sub 4 / #1513 fix1):
packages/lines 510-522 document the contract: any descendant of the chrome can setframeset/ src/ frame- chrome. tsx data-suppress-frame-active-border="true"to opt out of the outer accent border.ActiveFrameOverlay(lines 523-566) scans for the attribute viauseLayoutEffecton every render and sets localsuppressedstate.When suppressed, the overlay renders a
data-suppressed="true"/display: nonemarker instead of the inset shadow.The caller:
tauri-line 117 —app/ renderer/ components/ inbox- layouts/ inbox- timeline- layout. tsx data-suppress-frame-active-border="true"on the outer wrapper of the card grid, active only when a card is selected.
Guard tests: frame-chrome-active-border.test.tsx Row 13 — "Nested suppression via data-suppress-frame-active-border" — three sub-cases: attribute present/active, attribute present/false, attribute toggling mid-lifecycle. All assert against [data-testid="frame-active-overlay"][data-suppressed].
Bug 3 — Wrap-around cutout: inset shadow clipped by floating children (fix4)
Root cause: before Sub 4, the active-frame inset shadow was applied as inline boxShadow on the chrome wrapper div. When a provider rendered absolutely-positioned children (e.g., the Archive button in the inbox, DraftNumberBadge), those children could paint over the shadow's right or bottom edge, producing a visual cutout on 1-2 sides.
Fix: the ActiveFrameOverlay component (frame-chrome.tsx line 557-566) renders the inset shadow as a sibling element of the content slot, placed AFTER it in the DOM so it stacks on top via CSS painting order. The overlay uses:
className="pointer-events-none absolute inset-0"
style={{ boxShadow: "inset 0 0 0 1px var(--color-active-frame)" }}pointer-events: none keeps all children interactive. absolute inset-0 expands the overlay to fill the chrome wrapper on all four sides. inset 0 0 0 1px (four equal offsets) guarantees a continuous rectangle with no cutout regardless of what the content slot paints.
Guard tests: frame-chrome-active-border.test.tsx Row 14 — "Wrap-around overlay structural contract" — asserts: (a) overlay is a sibling of [data-testid="frame-content"], NOT nested inside it; (b) overlay boxShadow has four equal zero offsets; (c) pointer-events is none.
Why the prior four attempts didn't catch the stale #5CAAE9 color
Attempts #1–#4 fixed DOM ownership, CSS var propagation, and the outline collision — all code-side issues. None of them audited what happened when a user's existing .zudotext.settings.json contained a color.activeFrameBorder override value from a previous version of the settings schema. The stale value was written to --theme-active-frame-border on every applyColors() call, overriding the computed theme accent. Without a test that compared "stored override ≠ accent produces stale color" the regression was invisible in CI.
The fifth attempt's discovery protocol:
Read the user's actual
.zudotext.settings.jsonfield by field.Noticed
color.activeFrameBorder: "#5CAAE9"whilecolor.accent: "#AE8556".Traced
applyColors()→ confirmed the stored value was winning over the accent.Removed the field from the schema, added the unconditional write, added Row 12 guard.
Verification protocol (what finally worked)
Run in order:
Audit
applyColors()source — confirmactiveFrameBorderkey is absent fromcolorKeyToCssVarand the unconditionalcolors.accentwrite is present.Check
validateSettings.tsfor thedelete s.color.activeFrameBordermigration.Run
frame-chrome-active-border.test.tsxRows 12, 13, 14 — all must green before and after accent changes.For nested suppression: set
data-suppress-frame-active-border="true"in a descendant and assert[data-testid="frame-active-overlay"][data-suppressed="true"].For wrap-around: assert overlay is a sibling of
[data-testid="frame-content"]and NOT a child; assertboxShadowmatchesinset 0 0 0 1px.Verify all five leaf states (normal, zoomed, collapsed, popped-out,
core.empty) each produce an overlay that satisfies steps 4-5.
Structural rules (single DOM owner, all-four-sides)
Single DOM owner:
[data-testid="frame-active-overlay"]— one element, rendered byActiveFrameOverlayinsideFrameChrome— is the only node that paints the active border. No provider, wrapper div, or route component may add a competing border or shadow.Attribute-based suppression: opt-in via
data-suppress-frame-active-border="true"on any descendant of the chrome. The attribute triggers suppression; absence of the attribute is the default active state.All-four-sides verification: for each of the five leaf states (normal, zoomed, collapsed, popped-out,
core.empty), focus the leaf and confirm the inset shadow forms a continuous unbroken rectangle. The guard tests check this via theinset 0 0 0 1pxCSS value — four equal offsets are the mathematical guarantee of continuity.
2026-05-12 — Do NOT add a broad MutationObserver to ActiveFrameOverlay
What we set out to do
Catch suppression-attribute toggles that descendants make outside of React's render cycle (imperative DOM edits, third-party libraries). Existing code already used a no-deps useLayoutEffect to re-scan after every React render; commit da64d89b added a MutationObserver as defense-in-depth.
Approach we tried first
useLayoutEffect(() => {
const observer = new MutationObserver(scanSuppression);
observer.observe(parent, {
attributes: true,
subtree: true,
attributeFilter: ["data-suppress-frame-active-border"],
childList: true,
});
return () => observer.disconnect();
}, []);Inside scanSuppression, setSuppressed((prev) => prev === next ? prev : next) — relying on the functional-updater bail to prevent infinite loops.
Why it went wrong (root cause)
subtree: true + childList: true means the observer fires on every descendant DOM mutation in the chrome's entire subtree — CodeMirror typing, virtualizer item insert/remove, hover state changes, focus chrome flicker, etc. Each fire enqueues a setSuppressed dispatch. The functional bail prevents the re-render but does not prevent the dispatch from incrementing React's update-depth counter. Combined with ancestor render pressure (write-page mount), the counter hit React's limit and surfaced as production error #185 "Maximum update depth exceeded" at app startup.
Same structural mistake as the no-deps useLayoutEffect it was meant to defend: trusting Object.is / functional-updater bail to make repeated setState calls "free." It isn't free under sustained pressure.
What worked instead
Remove the
MutationObserverentirely. Descendants change the suppression attribute through React (or, at minimum, trigger a parent re-render that ripples down to the chrome). The no-depsuseLayoutEffectruns on every chrome render and catches every transition that matters. The third-party-library / imperative-DOM-update case was hypothetical — no caller in the codebase actually needs it.Gate the no-deps
useLayoutEffectitself with a ref. Even without the observer, callingsetSuppressedevery render via the functional bail still pumps the update queue. Track the last scanned value in auseRefand only dispatch on an actual transition:
const lastScannedRef = useRef(false);
useLayoutEffect(() => {
const next = parent.querySelector('[data-suppress-frame-active-border="true"]') !== null;
if (lastScannedRef.current !== next) {
lastScannedRef.current = next;
setSuppressed(next);
}
});The inline comment in packages/ ActiveFrameOverlay now warns against re-adding the observer.
Watch for next time
MutationObserver({ subtree: true, childList: true })inside a render-heavy chrome is a footgun. It will fire on every descendant DOM change — far more often than you think. If you need attribute-change detection only, omitchildList: trueand rely onattributes: true+attributeFilter.setStatewith a functional-updater bail is not free.(prev) => prev === next ? prev : nextskips the re-render but the dispatch still counts. Pair every "scan on every render" pattern with auseRefvalue-gate sosetStateis only called on a real transition.The CSS
:has()rule intokens.cssis the production backstop. The JS scan exists for jsdom-based guard tests; do not over-engineer it for production correctness.If you see React #185 at app startup with a stack pointing inside
frame-chrome.tsx, the dev-mode unminified error usually lands at the most-recentsetSuppressedcall site, but the root cause may be either the observer (too broad) or a sibling provider's prop-sync useEffect (seel-lessons-frameset-persistence2026-05-12 note). Check both before assuming one.
Would-skip-if-redoing
The MutationObserver addition in da64d89b was speculative defense-in-depth for a case no caller actually exercised. The commit message even noted that the no-deps useLayoutEffect was "a contributing factor to the StrictMode loop" — the right move would have been to gate that one useLayoutEffect with a ref, not to add a second mechanism on top. Adding broad DOM observation to defend against a hypothetical edge case made an existing tolerable pattern intolerable.