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(); +});