Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/extension/src/manager-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
137 changes: 137 additions & 0 deletions packages/extension/src/manager/attribute-family.ts
Original file line number Diff line number Diff line change
@@ -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<Record<Family, FamilyStyle>> = {
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";
}
}
14 changes: 14 additions & 0 deletions packages/extension/src/manager/claim-sensitivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,20 @@ const REGISTERED: Readonly<Record<string, ClaimTreatment>> = {
"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<string> = 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.
Expand Down
Loading
Loading