zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Active-Frame Border Strategy

Canonical audit and implementation strategy for the active-frame border. Sub 6a (issue #1489) implements directly against this document.

Caution

This is a historical strategy document. The fix described in Artifact 5 (box-shadow inset overlay via ActiveFrameOverlay and the data-suppress-frame-active-border attribute) has shipped. The open work items (sub 6a #1489 implementation) are complete. The exempt-route audit in Section 1c is partially outdated: search-page.tsx now uses a full <Frameset> with core.search — it is no longer hand-rolled and is no longer exempt. This document is preserved as a historical record.

The color-source description below also predates the Color Ramp Restructure epic (#3555) and no longer matches current code. At the time this document was written, activeFrameBorder was a persisted, per-scheme-overridable semantic field resolving to a numeric palette index (default: index 3), and the 10 built-in schemes included Catppuccin Mocha as the CSS-fallback scheme. Bug-fix #1512 §4 (see architecture/bug-fix-1512-1513-strategy.mdx) later removed activeFrameBorder as a persisted/overridable field entirely — applyColors() in packages/color-themes/src/color-settings.ts now writes --theme-active-frame-border unconditionally from colors.accent on every call, with no override possible. In the current ramp-based model, colors.accent itself resolves from a RampRef (default { accent: 1 }) through the active scheme's ramps.accent — there is no palette-index or Catppuccin Mocha fallback anymore, and a single default scheme owning a light and a dark mode is all that exists (epic #5893). The DOM-owner contract and border mechanism (box-shadow inset, single owner per leaf state) described in Artifacts 1–7 below are unaffected by this and remain accurate.

Warning

This document is the deliverable for issue #1483 (Wave 1 of the Frame Chrome Consolidation epic #1482). Sub 5 (#1487) will reject this doc if it contains TBD or vague DOM references. Sub 6a (#1489) implements directly against this spec.

Background

The active-frame border has been "fixed" three times (issues #1425, #1463, #1473) and still shows blue in the running app. This is the fourth attempt. User intuition: the problem is structural — applying a 1px border to a single DOM node should not require repeated fixes.

Visual target (user's table-cell mental model):

<table><tr>
  <td>hoge</td>
  <td class="active">hoge</td>
  <td>hoge</td>
</tr></table>
<style>
  table, td { border: 1px solid black; }
  td.active { border: 1px solid red; }
</style>

One border per leaf. The active border replaces the neutral border at the same DOM node. No outline layered on top of border. No nested wrappers each contributing their own border.


Catalog of every current site touching the active-frame border

Each entry is given with current code excerpted at exact file:line.

Site 1 — SEMANTIC_DEFAULTS.activeFrameBorder

File: packages/color-themes/src/color-settings.ts:105

activeFrameBorder: 3,

This is the palette index used when no per-scheme semantic override is present. Index 3 maps to the accent color. For Catppuccin Mocha (the CSS fallback scheme) palette[3] is #f9e2af (yellow). The comment at line 103–104 reads: "Active frame border tracks the accent color (palette index 3) so it matches the user's chosen accent without a separate override."

Site 2 — SEMANTIC_CSS_NAMES.activeFrameBorder

File: packages/color-themes/src/color-settings.ts:162

activeFrameBorder: "--theme-active-frame-border",

This maps the JS key to the CSS custom property that applyColors() sets on :root.

Site 3 — colorKeyToCssVar.activeFrameBorder

File: packages/color-themes/src/color-settings.ts:374

activeFrameBorder: "--theme-active-frame-border",

This is the second mapping table used inside applyColors(). Both SEMANTIC_CSS_NAMES (Site 2) and colorKeyToCssVar (Site 3) declare the same mapping — they exist for different callers. applyColors() iterates colorKeyToCssVar to write the JS-resolved hex values to :root inline styles at runtime.

Site 4 — resolveSemanticColors return

File: packages/color-themes/src/color-settings.ts:252

activeFrameBorder: resolveColor(resolveRef(sem.activeFrameBorder, bg, fg), p, p[SEMANTIC_DEFAULTS.activeFrameBorder]),

This resolves the color to a concrete hex string. For default-dark, no sem.activeFrameBorder override exists, so it falls back to p[3] which is #AE8556 (yellow-brown). For Catppuccin Mocha (default CSS fallback), p[3] is #f9e2af.

Site 5 — schemaToColors return

File: packages/color-themes/src/color-settings.ts:317

activeFrameBorder: sem.activeFrameBorder,

This passes the resolved string into the ColorSettings shape that applyColors() later consumes.

Site 6 — ColorScheme schema entry

File: packages/color-themes/src/color-themes.ts:52

activeFrameBorder?: ColorRef;

This is the optional per-scheme semantic override. None of the 10 built-in color schemes (default-dark, default-light, catppuccin-mocha, catppuccin-latte, tokyo-night, dracula, nord, solarized-dark, one-dark, gruvbox-dark) — as they existed at the time of this audit — currently set this field, so all schemes fall back to palette index 3. (Historical: both activeFrameBorder and this list of 10 schemes are gone post-restructure; see the caution note at the top of this document.)

Site 7 — CSS token @theme declaration

File: packages/ui-components/src/tokens.css:122

--color-active-frame: var(--theme-active-frame-border);

This lives inside the @theme {} block (line 9–134). It bridges the dynamic Tier-2 --theme-* variable to a Tailwind-registerable --color-* name. The @theme block is the Tailwind v4 mechanism that makes border-active-frame and text-active-frame etc. available as utility classes.

Site 8 — CSS Tier-2 fallback

File: packages/ui-components/src/tokens.css:212

--theme-active-frame-border: var(--palette-3);

This :root declaration is the CSS fallback for the brief period before JS runs applyColors(). It resolves to #f9e2af (Catppuccin Mocha palette-3, yellow) for the default Tier-1 palette also declared in this file. After JS runs, :root will have an inline style that overrides this with the resolved hex from the active color scheme.

Site 9 — Tailwind entry point @source

File: packages/ui-components/src/tailwind.css:1–4

@import "tailwindcss";
@import "./tokens.css";

@source ".";

The @source "." scans packages/ui-components/src/ for Tailwind utility usage. The renderer's own entry extends this:

File: tauri-app/renderer/tailwind.css:1–4

@import "tailwindcss";
@import "../../packages/ui-components/src/tokens.css";

@source "../../packages";

The renderer's @source "../../packages" causes Tailwind to scan ALL packages, including packages/frameset/src/frame-chrome.tsx where border-active-frame appears.

Site 10 — FrameChrome normal/zoomed state

File: packages/frameset/src/frame-chrome.tsx:319

className={[
  "flex flex-col h-full overflow-hidden",
  "border",
  isActive ? "border-active-frame" : "border-transparent",
].join(" ")}

This is the outer wrapper div for the normal and zoomed leaf states. It renders a 1px solid border (from Tailwind's border utility) in the active color when active, or transparent when inactive.

Site 11 — FrameChrome collapsed strip

File: packages/frameset/src/frame-chrome.tsx:283

className={[
  "flex items-center gap-xs px-sm overflow-hidden select-none",
  "border",
  isActive
    ? "border-active-frame bg-surface"
    : "border-edge bg-bg",
].join(" ")}

The collapsed state uses the same border + border-active-frame pattern, but also shows border-edge (neutral) when inactive — unlike the normal state which uses border-transparent.

Site 12 — FrameChrome popped-out strip

File: packages/frameset/src/frame-chrome.tsx:248

className={[
  "flex items-center gap-xs px-sm overflow-hidden select-none",
  "border",
  isActive
    ? "border-active-frame bg-surface"
    : "border-edge bg-bg",
].join(" ")}

Identical to the collapsed strip pattern.

Site 13 — Empty-leaf path in ChromeAdapter

File: tauri-app/renderer/components/frameset-chrome-adapter.tsx:158–162

style={{
  width: "100%",
  height: "100%",
  outline: isActive ? "1px solid var(--color-active-frame)" : "1px solid transparent",
  outlineOffset: "-1px",
  boxSizing: "border-box",
}}

This is a DIFFERENT mechanism from Sites 10–12. The concrete-leaf path (FrameChrome) uses border + Tailwind border-active-frame. The empty-leaf path uses an outline with outlineOffset: -1px applied via inline style. This inconsistency is a root cause of the double-rendering problem — the leaf wrapper inside LeafRenderer renders content into the Chrome, and the empty-leaf Chrome adds its own inline style on top of whatever LeafRenderer provides.

Site 14 — Storybook hardcoded hex

File: packages/frameset/src/integration.stories.tsx:460

border: isActive ? "1px solid #89b4fa" : "1px solid transparent",

Also line 320:

color: item.endsWith("/") ? "#89b4fa" : "#cdd6f4",

These are Storybook-only and do not affect production. They use the Catppuccin Mocha palette-4 blue directly. The border usage (line 460) is in the integration story's makeChromeAdapter which is separate from the production frameset-chrome-adapter.tsx.

Site 15 — applyColors() runtime invocation

File: tauri-app/renderer/lib/apply-appearance.ts:26

applyColors(colors);

Called from app.tsx::handleSettingsLoaded (line 2041) on initial settings load. Also called in app.tsx:878 inside a useEffect that fires when appSettings changes (i.e., after settings dialog closes). The colors object here is saved?.color ?? defaultColorSettings, where defaultColorSettings = schemaToColors(getSchemeByName(defaultThemeName)).


Artifact 1 — Build evidence

Run from the worktree: pnpm writing:build (the frontend build succeeded; the Linux AppImage bundling step failed due to missing icon, but the CSS was generated before that point).

$ grep -E '(--color-active-frame|\.border-active-frame)' tauri-app/dist-renderer/assets/index-l4h0dLbX.css

Verbatim output:

--color-active-frame:var(--theme-active-frame-border)
.border-active-frame{border-color:var(--color-active-frame)}

Finding: .border-active-frame IS generated by the build. The Tailwind utility resolves correctly to border-color: var(--color-active-frame). The chain is intact:

  1. @theme { --color-active-frame: var(--theme-active-frame-border) } → Tailwind registers border-active-frame as border-color: var(--color-active-frame)

  2. Minified output confirms both the CSS var declaration and the utility class are present

This rules out the hypothesis that "Tailwind is not emitting the utility." The CSS pipeline is correct. The bug must be elsewhere.


Artifact 2 — Live-app computed-style transcript

Note: build-time inference only (running app verification was not feasible from WSL2).

Based on the complete CSS chain traced in Artifacts 1 and the Catalog:

On app boot, before JS runs:

  • getComputedStyle(:root).getPropertyValue('--theme-active-frame-border')var(--palette-3) → resolves to #f9e2af (Catppuccin Mocha yellow, from the Tier-1 palette CSS fallback at tokens.css:170)

  • getComputedStyle(:root).getPropertyValue('--color-active-frame')var(--theme-active-frame-border)#f9e2af

After JS applyColors() runs with default-dark scheme:

  • applyColors() calls document.documentElement.style.setProperty('--theme-active-frame-border', resolvedHex) where resolvedHex = palette[3] = '#AE8556' (from default-dark palette)

  • getComputedStyle(:root).getPropertyValue('--theme-active-frame-border')#AE8556 (the inline style overrides the :root rule)

  • getComputedStyle(:root).getPropertyValue('--color-active-frame') → still resolves through var(--theme-active-frame-border)#AE8556

  • getComputedStyle(focusedLeafElement).borderColor#AE8556 (RGB equivalent: rgb(174, 133, 86))

Why the user still sees blue:

The visible blue (#89b4fa, Catppuccin Mocha palette-4) comes from the pre-b9502dc3 state where:

  • The empty-leaf path used var(--color-border-active-frame, #89b4fa) — a nonexistent CSS var with a hardcoded blue fallback

  • SEMANTIC_DEFAULTS.activeFrameBorder was 4 (blue in Catppuccin Mocha: #89b4fa)

  • The CSS fallback --theme-active-frame-border pointed at var(--palette-4)

Commit b9502dc3 fixed these, but if the user's .zudotext.settings.json persisted the resolved color from the pre-fix era (when index 4 resolved to #89b4fa), the validateHexColor() pass in validateSettings() would accept that stored hex and pass it through — keeping blue until the user manually changes the color in Settings.

The user's settings file is the likely culprit. See Artifact 7.


Artifact 3 — DOM before/after sketches

Current DOM (concrete leaf, normal state)

LeafRenderer (packages/frameset/src/leaf-renderer.tsx:184)
  └── Chrome (= ChromeAdapter from frameset-chrome-adapter.tsx)
        └── FrameChrome (packages/frameset/src/frame-chrome.tsx:310)
              div [data-testid="frame-chrome"]
                className="flex flex-col h-full overflow-hidden border border-active-frame"
                aria-current="true"
              ├── header [data-testid="frame-header"]
              │     className="... bg-surface border-b border-edge"
              └── div [data-testid="frame-content"]
                    className="flex-1 min-h-0 overflow-hidden"
                    └── div [data-frame-id="..."] (from LeafRenderer:189)
                          onFocus={handleFocus}
                          └── <provider content>

What paints what (current):

  • The frame-chrome div (FrameChrome outer): border-active-frameborder-color: var(--color-active-frame) — 1px on all sides

  • The frame-header div: border-b border-edge — an ADDITIONAL bottom border (neutral color) on the header

  • No other border contributors for the concrete-leaf normal state

Double-border analysis for the header: The frame-chrome outer div has a 1px active-frame border on ALL sides, including the top. The frame-header inside adds ANOTHER border-b border-edge. So at the header area, there are two borders stacked: the outer active-frame border (top 1px) + the inner header bottom border (neutral 1px). This is not a seam problem but a style stacking issue — the inner header border separates header from content, which is correct, while the outer border indicates frame activity.

Current DOM (empty leaf)

LeafRenderer (packages/frameset/src/leaf-renderer.tsx:184)
  └── Chrome (= ChromeAdapter from frameset-chrome-adapter.tsx)
        └── div (inline style, frameset-chrome-adapter.tsx:155)
              style="outline: 1px solid var(--color-active-frame); outline-offset: -1px;"
              └── div [data-testid="leaf-..."] (from LeafRenderer:189)
                    onFocus={handleFocus}
                    └── <EmptyRenderer />

The inconsistency: Empty leaves use outline with -1px offset; concrete leaves use border. These are fundamentally different CSS properties with different box-model behavior.

After restructure (target DOM — all leaf states)

LeafRenderer
  └── Chrome (= ChromeAdapter)
        └── FrameChrome
              div [data-testid="frame-chrome"]
                className="... border border-edge"   ← ALWAYS neutral 1px border
                aria-current={isActive ? "true" : undefined}
              ├── (when active) box-shadow: inset 0 0 0 1px var(--color-active-frame)
              └── (children unchanged)

OR, for the empty-leaf state, the same FrameChrome renders a simplified version without provider controls but using the same border mechanism.

After the restructure:

  • A single DOM node owns the border for ALL leaf states

  • The active indicator uses box-shadow inset (see Artifact 5 for justification), not a second border

  • The empty-leaf and concrete-leaf paths both route through FrameChrome


Artifact 4 — Exact named DOM owner per leaf state

In each case the single active-border owner is the div[data-testid="frame-chrome"] root element of FrameChrome.

Leaf stateFileLineClass/style that owns the active border
Normalpackages/frameset/src/frame-chrome.tsx310–320outer div className (currently border-active-frame; after fix: border-edge + box-shadow inset)
Zoomedpackages/frameset/src/frame-chrome.tsx310–320same outer div (zoomed uses same branch as normal)
Collapsedpackages/frameset/src/frame-chrome.tsx274–284outer div className of collapsed strip
Popped-outpackages/frameset/src/frame-chrome.tsx238–250outer div className of popped-out strip
Emptytauri-app/renderer/components/frameset-chrome-adapter.tsx152–166inline-style div (CURRENTLY INCONSISTENT — must be moved to FrameChrome)

Sub 6a action: Migrate the empty-leaf branch in frameset-chrome-adapter.tsx:152–166 to render a stripped-down FrameChrome (or a dedicated minimal chrome) instead of a bare inline-style div. After migration, every leaf state's active border lives in packages/frameset/src/frame-chrome.tsx.


Artifact 5 — Exact named shared-edge mechanism

Chosen mechanism: box-shadow inset

Rejected alternatives:

  • :has() adjacent-sibling suppressionWebKit supports it but it creates fragile selectors that depend on adjacent DOM structure. If the frameset ever renders a non-leaf between two leaves (e.g., a resize handle div), the selector breaks. The frameset's Rust-style split-tree DOM structure does place dividers between leaves; adjacent-sibling suppression would interact unpredictably with those.

  • Single-side borders — Only top + left, relying on the container for bottom + right. This creates an asymmetric rendering where the container must have exactly 1px padding to reveal its own border. Fragile with overflow:hidden and doesn't naturally support a "transparent inactive" state.

  • Outline with negative offset — This is what the empty-leaf path currently uses. It works (paints inside the box boundary) but does not interact with border-radius correctly, is not honored by all accessibility tools when announcing focus rings, and uses a different CSS property from the concrete-leaf border — creating two mechanisms to maintain.

box-shadow inset justification:

/* The leaf always has a neutral border so the leaf boundary is always visible */
border: 1px solid var(--color-edge);

/* Active state adds an inset shadow that paints OVER the neutral border */
box-shadow: inset 0 0 0 1px var(--color-active-frame);

Properties of this approach:

  1. The box boundary (1px border-edge) is always present for all leaves. At the boundary between two adjacent inactive leaves, the combined 2px is purely the border-collapse issue — but since these are divs (not table cells), there is no border-collapse. The visual result is a 2px seam between inactive leaves. This matches the current behavior and is acceptable — the visual weight of a 2px neutral seam is low.

  2. The active leaf adds a 1px inset shadow. The inset shadow paints INSIDE the border, so it does not add width at the leaf boundary. The leaf's bounding box does not change size. Adjacent leaves are not affected.

  3. When a leaf becomes active, the user sees: the outer 1px border-edge (still there) + the inner 1px shadow in active-frame color. Net visual: a small accent ring just inside the leaf boundary.

  4. This mechanism applies identically to normal, zoomed, collapsed, and popped-out states — no per-state branching needed. The empty-leaf state after migration also uses the same FrameChrome root and the same mechanism.

DOM node the mechanism applies to: div[data-testid="frame-chrome"] in packages/frameset/src/frame-chrome.tsx.

Inactive state in this mechanism:

className={[
  "flex flex-col h-full overflow-hidden",
  "border border-edge",
  isActive ? "shadow-[inset_0_0_0_1px_var(--color-active-frame)]" : "",
].join(" ")}

Inactive: just border border-edge. Active: border border-edge + inline box-shadow or a Tailwind arbitrary shadow. No border-active-frame utility needed (which removes the current dependency on Tailwind emitting that class).

Alternative if arbitrary shadow utilities are undesirable: Use an inline style for the box-shadow on the active state only, keeping border border-edge in className. Sub 6a can choose whichever pattern is cleaner in context.


Artifact 6 — A11y check

Focus order: The frameset's focus model uses onFocus bubbling on the div[data-frame-id] inside LeafRenderer (leaf-renderer.tsx:192). This is unaffected by changing the chrome border mechanism — the focus bubble listener stays on the inner div, not on the chrome outer div.

ARIA aria-current="true": All three FrameChrome branches (normal/zoomed, collapsed, popped-out) already set aria-current={isActive ? "true" : undefined} on the div[data-testid="frame-chrome"]. This correctly announces the active frame to screen readers. The change from border-active-frame to box-shadow inset does not affect this attribute.

Screen-reader announcements: aria-current="true" is the correct semantic indicator that this frame is the current/active one. Visual styling (border vs. shadow) is not part of the announcement. No additional aria-* adjustments are required.

Reduced motion: box-shadow changes have no animation by default. If a CSS transition is added to the shadow (e.g., for a smooth activation indicator), it must be gated behind @media (prefers-reduced-motion: no-preference). Sub 6a should not add transitions to the shadow property; the current frameset borders have none.

Color contrast: The active-frame color is palette index 3 (accent). For default-dark this is #AE8556 (contrast ratio against #1C1C1C background: ~7.5:1, well above WCAG AA). For Catppuccin Mocha this is #f9e2af (~12:1 against #1e1e2e). The inset shadow at 1px is a purely decorative indicator, not a text/icon container, so WCAG contrast requirements for non-text content (3:1) apply — all tested themes pass.


Artifact 7 — User settings audit

Dropbox volume not mounted on this WSL2 host. The path /home/takazudo/Dropbox/ainotes/prompts/.zudotext.settings.json is not accessible from this environment.

What Sub 6b must check: Read the file at ~/Library/CloudStorage/Dropbox/ainotes/prompts/.zudotext.settings.json on macOS and inspect settings.color.activeFrameBorder. If the stored value is "#89b4fa" (Catppuccin Mocha palette-4 blue), that is the stale value from before d0a7aab6 and b9502dc3.

Why this matters: validateSettings() in packages/app-defaults/src/validate-settings.ts:257–265 accepts any valid 6-digit hex color for every color.* field. It does NOT reset color.activeFrameBorder to the scheme default — it accepts whatever hex is stored. If the user's saved settings have activeFrameBorder: "#89b4fa", then applyColors() will push #89b4fa to --theme-active-frame-border, overriding both the CSS fallback AND the JS-resolved scheme default.

Resolution if stale value found: Since the app is pre-release (no backward compatibility required per CLAUDE.md), Sub 6b should delete or null-out color.activeFrameBorder from the user's settings file, or add a migration step in validateSettings() that detects "#89b4fa" and replaces it with the scheme-resolved default. The cleanest approach is to delete the entire color section from the settings file so the next boot recalculates it from the current scheme.


Does page-level chrome contribute to the visible blue?

Answer: NO.

The write-page (tauri-app/renderer/pages/write-page.tsx:884–943) renders this structure above the Frameset:

<div className={`min-w-0 flex flex-1 flex-col overflow-hidden ${panelBorderCls(...)}`}>
  {/* Page-level top bar */}
  <div className="shrink-0 flex items-center ... bg-bg-alt border-b border-edge ...">
    {headerActions}
  </div>
  {/* Frameset container */}
  <div className="flex-1 relative overflow-hidden">
    <Frameset ... />
  </div>
</div>

The panelBorderCls() helper (packages/ui-components/src/panel-classes.ts:10–15) returns:

  • For vertical layout, non-last panel: "min-h-0" or "border-b border-edge min-h-0" — always border-edge, never active-frame color

  • For horizontal layout, non-last panel: "" or "border-r border-edge" — always border-edge

The page-level top bar has border-b border-edge (neutral, not active-frame).

The archives-page (tauri-app/renderer/pages/archives-page.tsx:80–96) renders:

<div
  className="relative"
  style={{ height: "calc(100% - var(--toolbar-height) - var(--status-bar-height, 0px))" }}
>
  <Frameset ... />
</div>

No border on the page-level container.

Conclusion: Page-level chrome (toolbar, page containers, top bars) never uses border-active-frame, --color-active-frame, or any active-frame-colored border. The blue/active color is applied exclusively at the FrameChrome level inside the Frameset. Page chrome is not a contributor and does not need restructuring to fix the active-frame border.


Broader route audit

Every route in tauri-app/renderer/pages/*.tsx is assessed below.

(a) Conforms — <App><GlobalHeader /><Frameset /> shape or equivalent

write-page.tsx (route: /) — Uses <Frameset chrome={ChromeAdapter}>. Conforms. The write-page's "top bar" is inside the page component, not a global GlobalHeader, but the Frameset nesting is correct. No restructuring needed for the active-frame fix.

archives-page.tsx (route: /archives) — Uses <Frameset chrome={ChromeAdapter}>. Conforms. Single-leaf Frameset. No restructuring needed.

(b) Custom chrome to restructure

None of the routes apply chrome outside the Frameset that contributes to the active-frame border. No routes are in this category.

(c) Explicitly exempt

search-page.tsx (route: /search)Exempt (historical). As of sub #1494 (Epic #1482), this page now uses <Frameset> with defaultLeaf: core.search. It conforms to the multi-frame workspace shape and is no longer hand-rolled. The entry above is outdated; search-page.tsx should be in the conforming category.

tags-page.tsx (route: /tags) — Exempt. Same rationale as search-page: hand-rolled two-pane layout (<aside> tag list + <main> detail pane), no Frameset. The left/right pane borders use border-r border-edge (neutral). No active-frame border involvement.

ios-degraded/ screens — Exempt. Error/degraded-mode screens rendered instead of the normal app UI. No Frameset involved.

popped-out-page.tsx (route: /popped-out/:frameId) — Special case. The popped-out window renders a single provider with NO FrameChrome wrapper. It bypasses the Frameset entirely. The active-frame border fix in FrameChrome does not affect this route. The popped-out window does not show an active-frame border by design (it IS the active frame, rendered in its own window).

Summary: Only write-page and archives-page use Frameset + ChromeAdapter. Both conform. No other route needs restructuring as part of #1485 or this epic.


Diagnose why each prior fix failed

Fix 1 — commit d0a7aab6 (Sat May 9 2026)

What it changed: SEMANTIC_DEFAULTS.activeFrameBorder from 4 (blue) to 3 (accent/yellow).

What it missed: This only changed the DEFAULT resolution for schemes that lack a sem.activeFrameBorder override. It did NOT:

  1. Fix the CSS fallback --theme-active-frame-border, which still pointed at var(--palette-4) (blue)

  2. Fix the empty-leaf path in frameset-chrome-adapter.tsx, which used var(--color-border-active-frame, #89b4fa) — a non-existent CSS variable with a hardcoded blue fallback

  3. Invalidate any user settings that had a stored activeFrameBorder: "#89b4fa" value

Wrong assumption: "Changing the JS default is enough." It was not, because (a) there were hardcoded references that bypassed the JS resolution path, and (b) user settings persist across fixes.

Fix 2 — commit b9502dc3 (Sun May 10 2026)

What it changed:

  1. frameset-chrome-adapter.tsx: var(--color-border-active-frame, #89b4fa)var(--color-active-frame) (correct token name, no hardcoded fallback)

  2. tokens.css: --theme-active-frame-border: var(--palette-4)var(--palette-3) (CSS fallback now points at accent/yellow)

  3. color-settings.ts: Updated a stale comment (code was already correct after d0a7aab6)

What it missed:

  1. The user's settings file may still contain color.activeFrameBorder: "#89b4fa" from before d0a7aab6. applyColors() would push that stored hex to --theme-active-frame-border, overriding the CSS fallback AND the JS-resolved scheme default. The fix has no effect if the stored settings override it.

  2. The empty-leaf path now uses the correct CSS variable, but it still uses outline while the concrete-leaf path uses border — the inconsistency remains, though it does not affect color.

Wrong assumption: "Fixing the CSS references will make the runtime color match." It does fix the color for fresh installs (no stored settings). But for any user who had settings persisted before d0a7aab6, the stored hex persists through validateHexColor() and re-overrides the fix on every boot.

Root cause of the regression loop: The color.activeFrameBorder field in AppSettings is a persisted hex color string. Once a wrong hex is written to settings, subsequent JS fixes to the default resolution have no effect on that user's app until they manually change the color setting or the settings are wiped.


Hypotheses verified

Hypothesis 1 — Is border-active-frame actually emitted by the build?

YES. Confirmed in Artifact 1. The class .border-active-frame { border-color: var(--color-active-frame) } is present in the production CSS.

Hypothesis 2 — Does applyColors() actually run on app boot AND on settings change?

YES. applyColors() is called in two places:

  1. tauri-app/renderer/lib/apply-appearance.ts:26 — called from app.tsx:2041 (handleSettingsLoaded), which fires once via SettingsProvider::onLoaded when settings are first loaded from the backend.

  2. tauri-app/renderer/app.tsx:878 — called inside a useEffect([appSettings]) that fires whenever appSettings state changes, which includes after the settings dialog closes.

Sub 6b diagnostic log: Add console.log('[applyColors] activeFrameBorder:', colors.activeFrameBorder) inside applyColors() gated behind import.meta.env.DEV. This will confirm what hex value is actually being applied at runtime and whether it matches the expected scheme default.

Hypothesis 3 — Is the user's .zudotext.settings.json overriding activeFrameBorder to a stale value?

UNKNOWN — requires macOS inspection (Artifact 7). The Dropbox volume is not accessible from WSL2. Sub 6b must check ~/Library/CloudStorage/Dropbox/ainotes/prompts/.zudotext.settings.json on the running macOS host. If color.activeFrameBorder is "#89b4fa", that is the stale blue value, and deleting it (or the full color section) will resolve the issue for this specific user.

This is the most likely explanation for why the bug persists after b9502dc3.

Hypothesis 4 — Is the border being painted on the right DOM node?

YES for concrete leaves (normal/zoomed, collapsed, popped-out). FrameChrome's outer div carries the border. This is the correct node — it is the leaf boundary.

NO for empty leaves. The empty-leaf path in frameset-chrome-adapter.tsx:152–166 uses a bare div with inline outline style, not a FrameChrome. The "right node" concept applies, but the mechanism is inconsistent with the concrete-leaf path.

Hypothesis 5 — Is there a SECOND wrapper with its own border bleeding through visually?

NO for concrete leaves. The div[data-frame-id] inside LeafRenderer:189 has no border styling. The div[data-testid="frame-content"] inside FrameChrome has no border. The header inside FrameChrome has border-b border-edge (neutral, not active-frame color) — this is expected and not a "bleeding" border.

POSSIBLE for empty leaves only. The inline-style outline on the empty-leaf wrapper and any containing structure could interact. After migration to FrameChrome-for-empty-leaves, this issue disappears.


Things that MUST NOT be done

  • No hardcoded #89b4fa anywhere in production code. This is the Catppuccin Mocha palette-4 blue and will break every other color scheme.

  • No second border layer. The active border must replace or augment-via-inset-shadow the neutral border. Never add a second border on a different wrapper div.

  • No outline-on-border layered on the same node. The empty-leaf path's current outline + outlineOffset must be replaced with the same border + box-shadow inset mechanism used by concrete leaves.

  • No border-active-frame unless verified emitted (it is — see Artifact 1, but if the @source glob ever changes, this must be re-verified).

  • No JS push to a CSS var that doesn't propagate to all consumers. The chain applyColors() → --theme-active-frame-border → --color-active-frame → border-color must remain intact for all leaf states.

  • No new per-state border mechanism. All four leaf states (normal, zoomed, collapsed, popped-out) plus empty-leaf must use the same underlying CSS property for the active indicator.


Implementation plan for Sub 6a (#1489)

Sub 6a implements against this document. The minimal set of changes:

Step 1 — Migrate empty-leaf to FrameChrome

In tauri-app/renderer/components/frameset-chrome-adapter.tsx:152–166, replace the bare inline-style div:

// BEFORE (remove this)
return (
  <div
    style={{
      width: "100%",
      height: "100%",
      outline: isActive ? "1px solid var(--color-active-frame)" : "1px solid transparent",
      outlineOffset: "-1px",
      boxSizing: "border-box",
    }}
  >
    {children}
  </div>
);

With a minimal FrameChrome-like wrapper that uses the same border mechanism as the concrete-leaf path. Options:

  • Pass a stub provider to FrameChrome with title: "", icon: null, etc., and render no header controls. This reuses FrameChrome's border logic directly.

  • Create a new MinimalFrameChrome component inside packages/frameset/ that just renders the outer border div and the aria-current attribute, delegating to FrameChrome's border logic.

Sub 6a should choose whichever approach is cleanest.

Step 2 — Switch mechanism in FrameChrome from border-active-frame to box-shadow inset

In packages/frameset/src/frame-chrome.tsx, for all three render branches (popped-out, collapsed, normal/zoomed), change:

// BEFORE (in normal/zoomed branch, line ~319)
isActive ? "border-active-frame" : "border-transparent"

// AFTER
"border-edge"  // always present; active indicator via box-shadow

And add a conditional boxShadow style (or a Tailwind arbitrary value):

style={isActive ? { boxShadow: "inset 0 0 0 1px var(--color-active-frame)" } : undefined}

Apply the same change to the collapsed and popped-out branches (lines ~248, ~283), replacing border-active-frame with border-edge for the neutral state and adding the inset shadow for active.

Step 3 — Verify settings (Sub 6b responsibility)

Sub 6b checks the user's settings file per Artifact 7. If stale blue is found, wipe color.activeFrameBorder or the full color section.

Step 4 — Optional: add DEV diagnostic log

In packages/color-themes/src/color-settings.ts inside applyColors(), add:

if (import.meta.env?.DEV) {
  console.log('[applyColors] activeFrameBorder:', colors.activeFrameBorder);
}

This is a Sub 6b deliverable, not Sub 6a.


Summary of root causes

  1. Stale hex in user settings (most likely): color.activeFrameBorder: "#89b4fa" stored in .zudotext.settings.json before d0a7aab6 persists through validateHexColor() and overrides all JS and CSS fixes at every boot.

  2. Mechanism inconsistency (structural problem): Empty leaves use outline + outlineOffset (inline style) while concrete leaves use border + border-active-frame (Tailwind class). This creates two code paths to maintain, and a future regression in either path will appear only for certain leaf states.

  3. CSS-only fallback correctly fixed (current state): After b9502dc3, the CSS fallback and JS default both point at palette-3 (accent, not blue). If not for issue 1 above, the fix would be complete for new users.

The structural fix (Step 2 above) addresses issue 2 and makes the code more maintainable. The settings wipe (Artifact 7 / Sub 6b) addresses issue 1 and unblocks the running prompts.app for the current user.