Peek Architecture #
A guide for contributors landing on the repo cold. Covers the tile model, the three state machines that govern runtime behavior, and how Peek's tile concept relates to the webtil.es / DASL Tiles specification.
This document is a map. For the deep design behind any individual piece, follow the per-topic docs linked inline.
The tile model #
A tile is a feature. Full stop. A tile is an HTML file that runs in a renderer process. It can register commands, subscribe to events, hold state, show a UI, or stay completely hidden. Some tiles never show anything; some show themselves when a command fires; some do both.
There is no "tile type" and no "tile role" in the conceptual model. Every
feature is a tile. If functionality lives outside the tile model it is core
(apps/desktop/renderer/), not a feature. See docs/tiles-single-file.md
for the rationale and current manifest shape.
One renderer mechanic, one preload, one IPC surface #
Every renderer in Peek — feature tiles in apps/desktop/features/<id>/, the bundled core
renderers in apps/desktop/renderer/ (the bgWindow orchestrator, the cmd panel, the HUD, the
page canvas), and the test fixture — runs through the same machinery:
- A single HTML entry point
- Loaded into a per-window Electron
BrowserWindowwith theapps/desktop/main/tile-preload.ctspreload script (CommonJS — Electron's sandbox preload rejects ESM) - Talks to the main process exclusively over the strict
tile:*IPC surface - Holds a per-window capability token that the main process mints at load time and revokes at unload
The legacy era — preload.js, an extensionHostWindow that loaded multiple
features as iframes, and a parallel set of legacy IPC channels — was removed
in April 2026 (docs/historical/v1-removal-plan.md, Status:
DONE). The tile path is the only path.
Manifest schema (v3) #
A tile is described by a manifest.json at manifestVersion: 3. The full
TypeScript schema lives in
apps/desktop/main/tile-manifest.ts;
the abbreviated shape is:
{
"manifestVersion": 3,
"id": "my-tile",
"name": "My Tile",
"tiles": [
{
"id": "home",
"url": "home.html",
"width": 700,
"height": 500,
"resident": true
}
],
"capabilities": {
"pubsub": { "scopes": ["global"], "topics": ["my-tile:*"] },
"datastore": { "tables": ["items"] },
"commands": true
}
}
Two axes describe a tile's runtime behavior, and only one of them lives in the manifest:
| Axis | Values | Decided in |
|---|---|---|
| Residency | resident: true (loaded at startup) vs lazy (loaded on demand) |
manifest |
| Visibility | shown vs hidden | runtime — api.window.showSelf() / hideSelf() |
The legacy type: "background" | "window" | "panel" and the nested
windowHints object were both removed in manifestVersion: 3 and the
validator now rejects them outright. Window geometry fields (width,
height, transparent, alwaysOnTop, frame, etc.) are flat on TileEntry.
Capabilities #
Each tile declares the capabilities it needs in its manifest. At load time
the main process resolves the request into a CapabilityGrant (full schema in
tile-manifest.ts::TileCapabilities):
| Capability | Purpose | Sub-shape |
|---|---|---|
pubsub |
publish/subscribe to topics | scopes (self/global/system) + optional topic allowlist |
datastore |
SQLite access | per-table allowlist |
network |
cross-origin fetch | per-domain allowlist |
window |
open/close/manage other windows | create/query/manage + URL-prefix allowlist |
filesystem |
read/write paths (privileged — built-ins only) | per-path glob allowlists |
shortcuts |
global/local hotkeys | optional per-key allowlist |
commands |
register commands in the cmd panel | boolean |
theme / sync / settings / context / dialogs / oauth / features / session / izui |
scoped per-API access | mostly boolean or small allowlist objects |
sync and filesystem are restricted to built-in tiles. Most capabilities
accept either true (broad grant) or an object form (allowlisted grant).
Bundled core renderers (cmd, hud, page, the bgWindow orchestrator, the test
fixture) carry a trustedBuiltin: true grant from
createTrustedBuiltinGrant(). Every capability check at runtime returns "ok"
without consulting the grant when this flag is set — the bypass is explicit
at every site (grep for trustedBuiltin to audit). Core renderers use the
same tile mechanics as feature tiles; they just skip enforcement because they
ship as part of the trusted codebase.
Lazy feature activation #
Built-in feature tiles (mcp-server, windows, websearch, peeks, slides) are lazy — they spawn on demand, not at boot. Production fixed boot dropped from 6 to 2 windows.
Making a tile lazy #
Remove resident: true from the manifest TileEntry. apps/desktop/main/tile-compat.ts
routes any tile with !resident && (hasCommands || hasLazyEvents) through
registerLazyTile() instead of the eager launch path. A tile with no
commands but event-driven behavior needs lazyEvents: [...] on its entry
(otherwise the lazy registrar has nothing to intercept and the tile never
wakes).
Wake triggers #
apps/desktop/main/tile-lazy.ts installs one pre-publish hook via
installLoadOnDispatchHook. The hook's predicate is a single Set lookup
plus a startsWith check — it runs on every publish with O(1) cost. It
intercepts two kinds of publish:
- Command dispatch —
cmd:execute:<name>for any command declared in the tile's manifestcommands[]. CLI verb topics (cli:*) are pubsub topics, so they also wake a tile that lists them inlazyEvents. - Declared lazy events — any topic listed in
TileEntry.lazyEvents[].
When the hook fires it calls ensureTileLoaded(tileId) →
waitForTileReady(tileId, entryId), then returns 'continue' so the
deferred publish reaches the now-live handler.
Declarative commands (action.type === 'window' and action.type === 'publish') are short-circuited: the hook handles them directly and returns
'skip' — no tile load required and no dangling publish.
Timeout UX: if a tile fails to reach READY within 10 s, the hook publishes
notification:show with an error message and resolves the caller's result
topic with an { error } shape so the cmd panel returns to IDLE.
Global hotkeys without a renderer #
A lazy tile's renderer isn't running at boot, so it cannot register its own
OS global shortcuts. apps/desktop/main/feature-shortcut-registrar.ts
registerWakeShortcuts(tileId, manifest) fills the gap: it registers OS
accelerators in the main process at tile-registration time, with no renderer
present. Each accelerator's callback publishes the wake topic, which flows
through the load-on-dispatch hook to boot the tile.
Manifest TileShortcut supports two forms:
| Form | Manifest fields | Keypress publishes |
|---|---|---|
| Command form | { keys, command } |
cmd:execute:<command> |
| Topic form | { keys, wakeTopic, wakeArgs? } |
wakeTopic with { args: wakeArgs } |
Accelerators are tracked under peek://<tileId>/lazy-shortcut so teardown
is a single unregisterShortcutsForAddress call.
Wake-shortcut registry (user-configured hotkeys) #
Users can override manifest defaults. The persisted state lives in two datastore tables:
lazy_wake_shortcuts— rows keyed byownerTileId, each holding(accelerator, wakeTopic, wakeArgs).lazy_wake_declared— a marker row written the first time a tile callsapi.shortcuts.declareWakeShortcuts(list)(IPC:tile:shortcuts:declare-wake, requiresshortcutscapability). The marker distinguishes "declared empty — register nothing" from "never declared — use manifest defaults".
Precedence at boot (inside registerWakeShortcuts):
- If
hasDeclaredWakeShortcuts(tileId)→ register exactly the persistedlazy_wake_shortcutsrows (may be empty → register nothing). - Otherwise → register the manifest's static
shortcuts[]entries (fresh-profile defaults).
The renderer computes accelerators from its own settings UI and calls
declareWakeShortcuts once loaded; main re-registers at next boot.
Core page events #
apps/desktop/main/ipc.ts (wireHybridContentEvents) emits semantic events
from the page host's load handler that lazy features wake on, keeping feature
logic out of core:
page:search-engine-discovered— published when a page advertises an OpenSearch descriptor. The websearch tile declareslazyEvents: ["page:search-engine-discovered"]and boots hidden on first match, running its fetch/parse/record logic there. Core detects the descriptor (a lightweight DOM check it already does forpage:loaded); the feature logic stays in the websearch renderer.
Additional semantic page events follow this same pattern as features are added.
CRITICAL init-ordering rule for lazy tile authors #
Register all wake handlers synchronously in
init()BEFORE the firstawait. The preload firestile:lifecycle:readyon asetTimeout(0)macrotask before asyncinit()awaits resolve. Any handler registered after anawaitmay miss the first deferred publish — the exact message that woke the tile.
The recommended pattern:
async function init() {
await api.initialize(); // synchronous token exchange
// Register SYNCHRONOUSLY — before any await below
api.pubsub.subscribe('my:lazy-event', handler);
api.commands.register('my-command', handler);
// Handler bodies gate on initReady so they read loaded state
await loadState();
resolveInitReady();
}
let resolveInitReady;
const initReady = new Promise(r => { resolveInitReady = r; });
async function handler(msg) {
await initReady; // wait for state before acting
// ... use loaded state safely
}
See apps/desktop/features/websearch/home.js for the reference implementation.
Constraints upheld #
- Features stay modular — not folded into core. Each feature tile owns its logic; core owns only generic infra (the hook predicate, the OS-accelerator registrar, the opaque wake-shortcut registry).
- Features stay frontend — no feature logic lives in main. Only feature-agnostic infrastructure (OS shortcut registration, the persisted registry, the dispatch hook) lives in main.
The IPC chokepoint #
Every tile:* IPC frame goes through a single main-process pipeline,
apps/desktop/main/tile-ipc-gate.ts.
Handlers cannot be registered any other way. The pipeline runs in fixed
order; failing any step emits a gate:rejected telemetry event and drops
the frame:
- Channel allowlisted? (implicit via
registerTileIpc()registration) - Sender frame matches token owner? (closes the "forged token via XSS" hole)
- Payload schema valid? (hand-rolled shape checks — Zod/Ajv is too slow for the per-frame budget)
- Token valid + required capability granted?
- Tile in a state that can dispatch? (cross-check against the lifecycle FSM)
- Sender role allowlisted for this channel?
In dev mode, two bypass detectors run alongside: installDirectSendGuard()
patches WebContents.prototype.send to reject any direct pubsub:* push
that bypasses the broadcaster, and installOffPathWindowGuard() patches
BrowserWindow.prototype.loadURL to assert any peek://{tile}/ URL has a
matching registered → loading lifecycle transition. Anything that tries
to back-door a tile renders a loud assertion failure rather than a silent bug.
The preload surface #
tile-preload.cts exposes
window.app to the renderer. This is the entire API a tile sees. The shape
is tracked in docs/tile-api.md; see
docs/historical/tile-preload-trimming-plan.md for
the in-flight cleanup of the remaining v1-compat shims.
The preload must stay a .cts (CommonJS TypeScript) file. The backend
tsconfig.json uses module: NodeNext, which emits ESM for .ts files,
which Electron's sandbox preload silently rejects (no API surface, no error
thrown — just an empty window.app).
The bgWindow is core, not legacy #
peek://app/background.html (loaded as bgWindow via the same
tile-preload.cts) is the core orchestrator of Peek. It hosts
apps/desktop/renderer/index.js, which initializes the cmd registry, the page canvas
controller and the HUD via initCmd() / initPage() (all consolidated into
the core renderer in April 2026). bgWindow is not a tile,
not a feature, not v1 anything. Every pubsub publish in the system must
reach it.
State machines #
Peek runs three explicit state machines. Each replaces a class of ad-hoc
timing code (sleeps, polling loops, visibilitychange listeners, retry
hacks) with a deterministic transition table. Each has its own design doc;
the summaries below describe what each FSM owns and what bug class it makes
impossible.
1. Tile lifecycle FSM #
Owns: when a tile is alive.
Lives in: apps/desktop/main/tile-lifecycle.ts +
apps/desktop/main/tile-fsm.ts (pure transition table).
Doc: docs/tile-lifecycle-fsm.md
States: unregistered → registered → loading → ready → visible plus
unloading and crashed. The tile manifest cache is populated on
unregistered → registered and static declarations (commands, shortcuts,
nouns, lazyEvents) become visible to the cmd panel without the tile
running. The renderer is created on registered → loading, the capability
token is minted on loading → ready, and revoked on → unloading /
→ crashed. There is one code path for window creation regardless of
trigger (eager-at-boot, lazy-on-dispatch, or relaunch-after-crash) — timing
controls when, never how.
This FSM replaces a year's worth of "tile loaded but its commands haven't appeared yet" / "spinner hangs forever because the dispatch target was never live" / "ESM-side and renderer-side disagree about whether the tile exists" bug clusters.
2. PubSub state machine #
Owns: what messages can flow between tiles in each state.
Lives in: apps/desktop/main/pubsub.ts +
the tile-ipc-gate.ts validation pipeline above.
Doc: docs/pubsub-state-machine.md
The token is the capability handle: if validateToken(t) returns a grant,
the tile is alive and the publish/subscribe is allowed against the granted
scopes. The pubsub path does not read lifecycle state — token validity
and lifecycle state are kept in sync by tile-lifecycle.ts as the sole
owner of both, removing a hot-path cross-module dependency and making the
race question ("was the publish checked before or after the state change?")
unaskable.
A single pubsubBroadcaster in main fans every publish out to all tile
windows (including bgWindow) and webview guests. Subscribe-before-publish is
guaranteed by a startup latch: no non-bgWindow tile can transition
registered → loading until bgWindow has signaled lifecycle ready. The
cmd:request-registers replay machinery that previously papered over this
race was deleted once the latch landed.
This FSM rolled out in 8 phases in April 2026 (see
project_pubsub_fsm_conversion_done.md — internal
memory). It made "a publish was dropped somewhere in the fabric" impossible
as a bug class: if a message doesn't flow, the validation pipeline says
exactly which step rejected it.
3. Cmd panel state machine #
Owns: the interactive command-bar UI states.
Lives in: apps/desktop/renderer/cmd/state-machine.js (pure
module — no DOM, no IPC).
Doc: docs/cmd-state-machine.md
Ten states: IDLE, TYPING, RESULTS_OPEN, PARAM_MODE, EXECUTING,
OUTPUT_SELECTION, CHAIN_MODE, CHAIN_POPUP, ERROR, CLOSING. Actions
run before currentState is updated. The pure module is unit-tested
exhaustively; the DOM adapter in apps/desktop/renderer/cmd/panel.js is the only thing that
talks to electron / DOM.
This FSM replaces the previous "every panel.js bug fix added another boolean flag" model. Each of the previous regression cycles (param-mode escape, Tab-fills-vs-Enter-executes, output selection cancel) becomes a single transition entry plus a test.
Window placement #
Owns: where every window lands when it's opened, reused, or when display topology changes. Core window-manager functionality, platform-agnostic by design (Electron today, Tauri to follow against the same contract).
Spec: docs/window-placement.md is canonical. Read that first.
Lives in: apps/desktop/main/window-placement.ts —
a pure module with no screen.* or BrowserWindow.* calls inside; the
caller passes display state in.
Historical: docs/historical/window-placement-refactor.md is
the previous sprint's plan; superseded by the spec above (model has changed:
manual and cursor-display-fallback are gone, proportional replaces them).
The four modes — centered, edge, proportional, parent-centered —
capture intent at window-open time. Bounds are always a derived projection
of intent against live display state, never stored as authoritative truth.
Every consumer (open, reuse, drag, display-change, restore) calls the same
pure project(). Display add/remove events project synchronously (no
debounce) so user gestures cannot race a deferred setBounds. Cursor
position is never consulted — display selection is by current display +
primary fallback, with primary read fresh from the OS per-call. Synthetic
display layouts in unit tests catch display-switch regressions as single
failing assertions, no real multi-monitor hardware required.
Peek tiles vs webtil.es / DASL Tiles #
The DASL Tiles specification (the spec referenced from webtil.es) and Peek's tiles share the same core word and a similar instinct — composable web docs/apps that can be embedded in arbitrary contexts and protected from data exfiltration — but they sit at very different points in the design space. The DASL spec is a portable distribution format: how a tile is identified, signed, declared, and loaded into any host. Peek's tile is an in-process runtime contract: how a feature lives inside the Peek desktop app.
The tables below compare them axis by axis. The spec is dated 2026-02-11 and explicitly defers several features (capabilities, IPC, composition) to future versions, so a number of cells are honestly "not specified" rather than "different from Peek."
Lifecycle model #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Defined states | None — spec mentions tiles are "loaded into contexts" but defines no state machine | 7 explicit states (unregistered, registered, loading, ready, visible, unloading, crashed) |
| Transitions | None | Explicit table in tile-fsm.ts; enforced by tile-lifecycle.ts |
| Resident vs lazy | Not addressed | First-class — resident: true in manifest |
| Crash recovery | Not addressed | crashed state + auto-relaunch policy |
Peek goes considerably further here because it runs tiles as long-lived in-process renderers, not as one-shot embeds.
Capability / permission system #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Per-tile capability declarations | "tiles can be granted additional contextual access" — deferred to future spec version | Required capabilities object on every manifest |
| Network access | All-or-nothing: tiles are "completely cut off from network access beyond loading their own pre-declared dependencies" via CSP default-src 'self' blob: data: |
Per-domain allowlist via network: { domains: [...] } |
| Storage access | Not specified | Per-table allowlist via datastore: { tables: [...] } |
| Privilege tiers | Not specified | builtin vs external; some caps (filesystem, sync) are builtin-only; trustedBuiltin flag for core renderers |
| User approval | Not specified | Privileged caps (currently filesystem) flagged PRIVILEGED_CAPABILITIES for explicit user approval |
Peek is more conservative on what's declared (manifest-driven allowlists) and on what's enforced (gate pipeline runs every IPC frame). DASL is more conservative on what's possible at all (network is locked off entirely at the platform level).
IPC / message-passing surface #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Inter-tile messaging | "A future version… will add support for chat channels… and an intent-based way for tiles to call one another." Not in current spec. | Full pubsub: api.publish(topic, data) / api.subscribe(topic, cb) |
| Host ↔ tile messaging | Not specified | tile:* strict IPC, gated through 6-step pipeline in tile-ipc-gate.ts |
| Sender authentication | N/A | Per-window capability token; sender-frame cross-check at every IPC handler |
| Topics namespace | N/A | Topic allowlist supported in pubsub.topics capability |
This is the largest gap. Peek's tiles are built around in-process messaging; DASL Tiles, today, are essentially message-less embeds.
Persistence / storage #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Storage model | Not specified | Shared SQLite datastore (per-tile table allowlist), per-tile feature_settings namespace, optional cross-tile read via settings.readForeign |
| Cross-tile data | Not specified | Cross-tile reads explicit (readForeign: ['ext-id']); cross-tile writes not exposed |
| Sync | Not specified | Built-in sync capability (builtin-only), shared schema across desktop/server/mobile |
Manifest schema #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Format | MASL (DASL flavor); CID-addressable; AT Protocol PDS-hosted | Plain JSON in manifest.json; loaded from local disk (built-in or installed) |
| Required fields | name, resources (with / entry) |
manifestVersion: 3, id, name, tiles[], capabilities |
| Versioning | prev: cid-link for previous version; version: 1 (CAR compat only) |
manifestVersion: 3 (schema version); version (semantic version of the tile) |
| Identity | CID of the tile content | String id unique within the install |
| Display metadata | description, icons, screenshots, categories, background_color, theme_color, short_name |
description, name, shortname (no built-in icon/screenshot fields yet) |
| Sizing | sizing: { width, height } (advisory) |
Per-tile-entry width/height/minWidth/minHeight/alwaysOnTop/transparent/etc. (concrete window geometry) |
| Multiple entry points | Not supported (a tile is one resource bundle) | tiles: TileEntry[] array, up to 10 per manifest |
Origin / sandbox #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Origin model | Unique random origin per tile (may become CID-derived) | Custom peek://{tileId}/{entry} scheme |
| Sandbox flags | allow-scripts allow-forms allow-modals allow-popups allow-same-origin allow-downloads |
Electron sandbox: true + per-tile preload + contextIsolation: true |
| CSP | default-src 'self' blob: data: enforced by spec |
Not enforced by the runtime today; tiles run inside the peek:// scheme with main-process gatekeeping instead |
| COOP/COEP | same-origin / same-origin |
Not applicable — single-process electron host |
| Referrer | no-referrer |
Not applicable to in-process tiles |
DASL's sandbox model is browser-native and applies to any host. Peek's sandbox is Electron-native and assumes a single trusted host (the Peek main process) gating everything.
Composition #
| DASL Tiles | Peek tiles | |
|---|---|---|
| Tile-calls-tile | "Future version… intent-based way for tiles to call one another" | Pubsub topics + the cmd palette as a discoverable intent surface |
| Embedding tiles | The spec is fundamentally about "assembled from multiple sources to carry out complex user interface tasks" — this is the model | Not the model — tiles are top-level windows, not embeds. Composition is event-based, not visual |
Terminology Peek could adopt #
The DASL spec uses "tile execution context" for the environment that loads a tile, and "embedding context" for the source that embeds it. Peek's docs occasionally drift between "tile", "feature", "extension", and "window"; aligning on "tile" everywhere (which the v1-removal pass largely accomplished) and adopting "tile execution context" as the term for the per-window renderer + preload + grant triple would tighten the vocabulary.
Where Peek goes further than the spec #
- Lifecycle as an explicit FSM with crash recovery and a manifest cache for static declarations.
- Per-frame capability gating on every IPC channel, not a coarse load-time grant.
- Inter-tile messaging as a first-class system, with subscribe- before-publish guarantees.
- Multiple entry points per manifest (
tiles: TileEntry[]) supporting resident orchestrators alongside lazy UI windows. - Per-tile capability tokens with sender-frame cross-check, defending against forged tokens via XSS.
Where the spec goes further than Peek #
- Content-addressed identity (CID) rather than string IDs that can collide across installs.
- Browser-native sandbox (sandbox flags, CSP, COOP/COEP) that works in any host, not just Peek.
- Distribution as a first-class concern — tiles are AT Protocol records, signed, fetchable from any PDS.
- Display metadata (icons, screenshots, theme colors) standardized in the manifest.
The two models are largely complementary: Peek's runtime contract could be layered on top of DASL's distribution format, and DASL's distribution format could be the install path for Peek tiles. Today the only meeting point is the AT Protocol — Peek does install feature tiles from PDS records, but the format is Peek's, not DASL's.
Implementation conventions #
A grab-bag of conventions that aren't structural but recur often enough to bite. The state machines and lifecycle docs cover the spine; this section covers the corners.
bgWindow is the orchestrator #
The background window (peek://app/background.html) is THE core
orchestrator. The cmd, hud, and page surfaces are consolidated into it.
Every published event must reach it; if you find a producer publishing
to a renderer that bgWindow doesn't observe, the routing is wrong.
Declarative publish-type commands #
Commands of type: 'publish' must publish a :result event back so the
cmd panel proxy resolves. Otherwise the panel hangs for 30 s before
timing out.
HUD #
The HUD is one pinned window, not a set of widget tiles. The hud command in
apps/desktop/renderer/index.js opens peek://app/hud/hud.html under the
window key hud-stats and pins it globally;
apps/desktop/renderer/hud/hud.js polls api.app.metrics() and shows memory,
process, window and pubsub counts.
The earlier per-widget HUD at peek://ext/hud/widgets/* is gone.
apps/desktop/main/migrate-hud.ts rewrites saved sessions that still name
those URLs.
Cmd panel state machine #
apps/desktop/renderer/cmd/state-machine.js is a pure module with no DOM/IPC deps. Ten
states: IDLE, TYPING, RESULTS_OPEN, PARAM_MODE, EXECUTING,
OUTPUT_SELECTION, CHAIN_MODE, CHAIN_POPUP, ERROR, CLOSING. Actions run
BEFORE currentState is updated. See
docs/cmd-state-machine.md for the full spec
and test coverage notes.
peek-card component #
- Empty slot sections are hidden via
slotchange+ theslot-emptyclass. CSS:emptydoes not work on slot elements. - Card action buttons must always be visible. Never hover-reveal them.
Window open IPC #
- External URLs need centered coordinates computed BEFORE canvas bounds
adjustment (otherwise undefined
x/yproduceNaN). handleExternalUrlis exposed on the__peek_testglobal for Playwright.window._cmdStatein the cmd panel exists for deterministic test waits.- Main-process
setTimeoutbodies that reference a window orwebContentsmust guardisDestroyed()— closing inside the deferred callback throws unhandled. workspaceKey'd tiles collapse opens — Playwright cannot get a fresh window when the key matches an existing one. Static manifest assertions are the fallback.
Page widget panels #
Page info (top-left) and entities (right-side) live in
apps/desktop/renderer/page/overlay.js. The entities panel subscribes to entities:extracted
pubsub and falls back to DOM extraction after 2 s.
Page-host overlays (load error, etc.) belong inside .center-column
with position: absolute; inset: 0, NOT on <body> with
position: fixed — fixed-positioned overlays stretch with show()'s
window expansion.
Groups UI #
promoteTagToGroup() must only pass valid DB columns to setRow().
Runtime-only props like addressCount cause silent SQL failures.
Canvas page rewriting #
Web URLs are rewritten on open: https://example.com →
peek://app/page/index.html?url=.... Group mode context is set on new
windows BEFORE loadURL; openWindow falls back to
lastFocusedVisibleWindowId when no opener is provided.
CSS gotcha: -webkit-mask-image on a webview clips box-shadow —
use border + outline instead.
Webview / visible content area #
The visible content area must NEVER resize except via direct user interaction. Window + chrome may grow (e.g. for navbar reveal), but the visible content stays put.
Electron ESC handling #
Electron's before-input-event intercepts ESC at the main-process level
and calls e.preventDefault(). DOM keydown listeners for ESC never
fire in the renderer — textarea / input keydown listeners are dead code
for ESC.
ESC is delivered via escape-pressed IPC → preload _escapeCallback →
the tile's onEscape handler. To handle ESC in custom UI elements,
check document.activeElement in the onEscape handler, blur/close as
needed, return { handled: true }.
escUnhandledPolicy() is the second-phase handler; it opens the
Windows switcher at the workspace root.
Editor pane architecture #
Four panes: outline, editor (center), preview, notes/annotations.
Collapse icons MUST set font-family: system-ui on toggle elements;
ServerMono lacks the Unicode glyphs.
PubSub event publishing #
- Direct
tagItem()calls do NOT publish events — UIs won't update reactively. UsetagItemAndPublish()inipc.tsfor any tagging that should trigger a UI refresh. - Same pattern applies to any data mutation that skips the IPC handler: it must manually publish.
- Groups UI listens for:
tag:item-added,tag:item-removed,tag:created,item:created,item:deleted,sync:pull-completed. - The Tags page widget (
apps/desktop/renderer/page/overlay.js) also subscribes totag:item-added/tag:item-removedand resolvescurrentItemIdon the first tag event when null.
Noun system #
apps/desktop/renderer/cmd/noun-registry.js + apps/desktop/renderer/cmd/nouns.js. Register a
noun via registerNoun({ name, singular, query, create, browse, ... })
and the system auto-generates: bare command, list {name},
new {singular}, open {singular}. Used by groups, tags (with
skipBare), and lex (singular lex, NSID-filtered query). Prefer
nouns over registering many individual commands — avoids command
registry pollution.
Sync architecture #
- Device IDs are raw UUIDs (no
desktop-/mobile-/extension-prefixes). _syncmetadata lives inside the itemmetadataJSON:{ _sync: { createdBy, createdAt, modifiedBy, modifiedAt } }.syncSourcewas removed from desktop Electron, server, andpackages/sync/sync.js; it still exists in iOSlib.rsand Taurisync.rspending refactor.- Push filter:
syncedAt === 0ORupdatedAt > syncedAt. - E2E sync tests use
E2E_TEST=trueto bypass auth.
"Extension" is a smell when used for tiles #
Real Chrome MV3 extensions live in apps/desktop/chrome-extensions/ and keep the
"extension" name. Anywhere else, "extension" used as a synonym for
tile / feature is a leftover from v1 and should be renamed.
See also #
- docs/tiles-single-file.md — the "single-file tile" model and the manifest target shape
- docs/tile-api.md — the
window.appsurface - docs/historical/tile-preload-trimming-plan.md — in-flight cleanup of v1-compat shims in the preload
- docs/tile-lifecycle-fsm.md — lifecycle FSM design
- docs/pubsub-state-machine.md — pubsub FSM design
- docs/cmd-state-machine.md — cmd panel FSM design
- docs/historical/v1-removal-plan.md — historical record of the v1 removal (DONE 2026-04-24)