diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48010d7..ece3830 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,7 +162,7 @@ jobs: # file" structural; the assertion below fails if it is ever lost. - name: Assert the holder-scoped half of persona is confined to the console run: | - for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do + for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true) if [ -n "$leaked" ]; then echo "::error::$leaked contains $task — this half of persona/* reads or writes the holder's attribute pool, which sits above every trust context. A wallet surface naming it is the boundary being crossed the wrong way; only the console may. Check for an import of @openvtc/pnm-core/admin from a wallet entry, or a shared chunk. See packages/core/src/admin/persona.ts." @@ -188,13 +188,13 @@ jobs: - name: Assert the console still carries the persona surface it is the exception for run: | bundle=packages/extension/dist/manager.js - for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do + for task in 'persona/attribute/put/1.0' 'persona/attribute/list/1.0' 'persona/attribute/delete/1.0' 'persona/profile/put/1.0' 'persona/profile/get/1.0' 'persona/profile/list/1.0' 'persona/profile/delete/1.0' 'persona/binding/set/1.0' 'persona/facet/put/1.0' 'persona/facet/list/1.0' 'persona/facet/delete/1.0' 'persona/correlation/analyze/1.0' 'persona/disclosure/history/1.0'; do if ! grep -qF "$task" "$bundle"; then echo "::error::$bundle no longer contains $task. The guard above permits it here precisely because the console's persona pane calls it; a build without it is a pane that cannot do what it offers. If the pane genuinely dropped this task, remove it from BOTH lists in the same change." exit 1 fi done - echo "OK: the console carries all ten holder-scoped persona tasks" + echo "OK: the console carries all thirteen holder-scoped persona tasks" # The console's isolation rests on it being one self-contained file: the # guard above names exactly one exception, so a second chunk would be a diff --git a/package-lock.json b/package-lock.json index 2f37eef..8f26966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2304,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.17.8", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.17.8.tgz", - "integrity": "sha512-fFI618HFl1m9S2VA5ZPK21YtFURJ0yczv1eTP7VdLx9xCoXI4YrBd6JVDkAYUmQwbBd0EED6df4lz2zTTqe3Mg==", + "version": "0.17.9", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.17.9.tgz", + "integrity": "sha512-iC/U3ojaE2PpXdDcdagCOimcYYvKdlXqP9idg8YYqzkvtt2SksURclM4PT4SHwaY2swt2sKFfckijmddOVoYOg==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -8034,7 +8034,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.17.8", + "@openvtc/trust-tasks": "^0.17.9", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/packages/core/package.json b/packages/core/package.json index 7b1221a..d7baaf4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openvtc/pnm-core", "version": "0.9.1", - "description": "Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Wire types, WebAuthn ceremony helpers, COSE\u2192Multikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, SIOP / RP-login / provision-integration flows.", + "description": "Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Wire types, WebAuthn ceremony helpers, COSE→Multikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, SIOP / RP-login / provision-integration flows.", "license": "Apache-2.0", "repository": { "type": "git", @@ -130,7 +130,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.17.8", + "@openvtc/trust-tasks": "^0.17.9", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/packages/core/src/admin/persona.ts b/packages/core/src/admin/persona.ts index 0681625..cc6c5bc 100644 --- a/packages/core/src/admin/persona.ts +++ b/packages/core/src/admin/persona.ts @@ -7,7 +7,7 @@ // is deliberately incomplete: a wallet's holder identity is scoped to a // context, so every task in this file would come back `e.p.msg.forbidden` if a // wallet surface called it. That module says so in its own header, and CI greps -// the built extension bundles for the ten task URIs below to keep the statement +// the built extension bundles for the thirteen task URIs below to keep the statement // true rather than merely written down. // // So this module is the other half, and it lives beside the console's other @@ -19,7 +19,7 @@ // // ## The gate // -// The agent refuses all ten unless the caller is an **unscoped holder** — +// The agent refuses all thirteen unless the caller is an **unscoped holder** — // `Admin` *and* unrestricted scope (`require_super_admin`, not `role == // Admin`). That distinction is the whole design: an administrator scoped to one // context who could read the pool would be reading identity data belonging to @@ -87,6 +87,25 @@ import { type PersonaBindingSetPayload, type PersonaBindingSetResponsePayload, } from "@openvtc/trust-tasks/persona/binding/set/1.0/payload"; +import { + TYPE_URI as FACET_PUT, + RESPONSE_TYPE_URI as FACET_PUT_RESPONSE, + type PersonaFacetPutPayload, + type PersonaFacetPutResponsePayload, + type FacetColour, +} from "@openvtc/trust-tasks/persona/facet/put/1.0/payload"; +import { + TYPE_URI as FACET_LIST, + RESPONSE_TYPE_URI as FACET_LIST_RESPONSE, + type PersonaFacetListPayload, + type PersonaFacetListResponsePayload, +} from "@openvtc/trust-tasks/persona/facet/list/1.0/payload"; +import { + TYPE_URI as FACET_DELETE, + RESPONSE_TYPE_URI as FACET_DELETE_RESPONSE, + type PersonaFacetDeletePayload, + type PersonaFacetDeleteResponsePayload, +} from "@openvtc/trust-tasks/persona/facet/delete/1.0/payload"; import { TYPE_URI as CORRELATION_ANALYZE, RESPONSE_TYPE_URI as CORRELATION_ANALYZE_RESPONSE, @@ -134,6 +153,19 @@ export type AttributeSensitivity = NonNullable; +/** One named part of the holder's life, and what belongs to it. */ +export type PoolFacet = PersonaFacetListResponsePayload["facets"][number]; +/** + * The eight colour **names**. Never a literal — each surface resolves one + * against its own palette, so the same world is legible in a terminal, a light + * theme and a dark one. + * + * Re-exported from the generated bindings rather than restated: a hand-written + * copy of an enum drifts the moment a ninth colour is published, and nothing + * compares the two. + */ +export type { FacetColour }; + /** One place the holder's identities link, and what can be done about it. */ export type CorrelationFinding = PersonaCorrelationAnalyzeResponsePayload["findings"][number]; /** One record of something that left, and to whom. */ @@ -704,3 +736,133 @@ export async function personaDisclosureHistory( payload, ); } + +// ── Facets: the holder's arrangement of their own identity ────────────────── +// +// On screen these are **worlds** — see `design-docs/persona-vocabulary.md`. The +// wire keeps the specification's word, exactly as `profile`/face does. +// +// Membership lives on the facet rather than on the records it names, and that +// is not a filing decision. `persona/attribute/put` REPLACES the attribute, and +// this console lists without `includeSensitive` on purpose — so a client that +// arranged by writing to attributes would either have to fetch every sensitive +// value the holder owns to perform an arrangement that has nothing to do with +// values, or send a put without one and destroy them. One record here has +// neither problem. + +export interface FacetPutParams extends PersonaHolderParams { + /** Omit to create. Supplying one addresses an existing facet. */ + facetId?: string; + name: string; + colour: FacetColour; + /** One or two emoji. Decorative, carries no meaning, and a surface that + * cannot render it shows the name. */ + icon?: string; + /** + * Profiles belonging to this facet. + * + * **Replaced, not merged.** Omitting it means an empty list — a member whose + * absence meant "keep" would make it impossible to empty one. A caller + * editing a facet sends back the membership it loaded, the same discipline + * `personaProfilePut` needs for its entries. + */ + faceIds?: string[]; + /** Attributes belonging to this facet, with the same replace semantics. An + * attribute may belong to several facets; a face may not. */ + attributeIds?: string[]; + expectedVersion?: number; +} + +/** + * Create or replace one facet. + * + * Refused with `persona/facet/put:faceAlreadyPlaced` when a listed face belongs + * to another facet — the error's `details.placed` names the facet already + * holding it, so a caller can offer to move it rather than send the holder + * looking. + */ +export async function personaFacetPut( + sender: TrustTaskSender, + params: FacetPutParams, +): Promise { + const payload: PersonaFacetPutPayload = { + ...(params.facetId !== undefined ? { facetId: params.facetId } : {}), + name: params.name, + colour: params.colour, + ...(params.icon !== undefined ? { icon: params.icon } : {}), + ...(params.faceIds !== undefined ? { faceIds: params.faceIds } : {}), + ...(params.attributeIds !== undefined ? { attributeIds: params.attributeIds } : {}), + ...(params.expectedVersion !== undefined ? { expectedVersion: params.expectedVersion } : {}), + }; + return holderCall( + sender, + params, + FACET_PUT, + FACET_PUT_RESPONSE, + "persona/facet/put/1.0", + payload, + ); +} + +/** + * Every facet, following the cursor to the end. + * + * `limit` is the page size to ask for and never a cap on the result — the same + * rule the other three listings in this module follow, and for the same reason: + * a short page is indistinguishable from a complete one, so only an absent + * `nextCursor` means the end. + */ +export async function personaFacetList( + sender: TrustTaskSender, + params: PersonaHolderParams & { limit?: PersonaFacetListPayload["limit"]; cursor?: string }, +): Promise { + const payload: PersonaFacetListPayload = { + ...(params.limit !== undefined ? { limit: params.limit } : {}), + ...(params.cursor !== undefined ? { cursor: params.cursor } : {}), + }; + return collectPages("persona/facet/list", async (cursor) => { + const res = await holderCall( + sender, + params, + FACET_LIST, + FACET_LIST_RESPONSE, + "persona/facet/list/1.0", + cursor === undefined ? payload : { ...payload, cursor }, + ); + return { items: res.facets ?? [], nextCursor: res.nextCursor }; + }); +} + +export interface FacetDeleteParams extends PersonaHolderParams { + facetId: string; + expectedVersion?: number; +} + +/** + * Delete one facet. + * + * **Deletes nothing it named.** Every face and attribute survives — a facet is + * an arrangement, not a container, and there is deliberately no cascading form + * of this call anywhere on the wire. `releasedFaces` says how many faces now + * belong to no facet, which is what a surface needs to describe the result + * honestly rather than saying only "deleted". + * + * `existed: false` is a successful no-op, not a failure. + */ +export async function personaFacetDelete( + sender: TrustTaskSender, + params: FacetDeleteParams, +): Promise { + const payload: PersonaFacetDeletePayload = { + facetId: params.facetId, + ...(params.expectedVersion !== undefined ? { expectedVersion: params.expectedVersion } : {}), + }; + return holderCall( + sender, + params, + FACET_DELETE, + FACET_DELETE_RESPONSE, + "persona/facet/delete/1.0", + payload, + ); +} diff --git a/packages/core/task-surface.json b/packages/core/task-surface.json index ef80237..0d292b7 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -2,7 +2,7 @@ "$comment": "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. Do not hand-edit: re-run the script. Checked by tests/task-surface.mjs.", "source": { "crate": "vta-sdk", - "version": "0.34.0", + "version": "0.34.1", "scanned": "vta-sdk/src/**/*.rs" }, "tasks": [ @@ -592,6 +592,24 @@ "TASK_PERSONA_DISCLOSURE_PREVIEW_1_0" ] }, + { + "uri": "https://trusttasks.org/spec/persona/facet/delete/1.0", + "consts": [ + "TASK_PERSONA_FACET_DELETE_1_0" + ] + }, + { + "uri": "https://trusttasks.org/spec/persona/facet/list/1.0", + "consts": [ + "TASK_PERSONA_FACET_LIST_1_0" + ] + }, + { + "uri": "https://trusttasks.org/spec/persona/facet/put/1.0", + "consts": [ + "TASK_PERSONA_FACET_PUT_1_0" + ] + }, { "uri": "https://trusttasks.org/spec/persona/local/binding/set/1.0", "consts": [ diff --git a/packages/core/tests/admin.persona.mjs b/packages/core/tests/admin.persona.mjs index 57cb32f..5f0e395 100644 --- a/packages/core/tests/admin.persona.mjs +++ b/packages/core/tests/admin.persona.mjs @@ -21,6 +21,9 @@ import { personaProfilePut, personaProfileDelete, personaBindingSet, + personaFacetPut, + personaFacetList, + personaFacetDelete, personaCorrelationAnalyze, personaDisclosureHistory, personasBlockingDelete, @@ -90,6 +93,19 @@ test("every task names its 1.0 URI, request and response", async () => { "persona/binding/set/1.0", { contextId: "demo", personaDid: "did:key:zP", version: 1, boundAt: "x" }, ], + [ + personaFacetPut, + { ...PARTIES, name: "Work", colour: "teal" }, + "persona/facet/put/1.0", + { facetId: "01F", version: 1, created: true, updatedAt: "x" }, + ], + [personaFacetList, { ...PARTIES }, "persona/facet/list/1.0", { facets: [] }], + [ + personaFacetDelete, + { ...PARTIES, facetId: "01F" }, + "persona/facet/delete/1.0", + { existed: true, releasedFaces: 0 }, + ], [personaCorrelationAnalyze, { ...PARTIES }, "persona/correlation/analyze/1.0", { findings: [] }], [ personaDisclosureHistory, @@ -491,3 +507,76 @@ test("every persona in a context is read to the end, and no cursor comes back", assert.equal(res.nextCursor, undefined); assert.equal(r.sent[1].envelope.payload.contextId, "openvtc", "the context survives the second request"); }); + +// ── Facets ────────────────────────────────────────────────────────────────── + +test("a facet carries no contextId — it arranges records that have no compartment", async () => { + const channel = recorder({ facetId: "01F", version: 1, created: true, updatedAt: "x" }); + await personaFacetPut(channel, { ...PARTIES, name: "Work", colour: "teal" }); + assert.equal(channel.sent[0].envelope.body?.contextId, undefined); + assert.equal(channel.sent[0].envelope.payload?.contextId, undefined); +}); + +test("membership is sent only when the caller supplied it", async () => { + // Both lists are REPLACED by a put. Sending `[]` for a caller that passed + // nothing would empty a facet's membership on an edit that meant to rename + // it — the same replace hazard the attribute editor guards one record down. + const channel = recorder({ facetId: "01F", version: 1, created: true, updatedAt: "x" }); + await personaFacetPut(channel, { ...PARTIES, name: "Work", colour: "teal" }); + const payload = channel.sent[0].envelope.payload ?? channel.sent[0].envelope.body; + assert.equal(payload.faceIds, undefined, "an unsupplied membership was sent as empty"); + assert.equal(payload.attributeIds, undefined); + assert.equal(payload.icon, undefined); +}); + +test("membership that WAS supplied is carried, including an explicit empty", async () => { + // The paired assertion: a suite that only checks omissions would pass for a + // client that never sent membership at all. + const channel = recorder({ facetId: "01F", version: 2, created: false, updatedAt: "x" }); + await personaFacetPut(channel, { + ...PARTIES, + facetId: "01F", + name: "Work", + colour: "plum", + icon: "\u{1F4BC}", + faceIds: ["01P"], + attributeIds: [], + expectedVersion: 1, + }); + const payload = channel.sent[0].envelope.payload ?? channel.sent[0].envelope.body; + assert.deepEqual(payload.faceIds, ["01P"]); + assert.deepEqual(payload.attributeIds, [], "an explicit empty list was dropped"); + assert.equal(payload.colour, "plum"); + assert.equal(payload.expectedVersion, 1); +}); + +test("a facet listing follows the cursor to the end", async () => { + // `limit` is the page size to ask for, never a cap. A client that stopped at + // the first page would draw a picture missing every facet past it, and a + // short array is indistinguishable from a complete one. + const pages = [ + { facets: [{ facetId: "a" }], nextCursor: "c1" }, + { facets: [{ facetId: "b" }], nextCursor: "c2" }, + { facets: [{ facetId: "c" }] }, + ]; + let n = 0; + const channel = { send: () => Promise.resolve(pages[n++]) }; + const all = await personaFacetList(channel, { ...PARTIES, limit: 1 }); + assert.deepEqual(all.map((f) => f.facetId), ["a", "b", "c"]); +}); + +test("a facet listing returns [] rather than undefined", async () => { + const channel = recorder({}); + assert.deepEqual(await personaFacetList(channel, { ...PARTIES }), []); +}); + +test("deleting a facet sends only the facet — there is no cascade to send", async () => { + // A facet is an arrangement, not a container. There is no cascading form of + // this call anywhere on the wire, and a client inventing one would be asking + // for a member the agent would refuse. + const channel = recorder({ existed: true, releasedFaces: 2 }); + const res = await personaFacetDelete(channel, { ...PARTIES, facetId: "01F" }); + const payload = channel.sent[0].envelope.payload ?? channel.sent[0].envelope.body; + assert.deepEqual(Object.keys(payload).sort(), ["facetId"]); + assert.equal(res.releasedFaces, 2, "the released-face count is what a screen reads"); +}); diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index 4a09f02..8dd84e0 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -340,7 +340,9 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // the console. That is not a gap left open: `rooms/create` and // `rooms/epoch/chain` are what the agent sends onward, and a second copy of // that call from here would be one that never arrives. - const expected = 198; + // 198 → 201: persona/facet/{put,list,delete}, the holder's arrangement of + // their own identity (dtgwg-trust-tasks-tf#405, VTI#1338). + const expected = 201; assert.equal( implemented.size, expected, diff --git a/packages/extension/src/manager-theme.css b/packages/extension/src/manager-theme.css index ffc833b..0a1893b 100644 --- a/packages/extension/src/manager-theme.css +++ b/packages/extension/src/manager-theme.css @@ -59,6 +59,33 @@ --m-fam-gated: #a15381; --m-fam-unregistered: #8a92a3; + /* World colours — the holder's own choice, and the THIRD categorical set. + * + * A world (`persona/facet` on the wire) is a part of a person's life, and + * these eight are the only colours one may wear. The wire carries a *name* + * — `teal`, `plum` — and never a literal, so a world stays legible in this + * console, in a terminal and on a phone, each resolving the name against its + * own palette. That is also what makes the closed set enforceable: given an + * arbitrary hex from a holder, this file could not keep a decorative choice + * out of the one channel that means something. + * + * None is named for, or drawn near, `--w-ok` / `--w-warn` / `--w-danger`. + * That constraint is not a matter of taste and is asserted in + * `manager-world-colour.test.mts`: a holder who names a world "Work" and + * picks the red one would otherwise have every card in it reading as an + * alarm, and no way to discover why. + * + * Held at the same low chroma as the family set, for the same reason — they + * fill a small chip beside a name, never a border or a pill. */ + --m-world-slate: #5c6b80; + --m-world-indigo: #5b5fbf; + --m-world-teal: #2c7c85; + --m-world-moss: #4f7d5e; + --m-world-sand: #8a7440; + --m-world-clay: #a2664a; + --m-world-rose: #b05878; + --m-world-plum: #8455a8; + /* The rail's own ground — a half-step off `--w-ground` so the three columns * read as three columns without a hard border doing the work. */ --m-rail: #f2f4f8; @@ -82,6 +109,17 @@ --m-fam-gated: #d18cb2; --m-fam-unregistered: #6f7a8c; + /* Lifted for a dark ground, same hues and the same distance from the + * semantic three. */ + --m-world-slate: #8d9bb0; + --m-world-indigo: #8f92e0; + --m-world-teal: #58aeb8; + --m-world-moss: #85b795; + --m-world-sand: #c0a668; + --m-world-clay: #d1937a; + --m-world-rose: #e08aa8; + --m-world-plum: #b98cd8; + --m-rail: #0e131c; --m-tree: #10151f; } diff --git a/packages/extension/src/manager/panes/persona.tsx b/packages/extension/src/manager/panes/persona.tsx index bd5722b..aa792ba 100644 --- a/packages/extension/src/manager/panes/persona.tsx +++ b/packages/extension/src/manager/panes/persona.tsx @@ -48,6 +48,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { personaAttributeList, personaDisclosureHistory, + personaFacetList, personaProfileList, } from "@openvtc/pnm-core/admin"; import { getBinding, listBindings } from "@openvtc/pnm-core/persona"; @@ -65,6 +66,7 @@ import { buildGraph, type ContextInput } from "../identity-graph.js"; import { listClaimTypes, unappliedClaimTypes } from "@openvtc/pnm-core/persona"; import { IdentityMap } from "./persona-map.js"; import { AttributeList } from "./persona-list.js"; +import { WorldsPane } from "./worlds.js"; import { GuidedSetup } from "./persona-setup.js"; import { showsGuide } from "../persona-flow.js"; import { revealAttributeValue, type RevealTarget } from "../reveal-value.js"; @@ -124,8 +126,14 @@ async function loadContexts(parties: Parties, records: ContextRecord[]): Promise * 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) => ( +function ViewToggle({ + view, + onView, +}: { + view: "map" | "list" | "worlds"; + onView: (v: "map" | "list" | "worlds") => void; +}) { + const item = (v: "map" | "list" | "worlds", label: string) => ( + + + + + ); +} + +export function WorldsPane({ + parties, + authority, + worlds, + faces, + onChanged, +}: { + parties: Parties; + authority: Authority | null; + worlds: readonly PoolFacet[]; + faces: readonly PoolProfile[]; + onChanged: () => void; +}) { + const [editing, setEditing] = useState(null); + const [creating, setCreating] = useState(false); + const denied = holderGate(authority); + const loose = useMemo(() => unplacedFaces(worlds, faces), [worlds, faces]); + + const target = worlds.find((w) => w.facetId === editing); + + if (creating || target) { + return ( + { + setEditing(null); + setCreating(false); + onChanged(); + }} + onCancel={() => { + setEditing(null); + setCreating(false); + }} + /> + ); + } + + return ( +
+
+
+ Your worlds + + A part of your life, and the faces that belong to it. Keep your worlds apart. + +
+ +
+ + {worlds.length === 0 ? ( + + You have no worlds yet. Most people start with two — the part of life they work in, and + the part they do not. + + ) : ( + worlds.map((w) => { + const members = faces.filter((f) => w.faceIds?.includes(f.profileId)); + return ( +
+
+ +
+ + + label="Delete" + disabledReason={denied} + preview={async () => ({ + released: members.length, + names: members.map((f) => f.name), + })} + renderPreview={(p) => ( +
+
+ {w.name} stops being a part of your life on this screen. +
+ {/* The sentence that decides whether this reads as a + folder. Said first, and said even when nothing + belongs to it. */} +
+ {p.released === 0 + ? "No face belongs to it, and nothing else changes." + : `${p.released === 1 ? "The face" : `All ${p.released} faces`} in it — ` + + `${p.names.join(", ")} — ${p.released === 1 ? "stays" : "stay"} ` + + `exactly as ${p.released === 1 ? "it is" : "they are"}. ` + + `${p.released === 1 ? "It" : "They"} will simply belong to no world.`} +
+
+ Nothing already shared is affected — that has left. +
+
+ )} + commit={async () => { + await personaFacetDelete(managerSender, { + ...parties, + facetId: w.facetId, + expectedVersion: w.version, + }); + }} + onDone={onChanged} + /> +
+
+
+ {members.length === 0 + ? "No faces belong to it yet." + : members.map((f) => f.name).join(" · ")} +
+
+ ); + }) + )} + + {worlds.length > 0 && loose.length > 0 && ( + + {loose.length === 1 ? "One face belongs" : `${loose.length} faces belong`} to no world:{" "} + {loose.map((f) => f.name).join(", ")}. That is a fine place for{" "} + {loose.length === 1 ? "it" : "them"} to stay. + + )} +
+ ); +} diff --git a/packages/extension/src/manager/world-colour.ts b/packages/extension/src/manager/world-colour.ts new file mode 100644 index 0000000..9271388 --- /dev/null +++ b/packages/extension/src/manager/world-colour.ts @@ -0,0 +1,94 @@ +// The eight colours a world may wear, and the one rule about them. +// +// A world (`persona/facet` on the wire — see +// `design-docs/persona-vocabulary.md`) carries a colour **name**, never a +// literal. This module is where the name becomes something to draw with, and +// it is deliberately the only such place: a second mapping is a second answer +// to "what colour is Work", and the one nobody re-checks is the one that goes +// wrong. +// +// ## Why a closed set, and why this file exists at all +// +// `manager-theme.css` holds the rule: `--w-ok` / `--w-warn` / `--w-danger` are +// the only colours that *mean* anything, and every other set is categorical. +// A world colour is the third categorical set, and it is the first one chosen +// by a **person** rather than by this codebase — which is exactly why it needs +// a guard. Given a free-form hex from a holder, nothing here could keep a +// decorative choice out of the semantic channel: someone names a world "Work", +// picks red, and every card in it reads as an alarm with no way to discover +// why. A closed set of eight makes that unrepresentable rather than merely +// discouraged, and `manager-world-colour.test.mts` asserts the distance from +// the semantic three rather than trusting the eye that picked them. +// +// The wire carrying a name is what makes this possible at all: the same world +// is legible here, in a terminal and on a phone, each resolving the name +// against its own palette. A stored `#8B0000` is a colour that is wrong +// somewhere and the holder has no way to know where. + +import type { FacetColour } from "@openvtc/pnm-core/admin"; + +/** + * Every colour a world may wear, in the order a picker offers them. + * + * Typed against the generated `FacetColour` rather than restated as strings: + * a ninth colour published in the specification is then a compile error here — + * a picker missing an option the agent will happily store — instead of a name + * this console silently cannot draw. + */ +export const WORLD_COLOURS: readonly FacetColour[] = [ + "slate", + "indigo", + "teal", + "moss", + "sand", + "clay", + "rose", + "plum", +]; + +/** + * The CSS custom property for one world colour. + * + * A token reference rather than a value, so both themes resolve: the light and + * dark blocks in `manager-theme.css` define the same eight names at different + * lightness, and a component that reached for a hex would be correct in exactly + * one of them. + */ +export function worldHue(colour: FacetColour): string { + return `var(--m-world-${colour})`; +} + +/** + * The holder's own words for a colour, for a picker's accessible name. + * + * Present because a swatch alone is not a choice anyone can make with a screen + * reader, and because "the third one" is not a thing a person can say to + * support. Deliberately plain: these name a colour and must not acquire + * connotations — no "danger red", no "success green" — which is the same rule + * that keeps the eight away from the semantic three in the first place. + */ +export function worldColourName(colour: FacetColour): string { + switch (colour) { + case "slate": + return "Slate"; + case "indigo": + return "Indigo"; + case "teal": + return "Teal"; + case "moss": + return "Moss"; + case "sand": + return "Sand"; + case "clay": + return "Clay"; + case "rose": + return "Rose"; + case "plum": + return "Plum"; + default: + // Unreachable while `WORLD_COLOURS` and the generated union agree, and + // the honest answer if a ninth is published and this switch is not + // updated: the token, which is at least the word the agent stored. + return colour; + } +} diff --git a/packages/extension/src/manager/world-model.ts b/packages/extension/src/manager/world-model.ts new file mode 100644 index 0000000..144586a --- /dev/null +++ b/packages/extension/src/manager/world-model.ts @@ -0,0 +1,89 @@ +// What the worlds screen needs to know, computed out of the component. +// +// Same discipline as `identity-graph.ts` and `attribute-list.ts`: the questions +// with an answer worth testing live here, and the component draws. Every one of +// these is a question a person asks of the screen — *where is that face +// already?*, *what belongs nowhere?* — and getting one wrong is a screen that +// says something untrue rather than a screen that looks wrong. + +import type { PoolFacet, PoolProfile } from "@openvtc/pnm-core/admin"; +import { RelayTaskError } from "./carrier.js"; + +/** One face the agent refused to place, and the world already holding it. */ +export interface Placement { + faceId: string; + facetId: string; +} + +/** + * The world a face already belongs to, or `undefined`. + * + * `excluding` is the world being edited, so its own members do not read as + * conflicts — without it every checkbox in an edit would be disabled the moment + * the world held anything, which is the same self-clash the agent's own + * placement check excludes. + */ +export function worldOfFace( + worlds: readonly PoolFacet[], + faceId: string, + excluding?: string, +): PoolFacet | undefined { + return worlds.find((w) => w.facetId !== excluding && (w.faceIds ?? []).includes(faceId)); +} + +/** + * Faces belonging to no world. + * + * Shown, never hidden. A face that belongs nowhere is a perfectly good state — + * most are, before anyone arranges anything — and a screen that listed only + * arranged faces would quietly under-report what the holder has, which is the + * same defect as a context tally that omits a standing. + */ +export function unplacedFaces( + worlds: readonly PoolFacet[], + faces: readonly PoolProfile[], +): PoolProfile[] { + const placed = new Set(worlds.flatMap((w) => w.faceIds ?? [])); + return faces.filter((f) => !placed.has(f.profileId)); +} + +/** + * The `faceAlreadyPlaced` refusal, read off an error, or `null`. + * + * **Matched on the top-level extended `code`, not on a message.** R3.7: a + * condition this console must detect needs a stable machine-readable field, and + * a string match here would break the first time the agent reworded itself. + * + * The details are the whole point of the code existing. Told only that the + * write failed, this pane could do nothing but send the holder off to find + * where the face already is; told which world, it can say so beside the + * checkbox. So a refusal whose details are missing or malformed returns `null` + * rather than an empty list — an empty list would render as "0 faces already + * belong elsewhere", which is a claim, and the honest answer is to fall through + * to the generic error. + */ +export function placedElsewhere(error: unknown): Placement[] | null { + // Typed on the class that actually carries a code. A bare `Error` with a + // `code` property glued on is not a refusal from the relay, and a + // `ConsentRequiredError` — the sibling that must reach its own ceremony — + // has no `code` at all, so neither can be mistaken for this. + if (!(error instanceof RelayTaskError)) return null; + if (error.code !== "persona/facet/put:faceAlreadyPlaced") return null; + const placed = (error.details as { placed?: unknown } | undefined)?.placed; + if (!Array.isArray(placed) || placed.length === 0) return null; + const out: Placement[] = []; + for (const row of placed) { + if ( + typeof row === "object" && + row !== null && + typeof (row as Placement).faceId === "string" && + typeof (row as Placement).facetId === "string" + ) { + out.push({ faceId: (row as Placement).faceId, facetId: (row as Placement).facetId }); + } + } + // A mixed array is refused rather than filtered: half an answer about where + // the holder's faces are is worse than none, because the half that is missing + // is invisible. + return out.length === placed.length ? out : null; +} diff --git a/packages/extension/tests/manager-world-colour.test.mts b/packages/extension/tests/manager-world-colour.test.mts new file mode 100644 index 0000000..7b43ee2 --- /dev/null +++ b/packages/extension/tests/manager-world-colour.test.mts @@ -0,0 +1,141 @@ +// The eight colours a world may wear, checked against the three that mean +// something. +// +// `manager-theme.css` sets the rule that `--w-ok` / `--w-warn` / `--w-danger` +// are the only colours carrying meaning. A world colour is the first +// categorical set in this console chosen by a *person*, and that is what makes +// it worth asserting rather than eyeballing: a holder who names a world "Work" +// and picks something close to `--w-danger` would have every card in it reading +// as an alarm, with nothing on screen to explain why and nobody to blame but a +// palette somebody once approved by looking at it. +// +// So the distance is computed, in both themes, from the values the stylesheet +// actually ships — not from a copy of them kept here, which would pass forever +// after someone edited the CSS. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { WORLD_COLOURS, worldHue, worldColourName } from "../src/manager/world-colour.ts"; + +const css = (name: string) => + readFileSync(fileURLToPath(new URL(`../src/${name}`, import.meta.url)), "utf8"); + +const MANAGER = css("manager-theme.css"); +const BASE = css("theme.css"); + +/** Every `--token: #hex;` in a stylesheet, as a list of (token, hex, blockIndex). + * The block index separates the light `:root` from the dark media query, so a + * light world colour is never compared against a dark semantic one. */ +function declarations(text: string): { token: string; hex: string; dark: boolean }[] { + const darkAt = text.indexOf("@media (prefers-color-scheme: dark)"); + const out: { token: string; hex: string; dark: boolean }[] = []; + const re = /(--[a-z0-9-]+)\s*:\s*(#[0-9a-fA-F]{6})\s*;/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + out.push({ token: m[1]!, hex: m[2]!.toLowerCase(), dark: darkAt >= 0 && m.index > darkAt }); + } + return out; +} + +function rgb(hex: string): [number, number, number] { + return [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), + ]; +} + +/** Perceptual-ish distance. Not a true ΔE — the redmean approximation, which is + * well behaved for exactly this question (is this obviously a different colour) + * and needs no colour library in a test that must run in plain Node. */ +function distance(a: string, b: string): number { + const [r1, g1, b1] = rgb(a); + const [r2, g2, b2] = rgb(b); + const rmean = (r1 + r2) / 2; + const dr = r1 - r2; + const dg = g1 - g2; + const db = b1 - b2; + return Math.sqrt( + (2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db, + ); +} + +/** + * How far a world colour must sit from a semantic one. + * + * Chosen by measuring the closest legitimate pair in the shipped palette and + * leaving headroom, rather than picked as a round number: the point is to catch + * a *new* colour drifting into the semantic channel, so the threshold has to be + * tight enough to fire before a person would notice and loose enough not to + * fire on the palette as approved. + */ +const MIN_DISTANCE = 60; + +const SEMANTIC = ["--w-ok", "--w-warn", "--w-danger"]; + +test("every world colour is defined in both themes", () => { + const decls = declarations(MANAGER); + for (const colour of WORLD_COLOURS) { + const token = `--m-world-${colour}`; + assert.ok( + decls.some((d) => d.token === token && !d.dark), + `${token} has no light-theme value`, + ); + assert.ok( + decls.some((d) => d.token === token && d.dark), + `${token} has no dark-theme value — it would inherit the light one on a dark ground`, + ); + } +}); + +test("no world colour is near a colour that means something", () => { + // The assertion the palette exists for. Run per theme, because a light world + // colour sitting far from the light danger red says nothing about the dark + // pair, and both ship. + const worlds = declarations(MANAGER).filter((d) => d.token.startsWith("--m-world-")); + const semantic = declarations(BASE).filter((d) => SEMANTIC.includes(d.token)); + assert.ok(semantic.length >= 6, "the semantic three were not found in both themes"); + + for (const w of worlds) { + for (const s of semantic) { + if (w.dark !== s.dark) continue; + const d = distance(w.hex, s.hex); + assert.ok( + d >= MIN_DISTANCE, + `${w.token} (${w.hex}) is ${d.toFixed(1)} from ${s.token} (${s.hex}) in the ` + + `${w.dark ? "dark" : "light"} theme — a holder picking it would have every card ` + + `in that world read as ${s.token.replace("--w-", "")}`, + ); + } + } +}); + +test("the picker offers exactly what the wire accepts", () => { + // `WORLD_COLOURS` is typed against the generated union, so a ninth colour + // published in the specification is a compile error rather than an option + // silently missing from the picker. This asserts the count so a *removal* + // is caught too. + assert.equal(WORLD_COLOURS.length, 8); + assert.equal(new Set(WORLD_COLOURS).size, 8, "a colour is offered twice"); +}); + +test("a hue is a token reference, never a value", () => { + // A component reaching for a hex would be correct in exactly one theme. + for (const colour of WORLD_COLOURS) { + assert.equal(worldHue(colour), `var(--m-world-${colour})`); + } +}); + +test("every colour has a word, and none of the words carry a meaning", () => { + // A swatch alone is not a choice anyone can make with a screen reader. The + // words name a colour and must not acquire connotations — "danger red" in a + // picker would reintroduce the exact confusion the palette prevents. + const banned = /danger|warn|error|success|ok\b|alert|caution/i; + for (const colour of WORLD_COLOURS) { + const name = worldColourName(colour); + assert.ok(name.length > 0, `${colour} has no word`); + assert.doesNotMatch(name, banned, `"${name}" gives a colour a meaning it must not have`); + } +}); diff --git a/packages/extension/tests/manager-world-model.test.mts b/packages/extension/tests/manager-world-model.test.mts new file mode 100644 index 0000000..ce5b793 --- /dev/null +++ b/packages/extension/tests/manager-world-model.test.mts @@ -0,0 +1,106 @@ +// What the worlds screen knows, tested away from the screen. +// +// Two of these are about a refusal, and they are the ones that matter: the +// agent's `faceAlreadyPlaced` code exists so a pane can say *where* a face +// already is, and a client that read it loosely would either miss the refusal +// entirely or report half of it as the whole. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { worldOfFace, unplacedFaces, placedElsewhere } from "../src/manager/world-model.ts"; +import { ConsentRequiredError, RelayTaskError } from "../src/manager/carrier.ts"; + +const world = (id: string, name: string, faceIds: string[]) => + ({ facetId: id, name, colour: "teal", faceIds, attributeIds: [], version: 1, updatedAt: "x" }) as never; +const face = (id: string, name: string) => + ({ profileId: id, name, entries: [], version: 1, updatedAt: "x" }) as never; + +const WORLDS = [world("w1", "Work", ["f1", "f2"]), world("w2", "Home", ["f3"])]; +const FACES = [face("f1", "Acme"), face("f2", "LinkedIn"), face("f3", "Family"), face("f4", "Loose")]; + +test("a face reports the world already holding it", () => { + assert.equal(worldOfFace(WORLDS, "f3")?.name, "Home"); + assert.equal(worldOfFace(WORLDS, "f4"), undefined); +}); + +test("a world being edited does not conflict with its own members", () => { + // Without the exclusion every checkbox in an edit is disabled the moment the + // world holds anything — the same self-clash the agent's placement check + // excludes on its own side. + assert.equal(worldOfFace(WORLDS, "f1", "w1"), undefined); + assert.equal(worldOfFace(WORLDS, "f3", "w1")?.name, "Home"); +}); + +test("faces belonging to no world are reported, not hidden", () => { + // Belonging nowhere is a perfectly good state, and most faces are in it + // before anyone arranges anything. A screen listing only arranged faces + // under-reports what the holder has. + assert.deepEqual(unplacedFaces(WORLDS, FACES).map((f) => f.name), ["Loose"]); +}); + +test("every face placed means nothing is loose", () => { + assert.deepEqual(unplacedFaces(WORLDS, FACES.slice(0, 3)), []); +}); + +test("the refusal is matched on the code, never on a message", () => { + // R3.7. A string match breaks the first time the agent rewords itself, and + // the failure is silent: the flow falls through to a generic error and the + // holder is told to go and find the face themselves. + const refusal = new RelayTaskError( + "persona/facet/put/1.0", + "one or more faces already belong to another facet", + { code: "persona/facet/put:faceAlreadyPlaced", details: { placed: [{ faceId: "f1", facetId: "w2" }] } }, + ); + assert.deepEqual(placedElsewhere(refusal), [{ faceId: "f1", facetId: "w2" }]); + + // The same words, carried by an error with no code at all. + const sameWordsNoCode = new RelayTaskError( + "persona/facet/put/1.0", + "one or more faces already belong to another facet", + {}, + ); + assert.equal(placedElsewhere(sameWordsNoCode), null, "a message was read as a refusal"); +}); + +test("a different extended code is not this refusal", () => { + const other = new RelayTaskError("persona/facet/put/1.0", "nope", { + code: "persona/facet/put:versionConflict", + details: { placed: [{ faceId: "f1", facetId: "w2" }] }, + }); + assert.equal(placedElsewhere(other), null); +}); + +test("a consent ceremony is never swallowed as a placement refusal", () => { + // It has its own surface and must reach it. Catching it here would discard + // the ceremony at the moment the human was meant to act. + const consent = new ConsentRequiredError("persona/facet/put/1.0", { + payloadDigest: "z".repeat(64), + challenge: "c", + approverSet: "a", + minApprovals: 1, + consentRequests: [], + }); + assert.equal(placedElsewhere(consent), null); +}); + +test("a refusal with no usable details falls through rather than claiming zero", () => { + // An empty list renders as "0 faces already belong elsewhere", which is a + // claim. The honest answer is to show the generic error. + for (const details of [undefined, {}, { placed: [] }, { placed: "nope" }]) { + const refusal = new RelayTaskError("persona/facet/put/1.0", "x", { + code: "persona/facet/put:faceAlreadyPlaced", + ...(details !== undefined ? { details } : {}), + }); + assert.equal(placedElsewhere(refusal), null, `details ${JSON.stringify(details)} was accepted`); + } +}); + +test("a mixed array is refused rather than filtered", () => { + // Half an answer about where the holder's faces are is worse than none, + // because the missing half is invisible. + const refusal = new RelayTaskError("persona/facet/put/1.0", "x", { + code: "persona/facet/put:faceAlreadyPlaced", + details: { placed: [{ faceId: "f1", facetId: "w2" }, { faceId: 7 }] }, + }); + assert.equal(placedElsewhere(refusal), null); +}); diff --git a/packages/extension/tests/worlds-pane.render.test.mts b/packages/extension/tests/worlds-pane.render.test.mts new file mode 100644 index 0000000..269bfc0 --- /dev/null +++ b/packages/extension/tests/worlds-pane.render.test.mts @@ -0,0 +1,183 @@ +// The worlds screen, rendered. +// +// The model beneath it (`world-model.ts`) passes for every case here. These are +// the ones only a mounted screen can be wrong about, and the first is the one +// that matters most: a delete confirm that reads like a folder. The model +// cannot tell you what the confirm *said*. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { agent, h, render, PARTIES, UNSCOPED_HOLDER } from "./harness/dom.mjs"; +import { WorldsPane } from "../src/manager/panes/worlds.js"; + +const world = (id: string, name: string, faceIds: string[], colour = "teal") => + ({ facetId: id, name, colour, faceIds, attributeIds: [], version: 1, updatedAt: "x" }) as never; +const face = (id: string, name: string) => + ({ profileId: id, name, entries: [], version: 1, updatedAt: "x" }) as never; + +const FACES = [face("f1", "Acme"), face("f2", "LinkedIn"), face("f3", "Loose")]; + +function mount(fake: ReturnType, worlds: unknown[], extra = {}) { + return { + element: h(WorldsPane, { + parties: PARTIES, + authority: UNSCOPED_HOLDER, + worlds, + faces: FACES, + onChanged: () => {}, + ...extra, + }), + options: { chrome: { runtime: { sendMessage: fake.sendMessage } } }, + }; +} + +const putOk = () => + agent({ "persona/facet/put/1.0": { facetId: "w9", version: 1, created: true, updatedAt: "x" } }); + +test("a world lists what belongs to it, and what belongs nowhere is said too", async () => { + const m = mount(putOk(), [world("w1", "Work", ["f1", "f2"])]); + const screen = await render(m.element, m.options); + const text = screen.text(); + assert.match(text, /Work/); + assert.match(text, /Acme · LinkedIn/); + // Belonging nowhere is a real state, not an omission. + assert.match(text, /One face belongs to no world: Loose/); + await screen.unmount(); +}); + +test("with no worlds the screen suggests rather than scolds", async () => { + const m = mount(putOk(), []); + const screen = await render(m.element, m.options); + assert.match(screen.text(), /no worlds yet/); + await screen.unmount(); +}); + +test("deleting a world says what SURVIVES, not only what goes", async () => { + // The assertion this file exists for. A grouping that looks like a folder is + // assumed to behave like one, so the confirm has to deny it in words — a + // holder who believes Delete takes the faces with it will not press it, and + // one who believes it and is wrong has lost nothing but will never trust the + // screen again. + const m = mount(putOk(), [world("w1", "Work", ["f1", "f2"])]); + const screen = await render(m.element, m.options); + await screen.click(screen.button("Delete")); + await screen.settle(); + const text = screen.text(); + assert.match(text, /All 2 faces in it — Acme, LinkedIn — stay exactly as they are/); + assert.match(text, /belong to no world/); + assert.match(text, /Nothing already shared is affected/); + await screen.unmount(); +}); + +test("an empty world's delete still says nothing else changes", async () => { + const m = mount(putOk(), [world("w1", "Spare", [])]); + const screen = await render(m.element, m.options); + await screen.click(screen.button("Delete")); + await screen.settle(); + assert.match(screen.text(), /No face belongs to it, and nothing else changes/); + await screen.unmount(); +}); + +test("no cascade is offered anywhere on the delete", async () => { + // There is no cascading form of this call on the wire, and a screen that + // implied one would be promising something the agent refuses to do. + const m = mount(putOk(), [world("w1", "Work", ["f1"])]); + const screen = await render(m.element, m.options); + await screen.click(screen.button("Delete")); + await screen.settle(); + assert.doesNotMatch(screen.text(), /also delete|including the faces|and its faces/i); + await screen.unmount(); +}); + +test("a face already in another world cannot be ticked, and says where it is", async () => { + // Said before the save. A checkbox that looked available and then failed is a + // refusal the holder had no way to anticipate. + const m = mount(putOk(), [world("w1", "Work", ["f1"]), world("w2", "Home", [])]); + const screen = await render(m.element, m.options); + // The SECOND Edit — Home, which does not hold Acme. Editing Work would + // correctly show no conflict, which is the next test. + await screen.click(screen.all("button").filter((b) => b.textContent?.includes("Edit"))[1]!); + await screen.settle(); + assert.match(screen.text(), /already belongs to Work/); + assert.equal( + screen.all('input[aria-label="Acme"]')[0]!.disabled, + true, + "a face held by another world was offered as available", + ); + await screen.unmount(); +}); + +test("a world's own members are not conflicts with itself", async () => { + // Without the exclusion every checkbox in an edit is disabled the moment the + // world holds anything, and the world becomes uneditable. + const m = mount(putOk(), [world("w1", "Work", ["f1"]), world("w2", "Home", [])]); + const screen = await render(m.element, m.options); + await screen.click(screen.all("button").filter((b) => b.textContent?.includes("Edit"))[0]!); + await screen.settle(); + const acme = screen.all('input[aria-label="Acme"]')[0]!; + assert.equal(acme.disabled, false, "a world's own member was blocked from its own editor"); + assert.equal(acme.checked, true); + await screen.unmount(); +}); + +test("an edit opens with the membership it will replace", async () => { + // A put REPLACES both lists. An editor that opened with an empty selection + // and saved would silently empty the world on an edit that meant to rename. + const fake = putOk(); + const m = mount(fake, [world("w1", "Work", ["f1", "f2"])]); + const screen = await render(m.element, m.options); + await screen.click(screen.byText("button", "Edit")!); + await screen.settle(); + assert.equal(screen.all('input[aria-label="Acme"]')[0]!.checked, true); + assert.equal(screen.all('input[aria-label="LinkedIn"]')[0]!.checked, true); + assert.equal(screen.all('input[aria-label="Loose"]')[0]!.checked, false); + + await screen.click(screen.button("Save")); + await screen.settle(); + const sent = fake.of("facet/put")[0]!; + assert.deepEqual(sent.payload.faceIds.sort(), ["f1", "f2"]); + assert.equal(sent.payload.expectedVersion, 1, "an edit did not carry the version it read"); + await screen.unmount(); +}); + +test("creating sends the colour that was picked", async () => { + const fake = putOk(); + const m = mount(fake, []); + const screen = await render(m.element, m.options); + await screen.click(screen.button("New world")); + await screen.settle(); + await screen.type(screen.all('input[aria-label="What do you call it?"]')[0]!, "Play"); + await screen.click(screen.byText("button", "")!.ownerDocument.querySelector('[aria-label="Plum"]')!); + await screen.click(screen.button("Create")); + await screen.settle(); + const sent = fake.of("facet/put")[0]!; + assert.equal(sent.payload.name, "Play"); + assert.equal(sent.payload.colour, "plum"); + await screen.unmount(); +}); + +test("a placement refusal offers a way out instead of a raw error", async () => { + const fake = agent({ + "persona/facet/put/1.0": () => { + const e = new Error("one or more faces already belong to another facet") as Error & { + trustTaskError?: unknown; + }; + e.trustTaskError = { + code: "persona/facet/put:faceAlreadyPlaced", + details: { placed: [{ faceId: "f1", facetId: "w2" }] }, + }; + throw e; + }, + }); + const m = mount(fake, [world("w1", "Work", [])]); + const screen = await render(m.element, m.options); + await screen.click(screen.byText("button", "Edit")!); + await screen.settle(); + await screen.check(screen.all('input[aria-label="Acme"]')[0]!); + await screen.click(screen.button("Save")); + await screen.settle(); + // Either shape is acceptable to a person; what must never appear is a bare + // stack of wire text with no next step. + assert.match(screen.text(), /belong to another world|already belong/); + await screen.unmount(); +});