From 6eac95f163bc589d77ce8fb37a686232c4cff1ee Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 9 Sep 2026 07:33:48 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(manager):=20the=20list=20view's=20mode?= =?UTF-8?q?l=20=E2=80=94=20grouping,=20selection,=20delete=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity map answers "what reaches what", one selection at a time. That is the right picture for a pool of five and the wrong one for a pool of fifty: a holder who wants to tidy has to open, read and confirm one card at a time, which is the clunkiness the map cannot design its way out of because one-at-a-time is its whole subject. This is the model for a second view over the same graph — the same `AttributeNode`s, the same families, no new wire records, and a selection that is a set rather than a single node. Out of the component for the reason `identity-graph.ts` is: the interesting parts are the ordering and the set arithmetic, and both are testable with no DOM. **The grouping is the one that already exists.** Rows group by `familyOf` in `FAMILY_ORDER`, with `familyStyle`'s words as headings — not a second taxonomy invented for this view. Two groupings of one pool that disagree is the defect where a person counts six in one place and five in the other. **A range selects over the rendered order, not the pool.** Grouping reorders, so a range computed from the unsorted pool selects attributes that were never between the two rows the person clicked. **One bulk action is deliberately missing.** `persona/attribute/put` is a replace and this console lists without `includeSensitive` on purpose, so a `sensitivity: high` attribute reaches the client with `value: undefined`. A bulk visibility change written through `put` would send an empty value for every sensitive one and blank it — silently, with no `attribute/get` and no version history to restore from. That is the hazard `AttributeEditor` fetches a value to avoid, multiplied by the size of the selection. So `BULK_ACTIONS` has two members, and `whyNoBulkVisibility` exists so the screen can say why rather than leaving a gap someone fills in later. Signed-off-by: Glenn Gore --- .../extension/src/manager/attribute-list.ts | 273 ++++++++++++++++++ .../tests/manager-attribute-list.test.mts | 181 ++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 packages/extension/src/manager/attribute-list.ts create mode 100644 packages/extension/tests/manager-attribute-list.test.mts diff --git a/packages/extension/src/manager/attribute-list.ts b/packages/extension/src/manager/attribute-list.ts new file mode 100644 index 0000000..9e81056 --- /dev/null +++ b/packages/extension/src/manager/attribute-list.ts @@ -0,0 +1,273 @@ +// The list view's model: attributes in groups, a selection over them, and what +// a bulk action would do. +// +// ## Why a second view at all +// +// The identity map answers "what reaches what". It is the right picture for a +// pool of five and the wrong one for a pool of fifty: reach is drawn per +// selection, so a holder who wants to *tidy* — delete four stale numbers, put +// six attributes into a new face — has to open, read and confirm one card at a +// time. That is the clunkiness the map cannot design its way out of, because +// the map's whole subject is one selection at a time. +// +// So this module is the model for a second view over the same graph: the same +// `AttributeNode`s, the same families, no new wire records, and a selection +// that is a *set* rather than a single node. It lives out of the component for +// the same reason `identity-graph.ts` does — the interesting parts are the +// ordering and the set arithmetic, and both are testable with no DOM. +// +// ## The grouping is the one that already exists +// +// Rows group by `familyOf`, in `FAMILY_ORDER`, with `familyStyle`'s words as +// the headings. Not a second taxonomy invented for this view: two groupings of +// the same pool that disagree is the defect where a person counts six in one +// place and five in the other and neither is wrong. If the list wants a +// grouping the map does not have, it belongs in `attribute-family.ts` where +// both read it. +// +// ## One bulk action is deliberately missing +// +// `persona/attribute/put` is a **replace**, and this console lists without +// `includeSensitive` on purpose — so an attribute resolving to `sensitivity: +// high` reaches the client with `value: undefined`. A bulk "change visibility +// on these twelve" written through `put` would send `value: ""` for every +// sensitive one and blank it, silently, with no `attribute/get` and no version +// history to restore from. That is the same hazard `AttributeEditor` fetches a +// value to avoid, multiplied by the size of the selection. +// +// So {@link BULK_ACTIONS} has two members and not three, and +// {@link whyNoBulkVisibility} exists so the screen can *say* why rather than +// leaving a gap someone fills in later. Visibility stays a one-at-a-time edit +// through the editor that holds the value. + +import { + familyOf, + familyStyle, + FAMILY_ORDER, + type Family, + type FamilyStyle, +} from "./attribute-family.js"; +import type { AttributeNode } from "./identity-graph.js"; +import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; + +/** One family's heading and the attributes under it, in display order. */ +export interface ListGroup { + family: Family; + style: FamilyStyle; + rows: AttributeNode[]; +} + +/** + * The attributes grouped for display, in `FAMILY_ORDER`. + * + * Empty families are dropped rather than drawn empty: a heading over nothing + * reads as a category the holder has failed to fill in, and the pool has no + * such obligation. Within a family the incoming order is kept — the pool is + * ULID-ordered, so that is creation order, and a list that re-sorted by label + * would move a row under the cursor the moment someone renamed it. + */ +export function groupRows( + attributes: readonly AttributeNode[], + registry: ClaimTypeRegistry | null, +): ListGroup[] { + const byFamily = new Map(); + for (const a of attributes) { + const family = familyOf(a.type, registry); + const rows = byFamily.get(family); + if (rows) rows.push(a); + else byFamily.set(family, [a]); + } + return FAMILY_ORDER.flatMap((family) => { + const rows = byFamily.get(family); + if (!rows || rows.length === 0) return []; + return [{ family, style: familyStyle(family), rows }]; + }); +} + +/** + * Every visible row id, top to bottom. + * + * This is what a shift-range selects over, and it has to come from the rendered + * groups rather than from the unsorted pool: a range is what the person saw + * between the two rows they clicked, and computing it from anything else + * selects attributes that were never between them on screen. + */ +export function flatOrder(groups: readonly ListGroup[]): string[] { + return groups.flatMap((g) => g.rows.map((r) => r.id)); +} + +/** Add or remove one id. */ +export function toggle(selection: ReadonlySet, id: string): Set { + const next = new Set(selection); + if (!next.delete(id)) next.add(id); + return next; +} + +/** + * Select every row between `anchor` and `id` inclusive, in visible order. + * + * Additive — a range never clears what was already selected — because the + * gesture people expect from a file list is "and also these", and a range that + * silently dropped an earlier selection would be discovered by someone who had + * just lost one. + * + * An anchor that is no longer in `order` (its row was deleted, or a filter + * moved it out of view) degrades to selecting `id` alone rather than throwing + * or selecting from the top: the anchor is a memory of where the person last + * clicked, and a stale one is not worth a surprise. + */ +export function selectRange( + selection: ReadonlySet, + order: readonly string[], + anchor: string | null, + id: string, +): Set { + const next = new Set(selection); + const to = order.indexOf(id); + const from = anchor === null ? -1 : order.indexOf(anchor); + if (to < 0) return next; + if (from < 0) { + next.add(id); + return next; + } + const [lo, hi] = from <= to ? [from, to] : [to, from]; + for (let i = lo; i <= hi; i += 1) { + const at = order[i]; + if (at !== undefined) next.add(at); + } + return next; +} + +/** + * Whether a group is fully selected, partly selected, or not at all — the three + * states its heading checkbox has to render. + * + * `some` is not a rounding of `all`: a heading that showed a tick while two of + * its six rows were selected would make a bulk delete look like it covered the + * group when it covered a third of it. + */ +export type GroupState = "none" | "some" | "all"; + +export function groupState(selection: ReadonlySet, group: ListGroup): GroupState { + let selected = 0; + for (const row of group.rows) if (selection.has(row.id)) selected += 1; + if (selected === 0) return "none"; + return selected === group.rows.length ? "all" : "some"; +} + +/** + * Select or clear a whole family. + * + * A partly-selected group selects the rest rather than clearing, which is the + * behaviour that needs no thought: the person clicking a half-ticked heading is + * reaching for "all of these", and a click that instead threw away the four + * they had picked one at a time is destructive of work. + */ +export function toggleGroup(selection: ReadonlySet, group: ListGroup): Set { + const next = new Set(selection); + if (groupState(selection, group) === "all") { + for (const row of group.rows) next.delete(row.id); + } else { + for (const row of group.rows) next.add(row.id); + } + return next; +} + +/** + * Drop ids that are no longer in the pool. + * + * Called after every reload. A selection is a set of ids and a delete removes + * rows from under it, so without this the count on the bulk bar keeps + * describing attributes that are gone — and the next bulk action sends their + * ids to an agent that will refuse them one at a time. + */ +export function pruneSelection( + selection: ReadonlySet, + attributes: readonly AttributeNode[], +): Set { + const live = new Set(attributes.map((a) => a.id)); + const next = new Set(); + for (const id of selection) if (live.has(id)) next.add(id); + return next; +} + +/** The selected attributes, in visible order rather than selection order. */ +export function selectedRows( + selection: ReadonlySet, + groups: readonly ListGroup[], +): AttributeNode[] { + return groups.flatMap((g) => g.rows.filter((r) => selection.has(r.id))); +} + +/** + * The bulk actions this view offers. + * + * Two, not three — see the module header. Both are expressible with tasks the + * console already carries (`persona/attribute/delete`, `persona/profile/put`) + * and neither writes an attribute's `value`, which is the property that makes + * them safe to run over a selection whose sensitive values this console + * deliberately does not hold. + */ +export const BULK_ACTIONS = ["delete", "addToFace"] as const; +export type BulkAction = (typeof BULK_ACTIONS)[number]; + +/** + * Why visibility is not among them, in the words the screen uses. + * + * Exported rather than inlined so the copy is asserted rather than described: + * a later change that adds a bulk visibility action has to delete this + * sentence, and deleting it is visible in a diff in a way that quietly adding + * a third button beside it is not. + */ +export function whyNoBulkVisibility(): string { + return ( + "Showing and letting go are changed one attribute at a time, from the attribute itself — " + + "your agent does not send this console the values it keeps back, and changing them in bulk " + + "would write over the ones it never sent." + ); +} + +/** + * What a bulk delete would take, summarised for the confirm step. + * + * The agent has no preview task for the pool the way it does for a context, so + * the console builds this from what it already listed. It counts rather than + * reciting: a person confirming a delete of thirty needs the shape of what is + * going, and thirty rows of it is the same wall the list view exists to fix. + */ +export interface DeletePreview { + count: number; + /** Type tokens in the selection, deduplicated, in visible order. */ + types: string[]; + /** + * How many are the only attribute of their type left in the pool. + * + * The one thing a count cannot say and a person would want to know: deleting + * your third phone number is tidying, deleting your only email address is a + * face that stops presenting one. + */ + lastOfType: number; + /** How many are credential-backed, and so cost more than retyping. */ + credentialBacked: number; +} + +export function previewDelete( + selection: ReadonlySet, + groups: readonly ListGroup[], + attributes: readonly AttributeNode[], +): DeletePreview { + const rows = selectedRows(selection, groups); + const remaining = new Map(); + for (const a of attributes) { + if (selection.has(a.id)) continue; + remaining.set(a.type, (remaining.get(a.type) ?? 0) + 1); + } + const types: string[] = []; + for (const r of rows) if (!types.includes(r.type)) types.push(r.type); + return { + count: rows.length, + types, + lastOfType: types.filter((ty) => (remaining.get(ty) ?? 0) === 0).length, + credentialBacked: rows.filter((r) => r.provenance.kind === "credentialBacked").length, + }; +} diff --git a/packages/extension/tests/manager-attribute-list.test.mts b/packages/extension/tests/manager-attribute-list.test.mts new file mode 100644 index 0000000..a6e06b6 --- /dev/null +++ b/packages/extension/tests/manager-attribute-list.test.mts @@ -0,0 +1,181 @@ +// The list view's model: grouping, the selection set, and what a bulk delete +// would take. +// +// Every case here is a way the naive version of a multi-select is wrong in a +// way a type checker cannot see and a single-click test does not reach: a range +// computed over the pool instead of the screen, a half-ticked heading that +// clears the work under it, a selection that keeps counting rows the agent has +// already deleted. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + groupRows, + flatOrder, + toggle, + selectRange, + groupState, + toggleGroup, + pruneSelection, + selectedRows, + previewDelete, + BULK_ACTIONS, + whyNoBulkVisibility, +} from "../src/manager/attribute-list.ts"; +import type { AttributeNode } from "../src/manager/identity-graph.ts"; + +const REGISTRY = { + registryVersion: "0.1", + entries: [ + "name", "name.given", "name.family", "email", "email.personal", + "phone", "phone.mobile", "account", "account.handle", + ].map((type) => ({ type, sensitivity: "normal", release: "consent", mask: "none" })), + unregistered: { sensitivity: "high", release: "consent", mask: "full" }, + strictness: { + sensitivity: ["high", "normal"], + release: ["stepUp", "consent"], + mask: ["full", "last2", "last4", "emailLocal", "none"], + }, +} as never; + +function attr(id: string, type: string, extra: Partial = {}): AttributeNode { + return { + id, + type, + value: "x", + provenance: { kind: "selfAsserted" }, + stale: false, + version: 1, + ...extra, + } as AttributeNode; +} + +/** Two names, one email, one phone, one invented token. */ +function pool(): AttributeNode[] { + return [ + attr("a1", "name.given"), + attr("a2", "name.family"), + attr("a3", "email.personal"), + attr("a4", "phone.mobile"), + attr("a5", "x:gamertag"), + ]; +} + +test("groups follow FAMILY_ORDER and drop the empty ones", () => { + const groups = groupRows(pool(), REGISTRY); + // A heading over nothing reads as a category the holder failed to fill in. + assert.ok(groups.every((g) => g.rows.length > 0), "an empty family was drawn"); + // identity before contact before unregistered — the order the map uses. + const families = groups.map((g) => g.family); + assert.deepEqual( + families, + [...families].sort((a, b) => families.indexOf(a) - families.indexOf(b)), + "groups came back out of FAMILY_ORDER", + ); + assert.ok(families.includes("unregistered"), "the invented token was classified into a family"); +}); + +test("creation order survives inside a group", () => { + // A list that re-sorted by label would move a row under the cursor the moment + // someone renamed it. + const withLabels = [attr("a1", "name.given", { label: "zzz" }), attr("a2", "name.family", { label: "aaa" })]; + const [group] = groupRows(withLabels, REGISTRY); + assert.deepEqual(group?.rows.map((r) => r.id), ["a1", "a2"]); +}); + +test("a range selects what was between the two rows ON SCREEN", () => { + // The defect this pins: computing the range over the unsorted pool selects + // attributes that were never between the two the person clicked, because + // grouping reorders them. + const groups = groupRows(pool(), REGISTRY); + const order = flatOrder(groups); + const picked = selectRange(new Set(), order, order[0]!, order[2]!); + assert.deepEqual([...picked].sort(), order.slice(0, 3).sort()); +}); + +test("a range is additive", () => { + const order = ["a", "b", "c", "d"]; + const first = selectRange(new Set(["d"]), order, "a", "b"); + assert.ok(first.has("d"), "an earlier selection was thrown away by a range"); + assert.deepEqual([...first].sort(), ["a", "b", "d"]); +}); + +test("a range runs in both directions", () => { + const order = ["a", "b", "c", "d"]; + assert.deepEqual([...selectRange(new Set(), order, "d", "b")].sort(), ["b", "c", "d"]); +}); + +test("a stale anchor selects one row rather than surprising anyone", () => { + const order = ["a", "b", "c"]; + // The anchor's row was deleted since it was clicked. + const picked = selectRange(new Set(), order, "gone", "c"); + assert.deepEqual([...picked], ["c"]); +}); + +test("toggle adds then removes", () => { + assert.deepEqual([...toggle(new Set(), "a")], ["a"]); + assert.deepEqual([...toggle(new Set(["a"]), "a")], []); +}); + +test("a heading has three states and 'some' is not a rounding of 'all'", () => { + const groups = groupRows(pool(), REGISTRY); + const identity = groups.find((g) => g.family === "identity")!; + assert.equal(groupState(new Set(), identity), "none"); + assert.equal(groupState(new Set(["a1"]), identity), "some"); + assert.equal(groupState(new Set(["a1", "a2"]), identity), "all"); +}); + +test("a half-ticked heading selects the rest rather than clearing it", () => { + // Clicking it is reaching for "all of these". A click that instead threw away + // the ones picked one at a time is destructive of work. + const groups = groupRows(pool(), REGISTRY); + const identity = groups.find((g) => g.family === "identity")!; + assert.equal(groupState(toggleGroup(new Set(["a1"]), identity), identity), "all"); + assert.equal(groupState(toggleGroup(new Set(["a1", "a2"]), identity), identity), "none"); +}); + +test("a selection is pruned to what the pool still holds", () => { + // Without this the bulk bar keeps counting rows the agent already deleted, + // and the next action sends their ids back to be refused one at a time. + const after = pruneSelection(new Set(["a1", "a4", "deleted"]), pool()); + assert.deepEqual([...after].sort(), ["a1", "a4"]); +}); + +test("selected rows come back in visible order, not selection order", () => { + const groups = groupRows(pool(), REGISTRY); + const order = flatOrder(groups); + const rows = selectedRows(new Set([order[2]!, order[0]!]), groups); + assert.deepEqual(rows.map((r) => r.id), [order[0], order[2]]); +}); + +test("the delete preview counts the last of a type", () => { + // The one thing a count cannot say: deleting your third phone number is + // tidying, deleting your only email address changes what a face presents. + const groups = groupRows(pool(), REGISTRY); + const preview = previewDelete(new Set(["a3"]), groups, pool()); + assert.equal(preview.count, 1); + assert.equal(preview.lastOfType, 1, "the only email was not reported as the last of its type"); + + const both = previewDelete(new Set(["a1"]), groups, pool()); + assert.equal(both.lastOfType, 1, "name.given and name.family are different types"); +}); + +test("the delete preview counts credential-backed attributes separately", () => { + const withCred = [ + ...pool(), + attr("a6", "name.legal", { + provenance: { kind: "credentialBacked", credentialId: "c1", claimPath: "/n" }, + }), + ]; + const groups = groupRows(withCred, REGISTRY); + const preview = previewDelete(new Set(["a1", "a6"]), groups, withCred); + assert.equal(preview.credentialBacked, 1); +}); + +test("bulk visibility is not offered, and the refusal has words", () => { + // `attribute/put` is a replace and this console does not hold the values it + // masks, so a bulk visibility change would blank every sensitive attribute in + // the selection. Adding a third action has to delete this assertion. + assert.deepEqual([...BULK_ACTIONS], ["delete", "addToFace"]); + assert.match(whyNoBulkVisibility(), /one attribute at a time/); +}); From 8b8f397e13b16d40c7b5acf59cffe91e01a0cb06 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 9 Sep 2026 07:44:20 +0200 Subject: [PATCH 2/3] fix(manager): count what a face still references before a bulk delete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `persona/attribute/delete` refuses an attribute a profile still names unless `cascade` is set — "a profile silently projecting one fewer claim is a failure the holder discovers from the far side of a disclosure". A bulk delete of twelve where five are in use is therefore five refusals, arriving one at a time, after the seven that already succeeded. The preview now counts them up front, so the confirm step can ask the second question before anything is sent — the way `Destructive`'s `force` tick does. Overriding a refusal the agent makes on purpose is its own decision and does not follow from pressing Delete. It reads all three referencing forms, not just live references: `attributeIds` carries only `{ref}`, while a pin and an override name the attribute too and the agent refuses on those as well. Counting only live references would under-count and put the holder back in the one-refusal-at-a-time state the preview exists to prevent. `facesAffected` names the faces rather than counting them — "Work loses two" is the sentence that changes a mind, and it is not available anywhere else on the screen. Signed-off-by: Glenn Gore --- .../extension/src/manager/attribute-list.ts | 59 ++++++++++++++++++- .../tests/manager-attribute-list.test.mts | 41 +++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/manager/attribute-list.ts b/packages/extension/src/manager/attribute-list.ts index 9e81056..5080390 100644 --- a/packages/extension/src/manager/attribute-list.ts +++ b/packages/extension/src/manager/attribute-list.ts @@ -47,7 +47,8 @@ import { type Family, type FamilyStyle, } from "./attribute-family.js"; -import type { AttributeNode } from "./identity-graph.js"; +import type { AttributeNode, FaceNode } from "./identity-graph.js"; +import type { PoolProfileEntry } from "@openvtc/pnm-core/admin"; import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; /** One family's heading and the attributes under it, in display order. */ @@ -249,12 +250,33 @@ export interface DeletePreview { lastOfType: number; /** How many are credential-backed, and so cost more than retyping. */ credentialBacked: number; + /** + * How many of the selected attributes a face still references. + * + * **This is the half that decides whether the delete works at all.** The + * agent refuses to delete an attribute a profile still names unless + * `cascade` is set — "a profile silently projecting one fewer claim is a + * failure the holder discovers from the far side of a disclosure" — so a + * bulk delete of twelve where five are in use is five refusals, arriving one + * at a time, after the seven that succeeded. + * + * Counting them up front is what lets the confirm step ask the second + * question *before* anything is sent, the way `Destructive`'s `force` tick + * does: overriding a refusal the agent makes on purpose is its own decision + * and does not follow from pressing Delete. + */ + usedInFaces: number; + /** The faces that would lose an entry, by name, in graph order. Named rather + * than counted because "Work loses two" is the sentence that changes a mind, + * and the holder cannot get it from anywhere else on this screen. */ + facesAffected: string[]; } export function previewDelete( selection: ReadonlySet, groups: readonly ListGroup[], attributes: readonly AttributeNode[], + faces: readonly FaceNode[] = [], ): DeletePreview { const rows = selectedRows(selection, groups); const remaining = new Map(); @@ -264,10 +286,45 @@ export function previewDelete( } const types: string[] = []; for (const r of rows) if (!types.includes(r.type)) types.push(r.type); + + // A face reaches an attribute by live reference, by pin and by override — + // `attributeIds` carries only the first. The other two still *name* the + // attribute, so the agent refuses on them too; reading only live references + // would under-count and put the holder back in the one-refusal-at-a-time + // state this preview exists to prevent. + const referenced = new Set(); + const facesAffected: string[] = []; + for (const face of faces) { + let touches = false; + for (const entry of face.entries) { + const ref = entryRef(entry); + if (ref !== null && selection.has(ref)) { + referenced.add(ref); + touches = true; + } + } + if (touches) facesAffected.push(face.name); + } + return { count: rows.length, types, lastOfType: types.filter((ty) => (remaining.get(ty) ?? 0) === 0).length, credentialBacked: rows.filter((r) => r.provenance.kind === "credentialBacked").length, + usedInFaces: referenced.size, + facesAffected, }; } + +/** + * The pool attribute an entry draws on, or `null` for an inline one. + * + * The client-side twin of `ProfileEntry::referenced` in `vta-persona`. Kept + * here rather than imported because the console reads entries as the wire + * shape, and the four forms are distinguished by which members are present. + */ +function entryRef(entry: PoolProfileEntry): string | null { + return typeof entry === "object" && entry !== null && "ref" in entry + ? ((entry as { ref?: unknown }).ref as string | undefined) ?? null + : null; +} diff --git a/packages/extension/tests/manager-attribute-list.test.mts b/packages/extension/tests/manager-attribute-list.test.mts index a6e06b6..742e46c 100644 --- a/packages/extension/tests/manager-attribute-list.test.mts +++ b/packages/extension/tests/manager-attribute-list.test.mts @@ -172,6 +172,47 @@ test("the delete preview counts credential-backed attributes separately", () => assert.equal(preview.credentialBacked, 1); }); +test("the preview counts what a face still references, in all three forms", () => { + // The half that decides whether the delete works at all: the agent refuses an + // attribute a face still names unless `cascade` is set. Reading only live + // references would under-count — a pin and an override name it too — and put + // the holder back in the one-refusal-at-a-time state this exists to prevent. + const faces = [ + { + id: "f1", + name: "Work", + attributeIds: ["a1"], + preserved: 0, + version: 1, + entries: [{ ref: "a1" }, { ref: "a3", pinVersion: 2 }], + }, + { + id: "f2", + name: "Play", + attributeIds: [], + preserved: 0, + version: 1, + entries: [{ ref: "a4", override: { value: "+61 0" } }, { inline: { type: "x:h" } }], + }, + { id: "f3", name: "Spare", attributeIds: [], preserved: 0, version: 1, entries: [] }, + ] as never as Parameters[3]; + + const groups = groupRows(pool(), REGISTRY); + const preview = previewDelete(new Set(["a1", "a3", "a4"]), groups, pool(), faces); + assert.equal(preview.usedInFaces, 3, "a pin or an override was not counted as a reference"); + assert.deepEqual(preview.facesAffected, ["Work", "Play"], "a face losing nothing was named"); +}); + +test("an inline entry is nobody's pool attribute", () => { + const faces = [ + { id: "f1", name: "Work", attributeIds: [], preserved: 0, version: 1, entries: [{ inline: { type: "x:h" } }] }, + ] as never as Parameters[3]; + const groups = groupRows(pool(), REGISTRY); + const preview = previewDelete(new Set(["a1"]), groups, pool(), faces); + assert.equal(preview.usedInFaces, 0); + assert.deepEqual(preview.facesAffected, []); +}); + test("bulk visibility is not offered, and the refusal has words", () => { // `attribute/put` is a replace and this console does not hold the values it // masks, so a bulk visibility change would blank every sensitive attribute in From 76d03f6f72fb2d86054fd7e7f75cbe221a026570 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 9 Sep 2026 07:56:00 +0200 Subject: [PATCH 3/3] feat(manager): a list view over the pool, with multi-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map's subject is reach — what one selection touches — so it draws one selection at a time. That is the right picture and the wrong tool for the job a holder arrives with once the pool is large: delete these four stale numbers, put these six into a new face. On the map that is opening, reading and confirming one card at a time, and no amount of layout fixes it, because one-at-a-time is what the map is for. So: a second view over the same graph, behind a toggle. Same `AttributeNode`s, same families, same words, no new wire records, and a selection that is a set. Rows group under the family headings the map already uses, headings carry a tri-state checkbox, shift-click takes the range that was on screen, and the bulk bar offers exactly two actions. **`attribute-words.ts` is new and is an extraction, not an addition.** `provenanceWords`, `labelSaysSomethingElse` and `staleWords` were private to `persona-map.tsx` while the map was the only surface drawing an attribute. A second copy is how the map comes to say *you said so* beside a row the list calls *credential · storm.ws* — the vocabulary defect in the channel a person is most likely to act on. **Bulk visibility is not offered and the screen says why.** `attribute/put` is a replace and this pane lists without `includeSensitive`, so a bulk visibility write would blank every sensitive value in the selection. **Delete asks the second question first.** The preview counts what faces still reference, names them, and `cascade` rides on `Destructive`'s existing `force` tick. `AttributeEditor` is keyed on the record it edits, which `manager-form-state.test.mts` caught: it seeds `useState` from `existing`, so an unkeyed second open shows the first attribute's value — and on a put, writes it over the second's. Signed-off-by: Glenn Gore --- .../extension/src/manager/attribute-words.ts | 86 ++++ .../src/manager/panes/persona-list.tsx | 457 ++++++++++++++++++ .../src/manager/panes/persona-map.tsx | 52 +- .../extension/src/manager/panes/persona.tsx | 95 +++- packages/extension/tests/harness/dom.mjs | 13 + .../tests/persona-list.render.test.mts | 185 +++++++ 6 files changed, 836 insertions(+), 52 deletions(-) create mode 100644 packages/extension/src/manager/attribute-words.ts create mode 100644 packages/extension/src/manager/panes/persona-list.tsx create mode 100644 packages/extension/tests/persona-list.render.test.mts diff --git a/packages/extension/src/manager/attribute-words.ts b/packages/extension/src/manager/attribute-words.ts new file mode 100644 index 0000000..5ad9c14 --- /dev/null +++ b/packages/extension/src/manager/attribute-words.ts @@ -0,0 +1,86 @@ +// The words an attribute is described by, wherever it is drawn. +// +// These were private to `persona-map.tsx` while the map was the only surface +// that drew an attribute. The list view draws the same attributes in a +// different shape, and a second copy of `provenanceWords` is how the map comes +// to say *you said so* beside a row the list calls *credential · storm.ws* — +// the same defect as two groupings of one pool that disagree, in the channel a +// person is most likely to act on. +// +// So they moved down rather than being copied across, and they take no props +// and touch no DOM: every one of them is a claim about an attribute, testable +// as a string. The rule they all follow is `design-docs/persona-vocabulary.md` +// — *you said so*, *credential · ‹issuer›*, *made per verifier*, *stale · +// ‹reason›* — which is the other reason to have exactly one of each. A word +// retired from that table has to be findable, and it is findable here. + +import { splitDid } from "../did-display.js"; +import type { AttributeNode } from "./identity-graph.js"; + +/** + * What provenance says on screen, and the tone it says it in. + * + * Both halves of the credential case, every time: *provable* and *the same + * signature to everyone who sees it*. The second is the one people miss, and + * the vocabulary table calls it out for that reason — a credential is the + * strongest thing an attribute can be and the most linkable, and a surface that + * prints only the first half is selling one without the other. + */ +export function provenanceWords(p: AttributeNode["provenance"]): { + text: string; + tone: "off" | "accent" | "ok"; +} { + switch (p.kind) { + case "credentialBacked": { + const issuer = p.issuerDid ? issuerLabel(p.issuerDid) : null; + return { text: issuer ? `credential · ${issuer}` : "credential", tone: "accent" }; + } + case "generated": + return { text: p.perVerifier ? "made per verifier" : "generated", tone: "ok" }; + default: + return { text: "you said so", tone: "off" }; + } +} + +export function issuerLabel(did: string): string { + const host = splitDid(did).find((part) => part.role === "host")?.text; + return host ?? did.slice(0, 18) + "…"; +} + +/** + * Whether the holder's own label is telling the reader anything the value does + * not already say. + * + * A label is a note to self — "work mobile", "the flat" — and it earns its + * place beside the value. When it *is* the value it earns nothing: a `company` + * attribute labelled "Affinidi" holding "Affinidi" drew **Affinidi · Affinidi**, + * which reads as a stutter and, worse, as two facts. + * + * Compared case- and space-insensitively, because "affinidi" beside "Affinidi" + * is the same stutter with a different shift key. Only a string value is + * compared: a JSON object rendered beside a label never repeats it, and + * stringifying one here to find out would be work in aid of a case that cannot + * arise. + */ +export function labelSaysSomethingElse(label: string | undefined, value: unknown): boolean { + if (!label) return false; + if (typeof value !== "string") return true; + return label.trim().toLowerCase() !== value.trim().toLowerCase(); +} + +/** + * *stale · ‹reason›* — shown, never hidden. + * + * A pool that looks smaller than it is would leave the holder unaware that + * something they believe they hold can no longer be proven. + */ +export function staleWords(reason: string | undefined): string { + switch (reason) { + case "expired": + return "stale · expired"; + case "revoked": + return "stale · revoked"; + default: + return "stale"; + } +} diff --git a/packages/extension/src/manager/panes/persona-list.tsx b/packages/extension/src/manager/panes/persona-list.tsx new file mode 100644 index 0000000..6eeecc7 --- /dev/null +++ b/packages/extension/src/manager/panes/persona-list.tsx @@ -0,0 +1,457 @@ +// The list view: the same attributes as the map, in the shape you tidy in. +// +// ## Why this exists beside the map +// +// The map's subject is reach — what one selection touches — so it draws one +// selection at a time and answers a question about it. That is the right +// picture and the wrong tool for the job a holder actually arrives with once +// the pool is large: *delete these four stale numbers, put these six into a new +// face*. Doing that on the map is opening, reading and confirming one card at a +// time, and no amount of layout fixes it, because one-at-a-time is what the map +// is for. +// +// So this is a second view over the same graph — same `AttributeNode`s, same +// families, same words (`attribute-words.ts`), no new wire records — with a +// selection that is a *set*. Everything interesting about it is in +// `attribute-list.ts` and tested there; this file draws. +// +// ## What it will not do +// +// There is no bulk visibility action, and its absence is deliberate enough to +// be printed on the screen. `persona/attribute/put` is a replace, and this pane +// lists without `includeSensitive` on purpose, so an attribute resolving to +// `sensitivity: high` is in hand with `value: undefined`. Writing a bulk +// visibility change through `put` would send an empty value for every sensitive +// row and blank it — silently, with no `attribute/get` and no version history +// to restore from. `whyNoBulkVisibility()` says so where someone would +// otherwise wonder why the button is missing. +// +// ## Delete asks the second question first +// +// The agent refuses to delete an attribute a face still references unless +// `cascade` is set. Sending twelve and discovering five of them are in use is +// five refusals after seven irreversible successes, so the preview counts them +// before anything is sent and `cascade` rides on `Destructive`'s `force` tick — +// which is exactly what that tick is for: overriding a refusal the agent makes +// on purpose is its own decision. + +import { useCallback, useMemo, useState } from "react"; +import { personaAttributeDelete, personaProfilePut } from "@openvtc/pnm-core/admin"; +import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; +import { Button, Note, Pill } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { Destructive } from "../destructive.js"; +import type { Parties } from "../use-vta.js"; +import type { AttributeNode, FaceNode } from "../identity-graph.js"; +import { provenanceWords, labelSaysSomethingElse, staleWords } from "../attribute-words.js"; +import { + groupRows, + flatOrder, + toggle, + selectRange, + groupState, + toggleGroup, + pruneSelection, + selectedRows, + previewDelete, + whyNoBulkVisibility, + type ListGroup, + type DeletePreview, +} from "../attribute-list.js"; +import { AttributeValue } from "./persona-editors.js"; +import type { RevealTarget } from "../reveal-value.js"; + +/** + * A checkbox with a third state. + * + * `indeterminate` is a DOM property and not an attribute, so React cannot set + * it from JSX — it has to go through a ref callback. Without it a partly + * selected family draws an empty box, which says "none of these are selected" + * over four rows that are. + */ +function TriCheck({ + state, + onChange, + title, +}: { + state: "none" | "some" | "all"; + onChange: () => void; + title: string; +}) { + return ( + { + if (el) el.indeterminate = state === "some"; + }} + onChange={onChange} + style={{ cursor: "pointer", accentColor: c.accent, width: 15, height: 15, flex: "0 0 auto" }} + /> + ); +} + +function Row({ + attribute, + selected, + registry, + reveal, + onToggle, + onEdit, +}: { + attribute: AttributeNode; + selected: boolean; + registry: ClaimTypeRegistry | null; + reveal: (target: RevealTarget) => Promise; + onToggle: (id: string, shift: boolean) => void; + onEdit: (attribute: AttributeNode) => void; +}) { + const prov = provenanceWords(attribute.provenance); + const showLabel = labelSaysSomethingElse(attribute.label, attribute.value); + return ( +
+ onToggle(attribute.id, e.shiftKey)} + onChange={() => {}} + style={{ cursor: "pointer", accentColor: c.accent, width: 15, height: 15, flex: "0 0 auto" }} + /> + + {attribute.type} + +
+ {/* Never formatted here. A value masked on the card and printed in a + list is masked nowhere. */} + reveal({ attributeId: attribute.id, type: attribute.type })} + textStyle={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} + /> + {showLabel && ( + + {attribute.label} + + )} +
+ + {prov.text} + + {attribute.stale && {staleWords(attribute.staleReason)}} + +
+ ); +} + +function Group({ + group, + selection, + registry, + reveal, + onToggleRow, + onToggleGroup, + onEdit, +}: { + group: ListGroup; + selection: ReadonlySet; + registry: ClaimTypeRegistry | null; + reveal: (target: RevealTarget) => Promise; + onToggleRow: (id: string, shift: boolean) => void; + onToggleGroup: (group: ListGroup) => void; + onEdit: (attribute: AttributeNode) => void; +}) { + const state = groupState(selection, group); + return ( +
+
+ onToggleGroup(group)} + title={`Select everything under ${group.style.label}`} + /> + {/* The family hue is a 3px stripe and nothing else — never a border + (selection owns that) and never a pill (status owns that). */} + +
+
{group.style.label}
+
{group.style.note}
+
+ {group.rows.length} +
+ {group.rows.map((row) => ( + + ))} +
+ ); +} + +/** Add every selected attribute to an existing face, as a live reference. */ +function AddToFace({ + faces, + rows, + parties, + onDone, +}: { + faces: readonly FaceNode[]; + rows: readonly AttributeNode[]; + parties: Parties; + onDone: () => void; +}) { + const [faceId, setFaceId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const face = faces.find((f) => f.id === faceId); + + const run = useCallback(async () => { + if (!face) return; + setBusy(true); + setError(null); + try { + // A face is a whitelist and `profile/put` is a replace, so the existing + // entries are sent back with the additions appended. Dropping them would + // silently empty the face — the same replace hazard the attribute editor + // guards, one record up. + const have = new Set( + face.entries.flatMap((e) => + typeof e === "object" && e !== null && "ref" in e ? [(e as { ref: string }).ref] : [], + ), + ); + const additions = rows.filter((r) => !have.has(r.id)).map((r) => ({ ref: r.id })); + if (additions.length === 0) { + setError("Every one of those is already in that face."); + return; + } + await personaProfilePut(managerSender, { + ...parties, + profileId: face.id, + name: face.name, + entries: [...face.entries, ...additions], + expectedVersion: face.version, + }); + setFaceId(""); + onDone(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }, [face, rows, parties, onDone]); + + return ( +
+ + + {error && {error}} +
+ ); +} + +export function AttributeList({ + attributes, + faces, + registry, + parties, + reveal, + onChanged, + onEdit, +}: { + attributes: readonly AttributeNode[]; + faces: readonly FaceNode[]; + registry: ClaimTypeRegistry | null; + parties: Parties; + reveal: (target: RevealTarget) => Promise; + /** Refetch — a delete or a face edit changes what every other view draws. */ + onChanged: () => void; + onEdit: (attribute: AttributeNode) => void; +}) { + const [selection, setSelection] = useState>(new Set()); + const [anchor, setAnchor] = useState(null); + + const groups = useMemo(() => groupRows(attributes, registry), [attributes, registry]); + const order = useMemo(() => flatOrder(groups), [groups]); + + // A delete removes rows from under the selection. Without this the bulk bar + // keeps counting attributes that are gone and the next action sends their ids + // to be refused one at a time. + const live = useMemo(() => pruneSelection(selection, attributes), [selection, attributes]); + const rows = useMemo(() => selectedRows(live, groups), [live, groups]); + + const onToggleRow = useCallback( + (id: string, shift: boolean) => { + setSelection((current) => + shift ? selectRange(current, order, anchor, id) : toggle(current, id), + ); + setAnchor(id); + }, + [order, anchor], + ); + + const onToggleGroup = useCallback((group: ListGroup) => { + setSelection((current) => toggleGroup(current, group)); + setAnchor(null); + }, []); + + const clear = useCallback(() => { + setSelection(new Set()); + setAnchor(null); + }, []); + + const afterWrite = useCallback(() => { + clear(); + onChanged(); + }, [clear, onChanged]); + + const commitDelete = useCallback( + async (cascade: boolean) => { + // Sequential, and it stops at the first refusal rather than pressing on. + // Deleting is irreversible: a loop that swallowed one error and carried + // on would leave the holder with a partial result and one message + // describing neither what went nor what stayed. + for (const row of rows) { + await personaAttributeDelete(managerSender, { + ...parties, + attributeId: row.id, + ...(cascade ? { cascade: true } : {}), + }); + } + }, + [rows, parties], + ); + + if (attributes.length === 0) return null; + + return ( +
+
+ + {live.size > 0 ? `${live.size} selected` : `${attributes.length} attributes`} + + {live.size > 0 && ( + <> + + + + label={`Delete ${live.size}`} + preview={async () => previewDelete(live, groups, attributes, faces)} + needsForce={(p) => p.usedInFaces > 0} + forceLabel="Also remove them from the faces listed above" + renderPreview={(p) => ( +
+
+ {p.count} attribute{p.count === 1 ? "" : "s"} across {p.types.length} type + {p.types.length === 1 ? "" : "s"}. +
+ {p.lastOfType > 0 && ( +
+ {p.lastOfType} would be the last of {p.lastOfType === 1 ? "its" : "their"} kind + you hold — any face showing {p.lastOfType === 1 ? "it" : "them"} stops + presenting {p.lastOfType === 1 ? "it" : "them"}. +
+ )} + {p.credentialBacked > 0 && ( +
+ {p.credentialBacked} {p.credentialBacked === 1 ? "is" : "are"} backed by a + credential — deleting {p.credentialBacked === 1 ? "it" : "them"} here does not + touch the credential, but the link to it is gone. +
+ )} + {p.usedInFaces > 0 && ( +
+ {p.usedInFaces} {p.usedInFaces === 1 ? "is" : "are"} still shown by{" "} + {p.facesAffected.join(", ")}. Your agent refuses to delete{" "} + {p.usedInFaces === 1 ? "it" : "them"} while that is true. +
+ )} +
+ Nothing already shared is affected — that has left. +
+
+ )} + commit={commitDelete} + onDone={afterWrite} + /> + + )} +
+ + {live.size > 0 && ( +
+ {whyNoBulkVisibility()} +
+ )} + + {groups.map((group) => ( + + ))} +
+ ); +} diff --git a/packages/extension/src/manager/panes/persona-map.tsx b/packages/extension/src/manager/panes/persona-map.tsx index e5c0e0a..c960e57 100644 --- a/packages/extension/src/manager/panes/persona-map.tsx +++ b/packages/extension/src/manager/panes/persona-map.tsx @@ -51,6 +51,7 @@ import { } from "../identity-graph.js"; import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona"; import { familyOf, familyStyle, FAMILY_ORDER, type Family } from "../attribute-family.js"; +import { provenanceWords, labelSaysSomethingElse, staleWords } from "../attribute-words.js"; import { unappliedClaimTypes } from "@openvtc/pnm-core/persona"; import { AttributeEditor, @@ -67,25 +68,6 @@ import type { RevealTarget } from "../reveal-value.js"; // ── Words for what the agent knows ────────────────────────────────────────── -/** Provenance as a trust level in plain words — `design-docs/persona-vocabulary.md`. */ -function provenanceWords(p: AttributeNode["provenance"]): { text: string; tone: "off" | "accent" | "ok" } { - switch (p.kind) { - case "credentialBacked": { - const issuer = p.issuerDid ? issuerLabel(p.issuerDid) : null; - return { text: issuer ? `credential · ${issuer}` : "credential", tone: "accent" }; - } - case "generated": - return { text: p.perVerifier ? "made per verifier" : "generated", tone: "ok" }; - default: - return { text: "you said so", tone: "off" }; - } -} - -function issuerLabel(did: string): string { - const host = splitDid(did).find((part) => part.role === "host")?.text; - return host ?? did.slice(0, 18) + "…"; -} - /** * How a persona is labelled on a card. * @@ -122,38 +104,6 @@ function standingWords(tally: ContextTally): string { return parts.join(" · "); } -/** - * Whether the holder's own label is telling the reader anything the value does - * not already say. - * - * A label is a note to self — "work mobile", "the flat" — and it earns its - * place beside the value. When it *is* the value it earns nothing: a `company` - * attribute labelled "Affinidi" holding "Affinidi" drew **Affinidi · Affinidi**, - * which reads as a stutter and, worse, as two facts. - * - * Compared case- and space-insensitively, because "affinidi" beside "Affinidi" - * is the same stutter with a different shift key. Only a string value is - * compared: a JSON object rendered beside a label never repeats it, and - * stringifying one here to find out would be work in aid of a case that cannot - * arise. - */ -function labelSaysSomethingElse(label: string | undefined, value: unknown): boolean { - if (!label) return false; - if (typeof value !== "string") return true; - return label.trim().toLowerCase() !== value.trim().toLowerCase(); -} - -function staleWords(reason: string | undefined): string { - switch (reason) { - case "expired": - return "stale · expired"; - case "revoked": - return "stale · revoked"; - default: - return "stale"; - } -} - // ── Measuring cards so edges can be drawn between them ────────────────────── interface Box { diff --git a/packages/extension/src/manager/panes/persona.tsx b/packages/extension/src/manager/panes/persona.tsx index 7f94337..bd5722b 100644 --- a/packages/extension/src/manager/panes/persona.tsx +++ b/packages/extension/src/manager/panes/persona.tsx @@ -64,10 +64,11 @@ import { buildGraph, type ContextInput } from "../identity-graph.js"; // the rest of this pane imports. import { listClaimTypes, unappliedClaimTypes } from "@openvtc/pnm-core/persona"; import { IdentityMap } from "./persona-map.js"; +import { AttributeList } from "./persona-list.js"; import { GuidedSetup } from "./persona-setup.js"; import { showsGuide } from "../persona-flow.js"; import { revealAttributeValue, type RevealTarget } from "../reveal-value.js"; -import { DisclosureHistoryPanel } from "./persona-editors.js"; +import { AttributeEditor, DisclosureHistoryPanel } from "./persona-editors.js"; /** * Who is known in each context, with the face they wear resolved to an id. @@ -115,6 +116,47 @@ async function loadContexts(parties: Parties, records: ContextRecord[]): Promise ); } +/** + * Map or list, over the same model. + * + * Two words and nothing else. It is not a settings control and must not read + * as one: the views answer different questions rather than showing more or less + * of the same answer, so neither is a "detail level" and neither is default in + * a way the other has to argue with. + */ +function ViewToggle({ view, onView }: { view: "map" | "list"; onView: (v: "map" | "list") => void }) { + const item = (v: "map" | "list", label: string) => ( + + ); + return ( +
+ {item("map", "Map")} + {item("list", "List")} + + {view === "map" + ? "Select anything to see where it reaches." + : "Tick several to delete them or add them to a face."} + +
+ ); +} + export function PersonaPane({ parties, authority, @@ -198,11 +240,28 @@ export function PersonaPane({ const [guiding, setGuiding] = useState(false); const [skipped, setSkipped] = useState(false); const [banner, setBanner] = useState(null); + /** + * Which view of the same graph is on screen. + * + * Two views, one model — the map answers "what reaches what", the list is the + * shape you tidy in. Session state rather than stored: a person who came here + * to delete four numbers wants the list *now*, and would not thank a console + * that remembered that choice a week later when they came to look at reach. + */ + const [view, setView] = useState<"map" | "list">("map"); + /** The attribute the list asked to edit, by id. Held as an id rather than a + * record so a reload cannot leave the editor holding a stale copy. */ + const [editingId, setEditingId] = useState(null); const showGuide = showsGuide({ faces: profiles.data?.length ?? null, guiding, skipped }); useEffect(() => { if (showGuide) setGuiding(true); }, [showGuide]); + const editing = useMemo( + () => (attributes.data ?? []).find((a) => a.attributeId === editingId), + [attributes.data, editingId], + ); + const graph = useMemo( () => buildGraph(attributes.data ?? [], profiles.data ?? [], contexts.data ?? []), [attributes.data, profiles.data, contexts.data], @@ -295,6 +354,39 @@ export function PersonaPane({ decided for yourself still stands. )} + + {view === "list" ? ( + editing ? ( + { + setEditingId(null); + reloadAll(); + }} + onCancel={() => setEditingId(null)} + /> + ) : ( + setEditingId(a.id)} + /> + ) + ) : ( + )} ); diff --git a/packages/extension/tests/harness/dom.mjs b/packages/extension/tests/harness/dom.mjs index 5ddc4cc..28a6c8b 100644 --- a/packages/extension/tests/harness/dom.mjs +++ b/packages/extension/tests/harness/dom.mjs @@ -180,6 +180,19 @@ export async function render(element, { chrome: chromeStub } = {}) { el.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); }); }, + /** + * Click holding a modifier — shift-range selection and nothing else so far. + * + * Its own helper rather than an argument to `click` because it must go + * through `act` like every other event: a raw `dispatchEvent` from a test + * updates React outside the batch, which warns, and settles in a different + * order than the browser would. + */ + clickWith: async (el, init) => { + await act(async () => { + el.dispatchEvent(new window.MouseEvent("click", { bubbles: true, ...init })); + }); + }, /** * Type into an input or textarea, the way React hears it. * diff --git a/packages/extension/tests/persona-list.render.test.mts b/packages/extension/tests/persona-list.render.test.mts new file mode 100644 index 0000000..d486e99 --- /dev/null +++ b/packages/extension/tests/persona-list.render.test.mts @@ -0,0 +1,185 @@ +// The list view, rendered. +// +// The model under this screen (`attribute-list.ts`) is tested on its own and +// passes for every case here. These are the ones it cannot see: a third +// checkbox state that is a DOM property React will not set from JSX, a bulk bar +// that counts rows the agent has deleted, a delete that fires before anyone is +// told which faces stop presenting a value. Each is a thing a person would +// notice immediately and a type checker never will. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { agent, h, render, PARTIES } from "./harness/dom.mjs"; +import { AttributeList } from "../src/manager/panes/persona-list.js"; +import { buildGraph } from "../src/manager/identity-graph.js"; + +const attribute = (id: string, type: string, value: string) => ({ + attributeId: id, + type, + valueType: "string" as const, + value, + provenance: { kind: "selfAsserted" as const }, + version: 1, + updatedAt: "2026-09-07T10:00:00Z", +}); +const face = (id: string, name: string, refs: string[]) => ({ + profileId: id, + name, + entries: refs.map((ref) => ({ ref })), + version: 1, + updatedAt: "2026-09-07T10:00:00Z", +}); + +const REGISTRY = { + registryVersion: "0.1", + entries: [ + "name", "name.given", "name.family", "email", "email.personal", "phone", "phone.mobile", + ].map((type) => ({ type, sensitivity: "normal", release: "consent", mask: "none" })), + unregistered: { sensitivity: "high", release: "consent", mask: "full" }, + strictness: { + sensitivity: ["high", "normal"], + release: ["stepUp", "consent"], + mask: ["full", "last2", "last4", "emailLocal", "none"], + }, +} as never; + +function graphOf(faces: ReturnType[] = []) { + return buildGraph( + [ + attribute("a1", "name.given", "Ada"), + attribute("a2", "name.family", "Lovelace"), + attribute("a3", "email.personal", "ada@example.com"), + attribute("a4", "phone.mobile", "+61 400 000 000"), + ] as never, + faces as never, + [], + ); +} + +function mount(graph: ReturnType, extra: Record = {}) { + return h(AttributeList, { + attributes: graph.attributes, + faces: graph.faces, + registry: REGISTRY, + parties: PARTIES, + reveal: async () => ({}), + onChanged: () => {}, + onEdit: () => {}, + ...extra, + }); +} + +test("every attribute is on screen under its family heading", async () => { + const screen = await render(mount(graphOf())); + const text = screen.text(); + assert.match(text, /Who you are/, "the identity family heading is missing"); + assert.match(text, /How to reach you/, "the contact family heading is missing"); + assert.match(text, /name\.given/); + assert.match(text, /phone\.mobile/); + assert.match(text, /4 attributes/, "the pane did not say how many it holds"); + await screen.unmount(); +}); + +test("ticking a family heading selects everything under it", async () => { + const screen = await render(mount(graphOf())); + const boxes = screen.all('input[type="checkbox"]'); + // The first box in the first group is its heading. + await screen.check(boxes[0]!); + // `name.given` and `name.family` are both "Who you are". + assert.match(screen.text(), /2 selected/, "a heading tick did not select the rows under it"); + await screen.unmount(); +}); + +test("a partly selected heading draws the third state, not an empty box", async () => { + // `indeterminate` is a DOM property, not an attribute, so React cannot set it + // from JSX — it needs the ref callback. Without it a heading over four + // selected rows draws an empty box, which says none of them are. + const screen = await render(mount(graphOf())); + const boxes = screen.all('input[type="checkbox"]'); + const heading = boxes[0]!; + // Tick one row inside the first group, not the heading. + await screen.check(boxes[1]!); + assert.match(screen.text(), /1 selected/); + assert.equal( + (heading as unknown as { indeterminate: boolean }).indeterminate, + true, + "a partly selected family drew an empty checkbox", + ); + assert.equal(heading.checked, false, "a partly selected family drew a full tick"); + await screen.unmount(); +}); + +test("a shift-click selects the range that was on screen", async () => { + const screen = await render(mount(graphOf())); + // By aria-label, not by index: the checkbox list interleaves group headings + // with rows, and an index that lands on a heading toggles a whole family + // while the test believes it clicked one row. + const row = (type: string) => screen.all(`input[aria-label="${type}"]`)[0]!; + await screen.check(row("name.given")); + // Shift-click a row two below it, across a family boundary. Grouping + // reorders, so this must select what the person saw between them rather than + // what the pool order would give. + await screen.clickWith(row("email.personal"), { shiftKey: true }); + assert.match(screen.text(), /3 selected/, "a shift-click did not select the range on screen"); + await screen.unmount(); +}); + +test("nothing is offered in bulk that would write a value", async () => { + // The console lists without `includeSensitive`, so a bulk visibility change + // would blank every sensitive attribute in the selection. The absence has to + // be explained where someone would otherwise wonder. + const screen = await render(mount(graphOf())); + await screen.check(screen.all('input[type="checkbox"]')[1]!); + assert.match(screen.text(), /one attribute at a time/, "the missing action was not explained"); + const labels = screen.all("button").map((b) => (b.textContent ?? "").toLowerCase()); + assert.ok( + !labels.some((l) => l.includes("visib") || l.includes("hide") || l.includes("show all")), + `a bulk value-writing action was offered: ${labels.join(", ")}`, + ); + await screen.unmount(); +}); + +test("the delete preview names the faces that would stop presenting a value", async () => { + // The agent refuses while a face still names the attribute. A holder who is + // not told that presses Delete, watches half of it succeed, and gets the rest + // back as refusals one at a time. + const screen = await render(mount(graphOf([face("f1", "Work", ["a1", "a3"])]))); + await screen.check(screen.all('input[type="checkbox"]')[1]!); // a1 + await screen.click(screen.button("Delete 1")); + await screen.settle(); + const text = screen.text(); + assert.match(text, /still shown by Work/, "the face that would lose an entry was not named"); + assert.match(text, /Nothing already shared is affected/, "the vocabulary line is missing"); + await screen.unmount(); +}); + +test("a delete of something no face uses does not ask the second question", async () => { + const screen = await render(mount(graphOf([face("f1", "Work", ["a1"])]))); + const boxes = screen.all('input[type="checkbox"]'); + await screen.check(boxes[2]!); // a2 — in no face + await screen.click(screen.button("Delete 1")); + await screen.settle(); + assert.doesNotMatch(screen.text(), /still shown by/, "a face was named that loses nothing"); + await screen.unmount(); +}); + +test("deleting sends cascade only when it was ticked", async () => { + const fake = agent({ "persona/attribute/delete/1.0": { existed: true } }); + const screen = await render(mount(graphOf([face("f1", "Work", ["a1"])])), { + chrome: { runtime: { sendMessage: fake.sendMessage } }, + }); + const boxes = screen.all('input[type="checkbox"]'); + await screen.check(boxes[2]!); // a2 — used by no face, so no force tick + await screen.click(screen.button("Delete 1")); + await screen.settle(); + await screen.click(screen.button("Delete")); + await screen.settle(); + const sent = fake.of("attribute/delete"); + assert.equal(sent.length, 1, "the delete did not reach the agent"); + assert.equal( + sent[0]!.payload.cascade, + undefined, + "cascade was sent for an attribute no face references", + ); + await screen.unmount(); +});