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
25 changes: 19 additions & 6 deletions packages/extension/src/manager/panes/persona-editors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,19 @@ export function AttributeEditor({
existing,
onDone,
onCancel,
cancelLabel = "Cancel",
}: {
parties: Parties;
authority: Authority | null;
existing?: PoolAttribute;
/** Omit where there is nothing to go back to — the guided setup's first step
* has no earlier state, and a "Cancel" that abandons the whole flow is not
* what a person reads it as. */
onCancel?: (() => void) | undefined;
/** "Cancel" unless the caller says otherwise. The guide says "Back", because
* that is where its button goes. */
cancelLabel?: string | undefined;
onDone: () => void;
onCancel: () => void;
}) {
const [type, setType] = useState(existing?.type ?? "");
const [label, setLabel] = useState(existing?.label ?? "");
Expand Down Expand Up @@ -366,9 +373,11 @@ export function AttributeEditor({
>
{busy ? "Saving…" : existing ? "Save" : "Add fact"}
</Button>
<Button kind="quiet" disabled={busy} onClick={onCancel}>
Cancel
</Button>
{onCancel && (
<Button kind="quiet" disabled={busy} onClick={onCancel}>
{cancelLabel}
</Button>
)}
</div>
{denied && <span style={{ fontSize: t.sm, color: c.muted }}>{denied}</span>}
</div>
Expand All @@ -385,6 +394,7 @@ export function ProfileEditor({
onPreview,
onDone,
onCancel,
cancelLabel = "Cancel",
}: {
parties: Parties;
authority: Authority | null;
Expand All @@ -403,6 +413,7 @@ export function ProfileEditor({
onPreview?: ((selection: { ids: string[]; name: string }) => void) | undefined;
onDone: () => void;
onCancel: () => void;
cancelLabel?: string | undefined;
}) {
const [name, setName] = useState(existing?.name ?? "");
const [selected, setSelected] = useState<Set<string>>(
Expand Down Expand Up @@ -561,7 +572,7 @@ export function ProfileEditor({
{busy ? "Saving…" : existing ? "Save" : "Create face"}
</Button>
<Button kind="quiet" disabled={busy} onClick={onCancel}>
Cancel
{cancelLabel}
</Button>
</div>
{denied && <span style={{ fontSize: t.sm, color: c.muted }}>{denied}</span>}
Expand Down Expand Up @@ -1092,6 +1103,7 @@ export function BindingForm({
personaDid: initialDid,
onDone,
onCancel,
cancelLabel = "Cancel",
}: {
parties: Parties;
authority: Authority | null;
Expand All @@ -1102,6 +1114,7 @@ export function BindingForm({
personaDid?: string | undefined;
onDone: (outcome: string) => void;
onCancel?: (() => void) | undefined;
cancelLabel?: string | undefined;
}) {
const [personaDid, setPersonaDid] = useState(initialDid ?? "");
const [profileId, setProfileId] = useState<string>(profiles[0]?.profileId ?? "");
Expand Down Expand Up @@ -1222,7 +1235,7 @@ export function BindingForm({
</Button>
{onCancel && (
<Button kind="quiet" disabled={busy} onClick={onCancel}>
Cancel
{cancelLabel}
</Button>
)}
</div>
Expand Down
36 changes: 30 additions & 6 deletions packages/extension/src/manager/panes/persona-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import { c, t, font } from "../../theme.js";
import { contextHeading } from "../format.js";
import type { Authority, Parties } from "../use-vta.js";
import { AttributeEditor, BindingForm, ProfileEditor, formatValue, holderGate } from "./persona-editors.js";
import { reachableStep } from "../persona-flow.js";

type Step = 1 | 2 | 3;

function Stepper({ step }: { step: Step }) {
function Stepper({ step, reachable, onGo }: { step: Step; reachable: (s: Step) => boolean; onGo: (s: Step) => void }) {
const items: [Step, string][] = [
[1, "Add a fact or two"],
[2, "Make a face"],
Expand All @@ -32,9 +33,20 @@ function Stepper({ step }: { step: Step }) {
<div style={{ display: "flex", alignItems: "center", background: c.surface, border: `1px solid ${c.line}`, borderRadius: "var(--w-r-md)", padding: "12px 18px" }}>
{items.map(([n, title], i) => {
const state = n < step ? "done" : n === step ? "now" : "todo";
// A step you could be on is a way to get there. The ticked circle is
// the affordance a person reaches for first, and until it did anything
// the only route back was a button labelled "Cancel".
const go = reachable(n) && n !== step;
return (
<div key={n} style={{ display: "flex", alignItems: "center", flex: i < items.length - 1 ? 1 : "0 0 auto" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div
role={go ? "button" : undefined}
tabIndex={go ? 0 : undefined}
onClick={go ? () => onGo(n) : undefined}
onKeyDown={go ? (e) => { if (e.key === "Enter" || e.key === " ") onGo(n); } : undefined}
title={go ? `Back to “${title}”` : undefined}
style={{ display: "flex", alignItems: "center", gap: 10, cursor: go ? "pointer" : "default", borderRadius: "var(--w-r-sm)", padding: "2px 6px", margin: "-2px -6px" }}
>
<span
style={{
width: 22,
Expand Down Expand Up @@ -155,12 +167,21 @@ export function GuidedSetup({
<Button kind="quiet" onClick={onSkip}>Skip — I'll build it myself</Button>
</div>
{denied && <Note tone="warn">{denied}</Note>}
<Stepper step={step} />
<Stepper
step={step}
reachable={(s) => reachableStep(s, { facts: attributes.length, faces: profiles.length })}
onGo={setStep}
/>

{step === 1 && (
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr)", gap: 16 }}>
<div style={{ display: "grid", gap: 12, alignContent: "start" }}>
<AttributeEditor key={`new-${attributes.length}`} parties={parties} authority={authority} onDone={onChanged} onCancel={onSkip} />
<AttributeEditor
key={`new-${attributes.length}`}
parties={parties}
authority={authority}
onDone={onChanged}
/>
{attributes.length > 0 && (
<Panel title={`${attributes.length} fact${attributes.length === 1 ? "" : "s"} so far`}>
<div style={{ display: "grid", gap: 6 }}>
Expand Down Expand Up @@ -194,10 +215,11 @@ export function GuidedSetup({
{step === 2 && (
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(0, 1fr)", gap: 16 }}>
<ProfileEditor
key="setup"
key={face?.profileId ?? "new"}
parties={parties}
authority={authority}
attributes={attributes}
{...(face ? { existing: face } : {})}
onPreview={(selection) => {
setTicked(new Set(selection.ids));
setFaceName(selection.name);
Expand All @@ -207,6 +229,7 @@ export function GuidedSetup({
setStep(3);
}}
onCancel={() => setStep(1)}
cancelLabel="Back — add more facts"
/>
<StrangerCard facts={preview} faceName={faceName} />
</div>
Expand Down Expand Up @@ -236,7 +259,8 @@ export function GuidedSetup({
contextLabel={contextHeading(records.find((r) => r.id === contextId), contextId)}
profiles={profiles}
onDone={onFinished}
onCancel={onSkip}
onCancel={() => setStep(2)}
cancelLabel="Back — change the face"
/>
) : (
<Note tone="warn">
Expand Down
19 changes: 19 additions & 0 deletions packages/extension/src/manager/persona-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,22 @@ export function showsGuide(state: { faces: number | null; guiding: boolean; skip
if (state.skipped) return false;
return state.guiding || state.faces === 0;
}

/**
* Whether a holder can jump to a step of the guided setup.
*
* Step one is always reachable — going back to add another fact is the most
* ordinary thing a person wants here, and until the stepper answered clicks
* the only route was a button labelled "Cancel", which reads as abandoning the
* whole flow rather than stepping back one.
*
* The later two are reachable only once they have something to work on: a face
* cannot be composed out of no facts, and a persona cannot wear a face that
* does not exist. Reaching them empty would present a form whose every control
* refuses, which is a worse answer than not offering the step.
*/
export function reachableStep(step: 1 | 2 | 3, have: { facts: number; faces: number }): boolean {
if (step === 1) return true;
if (step === 2) return have.facts > 0;
return have.faces > 0;
}
33 changes: 33 additions & 0 deletions packages/extension/tests/manager-persona-guide.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,36 @@ test("skipping is sticky, even when the last face goes", () => {
// …and it beats `guiding`, so a skip mid-guide is honoured immediately.
assert.equal(s(0, true, true), false);
});

// ── Stepping back ───────────────────────────────────────────────────────────
//
// "Be good to go back a step to add more attributes" — and the affordance a
// person reaches for is the ticked circle in the stepper. Before it answered
// clicks, the only route back was a button labelled "Cancel", which reads as
// abandoning setup rather than stepping back one.

import { reachableStep } from "../src/manager/persona-flow.ts";

const reach = (step: 1 | 2 | 3, facts: number, faces: number) => reachableStep(step, { facts, faces });

test("step one is always reachable, including from a standing start", () => {
assert.equal(reach(1, 0, 0), true);
assert.equal(reach(1, 3, 1), true);
});

test("a face cannot be composed out of no facts", () => {
assert.equal(reach(2, 0, 0), false);
// The pair: one fact is enough to have something to tick.
assert.equal(reach(2, 1, 0), true);
});

test("a persona cannot wear a face that does not exist", () => {
assert.equal(reach(3, 4, 0), false);
assert.equal(reach(3, 4, 1), true);
});

test("an unreachable step is refused rather than shown empty", () => {
// Reaching a step with nothing to work on presents a form whose every
// control refuses — a worse answer than not offering the step at all.
assert.equal(reach(2, 0, 1), false, "facts, not faces, are what step two needs");
});
Loading