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
49 changes: 48 additions & 1 deletion packages/extension/src/manager/panes/persona-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
// on purpose is its own decision.

import { useCallback, useMemo, useState } from "react";
import { personaAttributeDelete, personaProfilePut } from "@openvtc/pnm-core/admin";
import { personaAttributeDelete, personaProfilePut, type PoolFacet } from "@openvtc/pnm-core/admin";
import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona";
import { Button, Note, Pill } from "../../ui.js";
import { c, t, font } from "../../theme.js";
Expand All @@ -60,6 +60,9 @@ import {
type DeletePreview,
} from "../attribute-list.js";
import { AttributeValue } from "./persona-editors.js";
import { worldsOfAttribute } from "../world-model.js";
import { worldHue } from "../world-colour.js";
import type { FacetColour } from "@openvtc/pnm-core/admin";
import type { RevealTarget } from "../reveal-value.js";

/**
Expand Down Expand Up @@ -94,16 +97,51 @@ function TriCheck({
);
}

/**
* The worlds an attribute belongs to, as dots.
*
* Dots and a title rather than named chips: a row already carries a type, a
* value, a provenance and a status, and four more words per row would bury the
* value under its own metadata. The name is on the title and in the editor —
* this is a reminder that the attribute is arranged, not the place a person
* reads the arrangement.
*/
function WorldDots({ worlds }: { worlds: readonly PoolFacet[] }) {
if (worlds.length === 0) return null;
return (
<span
style={{ display: "inline-flex", gap: 3, flex: "0 0 auto" }}
title={`In ${worlds.map((w) => w.name).join(", ")}`}
aria-label={`In ${worlds.map((w) => w.name).join(", ")}`}
>
{worlds.map((w) => (
<span
key={w.facetId}
aria-hidden="true"
style={{
width: 7,
height: 7,
borderRadius: 999,
background: worldHue(w.colour as FacetColour),
}}
/>
))}
</span>
);
}

function Row({
attribute,
selected,
registry,
worlds,
reveal,
onToggle,
onEdit,
}: {
attribute: AttributeNode;
selected: boolean;
worlds: readonly PoolFacet[];
registry: ClaimTypeRegistry | null;
reveal: (target: RevealTarget) => Promise<unknown>;
onToggle: (id: string, shift: boolean) => void;
Expand Down Expand Up @@ -156,6 +194,7 @@ function Row({
<span style={{ fontSize: t.xs, color: prov.tone === "off" ? c.faint : c.muted, whiteSpace: "nowrap" }}>
{prov.text}
</span>
<WorldDots worlds={worlds} />
{attribute.stale && <Pill tone="warn">{staleWords(attribute.staleReason)}</Pill>}
<Button kind="quiet" onClick={() => onEdit(attribute)}>
Edit
Expand All @@ -168,6 +207,7 @@ function Group({
group,
selection,
registry,
worlds,
reveal,
onToggleRow,
onToggleGroup,
Expand All @@ -176,6 +216,7 @@ function Group({
group: ListGroup;
selection: ReadonlySet<string>;
registry: ClaimTypeRegistry | null;
worlds: readonly PoolFacet[];
reveal: (target: RevealTarget) => Promise<unknown>;
onToggleRow: (id: string, shift: boolean) => void;
onToggleGroup: (group: ListGroup) => void;
Expand Down Expand Up @@ -213,6 +254,7 @@ function Group({
attribute={row}
selected={selection.has(row.id)}
registry={registry}
worlds={worldsOfAttribute(worlds, row.id)}
reveal={reveal}
onToggle={onToggleRow}
onEdit={onEdit}
Expand Down Expand Up @@ -307,6 +349,7 @@ function AddToFace({
export function AttributeList({
attributes,
faces,
worlds,
registry,
parties,
reveal,
Expand All @@ -315,6 +358,9 @@ export function AttributeList({
}: {
attributes: readonly AttributeNode[];
faces: readonly FaceNode[];
/** The holder's worlds, so a row can say it is arranged. Empty is the honest
* answer before anyone has made one, and draws nothing. */
worlds: readonly PoolFacet[];
registry: ClaimTypeRegistry | null;
parties: Parties;
reveal: (target: RevealTarget) => Promise<unknown>;
Expand Down Expand Up @@ -446,6 +492,7 @@ export function AttributeList({
group={group}
selection={live}
registry={registry}
worlds={worlds}
reveal={reveal}
onToggleRow={onToggleRow}
onToggleGroup={onToggleGroup}
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/src/manager/panes/persona.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,8 @@ export function PersonaPane({
authority={authority}
worlds={worlds.data ?? []}
faces={profiles.data}
attributes={attributes.data}
registry={registry.data}
onChanged={reloadAll}
/>
) : view === "list" ? (
Expand All @@ -410,6 +412,7 @@ export function PersonaPane({
<AttributeList
attributes={graph.attributes}
faces={graph.faces}
worlds={worlds.data ?? []}
registry={registry.data}
parties={parties}
reveal={reveal}
Expand Down
99 changes: 93 additions & 6 deletions packages/extension/src/manager/panes/worlds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
personaFacetDelete,
type PoolFacet,
type PoolProfile,
type PoolAttribute,
type FacetColour,
} from "@openvtc/pnm-core/admin";
import { Button, Note, Panel } from "../../ui.js";
Expand All @@ -44,6 +45,8 @@ import { holderGate } from "../holder-gate.js";
import type { Authority, Parties } from "../use-vta.js";
import { WORLD_COLOURS, worldHue, worldColourName } from "../world-colour.js";
import { placedElsewhere, worldOfFace, unplacedFaces, type Placement } from "../world-model.js";
import { groupRows } from "../attribute-list.js";
import type { ClaimTypeRegistry } from "@openvtc/pnm-core/persona";

function Swatch({
colour,
Expand Down Expand Up @@ -104,6 +107,8 @@ function WorldEditor({
authority,
existing,
faces,
attributes,
registry,
worlds,
onDone,
onCancel,
Expand All @@ -112,6 +117,8 @@ function WorldEditor({
authority: Authority | null;
existing?: PoolFacet;
faces: readonly PoolProfile[];
attributes: readonly PoolAttribute[];
registry: ClaimTypeRegistry | null;
worlds: readonly PoolFacet[];
onDone: () => void;
onCancel: () => void;
Expand All @@ -123,6 +130,10 @@ function WorldEditor({
// opened with an empty selection and saved would silently empty the world's
// membership on an edit that meant to rename it.
const [chosen, setChosen] = useState<Set<string>>(new Set(existing?.faceIds ?? []));
// Same seeding rule, same reason: a put replaces this list too.
const [chosenAttrs, setChosenAttrs] = useState<Set<string>>(
new Set(existing?.attributeIds ?? []),
);
const [busy, setBusy] = useState(false);
const [conflict, setConflict] = useState<Placement[] | null>(null);
const [error, setError] = useState<string | null>(null);
Expand All @@ -140,11 +151,7 @@ function WorldEditor({
colour,
...(icon.trim() ? { icon: icon.trim() } : {}),
faceIds: [...chosen],
// Sent explicitly, and empty until this pane offers attribute
// membership. Omitting it would mean the same thing on the wire — a
// replace — but saying so here is what stops someone later reading the
// omission as "leave them alone".
attributeIds: existing?.attributeIds ?? [],
attributeIds: [...chosenAttrs],
});
onDone();
} catch (e) {
Expand All @@ -157,7 +164,7 @@ function WorldEditor({
} finally {
setBusy(false);
}
}, [parties, existing, name, colour, icon, chosen, onDone]);
}, [parties, existing, name, colour, icon, chosen, chosenAttrs, onDone]);

const toggle = (id: string) =>
setChosen((current) => {
Expand All @@ -166,6 +173,30 @@ function WorldEditor({
return next;
});

const toggleAttr = (id: string) =>
setChosenAttrs((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
return next;
});

// Grouped by the families the map and the list already use, rather than a
// flat column of thirty. Same grouping, one definition — two arrangements of
// one pool that disagree is the defect where a person counts six in one place
// and five in another.
const attributeGroups = groupRows(
attributes.map((a) => ({
id: a.attributeId,
type: a.type,
label: a.label,
value: a.value,
provenance: a.provenance,
stale: Boolean(a.stale),
version: a.version,
})) as never,
registry,
);

return (
<Panel title={existing ? `Edit ${existing.name}` : "A new part of your life"}>
{denied && <Note tone="warn">{denied}</Note>}
Expand Down Expand Up @@ -254,6 +285,49 @@ function WorldEditor({
)}
</div>

<div style={{ display: "grid", gap: 8 }}>
<span style={{ fontSize: t.sm, color: c.text }}>Which attributes belong to it?</span>
{/* No exclusivity here, and none is implied: an attribute may belong
to several worlds, because a mobile number is genuinely part of a
working life and a home one at once. */}
<span style={{ fontSize: t.xs, color: c.faint }}>
An attribute can belong to more than one — your mobile is probably in both.
</span>
{attributeGroups.length === 0 ? (
<span style={{ fontSize: t.sm, color: c.faint }}>You have no attributes yet.</span>
) : (
attributeGroups.map((g) => (
<div key={g.family} style={{ display: "grid", gap: 4 }}>
<span style={{ fontSize: t.xs, color: c.faint, display: "flex", alignItems: "center", gap: 6 }}>
<span
aria-hidden="true"
style={{ width: 3, height: 11, background: g.style.hue, borderRadius: 2 }}
/>
{g.style.label}
</span>
{g.rows.map((row) => (
<label
key={row.id}
style={{ display: "flex", alignItems: "center", gap: 9, fontSize: t.sm, paddingLeft: 9 }}
>
<input
type="checkbox"
checked={chosenAttrs.has(row.id)}
aria-label={row.label ?? row.type}
onChange={() => toggleAttr(row.id)}
style={{ accentColor: c.accent, width: 15, height: 15 }}
/>
<code style={{ fontFamily: font.mono, fontSize: t.xs, color: c.muted }}>
{row.type}
</code>
{row.label && <span style={{ color: c.faint, fontSize: t.xs }}>{row.label}</span>}
</label>
))}
</div>
))
)}
</div>

{conflict && (
<Note tone="warn">
{conflict.length === 1
Expand Down Expand Up @@ -287,12 +361,16 @@ export function WorldsPane({
authority,
worlds,
faces,
attributes,
registry,
onChanged,
}: {
parties: Parties;
authority: Authority | null;
worlds: readonly PoolFacet[];
faces: readonly PoolProfile[];
attributes: readonly PoolAttribute[];
registry: ClaimTypeRegistry | null;
onChanged: () => void;
}) {
const [editing, setEditing] = useState<string | null>(null);
Expand All @@ -313,6 +391,8 @@ export function WorldsPane({
authority={authority}
{...(target ? { existing: target } : {})}
faces={faces}
attributes={attributes}
registry={registry}
worlds={worlds}
onDone={() => {
setEditing(null);
Expand Down Expand Up @@ -415,6 +495,13 @@ export function WorldsPane({
? "No faces belong to it yet."
: members.map((f) => f.name).join(" · ")}
</div>
{(w.attributeIds?.length ?? 0) > 0 && (
<div style={{ fontSize: t.xs, color: c.faint }}>
{w.attributeIds!.length} attribute
{w.attributeIds!.length === 1 ? "" : "s"} belong
{w.attributeIds!.length === 1 ? "s" : ""} to it
</div>
)}
</div>
);
})
Expand Down
23 changes: 23 additions & 0 deletions packages/extension/src/manager/world-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,26 @@ export function placedElsewhere(error: unknown): Placement[] | null {
// is invisible.
return out.length === placed.length ? out : null;
}

/**
* The worlds an attribute belongs to.
*
* Plural, and that is the difference from {@link worldOfFace}. A face belongs
* to one world because the world is where a consumer reads its colour from; an
* attribute belongs to as many as are true, because a mobile number is
* genuinely part of a working life and a home one at the same time. A model
* that made the holder choose would be asking a question about their phone that
* has no answer, and the agent enforces no exclusivity here for the same
* reason.
*
* Returned in the order the worlds are listed rather than the order the
* attribute was added to each, so two attributes in the same worlds draw their
* chips in the same order — a row whose chips reshuffle between renders reads
* as a change when nothing changed.
*/
export function worldsOfAttribute(
worlds: readonly PoolFacet[],
attributeId: string,
): PoolFacet[] {
return worlds.filter((w) => (w.attributeIds ?? []).includes(attributeId));
}
Loading
Loading