From 9760cbd416cd78af1adc24e0a1d90563f294a7a9 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 19 Sep 2026 11:54:33 +0200 Subject: [PATCH] feat(vetting): make a face for this community without leaving the flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #356 made the picker say which faces cannot make this community's card. That leaves the case where none of them can: the answer was to go to My Identity, build a face there, come back and press f again — the out-of-cycle config work this flow has been folding inline everywhere else. The picker gains a last row, "Make a face for this community". A row rather than a key, so every way out of the screen is in the list the eye is already on. It opens a form that knows what the face is for. The community has already named the claim types its card must carry, so the matching attributes open **already ticked** and the holder's decision is usually just a name. What is still missing is said against the current selection, not against the pool, so unticking something required says so at the moment of the choice rather than at the card preview. Composed from attributes the pool already holds; it never creates one. Supplying a value for a legal name is a different act from arranging which attributes a face shows, and it belongs with the editor that knows about value types, sensitivity and masking. What this form owes the holder is to name the claim type no attribute can cover — a different problem from one merely unticked, because ticking harder will not fix it — rather than offer a face that cannot make the card. Creating and wearing are one step. A face made here exists to be worn by this application; leaving it created but unworn would put the holder back on the picker to do what they had just asked for. It reuses the `FaceWorn` outcome, so the face is recorded on the application and shown by the same paths as one chosen from the list. The pool read meets the same holder-grant refusal the faces read does — the pool is what a face is built over — and routes it to the same view rather than passing the agent's paragraph through. `other_entries` is empty on the write because this creates: there is no profile whose pinned or inline entries could be dropped. The general editor has to carry them; here there is nothing yet to carry. Signed-off-by: Glenn Gore --- .../src/state_handler/main_page/content.rs | 104 ++++++ openvtc/src/state_handler/vetting_actions.rs | 352 +++++++++++++++++- .../ui/pages/main/components/vetting_panel.rs | 124 +++++- 3 files changed, 567 insertions(+), 13 deletions(-) diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index a602770..e7cc6cd 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -1816,6 +1816,104 @@ pub struct VetterStandingRow { pub profile: String, } +/// One of the holder's pool attributes, as a row to tick. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PoolRow { + pub attribute_id: String, + pub claim_type: String, + /// What to call it on screen — the holder's label, else the claim type. + pub label: String, +} + +/// Which half of the new-face form has the keyboard. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum NewFaceFocus { + #[default] + Name, + Attributes, +} + +/// Making a face without leaving the vetting flow. +/// +/// The general face editor lives on the Identity page and is the right place to +/// build a face for its own sake. This is the narrow case: a community has +/// already said which claim types its card must carry, so the form knows what +/// the face is *for* and can open with those attributes already ticked. +/// +/// Composed from attributes the pool already holds — it never creates one. +/// Supplying a value for, say, a legal name is a different act from arranging +/// which attributes a face shows, and it belongs with the editor that knows +/// about value types, sensitivity and masking. What this form owes the holder +/// when the pool is short is to say exactly which claim type is missing, rather +/// than to offer a face that cannot make the card. +#[derive(Clone, Debug, Default)] +pub struct NewFaceForm { + pub application_id: String, + /// The claim types this community's card must carry, so the list can mark + /// them and the form can warn when the pool cannot cover them. + pub required: Vec, + /// Every attribute the holder has, metadata only. + pub pool: Vec, + pub name: String, + /// Attribute ids ticked, in the order they were ticked — which is the + /// order the face will present them in. + pub ticked: Vec, + pub cursor: usize, + pub focus: NewFaceFocus, + pub error: Option, +} + +impl NewFaceForm { + /// Required claim types no attribute in the pool can supply. + /// + /// Not a refusal: a holder may be building a face now and adding the + /// attribute afterwards. It is named so the reason the card will be + /// refused is on screen *before* a vetter is waiting on it. + #[must_use] + pub fn uncoverable(&self) -> Vec<&str> { + self.required + .iter() + .filter(|r| !self.pool.iter().any(|a| &&a.claim_type == r)) + .map(String::as_str) + .collect() + } + + /// The claim types the ticked attributes would disclose. + #[must_use] + pub fn covered(&self) -> Vec<&str> { + self.pool + .iter() + .filter(|a| self.ticked.contains(&a.attribute_id)) + .map(|a| a.claim_type.as_str()) + .collect() + } + + /// Required claim types the current selection still does not carry. + #[must_use] + pub fn still_missing(&self) -> Vec<&str> { + let covered = self.covered(); + self.required + .iter() + .filter(|r| !covered.contains(&r.as_str())) + .map(String::as_str) + .collect() + } + + /// Tick or untick the attribute under the cursor. + pub fn toggle(&mut self) { + let Some(row) = self.pool.get(self.cursor) else { + return; + }; + match self.ticked.iter().position(|id| id == &row.attribute_id) { + Some(i) => { + self.ticked.remove(i); + } + None => self.ticked.push(row.attribute_id.clone()), + } + self.error = None; + } +} + /// What the Vetting page is doing. #[derive(Clone, Debug, Default)] pub enum VettingMode { @@ -1841,6 +1939,8 @@ pub enum VettingMode { /// The DID to grant it to, when this install has one. credential_did: Option, }, + /// Make a face for this community, without leaving the flow. + NewFace(Box), /// Choose the face an application shows vetters. ChooseFace { application_id: String, @@ -1937,6 +2037,9 @@ impl VettingMode { Some(event) => event.text().map(String::as_str), None => form.text().map(String::as_str), }, + // Only while the name has focus: on the tick list, space has to + // reach `Toggle` rather than being typed into a field. + VettingMode::NewFace(form) if form.focus == NewFaceFocus::Name => Some(&form.name), _ => None, } } @@ -1955,6 +2058,7 @@ impl VettingMode { VettingMode::RequestVetter { code, field: 1, .. } => Some(code), VettingMode::Directory(view) => view.text_mut(), VettingMode::Profile(form) => form.focused_text_mut(), + VettingMode::NewFace(form) if form.focus == NewFaceFocus::Name => Some(&mut form.name), _ => None, } } diff --git a/openvtc/src/state_handler/vetting_actions.rs b/openvtc/src/state_handler/vetting_actions.rs index 0a0c03e..8e91aa9 100644 --- a/openvtc/src/state_handler/vetting_actions.rs +++ b/openvtc/src/state_handler/vetting_actions.rs @@ -57,10 +57,11 @@ use crate::state_handler::join_flow; use crate::state_handler::main_page::content::{ ApplicationRow, AttestForm, CardPreview, DIRECTORY_FIELDS, DIRECTORY_LABELS, DIRECTORY_METHODS, DeskRow, DeskStage, DeskView, DirectoryCommunity, DirectoryView, EVENT_FIELDS, EVENT_LABELS, - EventForm, FaceChoice, IssuedRow, LineTone, ListedVetterRow, PROFILE_FIELDS, PROFILE_LABELS, - RequestRow, TicketRow, VETTING_METHODS, VETTING_RELATIONSHIPS, VETTING_TICKET_USES, - VETTING_WITHDRAWAL_REASONS, VetterProfileForm, VetterStandingRow, VettingMembership, - VettingMode, VettingPersona, VettingState, VettingTab, method_label, row_of, + EventForm, FaceChoice, IssuedRow, LineTone, ListedVetterRow, NewFaceFocus, NewFaceForm, + PROFILE_FIELDS, PROFILE_LABELS, PoolRow, RequestRow, TicketRow, VETTING_METHODS, + VETTING_RELATIONSHIPS, VETTING_TICKET_USES, VETTING_WITHDRAWAL_REASONS, VetterProfileForm, + VetterStandingRow, VettingMembership, VettingMode, VettingPersona, VettingState, VettingTab, + method_label, row_of, }; use crate::state_handler::main_page::menu::MainMenu; use crate::state_handler::main_page::{sanitize_display, shorten_did}; @@ -665,6 +666,7 @@ pub(crate) async fn dispatch(ctx: &mut ActionCtx<'_>, action: VettingAction) { refresh_application_contexts(ctx); } VettingAction::Toggle => match &mut page(ctx).mode { + VettingMode::NewFace(form) if form.focus == NewFaceFocus::Attributes => form.toggle(), VettingMode::Attest { form, .. } => match form.field { 3 => form.liveness_confirmed = !form.liveness_confirmed, 4 => form.attested = !form.attested, @@ -903,7 +905,23 @@ fn move_field(v: &mut VettingState, forward: bool) { step(&mut form.field, rows); } }, - VettingMode::ChooseFace { faces, index, .. } => step(index, faces.len()), + // One past the faces is "make a new one" — a row, not a key, so that + // every way out of this screen is in the list the eye is already on. + VettingMode::ChooseFace { faces, index, .. } => step(index, faces.len() + 1), + VettingMode::NewFace(form) => match form.focus { + NewFaceFocus::Name => form.focus = NewFaceFocus::Attributes, + NewFaceFocus::Attributes => { + let rows = form.pool.len(); + if rows == 0 { + form.focus = NewFaceFocus::Name; + } else if form.cursor + 1 < rows { + form.cursor += 1; + } else { + form.focus = NewFaceFocus::Name; + form.cursor = 0; + } + } + }, VettingMode::Attest { form, .. } => step(&mut form.field, AttestForm::FIELDS), _ => {} } @@ -966,7 +984,7 @@ fn cycle(v: &mut VettingState, forward: bool) { VettingMode::Withdraw { reason_index, .. } => { turn(reason_index, VETTING_WITHDRAWAL_REASONS.len()); } - VettingMode::ChooseFace { faces, index, .. } => turn(index, faces.len()), + VettingMode::ChooseFace { faces, index, .. } => turn(index, faces.len() + 1), _ => {} } } @@ -998,8 +1016,15 @@ async fn submit(ctx: &mut ActionCtx<'_>) { application_id, faces, index, - .. - } => wear_face(ctx, &application_id, faces.get(index).cloned()), + required, + } => { + if index == faces.len() { + open_new_face(ctx, &application_id, required); + } else { + wear_face(ctx, &application_id, faces.get(index).cloned()); + } + } + VettingMode::NewFace(form) => create_face(ctx, *form), VettingMode::RequestVetter { application_id, vetter, @@ -1856,6 +1881,75 @@ fn preview_refusal(error: &str, application_id: &str, config: &Config) -> String ) } +/// Open the make-a-face form, reading the pool to fill its tick list. +/// +/// The read is what makes this worth doing inline: the community has already +/// said which claim types it needs, so the form opens with the matching +/// attributes already ticked and the holder's decision is usually just a name. +fn open_new_face(ctx: &mut ActionCtx<'_>, application_id: &str, required: Vec) { + let Some(client) = admin_client(ctx) else { + return; + }; + if !begin(ctx) { + return; + } + status(ctx, "Reading your attributes…"); + let job = FaceJob::Pool { + client, + application_id: application_id.to_string(), + required, + }; + spawn_job(ctx, job.run()); +} + +/// Create the face the form describes, and wear it. +/// +/// One step, not two. A face made here exists only to be worn by this +/// application — leaving it created but unworn would put the holder back on the +/// picker to do the thing they had just asked for. +fn create_face(ctx: &mut ActionCtx<'_>, mut form: NewFaceForm) { + let name = form.name.trim().to_string(); + if name.is_empty() { + form.error = Some("Give the face a name — it is how you will recognise it later.".into()); + form.focus = NewFaceFocus::Name; + page(ctx).mode = VettingMode::NewFace(Box::new(form)); + return; + } + if form.ticked.is_empty() { + form.error = Some( + "Tick at least one attribute with space — a face that shows nothing cannot make a \ + card." + .into(), + ); + form.focus = NewFaceFocus::Attributes; + page(ctx).mode = VettingMode::NewFace(Box::new(form)); + return; + } + let Some(client) = admin_client(ctx) else { + return; + }; + let application_id = form.application_id.clone(); + let (context_id, persona_did) = match application_context(ctx.config, &application_id) { + Ok(found) => found, + Err(e) => return status(ctx, format!("Cannot make a face: {e}")), + }; + if !begin(ctx) { + return; + } + page(ctx).mode = VettingMode::List; + status(ctx, format!("Making {name}…")); + let job = FaceJob::Create { + client, + top_context_id: ctx.config.account.top_context_id.clone(), + context_id, + persona_did, + application_id, + name, + live_refs: form.ticked.clone(), + }; + spawn_job(ctx, job.run()); +} + /// The claim types a card for this community must carry. /// /// From the manifest when it has been read, and from the fallback set when it @@ -2517,6 +2611,22 @@ pub(crate) enum FaceJob { persona_did: String, application_id: String, }, + /// Read the pool so the make-a-face form has something to tick. + Pool { + client: VtaClient, + application_id: String, + required: Vec, + }, + /// Create a face and wear it, in one step. + Create { + client: VtaClient, + top_context_id: String, + context_id: String, + persona_did: String, + application_id: String, + name: String, + live_refs: Vec, + }, Wear { client: VtaClient, top_context_id: String, @@ -2603,6 +2713,85 @@ impl FaceJob { result, } } + FaceJob::Pool { + client, + application_id, + required, + } => { + // Metadata only. The form arranges which attributes a face + // shows; it never reads or writes their values, so there is + // nothing here to decrypt. + let result = pool::list(&client, false, false) + .await + .map(|attributes| { + attributes + .into_iter() + .map(|a| PoolRow { + label: sanitize_display(a.display_name(), 128), + claim_type: a.claim_type, + attribute_id: a.attribute_id, + }) + .collect::>() + }) + .map_err(|e| e.to_string()); + VettingOutcome::Pool { + application_id, + required, + result, + } + } + FaceJob::Create { + client, + top_context_id, + context_id, + persona_did, + application_id, + name, + live_refs, + } => { + // `other_entries` is empty because this creates: there is no + // profile whose pinned or inline entries could be dropped. The + // general editor has to carry them; here there is nothing yet + // to carry. + let created = profile::put(&client, None, &name, &live_refs, &[], None).await; + let (profile_id, error) = match created { + Ok(id) => (id, None), + Err(e) => (String::new(), Some(e.to_string())), + }; + // Wearing it is the point, so a face created but left unworn + // would put the holder back on the picker to do what they had + // just asked for. A failure to wear is still reported against + // the face that now exists. + let error = match error { + Some(e) => Some(e), + None => { + let slug = parse_sub_context_id(&context_id) + .map_or(context_id.as_str(), |(_, slug)| slug); + match community_context::ensure_context( + &client, + &top_context_id, + &context_id, + slug, + ) + .await + { + Err(e) => Some(e.to_string()), + Ok(_) => { + binding::set(&client, &context_id, &persona_did, Some(&profile_id)) + .await + .err() + .map(|e| e.to_string()) + } + } + } + }; + VettingOutcome::FaceWorn { + error, + application_id, + profile_id, + name, + } + } FaceJob::Wear { client, top_context_id, @@ -2811,6 +3000,12 @@ pub(crate) enum VettingOutcome { application_id: String, result: Result, String>, }, + /// The pool, for the make-a-face form. + Pool { + application_id: String, + required: Vec, + result: Result, String>, + }, /// A face is worn, or is not. FaceWorn { application_id: String, @@ -2895,6 +3090,58 @@ impl VettingOutcome { // The application may have just been given its context. (message.to_string(), true) } + VettingOutcome::Pool { + application_id, + required, + result: Ok(pool), + } => { + // Open with the required claim types already ticked. The + // community has said what the card needs, so leaving the + // holder to work that out from a list of thirty attributes + // would be withholding the one thing that makes this inline. + let ticked: Vec = required + .iter() + .filter_map(|want| { + pool.iter() + .find(|a| &a.claim_type == want) + .map(|a| a.attribute_id.clone()) + }) + .collect(); + let form = NewFaceForm { + application_id, + required, + pool, + name: String::new(), + ticked, + cursor: 0, + focus: NewFaceFocus::Name, + error: None, + }; + let short = form.uncoverable(); + let message = if short.is_empty() { + "Name the face. What this community needs is already ticked.".to_string() + } else { + format!( + "You have no {} attribute — add one under My Identity, or make the face \ + now and add it after.", + short.join(" or ") + ) + }; + v.mode = VettingMode::NewFace(Box::new(form)); + (message, true) + } + VettingOutcome::Pool { result: Err(e), .. } => { + // The same refusal the faces read has, for the same reason: + // the pool is what a face is built over. + if crate::holder_grant::needs_holder_grant(&e) { + v.mode = VettingMode::HolderGrant { + credential_did: agent_credential_did(config).map(str::to_string), + }; + ("Could not read your attributes.".to_string(), true) + } else { + (format!("Could not read your attributes: {e}"), true) + } + } VettingOutcome::Faces { result: Err(e), .. } => { // Faces are built over the holder's attribute pool, which sits // above every context — so the commonest way this fails is the @@ -3808,6 +4055,95 @@ mod tests { assert_eq!(v.worn_faces.get("a").map(String::as_str), Some("WORK")); } + fn pool_row(id: &str, claim_type: &str) -> PoolRow { + PoolRow { + attribute_id: id.into(), + claim_type: claim_type.into(), + label: claim_type.to_uppercase(), + } + } + + /// The whole point of making the face *here*: the community has already + /// said what its card needs, so the holder should not have to work that out + /// again from a list of attributes. + #[test] + fn the_new_face_form_opens_with_the_required_claims_ticked() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + VettingOutcome::Pool { + application_id: "a".into(), + required: vec!["name.legal".into()], + result: Ok(vec![ + pool_row("attr-email", "email.work"), + pool_row("attr-name", "name.legal"), + ]), + } + .apply(&mut state, &mut config, &mut save); + + let VettingMode::NewFace(form) = &state.main_page.content_panel.vetting.mode else { + panic!("the form did not open"); + }; + assert_eq!(form.ticked, vec!["attr-name".to_string()]); + assert!( + form.still_missing().is_empty(), + "the required claim is covered by the opening selection" + ); + // Unticking it is said against the selection, not the pool, so the + // warning appears at the moment the choice is made. + let mut form = (**form).clone(); + form.cursor = 1; + form.toggle(); + assert_eq!(form.still_missing(), vec!["name.legal"]); + } + + /// A claim type no attribute can supply is a different problem from one + /// that is merely unticked, and the form must not conflate them: ticking + /// harder will not fix it. + #[test] + fn a_claim_the_pool_cannot_cover_is_named_as_such() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + VettingOutcome::Pool { + application_id: "a".into(), + required: vec!["name.legal".into()], + result: Ok(vec![pool_row("attr-email", "email.work")]), + } + .apply(&mut state, &mut config, &mut save); + + let VettingMode::NewFace(form) = &state.main_page.content_panel.vetting.mode else { + panic!("the form did not open"); + }; + assert!(form.ticked.is_empty(), "nothing in the pool matches"); + assert_eq!(form.uncoverable(), vec!["name.legal"]); + assert_eq!(form.still_missing(), vec!["name.legal"]); + } + + /// The pool is what a face is built over, so it meets the same refusal the + /// faces read does — and must answer it the same way rather than passing + /// the agent's paragraph through. + #[test] + fn the_pool_read_routes_the_holder_refusal_to_the_same_view() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + VettingOutcome::Pool { + application_id: "a".into(), + required: Vec::new(), + result: Err("forbidden: requires an unscoped holder credential".into()), + } + .apply(&mut state, &mut config, &mut save); + + assert!(matches!( + &state.main_page.content_panel.vetting.mode, + VettingMode::HolderGrant { .. } + )); + } + /// The card preview's one explicable refusal. The agent's sentence names /// neither the claims, nor the face, nor the key that changes it — and it /// arrives three steps after the choice that caused it. diff --git a/openvtc/src/ui/pages/main/components/vetting_panel.rs b/openvtc/src/ui/pages/main/components/vetting_panel.rs index 44b2d7a..1ed3f0e 100644 --- a/openvtc/src/ui/pages/main/components/vetting_panel.rs +++ b/openvtc/src/ui/pages/main/components/vetting_panel.rs @@ -23,7 +23,7 @@ use crate::state_handler::{ main_page::content::{ AttestForm, CardPreview, ContentPanelState, DIRECTORY_FIELDS, DIRECTORY_LABELS, DIRECTORY_METHODS, DeskStage, DeskView, DirectoryView, EVENT_LABELS, EventForm, LineTone, - PROFILE_FIELDS, PROFILE_LABELS, VETTING_METHODS, VETTING_RELATIONSHIPS, + NewFaceFocus, PROFILE_FIELDS, PROFILE_LABELS, VETTING_METHODS, VETTING_RELATIONSHIPS, VETTING_TICKET_USES, VETTING_WITHDRAWAL_REASONS, VetterProfileForm, VettingMode, VettingState, VettingTab, method_label, reason_label, relationship_label, }, @@ -67,6 +67,7 @@ pub fn mode_id(state: &VettingState) -> &'static str { (VettingMode::NewApplication { .. }, _) => "new-application", (VettingMode::HolderGrant { .. }, _) => "holder-grant", (VettingMode::ChooseFace { .. }, _) => "face", + (VettingMode::NewFace(_), _) => "new-face", (VettingMode::RequestVetter { .. }, _) => "request", (VettingMode::Directory(_), _) => "directory", (VettingMode::Profile(form), _) if form.event.is_some() => "profile-event", @@ -246,6 +247,101 @@ pub fn render(v: &VettingState) -> Vec> { lines.push(Line::from("")); lines.push(hint("Esc: back")); } + VettingMode::NewFace(form) => { + lines.push(heading("Make a face for this community")); + lines.push(Line::from("")); + lines.push(hint( + "A face is a selection of your attributes. Making one here does not copy them —", + )); + lines.push(hint( + "the same attribute can appear in as many faces as you like.", + )); + lines.push(Line::from("")); + lines.push(field( + "Name", + if form.name.is_empty() { + "—".to_string() + } else { + form.name.clone() + }, + form.focus == NewFaceFocus::Name, + false, + )); + lines.push(Line::from("")); + if !form.required.is_empty() { + lines.push(Line::from(vec![ + Span::styled(" This community's card needs ", label()), + Span::styled(form.required.join(", "), value()), + ])); + lines.push(Line::from("")); + } + if form.pool.is_empty() { + lines.push(Line::from(Span::styled( + " You have no attributes yet. Add them under My Identity, then press f again.", + dim(), + ))); + } + for (i, attribute) in form.pool.iter().enumerate() { + let on_row = form.focus == NewFaceFocus::Attributes && i == form.cursor; + let ticked = form.ticked.contains(&attribute.attribute_id); + // A required claim type is marked wherever it appears, ticked + // or not: the holder is choosing against a list the community + // set, and that list should be visible on the rows it governs. + let wanted = form.required.contains(&attribute.claim_type); + let style = if on_row { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + label() + }; + lines.push(Line::from(vec![ + Span::styled(if on_row { "▸ " } else { " " }, style), + Span::styled(if ticked { "[x] " } else { "[ ] " }, style), + Span::styled(attribute.label.clone(), style), + Span::styled(format!(" {}", attribute.claim_type), dim()), + Span::styled( + if wanted { " needed here" } else { "" }, + Style::new().fg(COLOR_SUCCESS), + ), + ])); + } + // Said against the current selection, not against the pool, so + // unticking something required says so at once rather than at the + // card preview. + let short = form.still_missing(); + if !short.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled(" missing ", dim()), + Span::styled( + short.join(", "), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ), + Span::styled(" — this face could not make the card", dim()), + ])); + let uncoverable = form.uncoverable(); + if !uncoverable.is_empty() { + lines.push(Line::from(Span::styled( + format!( + " no attribute of yours is {} — add one under My Identity", + uncoverable.join(" or ") + ), + dim(), + ))); + } + } + if let Some(error) = &form.error { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + format!(" {error}"), + Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED), + ))); + } + lines.push(Line::from("")); + lines.push(hint( + "Tab: name / attributes ↑/↓: move Space: tick Enter: make it and wear it \ + Esc: cancel", + )); + } VettingMode::ChooseFace { faces, index, @@ -335,10 +431,28 @@ pub fn render(v: &VettingState) -> Vec> { ])); } } - lines.push(Line::from("")); - lines.push(hint( - "Add a missing claim to a face under My Identity, then press f again.", - )); + // A row rather than a key, so every way out of this screen is in + // the list the eye is already on. + let on_new = *index == faces.len(); + lines.push(Line::from(vec![ + Span::styled( + if on_new { "▸ " } else { " " }, + if on_new { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + label() + }, + ), + Span::styled( + "Make a face for this community", + if on_new { + Style::new().fg(COLOR_SUCCESS).bold() + } else { + label() + }, + ), + Span::styled(" from attributes you already have", dim()), + ])); lines.push(Line::from("")); lines.push(hint("↑/↓: choose Enter: wear it Esc: cancel")); }