Full-TDD per-stream plans for the v0.9.0 multi-agent train: - org-a-foundation (A0+A1): WASM org_unwrap_key + multi-context SW session + org config + grant-filtered manifest read. - org-b-read-ui (A2): org switcher + grant-filtered browse/read + offline banner. - org-c-write (A3): GO/NO-GO signing spike first, then commitSigned + org write handlers + UI. Spike-gated; NO-GO ships read-only. - keyfile-core-cli (B1+B2): core armor + unlock_with_secret + params hint + WASM bindings + CLI init/unlock --key-file. - keyfile-ext-positioning (B3+B4): setup container choice + unlock + the README/DESIGN/CRYPTO/FORMATS positioning pivot. Cross-plan contracts pinned and self-reviewed for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQbgrP6KQW5pibjbPEoTSs
14 KiB
Org Read UI Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a member browse and view org items in the browser — a context switcher (Personal + each org), grant-filtered list/detail reusing the existing renderers, and an offline indicator.
Architecture: UI-only. All org data comes from the SW messages Plan 1 produced, sent through the shared/state.ts sendMessage channel. The current context lives in PopupState.orgContext; list/detail data-loading branches on it (list_items vs org_list_items, get_item vs org_get_item) but reuses the same popup/components/* renderers via the StateHost service locator, so org items render with the unchanged per-type detail views.
Tech Stack: TypeScript, vitest + happy-dom.
Global Constraints
- Release target: v0.9.0.
- Reuse existing
popup/components/*renderers viashared/state.ts— do NOT fork per-type views for org. - This plan is READ-ONLY: no add/edit/delete UI (Plan 3). In org context, hide write affordances.
- Org messages are popup-class (sent only from
popup.html/vault.html). - Consume Plan 1's contract verbatim:
org_list_configs,org_switch {context},org_list_items,org_get_item {id},org_list_collections. - Keep
manifest.jsonandmanifest.firefox.jsonin sync if permissions change (they should not for this plan). - Capitalize "Relicario" in prose.
File Structure
extension/src/shared/popup-state.ts— addorgContext,orgConfigs,orgCollections,orgOfflinetoPopupState.extension/src/shared/org-context.ts(new) —currentContext(),messageForList(),messageForGet(id)helpers that pick the personal vs org message by context (single source of truth, consumed by list + detail).extension/src/popup/components/org-switcher.ts(new) — the Personal/org selector + offline banner; mounted in both the popup header and the vault sidebar header.extension/src/popup/components/item-list.ts— load viamessageForList(); hide the "+ new" affordance in org context.extension/src/popup/components/item-detail.ts— load viamessageForGet(id).extension/src/vault/vault-sidebar.ts— mountorg-switcherinvault-sidebar__header; add a collection facet for org context.extension/src/popup/popup.ts— mountorg-switcherin the popup header.- Tests:
extension/src/popup/components/__tests__/org-switcher.test.ts,org-context.test.ts, and additions toitem-list.test.ts.
Task 1: PopupState org fields + context message helper
Files:
- Modify:
extension/src/shared/popup-state.ts - Create:
extension/src/shared/org-context.ts - Test:
extension/src/shared/__tests__/org-context.test.ts
Interfaces:
-
Produces:
PopupState.orgContext: 'personal' | string(default'personal'),orgConfigs: OrgConfigSummary[],orgCollections: Collection[],orgOffline: boolean;currentContext(): 'personal' | string;messageForList(): Request;messageForGet(id: string): Request. -
Step 1: Write the failing test
import { messageForList, messageForGet } from '../org-context';
import * as state from '../state';
test('messageForList/Get pick personal vs org by current context', () => {
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'personal' } as any);
expect(messageForList()).toEqual({ type: 'list_items' });
expect(messageForGet('x')).toEqual({ type: 'get_item', id: 'x' });
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1' } as any);
expect(messageForList()).toEqual({ type: 'org_list_items' });
expect(messageForGet('x')).toEqual({ type: 'org_get_item', id: 'x' });
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run src/shared/__tests__/org-context.test.ts
Expected: FAIL — org-context module missing.
- Step 3: Implement
// org-context.ts
import { getState } from './state';
import type { Request } from './messages';
export function currentContext(): 'personal' | string {
return getState().orgContext ?? 'personal';
}
export function messageForList(): Request {
return currentContext() === 'personal' ? { type: 'list_items' } : { type: 'org_list_items' };
}
export function messageForGet(id: string): Request {
return currentContext() === 'personal' ? { type: 'get_item', id } : { type: 'org_get_item', id };
}
Add the four fields to PopupState in popup-state.ts (defaults: orgContext: 'personal', orgConfigs: [], orgCollections: [], orgOffline: false).
- Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/shared/__tests__/org-context.test.ts
Expected: PASS.
- Step 5: Type-check + commit
Run: cd extension && npm run build:all
git add extension/src/shared/popup-state.ts extension/src/shared/org-context.ts extension/src/shared/__tests__/org-context.test.ts
git commit -m "feat(ext): PopupState org fields + context-aware message helper"
Task 2: Org switcher component
Files:
- Create:
extension/src/popup/components/org-switcher.ts - Test:
extension/src/popup/components/__tests__/org-switcher.test.ts
Interfaces:
-
Consumes:
org_list_configs,org_switch(viasendMessage);setState,navigate. -
Produces:
renderOrgSwitcher(host: HTMLElement): Promise<void>(renders a<select>of Personal + each org, an offline badge),teardown(). -
Step 1: Write the failing test (mock
shared/state, the established component-test pattern)
import { renderOrgSwitcher } from '../org-switcher';
import * as state from '../../../shared/state';
test('switching to an org sends org_switch and reloads the list', async () => {
const send = vi.spyOn(state, 'sendMessage').mockImplementation(async (req: any) => {
if (req.type === 'org_list_configs') return { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] };
if (req.type === 'org_switch') return { ok: true, data: { context: 'org-1', offline: false } };
return { ok: true, data: [] };
});
const nav = vi.spyOn(state, 'navigate').mockImplementation(() => {});
const host = document.createElement('div');
await renderOrgSwitcher(host);
const sel = host.querySelector('select') as HTMLSelectElement;
sel.value = 'org-1'; sel.dispatchEvent(new Event('change'));
await Promise.resolve();
expect(send).toHaveBeenCalledWith({ type: 'org_switch', context: 'org-1' });
expect(nav).toHaveBeenCalledWith('list', expect.anything());
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts
Expected: FAIL — org-switcher missing.
-
Step 3: Implement — fetch
org_list_configs, render<select>(Personal + each), onchangesendorg_switch, writesetState({ orgContext, orgOffline }), thennavigate('list', {})to reload. Render an "org offline — writes disabled" badge whendata.offline. -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts
Expected: PASS.
- Step 5: Mount in both surfaces + commit — call
renderOrgSwitcherinto the popup header (popup.ts) and thevault-sidebar__header(vault-sidebar.ts, after the brand block at:26-29).
Run: cd extension && npm run build:all
git add extension/src/popup/components/org-switcher.ts extension/src/popup/popup.ts extension/src/vault/vault-sidebar.ts extension/src/popup/components/__tests__/org-switcher.test.ts
git commit -m "feat(ext): org context switcher (popup header + vault sidebar)"
Task 3: List + detail consume the context-aware data source
Files:
- Modify:
extension/src/popup/components/item-list.ts,item-detail.ts - Test: additions to
extension/src/popup/components/__tests__/item-list.test.ts
Interfaces:
-
Consumes:
messageForList()/messageForGet(id)(Task 1). -
Step 1: Write the failing test
test('item-list loads org items (grant-filtered) when context is an org', async () => {
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', items: [] } as any);
const send = vi.spyOn(state, 'sendMessage').mockResolvedValue({ ok: true, data: [
{ id: 'a', title: 'db', collection: 'prod-infra', modified: 1 },
]});
await renderItemList(document.createElement('div'));
expect(send).toHaveBeenCalledWith({ type: 'org_list_items' });
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run src/popup/components/__tests__/item-list.test.ts -t "org items"
Expected: FAIL — list still sends list_items.
-
Step 3: Implement — replace the hard-coded
sendMessage({ type: 'list_items' })initem-list.tswithsendMessage(messageForList()), andget_iteminitem-detail.tswithmessageForGet(id). In org context, hide the "+ new item" button (read-only this plan). -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/popup/components/__tests__/item-list.test.ts
Expected: PASS (personal path unchanged: context 'personal' → list_items).
- Step 5: Type-check + commit
Run: cd extension && npm run build:all
git add extension/src/popup/components/item-list.ts extension/src/popup/components/item-detail.ts extension/src/popup/components/__tests__/item-list.test.ts
git commit -m "feat(ext): list/detail load org items in org context"
Task 4: Collection facet in the vault sidebar (org context)
Files:
- Modify:
extension/src/vault/vault-sidebar.ts,extension/src/vault/vault-context.ts(filter helper) - Test:
extension/src/popup/components/__tests__/org-switcher.test.ts(extend) or a newvault-sidebartest
Interfaces:
-
Consumes:
org_list_collections;PopupState.orgCollections. -
Produces: a collection nav list (parallel to the type-category nav) shown only in org context; selecting a collection filters the org list to that slug.
-
Step 1: Write the failing test
test('org context renders a collection facet from org_list_collections', async () => {
vi.spyOn(state, 'getState').mockReturnValue({ orgContext: 'org-1', orgCollections: [
{ slug: 'prod-infra', display_name: 'Production Infra' },
]} as any);
const el = document.createElement('div');
renderCollectionFacet(el);
expect(el.textContent).toContain('Production Infra');
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run -t "collection facet"
Expected: FAIL — renderCollectionFacet missing.
-
Step 3: Implement — on
org_switchsuccess, fetchorg_list_collectionsintostate.orgCollections; render a collection list in the sidebar (reuse the category-nav markup pattern atvault-sidebar.ts:33); clicking a collection sets acollectionFilterin state and re-renders the filtered list. Hidden whenorgContext === 'personal'. -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run -t "collection facet"
Expected: PASS.
- Step 5: Commit
git add extension/src/vault/vault-sidebar.ts extension/src/vault/vault-context.ts
git commit -m "feat(ext): collection facet for org browse"
Task 5: Offline read-only banner
Files:
- Modify:
extension/src/popup/components/org-switcher.ts(or a smallorg-banner.ts) - Test:
extension/src/popup/components/__tests__/org-switcher.test.ts(extend)
Interfaces:
-
Consumes:
org_switchresponse{ offline };PopupState.orgOffline. -
Step 1: Write the failing test
test('offline org_switch renders the writes-disabled banner', async () => {
vi.spyOn(state, 'sendMessage').mockImplementation(async (req: any) =>
req.type === 'org_switch' ? { ok: true, data: { context: 'org-1', offline: true } }
: req.type === 'org_list_configs' ? { ok: true, data: [{ orgId: 'org-1', displayName: 'Acme' }] }
: { ok: true, data: [] });
const host = document.createElement('div');
await renderOrgSwitcher(host);
(host.querySelector('select') as HTMLSelectElement).value = 'org-1';
host.querySelector('select')!.dispatchEvent(new Event('change'));
await Promise.resolve();
expect(host.textContent).toContain('org offline — writes disabled');
});
- Step 2: Run to verify it fails
Run: cd extension && npx vitest run -t "writes-disabled banner"
Expected: FAIL — no banner.
-
Step 3: Implement — when
org_switchreturnsoffline: true, setstate.orgOfflineand render the banner element in the switcher host. (Plan 3's write UI readsorgOfflineto disable add/edit.) -
Step 4: Run to verify it passes
Run: cd extension && npx vitest run src/popup/components/__tests__/org-switcher.test.ts
Expected: PASS.
- Step 5: Full suite, type-check, commit
Run: cd extension && npx vitest run && npm run build:all
git add extension/src/popup/components/org-switcher.ts extension/src/popup/components/__tests__/org-switcher.test.ts
git commit -m "feat(ext): org offline read-only banner"
Hand-off note (Plan 3 write builds on this)
Plan 3 adds the write affordances this plan deliberately hid: the "+ new item" button in org context, edit/delete in the org item detail, and a granted-collection picker on add. It reads PopupState.orgOffline to disable writes when offline, and PopupState.orgCollections for the collection picker. Write operations call the org_add_item/org_update_item/org_delete_item messages Plan 3 adds to the SW.