Skip to content

Boot Overlay, Onboarding & Shared UI Kit

dazeb edited this page Sep 17, 2026 · 2 revisions

Boot Overlay, Onboarding & Shared UI Kit

This page covers the renderer surfaces that frame the canvas rather than live on it: the overlay that covers startup, the first-run guide, transient help affordances, and the shared primitives (a control kit and a resize hook) the rest of the renderer builds on. The recurring pattern is that behavior lives in small pure functions and focused hooks, while components stay presentational and delegate to stores, IPC, or the design tokens owned elsewhere.

Boot overlay: a three-phase exit gate

The workspace usually finishes loading well under a second, so an overlay that unmounts the instant loading completes reads as a hard flicker. The fix in hooks/useBootOverlay.ts is not to delay mounting (that would add startup latency) but to keep the overlay on top of the already-mounted canvas and cross-fade it out.

The transition is modeled as a pure function, so it is testable without a DOM:

  • resolveBootPhase(loaded, fadeDone) returns 'boot' while loaded is false, 'leaving' once loaded but before the fade timer fires, and 'done' afterwards.
  • useBootOverlay(loaded, fadeMs = BOOT_FADE_MS) is — per its own comment — "only the timer that drives it": one useEffect keyed on [loaded, fadeMs] schedules setFadeDone(true) after fadeMs, and the timeout is cleared on re-run or unmount.
  • The hook returns a view model: visible is phase !== 'done', leaving is phase === 'leaving'. The caller applies the leaving CSS class when leaving is true.
stateDiagram-v2
    [*] --> boot
    boot --> leaving: loaded = true (timer armed)
    leaving --> done: fade timer fires (BOOT_FADE_MS = 180)
    done --> [*]
    note right of boot
        visible = true, leaving = false
        overlay stays fully opaque over the canvas
    end note
    note right of leaving
        visible = true, leaving = true
        cross-fade runs over the already-mounted canvas
    end note
    note right of done
        visible = false
        overlay is unmounted for good
    end note
Loading

Key nodes. boot is defensive: !loaded wins over everything, so the overlay is opaque even if a stale fadeDone exists. leaving is the only phase where the fade applies; the comment explains the asymmetry — during boot the overlay must be fully opaque so the canvas never shows through a half-transparent tesseract. done is terminal for the hook instance.

Boundaries. fadeDone is latched: nothing sets it back to false, so if loaded ever regressed and recovered, the next loaded = true would compute 'done' immediately with no second fade. Treat loaded as one-way. The hook does not decide what "loaded" means — the composition layer owns that (renderer bootstrap and service readiness). BOOT_FADE_MS = 180 is exported specifically because it must match the CSS transition duration; changing one without the other makes the overlay unmount early or late, so keep them in sync manually.

First-run onboarding

components/Onboarding.tsx exports two things: a gate predicate and the overlay component. Neither owns project creation or the onboardedAt setting — those belong to the caller and the stores.

The gate. ShouldShowOnboarding(settings, projectCount) returns true only when settings.onboardedAt is absent and projectCount === 0. Both inputs are external state, so mounting and re-evaluation belong to the composition layer; the component itself never checks them.

Dismissal. onDismiss is the single exit. Escape, "Skip", and "Get started" all converge on it, and the file's header states that dismissal writes the onboardedAt marker, so the guide is never shown again (Settings exposes a re-run path). The Escape listener is registered on window for the mount duration, matching the settings sheet convention. Step copy lives in the static STEPS array and carries an editorial invariant: every step must describe shipped behavior only, so adding a step for a planned feature is a policy violation, not a style choice.

Create flow. createProject deliberately dismisses first so the folder dialog opens over a clean canvas, then runs the same flow the + tab uses:

sequenceDiagram
    participant U as User
    participant O as Onboarding
    participant W as "window.termsprawl (preload)"
    participant P as "useProjects (store)"

    U->>O: click "Get started — create a project"
    O->>O: onDismiss()
    Note over O: dismissal precedes the dialog,<br/>so the picker opens over a clean canvas
    O->>W: workspace.selectFolder()
    W-->>O: cwd or null
    alt cwd is a path
        O->>P: getState().create(projectNameFromPath(cwd, 'project-1'), cwd)
    else canceled / null
        Note over O: returns early; onboarding is already dismissed
    end
Loading

Boundaries. Because dismissal happens before the dialog resolves, a canceled folder picker still releases the onboarding gate — recovery is the Settings re-run, not a retry of the card. The naming fallback 'project-1' comes from projectNameFromPath. onDismiss sits in the effect's dependency array, so passing an unstable callback re-registers the keydown listener on each render. The header also notes that import React is required for the vitest build (no jsx:react-jsx transform in tests) even though the app build ignores it — do not remove that import.

useSafeResize: the shared ResizeObserver contract

hooks/useSafeResize.ts encodes two production incidents so no consumer has to rediscover them. The hook signature is useSafeResize(ref, onResize) and the consumer’s only job is to make onResize idempotent, because the hook guarantees when it runs, not what it does.

  • Failure mode 1 — "ResizeObserver loop completed with undelivered notifications." Chromium warns when a callback synchronously mutates the observed element's size in the same frame (xterm fit() writing host dimensions, Monaco layout writing its container). Fix: defer the work to requestAnimationFrame so the mutation lands in a later frame, and skip deliveries under 1px so subpixel feedback never reaches the callback.
  • Failure mode 2 — renderer crash / black screen. If an RO callback throws (Chromium's "loop limit exceeded" is thrown when an observer keeps rescheduling itself), the uncaught exception can blank the page. Fix: every callback body is wrapped in try/catch, and the hook never disconnects/re-observes from inside the callback.
flowchart TD
    A["ResizeObserver delivery (batch of entries)"] --> B["take the last entry (newest coalesced size)"]
    B --> C{"both |Δw| and |Δh| < 1px vs last processed size?"}
    C -- yes --> D["skip: subpixel noise"]
    C -- no --> E["record lastSize, cancel pending rAF"]
    E --> F["requestAnimationFrame"]
    F --> G["call onResizeRef.current() in try/catch"]
    G -- throws --> H["swallowed: layout failure degrades to no-op"]
    G -- ok --> I["consumer relayout: xterm fit / Monaco layout"]
Loading

Key implementation details. Only [ref] is an effect dependency, so a changing onResize identity never tears down the observer — the callback is kept fresh in onResizeRef.current. lastSize and frame are refs, not state, so resizing never triggers React renders. The guard compares against the last processed size (the older return-inside-loop version could bail out of the whole batch on a subpixel entry and desync from the real size during drags). A burst of deliveries coalesces into one callback per frame via cancelAnimationFrame.

Boundaries. If ref.current is null when the effect runs, the hook silently no-ops and will not retry until the ref identity changes — mount the observed element together with the hook. The first delivery for a rendered, non-zero element passes the guard (lastSize starts at 0×0), so consumers get one asynchronous initial call; a zero-sized element produces nothing. onResize is always invoked in a later frame, never synchronously inside the RO delivery. Errors are intentionally swallowed, so if a failure ever needs reporting it must be surfaced inside the consumer's own callback. Do not "simplify" this back to a synchronous fit or a disconnect/re-observe dance — the file comment states both reintroduce the crash.

Transient help: HelpBadge

components/HelpBadge.tsx is a self-contained inline-help primitive: a badge button plus a portal-rendered popover. Callers only supply text and an optional label (default 'help', used as aria-label).

Behavior. Hover and focus call show() (cancel pending hide, re-measure position, open); mouse-leave and blur schedule a hide after HIDE_MS = 160 so the pointer has grace to travel into the popover; re-entry cancels the timer. While open, Escape closes it, and resize plus capture-phase scroll listeners re-run place(), so the popover tracks its anchor inside any scrolling ancestor. All listeners are removed when the popover closes or the badge unmounts, and a pending hide timer is cleared on unmount.

Positioning. place() reads the button rect, clamps left into [8, innerWidth - POP_WIDTH - 8] with POP_WIDTH = 268, prefers rect.bottom + 6, and flips above (max(8, rect.top - 8 - 140)) when the estimated height would overflow the viewport. The flip uses assumed heights (160/140), not measured ones — unusually tall help text can clip.

Canvas coexistence. The button carries the nodrag class and stops propagation on pointerdown and click, which is what lets it sit inside draggable canvas nodes without starting a drag or canvas selection. Accessibility is wired through aria-expanded and aria-describedby (only while open, via useId).

The shared UI kit (components/ui/kit.tsx)

The kit documents itself as "the component system for the settings panel (AppSettingsPanel.tsx)" — but Onboarding importing Button shows it is shared beyond settings. It is pure presentation: no store, IPC, or canvas imports; every value and callback arrives via props. Its styling comes from Tailwind v4 utilities whose tokens (bg-panel, text-ink, border-edge, text-mute, bg-raised, text-danger, text-page) live in src/renderer/src/settings.css, so token changes propagate to every consumer. One explicit design boundary: the kit is neutral by design — no lime — with emphasis as ink-on-page and red reserved for destructive states.

Layering primitives.

  • Section — a titled card: sentence-case title above the card with an optional count. The card has no horizontal padding; rows bring their own px-4 so hairline dividers run the full card width and meet its border, and [&>*:last-child]:border-b-0 trims the final rule.
  • PrefRow — the workhorse label/sub-copy-left, controls-right row; copy is measure-capped at 52ch, the control column is shrink-0.
  • Row — generic list row for accounts, peers, providers; FieldRow — new-item form row; CardNote — a full-bleed intro band that aligns with row dividers (distinct from the Hint element the comments reserve for nested cards and page footnotes).

Controls. All follow the documented conventions: 32px control height, 7px radius on controls, ink :focus-visible rings, hover via border-raised.

  • Button — variants neutral | primary | danger; primary is the only loud surface (solid ink); danger rests neutral and turns red on hover, or rests red when armed (destructive confirmation); sizes md (32px) and sm (28px, documented for dense surfaces such as the onboarding card). It defaults type="button" to prevent accidental form submits.
  • Toggle — controlled role="switch" with aria-checked; the 36×20 track keeps a visible border/background when off, and a pseudo-element extends the hit area to roughly 44px without affecting layout.
  • Select — appearance: none plus a custom chevron SVG because the native GTK arrow cannot be styled.
  • TextInput / TextArea — default to a 280px cap for pref-row values; grow fills the row for form layouts.
  • Card (partially inside the captured range) — a raised block for status cards such as relay pairing and remote terminals.

Boundaries. The kit holds no state; Toggle is fully controlled. Adding a control means following the existing height/radius/focus conventions and extending the variant maps (Record<ButtonVariant, string> etc.) rather than ad-hoc classNames. Note one drift worth knowing: the header comment mentions 8px card radii, while Section renders rounded-[10px] — treat the JSX class strings, not the comment, as the source of truth.

How the files collaborate

Dependencies point one way: first-run surfaces depend on the kit, the kit depends on tokens, and every cross-boundary action goes through an explicit prop, hook input, or existing store API.

Relationship Mechanism Why it matters
Onboarding → ui/kit Button import First-run UI reuses the settings-grade control vocabulary; appearance is token-driven, so theming onboarding never touches its code.
Onboarding → preload bridge window.termsprawl.workspace.selectFolder() Folder picking stays a main-process concern; the renderer just awaits a cwd.
Onboarding → projects store useProjects.getState().create(name, cwd) Onboarding owns ordering and copy only; it runs the same creation path as the + tab so semantics stay in one place.
App composition → useBootOverlay loaded boolean in, { visible, leaving } out The caller owns readiness and the overlay element; the hook owns timing and phase only.
Node internals → useSafeResize ref + imperative onResize The hook neutralizes the ResizeObserver hazards; the consumer does the actual fit()/layout work.
UI surfaces → HelpBadge text prop, portal + nodrag Help is self-contained and safe to embed in canvas nodes; call sites stay unaware of positioning logic.
kit, HelpBadge → settings.css Tailwind v4 token classes One token change restyles every surface in this page at once.

State ownership, which is where most regressions in this area come from:

State Owner Lifecycle
loaded app composition (external) single input to the boot hook; must be one-way
fadeDone useBootOverlay latched true by the fade timer; never reset
onboardedAt settings store (written via onDismiss) permanent once set; gate input
project count projects store gate input; re-evaluation is the host's job
lastSize, frame useSafeResize refs per hook instance; no React state, no renders
open, pos, hideTimer HelpBadge per badge; cleared on close/unmount

Boundaries and extension points

  • Boot timing. To change the cross-fade, update BOOT_FADE_MS and the matching CSS transition together. If more phases are ever needed, extend resolveBootPhase (keeping it pure and DOM-free) and leave the hook as the timer. Do not build flows that expect the fade to replay — fadeDone does not reset.
  • Onboarding. New steps go into STEPS and must describe shipped behavior. Any new starting action should dismiss before invoking an existing flow, mirroring createProject, and route through the same preload/store calls rather than duplicating creation logic.
  • Settings surfaces. New controls should follow the 32px/7px/focus-visible conventions, add entries to the variant maps instead of one-off classes, keep horizontal padding on rows (not the card) so dividers stay full-bleed, and use grow for full-width fields.
  • Resize-driven layout. Always go through useSafeResize; never perform synchronous relayout inside an RO callback and never disconnect/re-observe from within it. Remember the hook swallows errors — surface failures inside your own callback if they matter operationally.
  • Help affordances. HelpBadge is self-contained; if embedded in canvas nodes, keep the nodrag class and pointer-event stops. If popover copy grows tall, revisit the assumed heights (160/140) in place() or the flip can overflow.

Limits of this page

The captured evidence covers the five files below, and two of them are truncated: kit.tsx is captured through the start of Card, and HelpBadge.tsx through the click handler (the portal body and toggle-close branch are beyond the range). Also outside the captured snippets: the component that renders the boot overlay surface, the host that evaluates ShouldOnboarding and writes onboardedAt, and the token definitions in settings.css. Claims about those are limited to what the comments and imports in the captured code state.

Sources: src/renderer/src/hooks/useBootOverlay.ts, src/renderer/src/components/Onboarding.tsx, src/renderer/src/components/ui/kit.tsx, src/renderer/src/hooks/useSafeResize.ts, src/renderer/src/components/HelpBadge.tsx

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally