Troubleshooting Guide

Known issues, root causes, and proven fixes. Consult this before debugging a problem that looks familiar — we may have solved it before.

Debugging Strategies

Freeze-frame: slow or pause animations to inspect frame 0

When a transition has a visual glitch (flash, stutter, wrong position) that's too fast to see, slow the animation dramatically to observe what happens at each phase.

Technique 1 — Slow motion: Set animation duration to 20s (from 450ms). The entire animation plays in slow motion, making frame-level issues visible.

Technique 2 — Freeze frame 0: Add a 3s animation-delay so the pre-animation state is frozen for 3 seconds. This reveals what the DOM looks like at the exact moment of mount, before any animation processing — the critical frame where flashes originate.

/* DEBUG: freeze the start state for 3 seconds */
.content-panel-overlay.expanding {
  animation: expandToFill 20000ms ease-out 3s both;
}

When to use: Any time the glitch is "instantaneous" or "camera-flash-like" and you can't determine what's flashing. The freeze makes the invisible visible.

Seen in: #135 (card expansion flash), where freezing frame 0 revealed the backdrop DocumentBrowserPanel painting at full size — a finding that was invisible at normal speed.

Layer elimination: hide elements one by one

When a flash or visual glitch is confirmed but the source layer is unknown, systematically hide DOM layers until the glitch disappears. The layer whose removal eliminates the glitch is the culprit.

Technique: Wrap each suspect layer with {false && ...} in JSX to remove it from the render tree. Test after each change. Restore all layers, then hide only the confirmed culprit to verify isolation.

{/* DEBUG: hide to test if this layer causes the flash */}
{false && backdrop}

{/* DEBUG: hide overlay to test */}
{false && <div className={overlayClass}>{overlayContent}</div>}

Order of elimination (for transition flashes):

  1. Backdrop / underlayer (most common — mounting complex component trees)
  2. Overlay / animation container
  3. Sidebar conveyor (both panels rendering simultaneously)
  4. Background pulse / workspace class toggles

When to use: After freeze-frame confirms a frame-0 issue but the specific element isn't identifiable visually. Binary search via elimination is faster than reading code.

Seen in: #135 (card expansion flash), where 3 tests identified DocumentBrowserPanel backdrop as the sole source. Root cause was React unmount-remount from branching render paths — not a CSS issue at all.

CSS Animation & Rendering

Flash of full opacity before animation starts

Symptom: A newly mounted element appears at full opacity for one frame, then the fade-in animation kicks in. Looks like a brief "flash" or "stutter."

Root cause: The element mounts into the DOM in normal flow (opacity: 1) before the CSS animation class is applied. Even if the animation keyframe starts at opacity: 0, there's at least one frame between DOM insertion and animation start where the element is fully visible.

Fix: Apply opacity: 0 as an inline style on the element from the moment it mounts. The animation then takes over to transition 0→1. The inline style ensures the element is invisible from its very first frame.

// Mounting phase — invisible from first frame
<div style={{ opacity: 0 }}>{newView}</div>

// Fading-in phase — animation takes over
<div className="fade-in">{newView}</div>

Seen in: #121 (view mode crossfade), where switching gallery↔list flashed the new view before the fade-in began.

Canonical fix: The transition orchestrator in ZuiShell provides system-level paint gating via the content-mounted phase (double-rAF). Content transitions use the shared phase clock — no ad-hoc inline opacity management needed. For perspective transitions, animation-fill-mode: both on all CSS animations ensures the from keyframe applies before the first paint. See Transition Architecture Principles 6 and 7.

Overlay removal pop / sub-pixel mismatch on handoff

Symptom: When removing an absolutely-positioned overlay that's visually aligned with a layout-positioned element beneath it, there's a brief "pop" or shift at the moment of removal. The two elements should be pixel-identical but aren't.

Root cause: CSS transforms (used by the overlay) and normal layout flow (used by the underlying element) can produce sub-pixel differences due to rounding. When the overlay is removed in the same frame as a state change, the browser may not have completed layout of the underlying element yet.

Fix: Defer the overlay removal by one frame using requestAnimationFrame. This gives the browser one compositing cycle to settle the underlying layout before the overlay disappears.

requestAnimationFrame(() => {
  // Now safe to remove the overlay — underlying element is painted
  resetOverlayState()
})

Seen in: #116 (card collapse pop), where the card overlay's CSS transform position didn't match the grid's layout position at the moment of removal.

Concurrent rendering during CSS animation causes stutter

Symptom: A crossfade or transition animation drops frames / stutters visibly, even though the CSS animation itself is compositor-friendly (opacity, transform).

Root cause: Mounting a new React component tree during an active animation blocks the main thread. Even though opacity animations run on the compositor, the compositor can't composite what hasn't been laid out. Slate editor initialization, large list rendering, or any expensive synchronous mount will block frame production.

Fix: Never mount expensive components during an active animation. Use a sequential approach:

  1. Fade out the current view (only existing DOM, zero mount cost)
  2. In the dead zone between animations, mount the new view (at opacity 0)
  3. After the new view has painted (use double requestAnimationFrame), start the fade-in
// Double rAF ensures one full frame has been painted
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    startFadeIn()
  })
})

Seen in: #121 (view mode crossfade), where simultaneous rendering of gallery and list view during a crossfade caused frame drops. Also #130 (split-expand transition), where the Slate editor in the detail pane stutters when settling after the expand/collapse animation.

Canonical fix: The transition orchestrator in ZuiShell sequences mount and fade via the phase clock: content-exiting (fade out) → content-mounted (new DOM at opacity 0, double-rAF paint gate) → content-entering (fade in). This is a system-level guarantee — no per-component implementation needed. See Transition Architecture.

CSS transition causes element to lag behind divider during drag

Symptom: An absolutely-positioned element that tracks a split divider (via a CSS custom property like --split-ratio) visibly lags behind the divider during drag resize. The divider and its panes move instantly, but the tracking element slides smoothly to catch up.

Root cause: The element has a CSS transition on its positioning property (e.g., transition: right var(--transition-sidebar)) intended for sidebar open/close animation. During drag, the position updates every frame via a reactive custom property, but the transition smooths over frame-by-frame changes instead of applying them instantly.

Fix: Disable the transition during drag. Propagate the drag state (from MasterDetailSplit or equivalent) to the element and add a .dragging class that sets transition: none. This is the same pattern SidebarPanel uses for its resize divider.

/* Normal: transitions for sidebar open/close */
.my-element {
  transition: right var(--transition-sidebar);
}

/* During drag: instant tracking */
.my-element.dragging {
  transition: none;
}

Rule of thumb: Any element that tracks a split divider position AND has a CSS transition on its positioning property needs a drag-state override. This applies every time a new component is added to a panel with a resizable divider.

Seen in: #129 (ellipsis island in list view), where transition: right var(--transition-sidebar) on .zui-ellipsis-island caused it to lag behind the master-detail divider during drag.

One-frame flash from React tree position change

Symptom: A component that should persist across a state change (e.g., DocumentBrowserPanel visible as both static content and animation backdrop) flashes for one frame during the transition.

Root cause: The component appears at different positions in the React element tree depending on the state branch. React uses element position + type for instance identity. If the component moves to a different tree position (e.g., from one return branch to another), React unmounts the old instance and mounts a new one. The fresh mount produces one paint frame.

Fix: Use a single return statement with the component at a fixed tree position. Control visibility with conditional rendering ({showBrowser && <DocumentBrowserPanel />}) instead of separate return branches.

Diagnostic: Use the layer elimination technique. If hiding the component eliminates the flash, and animation-fill-mode: both doesn't fix it, the issue is React lifecycle (unmount-remount) not CSS timing.

Seen in: #135 (card expansion flash), where ContentPanel had two return branches with DocumentBrowserPanel at different positions. Unified to a single render path.

Documented in: Transition Architecture Principle 7 (Stable React tree positions).

Stale children aren't actually stale (synchronous reactive data)

Symptom: A <Crossfade> content transition shows the new content during the fade-out phase — the stale children ref holds old JSX, but it renders with new data.

Root cause: Crossfade captures JSX elements in a ref, but JSX is a description, not frozen DOM. When workspace observable state changes synchronously (e.g., selectedFolderId), React re-renders the "stale" JSX with the new state. The observable's live-read characteristic means every render sees the latest data.

Fix: Deferred writes. Trigger sites separate "declare intent" from "apply data change":

  1. Store the target in transition.pendingFolderId (don't touch selectedFolderId)
  2. Increment transition.contentEpoch (gives Crossfade a key mismatch to participate)
  3. Set transition.phase = 'content-exiting'
  4. The orchestrator (ZuiShell) applies selectedFolderId = pendingFolderId at content-mounted (opacity at floor)

When to apply: Any time a content transition trigger changes synchronous reactive data that Crossfade's stale children would reflect. Async data (e.g., IndexedDB loads) doesn't need this — the data isn't available during fade-out anyway.

Seen in: #135 (folder selection stutter), where selectedFolderId changed at trigger time and the document list visibly updated during fade-out.

Documented in: Transition Architecture — Deferred Writes

CSS Box Model

Auto-sized element is larger than explicit-sized element with same content

Symptom: Two elements that should be the same size render at different sizes. One has an explicit width/height and the other auto-sizes to identical children, yet the auto-sized one is visibly larger (e.g., rounded corners look squared instead of circular).

Root cause: The global reset * { box-sizing: border-box } in core.css makes explicit width/height include the border. A 36×36 element with a 1px border has a 36×36 outer box (34×34 content). But box-sizing has no effect on auto-sized elements — when width/height is auto, the browser sizes the content box to fit the children, then the border adds outside that. So a flex container wrapping a 36×36 child with its own 1px border ends up 38×38 outer.

Fix: Size the inner children to account for the parent's border. If the target outer size is 36px and the parent has a 1px border, the children should be 34×34 (not 36×36), so the parent auto-sizes to 34 content + 2 border = 36 outer.

Explicit-sized:  width: 36px + border: 1px → 36×36 outer (border-box)
Auto-sized:      child: 36×36 + border: 1px → 38×38 outer (border adds outside)
Fixed:           child: 34×34 + border: 1px → 36×36 outer (matches explicit)

Seen in: #127 (island margin increase), where toolbar and ellipsis islands (auto-sized <div> wrapping 36×36 buttons) were 38×38 while sidebar toggle icons (explicit width: 36px <button>) were 36×36. The 2px difference made the 17px border-radius look noticeably less circular on the larger islands.

Selection & Focus

Unfocused selection styling on page restore

Symptom: After page reload, selected items appear in unfocused (muted) selection color instead of the focused (accent) selection color.

Root cause: :focus-within CSS selectors require actual DOM focus. After a page reload, selection state is restored from localStorage but nothing programmatically focuses an element inside the list container.

Resolution: Accepted as correct default behavior — restored state should be visible but not presumptuous about focus. Clicking anywhere in the list switches to focused styling naturally.

Seen in: #117 (view state restoration).

React Rendering

Effect fires on mount even with "change detection" guard

Symptom: A useEffect intended to respond only to value changes also fires on initial mount, causing unintended side effects (clearing selection, resetting scroll, etc.).

Root cause: React always runs effects after the first render, regardless of dependencies. A dependency like [selectedFolderId] will fire on mount with the initial value.

Fix: Use a useRef to track the previous value. Skip the effect body when the ref matches the current value. Initialize the ref to the current value so the first run is a no-op.

const prevRef = useRef(currentValue)
useEffect(() => {
  if (prevRef.current === currentValue) return  // Skip mount
  prevRef.current = currentValue
  // ... actual change handling
}, [currentValue])

Seen in: #114 (selection/scroll reset on editor close), where the folder-change effect cleared selection on component remount even when the folder hadn't changed.

Pointer Events & Drag

Browser intercepts drag on <a> or <img> elements

Symptom: Custom pointer-event drag doesn't work on anchor or image elements. Chrome tries to drag the element to create a tab or bookmark instead.

Root cause: Browsers treat <a> and <img> as natively draggable. Chrome intercepts drag gestures for tab-splitting and bookmark creation.

Fix: Set draggable={false} on the element and onDragStart={(e) => e.preventDefault()} as a safety net.

Documented in: .claude/rules/pointer-events.md

Click fires after drag gesture

Symptom: After a drag or marquee gesture completes on pointerup, an unwanted click event fires, causing side effects (clearing selection, opening a document, etc.).

Root cause: Browsers fire click after pointerup as part of the normal event sequence. There's no built-in way to distinguish "click after drag" from "intentional click."

Fix: Module-level flag set during drag, consumed once in the click handler:

let suppressNextClick = false

export function shouldSuppressDragClick(): boolean {
  if (suppressNextClick) { suppressNextClick = false; return true }
  return false
}

Documented in: .claude/rules/pointer-events.md