# Layering System Design ## Problem Statement Flow's UI composites multiple rendering surfaces: the base BrowserWindow webContents (browser chrome), child WebContentsViews (tabs, portals, omnibox), and CSS-stacked elements within each renderer. The current system manages these with scattered magic numbers, duplicated constants, and two completely decoupled z-index scales that share no common model. Specific pain points: - **No single source of truth.** Electron-level z-index constants live in `tab-layout.ts`, `portal-component-windows.ts`, `popover.tsx`, and `browser.ts`. CSS z-index values are raw Tailwind classes across 40+ files. - **Collisions.** Glance front tabs and portal component windows both occupy z-index 3 at the Electron level. Their visual ordering depends on insertion order, which is fragile and implicit. - **Magic numbers.** The omnibox uses 999, portals use 3, popovers use 4. These values have no semantic meaning and the gaps between them are arbitrary. - **Overloaded CSS layers.** Eight different component types all use `z-50`: dialogs, sheets, tooltips, popovers, dropdown menus, select menus, the sidebar hover detector, and settings editors. There is no way to intentionally layer one above another. - **No enforceability.** Nothing prevents a developer from adding `z-[9999]` to solve a visual bug, silently breaking the hierarchy. --- ## Core Concept: Stacking Context Isolation Before defining the layers, it is critical to understand how the two tiers relate to each other. **Each WebContentsView is a completely isolated stacking context.** CSS `z-index` inside the main browser chrome renderer has zero effect on the Electron-level view ordering, and vice versa. A `z-index: 99999` in the base renderer will never make an element appear above a WebContentsView child like a tab or portal. Conversely, a portal at `ViewLayer.OVERLAY` cannot use CSS to push itself above the omnibox at `ViewLayer.OMNIBOX`. This means: 1. **ViewLayer** controls which WebContentsView is on top of which. It is the only mechanism for cross-view stacking. 2. **UILayer** controls stacking within a single renderer. It applies independently and identically inside every renderer — the main chrome, the omnibox, each portal window. A `z-modal` inside a portal and a `z-modal` inside the main chrome are in separate stacking contexts and do not interact. 3. **If UI needs to appear above tab content**, it must be rendered in a WebContentsView at a higher ViewLayer (i.e. a portal component window). CSS alone cannot solve this. ``` BrowserWindow | | Base webContents (browser chrome React app) | ┌─────────────────────────────────────────┐ | │ UILayer stacking context (isolated) │ | │ z-controls: resize rails │ | │ z-elevated: floating sidebar, loading │ | │ z-base: topbar, sidebar, content area │ | └─────────────────────────────────────────┘ | This entire surface sits BELOW all child views. | Nothing here can appear above tab content via CSS. | +-- WebContentsView children (ordered by ViewLayer) | | ViewLayer.TAB_BACK (0) ........ glance back tab | ViewLayer.TAB (10) ............ normal tab content | ViewLayer.TAB_FRONT (20) ...... glance front tab | ViewLayer.OVERLAY (30) ........ portal component windows | ViewLayer.POPOVER (40) ........ portal popovers | ViewLayer.OMNIBOX (100) ....... command palette | +-- Each WebContentsView has its own isolated UILayer ┌──────────────────────────────────────┐ │ UILayer stacking context (isolated) │ │ Independently uses z-base through │ │ z-max, no interaction with other │ │ renderers or the base chrome. │ └──────────────────────────────────────┘ ``` --- ## Layer Definitions ### ViewLayer (Electron-level view stacking) Controls the ordering of `WebContentsView` children within a `BrowserWindow`. Managed by the `ViewManager`. Values are spaced by 10 to leave room for future layers without renumbering. ```ts // src/shared/layers.ts export const ViewLayer = { /** Glance mode: the background tab, rendered behind the active tab */ TAB_BACK: 0, /** Standard tab web content (the page the user is browsing) */ TAB: 10, /** Glance mode: the foreground tab, rendered on top of the back tab */ TAB_FRONT: 20, /** Portal component windows: floating sidebar, toasts, extension popups. These are WebContentsViews that render browser chrome UI on top of tab content. */ OVERLAY: 30, /** Portal popovers: context menus, dropdowns anchored to overlay content. Must be above OVERLAY so a popover triggered from a portal renders on top of its parent portal. */ POPOVER: 40, /** The omnibox / command palette. Always the topmost view in the window. Nothing should be added above this layer. */ OMNIBOX: 100 } as const; export type ViewLayerValue = (typeof ViewLayer)[keyof typeof ViewLayer]; ``` #### ViewLayer Invariants These ordering guarantees must always hold: ``` TAB_BACK < TAB < TAB_FRONT < OVERLAY < POPOVER < OMNIBOX ``` - The omnibox is always the topmost view. No other layer may exceed it. - Portal popovers are always above portal overlays. - Portal overlays are always above all tab variants. - Tab variants are ordered: back < normal < front. #### ViewLayer Band Ranges Values between defined layers are reserved for future use. When adding a new layer, place it within the appropriate band: | Band | Range | Purpose | | ------ | ------ | ------------------------------------------------------ | | Tabs | 0–29 | Tab content variants (back, normal, front, split, pip) | | Chrome | 30–59 | UI surfaces above tabs (overlays, popovers, panels) | | System | 60–100 | System-level surfaces (omnibox, devtools overlays) | Example: a future picture-in-picture mini-player could use `ViewLayer.PIP = 15` (within the Tabs band, above normal tabs but below front/glance). A devtools overlay could use `ViewLayer.DEVTOOLS = 90` (within the System band, below omnibox). --- ### UILayer (CSS-level stacking within a renderer) Controls z-index of elements **within any single renderer process**. Every renderer — the main browser chrome, the omnibox, each portal window — uses the same UILayer scale independently. Layers in one renderer do not interact with layers in another renderer. ```ts // src/shared/layers.ts (continued) export const UILayer = { /** Default stacking level. Most elements live here. No z-index needed. */ BASE: 0, /** Elements that float above their normal document-flow siblings. Examples: floating sidebar (position:fixed over content area), loading indicator bar, search suggestion dropdowns, drag-and-drop indicator overlays, sticky toolbars. */ ELEVATED: 10, /** Interactive controls that must remain accessible above elevated elements. Examples: sidebar resize rails (must be grabbable on top of the sidebar container), resize handles, sidebar edge-hover detection strips. */ CONTROLS: 20, /** Full-viewport backdrops that dim content behind a modal or sheet. Rendered as a semi-transparent overlay covering the entire viewport. */ SCRIM: 30, /** Modal dialogs, sheets, alert dialogs. Content that sits on top of a scrim and blocks interaction with elements beneath it. */ MODAL: 40, /** Popovers, dropdown menus, select menus, color pickers. Content anchored to a trigger element that floats above everything else, including modals (a dropdown inside a modal must render above it). */ POPOVER: 50, /** Tooltips. The highest-priority normal UI element. Must render above popovers because a tooltip can appear on a popover trigger. */ TOOLTIP: 60, /** Reserved for developer tools, debug overlays, update animations. Nothing in normal UI should use this. */ MAX: 100 } as const; export type UILayerValue = (typeof UILayer)[keyof typeof UILayer]; ``` #### UILayer vs old names | Old name | New name | Rationale | | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ELEVATED | ELEVATED | Unchanged. Clear meaning: "above siblings." | | STICKY | **CONTROLS** | "Sticky" implies `position: sticky`, but these are resize rails and handles with `position: absolute`. "Controls" accurately describes interactive handles that must remain accessible. | --- ## CSS Integration ### Custom Properties Inject the UILayer values as CSS custom properties on `:root` so they are available in plain CSS, Tailwind arbitrary values, and inline styles. ```css /* src/renderer/src/css/layers.css */ @layer base { :root { --z-base: 0; --z-elevated: 10; --z-controls: 20; --z-scrim: 30; --z-modal: 40; --z-popover: 50; --z-tooltip: 60; --z-max: 100; } } ``` ### Tailwind Utilities Register semantic z-index utilities so developers use names instead of numbers. These use `@utility` (Tailwind CSS v4 syntax): ```css /* src/renderer/src/css/layers.css (continued) */ @utility z-base { z-index: var(--z-base); } @utility z-elevated { z-index: var(--z-elevated); } @utility z-controls { z-index: var(--z-controls); } @utility z-scrim { z-index: var(--z-scrim); } @utility z-modal { z-index: var(--z-modal); } @utility z-popover { z-index: var(--z-popover); } @utility z-tooltip { z-index: var(--z-tooltip); } @utility z-max { z-index: var(--z-max); } ``` Usage in components: ```tsx // Before // After ``` --- ## Migration Plan ### Phase 1: Create shared constants and CSS utilities 1. Create `src/shared/layers.ts` with both `ViewLayer` and `UILayer` exports. 2. Create `src/renderer/src/css/layers.css` with custom properties and Tailwind utilities. 3. Import `layers.css` in the renderer CSS entrypoint. ### Phase 2: Migrate ViewLayer (Electron-level) | Current | Constant | New | | ------- | --------------------------- | -------------------------- | | `0` | `GLANCE_BACK_ZINDEX` | `ViewLayer.TAB_BACK` (0) | | `2` | `TAB_ZINDEX` | `ViewLayer.TAB` (10) | | `3` | `GLANCE_FRONT_ZINDEX` | `ViewLayer.TAB_FRONT` (20) | | `3` | `DEFAULT_Z_INDEX` (portals) | `ViewLayer.OVERLAY` (30) | | `4` | (inline, popover.tsx) | `ViewLayer.POPOVER` (40) | | `999` | (inline, browser.ts) | `ViewLayer.OMNIBOX` (100) | Steps: 1. Delete `GLANCE_FRONT_ZINDEX`, `TAB_ZINDEX`, `GLANCE_BACK_ZINDEX` from `tab-layout.ts`. Import from `src/shared/layers.ts`. 2. Delete `DEFAULT_Z_INDEX` from `portal-component-windows.ts` and `portal.tsx`. Import `ViewLayer.OVERLAY`. 3. Replace the inline `4` in `popover.tsx` with `ViewLayer.POPOVER`. 4. Replace the inline `999` in `browser.ts` with `ViewLayer.OMNIBOX`. This resolves the z=3 collision: glance front tabs move to 20, portals move to 30. ### Phase 3: Migrate UILayer (CSS-level) Map every existing Tailwind z-index class to its semantic replacement: | Current | New | Components | | --------------------------- | -------------- | -------------------------------------------------------------------------------------------- | | `z-50` (overlays/backdrops) | `z-scrim` | `dialog.tsx` overlay, `alert-dialog.tsx` overlay, `sheet.tsx` overlay | | `z-50` (modal content) | `z-modal` | `dialog.tsx` content, `alert-dialog.tsx` content, `sheet.tsx` content, space/profile editors | | `z-50` (popover/dropdown) | `z-popover` | `popover.tsx`, `dropdown-menu.tsx`, `select.tsx` | | `z-50` (tooltip) | `z-tooltip` | `tooltip.tsx` | | `z-50` (hover detector) | `z-controls` | `hover-detector.tsx` (old UI — interactive edge strip) | | `z-30` | `z-popover` | PDF viewer menus (same semantic role as popovers) | | `z-20` | `z-controls` | Sidebar resize rails (`sidebar.tsx`, `resizable-sidebar.tsx`), onboarding content | | `z-10` | `z-elevated` | Floating sidebar, loading indicator, sidebar containers, drag indicators, search suggestions | | `z-[5]` | Keep or remove | Onboarding background effects (purely decorative, below ELEVATED) | | `-z-10` | Keep as-is | Decorative backgrounds (purely cosmetic, negative z-index is fine) | | `z-index: 999` (CSS) | `z-max` | Update animation overlay | | `zIndex: 10` (inline JS) | `z-elevated` | Framer Motion `whileDrag` style (space list reordering) | Steps: 1. Update shadcn/ui primitives (`dialog.tsx`, `sheet.tsx`, `alert-dialog.tsx`, `popover.tsx`, `dropdown-menu.tsx`, `select.tsx`, `tooltip.tsx`) to use the new utilities. 2. Update browser-ui components (`browser-sidebar`, `main.tsx`, `sidebar.tsx`, `resizable-sidebar.tsx`). 3. Update remaining application components file-by-file. Old Tailwind classes continue to work during transition; this can be done incrementally. ### Phase 4: Enforce via CI Add a lint check that flags prohibited patterns (see Enforcement section below). This is a **required** step, not optional — without it the system will drift back to magic numbers within weeks. --- ## ViewManager Improvements The current `ViewManager` re-adds every child view on every z-index change (O(n) Electron IPC calls per update). This is acceptable at current scale (~5-10 views) but can be tightened. ### Dirty-flag reordering Only call `reorderViews()` when the sorted order actually changes, not on every `addOrUpdateView` call. ```ts addOrUpdateView(view: WebContentsView, zIndex: number): void { const current = this.views.get(view); if (current === zIndex) return; // already correct this.views.set(view, zIndex); // Only reorder if the insertion changed the sorted sequence if (this.orderChanged(view, current, zIndex)) { this.reorderViews(); } } ``` ### Batch updates When multiple views change z-index simultaneously (e.g. switching tab groups or entering glance mode), defer reordering until all updates are applied. ```ts batchUpdate(fn: () => void): void { this._batching = true; fn(); this._batching = false; this.reorderViews(); } ``` **Flush timing contract:** `batchUpdate` is **synchronous**. The callback `fn` runs synchronously, all `addOrUpdateView` calls within it skip reordering (because `_batching` is true), and `reorderViews()` is called exactly once after `fn` returns. There is no deferred/async flush, no microtask, no `requestAnimationFrame`. The view order is fully consistent before `batchUpdate` returns. This means the view ordering is **deterministically updated in a single pass** before `batchUpdate` returns. Because the Electron main process is single-threaded, no rendering frame is composited between the individual `addOrUpdateView` calls and the final `reorderViews()`. In practice, users will not see intermediate stacking states. ```ts // Usage: viewManager.batchUpdate(() => { viewManager.addOrUpdateView(frontTab, ViewLayer.TAB_FRONT); viewManager.addOrUpdateView(backTab, ViewLayer.TAB_BACK); }); // View order is fully consistent here. ``` --- ## Portal-Specific Rules Portal component windows are WebContentsViews created via `window.open()` from the browser chrome renderer. Each portal is a separate renderer process with its own isolated UILayer stacking context. ### Rules 1. **Default layer:** Portals are created at `ViewLayer.OVERLAY` (30). This places them above all tab variants but below popovers and the omnibox. 2. **Popovers from portals:** When a portal needs to show a popover (e.g. a context menu triggered from the floating sidebar), it creates a second portal at `ViewLayer.POPOVER` (40). This ensures the popover renders above its parent portal. 3. **Internal CSS layering:** Each portal uses UILayer independently. A `z-modal` inside a portal is z-index 40 within that portal's renderer and has no interaction with `z-modal` in the main chrome or any other portal. 4. **Portals never exceed OMNIBOX.** No portal may use a ViewLayer value > = `ViewLayer.OMNIBOX`. The omnibox is always the topmost view. 5. **Bounds and visibility:** Portal positioning is controlled via IPC (`setComponentWindowBounds`, `setComponentWindowVisible`). The renderer measures a sizer element's bounding rect and sends it to the main process. This is orthogonal to layering — bounds control where a portal appears, ViewLayer controls what it stacks above/below. ### Omnibox Rules The omnibox is a WebContentsView at `ViewLayer.OMNIBOX` (100) — the highest layer in the system. It has its own renderer with its own isolated UILayer stacking context. The following rules apply: 1. **Self-contained renderer.** All omnibox UI — suggestions list, inline autocomplete, tooltips, keyboard hints — must render within the omnibox's own renderer using UILayer. The omnibox must never spawn portal component windows or create additional WebContentsViews. 2. **Nothing above OMNIBOX.** No ViewLayer value may equal or exceed `ViewLayer.OMNIBOX` (100). This is an absolute ceiling. If code attempts to add a view at >= 100 (other than the omnibox itself), it is a bug. 3. **No escape hatches.** The omnibox cannot use IPC or any other mechanism to re-layer itself or other views to circumvent the ViewLayer ordering. If the omnibox needs to show complex UI (e.g. a settings panel), it should delegate to the main chrome renderer and hide itself, not try to render above itself. --- ## Enforcement Enforcement is required to prevent drift. The system is only as good as its consistency. ### CI Lint Rule Add a grep-based check to CI that fails the build if prohibited patterns are found. This serves as the first line of defense. An ESLint rule for JS/TS `zIndex` style props is recommended as a future upgrade for better accuracy. **Prohibited in `.tsx`, `.ts`, `.css` files:** | Pattern | Why | Allowed exceptions | | ---------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------- | | `z-\d+` (Tailwind, e.g. `z-10`, `z-50`) | Use semantic names (`z-elevated`, `z-modal`, etc.) | `-z-10` or `-z-\d+` for decorative backgrounds | | `z-\[.+\]` (Tailwind arbitrary, e.g. `z-[42]`) | Add a named layer if existing ones don't fit | None | | `z-index:\s*\d+` (raw CSS) | Use `var(--z-*)` custom properties | None | | `zIndex:\s*\d+` (inline JS style) | Import from `UILayer` or use Tailwind | `framer-motion` animations where dynamic values are required — use `UILayer.*` constant | **Implementation:** ```bash #!/bin/bash # scripts/lint-z-index.sh ERRORS=0 # Paths to exclude from checks EXCLUDE="layers.css\|layers.ts\|node_modules\|\.test\.\|\.spec\.\|__tests__\|\.md$" # Check for raw Tailwind z-index classes (but not our semantic ones or negative values) MATCHES=$(grep -rn --include='*.tsx' --include='*.ts' --include='*.css' \ -E '\bz-[0-9]+\b' src/ \ | grep -v -E '\b-z-[0-9]+\b' \ | grep -v "$EXCLUDE") if [ -n "$MATCHES" ]; then echo "ERROR: Raw Tailwind z-index classes found. Use z-elevated, z-modal, etc." echo "$MATCHES" ERRORS=1 fi # Check for arbitrary z-index values MATCHES=$(grep -rn --include='*.tsx' --include='*.ts' --include='*.css' \ -E 'z-\[.+\]' src/ \ | grep -v "$EXCLUDE") if [ -n "$MATCHES" ]; then echo "ERROR: Arbitrary z-index values found. Add a named layer to layers.ts." echo "$MATCHES" ERRORS=1 fi # Check for raw CSS z-index declarations MATCHES=$(grep -rn --include='*.css' \ -E 'z-index:\s*[0-9]+' src/ \ | grep -v "$EXCLUDE") if [ -n "$MATCHES" ]; then echo "ERROR: Raw CSS z-index found. Use var(--z-*) custom properties." echo "$MATCHES" ERRORS=1 fi # Check for inline JS zIndex with numeric literals MATCHES=$(grep -rn --include='*.tsx' --include='*.ts' \ -E 'zIndex:\s*[0-9]+' src/ \ | grep -v "$EXCLUDE") if [ -n "$MATCHES" ]; then echo "ERROR: Inline numeric zIndex found. Use UILayer.* constants." echo "$MATCHES" ERRORS=1 fi exit $ERRORS ``` **Recommended future upgrade:** Add an ESLint rule (or Stylelint rule) that catches `zIndex` in style objects and JSX style props at the AST level. This eliminates false negatives from template literals, computed values, and variable indirection that grep cannot catch. The grep script remains useful as a fast, zero-dependency baseline. ### Code Review Checklist When reviewing PRs that touch z-index or layering: - [ ] Does it use `ViewLayer.*` or `z-*` semantic utilities? - [ ] If a new layer was added, is it documented in `layers.ts` with JSDoc? - [ ] If a portal is involved, does it respect the OMNIBOX ceiling? - [ ] Does the change maintain all ViewLayer invariants? --- ## Guidelines for Future Development ### When to use ViewLayer vs UILayer - **ViewLayer** is for content that renders in its own `WebContentsView` (tabs, portals, omnibox). You control stacking by passing a `ViewLayer` value to `viewManager.addOrUpdateView()`. - **UILayer** is for elements within a single React renderer. You control stacking with the `z-*` Tailwind utilities (`z-elevated`, `z-modal`, etc.). - **Never mix the two.** A CSS `z-index: 9999` inside the base renderer will never make an element appear above a `WebContentsView` child. Use a portal component window if you need to overlay tab content. ### Adding a new ViewLayer 1. Identify which band the layer belongs to (Tabs 0–29, Chrome 30–59, System 60–100). 2. Add the constant to `ViewLayer` in `src/shared/layers.ts` with a JSDoc comment explaining its purpose. 3. Verify the invariant ordering still holds. ### Adding a new UILayer 1. Add the constant to `UILayer` in `src/shared/layers.ts`. 2. Add the CSS custom property to `src/renderer/src/css/layers.css`. 3. Add the `@utility` directive in the same file. 4. Document the layer's purpose in the JSDoc comment. ### Prohibited patterns - **No raw z-index numbers.** Always use `ViewLayer.*` or `z-*` utilities. The only exception is negative z-index for purely decorative backgrounds. - **No arbitrary Tailwind z-index** (`z-[42]`). If the existing layers do not fit, add a new named layer. - **No sentinel values** (`z-index: 999`). The layer scale is compact (0–100) and intentional. --- ## File Structure ``` src/shared/layers.ts # ViewLayer + UILayer constants (single source of truth) src/renderer/src/css/layers.css # CSS custom properties + Tailwind @utility definitions scripts/lint-z-index.sh # CI enforcement script ``` Everything else imports from these files. No other file should define a z-index constant or use a raw z-index number.