···88// When compiled, __dirname is dist/backend/electron/ — go up 3 levels to project root
99const pageHtml = readFileSync(join(__dirname, '..', '..', '..', 'app', 'page', 'index.html'), 'utf-8');
10101111+// --- Extracted decision logic from page.js did-fail-load handler ---
1212+1313+interface FailLoadContext {
1414+ isMainFrame: boolean;
1515+ errorCode: number;
1616+ hasEverLoaded: boolean;
1717+}
1818+1919+/**
2020+ * Determines whether the loading indicator should be cleared on a did-fail-load event.
2121+ *
2222+ * Key behaviors:
2323+ * - Sub-frame failures are always ignored (isMainFrame guard)
2424+ * - Error -3 (ABORTED) always clears loading regardless of hasEverLoaded
2525+ * (previously the bug: error -3 with hasEverLoaded=true left indicator stuck)
2626+ * - All other main-frame errors clear loading
2727+ */
2828+export function shouldClearLoadingOnFail(context: FailLoadContext): { clearLoading: boolean; reason?: string } {
2929+ if (!context.isMainFrame) {
3030+ return { clearLoading: false, reason: 'sub-frame failure ignored' };
3131+ }
3232+3333+ if (context.errorCode === -3) {
3434+ return { clearLoading: true, reason: 'aborted navigation — always clear' };
3535+ }
3636+3737+ return { clearLoading: true, reason: 'main frame load error' };
3838+}
3939+1140describe('Page loading CSS', () => {
1241 it('webview.loading should have a dark mode background', () => {
1342 // The loading state must not hardcode a bright white background.
···86115 );
87116 });
88117});
118118+119119+describe('shouldClearLoadingOnFail — aborted navigation fix', () => {
120120+ it('main frame error -3 (ABORTED) should clear loading regardless of hasEverLoaded', () => {
121121+ const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: false });
122122+ assert.equal(result.clearLoading, true);
123123+ });
124124+125125+ it('main frame error -3 with hasEverLoaded=true should clear (this was the bug)', () => {
126126+ // Previously, error -3 with hasEverLoaded=true skipped stopLoading(),
127127+ // leaving the loading indicator permanently stuck.
128128+ const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: true });
129129+ assert.equal(result.clearLoading, true, 'error -3 must clear loading even when hasEverLoaded is true');
130130+ });
131131+132132+ it('main frame error -3 with hasEverLoaded=false should clear', () => {
133133+ const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: false });
134134+ assert.equal(result.clearLoading, true);
135135+ });
136136+137137+ it('sub-frame error -3 should NOT clear loading (isMainFrame guard)', () => {
138138+ const result = shouldClearLoadingOnFail({ isMainFrame: false, errorCode: -3, hasEverLoaded: false });
139139+ assert.equal(result.clearLoading, false, 'sub-frame failures must be ignored');
140140+ assert.ok(result.reason?.includes('sub-frame'));
141141+ });
142142+143143+ it('sub-frame any error should NOT clear loading', () => {
144144+ const result = shouldClearLoadingOnFail({ isMainFrame: false, errorCode: -6, hasEverLoaded: false });
145145+ assert.equal(result.clearLoading, false);
146146+147147+ const result2 = shouldClearLoadingOnFail({ isMainFrame: false, errorCode: -102, hasEverLoaded: true });
148148+ assert.equal(result2.clearLoading, false);
149149+ });
150150+151151+ it('main frame other error (e.g., -6 CONNECTION_REFUSED) should clear loading', () => {
152152+ const result = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -6, hasEverLoaded: false });
153153+ assert.equal(result.clearLoading, true);
154154+ });
155155+156156+ it('main frame error -3 then did-start-navigation: loading restarts (not stuck)', () => {
157157+ // Simulate: error -3 clears loading, then a new navigation starts.
158158+ // This verifies the design: stopLoading() on -3 is safe because
159159+ // did-start-navigation will call startLoading() for the new nav.
160160+ let isLoading = true;
161161+162162+ // did-fail-load with -3
163163+ const failResult = shouldClearLoadingOnFail({ isMainFrame: true, errorCode: -3, hasEverLoaded: true });
164164+ if (failResult.clearLoading) {
165165+ isLoading = false; // stopLoading()
166166+ }
167167+ assert.equal(isLoading, false, 'loading should be cleared after error -3');
168168+169169+ // did-start-navigation fires for the new navigation
170170+ isLoading = true; // startLoading()
171171+ assert.equal(isLoading, true, 'loading should restart on new navigation');
172172+ });
173173+});
+372
features/spaces/DESIGN.md
···11+# Spaces Feature Design
22+33+## Overview
44+55+Split the current groups feature into two separate features:
66+- **Groups**: simplified tag browser (tags == groups, no promote/demote)
77+- **Spaces**: full workspace experience (mode context, border, auto-tagging, session management)
88+99+## Status: DRAFT — iterating on design
1010+1111+---
1212+1313+## Core Directive: Lean Backend
1414+1515+The backend must contain NO space-specific logic. All space behavior lives in the feature layer (`features/spaces/`). The backend provides only generic primitives: context API, pubsub, datastore, window management.
1616+1717+---
1818+1919+## Data Model: Machine Tags
2020+2121+### Space identity
2222+2323+A space is identified by a tag with a `space:` prefix (machine tag):
2424+- `space:research`, `space:work`, `space:side-project`
2525+2626+No new tables. No separate registry. Tags are the building block. The `isGroup` metadata flag is removed entirely.
2727+2828+### Space membership
2929+3030+All contents of a space are tagged with the space's machine tag. A URL tagged with `space:research` belongs to the research space. An item can belong to multiple spaces.
3131+3232+Users can manually add/remove the tag on any platform (e.g., tag on mobile, see in space on desktop).
3333+3434+### Space metadata
3535+3636+Stored in the tag's `metadata` JSON field:
3737+```json
3838+{
3939+ "color": "#ff6b6b",
4040+ "displayName": "Research",
4141+ "description": "Papers and references"
4242+}
4343+```
4444+4545+`displayName` is the human-friendly name. If absent, strip the `space:` prefix for display.
4646+4747+### Content types
4848+4949+Spaces can contain any addressable content:
5050+- URLs (web pages)
5151+- Notes
5252+- `peek://` addresses (search views for tag combos, editor, etc.)
5353+- Images/videos (future)
5454+5555+### Groups vs Spaces
5656+5757+| Aspect | Regular tag (group) | Space tag (`space:*`) |
5858+|--------|--------------------|-----------------------|
5959+| Prefix | None | `space:` |
6060+| Browsing | Groups UI shows items | Groups UI shows items (same) |
6161+| Workspace mode | No | Yes — mode indicator, auto-tagging, screen border |
6262+| Auto-tagging | No | New items created while in space get tagged |
6363+| Load behavior | N/A | Load-on-access — items open when user clicks them |
6464+| Session management | No | Close/restore hides/shows open windows |
6565+| Content types | URLs | URLs, notes, peek:// addresses, media (future) |
6666+6767+---
6868+6969+## Groups Feature (simplified: tags == groups)
7070+7171+Every tag is a group. Every group is a tag.
7272+7373+### Changes
7474+- Remove `isGroupTag()`, `promoteTagToGroup()`, promoted/unpromoted split
7575+- `renderGroups()` shows ALL tags (plus untagged pseudo-group)
7676+- Remove promote buttons, "All Tags" toggle section
7777+- Remove all workspace commands and mode context calls
7878+- `open group <name>` navigates groups UI to detail view (no workspace activation)
7979+- Groups is purely a data browser — no mode context
8080+8181+### Files: 4 modified
8282+- `features/groups/home.js` — remove promote/demote, merge all tags, strip mode calls
8383+- `features/groups/background.js` — remove workspace logic, keep tag CRUD and noun
8484+- `features/groups/manifest.json` — remove workspace commands
8585+- `features/groups/home.css` — remove promote/unpromoted styles
8686+8787+---
8888+8989+## Spaces Feature (workspace)
9090+9191+### Mental model
9292+"I enter a space to focus on a project. Everything I do is part of that space until I leave. Items load when I access them."
9393+9494+### Load-on-access pattern
9595+`open space` does NOT open all tagged items as windows. It:
9696+1. Activates space mode (border, auto-tagging context)
9797+2. Opens the spaces home UI (`peek://ext/spaces/home.html`) showing the space's items
9898+3. Items open as windows when the user clicks them, in space mode
9999+100100+This means a space with 50 items doesn't spawn 50 windows. The user browses and opens what they need.
101101+102102+### Commands
103103+| Command | What it does |
104104+|---------|-------------|
105105+| `open space <name>` | Restores the space as you left it (windows, positions). If never opened before, shows spaces home UI. Activates space mode. |
106106+| `close space` | Saves current window state (which windows, positions), deactivates space mode |
107107+| `switch space <name>` | Closes current space, opens target |
108108+109109+### Spaces home UI
110110+Spaces has its own home UI (`features/spaces/home.html`), initially forked from groups home. This will diverge quickly — spaces UI will gain workspace-specific affordances (space settings, member management, layout controls) that don't belong in the simple groups tag browser.
111111+112112+### Page host space widget
113113+The group-mode widget in `app/page/page.js` becomes the **space widget**. Reacts to `currentMode === 'space'`. Shows space name, color dot, ESC-to-close behavior.
114114+115115+---
116116+117117+## What Lives Where
118118+119119+### Feature layer (`features/spaces/background.js`) — ALL space logic
120120+121121+**Auto-tagging**: Subscribe to `item:created` (enriched with `windowId`). Check originating window's mode via `api.context.get('mode', windowId)`. If mode is `'space'`, tag item with space tag.
122122+123123+**Screen border**: Feature-owned overlay window via `api.window.open()` with special options. Subscribes to `window:focused`, `window:closed`, `context:changed`, `app:focus-changed`. Owns debounce logic, color resolution, label rendering.
124124+125125+**Tag removal reset**: Subscribe to `tag:item-removed`. If removed tag matches active space, reset window mode to `'page'`.
126126+127127+**Session management**: `close space` hides windows + saves positions. `restore space` re-shows them. No "open everything" pattern.
128128+129129+### Backend — ONLY generic primitives
130130+131131+- **Context API**: `setMode`, `getContextEntry`, `getWindowsMatchingContext`. No space awareness.
132132+- **Mode inheritance**: Generic — inherits opener's mode if `metadata.inherit !== false`. Not space-specific.
133133+- **`item:created` with `windowId`**: Generic enrichment, 6 call sites.
134134+- **Window primitives**: `setIgnoreMouseEvents()`, `setVisibleOnAllWorkspaces()`, `getPrimaryDisplay()`.
135135+- **`detectModeFromUrl`**: Just `http(s) → 'page'`, else `'default'`.
136136+137137+### Removed from backend (~250 lines from ipc.ts)
138138+- `autoTagIfGroupMode()` and all 6 inline auto-tag call sites
139139+- All screen border functions and variables
140140+- `VIVID_GROUP_COLORS`, `resolveGroupBorderColor`
141141+- Border-related pubsub subscriptions in `registerModesHandlers()`
142142+- `tag:item-removed` mode reset subscription
143143+- `cleanupGroupScreenBorder()` from quit handler
144144+145145+---
146146+147147+## Naming Renames
148148+149149+### 1. `extension_settings` → `feature_settings`
150150+151151+SQL migration: `ALTER TABLE extension_settings RENAME TO feature_settings`, rename `extensionId` → `featureId`.
152152+153153+~15 files. The `api.settings.*` methods (`get`, `set`, `getKey`) don't contain "extension" so 30+ feature files need no changes — only IPC transport layer and SQL.
154154+155155+### 2. Backend mode: `group` → `space`
156156+157157+~10 files, ~200 mechanical changes. Mode value, metadata fields (`spaceId`, `spaceName`), IPC names, preload methods, page host widget CSS, test file rename.
158158+159159+### 3. Storage keys
160160+- `group-workspaces` → `spaces`
161161+162162+---
163163+164164+## Implementation Phases
165165+166166+### Phase 1: Naming renames (foundation)
167167+- 1a: `extension_settings` → `feature_settings` (~15 files + SQL migration)
168168+- 1b: Backend `group` → `space` (~10 files, ~200 changes)
169169+- 1c: Storage key renames (~4 files)
170170+171171+### Phase 2: Backend generic enrichments
172172+- Add `windowId` to `item:created` payloads (6 sites)
173173+- Expose `setIgnoreMouseEvents`, `setVisibleOnAllWorkspaces`, `getPrimaryDisplay` in preload
174174+- Generalize mode inheritance to use `inherit` metadata flag
175175+176176+### Phase 3a: Create spaces feature (parallel with 3b)
177177+- `features/spaces/` — manifest, config, background, home.html, home.js, home.css
178178+- Home UI forked from groups (will diverge)
179179+- Machine tag resolution (`space:` prefix)
180180+- Auto-tagging via `item:created` subscription
181181+- Screen border as feature-owned window
182182+- Session management (close/restore window positions)
183183+184184+### Phase 3b: Strip backend space logic (parallel with 3a)
185185+- Remove ~250 lines from `ipc.ts`
186186+- Remove quit handler cleanup
187187+- Simplify `detectModeFromUrl`
188188+189189+### Phase 4: Simplify groups (parallel with 3)
190190+- Tags == groups, remove promote/demote
191191+- Strip mode context from home.js
192192+- ~4 files
193193+194194+### Phase 5: Integration
195195+- "Open as space" action on group cards (creates `space:` tag, opens spaces home)
196196+- Page host widget rename (CSS classes)
197197+- Test updates
198198+199199+---
200200+201201+## Open Questions
202202+203203+- Mode inheritance opt-in (`inherit: true`) vs opt-out? Recommend opt-in.
204204+- ESC behavior when space tag removed from page?
205205+- Multi-monitor border? (future)
206206+- Space extensibility/theming/personalization APIs? (future)
207207+- How should tag combos auto-import into spaces? (future)
208208+- Search views as space members (e.g., "all items tagged X+Y") — what's the item representation?
209209+210210+---
211211+212212+## Specification
213213+214214+Testable requirements for the Spaces feature. Each item follows the form WHEN [trigger], THEN [expected result].
215215+216216+The mode value is `'space'` (replacing `'group'`). Metadata fields use `spaceId` and `spaceName`.
217217+218218+### 1. Space Identity and Data Model
219219+220220+**1.1** WHEN a tag is created with name `space:research`, THEN it is discoverable by querying tags with the `space:` prefix.
221221+222222+**1.2** WHEN a space tag has metadata `{"displayName": "Work"}`, THEN display name resolves to `"Work"`.
223223+224224+**1.3** WHEN a space tag has no `displayName` in metadata, THEN display name is derived by stripping the `space:` prefix.
225225+226226+**1.4** WHEN an item is tagged with `space:research`, THEN it is a member of the research space.
227227+228228+**1.5** WHEN an item is tagged with both `space:research` and `space:work`, THEN it belongs to both spaces.
229229+230230+**1.6** WHEN querying for spaces, THEN only tags starting with exactly `space:` match (not `spaceship` or `myspace:foo`).
231231+232232+### 2. `open space` Command
233233+234234+**2.1** WHEN `open space <name>` is executed with no saved state, THEN the spaces home UI opens showing the space's tagged items.
235235+236236+**2.2** WHEN `open space <name>` is executed with saved state (URLs + positions), THEN the saved windows are restored at their saved positions.
237237+238238+**2.3** WHEN windows are restored by `open space`, THEN each has `context.mode = 'space'` with `spaceId` and `spaceName`.
239239+240240+**2.4** WHEN `open space` executes, THEN the screen border becomes visible with the space's color and name.
241241+242242+**2.5** WHEN `open space` executes, THEN auto-tagging activates for space-mode windows.
243243+244244+**2.6** WHEN `open space` executes for a space with 50 tagged items and no saved state, THEN exactly one window opens (spaces home UI), not 50.
245245+246246+### 3. `close space` Command
247247+248248+**3.1** WHEN `close space` is executed, THEN current window state (URLs, positions, sizes) is saved.
249249+250250+**3.2** WHEN `close space` is executed, THEN all space-mode windows for the active space are closed.
251251+252252+**3.3** WHEN `close space` is executed, THEN space mode is deactivated on all affected windows.
253253+254254+**3.4** WHEN `close space` is executed, THEN the screen border is hidden.
255255+256256+**3.5** WHEN `close space` is executed, THEN the spaces home window is also closed.
257257+258258+### 4. `switch space` Command
259259+260260+**4.1** WHEN `switch space <target>` is executed, THEN the current space is closed (equivalent to `close space`).
261261+262262+**4.2** WHEN `switch space <target>` is executed, THEN the target space is opened (equivalent to `open space <target>`).
263263+264264+**4.3** WHEN `switch space` completes, THEN the border color and label reflect the target space.
265265+266266+### 5. Auto-tagging
267267+268268+**5.1** WHEN a new item is created from a space-mode window, THEN it is tagged with the space's tag.
269269+270270+**5.2** WHEN a new item is created from a non-space window, THEN it is NOT tagged, even if space windows exist elsewhere.
271271+272272+**5.3** WHEN a new item is created from the cmd palette, THEN it is NOT auto-tagged (cmd palette has no space mode context).
273273+274274+**5.4** WHEN a new item is created from an external app, THEN it is NOT auto-tagged.
275275+276276+**5.5** WHEN auto-tagging fires, THEN a `tag:item-added` event is published for reactive UI updates.
277277+278278+### 6. Screen Border
279279+280280+**6.1** WHEN focused window has `context.mode = 'space'`, THEN border is visible with space color and name.
281281+282282+**6.2** WHEN focused window has mode `'page'` or `'default'`, THEN border is hidden (debounced).
283283+284284+**6.3** WHEN no window is focused, THEN border is hidden.
285285+286286+**6.4** WHEN focused window is destroyed, THEN border is hidden.
287287+288288+**6.5** WHEN app loses OS focus, THEN border is hidden.
289289+290290+**6.6** WHEN app regains OS focus, THEN border state is re-evaluated from focused window's mode.
291291+292292+**6.7** WHEN a modal window (cmd palette) gains focus while border was visible, THEN border remains visible (modals are transparent).
293293+294294+**6.8** WHEN a modal window gains focus while border was hidden, THEN border remains hidden.
295295+296296+**6.9** WHEN border would hide, THEN a 600ms debounce applies; cancelled if a space-mode window regains focus.
297297+298298+**6.10** WHEN border would show, THEN it appears immediately (no delay).
299299+300300+**6.11** WHEN focus switches from space A to space B, THEN border color and label update immediately.
301301+302302+**6.12** WHEN space color has chroma ≤ 40, THEN a vivid fallback is selected deterministically from palette.
303303+304304+**6.13** WHEN space color has chroma > 40, THEN the actual color is used.
305305+306306+### 7. Mode Inheritance
307307+308308+**7.1** WHEN a window is opened from a space-mode window, THEN it inherits space mode with the same metadata.
309309+310310+**7.2** WHEN a window is opened with explicit `spaceMode` option, THEN it receives that mode regardless of opener.
311311+312312+**7.3** WHEN a window is opened from a non-space window with no `spaceMode` option, THEN it does NOT get space mode.
313313+314314+**7.4** WHEN a window is opened with no opener and no `spaceMode`, THEN it does NOT get space mode (no lastFocusedVisible fallback).
315315+316316+**7.5** WHEN explicit `spaceMode` option AND opener space mode conflict, THEN explicit option wins.
317317+318318+**7.6** WHEN a popup is spawned from a space-mode page, THEN the popup inherits space mode.
319319+320320+### 8. Page Host Space Widget
321321+322322+**8.1** WHEN page has `context.mode = 'space'`, THEN space widget is visible with name and color dot.
323323+324324+**8.2** WHEN page has mode `'page'` or `'default'`, THEN space widget is hidden.
325325+326326+**8.3** WHEN ESC is pressed on a space-mode page, THEN the window closes.
327327+328328+### 9. Tag Removal and Mode Reset
329329+330330+**9.1** WHEN a space tag is removed from an item AND a matching space-mode window displays that URL, THEN that window's mode resets to `'page'`.
331331+332332+**9.2** WHEN a removed tag doesn't match any window's `spaceId`, THEN no windows are affected.
333333+334334+**9.3** WHEN a removed tag matches `spaceId` but the item URL doesn't match any window URL (after normalization), THEN no windows are affected.
335335+336336+**9.4** WHEN URLs are compared for tag-removal matching, THEN trailing slashes, default ports, and query param order are normalized.
337337+338338+**9.5** WHEN a window resets from space to page mode, THEN the border re-evaluates and hides if needed.
339339+340340+### 10. Groups Feature (Simplified)
341341+342342+**10.1** WHEN groups home opens, THEN ALL tags are displayed (no promoted/unpromoted distinction).
343343+344344+**10.2** WHEN a group card is clicked, THEN groups UI shows the tag's items. No mode context is set.
345345+346346+**10.3** WHEN viewing a group detail, THEN window mode stays `'default'`.
347347+348348+**10.4** WHEN viewing a group detail, THEN no screen border appears.
349349+350350+**10.5** WHEN "Open as space" is triggered on a group, THEN a `space:` tag is created and `open space` executes.
351351+352352+### 11. Spaces Home UI
353353+354354+**11.1** WHEN spaces home opens, THEN it loads at `peek://ext/spaces/home.html`.
355355+356356+**11.2** WHEN spaces home is active, THEN its window has `context.mode = 'space'`.
357357+358358+**11.3** WHEN items are added/removed from the space tag, THEN spaces home updates reactively.
359359+360360+**11.4** WHEN a user clicks an item in spaces home, THEN a window opens with space mode.
361361+362362+**11.5** WHEN a user clicks an item already open, THEN the existing window is focused (not duplicated).
363363+364364+### 12. Backend Boundary
365365+366366+**12.1** WHEN backend `ipc.ts` is inspected, THEN it contains NO space-specific functions or variables.
367367+368368+**12.2** WHEN backend emits `item:created`, THEN payload includes `windowId`.
369369+370370+**12.3** WHEN `detectModeFromUrl` is called, THEN it returns only `'page'` (http/s) or `'default'`. Never `'space'`.
371371+372372+**12.4** WHEN mode inheritance runs in the window-open handler, THEN it uses a generic `inherit` metadata flag, not space-specific logic.