-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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'whileloadedis 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": oneuseEffectkeyed on[loaded, fadeMs]schedulessetFadeDone(true)afterfadeMs, and the timeout is cleared on re-run or unmount. - The hook returns a view model:
visibleisphase !== 'done',leavingisphase === 'leaving'. The caller applies the leaving CSS class whenleavingis 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
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.
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
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.
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 torequestAnimationFrameso 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"]
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.
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 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 ownpx-4so hairline dividers run the full card width and meet its border, and[&>*:last-child]:border-b-0trims the final rule. -
PrefRow— the workhorse label/sub-copy-left, controls-right row; copy is measure-capped at52ch, the control column isshrink-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 theHintelement 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— variantsneutral | primary | danger;primaryis the only loud surface (solid ink);dangerrests neutral and turns red on hover, or rests red whenarmed(destructive confirmation); sizesmd(32px) andsm(28px, documented for dense surfaces such as the onboarding card). It defaultstype="button"to prevent accidental form submits. -
Toggle— controlledrole="switch"witharia-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: noneplus a custom chevron SVG because the native GTK arrow cannot be styled. -
TextInput/TextArea— default to a 280px cap for pref-row values;growfills 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.
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 |
-
Boot timing. To change the cross-fade, update
BOOT_FADE_MSand the matching CSS transition together. If more phases are ever needed, extendresolveBootPhase(keeping it pure and DOM-free) and leave the hook as the timer. Do not build flows that expect the fade to replay —fadeDonedoes not reset. -
Onboarding. New steps go into
STEPSand must describe shipped behavior. Any new starting action should dismiss before invoking an existing flow, mirroringcreateProject, and route through the same preload/store calls rather than duplicating creation logic. -
Settings surfaces. New controls should follow the 32px/7px/
focus-visibleconventions, 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 usegrowfor 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.
HelpBadgeis self-contained; if embedded in canvas nodes, keep thenodragclass and pointer-event stops. If popover copy grows tall, revisit the assumed heights (160/140) inplace()or the flip can overflow.
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance