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

301 lines
14 KiB
Markdown

# 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**
```ts
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**
```ts
// 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`
```bash
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)
```ts
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`
```bash
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**
```ts
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`
```bash
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**
```ts
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**
```bash
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**
```ts
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`
```bash
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.