diff --git a/CHANGELOG.md b/CHANGELOG.md index c42ada5..4502f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed + +- **The identity pane speaks the words a person would use.** Following + `design-docs/persona-vocabulary.md`, which fixes one vocabulary across the + console, `pnm`, the mobile agent and this TUI: an *attribute* is a **fact**, + a *profile* is a **face** — the set of facts you show together — and a persona + **wears** a face in a community. The tabs read *Personas · Your facts · Faces · + Communities · What has left*. + + The spec's words are exact and are not being replaced in code, on the wire or + in the audit log; they are kept off the screen. `persona/attribute/put` stays + `persona/attribute/put` — the form says *Add a fact*. + + A test renders every tab, both empty and populated, plus each editor, and + fails if any word from the table's avoid-list reaches the screen. Each of them + is a word this pane's own code uses, so they are one careless `format!` away + at all times, and the drift is invisible in review. + + ### Added - **Setup now asks for the grant the identity pane needs.** The `pnm` command on @@ -25,9 +44,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **One pane for your own identity — "My Identity" on the main menu.** Everything a holder can do with their persona now lives in one place, with five tabs in - the order the concepts build on each other: **Faces** (the persona DIDs), + the order the concepts build on each other: **Personas** (the persona DIDs), **Attributes** (the pool of facts behind them), **Profiles** (named subsets of - that pool), **Communities** (which face each community sees and what it + that pool), **Communities** (which persona each community sees and what it presents there) and **Disclosures** (the read-only record of what has actually left). @@ -47,7 +66,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). list. An unreachable agent and an empty pool are one pixel apart, and only one of them is a confident wrong answer about the holder's own data. - **Destructive questions are asked once, correctly.** Deleting an attribute a - profile uses, or a profile a face presents, needs a cascade or an unbind — + profile uses, or a profile a persona presents, needs a cascade or an unbind — and the pane knows which from data it already holds, so the first prompt names the real consequence rather than being refused and re-asked. - **The editor authors what it can honestly author.** Self-asserted attributes @@ -56,7 +75,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). and a profile's pinned, overridden and inline entries are read, carried through a save untouched, and left to `pnm` to change. -- **A face's linkage is on screen.** A membership row says when the same face is +- **A persona's linkage is on screen.** A membership row says when the same persona is shown to other communities — the fact that lets two of them compare notes and find one person behind both, and the one thing a holder cannot work out by looking at a single row. diff --git a/openvtc-core/src/persona/binding.rs b/openvtc-core/src/persona/binding.rs index f143ffe..6522af7 100644 --- a/openvtc-core/src/persona/binding.rs +++ b/openvtc-core/src/persona/binding.rs @@ -1,5 +1,5 @@ -//! What each of your faces actually says — the profile a persona presents in -//! one community's context. **Context-scoped**; see the [module header](super) +//! What each of your personas actually says — the face one wears in a given +//! community's context. **Context-scoped**; see the [module header](super) //! for the boundary this sits on the low side of. //! //! A membership already carries both halves of the key: a @@ -29,7 +29,7 @@ //! //! [`set`] is the exception, and it has to be: it is a decision the holder just //! made about what a community sees. A write that quietly failed would leave -//! them believing a face presents something it does not, so its error is +//! them believing a persona presents something it does not, so its error is //! returned rather than softened. use serde::{Deserialize, Serialize}; @@ -77,29 +77,33 @@ impl BindingSummary { } } - /// A one-line description for a panel row. + /// A one-line description for a panel row, in the words a person reads. + /// + /// A persona *wears* a face in a context — the on-screen vocabulary for what + /// the wire calls binding a profile (`design-docs/persona-vocabulary.md`). + /// The spec's words stay in the types; they are kept off the screen. /// /// Three distinct readings, deliberately worded so they cannot be confused: - /// we do not know; we know nothing is bound; we know what is bound. + /// we do not know; we know nothing is worn; we know what is worn. #[must_use] pub fn describe(&self) -> String { if self.unknown { - return "presents: unknown".to_string(); + return "wears: unknown".to_string(); } if !self.bound { - return "presents: nothing".to_string(); + return "wears: nothing".to_string(); } let label = self .profile_name .clone() .or_else(|| self.profile_id.clone()) - .unwrap_or_else(|| "an unlabelled profile".to_string()); - let claims = if self.claim_count == 1 { - "1 claim".to_string() + .unwrap_or_else(|| "an unnamed face".to_string()); + let facts = if self.claim_count == 1 { + "1 fact".to_string() } else { - format!("{} claims", self.claim_count) + format!("{} facts", self.claim_count) }; - format!("presents: {label} ({claims})") + format!("wears: {label} ({facts})") } } @@ -167,13 +171,13 @@ pub async fn get_or_unknown( /// Decide what one persona presents in one context. /// /// `profile_id: None` clears the binding — a persona that presents nothing is a -/// legitimate, common state (a throwaway face), not an absence to be inferred, +/// legitimate, common state (a throwaway persona), not an absence to be inferred, /// so clearing is a first-class call rather than a delete. /// /// This is the push across the boundary. The VTA resolves the profile *above* /// the context and writes a materialised projection into it: the context /// receives values, never pool identifiers, so nothing inside it can walk back -/// to the holder's other faces. That is why there is no "read the pool from a +/// to the holder's other personas. That is why there is no "read the pool from a /// context" counterpart to this function anywhere in the module. /// /// `publicEntries` is deliberately sent empty. It publishes attributes on the @@ -200,8 +204,8 @@ mod tests { #[test] fn unknown_is_not_the_same_as_unbound() { - assert_eq!(BindingSummary::unknown().describe(), "presents: unknown"); - assert_eq!(BindingSummary::default().describe(), "presents: nothing"); + assert_eq!(BindingSummary::unknown().describe(), "wears: unknown"); + assert_eq!(BindingSummary::default().describe(), "wears: nothing"); } /// The distinction the `unknown` flag exists to preserve. @@ -224,20 +228,20 @@ mod tests { claim_count: 3, ..Default::default() }; - assert_eq!(s.describe(), "presents: work (3 claims)"); + assert_eq!(s.describe(), "wears: work (3 facts)"); } /// One claim is not "1 claims". Small, and the kind of thing that makes a /// panel look unfinished. #[test] - fn a_single_claim_is_singular() { + fn a_single_fact_is_singular() { let s = BindingSummary { bound: true, profile_name: Some("gaming".into()), claim_count: 1, ..Default::default() }; - assert_eq!(s.describe(), "presents: gaming (1 claim)"); + assert_eq!(s.describe(), "wears: gaming (1 fact)"); } /// A profile with no label falls back to its id, and then to a phrase — @@ -251,16 +255,13 @@ mod tests { claim_count: 2, ..Default::default() }; - assert_eq!(by_id.describe(), "presents: 01J8 (2 claims)"); + assert_eq!(by_id.describe(), "wears: 01J8 (2 facts)"); let bare = BindingSummary { bound: true, claim_count: 2, ..Default::default() }; - assert_eq!( - bare.describe(), - "presents: an unlabelled profile (2 claims)" - ); + assert_eq!(bare.describe(), "wears: an unnamed face (2 facts)"); } } diff --git a/openvtc-core/src/persona/disclosure.rs b/openvtc-core/src/persona/disclosure.rs index e76440c..9c0bf07 100644 --- a/openvtc-core/src/persona/disclosure.rs +++ b/openvtc-core/src/persona/disclosure.rs @@ -46,7 +46,7 @@ pub struct DisclosureRow { pub context_id: String, /// Who it went to. pub verifier_did: String, - /// The face it was made as. + /// The persona it was made as. pub persona_did: String, pub claims: Vec, /// What the verifier said it was for, if they said. @@ -111,24 +111,41 @@ impl DisclosureRow { } } - /// The claims as one line: `email.work (whole), age.over18 (predicate)`. + /// The facts as one line: `email.work (whole), age.over18 (yes/no only)`. /// - /// The rung travels with every type rather than being summarised, because + /// The rung travels with every fact rather than being summarised, because /// there is no summary of a mixed release that is not misleading in one - /// direction or the other. + /// direction or the other — and because severity inverts intuition: a + /// credential shown *whole* links the holder more than a fact they simply + /// asserted. #[must_use] pub fn describe_claims(&self) -> String { if self.claims.is_empty() { - return "no claims recorded".to_string(); + return "no facts recorded".to_string(); } self.claims .iter() - .map(|c| format!("{} ({})", c.claim_type, c.rung)) + .map(|c| format!("{} ({})", c.claim_type, rung_label(&c.rung))) .collect::>() .join(", ") } } +/// What a person reads for a proof rung +/// (`design-docs/persona-vocabulary.md`). An unrecognised rung is shown +/// verbatim rather than mapped to a friendlier neighbour: the words carry a +/// privacy ordering, and guessing one would misstate how much left. +#[must_use] +fn rung_label(rung: &str) -> &str { + match rung { + "whole" => "whole", + "selectiveDisclosure" => "partly", + "derived" => "derived", + "predicate" => "yes/no only", + other => other, + } +} + /// Every release, newest first, across every context. /// /// `limit` caps the read: a history is append-only and unbounded, and a panel @@ -158,8 +175,12 @@ pub async fn history( mod tests { use super::*; + /// Rungs are shown in the words the vocabulary fixes, and an unrecognised + /// one is passed through rather than mapped to a friendlier neighbour — the + /// four words carry a privacy ordering, so guessing would misstate how much + /// of the fact left. #[test] - fn a_claim_carries_its_rung_into_the_row() { + fn a_fact_carries_its_rung_into_the_row() { let row = DisclosureRow::from_wire(&serde_json::json!({ "disclosureId": "01D", "contextId": "ctx", @@ -172,10 +193,18 @@ mod tests { })); assert_eq!( row.describe_claims(), - "email.work (whole), age.over18 (predicate)" + "email.work (whole), age.over18 (yes/no only)" ); } + #[test] + fn an_unknown_rung_is_shown_verbatim() { + let row = DisclosureRow::from_wire(&serde_json::json!({ + "claims": [{ "type": "email.work", "rung": "someFutureRung" }], + })); + assert_eq!(row.describe_claims(), "email.work (someFutureRung)"); + } + /// An unrecorded rung reads as `whole`. /// /// It is the least private of the four, and the conservative answer for an @@ -193,8 +222,8 @@ mod tests { /// A release with nothing recorded says so, rather than rendering as a /// blank line that reads like a release of nothing. #[test] - fn a_claimless_record_says_so() { + fn a_factless_record_says_so() { let row = DisclosureRow::default(); - assert_eq!(row.describe_claims(), "no claims recorded"); + assert_eq!(row.describe_claims(), "no facts recorded"); } } diff --git a/openvtc-core/src/persona/mod.rs b/openvtc-core/src/persona/mod.rs index 7b545b5..995b833 100644 --- a/openvtc-core/src/persona/mod.rs +++ b/openvtc-core/src/persona/mod.rs @@ -1,10 +1,10 @@ -//! The holder's own identity — the faces, the facts behind them, and what each -//! face presents where. +//! The holder's own identity — the personas, the facts behind them, and what each +//! persona presents where. //! //! # Two meanings of "persona", and they compose //! //! [`config::account::PersonaRecord`](crate::config::account::PersonaRecord) is -//! a face as an *identity*: a `did:webvh`, its keys, its mediator. That record +//! a persona as an *identity*: a `did:webvh`, its keys, its mediator. That record //! is local, and the TUI has always been able to mint one. //! //! The agent's `persona/*` Trust Tasks use the same word one layer up: a pool @@ -13,7 +13,7 @@ //! ([`binding`]). Those live in the VTA, not in `Config`, and every function //! here is a round-trip to it. //! -//! This crate holds the face; the agent holds what the face says. They join on +//! This crate holds the persona; the agent holds what the persona says. They join on //! the `(context_id, persona_did)` pair every community membership already //! carries. //! diff --git a/openvtc-core/src/persona/pool.rs b/openvtc-core/src/persona/pool.rs index 8446d0f..1854f20 100644 --- a/openvtc-core/src/persona/pool.rs +++ b/openvtc-core/src/persona/pool.rs @@ -76,13 +76,34 @@ impl ProvenanceKind { matches!(self, Self::SelfAsserted) } - /// One word for a panel row. + /// What a person reads for this provenance + /// (`design-docs/persona-vocabulary.md`). The spec's words stay in the + /// type; they are kept off the screen. #[must_use] pub fn label(self) -> &'static str { match self { - Self::SelfAsserted => "self-asserted", - Self::CredentialBacked => "credential-backed", - Self::Generated => "generated", + Self::SelfAsserted => "you said so", + Self::CredentialBacked => "credential", + Self::Generated => "made per verifier", + } + } + + /// Whether this value links the holder across everyone who sees it, said in + /// the words the table uses. + /// + /// Always shown beside the label, because it is the half people miss: a + /// credential is *provable* and carries the same issuer signature to every + /// verifier, which is the more consequential of the two facts and the less + /// obvious one. Severity inverts intuition here — a credential shown whole + /// links more than a value the holder simply asserted — so the words must + /// not hide it. + #[must_use] + pub fn linkage(self) -> Option<&'static str> { + match self { + // Passed on, never proven, and no signature to join on. + Self::SelfAsserted => None, + Self::CredentialBacked => Some("same signature everywhere — links you"), + Self::Generated => Some("different for everyone — cannot link you"), } } } @@ -106,6 +127,10 @@ pub struct PoolAttribute { /// absent [`value`](Self::value), which usually just means the read did not /// ask for one. pub stale: bool, + /// Why it went stale — `expired`, `revoked`, and so on, as the agent says + /// it. Shown beside the word, never instead of it: "stale" alone tells a + /// holder something is wrong without telling them what. + pub stale_reason: Option, /// Optimistic-concurrency token, passed back on edit so two editors cannot /// silently overwrite each other. pub version: u64, @@ -132,6 +157,10 @@ impl PoolAttribute { value: value.get("value").cloned(), provenance: ProvenanceKind::parse_wire(value.get("provenance")), stale: value.get("stale").and_then(Value::as_bool).unwrap_or(false), + stale_reason: value + .get("staleReason") + .and_then(Value::as_str) + .map(str::to_string), version: value.get("version").and_then(Value::as_u64).unwrap_or(0), updated_at: str_field("updatedAt"), } @@ -144,7 +173,7 @@ impl PoolAttribute { match self.label.as_deref() { Some(label) if !label.trim().is_empty() => label, _ if !self.claim_type.is_empty() => &self.claim_type, - _ => "(unnamed attribute)", + _ => "(unnamed fact)", } } @@ -156,7 +185,10 @@ impl PoolAttribute { #[must_use] pub fn display_value(&self, values_requested: bool) -> String { if self.stale { - return "(unavailable — its credential could not be read)".to_string(); + return match &self.stale_reason { + Some(reason) => format!("stale · {reason} — can no longer be proven"), + None => "stale — can no longer be proven".to_string(), + }; } match &self.value { Some(Value::String(s)) => s.clone(), @@ -215,18 +247,18 @@ impl AttributeEdit { pub fn refusal(kind: ProvenanceKind) -> Self { Self::Refused(match kind { ProvenanceKind::CredentialBacked => { - "This attribute's value comes from a credential — editing it here would turn an \ - attested claim into a typed one. Change it at its source, or replace the \ + "This fact comes from a credential — typing over it would turn something \ + provable into something you said. Change it at its source, or replace the \ credential." .to_string() } ProvenanceKind::Generated => { - "This attribute is minted by the agent, usually a fresh value per verifier. There \ - is no single value to edit." + "Your agent makes this one per verifier — a different value for everyone, so \ + there is no single value to edit." .to_string() } ProvenanceKind::SelfAsserted => { - "This attribute is editable; nothing should have refused it.".to_string() + "You said this one, so it is editable; nothing should have refused it.".to_string() } }) } @@ -406,7 +438,14 @@ mod tests { assert_eq!(attr.display_value(false), "(hidden)"); assert_eq!(attr.display_value(true), "(no value)"); attr.stale = true; - assert!(attr.display_value(true).contains("could not be read")); + assert!(attr.display_value(true).contains("can no longer be proven")); + // The reason is shown beside the word, never instead of it: "stale" + // alone says something is wrong without saying what. + attr.stale_reason = Some("revoked".into()); + assert_eq!( + attr.display_value(true), + "stale · revoked — can no longer be proven" + ); } /// A string value renders as itself, not as a quoted JSON string — the diff --git a/openvtc-core/src/persona/profile.rs b/openvtc-core/src/persona/profile.rs index 7810f94..e320ce9 100644 --- a/openvtc-core/src/persona/profile.rs +++ b/openvtc-core/src/persona/profile.rs @@ -11,7 +11,7 @@ //! attribute, a `pinVersion` pin of one, an `override` of its value, and an //! `inline` value that never enters the pool. [`put`] from here writes the //! first, and a picker over the pool is exactly what that form is: tick the -//! facts this face shows. It keeps "edit once, everywhere" true, which is the +//! facts this persona shows. It keeps "edit once, everywhere" true, which is the //! property a holder is relying on when they correct their address in one //! place. //! @@ -55,7 +55,7 @@ pub struct ProfileSummary { /// that from a rejection asks the holder the wrong question first. With /// this, the one question put is the right one. pub referenced: Vec, - /// Credentials listed as this profile's inventory — what the face can + /// Credentials listed as this profile's inventory — what the persona can /// prove, as distinct from the evidence behind a credential-backed value. pub credential_ref_count: usize, pub version: u64, @@ -91,12 +91,12 @@ impl ProfileSummary { } } - /// The name to show. Never empty: an unnamed profile still has to be + /// The name to show. Never empty: an unnamed face still has to be /// selectable in a list. #[must_use] pub fn display_name(&self) -> &str { if self.name.trim().is_empty() { - "(unnamed profile)" + "unnamed face" } else { &self.name } @@ -144,7 +144,7 @@ impl ResolvedClaim { #[must_use] pub fn display_value(&self) -> String { if self.stale { - return "(unavailable — its credential could not be read)".to_string(); + return "stale — can no longer be proven".to_string(); } match &self.value { Some(Value::String(s)) => s.clone(), @@ -185,7 +185,7 @@ impl ProfileDetail { #[must_use] pub fn refusal(&self) -> String { format!( - "This profile has {} entr{} this version of OpenVTC cannot read, so saving would drop \ + "This face has {} entr{} this version of OpenVTC cannot read, so saving would drop \ {}. Edit it with `pnm persona profile`, or upgrade.", self.unreadable_entries, if self.unreadable_entries == 1 { @@ -424,7 +424,7 @@ mod tests { "value": Value::Null, "stale": true, })); - assert!(claim.display_value().contains("could not be read")); + assert!(claim.display_value().contains("can no longer be proven")); } /// The put ordering: ticked entries first, preserved forms after, so the diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index c5849ac..c59e428 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -188,9 +188,9 @@ pub enum SettingsAction { ClipboardCopied(String), } -/// Identity-pane actions — the holder's own faces, pool, profiles and bindings. +/// Identity-pane actions — the holder's own personas, pool, profiles and bindings. /// -/// One sub-enum for a pane with four tabs, rather than four: every one of them +/// One sub-enum for a pane with five tabs, rather than five: every one of them /// shares the pane's selection, its confirmation slot and its single load /// domain, and splitting them would put that shared state behind four names. pub enum PersonaAction { @@ -224,7 +224,7 @@ pub enum PersonaAction { ProfileDeleteArm(usize), // ── Communities ────────────────────────────────────────────────────── - /// Open the picker: what should this face present here? + /// Open the picker: what should this persona present here? BindOpen(usize), /// Arm "present nothing here". UnbindArm(usize), @@ -276,7 +276,7 @@ pub enum Action { Relationship(RelationshipAction), Credential(CredentialAction), Settings(SettingsAction), - /// Identity pane (faces / pool / profiles / bindings). + /// Identity pane (personas / pool / profiles / bindings). Persona(PersonaAction), /// Dismiss the startup loading screen (Enter, once loading has completed) and diff --git a/openvtc/src/state_handler/background_dispatch.rs b/openvtc/src/state_handler/background_dispatch.rs index d12008e..612fb47 100644 --- a/openvtc/src/state_handler/background_dispatch.rs +++ b/openvtc/src/state_handler/background_dispatch.rs @@ -470,7 +470,7 @@ pub(crate) fn apply_outcome( state .main_page .content_panel - .personas + .identity .bindings .extend(results); } diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index 63c9308..7bf726d 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -10,7 +10,7 @@ //! minus the community/submit parts: pick a WebVH server, mint the DID via the //! VTA, then persist through the shared [`ConfigExtension::mint_persona_into`]. //! The minted persona is an orphan (no community) until a join reuses it, and -//! shows in the identity pane's Faces list. +//! shows in the identity pane's Personas list. use affinidi_tdk::TDK; use anyhow::Result; diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 64b6f79..ec63e83 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -69,9 +69,9 @@ pub struct ContentPanelState { pub communities: CommunitiesState, /// Per-community capabilities view (opened from Communities with `c`). pub capabilities: CapabilitiesState, - /// The holder's own identity: faces, pool, profiles, and what each face + /// The holder's own identity: personas, pool, profiles, and what each persona /// presents where. - pub personas: PersonasState, + pub identity: IdentityState, } // **************************************************************************** @@ -409,14 +409,14 @@ pub struct CommunitySummary { /// not been introduced. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum PersonaTab { - /// The persona DIDs themselves — a face as an *identity*. + /// The persona DIDs themselves — a persona as an *identity*. #[default] - Faces, + Personas, /// The attribute pool: the facts, held once. Attributes, /// Named projections over the pool. Profiles, - /// Which face each community sees, and what it presents there. + /// Which persona each community sees, and what it presents there. Communities, /// What has actually left, and to whom. Disclosures, @@ -427,7 +427,7 @@ impl PersonaTab { #[must_use] pub fn all() -> [PersonaTab; 5] { [ - PersonaTab::Faces, + PersonaTab::Personas, PersonaTab::Attributes, PersonaTab::Profiles, PersonaTab::Communities, @@ -435,22 +435,29 @@ impl PersonaTab { ] } - /// The tab's name in the header strip. + /// The tab's name in the header strip — the words a person reads, which are + /// not the words the code and the wire use + /// (`design-docs/persona-vocabulary.md`). + /// + /// The variants keep the spec's nouns because that is what they address: + /// `Profiles` is `persona/profile/*`. The screen says *Faces*, because + /// "profile" already means three things in this product and "my LinkedIn + /// page" to everyone else. #[must_use] pub fn label(self) -> &'static str { match self { - PersonaTab::Faces => "Faces", - PersonaTab::Attributes => "Attributes", - PersonaTab::Profiles => "Profiles", + PersonaTab::Personas => "Personas", + PersonaTab::Attributes => "Your facts", + PersonaTab::Profiles => "Faces", PersonaTab::Communities => "Communities", - PersonaTab::Disclosures => "Disclosures", + PersonaTab::Disclosures => "What has left", } } /// Whether this tab's contents come from the agent rather than `Config`. /// /// Drives which tabs a refresh has anything to do, and which can draw - /// before the agent has ever answered. Faces are local: an account with no + /// before the agent has ever answered. Personas are local: an account with no /// VTA session still has personas, and a pane that showed nothing until the /// network answered would be lying about the ones on disk. #[must_use] @@ -467,19 +474,19 @@ impl PersonaTab { #[must_use] pub fn next(self) -> PersonaTab { match self { - PersonaTab::Faces => PersonaTab::Attributes, + PersonaTab::Personas => PersonaTab::Attributes, PersonaTab::Attributes => PersonaTab::Profiles, PersonaTab::Profiles => PersonaTab::Communities, PersonaTab::Communities => PersonaTab::Disclosures, - PersonaTab::Disclosures => PersonaTab::Faces, + PersonaTab::Disclosures => PersonaTab::Personas, } } #[must_use] pub fn prev(self) -> PersonaTab { match self { - PersonaTab::Faces => PersonaTab::Disclosures, - PersonaTab::Attributes => PersonaTab::Faces, + PersonaTab::Personas => PersonaTab::Disclosures, + PersonaTab::Attributes => PersonaTab::Personas, PersonaTab::Profiles => PersonaTab::Attributes, PersonaTab::Communities => PersonaTab::Profiles, PersonaTab::Disclosures => PersonaTab::Communities, @@ -487,8 +494,8 @@ impl PersonaTab { } } -/// One community membership, as the Communities tab needs it: which face this -/// community sees, and enough to look up what that face presents. +/// One community membership, as the Communities tab needs it: which persona this +/// community sees, and enough to look up what that persona presents. #[derive(Clone, Debug, Default)] pub struct PersonaMembership { /// Display name of the community. @@ -497,13 +504,13 @@ pub struct PersonaMembership { pub sub_context_id: String, /// The persona DID this community sees. pub persona_did: String, - /// The holder's label for that face (or its agent name / DID). - pub face_label: String, + /// The holder's label for that persona (or its agent name / DID). + pub persona_label: String, /// Membership lifecycle, already worded for display. pub status_label: String, - /// How many *other* communities are shown this same face. + /// How many *other* communities are shown this same persona. /// - /// The linkage number. Two communities shown one face can compare notes and + /// The linkage number. Two communities shown one persona can compare notes and /// discover they are talking to the same person, which is the single most /// consequential fact about a persona arrangement and the one a holder /// cannot recompute by looking at one row. @@ -517,12 +524,12 @@ pub struct PersonaMembership { pub enum PersonaConfirm { #[default] None, - /// Remove an orphan persona DID (index into `faces`). + /// Remove an orphan persona DID (index into `personas`). /// /// The one variant still addressed by index, because it hands off to the /// existing identity-deletion path, which takes one and re-resolves the DID /// under its own guards before anything is removed. - DeleteFace(usize), + DeletePersona(usize), /// Delete a pool attribute, named by what it is rather than by where it sat. /// /// **Not an index.** An armed question survives across a listing that @@ -543,7 +550,7 @@ pub enum PersonaConfirm { }, /// Delete a profile. Named, not indexed, for the reason above. /// - /// `unbind` is the same decision one layer up: it makes every face + /// `unbind` is the same decision one layer up: it makes every persona /// presenting under this profile present nothing, and it is decided from /// the binding map rather than discovered from a refusal. DeleteProfile { @@ -551,7 +558,7 @@ pub enum PersonaConfirm { name: String, unbind: bool, }, - /// Clear what one face presents in one context — the pair the binding is + /// Clear what one persona presents in one context — the pair the binding is /// addressed by, carried whole for the same reason as above. Unbind { context_id: String, @@ -657,7 +664,7 @@ pub struct ProfileForm { pub working: bool, } -/// The profile picker for one membership: what should this face present here? +/// The profile picker for one membership: what should this persona present here? #[derive(Clone, Debug, Default)] pub struct BindPicker { /// The binding this picker will write, named rather than indexed. The @@ -669,9 +676,9 @@ pub struct BindPicker { pub persona_did: String, /// Display only: who is being shown something, and as whom. pub community: String, - pub face_label: String, + pub persona_label: String, /// Cursor over the options, where 0 is always "present nothing" — a - /// first-class choice rather than the absence of one, because a face that + /// first-class choice rather than the absence of one, because a persona that /// deliberately shows nothing is a common and legitimate arrangement. pub cursor: usize, pub working: bool, @@ -690,19 +697,19 @@ pub enum PersonaMode { /// The persona pane: every surface for the holder's own identity, in one place. /// -/// Three of the four tabs are served by the agent and one by `Config`, and the -/// difference is load-bearing rather than incidental. Faces are on disk, so +/// Four of the five tabs are served by the agent and one by `Config`, and the +/// difference is load-bearing rather than incidental. Personas are on disk, so /// they draw at launch with no session; the pool, the profiles and the bindings /// are the agent's, so each of them has to be able to say "I could not ask" /// distinctly from "you hold nothing" — see [`load_error`](Self::load_error). #[derive(Clone, Debug, Default)] -pub struct PersonasState { +pub struct IdentityState { pub tab: PersonaTab, - // ── Faces (from `Config`) ──────────────────────────────────────────── + // ── Personas (from `Config`) ──────────────────────────────────────────── /// Every persona DID in the account, with how many communities present it. - pub faces: Arc<[ManagedDid]>, - pub face_selected: usize, + pub personas: Arc<[ManagedDid]>, + pub persona_selected: usize, // ── Communities (from `Config`, annotated from the agent) ──────────── pub memberships: Arc<[PersonaMembership]>, @@ -776,8 +783,8 @@ pub struct PersonasState { pub status_message: Option, } -impl PersonasState { - /// What one membership's face presents, as far as we know. +impl IdentityState { + /// What one membership's persona presents, as far as we know. /// /// Falls back to `unknown` rather than a default summary: the two render /// differently on purpose, and a caller reaching for `unwrap_or_default()` diff --git a/openvtc/src/state_handler/main_page/menu.rs b/openvtc/src/state_handler/main_page/menu.rs index f719dc3..b9fe9fe 100644 --- a/openvtc/src/state_handler/main_page/menu.rs +++ b/openvtc/src/state_handler/main_page/menu.rs @@ -29,15 +29,15 @@ pub enum MainMenu { Inbox, Relationships, Credentials, - /// The holder's own identity — faces, the attribute pool behind them, the - /// profiles over that pool, and what each face presents in each community. + /// The holder's own identity — personas, the attribute pool behind them, the + /// profiles over that pool, and what each persona presents in each community. /// /// Sits beside the other "my …" panels rather than under the VTA service, /// which is where its parts used to live: minting a persona DID was a menu /// action, the list of them was a section of the VTA panel, and everything /// above them was reachable only from `pnm`. Identity is not a property of /// the agent that hosts its keys. - Personas, + Identity, Settings, Vta, Logs, @@ -52,7 +52,7 @@ impl Display for MainMenu { MainMenu::Inbox => write!(f, "Inbox"), MainMenu::Relationships => write!(f, "My Relationships"), MainMenu::Credentials => write!(f, "My Credentials"), - MainMenu::Personas => write!(f, "My Identity"), + MainMenu::Identity => write!(f, "My Identity"), MainMenu::Settings => write!(f, "Settings"), MainMenu::Vta => write!(f, "VTA Service"), MainMenu::Logs => write!(f, "Logs"), @@ -70,8 +70,8 @@ impl MainMenu { MainMenu::Inbox => MainMenu::Communities, MainMenu::Relationships => MainMenu::Inbox, MainMenu::Credentials => MainMenu::Relationships, - MainMenu::Personas => MainMenu::Credentials, - MainMenu::Settings => MainMenu::Personas, + MainMenu::Identity => MainMenu::Credentials, + MainMenu::Settings => MainMenu::Identity, MainMenu::Vta => MainMenu::Settings, MainMenu::Logs => MainMenu::Vta, MainMenu::Help => MainMenu::Logs, @@ -85,8 +85,8 @@ impl MainMenu { MainMenu::Communities => MainMenu::Inbox, MainMenu::Inbox => MainMenu::Relationships, MainMenu::Relationships => MainMenu::Credentials, - MainMenu::Credentials => MainMenu::Personas, - MainMenu::Personas => MainMenu::Settings, + MainMenu::Credentials => MainMenu::Identity, + MainMenu::Identity => MainMenu::Settings, MainMenu::Settings => MainMenu::Vta, MainMenu::Vta => MainMenu::Logs, MainMenu::Logs => MainMenu::Help, diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 8ace7ee..2f0d1c6 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -496,7 +496,7 @@ impl MainPageState { } self.content_panel.vta.active_dids = active_dids.into(); - // Faces: every persona in the account, with how many communities + // Personas: every persona in the account, with how many communities // present it. A persona bound to zero communities is an orphan (e.g. // left by a failed join before the rollback fix) — surfaced so the // operator can spot and manage it. @@ -505,7 +505,7 @@ impl MainPageState { // DID is the holder's identity, not a property of the agent that // happens to host its keys, and the pane that manages it is the pane // that should list it. - let mut faces: Vec = config + let mut personas: Vec = config .account .personas .values() @@ -523,17 +523,19 @@ impl MainPageState { is_active: p.did.as_str() == persona_did, }) .collect(); - faces.sort_by(|a, b| a.did.cmp(&b.did)); + personas.sort_by(|a, b| a.did.cmp(&b.did)); // Clamp the selection to the rebuilt list. Nothing else does: the list is // rebuilt wholesale on every sync, so deleting a persona (or loading a // profile with fewer than the last one had) could leave the index past // the end — where the panel draws no cursor and every list-scoped key // (`d`, `g`) silently does nothing, since each is guarded on - // `face_selected < face count`. Restarting "fixed" it only because the - // index starts at 0. - let personas = &mut self.content_panel.personas; - personas.face_selected = personas.face_selected.min(faces.len().saturating_sub(1)); - personas.faces = faces.into(); + // `persona_selected < persona count`. Restarting "fixed" it only because + // the index starts at 0. + let identity = &mut self.content_panel.identity; + identity.persona_selected = identity + .persona_selected + .min(personas.len().saturating_sub(1)); + identity.personas = personas.into(); // The VIC list is not derived from `Config` (it comes from the VTA // credential vault), so it is annotated rather than rebuilt here. @@ -626,8 +628,8 @@ impl MainPageState { } // The persona pane's Communities tab: one row per membership, not per - // community. A community the holder joined twice under two faces is two - // rows here, because the question this tab answers — which face does + // community. A community the holder joined twice under two personas is two + // rows here, because the question this tab answers — which persona does // this relationship use, and what does it say — has two different // answers in that case. // @@ -638,7 +640,7 @@ impl MainPageState { let mut memberships = Vec::new(); for c in config.account.memberships() { let persona = config.account.personas.get(&c.persona_ref); - // How many *other* memberships show this same face. Computed per + // How many *other* memberships show this same persona. Computed per // row rather than per persona because it is read that way: the // holder is looking at one community and asking "who else sees // this me". @@ -660,7 +662,7 @@ impl MainPageState { // holder's own label, then a *verified* agent name, then the // DID itself. An unverified `alsoKnownAs` claim never reaches // the cache this reads. - face_label: persona + persona_label: persona .and_then(|p| p.label.clone()) .or_else(|| { persona.and_then(|p| config.agent_name_for(&p.did).map(str::to_owned)) @@ -672,14 +674,14 @@ impl MainPageState { }); } // Stable order the holder can predict, and one that puts the rows they - // are most likely to be reconsidering — the faces shown to several + // are most likely to be reconsidering — the personas shown to several // communities — next to each other. memberships.sort_by(|a, b| { - a.face_label - .cmp(&b.face_label) + a.persona_label + .cmp(&b.persona_label) .then_with(|| a.community_name.cmp(&b.community_name)) }); - let personas = &mut self.content_panel.personas; + let personas = &mut self.content_panel.identity; personas.membership_selected = personas .membership_selected .min(memberships.len().saturating_sub(1)); @@ -1800,11 +1802,11 @@ mod tests { assert_eq!(task.remote_did, shorten_did(BOB_DID, 60)); } - /// The identity pane's Faces list carries the persona's verified name + /// The identity pane's Personas list carries the persona's verified name /// beside its DID; the persona's own label is a separate line and is left /// untouched. #[test] - fn face_row_carries_verified_agent_name() { + fn persona_row_carries_verified_agent_name() { let vtc_did = "did:webvh:QmScidCommunityBBBBBBBBBBBBBBBBBB:example.com:community"; let mut config = config_with_membership(ALICE_DID, Some("Work me"), vtc_did, None); config.set_cached_agent_name( @@ -1815,7 +1817,7 @@ mod tests { let mut page = MainPageState::default(); page.sync_from_config(&config); - let row = &page.content_panel.personas.faces[0]; + let row = &page.content_panel.identity.personas[0]; assert_eq!(row.agent_name.as_deref(), Some("example.com/@alice")); assert_eq!(row.did, ALICE_DID); @@ -1827,7 +1829,7 @@ mod tests { /// /// Nothing else did this: the list is rebuilt wholesale on every sync, and a /// stale index disables every list-scoped key (each is guarded on - /// `face_selected < face count`) while drawing no cursor to show why — a + /// `persona_selected < persona count`) while drawing no cursor to show why — a /// keyboard that looks broken until the next restart resets the index to 0. #[test] fn syncing_clamps_a_stale_persona_selection() { @@ -1836,12 +1838,12 @@ mod tests { let mut page = MainPageState::default(); // What a deletion (or a smaller profile) leaves behind. - page.content_panel.personas.face_selected = 4; + page.content_panel.identity.persona_selected = 4; page.sync_from_config(&config); - assert_eq!(page.content_panel.personas.faces.len(), 1); + assert_eq!(page.content_panel.identity.personas.len(), 1); assert_eq!( - page.content_panel.personas.face_selected, 0, + page.content_panel.identity.persona_selected, 0, "the selection must land on a row that exists" ); } @@ -2126,9 +2128,9 @@ mod tests { assert!(page.content_panel.vta.vics[0].issuer_agent_name.is_none()); } - /// A cached negative lookup leaves the face row on the DID. + /// A cached negative lookup leaves the persona row on the DID. #[test] - fn face_row_ignores_a_cached_negative_lookup() { + fn persona_row_ignores_a_cached_negative_lookup() { let vtc_did = "did:webvh:QmScidCommunityBBBBBBBBBBBBBBBBBB:example.com:community"; let mut config = config_with_membership(ALICE_DID, None, vtc_did, None); config.set_cached_agent_name(ALICE_DID, None, chrono::Utc::now()); @@ -2136,7 +2138,7 @@ mod tests { let mut page = MainPageState::default(); page.sync_from_config(&config); - assert!(page.content_panel.personas.faces[0].agent_name.is_none()); + assert!(page.content_panel.identity.personas[0].agent_name.is_none()); } // --- persona_in_scope (community-scoping filter, D10/R-C-6) --- diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index c2e3ba5..e3212db 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1442,8 +1442,8 @@ impl StateHandler { // Same rule for the identity pane: a write that just landed // asked for the listing it invalidated, and could not start // it while its own outcome held the domain. - if state.main_page.content_panel.personas.refresh_queued { - state.main_page.content_panel.personas.refresh_queued = false; + if state.main_page.content_panel.identity.refresh_queued { + state.main_page.content_panel.identity.refresh_queued = false; spawn_persona_effect( &dispatch_tx, &mut in_flight, @@ -2155,7 +2155,7 @@ impl StateHandler { // No session or no messaging runtime: say so and // disarm, rather than leaving the prompt hanging. _ => { - state.main_page.content_panel.personas.confirm = + state.main_page.content_panel.identity.confirm = main_page::content::PersonaConfirm::None; state .main_page @@ -2167,7 +2167,7 @@ impl StateHandler { // Serviced in State A as well: the attribute pool is // holder-scoped and the admin session that reads it // exists before any community does. The pane has no - // faces or memberships to show yet, which is a true + // personas or memberships to show yet, which is a true // answer rather than a missing one. let effect = persona_actions::apply(state, &persona_action); let av = join_ctx.as_ref().and_then(|c| c.admin_vta.as_ref()); @@ -2339,8 +2339,8 @@ impl StateHandler { let av = join_ctx.as_ref().and_then(|c| c.admin_vta.as_ref()); spawn_vic_refresh(&dispatch_tx, &mut in_flight, state, av); } - if state.main_page.content_panel.personas.refresh_queued { - state.main_page.content_panel.personas.refresh_queued = false; + if state.main_page.content_panel.identity.refresh_queued { + state.main_page.content_panel.identity.refresh_queued = false; let av = join_ctx.as_ref().and_then(|c| c.admin_vta.as_ref()); spawn_persona_effect( &dispatch_tx, @@ -2934,7 +2934,7 @@ fn spawn_persona_effect( "No VTA session — your identity is held by the agent, which cannot be reached \ right now." .to_string(); - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.loading = false; p.load_error = Some(reason.clone()); if matches!(effect, PersonaEffect::Job(_)) { @@ -2945,7 +2945,7 @@ fn spawn_persona_effect( if !in_flight.try_begin(domain) { match effect { PersonaEffect::Read => { - state.main_page.content_panel.personas.refresh_queued = true; + state.main_page.content_panel.identity.refresh_queued = true; } _ => persona_actions::release_form( state, @@ -2960,10 +2960,10 @@ fn spawn_persona_effect( PersonaEffect::Read => { let job = persona_actions::PersonaReadJob { admin_vta: admin_vta.clone(), - include_values: state.main_page.content_panel.personas.show_values, + include_values: state.main_page.content_panel.identity.show_values, targets: persona_actions::PersonaReadJob::targets(state), }; - state.main_page.content_panel.personas.loading = true; + state.main_page.content_panel.identity.loading = true; background_dispatch::spawn_dispatch(dispatch_tx.clone(), domain, async move { background_dispatch::DispatchOutcome::PersonaManage(job.run().await) }); @@ -3005,8 +3005,8 @@ fn open_agent_name_overlay(state: &mut State, index: usize) -> Option { let Some(persona) = state .main_page .content_panel + .identity .personas - .faces .get(index) .cloned() else { @@ -3054,12 +3054,12 @@ fn prepare_delete_context_did( didcomm_service: &openvtc_core::didcomm::Messaging, index: usize, ) -> Option { - state.main_page.content_panel.personas.confirm = main_page::content::PersonaConfirm::None; + state.main_page.content_panel.identity.confirm = main_page::content::PersonaConfirm::None; let did = state .main_page .content_panel + .identity .personas - .faces .get(index) .map(|d| d.did.clone())?; @@ -3249,14 +3249,14 @@ fn handle_nav_action(state: &mut State, action: &Action) -> bool { state.main_page.switcher = None; } Action::DidSelect(i) => { - state.main_page.content_panel.personas.face_selected = *i; + state.main_page.content_panel.identity.persona_selected = *i; } Action::DidConfirmDelete(i) => { - state.main_page.content_panel.personas.confirm = - main_page::content::PersonaConfirm::DeleteFace(*i); + state.main_page.content_panel.identity.confirm = + main_page::content::PersonaConfirm::DeletePersona(*i); } Action::DidCancelDelete => { - state.main_page.content_panel.personas.confirm = + state.main_page.content_panel.identity.confirm = main_page::content::PersonaConfirm::None; } Action::VicSelect(i) => { @@ -3985,12 +3985,12 @@ mod tests { }, }, Case { - name: "DidConfirmDelete arms the face confirmation (degraded mode used to drop this)", + name: "DidConfirmDelete arms the persona confirmation (degraded mode used to drop this)", action: Action::DidConfirmDelete(1), assert_fn: |s| { assert_eq!( - s.main_page.content_panel.personas.confirm, - main_page::content::PersonaConfirm::DeleteFace(1) + s.main_page.content_panel.identity.confirm, + main_page::content::PersonaConfirm::DeletePersona(1) ) }, }, diff --git a/openvtc/src/state_handler/persona_actions.rs b/openvtc/src/state_handler/persona_actions.rs index b5d962d..348f966 100644 --- a/openvtc/src/state_handler/persona_actions.rs +++ b/openvtc/src/state_handler/persona_actions.rs @@ -16,10 +16,18 @@ //! setting `refresh_queued` rather than starting one, because its own outcome //! is still holding the domain — the same shape the VIC manager uses. //! +//! # The words, and where they change +//! +//! This module is engineering-side: it keeps the spec's nouns +//! (`attribute`, `profile`, `binding`), because those are what it addresses on +//! the wire. The strings it *hands to the panel* — status lines, refusals — use +//! the vocabulary a person reads (`design-docs/persona-vocabulary.md`): a fact, +//! a face, wearing one. +//! //! # Questions are asked once, and correctly //! //! Deleting an attribute a profile references is refused by the VTA unless the -//! caller cascades; deleting a profile a face presents is refused unless the +//! caller cascades; deleting a profile a persona presents is refused unless the //! caller unbinds. Neither refusal is *discovered* here. The profile listing //! already says which attributes are referenced and the binding map already //! says which profiles are presented, so the prompt names the real consequence @@ -81,7 +89,7 @@ pub(crate) enum PersonaJob { /// to happen. edit: bool, }, - /// Decide what one face presents in one context. + /// Decide what one persona presents in one context. Bind { context_id: String, persona_did: String, @@ -99,7 +107,7 @@ pub(crate) enum PersonaJob { pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect { match action { PersonaAction::TabNext | PersonaAction::TabPrev => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.tab = match action { PersonaAction::TabNext => p.tab.next(), _ => p.tab.prev(), @@ -118,9 +126,9 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::Select(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match p.tab { - PersonaTab::Faces => p.face_selected = *index, + PersonaTab::Personas => p.persona_selected = *index, PersonaTab::Attributes => p.attribute_selected = *index, PersonaTab::Profiles => p.profile_selected = *index, PersonaTab::Communities => p.membership_selected = *index, @@ -130,7 +138,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect } PersonaAction::Refresh => PersonaEffect::Read, PersonaAction::ToggleValues => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.show_values = !p.show_values; // A re-read, not a redraw: a listing fetched without values does // not hold them. Flipping a display flag over data already in @@ -140,12 +148,12 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Attributes ─────────────────────────────────────────────────── PersonaAction::AttributeNew => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.mode = PersonaMode::Attribute(AttributeForm::default()); PersonaEffect::None } PersonaAction::AttributeEdit(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(attr) = p.attributes.get(*index).cloned() else { return PersonaEffect::None; }; @@ -170,7 +178,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::AttributeDeleteArm(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(attr) = p.attributes.get(*index) else { return PersonaEffect::None; }; @@ -191,7 +199,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Profiles ───────────────────────────────────────────────────── PersonaAction::ProfileOpen(index) | PersonaAction::ProfileEdit(index) => { let edit = matches!(action, PersonaAction::ProfileEdit(_)); - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(profile) = p.profiles.get(*index) else { return PersonaEffect::None; }; @@ -201,20 +209,20 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect }) } PersonaAction::ProfileClose => { - state.main_page.content_panel.personas.open_profile = None; + state.main_page.content_panel.identity.open_profile = None; PersonaEffect::None } PersonaAction::ProfileNew => { - state.main_page.content_panel.personas.mode = + state.main_page.content_panel.identity.mode = PersonaMode::Profile(ProfileForm::default()); PersonaEffect::None } PersonaAction::ProfileDeleteArm(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(profile) = p.profiles.get(*index) else { return PersonaEffect::None; }; - // Presented by a face somewhere? Deleting then leaves that face + // Presented by a persona somewhere? Deleting then leaves that persona // presenting nothing, which the prompt has to say. let unbind = p .bindings @@ -230,7 +238,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Communities ────────────────────────────────────────────────── PersonaAction::BindOpen(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(membership) = p.memberships.get(*index).cloned() else { return PersonaEffect::None; }; @@ -244,7 +252,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect context_id: membership.sub_context_id.clone(), persona_did: membership.persona_did.clone(), community: membership.community_name.clone(), - face_label: membership.face_label.clone(), + persona_label: membership.persona_label.clone(), cursor, working: false, error: None, @@ -252,7 +260,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::UnbindArm(index) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let Some(m) = p.memberships.get(*index) else { return PersonaEffect::None; }; @@ -266,7 +274,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── The confirmation slot ──────────────────────────────────────── PersonaAction::ConfirmNo => { - state.main_page.content_panel.personas.confirm = PersonaConfirm::None; + state.main_page.content_panel.identity.confirm = PersonaConfirm::None; PersonaEffect::None } PersonaAction::ConfirmYes => confirm_yes(state), @@ -274,7 +282,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect // ── Forms ──────────────────────────────────────────────────────── PersonaAction::FormKey(key) => { use tui_input::backend::crossterm::EventHandler; - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let event = crossterm::event::Event::Key(*key); match &mut p.mode { PersonaMode::Attribute(form) => { @@ -297,7 +305,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::FormField(forwards) => { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::Attribute(form) => { form.field = if *forwards { @@ -317,9 +325,9 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::FormCycle(forwards) => { - let attribute_count = state.main_page.content_panel.personas.attributes.len(); - let option_count = state.main_page.content_panel.personas.profiles.len() + 1; - let p = &mut state.main_page.content_panel.personas; + let attribute_count = state.main_page.content_panel.identity.attributes.len(); + let option_count = state.main_page.content_panel.identity.profiles.len() + 1; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::Attribute(form) => { if form.field == AttributeField::ValueType { @@ -343,7 +351,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect } PersonaAction::FormToggleEntry => { let attribute_id = { - let p = &state.main_page.content_panel.personas; + let p = &state.main_page.content_panel.identity; match &p.mode { PersonaMode::Profile(form) => p .attributes @@ -352,7 +360,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect _ => None, } }; - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; if let (PersonaMode::Profile(form), Some(id)) = (&mut p.mode, attribute_id) { match form.ticked.iter().position(|x| *x == id) { Some(i) => { @@ -366,7 +374,7 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaEffect::None } PersonaAction::FormCancel => { - state.main_page.content_panel.personas.mode = PersonaMode::View; + state.main_page.content_panel.identity.mode = PersonaMode::View; PersonaEffect::None } PersonaAction::FormSubmit => form_submit(state), @@ -379,12 +387,12 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect /// that arrived while the prompt was on screen cannot redirect the answer onto /// a row the operator never selected. fn confirm_yes(state: &mut State) -> PersonaEffect { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; let confirm = std::mem::replace(&mut p.confirm, PersonaConfirm::None); match confirm { - // A face deletion is not answered here — the pane arms the question and + // A persona deletion is not answered here — the pane arms the question and // the existing identity-deletion path answers it. See the key handler. - PersonaConfirm::None | PersonaConfirm::DeleteFace(_) => PersonaEffect::None, + PersonaConfirm::None | PersonaConfirm::DeletePersona(_) => PersonaEffect::None, PersonaConfirm::DeleteAttribute { attribute_id, cascade, @@ -414,12 +422,12 @@ fn form_submit(state: &mut State) -> PersonaEffect { let profiles: Vec = state .main_page .content_panel - .personas + .identity .profiles .iter() .cloned() .collect(); - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::View => PersonaEffect::None, @@ -454,7 +462,7 @@ fn form_submit(state: &mut State) -> PersonaEffect { PersonaMode::Profile(form) => { let name = form.name.value().trim().to_string(); if name.is_empty() { - form.error = Some("A name is required — \"Work\", \"Gaming\".".to_string()); + form.error = Some("A face needs a name — \"Work\", \"Gaming\".".to_string()); return PersonaEffect::None; } form.error = None; @@ -494,7 +502,7 @@ fn form_submit(state: &mut State) -> PersonaEffect { /// callers are the paths where the loop declines to spawn — no admin session, /// and the domain already busy. pub(crate) fn release_form(state: &mut State, reason: String) { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; match &mut p.mode { PersonaMode::Attribute(form) => { form.working = false; @@ -573,7 +581,7 @@ impl PersonaReadJob { state .main_page .content_panel - .personas + .identity .memberships .iter() .filter(|m| !m.sub_context_id.is_empty() && !m.persona_did.is_empty()) @@ -618,7 +626,7 @@ impl PersonaJobRun { let client = self.admin_vta; match self.job { PersonaJob::AttributePut(draft) => PersonaOutcome::Written { - verb: "Saved attribute", + verb: "Saved the fact", error: pool::put(&client, draft) .await .err() @@ -628,7 +636,7 @@ impl PersonaJobRun { attribute_id, cascade, } => PersonaOutcome::Written { - verb: "Deleted attribute", + verb: "Forgot the fact", error: pool::delete(&client, &attribute_id, cascade) .await .err() @@ -641,7 +649,7 @@ impl PersonaJobRun { other_entries, expected_version, } => PersonaOutcome::Written { - verb: "Saved profile", + verb: "Saved the face", error: profile::put( &client, profile_id.as_deref(), @@ -655,7 +663,7 @@ impl PersonaJobRun { .map(|e| format!("{e}")), }, PersonaJob::ProfileDelete { profile_id, unbind } => PersonaOutcome::Written { - verb: "Deleted profile", + verb: "Deleted the face", error: profile::delete(&client, &profile_id, unbind) .await .err() @@ -715,7 +723,7 @@ pub(crate) enum PersonaOutcome { impl PersonaOutcome { /// Fold the result into the pane, on the loop thread. pub(crate) fn apply(self, state: &mut State) { - let p = &mut state.main_page.content_panel.personas; + let p = &mut state.main_page.content_panel.identity; p.loading = false; match self { @@ -754,7 +762,7 @@ impl PersonaOutcome { } // Merged, not replaced: a read only carries the targets it was // given, and replacing would blank every row it did not cover — - // which reads on screen as those faces having stopped + // which reads on screen as those personas having stopped // presenting anything. p.bindings.extend(bindings); if p.load_error.is_none() { @@ -837,9 +845,9 @@ impl PersonaOutcome { None => { p.mode = PersonaMode::View; let msg = if cleared { - format!("{community} is now shown nothing.") + format!("Taken off. {community} is now shown nothing.") } else { - format!("Updated what {community} is shown.") + format!("Changed the face {community} sees.") }; p.status_message = Some(msg.clone()); state.main_page.log(msg); @@ -853,7 +861,7 @@ impl PersonaOutcome { } state .main_page - .log_error("Changing what a face presents failed", e.as_str()); + .log_error("Changing the face failed", e.as_str()); } } } @@ -864,7 +872,7 @@ impl PersonaOutcome { #[cfg(test)] mod tests { use super::*; - use crate::state_handler::main_page::content::{PersonaMembership, PersonasState}; + use crate::state_handler::main_page::content::{IdentityState, PersonaMembership}; use openvtc_core::persona::binding::BindingSummary; use openvtc_core::persona::pool::ProvenanceKind; @@ -878,14 +886,14 @@ mod tests { } } - fn state_with(personas: PersonasState) -> State { + fn state_with(personas: IdentityState) -> State { let mut state = State::default(); - state.main_page.content_panel.personas = personas; + state.main_page.content_panel.identity = personas; state } - fn personas(state: &State) -> &PersonasState { - &state.main_page.content_panel.personas + fn personas(state: &State) -> &IdentityState { + &state.main_page.content_panel.identity } /// Deleting an attribute a profile uses asks the cascading question *first*. @@ -896,7 +904,7 @@ mod tests { /// answers the follow-up with the first question's reasoning. #[test] fn a_referenced_attribute_arms_the_cascading_question_directly() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A"), attribute("01B")].into(), profiles: vec![ProfileSummary { profile_id: "01P".into(), @@ -905,7 +913,7 @@ mod tests { ..ProfileSummary::default() }] .into(), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::AttributeDeleteArm(0)); @@ -930,7 +938,7 @@ mod tests { ); } - /// Same rule one layer up: deleting a profile a face presents asks the + /// Same rule one layer up: deleting a profile a persona presents asks the /// unbinding question, because that is what will actually happen. #[test] fn a_presented_profile_arms_the_unbinding_question() { @@ -943,7 +951,7 @@ mod tests { ..BindingSummary::default() }, ); - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { profiles: vec![ ProfileSummary { profile_id: "01P".into(), @@ -956,7 +964,7 @@ mod tests { ] .into(), bindings, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::ProfileDeleteArm(0)); @@ -964,7 +972,7 @@ mod tests { personas(&state).confirm, PersonaConfirm::DeleteProfile { profile_id: "01P".into(), - name: "(unnamed profile)".into(), + name: "unnamed face".into(), unbind: true } ); @@ -973,7 +981,7 @@ mod tests { personas(&state).confirm, PersonaConfirm::DeleteProfile { profile_id: "01Q".into(), - name: "(unnamed profile)".into(), + name: "unnamed face".into(), unbind: false } ); @@ -989,9 +997,9 @@ mod tests { /// selected while showing them the name of something else. #[test] fn an_armed_delete_survives_the_list_moving_underneath_it() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A"), attribute("01B")].into(), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::AttributeDeleteArm(0)); @@ -1024,10 +1032,10 @@ mod tests { fn a_credential_backed_attribute_refuses_the_editor() { let mut attr = attribute("01A"); attr.provenance = ProvenanceKind::CredentialBacked; - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attr].into(), show_values: true, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::AttributeEdit(0)); @@ -1047,10 +1055,10 @@ mod tests { /// a value the holder never saw. #[test] fn editing_without_values_in_hand_asks_for_them() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), show_values: false, - ..PersonasState::default() + ..IdentityState::default() }); let effect = apply(&mut state, &PersonaAction::AttributeEdit(0)); @@ -1074,10 +1082,10 @@ mod tests { /// dropped rather than flashed — the same rule the VIC listing follows. #[test] fn a_superseded_read_is_discarded() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), show_values: true, - ..PersonasState::default() + ..IdentityState::default() }); PersonaOutcome::Read { @@ -1100,10 +1108,10 @@ mod tests { /// leave the pane claiming the holder has no attributes. #[test] fn a_failed_read_keeps_the_list_and_says_why() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A")].into(), loaded: true, - ..PersonasState::default() + ..IdentityState::default() }); PersonaOutcome::Read { @@ -1130,7 +1138,7 @@ mod tests { for error in [None, Some("refused".to_string())] { let mut state = State::default(); PersonaOutcome::Written { - verb: "Saved attribute", + verb: "Saved the fact", error, } .apply(&mut state); @@ -1146,12 +1154,12 @@ mod tests { /// clear it, and the form sits on "Saving…" over an edit nobody is saving. #[test] fn a_request_that_never_left_unlocks_the_form() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Attribute(AttributeForm { working: true, ..AttributeForm::default() }), - ..PersonasState::default() + ..IdentityState::default() }); release_form(&mut state, "no session".to_string()); @@ -1169,12 +1177,12 @@ mod tests { /// back if the write never went. #[test] fn a_request_that_never_left_unlocks_the_picker() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Bind(BindPicker { working: true, ..BindPicker::default() }), - ..PersonasState::default() + ..IdentityState::default() }); release_form(&mut state, "busy".to_string()); @@ -1192,17 +1200,17 @@ mod tests { /// does not lose what they typed. #[test] fn a_failed_save_keeps_the_form_and_its_contents() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Attribute(AttributeForm { claim_type: tui_input::Input::new("email.work".into()), working: true, ..AttributeForm::default() }), - ..PersonasState::default() + ..IdentityState::default() }); PersonaOutcome::Written { - verb: "Saved attribute", + verb: "Saved the fact", error: Some("version conflict".to_string()), } .apply(&mut state); @@ -1221,9 +1229,9 @@ mod tests { /// field with no sensible default: it is what a verifier matches on. #[test] fn a_typeless_attribute_is_refused_locally() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { mode: PersonaMode::Attribute(AttributeForm::default()), - ..PersonasState::default() + ..IdentityState::default() }); let effect = apply(&mut state, &PersonaAction::FormSubmit); @@ -1257,7 +1265,7 @@ mod tests { ..BindingSummary::default() }, ); - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { memberships: vec![membership].into(), profiles: vec![ ProfileSummary { @@ -1271,7 +1279,7 @@ mod tests { ] .into(), bindings, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::BindOpen(0)); @@ -1291,13 +1299,13 @@ mod tests { } } - /// An unbound face opens the picker on "nothing", which is where it + /// An unbound persona opens the picker on "nothing", which is where it /// already is. #[test] - fn an_unbound_face_opens_the_picker_on_nothing() { - let mut state = state_with(PersonasState { + fn an_unbound_persona_opens_the_picker_on_nothing() { + let mut state = state_with(IdentityState { memberships: vec![PersonaMembership::default()].into(), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::BindOpen(0)); match &personas(&state).mode { @@ -1310,13 +1318,13 @@ mod tests { /// presents. #[test] fn ticking_preserves_the_order_entries_were_chosen_in() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { attributes: vec![attribute("01A"), attribute("01B"), attribute("01C")].into(), mode: PersonaMode::Profile(ProfileForm { focus: ProfileFormFocus::Entries, ..ProfileForm::default() }), - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::FormCycle(true)); // → 01B @@ -1367,14 +1375,14 @@ mod tests { /// belongs to the screen that asked the question. #[test] fn changing_tab_disarms_the_confirmation() { - let mut state = state_with(PersonasState { + let mut state = state_with(IdentityState { tab: PersonaTab::Attributes, confirm: PersonaConfirm::DeleteAttribute { attribute_id: "01A".into(), name: "email.work".into(), cascade: false, }, - ..PersonasState::default() + ..IdentityState::default() }); apply(&mut state, &PersonaAction::TabNext); @@ -1388,13 +1396,13 @@ mod tests { #[test] fn an_agent_tab_reads_once_on_arrival() { let mut state = State::default(); - // Faces → Attributes: needs the agent, nothing loaded yet. + // Personas → Attributes: needs the agent, nothing loaded yet. assert!(matches!( apply(&mut state, &PersonaAction::TabNext), PersonaEffect::Read )); - state.main_page.content_panel.personas.loaded = true; + state.main_page.content_panel.identity.loaded = true; assert!(matches!( apply(&mut state, &PersonaAction::TabNext), PersonaEffect::None diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 16e3e88..05fc378 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -1257,7 +1257,7 @@ mod tests { } fn with_persona(&mut self) { - self.state.main_page.content_panel.personas.faces = vec![ManagedDid { + self.state.main_page.content_panel.identity.personas = vec![ManagedDid { did: "did:webvh:QmScidPersona:example.com:alice".into(), agent_name: None, label: "Alice".into(), diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index f0609fc..59762b4 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -25,14 +25,14 @@ impl Panel for CommunitiesPanel { state: &ContentPanelState, _connection: &ConnectionState, ) -> Vec> { - render(&state.communities, &state.personas.bindings) + render(&state.communities, &state.identity.bindings) } } /// Render the communities panel content. /// /// `bindings` is the persona pane's map, read here rather than copied: this -/// panel and the persona pane render the same fact — what a face presents in a +/// panel and the persona pane render the same fact — what a persona presents in a /// community — and two copies of it would drift the moment one was refreshed /// and the other was not. pub fn render( @@ -119,10 +119,10 @@ pub fn render( Span::styled(attention, Style::new().fg(COLOR_ORANGE)), ])); - // Secondary line: status · member-since · what this face presents. + // Secondary line: status · member-since · what this persona presents. // - // The row above says WHICH of the holder's faces this community sees; - // this says WHAT that face tells them. Both halves are needed to answer + // The row above says WHICH of the holder's personas this community sees; + // this says WHAT that persona tells them. Both halves are needed to answer // "what does this community know about me", and until now only the // first was on screen. // diff --git a/openvtc/src/ui/pages/main/components/content_panel.rs b/openvtc/src/ui/pages/main/components/content_panel.rs index 2510e2c..3249ab8 100644 --- a/openvtc/src/ui/pages/main/components/content_panel.rs +++ b/openvtc/src/ui/pages/main/components/content_panel.rs @@ -82,7 +82,7 @@ impl ContentPanelState { MainMenu::Credentials => Some(Box::new(CredentialsPanel)), MainMenu::Settings => Some(Box::new(SettingsPanel)), MainMenu::Vta => Some(Box::new(VtaPanel)), - MainMenu::Personas => Some(Box::new(super::personas_panel::PersonasPanel)), + MainMenu::Identity => Some(Box::new(super::identity_panel::IdentityPanel)), _ => None, }; diff --git a/openvtc/src/ui/pages/main/components/personas_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs similarity index 66% rename from openvtc/src/ui/pages/main/components/personas_panel.rs rename to openvtc/src/ui/pages/main/components/identity_panel.rs index 9952d5c..7ebaa26 100644 --- a/openvtc/src/ui/pages/main/components/personas_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -1,23 +1,42 @@ -//! The identity pane — every surface for the holder's own identity, in one -//! place. +//! The identity pane — every surface for a person's own identity, in one place. //! -//! Four tabs, in the order the concepts build on each other: the **faces** a -//! holder presents (persona DIDs), the **attributes** they hold about -//! themselves, the **profiles** that project a subset of those attributes, and -//! the **communities** each face is shown to. Tab one comes from `Config`; the -//! other three come from the agent. +//! Five tabs, in the order the concepts build on each other. On screen they are +//! **Personas**, **Your facts**, **Faces**, **Communities** and **What has +//! left**; in the code and on the wire they are personas, attributes, profiles, +//! contexts and disclosures. The first comes from `Config`; the rest come from +//! the agent. +//! +//! # The screen speaks a different language, on purpose +//! +//! `design-docs/persona-vocabulary.md` fixes the words a person reads, so the +//! console, `pnm`, the mobile agent and this pane say the same thing. The +//! spec's words are exact and stay in the types, on the wire and in the audit +//! log; they are kept off the screen. `persona/attribute/put` stays +//! `persona/attribute/put` — the form says *Add a fact*. +//! +//! The three that matter most here: +//! +//! - an **attribute** is a **fact** — *a fact about you, held once*; +//! - a **profile** is a **face** — *the set of facts you show together*. Not +//! "profile", which already means three things in this product and "my +//! LinkedIn page" to everyone else; +//! - a **binding** is **wearing**: a persona *wears* a face in a community. +//! `Be known here as…`, `Change face`, `Take it off`. +//! +//! A **persona** keeps its own name in both languages. It is already a human +//! word, and it is what a community knows you as. //! //! # What this pane will not draw //! -//! A value the holder never asked to see. The attribute list is fetched without +//! A value the holder never asked to see. The list of facts is fetched without //! values by default and `v` re-reads *with* them — an opt-in that costs a //! round-trip rather than a display flag over data already in memory, because a -//! listing that holds values has already read the holder's identity whether or -//! not the panel chose to paint it. +//! listing that holds values has already read someone's identity whether or not +//! the panel chose to paint it. //! //! A number it does not have. "We could not ask the agent" and "you hold //! nothing" are one pixel apart and one of them is a confident wrong answer -//! about the holder's own data, so every agent-served tab draws the failure +//! about a person's own data, so every agent-served tab draws the failure //! rather than an empty list. use super::panel::Panel; @@ -27,8 +46,8 @@ use crate::colors::{ }; use crate::state_handler::{ main_page::content::{ - AttributeField, AttributeForm, BindPicker, PersonaConfirm, PersonaMode, PersonaTab, - PersonasState, ProfileForm, ProfileFormFocus, VALUE_TYPES, + AttributeField, AttributeForm, BindPicker, IdentityState, PersonaConfirm, PersonaMode, + PersonaTab, ProfileForm, ProfileFormFocus, VALUE_TYPES, }, state::ConnectionState, }; @@ -47,22 +66,22 @@ const ID_WIDTH: usize = 46; const CONFIRM_DID_WIDTH: usize = 48; /// The identity pane. -pub struct PersonasPanel; +pub struct IdentityPanel; -impl Panel for PersonasPanel { +impl Panel for IdentityPanel { fn render( &self, state: &crate::state_handler::main_page::content::ContentPanelState, _connection: &ConnectionState, ) -> Vec> { - render(&state.personas) + render(&state.identity) } } /// Render the pane. A form or picker owns the whole panel while it is open — /// the lists behind it are not interactive then, and drawing them would invite /// keys that go nowhere. -pub fn render(state: &PersonasState) -> Vec> { +pub fn render(state: &IdentityState) -> Vec> { match &state.mode { PersonaMode::Attribute(form) => render_attribute_form(form), PersonaMode::Profile(form) => render_profile_form(state, form), @@ -75,7 +94,7 @@ pub fn render(state: &PersonasState) -> Vec> { // The tabbed view // --------------------------------------------------------------------------- -fn render_tabs(state: &PersonasState) -> Vec> { +fn render_tabs(state: &IdentityState) -> Vec> { let mut lines = vec![Line::from("")]; if let Some(msg) = &state.status_message { @@ -100,7 +119,7 @@ fn render_tabs(state: &PersonasState) -> Vec> { lines.push(Line::from("")); match state.tab { - PersonaTab::Faces => render_faces(state, &mut lines), + PersonaTab::Personas => render_personas(state, &mut lines), PersonaTab::Attributes => render_attributes(state, &mut lines), PersonaTab::Profiles => render_profiles(state, &mut lines), PersonaTab::Communities => render_communities(state, &mut lines), @@ -119,23 +138,23 @@ fn render_tabs(state: &PersonasState) -> Vec> { } /// The question to put, worded so a `y` cannot be given to the wrong one. -fn confirm_prompt(state: &PersonasState) -> Option { +fn confirm_prompt(state: &IdentityState) -> Option { match &state.confirm { PersonaConfirm::None => None, - PersonaConfirm::DeleteFace(i) => { - let face = state.faces.get(*i)?; + PersonaConfirm::DeletePersona(i) => { + let persona = state.personas.get(*i)?; // Keep *both* the name and the DID. The row the operator selected // shows the name, so a DID-only prompt names something they never // saw; but a destructive confirm must stay unambiguous, so the DID // is not dropped either — it is centre-truncated to keep the line // readable while both ends stay checkable. - let did = openvtc_core::display::truncate_did_centered(&face.did, CONFIRM_DID_WIDTH); - let name = match face + let did = openvtc_core::display::truncate_did_centered(&persona.did, CONFIRM_DID_WIDTH); + let name = match persona .label .is_empty() - .then_some(face.agent_name.as_deref()) + .then_some(persona.agent_name.as_deref()) .flatten() - .or_else(|| (!face.label.is_empty()).then_some(face.label.as_str())) + .or_else(|| (!persona.label.is_empty()).then_some(persona.label.as_str())) { Some(name) => format!("{name} ({did})"), None => did.into_owned(), @@ -147,63 +166,64 @@ fn confirm_prompt(state: &PersonasState) -> Option { // The cascading question names the consequence the plain one // does not have: profiles that show this value stop showing it. Some(format!( - "\"{name}\" is used by one or more profiles. Delete it AND remove it from \ - them? y: confirm n: cancel" + "\"{name}\" is on one or more faces. Forget it AND remove it from those \ + faces too? Nothing already shared is affected — that has left. \ + y: confirm n: cancel" )) } else { - Some(format!( - "Delete \"{name}\" from your pool? y: confirm n: cancel" - )) + Some(format!("Forget \"{name}\"? y: confirm n: cancel")) } } PersonaConfirm::DeleteProfile { name, unbind, .. } => { if *unbind { Some(format!( - "A face still presents \"{name}\". Delete it and leave that face presenting \ - nothing? y: confirm n: cancel" + "A persona wears \"{name}\". Delete it and leave that persona showing \ + nothing? Nothing already shared is affected — that has left. \ + y: confirm n: cancel" )) } else { Some(format!( - "Delete the profile \"{name}\"? y: confirm n: cancel" + "Delete the face \"{name}\"? y: confirm n: cancel" )) } } PersonaConfirm::Unbind { community, .. } => Some(format!( - "Stop presenting anything to {community}? y: confirm n: cancel" + "Take it off — show {community} nothing? Nothing already shared is affected \ + — that has left. y: confirm n: cancel" )), } } -fn hints(state: &PersonasState) -> &'static str { +fn hints(state: &IdentityState) -> &'static str { match state.tab { - PersonaTab::Faces => { - "↑/↓ select n: new face g: agent names d: remove orphan ⇥/⇧⇥: tab" + PersonaTab::Personas => { + "↑/↓ select n: new persona g: agent names d: remove unused ⇥/⇧⇥: tab" } PersonaTab::Attributes => { - "↑/↓ select n: new e: edit d: delete v: values r: refresh ⇥/⇧⇥: tab" + "↑/↓ select n: add a fact e: edit d: delete v: values r: refresh ⇥/⇧⇥: tab" } PersonaTab::Profiles => { - "↑/↓ select ⏎: what it shows n: new e: edit d: delete r: refresh ⇥/⇧⇥: tab" + "↑/↓ select ⏎: what it shows n: make a face e: edit d: delete r: refresh ⇥/⇧⇥: tab" } PersonaTab::Communities => { - "↑/↓ select b: choose what this face shows u: show nothing r: refresh ⇥/⇧⇥: tab" + "↑/↓ select b: change face u: take it off r: refresh ⇥/⇧⇥: tab" } PersonaTab::Disclosures => "↑/↓ select r: refresh ⇥/⇧⇥: tab", } } // --------------------------------------------------------------------------- -// Faces +// Personas // --------------------------------------------------------------------------- -fn render_faces(state: &PersonasState, lines: &mut Vec>) { - if state.faces.is_empty() { - lines.push(Line::from(" You have no persona DIDs yet.").fg(COLOR_DARK_GRAY)); +fn render_personas(state: &IdentityState, lines: &mut Vec>) { + if state.personas.is_empty() { + lines.push(Line::from(" You have no personas yet.").fg(COLOR_DARK_GRAY)); lines.push(Line::from("")); lines.push( Line::from( - " A face is a did:webvh you present to a community. Mint one with `n`, hand its \ - DID to a community, and they can issue an invitation bound to it.", + " A persona is who a community knows you as. Make one with `n`, hand its DID \ + to a community, and they can issue you an invitation bound to it.", ) .fg(COLOR_DARK_GRAY), ); @@ -212,20 +232,20 @@ fn render_faces(state: &PersonasState, lines: &mut Vec>) { lines.push( Line::from(format!( - " {} face{}", - state.faces.len(), - if state.faces.len() == 1 { "" } else { "s" } + " {} persona{}", + state.personas.len(), + if state.personas.len() == 1 { "" } else { "s" } )) .fg(COLOR_TEXT_DEFAULT), ); lines.push(Line::from("")); - for (i, face) in state.faces.iter().enumerate() { - let is_selected = i == state.face_selected; - // An orphan is a face no community sees. Usually the residue of a join + for (i, persona) in state.personas.iter().enumerate() { + let is_selected = i == state.persona_selected; + // An orphan is a persona no community sees. Usually the residue of a join // that failed, and worth spotting: it costs keys and a mediator // registration while presenting nothing to anybody. - let orphan = face.bound_communities == 0; + let orphan = persona.bound_communities == 0; let prefix = if is_selected { "▸ " } else { " " }; let marker_style = if orphan { Style::new().fg(COLOR_ORANGE) @@ -242,30 +262,35 @@ fn render_faces(state: &PersonasState, lines: &mut Vec>) { Span::styled(prefix, marker_style), Span::styled(if orphan { "○ " } else { "● " }, marker_style), Span::styled( - display_identifier(face.agent_name.as_deref(), &face.did, ID_WIDTH).into_owned(), + display_identifier(persona.agent_name.as_deref(), &persona.did, ID_WIDTH) + .into_owned(), row_style, ), ])); - let name = if face.label.is_empty() { - "unnamed face".to_string() + let name = if persona.label.is_empty() { + "unnamed persona".to_string() } else { - face.label.clone() + persona.label.clone() }; let seen_by = if orphan { - "orphan — no community".to_string() + "not known anywhere yet".to_string() } else { format!( - "seen by {} communit{}", - face.bound_communities, - if face.bound_communities == 1 { + "known to {} communit{}", + persona.bound_communities, + if persona.bound_communities == 1 { "y" } else { "ies" } ) }; - let active = if face.is_active { " · active" } else { "" }; + let active = if persona.is_active { + " · active" + } else { + "" + }; lines.push(Line::from(vec![ Span::styled( format!(" {name}{active} · "), @@ -287,15 +312,15 @@ fn render_faces(state: &PersonasState, lines: &mut Vec>) { // Attributes // --------------------------------------------------------------------------- -fn render_attributes(state: &PersonasState, lines: &mut Vec>) { - if push_agent_state(state, lines, "attributes") { +fn render_attributes(state: &IdentityState, lines: &mut Vec>) { + if push_agent_state(state, lines, "facts") { return; } lines.push(Line::from(vec![ Span::styled( format!( - " {} attribute{} in your pool", + " {} fact{} about you", state.attributes.len(), if state.attributes.len() == 1 { "" } else { "s" } ), @@ -319,8 +344,8 @@ fn render_attributes(state: &PersonasState, lines: &mut Vec>) { if state.attributes.is_empty() { lines.push( Line::from( - " Nothing in the pool yet. `n` adds a fact about yourself — a name, an email, a \ - date of birth — that profiles can then draw on.", + " No facts yet. `n` adds one — a name, an email, a date of birth. A fact \ + about you, held once; faces select from these.", ) .fg(COLOR_DARK_GRAY), ); @@ -374,7 +399,7 @@ fn render_attributes(state: &PersonasState, lines: &mut Vec>) { // Profiles // --------------------------------------------------------------------------- -fn render_profiles(state: &PersonasState, lines: &mut Vec>) { +fn render_profiles(state: &IdentityState, lines: &mut Vec>) { if let Some(detail) = &state.open_profile { lines.push( Line::from(format!(" {}", detail.summary.display_name())) @@ -388,13 +413,12 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { } if detail.resolved.is_empty() { lines.push( - Line::from(" This profile presents nothing — it has no entries.") - .fg(COLOR_DARK_GRAY), + Line::from(" This face shows nothing — no facts are on it.").fg(COLOR_DARK_GRAY), ); } else { lines.push( Line::from(format!( - " Presents {} claim{}:", + " Shows {} fact{}:", detail.resolved.len(), if detail.resolved.len() == 1 { "" } else { "s" } )) @@ -412,12 +436,12 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { Style::new().fg(COLOR_TEXT_DEFAULT), ), ])); - // An inline value lives only in this profile: it is not in the - // pool, so correcting it in the pool will not correct it here. - // Saying so on the row is the only place a holder finds out. + // A value that lives only in this face is not among the + // holder's facts, so correcting it there will not correct it + // here. Saying so on the row is the only place they find out. let origin = match claim.attribute_id { Some(_) => claim.provenance.label().to_string(), - None => format!("{} · only in this profile", claim.provenance.label()), + None => format!("{} · only in this face", claim.provenance.label()), }; lines.push(Line::from(Span::styled( format!(" {origin}"), @@ -430,13 +454,13 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { return; } - if push_agent_state(state, lines, "profiles") { + if push_agent_state(state, lines, "faces") { return; } lines.push( Line::from(format!( - " {} profile{}", + " {} face{}", state.profiles.len(), if state.profiles.len() == 1 { "" } else { "s" } )) @@ -447,8 +471,9 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { if state.profiles.is_empty() { lines.push( Line::from( - " No profiles yet. A profile is a named subset of your pool — \"Work\", \ - \"Gaming\" — and it is what a face presents to a community.", + " No faces yet. A face is the set of facts you show together — \"Work\", \ + \"Gaming\" — and it is what a persona wears in a community. What you leave \ + unticked stays out, including facts you add later.", ) .fg(COLOR_DARK_GRAY), ); @@ -470,9 +495,9 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { ), Span::styled( format!( - "{} entr{}", + "{} fact{}", profile.entry_count, - if profile.entry_count == 1 { "y" } else { "ies" } + if profile.entry_count == 1 { "" } else { "s" } ), Style::new().fg(COLOR_DARK_GRAY), ), @@ -484,7 +509,7 @@ fn render_profiles(state: &PersonasState, lines: &mut Vec>) { // Communities // --------------------------------------------------------------------------- -fn render_communities(state: &PersonasState, lines: &mut Vec>) { +fn render_communities(state: &IdentityState, lines: &mut Vec>) { if state.memberships.is_empty() { lines.push(Line::from(" You are not a member of any community yet.").fg(COLOR_DARK_GRAY)); return; @@ -515,13 +540,13 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { Span::styled(if is_selected { "▸ " } else { " " }, row_style), Span::styled(truncate(&m.community_name, 30), row_style), Span::styled( - format!(" as {}", truncate(&m.face_label, 24)), + format!(" as {}", truncate(&m.persona_label, 24)), Style::new().fg(COLOR_SOFT_PURPLE), ), ])); - // What that face tells them. `unknown` is drawn, never omitted: an - // omitted row is indistinguishable from a face bound to nothing, and + // What that persona tells them. `unknown` is drawn, never omitted: an + // omitted row is indistinguishable from a persona bound to nothing, and // "we have not asked" is not "you are sharing nothing". let detail = format!( " {} · {}", @@ -537,13 +562,13 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { }, ))); - // The linkage line. Two communities shown one face can compare notes + // The linkage line. Two communities shown one persona can compare notes // and find the same person behind both, and that is the consequence a // holder cannot see by looking at either row on its own. if m.shared_with > 0 { lines.push( Line::from(format!( - " ⚠ this face is also shown to {} other communit{}", + " ⚠ linked: the same persona is known to {} other communit{}", m.shared_with, if m.shared_with == 1 { "y" } else { "ies" } )) @@ -554,13 +579,13 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { lines.push(Line::from("")); // The honest limit of this tab. A membership is held by a credential issued - // to one persona DID, so which face a community sees is fixed at the join — - // `b` changes what that face says, never who it is. + // to one persona DID, so which persona a community sees is fixed at the join — + // `b` changes what that persona says, never who it is. lines.push( Line::from( - " `b` changes what a face presents here. To show a community a *different* face, join \ - it again with that face — the membership credential is bound to the persona that \ - joined.", + " `b` changes the face this persona wears here. To be known to a community as a \ + *different* persona, join it again with that one — the membership credential names \ + the persona that joined.", ) .fg(COLOR_DARK_GRAY), ); @@ -570,14 +595,14 @@ fn render_communities(state: &PersonasState, lines: &mut Vec>) { // Disclosures // --------------------------------------------------------------------------- -fn render_disclosures(state: &PersonasState, lines: &mut Vec>) { - if push_agent_state(state, lines, "disclosures") { +fn render_disclosures(state: &IdentityState, lines: &mut Vec>) { + if push_agent_state(state, lines, "history") { return; } lines.push( Line::from(format!( - " {} disclosure{}, newest first", + " {} release{}, newest first — what has left, and to whom", state.disclosures.len(), if state.disclosures.len() == 1 { "" @@ -592,9 +617,8 @@ fn render_disclosures(state: &PersonasState, lines: &mut Vec>) { if state.disclosures.is_empty() { lines.push( Line::from( - " Nothing has been disclosed from this agent yet. A release happens when a \ - verifier asks and you approve it — this is the record of those, and it is \ - read-only.", + " Nothing has left yet. Something leaves when a site asks and you approve \ + it — this is the record of those, and it is read-only.", ) .fg(COLOR_DARK_GRAY), ); @@ -628,7 +652,7 @@ fn render_disclosures(state: &PersonasState, lines: &mut Vec>) { if let Some(id) = &row.durable_credential_id { lines.push( Line::from(format!( - " ● still live as a credential ({})", + " ● still live as a credential ({}) — can be revoked", truncate(id, 40) )) .fg(COLOR_ORANGE), @@ -643,24 +667,30 @@ fn render_disclosures(state: &PersonasState, lines: &mut Vec>) { /// The failure case is why this exists: an empty list and an unreachable agent /// render identically unless something says otherwise, and of the two, "you /// hold no attributes" is the confident wrong answer (VTI R6.4). -fn push_agent_state(state: &PersonasState, lines: &mut Vec>, noun: &str) -> bool { +fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: &str) -> bool { if let Some(error) = &state.load_error { lines.push( Line::from(format!(" Could not read your {noun} from the agent.")) .fg(COLOR_WARNING_ACCESSIBLE_RED), ); lines.push(Line::from("")); - super::status::push_status(lines, error, " "); - lines.push(Line::from("")); - // The one failure with a specific answer, so it gets one. Everything - // above this line is the agent's own words; this is what to do about - // them. + // The one failure we recognise is said in our own words, and they are + // the agreed ones. The agent's sentence is accurate and abstract — + // "the holder's attribute pool, which sits above every trust context" — + // and echoing it would put three words on screen that the vocabulary + // keeps off it, in the place a person is least able to absorb them. + // + // Every other failure is echoed verbatim. That text is *data*: it may + // name a host, a port, a contract mismatch, and translating what we do + // not recognise would be inventing a cause (VTI R6.4). if needs_holder_grant(error) { for line in HOLDER_GRANT_HINT { lines.push(Line::from(*line).fg(COLOR_ORANGE)); } - lines.push(Line::from("")); + } else { + super::status::push_status(lines, error, " "); } + lines.push(Line::from("")); lines.push(Line::from(" r: try again").fg(COLOR_DARK_GRAY)); return true; } @@ -685,8 +715,8 @@ fn push_agent_state(state: &PersonasState, lines: &mut Vec>, noun: /// Kept as lines rather than a paragraph because the middle one is a command an /// operator has to read character by character. const HOLDER_GRANT_HINT: &[&str] = &[ - " Your agent credential administers this context. The pool and the profiles", - " over it sit above every context, so reaching them is a separate grant:", + " Your agent credential administers this context. Your facts, and the faces", + " over them, sit above every context — reaching them is a separate grant:", "", " pnm acl update --did --capabilities persona-holder", "", @@ -716,9 +746,9 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { let mut lines = vec![Line::from("")]; lines.push( Line::from(if form.attribute_id.is_some() { - " Edit attribute" + " Edit a fact" } else { - " New attribute" + " Add a fact" }) .fg(COLOR_SUCCESS) .bold(), @@ -726,8 +756,8 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { lines.push(Line::from("")); lines.push( Line::from( - " A fact about you, held once. Profiles reference it, so correcting it here corrects \ - it everywhere it is presented.", + " A fact about you, held once. Faces select it, so correcting it here corrects \ + it everywhere it is worn.", ) .fg(COLOR_DARK_GRAY), ); @@ -782,13 +812,13 @@ fn render_attribute_form(form: &AttributeForm) -> Vec> { // The profile editor // --------------------------------------------------------------------------- -fn render_profile_form(state: &PersonasState, form: &ProfileForm) -> Vec> { +fn render_profile_form(state: &IdentityState, form: &ProfileForm) -> Vec> { let mut lines = vec![Line::from("")]; lines.push( Line::from(if form.profile_id.is_some() { - " Edit profile" + " Edit face" } else { - " New profile" + " Make a face" }) .fg(COLOR_SUCCESS) .bold(), @@ -816,7 +846,8 @@ fn render_profile_form(state: &PersonasState, form: &ProfileForm) -> Vec Vec Vec> { +fn render_bind_picker(state: &IdentityState, picker: &BindPicker) -> Vec> { let mut lines = vec![Line::from("")]; lines.push( Line::from(format!( - " What should {} present to {}?", - picker.face_label, picker.community + " Be known to {} — which face should {} wear here?", + picker.community, picker.persona_label )) .fg(COLOR_SUCCESS) .bold(), @@ -895,15 +926,15 @@ fn render_bind_picker(state: &PersonasState, picker: &BindPicker) -> Vec Vec Vec { - let mut options = vec!["Nothing — present no identity here".to_string()]; +pub fn bind_options(state: &IdentityState) -> Vec { + let mut options = vec!["Take it off — show nothing here".to_string()]; options.extend(state.profiles.iter().map(|p| { format!( "{} ({} entr{})", @@ -994,55 +1025,220 @@ mod tests { .join("\n") } - fn loaded(state: &mut PersonasState) { + fn loaded(state: &mut IdentityState) { state.loaded = true; state.loading = false; } + /// The words `design-docs/persona-vocabulary.md` keeps off the screen, held + /// off it. + /// + /// Every one of them is a word this pane's own code and wire format use, so + /// they are one careless `format!` away at all times — and the drift is + /// invisible in review, because each looks correct to the person who wrote + /// the line. The table's whole point is that a person meets the same + /// sentence in the console, in `pnm` and here; a test is the only thing that + /// notices when one surface wanders off. + /// + /// Two are absent from the list on purpose. **"credential"** is on-screen + /// vocabulary (`credential · ‹issuer›`), and **"per verifier"** is the + /// agreed phrase for a generated value — the table bans *"verifier" in + /// prose*, not that phrase. + #[test] + fn the_pane_speaks_the_agreed_vocabulary() { + const BANNED: &[&str] = &[ + "attribute", + "pool", + "profile", + "binding", + "unbind", + "materialise", + "projection", + "disclosure", + "correlation", + "self-asserted", + "credential-backed", + "provenance", + "holder", + ]; + + // Every tab, in each of its three states — empty, populated, and having + // failed to load — plus the three things that own the screen when they + // are open. + // + // The failure state earns its place: the words there are the ones + // written under pressure, they include a hint carrying a `pnm` command, + // and nothing else on screen exercises them. This test did miss a + // "pool" and a "profiles" that lived only in that hint. + let mut screens: Vec = Vec::new(); + for tab in PersonaTab::all() { + let mut empty = IdentityState { + tab, + ..IdentityState::default() + }; + loaded(&mut empty); + screens.push(text(&render(&empty))); + + let mut full = populated(tab); + loaded(&mut full); + screens.push(text(&render(&full))); + + // The refusal we recognise, which this pane answers in its own + // words. An *unrecognised* failure is echoed verbatim and is the + // agent's text rather than ours, so it is not this test's to police. + let mut refused = populated(tab); + loaded(&mut refused); + refused.load_error = Some( + "this task reads or writes the holder's attribute pool, which sits above \ + every trust context. It requires an unscoped holder credential" + .to_string(), + ); + screens.push(text(&render(&refused))); + } + for mode in [ + PersonaMode::Attribute(AttributeForm::default()), + PersonaMode::Profile(ProfileForm::default()), + PersonaMode::Bind(BindPicker::default()), + ] { + let mut state = populated(PersonaTab::Personas); + state.mode = mode; + loaded(&mut state); + screens.push(text(&render(&state))); + } + + for screen in &screens { + // `persona-holder` is a capability name an operator types verbatim, + // like a task URI would be — wire vocabulary inside a command, not + // prose. Removing it before the scan keeps "holder" banned as a + // word while letting the one command that needs it stay correct; + // exempting it any more loosely would let a sentence hide behind a + // hyphen. + let lower = screen + .to_ascii_lowercase() + .replace("persona-holder", "‹capability›"); + for word in BANNED { + assert!( + !lower.contains(word), + "\"{word}\" is a word the vocabulary keeps off the screen \ + (design-docs/persona-vocabulary.md):\n{screen}" + ); + } + } + } + + /// One populated screen per tab, so the guard above reads real rows rather + /// than five empty states. + fn populated(tab: PersonaTab) -> IdentityState { + use openvtc_core::persona::disclosure::{DisclosedClaim, DisclosureRow}; + use openvtc_core::persona::pool::ProvenanceKind; + use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; + + let mut attr = PoolAttribute { + attribute_id: "01A".into(), + claim_type: "email.work".into(), + label: Some("Work email".into()), + ..PoolAttribute::default() + }; + attr.provenance = ProvenanceKind::CredentialBacked; + attr.stale = true; + attr.stale_reason = Some("revoked".into()); + + IdentityState { + tab, + show_values: true, + personas: vec![ManagedDid { + did: "did:webvh:example.com:alice".into(), + label: "Work me".into(), + ..ManagedDid::default() + }] + .into(), + attributes: vec![attr].into(), + profiles: vec![ProfileSummary { + profile_id: "01P".into(), + name: "Work".into(), + entry_count: 3, + ..ProfileSummary::default() + }] + .into(), + memberships: vec![PersonaMembership { + community_name: "Acme".into(), + persona_label: "Work me".into(), + status_label: "Member".into(), + shared_with: 1, + ..PersonaMembership::default() + }] + .into(), + disclosures: vec![DisclosureRow { + verifier_did: "did:webvh:example.com:acme".into(), + disclosed_at: "2026-09-07T10:00:00Z".into(), + claims: vec![DisclosedClaim { + claim_type: "email.work".into(), + rung: "selectiveDisclosure".into(), + }], + durable_credential_id: Some("urn:cred:1".into()), + ..DisclosureRow::default() + }] + .into(), + open_profile: (tab == PersonaTab::Profiles).then(|| ProfileDetail { + summary: ProfileSummary { + profile_id: "01P".into(), + name: "Work".into(), + ..ProfileSummary::default() + }, + resolved: vec![ResolvedClaim { + claim_type: "nickname".into(), + value: Some(serde_json::json!("Ace")), + attribute_id: None, + ..ResolvedClaim::default() + }], + ..ProfileDetail::default() + }), + ..IdentityState::default() + } + } + /// The distinction the whole pane is built around: an agent that could not - /// be asked must never render as an empty pool. One of those sentences is a + /// be asked must never render as "you have no facts". One of those is a /// confident claim about the holder's own data, and it would be wrong. #[test] - fn an_unreachable_agent_never_reads_as_an_empty_pool() { - let mut state = PersonasState { + fn an_unreachable_agent_never_reads_as_having_no_facts() { + let mut state = IdentityState { tab: PersonaTab::Attributes, - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); let empty = text(&render(&state)); - assert!(empty.contains("Nothing in the pool yet"), "{empty}"); + assert!(empty.contains("No facts yet"), "{empty}"); state.load_error = Some("connection refused".to_string()); let failed = text(&render(&state)); - assert!( - failed.contains("Could not read your attributes"), - "{failed}" - ); + assert!(failed.contains("Could not read your facts"), "{failed}"); assert!( failed.contains("connection refused"), "the reason has to reach the operator: {failed}" ); assert!( - !failed.contains("Nothing in the pool yet"), - "a failed read must not claim the pool is empty: {failed}" + !failed.contains("No facts yet"), + "a failed read must not claim there are no facts: {failed}" ); } /// The refusal a context-scoped credential earns says what to do about it. /// - /// This is the failure every install hits before the grant exists, so the - /// agent's own words — accurate but abstract — are not enough on their own: - /// the operator needs the command. + /// This is the failure every install hits before the grant exists, and the + /// agent's own words — accurate, abstract, and in the spec's vocabulary — + /// are not what a person needs at that moment. The pane answers it in its + /// own words and gives them the command. #[test] fn the_holder_refusal_carries_the_grant_command() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, load_error: Some( "this task reads or writes the holder's attribute pool … it requires an unscoped \ holder credential" .to_string(), ), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1055,10 +1251,10 @@ mod tests { /// their agent is simply unreachable sends them to fix the wrong thing. #[test] fn an_unrelated_failure_carries_no_grant_command() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, load_error: Some("connection refused".to_string()), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1077,10 +1273,10 @@ mod tests { label: Some("Work email".into()), ..PoolAttribute::default() }; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, attributes: vec![attr].into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1097,43 +1293,43 @@ mod tests { ); } - /// A face shown to more than one community carries the linkage warning. + /// A persona shown to more than one community carries the linkage warning. /// Nothing else on screen lets a holder work that out from a single row. #[test] - fn a_reused_face_is_flagged_as_linkable() { - let mut state = PersonasState { + fn a_reused_persona_is_flagged_as_linkable() { + let mut state = IdentityState { tab: PersonaTab::Communities, memberships: vec![ PersonaMembership { community_name: "Acme".into(), - face_label: "Work me".into(), + persona_label: "Work me".into(), status_label: "Member".into(), shared_with: 1, ..PersonaMembership::default() }, PersonaMembership { community_name: "Chess Club".into(), - face_label: "Gaming me".into(), + persona_label: "Gaming me".into(), status_label: "Member".into(), shared_with: 0, ..PersonaMembership::default() }, ] .into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); let out = text(&render(&state)); - assert!(out.contains("also shown to 1 other community"), "{out}"); + assert!(out.contains("known to 1 other community"), "{out}"); assert_eq!( - out.matches("also shown to").count(), + out.matches("⚠ linked").count(), 1, - "the face used once must not be flagged: {out}" + "the persona used once must not be flagged: {out}" ); } - /// "We have not asked" and "presents nothing" are different sentences on a + /// "We have not asked" and "wears nothing" are different sentences on a /// membership row, for the same reason they are different in /// `BindingSummary`. #[test] @@ -1142,23 +1338,23 @@ mod tests { community_name: "Acme".into(), sub_context_id: "ctx".into(), persona_did: "did:webvh:example.com:alice".into(), - face_label: "Work me".into(), + persona_label: "Work me".into(), status_label: "Member".into(), shared_with: 0, }; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Communities, memberships: vec![membership.clone()].into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); - assert!(text(&render(&state)).contains("presents: unknown")); + assert!(text(&render(&state)).contains("wears: unknown")); state.bindings.insert( ("ctx".to_string(), membership.persona_did.clone()), BindingSummary::default(), ); - assert!(text(&render(&state)).contains("presents: nothing")); + assert!(text(&render(&state)).contains("wears: nothing")); } /// The two delete questions are worded differently, and the cascading one @@ -1171,10 +1367,10 @@ mod tests { label: Some("Work email".into()), ..PoolAttribute::default() }; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Attributes, attributes: vec![attr].into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); @@ -1184,11 +1380,11 @@ mod tests { cascade: false, }; let plain = text(&render(&state)); + assert!(plain.contains("Forget \"Work email\"?"), "{plain}"); assert!( - plain.contains("Delete \"Work email\" from your pool?"), - "{plain}" + !plain.contains("faces"), + "the plain question must not mention what it does not do: {plain}" ); - assert!(!plain.contains("profiles"), "{plain}"); state.confirm = PersonaConfirm::DeleteAttribute { attribute_id: "01A".into(), @@ -1196,44 +1392,41 @@ mod tests { cascade: true, }; let escalated = text(&render(&state)); - assert!( - escalated.contains("used by one or more profiles"), - "{escalated}" - ); + assert!(escalated.contains("is on one or more faces"), "{escalated}"); } /// An armed confirmation replaces the key hints: a destructive question and /// a menu of other keys do not belong on screen together. #[test] fn a_confirmation_replaces_the_key_hints() { - let mut state = PersonasState { - tab: PersonaTab::Faces, - faces: vec![ManagedDid { + let mut state = IdentityState { + tab: PersonaTab::Personas, + personas: vec![ManagedDid { did: "did:webvh:example.com:alice".into(), label: "Work me".into(), ..ManagedDid::default() }] .into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); - assert!(text(&render(&state)).contains("n: new face")); + assert!(text(&render(&state)).contains("n: new persona")); - state.confirm = PersonaConfirm::DeleteFace(0); + state.confirm = PersonaConfirm::DeletePersona(0); let armed = text(&render(&state)); assert!(armed.contains("Remove Work me"), "{armed}"); - assert!(!armed.contains("n: new face"), "{armed}"); + assert!(!armed.contains("n: new persona"), "{armed}"); } /// The destructive confirm names what the operator selected *and* keeps the /// DID, so it is both recognisable and unambiguous. Moved here with the /// list it prompts over. #[test] - fn the_face_prompt_keeps_both_the_name_and_the_did() { + fn the_persona_prompt_keeps_both_the_name_and_the_did() { const DID: &str = "did:webvh:QmScidAliceAAAAAAAAAAAAAAAAAAAAAA:example.com:alice"; - let mut state = PersonasState { - tab: PersonaTab::Faces, - faces: vec![ManagedDid { + let mut state = IdentityState { + tab: PersonaTab::Personas, + personas: vec![ManagedDid { did: DID.into(), agent_name: Some("example.com/@alice".into()), label: String::new(), @@ -1241,8 +1434,8 @@ mod tests { is_active: false, }] .into(), - confirm: PersonaConfirm::DeleteFace(0), - ..PersonasState::default() + confirm: PersonaConfirm::DeletePersona(0), + ..IdentityState::default() }; loaded(&mut state); @@ -1261,17 +1454,17 @@ mod tests { /// With no name at all the prompt falls back to the DID alone — never to an /// empty parenthetical or a nameless "this identity". #[test] - fn the_face_prompt_without_a_name_shows_the_did() { + fn the_persona_prompt_without_a_name_shows_the_did() { const DID: &str = "did:webvh:QmScidAliceAAAAAAAAAAAAAAAAAAAAAA:example.com:alice"; - let mut state = PersonasState { - tab: PersonaTab::Faces, - faces: vec![ManagedDid { + let mut state = IdentityState { + tab: PersonaTab::Personas, + personas: vec![ManagedDid { did: DID.into(), ..ManagedDid::default() }] .into(), - confirm: PersonaConfirm::DeleteFace(0), - ..PersonasState::default() + confirm: PersonaConfirm::DeletePersona(0), + ..IdentityState::default() }; loaded(&mut state); @@ -1292,10 +1485,10 @@ mod tests { /// rather than as an empty row. #[test] fn the_bind_picker_offers_nothing_as_a_first_class_choice() { - let state = PersonasState::default(); + let state = IdentityState::default(); let options = bind_options(&state); assert_eq!(options.len(), 1); - assert!(options[0].starts_with("Nothing")); + assert!(options[0].starts_with("Take it off")); } /// The disclosure history is read-only and says so, and an empty one says @@ -1303,26 +1496,26 @@ mod tests { /// does anyone already know", and an unexplained blank answers nothing. #[test] fn an_empty_history_explains_itself_rather_than_going_blank() { - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Disclosures, - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); let out = text(&render(&state)); - assert!(out.contains("Nothing has been disclosed"), "{out}"); + assert!(out.contains("Nothing has left yet"), "{out}"); assert!(out.contains("read-only"), "{out}"); // No verbs beyond navigation and refresh: this pane cannot disclose. assert!(!out.contains("n: new"), "{out}"); } - /// Every claim carries the rung it went out at, and a release that is still + /// Every fact carries the rung it left at, and a release that is still /// live as a credential is marked as such — it is the one kind that can /// still be revoked rather than only regretted. #[test] fn a_disclosure_row_shows_its_rungs_and_flags_a_live_credential() { use openvtc_core::persona::disclosure::{DisclosedClaim, DisclosureRow}; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Disclosures, disclosures: vec![ DisclosureRow { @@ -1348,13 +1541,13 @@ mod tests { }, ] .into(), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); let out = text(&render(&state)); assert!(out.contains("email.work (whole)"), "{out}"); - assert!(out.contains("age.over18 (predicate)"), "{out}"); + assert!(out.contains("age.over18 (yes/no only)"), "{out}"); assert_eq!( out.matches("still live as a credential").count(), 1, @@ -1362,12 +1555,12 @@ mod tests { ); } - /// A resolved claim with no pool attribute behind it is marked, because - /// correcting the pool will not correct it. + /// A value that lives only in a face is marked as such, because correcting + /// the holder's facts will not correct it. #[test] fn an_inline_claim_says_it_lives_only_in_the_profile() { use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; - let mut state = PersonasState { + let mut state = IdentityState { tab: PersonaTab::Profiles, open_profile: Some(ProfileDetail { summary: ProfileSummary { @@ -1391,13 +1584,13 @@ mod tests { ], ..ProfileDetail::default() }), - ..PersonasState::default() + ..IdentityState::default() }; loaded(&mut state); let out = text(&render(&state)); assert_eq!( - out.matches("only in this profile").count(), + out.matches("only in this face").count(), 1, "exactly the inline claim is marked: {out}" ); diff --git a/openvtc/src/ui/pages/main/components/mod.rs b/openvtc/src/ui/pages/main/components/mod.rs index 3e98d79..74e9add 100644 --- a/openvtc/src/ui/pages/main/components/mod.rs +++ b/openvtc/src/ui/pages/main/components/mod.rs @@ -5,11 +5,11 @@ pub mod capabilities_panel; pub mod communities_panel; pub mod content_panel; pub mod credentials_panel; +pub mod identity_panel; pub mod inbox_panel; pub mod logs_panel; pub mod menu_panel; pub mod panel; -pub mod personas_panel; pub mod relationships_panel; pub mod settings_panel; pub mod status; diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 01384fa..a2c4e14 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -352,7 +352,7 @@ impl MainPage { MainMenu::Logs => self.handle_logs_key(key), MainMenu::Help => self.handle_help_key(key), MainMenu::Communities => self.handle_communities_key(key), - MainMenu::Personas => self.handle_personas_key(key), + MainMenu::Identity => self.handle_personas_key(key), MainMenu::Vta => self.handle_vta_key(key), _ => false, } @@ -472,7 +472,7 @@ impl MainPage { PersonaConfirm, PersonaMode, PersonaTab, ProfileFormFocus, }; - let personas = &self.props.main_page.content_panel.personas; + let personas = &self.props.main_page.content_panel.identity; let send = |action: PA| { let _ = self.action_tx.send(Action::Persona(action)); true @@ -532,13 +532,13 @@ impl MainPage { // ── An armed confirmation owns y/n ──────────────────────────────── // - // Removing a face goes through the existing identity-deletion path + // Removing a persona goes through the existing identity-deletion path // (`DeleteDid`), which does the VTA delete, the listener teardown and // the local cleanup. The pane owns the *question*; it does not own a // second implementation of the answer. match personas.confirm { PersonaConfirm::None => {} - PersonaConfirm::DeleteFace(i) => { + PersonaConfirm::DeletePersona(i) => { let act = match key.code { KeyCode::Char('y') | KeyCode::Enter => Action::DeleteDid(i), _ => Action::DidCancelDelete, @@ -574,9 +574,9 @@ impl MainPage { } match personas.tab { - PersonaTab::Faces => { - let count = personas.faces.len(); - let selected = personas.face_selected; + PersonaTab::Personas => { + let count = personas.personas.len(); + let selected = personas.persona_selected; match key.code { KeyCode::Up if count > 0 => { let _ = self @@ -598,14 +598,14 @@ impl MainPage { let _ = self.action_tx.send(Action::StartAgentNameManager(selected)); true } - // Only an orphan is removable: a face a community still + // Only an orphan is removable: a persona a community still // presents cannot be deleted without leaving that community // first, and the deletion path refuses it anyway. Refusing // to *arm* it keeps the prompt from appearing over a // question that has already been answered no. KeyCode::Char('d') | KeyCode::Delete if selected < count => { if personas - .faces + .personas .get(selected) .is_some_and(|f| f.bound_communities == 0) { @@ -3389,9 +3389,9 @@ mod key_handler_tests { } } - // ----- Identity pane: faces --------------------------------------------- + // ----- Identity pane: personas --------------------------------------------- - fn face(did: &str, label: &str, bound: usize) -> ManagedDid { + fn persona(did: &str, label: &str, bound: usize) -> ManagedDid { ManagedDid { did: did.to_string(), agent_name: None, @@ -3401,26 +3401,26 @@ mod key_handler_tests { } } - /// Two faces, the second one an orphan and selected. - fn faces_with_orphan_selected() -> impl Fn(&mut State) { + /// Two personas, the second one an orphan and selected. + fn personas_with_orphan_selected() -> impl Fn(&mut State) { move |s: &mut State| { - let p = &mut s.main_page.content_panel.personas; - p.faces = vec![ - face("did:webvh:example.com:alice", "Alice", 1), - face("did:webvh:example.com:bob", "Bob", 0), + let p = &mut s.main_page.content_panel.identity; + p.personas = vec![ + persona("did:webvh:example.com:alice", "Alice", 1), + persona("did:webvh:example.com:bob", "Bob", 0), ] .into(); - p.face_selected = 1; + p.persona_selected = 1; } } - /// Removing a face is armed here and answered by the existing + /// Removing a persona is armed here and answered by the existing /// identity-deletion path — the pane owns the question, not a second /// implementation of the answer. #[test] - fn personas_face_confirm_commits_and_cancels() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - s.main_page.content_panel.personas.confirm = PersonaConfirm::DeleteFace(1); + fn personas_confirm_commits_and_cancels() { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(1); }); page.handle_key_event(press(KeyCode::Enter)); match rx.try_recv() { @@ -3428,8 +3428,8 @@ mod key_handler_tests { _ => panic!("expected DeleteDid(1)"), } - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - s.main_page.content_panel.personas.confirm = PersonaConfirm::DeleteFace(0); + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.confirm = PersonaConfirm::DeletePersona(0); }); page.handle_key_event(press(KeyCode::Esc)); match rx.try_recv() { @@ -3438,33 +3438,33 @@ mod key_handler_tests { } } - /// Only an orphan arms: a face a community still presents cannot be + /// Only an orphan arms: a persona a community still presents cannot be /// deleted without leaving that community, and the deletion path refuses /// it. Arming anyway would put a question that has already been answered. #[test] - fn personas_d_arms_only_on_an_orphan_face() { - let (mut page, mut rx) = page_for(MainMenu::Personas, faces_with_orphan_selected()); + fn personas_d_arms_only_on_an_orphan_persona() { + let (mut page, mut rx) = page_for(MainMenu::Identity, personas_with_orphan_selected()); page.handle_key_event(press(KeyCode::Char('d'))); match rx.try_recv() { Ok(Action::DidConfirmDelete(1)) => {} _ => panic!("expected DidConfirmDelete(1)"), } - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - let p = &mut s.main_page.content_panel.personas; - p.faces = vec![face("did:webvh:example.com:alice", "Alice", 1)].into(); - p.face_selected = 0; + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + let p = &mut s.main_page.content_panel.identity; + p.personas = vec![persona("did:webvh:example.com:alice", "Alice", 1)].into(); + p.persona_selected = 0; }); page.handle_key_event(press(KeyCode::Char('d'))); assert!( rx.try_recv().is_err(), - "a face a community presents must not arm a deletion" + "a persona a community presents must not arm a deletion" ); } #[test] fn personas_n_opens_create_persona() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |_| {}); + let (mut page, mut rx) = page_for(MainMenu::Identity, |_| {}); page.handle_key_event(press(KeyCode::Char('n'))); match rx.try_recv() { Ok(Action::StartCreatePersona) => {} @@ -3473,8 +3473,8 @@ mod key_handler_tests { } #[test] - fn personas_g_opens_agent_name_manager_for_the_selected_face() { - let (mut page, mut rx) = page_for(MainMenu::Personas, faces_with_orphan_selected()); + fn personas_g_opens_agent_name_manager_for_the_selected_persona() { + let (mut page, mut rx) = page_for(MainMenu::Identity, personas_with_orphan_selected()); page.handle_key_event(press(KeyCode::Char('g'))); match rx.try_recv() { Ok(Action::StartAgentNameManager(1)) => {} @@ -3484,18 +3484,18 @@ mod key_handler_tests { // ----- Identity pane: tabs and their verbs ------------------------------- - /// Tab moves between the pane's four tabs; the verbs that follow belong to + /// Tab moves between the pane's five tabs; the verbs that follow belong to /// whichever one is showing. #[test] fn personas_tab_moves_between_tabs() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |_| {}); + let (mut page, mut rx) = page_for(MainMenu::Identity, |_| {}); page.handle_key_event(press(KeyCode::Tab)); assert!(matches!( rx.try_recv(), Ok(Action::Persona(PersonaAction::TabNext)) )); - let (mut page, mut rx) = page_for(MainMenu::Personas, |_| {}); + let (mut page, mut rx) = page_for(MainMenu::Identity, |_| {}); page.handle_key_event(press(KeyCode::BackTab)); assert!(matches!( rx.try_recv(), @@ -3509,8 +3509,8 @@ mod key_handler_tests { fn personas_attribute_keys_map_to_actions() { use openvtc_core::persona::pool::PoolAttribute; let open = || { - page_for(MainMenu::Personas, |s| { - let p = &mut s.main_page.content_panel.personas; + page_for(MainMenu::Identity, |s| { + let p = &mut s.main_page.content_panel.identity; p.tab = PersonaTab::Attributes; p.attributes = vec![PoolAttribute { attribute_id: "01A".into(), @@ -3545,8 +3545,8 @@ mod key_handler_tests { #[test] fn personas_confirmation_owns_the_keyboard() { let armed = || { - page_for(MainMenu::Personas, |s| { - let p = &mut s.main_page.content_panel.personas; + page_for(MainMenu::Identity, |s| { + let p = &mut s.main_page.content_panel.identity; p.tab = PersonaTab::Attributes; p.confirm = PersonaConfirm::DeleteAttribute { attribute_id: "01A".into(), @@ -3580,8 +3580,8 @@ mod key_handler_tests { fn personas_space_ticks_only_on_the_entry_list() { use crate::state_handler::main_page::content::{ProfileForm, ProfileFormFocus}; let with_focus = |focus: ProfileFormFocus| { - page_for(MainMenu::Personas, move |s| { - s.main_page.content_panel.personas.mode = PersonaMode::Profile(ProfileForm { + page_for(MainMenu::Identity, move |s| { + s.main_page.content_panel.identity.mode = PersonaMode::Profile(ProfileForm { focus, ..ProfileForm::default() }); @@ -3608,8 +3608,8 @@ mod key_handler_tests { #[test] fn personas_a_working_form_swallows_input() { use crate::state_handler::main_page::content::AttributeForm; - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { - s.main_page.content_panel.personas.mode = PersonaMode::Attribute(AttributeForm { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.mode = PersonaMode::Attribute(AttributeForm { working: true, ..AttributeForm::default() }); @@ -3739,7 +3739,7 @@ mod key_handler_tests { /// the TUI had. Everything it stood for is now a key inside the pane. #[test] fn menu_enter_on_identity_switches_to_the_panel() { - let (mut page, mut rx) = page_for(MainMenu::Personas, |s| { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { s.main_page.menu_panel.selected = true; s.main_page.content_panel.selected = false; }); @@ -3755,7 +3755,7 @@ mod key_handler_tests { use crate::state_handler::main_page::content::CreatePersonaState; // The open overlay owns all input regardless of the focused panel. let open = || { - page_for(MainMenu::Personas, |s| { + page_for(MainMenu::Identity, |s| { s.main_page.create_persona = Some(CreatePersonaState::default()); }) }; @@ -3786,7 +3786,7 @@ mod key_handler_tests { fn create_persona_done_phase_keys() { use crate::state_handler::main_page::content::{CreatePersonaPhase, CreatePersonaState}; let done = || { - page_for(MainMenu::Personas, |s| { + page_for(MainMenu::Identity, |s| { s.main_page.create_persona = Some(CreatePersonaState { phase: CreatePersonaPhase::Done, did: Some("did:webvh:example:alice".to_string()), diff --git a/tasks/follow-ups.md b/tasks/follow-ups.md index 172b707..a371ff6 100644 --- a/tasks/follow-ups.md +++ b/tasks/follow-ups.md @@ -26,7 +26,7 @@ support for rotating a persona's keys today. ### [ ] Identity pane — the four `persona/*` verbs it does not offer The pane (`ui/pages/main/components/personas_panel.rs` + `state_handler/persona_actions.rs`) -covers faces, the pool, profiles, bindings and the disclosure history. Four +covers personas, the pool, profiles, bindings and the disclosure history. Four parts of the family are deliberately not on it, each for a reason worth keeping: - **Authoring a credential-backed or generated attribute.** Both are shown and