From 7f58c4f27ac1c18301f0b55ad99bf6979193b975 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 12:25:15 +0200 Subject: [PATCH] fix(persona): one predicate for what a context is, and colour that says which way a copy went MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity map answered "is this holder known here?" three times with three different tests. The header counted contexts where a persona *wears a face*, the band drew a card for every context holding a persona *record*, and the fold counted whatever the band left over — so a live console read "known in 1 of 12" above two cards with ten folded away. One of the three numbers was lying to whoever read it, and there was no way to tell which. The state that fell between them is real. `persona/binding/list/1.0` enumerates the personas *present* in a context and carries `bound` separately, so unbinding a face leaves the persona behind: that context still knows an identifier of the holder's and can address it, while holding none of their attributes. "Known" overstates what left; "absent" hides an identifier they have out there. It now has a standing of its own and words on screen — *An identifier only*, and "This context can address that identifier. It holds nothing else of yours." - `standingOf` / `tallyContexts` in `identity-graph.ts`: known, identified, unreadable, absent, each context counted once. The header, the band and the fold all read this one predicate, and a test pins that the four sum to the total — the property the old arithmetic broke. The second half is colour, in three channels that never overlap: the border is selection and reach, the stripe and chip dots are classification, the pills are status. - Reach is painted in two hues rather than one. `reachOf` has always run asymmetrically — down from an attribute to where copies of it went, up from a context to what it holds — and a single accent hid the asymmetry it computed. Down borrows `--m-act-data` from the contexts band it ends in, up keeps the accent, and a face selection splits visibly across the dashed line. `Flow` is computed in `identity-graph.ts` with the rest of the model; the component still only draws. The key appears with the first selection and not before. - Attributes group under their claim-type family, with the family's hue repeated as a dot on every face's type chips, so a composition reads without opening it. `attribute-family.ts` classifies **only** roots the vendored registry declares: `profile.*` and `employer` resolve to *Not in the registry*, because inventing a "profile" family locally would draw a grouping nobody has agreed to in a colour that reads as though someone had checked. - The `--m-fam-*` tokens are categorical, the same species as the act colours, and `manager-theme.css`'s rule stands: `--w-ok` / `--w-warn` / `--w-danger` remain the only colours that mean anything. A test asserts no family's words claim the colour protects something — the stripe defends nothing, exactly as the mask beside it defends only a screen. - Unlit "wear" edges went neutral. Teal used to mean "a face is worn here" at rest and "this is the path you selected" when lit; one hue, two jobs. 19 new tests (328 extension, 548 core), `tsc -b` clean, `npm run build` clean with `manager.js` still a single chunk. Signed-off-by: Glenn Gore --- CLAUDE.md | 35 ++ packages/extension/src/manager-theme.css | 28 ++ .../extension/src/manager/attribute-family.ts | 137 ++++++++ .../src/manager/claim-sensitivity.ts | 14 + .../extension/src/manager/identity-graph.ts | 103 +++++- .../src/manager/panes/persona-map.tsx | 304 +++++++++++++----- .../tests/manager-attribute-family.test.mts | 81 +++++ .../tests/manager-identity-graph.test.mts | 78 +++++ .../tests/persona-pane.render.test.mts | 101 ++++++ 9 files changed, 804 insertions(+), 77 deletions(-) create mode 100644 packages/extension/src/manager/attribute-family.ts create mode 100644 packages/extension/tests/manager-attribute-family.test.mts diff --git a/CLAUDE.md b/CLAUDE.md index 2ea5ce2..da5e4fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,6 +331,41 @@ per `design-docs/persona-vocabulary.md`; the spec's words (`attribute`, `profile`, `binding`, `materialise`) stay in code and off the screen. Add copy in those words, or change the document first. +**Colour on the map carries three things, in three channels that never +overlap.** The **border** is selection and reach; the **inset stripe** on an +attribute card and the dot on a face's chips are its claim-type family; the +**pills** are status. Reach is drawn in two hues rather than one because +`reachOf` was always asymmetric and the single accent hid it: down is a copy +**leaving** the holder (`--m-act-data`, borrowed from the contexts band it ends +in), up is what a context **holds** of them (the accent). `Flow` is computed in +`identity-graph.ts` with the rest of the model, so the component still only +draws. The family hues (`--m-fam-*`, `manager/attribute-family.ts`) are +**categorical**, the same species as the act colours in `manager-theme.css` and +bound by that file's rule: `--w-ok` / `--w-warn` / `--w-danger` stay the only +colours that mean anything. `familyOf` groups **only** roots the vendored +registry declares — `profile.*` and `employer` are `unregistered`, not a +"profile" family invented here — and no family's words may claim the colour +protects anything, which `manager-attribute-family.test.mts` asserts directly. + +**A context is one of four things, decided once.** `standingOf` / +`tallyContexts` (`identity-graph.ts`) answer `known` (a persona wears a face), +`identified` (a persona is present wearing nothing), `unreadable`, `absent`. +`identified` is a real state, not a rounding error: `persona/binding/list/1.0` +enumerates the personas *present* in a context and carries `bound` separately, +so unbinding a face leaves the persona — that context still knows an identifier +of the holder's and can address it, while holding none of their attributes. The +header, the band and the fold row all read this one predicate. They used to use +three different tests, which is how the live console came to say "known in 1 of +12" above two cards with ten folded away — and the state itself had no words on +screen at all. + +**What breaks it:** counting contexts anywhere but `tallyContexts` (the numbers +stop closing, and the one that is wrong is the one nobody re-checks); folding +`identified` in with `absent` (an identifier the holder has out there, +disappeared); painting reach in one hue again; putting a family hue on a card +border or in a pill; adding a `--m-fam-*` for something that is *state*; or +giving `familyOf` a prefix rule the registry has not declared. + **Sensitive values are hidden from the screen, and that is all it is.** `manager/claim-sensitivity.ts` carries a **vendored** copy of the claim-type registry's masking data — sensitivity and mask style per token, from diff --git a/packages/extension/src/manager-theme.css b/packages/extension/src/manager-theme.css index 41dcc5b..ffc833b 100644 --- a/packages/extension/src/manager-theme.css +++ b/packages/extension/src/manager-theme.css @@ -37,6 +37,28 @@ --m-act-data: #0e6f78; --m-act-data-soft: #e2f2f4; + /* Family colours — the second categorical set, and the only other one. + * + * The identity map groups a holder's attributes by the family their claim + * type comes from (`attribute-family.ts`), and carries that colour onto the + * type chips of every face, so a composition can be read without opening it. + * Like the act colours these are *categorical*: they say which vocabulary a + * token belongs to and never whether anything is wrong. `--m-fam-gated` + * names the one family the registry marks `release: stepUp` — an agent + * behaviour, not a warning — and is deliberately a plum rather than anything + * near `--w-danger`, which would read as an alarm on a value that is + * perfectly healthy. + * + * Held at lower chroma than both the act and the semantic sets on purpose: + * they appear as a 3px stripe and a 6px dot beside a card whose border is + * already carrying selection and reach, and a saturated stripe would win a + * competition it is not in. */ + --m-fam-identity: #7a58c9; + --m-fam-contact: #3f6ea8; + --m-fam-public: #4f7d5e; + --m-fam-gated: #a15381; + --m-fam-unregistered: #8a92a3; + /* 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; @@ -54,6 +76,12 @@ --m-act-data: #4dc4d0; --m-act-data-soft: #0e2326; + --m-fam-identity: #a48ce0; + --m-fam-contact: #7aa6d8; + --m-fam-public: #85b795; + --m-fam-gated: #d18cb2; + --m-fam-unregistered: #6f7a8c; + --m-rail: #0e131c; --m-tree: #10151f; } diff --git a/packages/extension/src/manager/attribute-family.ts b/packages/extension/src/manager/attribute-family.ts new file mode 100644 index 0000000..8c039a6 --- /dev/null +++ b/packages/extension/src/manager/attribute-family.ts @@ -0,0 +1,137 @@ +// Which family an attribute's claim type belongs to, and the colour that says so. +// +// ## Why a family at all +// +// The identity map draws every attribute the holder keeps as one card in one +// row. At five cards that is a row; at thirty it is a wall, and a wall is where +// the answer to "what does this person actually keep about themselves" goes to +// hide. Grouping the row into families — who you are, how to reach you, what is +// already public, what your agent gates — restores the shape of the pool at a +// glance, and the colour is what carries that shape onto the face cards, where +// a composition can then be read without opening it. +// +// ## Colour here is categorical, and that distinction is the whole licence +// +// `manager-theme.css` sets the rule this module has to live inside: `--w-ok` / +// `--w-warn` / `--w-danger` are the only colours that *mean* something, and the +// act colours are navigation, never state. A family hue is the second kind. It +// says which vocabulary a token comes from and nothing about whether anything +// is wrong, which is why it is only ever a 3px stripe or a 6px dot — never a +// card border (selection and reach own that channel) and never a pill (status +// owns that one). Three channels, three meanings, no overlap. +// +// It is also never the only carrier: the type token itself is printed in mono +// on every card, and each group wears its family's words as a heading. Someone +// who cannot separate the hues loses nothing but the shortcut. +// +// ## Only what the registry declares gets classified +// +// `familyOf` reads the vendored claim-type table's roots and refuses to invent +// anything beyond them. A token the registry has never seen resolves to +// `unregistered` — not to a family guessed from its spelling — for the same +// reason `claim-sensitivity.ts` will not walk a prefix into a *looser* +// treatment: a local rule that groups `profile.github` under some invented +// "profile" family is a statement about a vocabulary nobody has agreed, drawn +// in a colour that reads as though somebody had. `unregistered` is an honest +// answer and its words on screen say so. + +import { REGISTERED_ROOTS } from "./claim-sensitivity.js"; + +/** The families this console groups by. `unregistered` is a real member, not a + * fallback bucket to be tidied away: it is the answer for every token the + * registry does not declare, which today includes most of what a holder + * invents for themselves. */ +export type Family = "identity" | "contact" | "public" | "gated" | "unregistered"; + +/** Top to bottom, the order the map lays the groups out in — roughly how + * closely a value identifies the person, so the row reads as a gradient rather + * than an alphabet. `unregistered` sits last because it is the group whose + * size is a question rather than a fact about the holder. */ +export const FAMILY_ORDER: readonly Family[] = ["identity", "contact", "public", "gated", "unregistered"]; + +export interface FamilyStyle { + /** The group heading, in the vocabulary of `design-docs/persona-vocabulary.md`. */ + label: string; + /** One line under the heading. Says what the group *is*, never what it + * protects — the mask defends a screen and this colour defends nothing. */ + note: string; + /** The stripe/dot colour, as a token reference so both themes resolve. */ + hue: string; +} + +const STYLES: Readonly> = { + identity: { + label: "Who you are", + note: "names, and what is true of you as a person", + hue: "var(--m-fam-identity)", + }, + contact: { + label: "How to reach you", + note: "an address someone can arrive at", + hue: "var(--m-fam-contact)", + }, + public: { + label: "Where you already appear", + note: "handles, pages and roles others can already see", + hue: "var(--m-fam-public)", + }, + gated: { + // Not "sensitive" and not "protected": the registry marks these + // `release: stepUp`, so the agent refuses a disclosure until the holder + // approves that particular one. That is an agent behaviour worth naming, + // and it is the only claim this label makes. + label: "Your agent asks first", + note: "the registry gates these — a disclosure needs your approval each time", + hue: "var(--m-fam-gated)", + }, + unregistered: { + label: "Not in the registry", + note: "your agent's claim-type table does not declare these, so they are treated as the most private kind", + hue: "var(--m-fam-unregistered)", + }, +}; + +export function familyStyle(family: Family): FamilyStyle { + return STYLES[family]; +} + +/** + * The family of a claim type. + * + * Matched on the **root segment only**, and only when the registry declares + * that root. `payment.giftCard` is `gated` because `payment` is a declared + * family entry; `profile.github` is `unregistered` because no `profile` entry + * exists, and inventing one here would put a colour on a grouping the registry + * has never agreed to. + * + * `x:` is the open extension namespace and is unregistered by construction — + * tested first so `x:name.legal` cannot borrow `name`'s group, exactly as + * `treatmentOf` refuses to let it borrow `name`'s mask. + */ +export function familyOf(type: string): Family { + if (type.startsWith("x:")) return "unregistered"; + const root = type.split(".")[0] ?? ""; + if (!REGISTERED_ROOTS.has(root)) return "unregistered"; + switch (root) { + case "name": + case "person": + return "identity"; + case "email": + case "phone": + case "address": + return "contact"; + case "account": + case "url": + case "org": + return "public"; + case "payment": + case "gov": + return "gated"; + default: + // A root the registry declares and this file has not placed. It reads as + // unclassified rather than being forced into the nearest group, and + // `manager-attribute-family.test.mts` fails on it — a re-sync that adds a + // vocabulary should be a decision someone makes, not a silent regrouping. + return "unregistered"; + } +} diff --git a/packages/extension/src/manager/claim-sensitivity.ts b/packages/extension/src/manager/claim-sensitivity.ts index ff756dd..276d3f4 100644 --- a/packages/extension/src/manager/claim-sensitivity.ts +++ b/packages/extension/src/manager/claim-sensitivity.ts @@ -126,6 +126,20 @@ const REGISTERED: Readonly> = { "org.role": { sensitivity: "normal", mask: "none" }, }; +/** + * The first segment of every token the table above declares. + * + * Derived rather than written out, so it cannot drift from the table on a + * re-sync — a root that appears here without anyone editing this line is the + * registry having grown one, which is exactly what `attribute-family.ts` wants + * to be told about. It is the only thing outside this module that may ask what + * the registry *covers*: whether a token is known is a registry question, + * while what a family means on screen is a console one. + */ +export const REGISTERED_ROOTS: ReadonlySet = new Set( + Object.keys(REGISTERED).map((token) => token.split(".")[0]!), +); + /** * How this type's values are treated — `CLAIM-TYPES.md` §4, minus the rule * this console cannot take part in. diff --git a/packages/extension/src/manager/identity-graph.ts b/packages/extension/src/manager/identity-graph.ts index 797e4ec..6fd8943 100644 --- a/packages/extension/src/manager/identity-graph.ts +++ b/packages/extension/src/manager/identity-graph.ts @@ -169,16 +169,51 @@ export function buildGraph( return { attributes: attributeNodes, faces, contexts: contextNodes, links }; } +/** + * Which way the light travelled to reach a node. + * + * The map paints these two directions in two different colours, and that is + * the whole reason the flow is carried here rather than inferred while + * drawing: `down` is a copy **leaving** the holder, `up` is what a context + * **holds** of them. A component that only knew "lit" would have to re-derive + * the asymmetry `reachOf` already computed, in a file with no tests. + */ +export type Flow = "self" | "down" | "up"; + +/** The four things a selection can light. */ +export type NodeKind = "attribute" | "face" | "persona" | "context"; + +/** One key across all four kinds, so a single map can carry every flow. An + * attribute id and a face id are both opaque strings and could collide. */ +export function flowKey(kind: NodeKind, id: string): string { + return `${kind} ${id}`; +} + /** Everything a selection reaches, in every direction it can reach. */ export interface Reach { attributeIds: Set; faceIds: Set; personaKeys: Set; contextIds: Set; + /** How each lit node was reached, keyed by {@link flowKey}. A persona's id + * here is its {@link personaKey}. */ + flow: Map; } function empty(): Reach { - return { attributeIds: new Set(), faceIds: new Set(), personaKeys: new Set(), contextIds: new Set() }; + return { + attributeIds: new Set(), + faceIds: new Set(), + personaKeys: new Set(), + contextIds: new Set(), + flow: new Map(), + }; +} + +/** Read a node's flow, or `null` when it is not lit at all. Saves every caller + * spelling the key and re-deciding what an absent entry means. */ +export function flowOf(reach: Reach, kind: NodeKind, id: string): Flow | null { + return reach.flow.get(flowKey(kind, id)) ?? null; } /** @@ -199,37 +234,57 @@ export function reachOf(graph: IdentityGraph, selection: Selection | null): Reac const out = empty(); if (!selection) return out; + // The selected node's own flow is written first and never overwritten, so a + // node cannot be reported as travelled-to when it is the thing travelled + // from. Everything after it keeps the first direction it was reached by, + // which is the direction a reader followed with their eye. + const mark = (kind: NodeKind, id: string, flow: Flow) => { + const key = flowKey(kind, id); + if (!out.flow.has(key)) out.flow.set(key, flow); + }; + const facesWithAttribute = (attributeId: string) => graph.faces.filter((f) => f.attributeIds.includes(attributeId)); const wearersOf = (faceId: string) => graph.contexts.flatMap((c) => c.personas.filter((p) => p.faceId === faceId)); const lightFaceDown = (faceId: string) => { out.faceIds.add(faceId); + mark("face", faceId, "down"); for (const p of wearersOf(faceId)) { out.personaKeys.add(personaKey(p.contextId, p.did)); out.contextIds.add(p.contextId); + mark("persona", personaKey(p.contextId, p.did), "down"); + mark("context", p.contextId, "down"); } }; const lightFaceUp = (faceId: string) => { out.faceIds.add(faceId); + mark("face", faceId, "up"); const face = graph.faces.find((f) => f.id === faceId); - for (const id of face?.attributeIds ?? []) out.attributeIds.add(id); + for (const id of face?.attributeIds ?? []) { + out.attributeIds.add(id); + mark("attribute", id, "up"); + } }; switch (selection.kind) { case "attribute": out.attributeIds.add(selection.id); + mark("attribute", selection.id, "self"); for (const face of facesWithAttribute(selection.id)) lightFaceDown(face.id); break; case "face": + mark("face", selection.id, "self"); lightFaceUp(selection.id); lightFaceDown(selection.id); break; case "context": { out.contextIds.add(selection.id); + mark("context", selection.id, "self"); const ctx = graph.contexts.find((c) => c.id === selection.id); for (const p of ctx?.personas ?? []) { out.personaKeys.add(personaKey(p.contextId, p.did)); + mark("persona", personaKey(p.contextId, p.did), "up"); if (p.faceId) lightFaceUp(p.faceId); } break; @@ -237,6 +292,8 @@ export function reachOf(graph: IdentityGraph, selection: Selection | null): Reac case "persona": { out.contextIds.add(selection.contextId); out.personaKeys.add(personaKey(selection.contextId, selection.did)); + mark("persona", personaKey(selection.contextId, selection.did), "self"); + mark("context", selection.contextId, "up"); const ctx = graph.contexts.find((c) => c.id === selection.contextId); const p = ctx?.personas.find((x) => x.did === selection.did); if (p?.faceId) lightFaceUp(p.faceId); @@ -258,3 +315,45 @@ export function attributeReach( ); return { faces, contextIds: [...new Set(wearers.map((w) => w.contextId))], wearers }; } +/** + * What a context is to this holder — the one predicate, computed once. + * + * The map used to answer this question twice with two different tests: the + * header counted contexts where a persona *wears a face*, the band drew a card + * for every context holding a persona *record*, and the fold counted whatever + * the band left over. A context holding an unbound persona fell between them, + * so the three numbers did not add up to the number of contexts — the header + * denied a card the band was drawing. + * + * `identified` is the state that was missing, and it is not a rounding error. + * `persona/binding/list/1.0` enumerates the personas *present* in a context and + * carries `bound` separately, so removing a face leaves the persona: that + * context still knows an identifier of the holder's, and can address it, while + * holding none of their attributes. "Known" overstates it and "absent" + * understates it, and only one of those two errors is visible. + */ +export type ContextStanding = "known" | "identified" | "absent" | "unreadable"; + +export function standingOf(ctx: ContextNode): ContextStanding { + if (ctx.unreadable !== undefined) return "unreadable"; + if (ctx.personas.some((p) => p.faceId !== null)) return "known"; + if (ctx.personas.length > 0) return "identified"; + return "absent"; +} + +/** Every context counted once, under exactly one standing. `known + + * identified + unreadable + absent === total` is the property the header was + * getting wrong, and `manager-identity-graph.test.mts` pins it. */ +export interface ContextTally { + known: number; + identified: number; + absent: number; + unreadable: number; + total: number; +} + +export function tallyContexts(graph: IdentityGraph): ContextTally { + const out: ContextTally = { known: 0, identified: 0, absent: 0, unreadable: 0, total: graph.contexts.length }; + for (const ctx of graph.contexts) out[standingOf(ctx)] += 1; + return out; +} diff --git a/packages/extension/src/manager/panes/persona-map.tsx b/packages/extension/src/manager/panes/persona-map.tsx index 066147c..b9a4109 100644 --- a/packages/extension/src/manager/panes/persona-map.tsx +++ b/packages/extension/src/manager/panes/persona-map.tsx @@ -37,13 +37,19 @@ import { formatInstant } from "../format.js"; import type { Authority, Parties } from "../use-vta.js"; import { attributeReach, + flowOf, personaKey, reachOf, + standingOf, + tallyContexts, type AttributeNode, + type ContextTally, + type Flow, type IdentityGraph, type PersonaNode, type Selection, } from "../identity-graph.js"; +import { familyOf, familyStyle, FAMILY_ORDER, type Family } from "../attribute-family.js"; import { AttributeEditor, BindingForm, @@ -95,6 +101,24 @@ function personaLabel(did: string): string { return did.length > 22 ? `${did.slice(0, 22)}…` : did; } +/** + * The header's account of the contexts, which has to be exactly that: every + * context, counted once, under one standing. + * + * It used to say "known in 1 of 12" while the band below drew two cards and the + * fold claimed ten — three numbers from three different tests, one of which + * quietly denied a card the reader could see. Every clause here comes from + * `tallyContexts`, so they cannot disagree, and the clauses that are zero are + * left out rather than printed as an absence nobody asked about. + */ +function standingWords(tally: ContextTally): string { + const parts = [`known in ${tally.known}`]; + if (tally.identified > 0) parts.push(`an identifier in ${tally.identified}`); + if (tally.unreadable > 0) parts.push(`${tally.unreadable} unreadable`); + parts.push(`absent from ${tally.absent}`); + return parts.join(" · "); +} + function staleWords(reason: string | undefined): string { switch (reason) { case "expired": @@ -165,34 +189,82 @@ function curve(from: Box, to: Box): string { // ── Cards ─────────────────────────────────────────────────────────────────── -type Mood = "plain" | "lit" | "selected" | "dim"; +/** + * The two directions, in colour. + * + * `reachOf` has always known that a selection reaches asymmetrically — down + * from an attribute to where copies of it went, up from a context to what it + * holds — and the map used to paint both in one accent, which told a reader + * *that* things were connected and left the direction to be worked out from + * the layout. Two hues say it outright. + * + * Down borrows `--m-act-data`, which the contexts band already wears: a + * downward path is coloured by where it ends, so the hue is the destination + * rather than a fifth thing to learn. Up keeps the accent. Neither is a + * semantic colour — `--w-ok` / `--w-warn` / `--w-danger` still mean the only + * things colour means here — and the words on the cards say the same thing + * without them. + */ +const FLOW_COLOUR: Record<"down" | "up", { edge: string; wash: string }> = { + down: { edge: "var(--m-act-data)", wash: "var(--m-act-data-soft)" }, + up: { edge: c.accent, wash: c.accentSoft }, +}; -function cardStyle(mood: Mood, extra?: React.CSSProperties): React.CSSProperties { +/** + * An edge is lit only when both of its ends are, and it takes its colour from + * the end the eye travelled *to* — the one that is not the selection. A dark + * end means the edge is not on the path at all, whatever else its ends happen + * to be lit by. + */ +function edgeFlow(from: Flow | null, to: Flow | null): "down" | "up" | null { + if (!from || !to) return null; + const far = from === "self" ? to : from; + return far === "self" ? "down" : far; +} + +type Mood = "plain" | "self" | "down" | "up" | "dim"; + +/** + * `stripe` is the family colour, drawn as an inset shadow rather than a + * `borderLeft`. Two reasons, and the second is the one that bites: the border + * is already carrying selection and reach, so a left border in a third colour + * would break that channel's own rule — and React warns (correctly) about a + * style object that sets the `border` shorthand on one render and `borderLeft` + * on another, which is exactly what a mood change does. + */ +function cardStyle(mood: Mood, extra?: React.CSSProperties, stripe?: string): React.CSSProperties { + const lit = mood === "down" || mood === "up" ? FLOW_COLOUR[mood] : null; const ring = - mood === "selected" - ? { border: `2px solid ${c.accent}`, boxShadow: `0 0 0 4px ${c.accentSoft}` } - : mood === "lit" - ? { border: `1px solid ${c.accent}` } - : { border: `1px solid ${c.line}` }; + mood === "self" + ? { border: `2px solid ${c.accent}`, background: c.surface } + : lit + ? { border: `1px solid ${lit.edge}`, background: lit.wash } + : { border: `1px solid ${c.line}`, background: c.surface }; + const shadows = [ + stripe ? `inset 3px 0 0 ${stripe}` : null, + mood === "self" ? `0 0 0 4px ${c.accentSoft}` : null, + ].filter(Boolean); return { - background: c.surface, borderRadius: "var(--w-r-md)", - padding: "10px 12px", + padding: stripe ? "10px 12px 10px 15px" : "10px 12px", + ...(shadows.length > 0 ? { boxShadow: shadows.join(", ") } : {}), display: "flex", flexDirection: "column", gap: 4, cursor: "pointer", opacity: mood === "dim" ? 0.45 : 1, - transition: "opacity 120ms ease, border-color 120ms ease", + transition: "opacity 120ms ease, border-color 120ms ease, background 120ms ease", boxSizing: "border-box", ...ring, ...extra, }; } -function moodOf(selected: boolean, lit: boolean, anySelection: boolean): Mood { - if (selected) return "selected"; - if (lit) return "lit"; +/** A node's mood follows its flow exactly: the selection itself, the two + * directions, or dimmed because something else is selected. */ +function moodOf(flow: Flow | null, anySelection: boolean): Mood { + if (flow === "self") return "self"; + if (flow) return flow; return anySelection ? "dim" : "plain"; } @@ -313,19 +385,39 @@ export function IdentityMap({ // is not that, and on an agent with a dozen contexts eleven cards saying // "nobody" drown the one that matters. So the empty ones fold into a single // row unless asked for — but a context the agent would not answer for stays - // visible, because "could not ask" is not "nobody is known here". - const isKnown = (ctx: (typeof graph.contexts)[number]) => ctx.personas.length > 0 || ctx.unreadable !== undefined; - const knownContexts = graph.contexts.filter(isKnown); - const emptyContexts = graph.contexts.filter((ctx) => !isKnown(ctx)); - const shownContexts = showEmpty ? graph.contexts : knownContexts; + // visible, because "could not ask" is not "nobody is known here", and one + // holding an unbound persona stays visible too, because "knows an identifier + // of yours" is not that either. + // + // Which is which is `standingOf`, in the model with tests, and the header + // below counts the same predicate. Two tests for one question is how the + // arithmetic came apart last time. + const presentContexts = graph.contexts.filter((ctx) => standingOf(ctx) !== "absent"); + const emptyContexts = graph.contexts.filter((ctx) => standingOf(ctx) === "absent"); + const shownContexts = showEmpty ? graph.contexts : presentContexts; const stage = useRef(null); const { boxes, size, register } = useBoxes(stage, [graph, editing, selection?.kind]); const reach = useMemo(() => reachOf(graph, selection), [graph, selection]); + // Grouped rather than one long row: see `attribute-family.ts` for what a + // family is and why only the registry's own roots get one. A family with no + // members draws no heading — the point is the shape of *this* pool, not a + // checklist of the vocabulary. + const grouped = useMemo(() => { + const byFamily = new Map(); + for (const a of graph.attributes) { + const family = familyOf(a.type); + byFamily.set(family, [...(byFamily.get(family) ?? []), a]); + } + return FAMILY_ORDER.filter((f) => byFamily.has(f)).map((family) => ({ + family, + members: byFamily.get(family)!, + })); + }, [graph.attributes]); const any = selection !== null; const linkedFaces = useMemo(() => new Set(graph.links.map((l) => l.faceId)), [graph.links]); - const known = graph.contexts.filter((ctx) => ctx.personas.some((p) => p.faceId)).length; + const tally = tallyContexts(graph); const select = (next: Selection) => setSelection((cur) => (cur && JSON.stringify(cur) === JSON.stringify(next) ? null : next)); @@ -355,7 +447,7 @@ export function IdentityMap({ // ── edges ── const edges = useMemo(() => { - const out: { d: string; kind: "attribute" | "wear" | "link"; lit: boolean }[] = []; + const out: { d: string; kind: "attribute" | "wear" | "link"; flow: "down" | "up" | null }[] = []; for (const face of graph.faces) { const fb = boxes.get(`face:${face.id}`); if (!fb) continue; @@ -365,19 +457,20 @@ export function IdentityMap({ out.push({ d: curve(ab, fb), kind: "attribute", - lit: reach.attributeIds.has(attributeId) && reach.faceIds.has(face.id), + flow: edgeFlow(flowOf(reach, "attribute", attributeId), flowOf(reach, "face", face.id)), }); } for (const ctx of graph.contexts) { for (const p of ctx.personas) { if (p.faceId !== face.id) continue; - const pb = boxes.get(`persona:${personaKey(ctx.id, p.did)}`); + const key = personaKey(ctx.id, p.did); + const pb = boxes.get(`persona:${key}`); if (!pb) continue; const isLink = showLinks && linkedFaces.has(face.id); out.push({ d: curve(fb, pb), kind: isLink ? "link" : "wear", - lit: reach.faceIds.has(face.id) && reach.personaKeys.has(personaKey(ctx.id, p.did)), + flow: edgeFlow(flowOf(reach, "face", face.id), flowOf(reach, "persona", key)), }); } } @@ -385,8 +478,11 @@ export function IdentityMap({ return out; }, [graph, boxes, reach, showLinks, linkedFaces]); + // An unlit edge is now neutral rather than teal. Teal used to mean "a face is + // worn here" at rest and "this is the path you selected" when lit, which is + // one hue doing two jobs; at rest the line itself already says it. const stroke = (e: (typeof edges)[number]) => - e.kind === "link" ? c.danger : e.lit ? c.accent : e.kind === "wear" ? "var(--m-act-data)" : c.line; + e.kind === "link" ? c.danger : e.flow ? FLOW_COLOUR[e.flow].edge : c.line; return (
@@ -402,9 +498,7 @@ export function IdentityMap({
{graph.attributes.length} attribute{graph.attributes.length === 1 ? "" : "s"} {graph.faces.length} face{graph.faces.length === 1 ? "" : "s"} - 0 ? "accent" : "off"}> - known in {known} of {graph.contexts.length} context{graph.contexts.length === 1 ? "" : "s"} - + 0 ? "accent" : "off"}>{standingWords(tally)} {graph.links.length > 0 && ( {graph.links.length} link{graph.links.length === 1 ? "" : "s"} )} @@ -428,6 +522,22 @@ export function IdentityMap({ )} {denied && {denied}} + {/* The key appears with the first selection and not before: a legend for + colours that are not yet on screen is noise, and the two hues only + exist while something is selected. */} + {any && ( +
+ + + goes down — a copy of this leaves you + + + + comes up — what that context holds of yours + +
+ )} +
{ if (e.target === e.currentTarget) setSelection(null); }}> @@ -437,59 +547,83 @@ export function IdentityMap({ height={size.h} fill="none" > - {edges.filter((e) => !e.lit && e.kind !== "link").map((e, i) => ( + {edges.filter((e) => !e.flow && e.kind !== "link").map((e, i) => ( ))} {edges.filter((e) => e.kind === "link").map((e, i) => ( - + ))} - {edges.filter((e) => e.lit && e.kind !== "link").map((e, i) => ( - + {edges.filter((e) => e.flow && e.kind !== "link").map((e, i) => ( + ))} {/* ── Attributes ── */}
-
- {graph.attributes.map((f) => { - const selected = selection?.kind === "attribute" && selection.id === f.id; - const prov = provenanceWords(f.provenance); - const linked = valueLinked.get(f.id); +
+ {grouped.map(({ family, members }) => { + const fam = familyStyle(family); return ( -
select({ kind: "attribute", id: f.id })} - style={cardStyle(moodOf(selected, reach.attributeIds.has(f.id), any), { width: 222, ...(f.stale ? { opacity: any && !reach.attributeIds.has(f.id) && !selected ? 0.35 : 0.72 } : {}) })} - > -
- {f.type} - {f.stale && {staleWords(f.staleReason)}} -
- {/* The label and the value are two spans rather than one - string, because the value now carries a control of its - own — and a *Show* that scrolled out of a card clipped to - one line would be a control nobody could press. */} -
- {f.label && ( - {f.label} · - )} - +
+
+ + {fam.label} + {fam.note}
-
- {prov.text} - {linked && {linked.severity === "high" ? "links" : "may link"}} +
+ {members.map((f) => { + const flow = flowOf(reach, "attribute", f.id); + const prov = provenanceWords(f.provenance); + const linked = valueLinked.get(f.id); + return ( +
select({ kind: "attribute", id: f.id })} + // The family stripe survives every mood, because which family + // a value comes from does not change with what is selected — + // and it is the only place a family hue touches a card, so + // the border and the pills keep meaning what they meant. + style={cardStyle( + moodOf(flow, any), + { width: 222, ...(f.stale ? { opacity: any && flow === null ? 0.35 : 0.72 } : {}) }, + fam.hue, + )} + > +
+ {f.type} + {f.stale && {staleWords(f.staleReason)}} +
+ {/* The label and the value are two spans rather than one + string, because the value now carries a control of its + own — and a *Show* that scrolled out of a card clipped to + one line would be a control nobody could press. */} +
+ {f.label && ( + {f.label} · + )} + +
+
+ {prov.text} + {linked && {linked.severity === "high" ? "links" : "may link"}} +
+
+ ); + })}
); })} - setEditing({ kind: "attribute" })} disabled={null} /> +
+ setEditing({ kind: "attribute" })} disabled={null} /> +
@@ -498,7 +632,6 @@ export function IdentityMap({
{graph.faces.map((face) => { - const selected = selection?.kind === "face" && selection.id === face.id; const wearers = graph.contexts.flatMap((ctx) => ctx.personas.filter((p) => p.faceId === face.id)); const linked = linkedFaces.has(face.id); return ( @@ -506,18 +639,24 @@ export function IdentityMap({ key={face.id} ref={register(`face:${face.id}`)} onClick={() => select({ kind: "face", id: face.id })} - style={cardStyle(moodOf(selected, reach.faceIds.has(face.id), any), { width: 330, padding: "12px 14px", gap: 8 })} + style={cardStyle(moodOf(flowOf(reach, "face", face.id), any), { width: 330, padding: "12px 14px", gap: 8 })} >
{face.name} {linked && showLinks && links {wearers.length} personas}
+ {/* Each chip carries its attribute's family dot, which is + what makes a face readable without opening it: four + contact dots and a gated one is a different offer from + five names, and the card can say so in the space it + already has. */} {face.attributeIds.map((id) => { const attribute = graph.attributes.find((f) => f.id === id); const lit = reach.attributeIds.has(id) && reach.faceIds.has(face.id); return ( - + + {attribute?.type ?? id} ); @@ -561,19 +700,19 @@ export function IdentityMap({ ) : undefined } /> - {knownContexts.length === 0 && !showEmpty && ( + {presentContexts.length === 0 && !showEmpty && (
You are not known anywhere yet. Nothing below the line holds a copy of anything.
)}
{shownContexts.map((ctx) => { - const selected = selection?.kind === "context" && selection.id === ctx.id; + const standing = standingOf(ctx); return (
select({ kind: "context", id: ctx.id })} - style={cardStyle(moodOf(selected, reach.contextIds.has(ctx.id), any), { padding: "12px 14px", gap: 10, minHeight: 120 })} + style={cardStyle(moodOf(flowOf(reach, "context", ctx.id), any), { padding: "12px 14px", gap: 10, minHeight: 120 })} >
@@ -584,15 +723,25 @@ export function IdentityMap({
{ctx.unreadable ? ( Your agent would not say who is known here — {ctx.unreadable} - ) : ctx.personas.length === 0 ? ( + ) : standing === "absent" ? ( Nobody yet. This context knows nothing about you. ) : (
- Known here as + {/* Two headings, because they are two different answers. + A persona that wears nothing is still a persona: the + context knows an identifier of the holder's and can + address it, while holding none of their attributes. + Calling that "known here as" overstates what left, and + folding it in with the contexts that hold nothing + hides an identifier the holder has out there. */} + + {standing === "identified" ? "An identifier only" : "Known here as"} + {ctx.personas.map((p) => { const key = personaKey(ctx.id, p.did); - const pSelected = selection?.kind === "persona" && selection.contextId === ctx.id && selection.did === p.did; - const lit = reach.personaKeys.has(key); + const pFlow = flowOf(reach, "persona", key); + const wash = pFlow === "self" ? c.accentSoft : pFlow ? FLOW_COLOUR[pFlow].wash : c.raised; + const edge = pFlow === "self" ? c.accent : pFlow ? FLOW_COLOUR[pFlow].edge : c.line; const linked = showLinks && p.faceId !== null && linkedFaces.has(p.faceId); return (
{personaLabel(p.did)} @@ -619,6 +768,11 @@ export function IdentityMap({ })}
)} + {standing === "identified" && ( + + This context can address that identifier. It holds nothing else of yours. + + )}
{/* A persona selected in this context is the one the holder is asking about, so the button acts on it rather diff --git a/packages/extension/tests/manager-attribute-family.test.mts b/packages/extension/tests/manager-attribute-family.test.mts new file mode 100644 index 0000000..cf357a7 --- /dev/null +++ b/packages/extension/tests/manager-attribute-family.test.mts @@ -0,0 +1,81 @@ +// Which family a claim type is grouped and coloured by. +// +// The stripe is a shortcut, and a shortcut that points at the wrong family is +// worse than none: it groups a holder's attributes under a heading the registry +// never agreed to, in a colour that reads as though somebody had checked. So +// the two directions of error are tested separately — a registered token +// landing in the wrong group, and an *un*registered one landing in any group at +// all. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { familyOf, familyStyle, FAMILY_ORDER, type Family } from "../src/manager/attribute-family.ts"; +import { REGISTERED_ROOTS } from "../src/manager/claim-sensitivity.ts"; + +test("the registry's own vocabularies land where their words say", () => { + assert.equal(familyOf("name.legal"), "identity"); + assert.equal(familyOf("person.birthDate"), "identity"); + assert.equal(familyOf("email.work"), "contact"); + assert.equal(familyOf("phone.mobile"), "contact"); + assert.equal(familyOf("address.postal"), "contact"); + assert.equal(familyOf("account.handle"), "public"); + assert.equal(familyOf("org.role"), "public"); + assert.equal(familyOf("gov.id.passport"), "gated"); + assert.equal(familyOf("payment.card"), "gated"); +}); + +test("a token invented under a declared family stays in it", () => { + // The same direction `treatmentOf` walks a prefix in: a family entry the + // registry declares covers what is invented beneath it. + assert.equal(familyOf("payment.giftCard"), "gated"); + assert.equal(familyOf("gov.id.somethingNew"), "gated"); +}); + +test("a token no registry root covers is unregistered, not guessed at", () => { + // `profile.*` and `employer` are what a holder actually types today, and + // neither is in the table. Inventing a "profile" family here would draw a + // grouping nobody has agreed to. + assert.equal(familyOf("profile.github"), "unregistered"); + assert.equal(familyOf("profile.signal"), "unregistered"); + assert.equal(familyOf("employer"), "unregistered"); + assert.equal(familyOf(""), "unregistered"); +}); + +test("the open extension namespace cannot borrow a core token's family", () => { + // Tested first inside `familyOf` for the same reason `treatmentOf` tests it + // first: `x:name.legal` must not inherit `name`. + assert.equal(familyOf("x:name.legal"), "unregistered"); + assert.equal(familyOf("x:payment.card"), "unregistered"); +}); + +test("every root the registry declares has been placed in a family", () => { + // A re-sync that adds a vocabulary fails here rather than quietly colouring + // it as unregistered — which would look identical to a token nobody has + // reasoned about, and be a different fact entirely. + const unplaced = [...REGISTERED_ROOTS].filter((root) => familyOf(`${root}.anything`) === "unregistered"); + assert.deepEqual(unplaced, [], "place these roots in attribute-family.ts"); +}); + +test("every family has words and a hue, and the order names them all", () => { + const families: Family[] = ["identity", "contact", "public", "gated", "unregistered"]; + assert.deepEqual([...FAMILY_ORDER].sort(), [...families].sort(), "a family with no place in the order never draws"); + for (const family of families) { + const style = familyStyle(family); + assert.ok(style.label.length > 0 && style.note.length > 0); + assert.match(style.hue, /^var\(--m-fam-[a-z]+\)$/, "colour comes from a token, so both themes resolve"); + } +}); + +test("no family's words claim the colour protects anything", () => { + // The stripe is categorical. `claim-sensitivity.ts` is emphatic that even the + // mask defends a screen and not the page, and a heading saying otherwise + // would be the console overstating what it does — the one thing this pane + // must never do about the holder's own data. + for (const family of FAMILY_ORDER) { + const { label, note } = familyStyle(family); + const words = `${label} ${note}`.toLowerCase(); + for (const overclaim of ["secure", "protected", "safe", "encrypted", "hidden from"]) { + assert.ok(!words.includes(overclaim), `"${overclaim}" claims a protection this colour does not give: ${words}`); + } + } +}); diff --git a/packages/extension/tests/manager-identity-graph.test.mts b/packages/extension/tests/manager-identity-graph.test.mts index 4b36a0c..d261a8e 100644 --- a/packages/extension/tests/manager-identity-graph.test.mts +++ b/packages/extension/tests/manager-identity-graph.test.mts @@ -12,8 +12,11 @@ import assert from "node:assert/strict"; import { buildGraph, attributeReach, + flowOf, personaKey, reachOf, + standingOf, + tallyContexts, type ContextInput, } from "../src/manager/identity-graph.ts"; @@ -138,3 +141,78 @@ test("attributeReach says where an attribute goes in the words the strip uses", assert.deepEqual(r.contextIds.sort(), ["openvtc", "vta"]); assert.equal(r.wearers.length, 2); }); + +// ── Which way the light travelled ─────────────────────────────────────────── +// +// The map paints the two directions in two colours, so a wrong flow is a +// picture that says a copy left the holder when a context merely holds one, or +// the reverse. Every case below names the direction as well as the node, +// because "lit" was what the pane used to know and it was not enough. + +test("selecting an attribute sends everything below it downwards", () => { + const r = reachOf(G, { kind: "attribute", id: "f-phone" }); + assert.equal(flowOf(r, "attribute", "f-phone"), "self"); + assert.equal(flowOf(r, "face", "F-dev"), "down"); + assert.equal(flowOf(r, "persona", personaKey("openvtc", "did:a")), "down"); + assert.equal(flowOf(r, "context", "openvtc"), "down"); +}); + +test("selecting a context pulls everything above it upwards", () => { + const r = reachOf(G, { kind: "context", id: "openvtc" }); + assert.equal(flowOf(r, "context", "openvtc"), "self"); + assert.equal(flowOf(r, "persona", personaKey("openvtc", "did:a")), "up"); + assert.equal(flowOf(r, "face", "F-dev"), "up"); + assert.equal(flowOf(r, "attribute", "f-phone"), "up"); +}); + +test("a face is the one selection that splits — up to its attributes, down to its wearers", () => { + const r = reachOf(G, { kind: "face", id: "F-dev" }); + assert.equal(flowOf(r, "face", "F-dev"), "self"); + assert.equal(flowOf(r, "attribute", "f-name"), "up"); + assert.equal(flowOf(r, "persona", personaKey("vta", "did:b")), "down"); + assert.equal(flowOf(r, "context", "vta"), "down"); +}); + +test("a node nothing reaches has no flow at all", () => { + const r = reachOf(G, { kind: "attribute", id: "f-phone" }); + assert.equal(flowOf(r, "attribute", "f-signal"), null, "not lit is not a direction"); + assert.equal(flowOf(reachOf(G, null), "context", "openvtc"), null); +}); + +// ── One predicate for what a context is ───────────────────────────────────── + +test("a persona wearing nothing leaves its context identified, not known and not absent", () => { + // This is the vta card in the live console: the face was unbound, the + // persona record stayed, and `binding/list` keeps enumerating it. The + // context knows an identifier and holds no attributes — a third answer. + const g = buildGraph(ATTRS, FACES, [ctx("vta", [{ did: "did:c", faceId: null }])]); + assert.equal(standingOf(g.contexts[0]!), "identified"); +}); + +test("every context is counted exactly once, so the header's numbers close", () => { + const g = buildGraph(ATTRS, FACES, [ + ...CTXS, + { id: "dark", label: "dark", bindings: { ok: false, error: "refused" } }, + ]); + const tally = tallyContexts(g); + assert.deepEqual(tally, { known: 2, identified: 0, absent: 1, unreadable: 1, total: 4 }); + assert.equal( + tally.known + tally.identified + tally.absent + tally.unreadable, + tally.total, + "the band, the header and the fold all read this — they cannot be allowed to disagree", + ); +}); + +test("a context holding one bound and one unbound persona is known, not identified", () => { + // `vta` in the fixture holds both. Whichever way it were counted twice, one + // of the two numbers on screen would be wrong. + assert.equal(standingOf(G.contexts.find((x) => x.id === "vta")!), "known"); + assert.deepEqual(tallyContexts(G), { known: 2, identified: 0, absent: 1, unreadable: 0, total: 3 }); +}); + +test("an unreadable context is never counted as absent", () => { + // "Could not ask" folded in with "holds nothing about you" is the one wrong + // answer this page must not give, and the tally is now where that is decided. + const g = buildGraph(ATTRS, FACES, [{ id: "dark", label: "dark", bindings: { ok: false, error: "refused" } }]); + assert.deepEqual(tallyContexts(g), { known: 0, identified: 0, absent: 0, unreadable: 1, total: 1 }); +}); diff --git a/packages/extension/tests/persona-pane.render.test.mts b/packages/extension/tests/persona-pane.render.test.mts index d87960a..218a3eb 100644 --- a/packages/extension/tests/persona-pane.render.test.mts +++ b/packages/extension/tests/persona-pane.render.test.mts @@ -427,3 +427,104 @@ test("a revealed value does not survive leaving the pane", async () => { assert.doesNotMatch(second.text(), /X1234567/, "coming back must not come back revealed"); await second.unmount(); }); + +// ── The context that was denied and drawn at once ─────────────────────────── +// +// The live console showed a card for a context holding an unbound persona, +// under a band captioned "where you are known", while the header counted it as +// nowhere and the fold row forgot it entirely: one of twelve, ten folded, two +// drawn. Whichever number a reader trusted, one of the others was lying to +// them, and the state underneath — a context that knows an identifier and +// holds no attributes — had no words anywhere on the screen. + +const identified = (id: string, label: string, did: string) => ({ + id, + label, + bindings: { ok: true as const, personas: [{ did, faceId: null, claimCount: 0 }] }, +}); +const knownAs = (id: string, label: string) => ({ + id, + label, + bindings: { + ok: true as const, + personas: [{ did: "did:a", faceId: "p1", faceName: "Developer", claimCount: 1 }], + }, +}); +const DEV = face("p1", "Developer", ["f1"]); + +const map = (graph: ReturnType, profiles = [DEV]) => + h(IdentityMap, { + parties: PARTIES, + authority: HOLDER, + graph, + attributes: FACTS, + profiles, + records: CONTEXTS, + history: [], + onChanged: () => {}, + }); + +test("a context holding an unbound persona is drawn as an identifier, not as knowing you", async () => { + const graph = buildGraph(FACTS, [DEV], [ + knownAs("openvtc", "OpenVTC"), + identified("vta", "Verifiable Trust Agent", "did:webvh:x:webvh.storm.ws:glenn-vta"), + ]); + const a = agent({}); + const ui = await render(map(graph), { chrome: { runtime: { sendMessage: a.sendMessage } } }); + const text = ui.text(); + assert.match(text, /An identifier only/, "the third state has words of its own"); + assert.match(text, /Known here as/, "and the context that does know the holder keeps its own"); + assert.match(text, /can address that identifier/); + await ui.unmount(); +}); + +test("the header counts every context once, so its numbers close", async () => { + const graph = buildGraph(FACTS, [DEV], [ + knownAs("openvtc", "OpenVTC"), + identified("vta", "Verifiable Trust Agent", "did:b"), + { id: "webvh", label: "webvh", bindings: { ok: true, personas: [] } }, + ]); + const a = agent({}); + const ui = await render(map(graph), { chrome: { runtime: { sendMessage: a.sendMessage } } }); + const text = ui.text(); + assert.match(text, /known in 1/); + assert.match(text, /an identifier in 1/); + assert.match(text, /absent from 1(?!\d)/); + // The header used to say one thing and the band another. The fold is the + // third voice, and it must agree with both. + assert.match(text, /Not known in 1 other context\b/); + assert.doesNotMatch(text, /known in 1 of 3/, "the old single-test count is what came apart"); + await ui.unmount(); +}); + +// ── Colour that says which way a copy went ────────────────────────────────── + +test("the direction key appears only once something is selected", async () => { + const graph = buildGraph(FACTS, [DEV], [knownAs("openvtc", "OpenVTC")]); + const a = agent({}); + const ui = await render(map(graph), { chrome: { runtime: { sendMessage: a.sendMessage } } }); + assert.doesNotMatch(ui.text(), /goes down/, "a key for colours nothing is wearing yet is noise"); + + // Selecting an attribute is what puts the two hues on screen. + const card = ui.byText("div", "name.legal"); + assert.ok(card, "the attribute card is on the map"); + await ui.click(card); + const text = ui.text(); + assert.match(text, /goes down/); + assert.match(text, /comes up/); + await ui.unmount(); +}); + +test("attributes are grouped under the family their claim type comes from", async () => { + // `name.legal` and `phone.mobile` are two different registry vocabularies and + // must not end up under one heading; the group's words come from the registry + // rather than from the spelling of the token. + const graph = buildGraph(FACTS, [], []); + const a = agent({}); + const ui = await render(map(graph, []), { chrome: { runtime: { sendMessage: a.sendMessage } } }); + const text = ui.text(); + assert.match(text, /Who you are/); + assert.match(text, /How to reach you/); + assert.doesNotMatch(text, /Not in the registry/, "no unregistered attribute here, so no heading for one"); + await ui.unmount(); +});