Files
relicario/docs/superpowers/plans/2026-06-20-v0.9.0-org-b-read-ui.md
adlee-was-taken 74cee8ac67 docs(plans): v0.9.0 implementation plans — 5 streams across 2 specs
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
2026-06-21 09:35:44 -04:00

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 via shared/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.json and manifest.firefox.json in sync if permissions change (they should not for this plan).
  • Capitalize "Relicario" in prose.

File Structure

  • extension/src/shared/popup-state.ts — add orgContext, orgConfigs, orgCollections, orgOffline to PopupState.
  • 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 via messageForList(); hide the "+ new" affordance in org context.
  • extension/src/popup/components/item-detail.ts — load via messageForGet(id).
  • extension/src/vault/vault-sidebar.ts — mount org-switcher in vault-sidebar__header; add a collection facet for org context.
  • extension/src/popup/popup.ts — mount org-switcher in the popup header.
  • Tests: extension/src/popup/components/__tests__/org-switcher.test.ts, org-context.test.ts, and additions to item-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 (via sendMessage); 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), on change send org_switch, write setState({ orgContext, orgOffline }), then navigate('list', {}) to reload. Render an "org offline — writes disabled" badge when data.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 renderOrgSwitcher into the popup header (popup.ts) and the vault-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' }) in item-list.ts with sendMessage(messageForList()), and get_item in item-detail.ts with messageForGet(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 new vault-sidebar test

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_switch success, fetch org_list_collections into state.orgCollections; render a collection list in the sidebar (reuse the category-nav markup pattern at vault-sidebar.ts:33); clicking a collection sets a collectionFilter in state and re-renders the filtered list. Hidden when orgContext === '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 small org-banner.ts)
  • Test: extension/src/popup/components/__tests__/org-switcher.test.ts (extend)

Interfaces:

  • Consumes: org_switch response { 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_switch returns offline: true, set state.orgOffline and render the banner element in the switcher host. (Plan 3's write UI reads orgOffline to 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.