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
40 changes: 36 additions & 4 deletions packages/extension/src/manager/panes/persona-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Button, Note, Panel } from "../../ui.js";
import { c, t, font } from "../../theme.js";
import { contextHeading } from "../format.js";
import type { Authority, Parties } from "../use-vta.js";
import type { RevealTarget } from "../reveal-value.js";
import { AttributeEditor, BindingForm, AttributeValue, ProfileEditor } from "./persona-editors.js";
import { StarterForm } from "./persona-starter.js";
import { holderGate } from "../holder-gate.js";
Expand Down Expand Up @@ -88,10 +89,12 @@ function StrangerCard({
attributes,
faceName,
registry,
onReveal,
}: {
attributes: PoolAttribute[];
faceName: string;
registry: ClaimTypeRegistry | null;
onReveal: (target: RevealTarget) => Promise<unknown>;
}) {
const name = attributes.find((f) => f.type === "name" || f.type.startsWith("name."));
const rest = attributes.filter((f) => f !== name);
Expand All @@ -109,7 +112,14 @@ function StrangerCard({
<>
<div style={{ display: "grid" }}>
{name ? (
<AttributeValue registry={registry} type={name.type} value={name.value} style={{ fontSize: t.md, fontWeight: 640 }} />
<AttributeValue
registry={registry}
type={name.type}
value={name.value}
sensitivity={name.sensitivity}
reveal={() => onReveal({ attributeId: name.attributeId, type: name.type })}
style={{ fontSize: t.md, fontWeight: 640 }}
/>
) : (
<span style={{ fontSize: t.md, fontWeight: 640 }}>—</span>
)}
Expand All @@ -120,7 +130,13 @@ function StrangerCard({
{rest.map((f) => (
<span key={f.attributeId} style={{ display: "contents" }}>
<span style={{ color: c.faint, fontFamily: font.mono, fontSize: t.xs }}>{f.label ?? f.type}</span>
<AttributeValue registry={registry} type={f.type} value={f.value} />
<AttributeValue
registry={registry}
type={f.type}
value={f.value}
sensitivity={f.sensitivity}
reveal={() => onReveal({ attributeId: f.attributeId, type: f.type })}
/>
</span>
))}
</div>
Expand Down Expand Up @@ -153,6 +169,7 @@ export function GuidedSetup({
onFinished,
onSkip,
registry,
onReveal,
}: {
parties: Parties;
authority: Authority | null;
Expand All @@ -164,6 +181,15 @@ export function GuidedSetup({
onFinished: (outcome: string) => void;
onSkip: () => void;
registry: ClaimTypeRegistry | null;
/**
* Ask the agent for one value it kept back.
*
* Threaded in with the fix that made this screen honour the holder's own
* `sensitivity`: without it a *Show* on a genuinely withheld value has
* nothing to call, and a button that cannot do what it says is the same
* defect one step along.
*/
onReveal: (target: RevealTarget) => Promise<unknown>;
}) {
const [step, setStep] = useState<Step>(attributes.length === 0 ? 1 : 2);
const [contextId, setContextId] = useState(records[0]?.id ?? "");
Expand Down Expand Up @@ -225,7 +251,13 @@ export function GuidedSetup({
{attributes.map((a) => (
<div key={a.attributeId} style={{ display: "flex", gap: 10, alignItems: "baseline", fontSize: t.sm }}>
<span style={{ fontFamily: font.mono, fontSize: t.xs, color: c.muted, minWidth: 120 }}>{a.type}</span>
<AttributeValue registry={registry} type={a.type} value={a.value} />
<AttributeValue
registry={registry}
type={a.type}
value={a.value}
sensitivity={a.sensitivity}
reveal={() => onReveal({ attributeId: a.attributeId, type: a.type })}
/>
</div>
))}
</div>
Expand Down Expand Up @@ -268,7 +300,7 @@ export function GuidedSetup({
onCancel={() => setStep(1)}
cancelLabel="Back — add more attributes"
/>
<StrangerCard registry={registry} attributes={preview} faceName={faceName} />
<StrangerCard onReveal={onReveal} registry={registry} attributes={preview} faceName={faceName} />
</div>
)}

Expand Down
1 change: 1 addition & 0 deletions packages/extension/src/manager/panes/persona.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ export function PersonaPane({
if (showGuide) {
return (
<GuidedSetup registry={registry.data}
onReveal={reveal}
parties={parties}
authority={authority}
records={records}
Expand Down
136 changes: 136 additions & 0 deletions packages/extension/tests/persona-pane.render.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ test("making a face does not loop the renderer", async () => {
onChanged: () => {},
onFinished: () => {},
onSkip: () => {},
onReveal: async () => ({}),
}),
{ chrome: { runtime: { sendMessage: a.sendMessage } } },
);
Expand Down Expand Up @@ -145,6 +146,7 @@ test("the stranger card starts empty and says so", async () => {
onChanged: () => {},
onFinished: () => {},
onSkip: () => {},
onReveal: async () => ({}),
}),
{ chrome: { runtime: { sendMessage: a.sendMessage } } },
);
Expand All @@ -170,6 +172,7 @@ test("a completed step in the stepper is a way back to it", async () => {
onChanged: () => {},
onFinished: () => {},
onSkip: () => {},
onReveal: async () => ({}),
}),
{ chrome: { runtime: { sendMessage: a.sendMessage } } },
);
Expand Down Expand Up @@ -1222,3 +1225,136 @@ test("the attribute using an unapplied type is marked on its own card", async ()
assert.match(ui.text(), /type not applied/);
await ui.unmount();
});

/**
* Step back to "Add an attribute or two".
*
* The guide opens on step two the moment the holder has any attribute, and the
* panel this section is about — "N attributes so far" — is on step one, which
* is where the report's screenshot was taken. The stepper's completed circles
* are the way back.
*/
async function backToStepOne(ui: Awaited<ReturnType<typeof render>>) {
const back = ui.all('[role="button"]')[0];
if (back) await ui.click(back);
}

// ── The guided setup honours the holder's own decision ──────────────────────

test("a value the holder marked SHOW IT is not drawn as bullets in the guide", async () => {
// Reported from the live console with a screenshot: `profile.github` set to
// *show it* still rendered as ●●●● in "N attributes so far".
//
// The cause was one missing prop. All three `AttributeValue` call sites in
// the guide passed `type` and `value` and never `sensitivity`, so
// `treatmentFor` fell through to the registry — and for an UNREGISTERED token
// the registry's answer is the conservative floor, `high`/`full`. The holder's
// answer existed, was stored, was returned by the agent, and was dropped on
// the way to the component.
//
// Asserted on the token the report named, and on the state the report
// described: the value legible, not the mask absent — a test for "no bullets"
// would also pass if the value vanished entirely.
const shown = {
attributeId: "a9",
type: "profile.github",
valueType: "string" as const,
value: "stormer78",
label: "github",
provenance: { kind: "selfAsserted" as const },
sensitivity: "normal" as const,
version: 1,
updatedAt: "2026-09-09T00:00:00Z",
};
const a = agent({});
const ui = await render(
h(GuidedSetup, {
registry: REGISTRY,
parties: PARTIES,
authority: HOLDER,
records: CONTEXTS,
attributes: [shown],
profiles: [],
onChanged: () => {},
onFinished: () => {},
onSkip: () => {},
onReveal: async () => ({}),
}),
{ chrome: { runtime: { sendMessage: a.sendMessage } } },
);
await backToStepOne(ui);

assert.match(ui.text(), /stormer78/, "the holder said show it and the guide hid it anyway");
assert.doesNotMatch(ui.text(), /●●●●|••••/, "a value marked show it was still masked");
await ui.unmount();
});

test("an unregistered value the holder did NOT decide on stays masked in the guide", async () => {
// The other direction, and the reason the first test is not just "never
// mask": absent is not a decision, so the registry's conservative floor is
// the right answer and must survive the fix.
const undecided = {
attributeId: "a8",
type: "profile.github",
valueType: "string" as const,
value: "stormer78",
provenance: { kind: "selfAsserted" as const },
version: 1,
updatedAt: "2026-09-09T00:00:00Z",
};
const a = agent({});
const ui = await render(
h(GuidedSetup, {
registry: REGISTRY,
parties: PARTIES,
authority: HOLDER,
records: CONTEXTS,
attributes: [undecided],
profiles: [],
onChanged: () => {},
onFinished: () => {},
onSkip: () => {},
onReveal: async () => ({}),
}),
{ chrome: { runtime: { sendMessage: a.sendMessage } } },
);
await backToStepOne(ui);
assert.doesNotMatch(ui.text(), /stormer78/, "an undecided unregistered value was shown in full");
await ui.unmount();
});

test("a DECLARED token keeps the registry's mask even when the holder says show it", async () => {
// §3.3: the axes are independent. The holder's `sensitivity` moves that axis
// only, and `email.work` stays `emailLocal` however they mark it. The
// unregistered exception is narrow and must not widen into this.
const declared = {
attributeId: "a7",
type: "email.work",
valueType: "string" as const,
value: "glenn@acme.example",
provenance: { kind: "selfAsserted" as const },
sensitivity: "normal" as const,
version: 1,
updatedAt: "2026-09-09T00:00:00Z",
};
const a = agent({});
const ui = await render(
h(GuidedSetup, {
registry: REGISTRY,
parties: PARTIES,
authority: HOLDER,
records: CONTEXTS,
attributes: [declared],
profiles: [],
onChanged: () => {},
onFinished: () => {},
onSkip: () => {},
onReveal: async () => ({}),
}),
{ chrome: { runtime: { sendMessage: a.sendMessage } } },
);
await backToStepOne(ui);
assert.doesNotMatch(ui.text(), /glenn@acme\.example/, "a declared token lost its registry mask");
assert.match(ui.text(), /@acme\.example/, "the emailLocal mask should still show the domain");
await ui.unmount();
});
Loading