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
301 lines
14 KiB
Markdown
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.
|