git-ents.gitmain
⌘K
foforge
commit 9330f14
roots: brighten both theme schemes and card the member identities

The proposal-c token values become THE tokens: brighter surfaces and a warmer accent in both light and dark schemes. Members render as identity cards — username prominent, key type badged, key material truncated through the middle with the full line behind a details toggle — instead of shredding an SSH key across a generic table cell; the generic list_table stays for the other entity families.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/tests/router.rs @@ -1159,6 +1159,59 @@ ); } +/// `GET /members` and `GET /members/{username}` render an identity card +/// per member -- username prominent, the key type as a badge, the key +/// material truncated through the middle with the full line behind a +/// details toggle -- never the generic entity table an SSH key's base64 +/// body used to shred. +#[tokio::test] +async fn members_pages_render_an_identity_card_per_member() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let key = Keypair::from_seed(1); + let full_key = key.public_openssh(); + enroll_member( + &refs, + &objects, + "jdc", + &key, + Provenance::AdminRegistered, + 100, + ); + let state = build_state_with( + FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(2), + }, + refs, + objects, + ); + let router = ents_web::router(state); + + let list = get_body(&router, "/members").await; + assert!(list.contains("member-card"), "the identity card renders"); + assert!( + list.contains("class=\"key-badge\"") && list.contains("ssh-ed25519"), + "the key type badges" + ); + assert!( + list.contains('\u{2026}'), + "the key material truncates through the middle" + ); + assert!( + list.contains("full key") && list.contains(&full_key), + "the full key line stays one details toggle away" + ); + assert!( + !list.contains("entity-list"), + "the generic table no longer renders here" + ); + + let show = get_body(&router, "/members/jdc").await; + assert!(show.contains("member-card")); + assert!(show.contains("class=\"key-badge\"")); +} + /// `GET /files/<path>` renders a `.md` blob as Markdown and a `.adoc` blob /// as AsciiDoc -- both a real rendered heading, not the raw source markup. #[tokio::test]
crates/cli/ents-web/src/assets/ents.css @@ -30,16 +30,19 @@ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; --max-width: 78rem; --content-narrow: 58rem; - --color-bg: #faf8f4; - --color-surface: #fff; + /* The brighter "Proposal C" pass (the workbench mocks' proposal-c.css + * token overrides, merged in as THE tokens rather than layered over the + * old, dimmer pre-redo values). */ + --color-bg: #fdfcf8; + --color-surface: #ffffff; --color-text: #2a2518; --color-text-muted: #8a7e6a; - --color-link: #b07d10; - --color-link-hover: #96690a; - --color-border: #ede9de; - --color-code-bg: #f5f3eb; - --color-accent: #b07d10; - --color-accent-subtle: #b07d100f; + --color-link: #c08a12; + --color-link-hover: #a8760c; + --color-border: #e9e4d6; + --color-code-bg: #f6f4ec; + --color-accent: #c08a12; + --color-accent-subtle: #c08a120f; --shadow-sm: 0 1px 3px #0000000d; --shadow-md: 0 4px 16px #0000000f; --radius-sm: 10px; @@ -57,16 +60,16 @@ } @media (prefers-color-scheme: dark) { :root { - --color-bg: #171510; - --color-surface: #211f17; - --color-text: #ede8d8; + --color-bg: #1d1b15; + --color-surface: #282419; + --color-text: #f3eedd; --color-text-muted: #a89e88; - --color-link: #d4a030; - --color-link-hover: #e4b850; - --color-border: #383324; - --color-code-bg: #211f17; - --color-accent: #d4a030; - --color-accent-subtle: #d4a03012; + --color-link: #e8b23c; + --color-link-hover: #f2c55c; + --color-border: #453e2a; + --color-code-bg: #322d1e; + --color-accent: #e8b23c; + --color-accent-subtle: #e8b23c12; --shadow-sm: 0 1px 3px #00000040; --shadow-md: 0 4px 16px #0000004d; --s-comment: #928374; @@ -313,6 +316,21 @@ .badge { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .5rem; margin-left: .5rem; } +/* Member identity cards (`crate::pages::members::member_card`): username + * prominent, key type as an accent badge, key material truncated through + * the middle with the full line behind a details toggle. */ +.member-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; padding: .8rem 1.1rem; } +.member-head .badge { margin-left: 0; } +.member-name { font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem; color: var(--color-text); } +a.member-name { text-decoration: none; } +a.member-name:hover { color: var(--color-accent); } +.key-badge { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: 0 .55rem; white-space: nowrap; } +.member-key { padding: 0 1.1rem .8rem; font-family: var(--font-mono); font-size: .82rem; } +.member-key code { color: var(--color-text-muted); background: var(--color-code-bg); border-radius: 5px; padding: .1rem .45rem; } +.member-key details { margin-top: .4rem; } +.member-key summary { font-size: .72rem; color: var(--color-text-muted); cursor: pointer; user-select: none; -webkit-user-select: none; } +.member-key pre { margin-top: .3rem; padding: .5rem .7rem; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); white-space: pre-wrap; word-break: break-all; font-size: .74rem; } + /* Forms (`account`, `comments`, `issues`, the review form): each labeled * field is a compact two-column grid row -- the label in a fixed left * column, its control on the right -- stacking back to one column under
crates/cli/ents-web/src/pages/members.rs @@ -1,14 +1,18 @@ -//! `GET /members`, `GET /members/{username}`: the generic list/view pair -//! for [`ents_model::Member`] -- read-only in this phase (enrollment stays -//! a `git ents members add` operation; see this crate's own top-level doc -//! for why write flows are demonstrated on [`super::account`] rather than -//! duplicated per entity). +//! `GET /members`, `GET /members/{username}`: the member surface -- an +//! identity card per enrolled key rather than [`crate::render`]'s generic +//! table (an SSH public key's base64 body defeats a table cell; the card +//! shows the key type as a badge and the material truncated through the +//! middle, with the full line behind a `<details>` toggle). Read-only in +//! this phase (enrollment stays a `git ents members add` operation; see +//! this crate's own top-level doc for why write flows are demonstrated on +//! [`super::account`] rather than duplicated per entity). use std::sync::Arc; use axum::extract::{Path, State}; -use ents_model::Member; +use ents_model::{Member, MemberState, Provenance}; use gix_object::{Find, Write}; +use maud::{Markup, html}; use crate::error::{Error, Result}; use crate::state::AppState; @@ -30,13 +34,17 @@ Err(error) => failures.push((format!("refs/meta/member/{username}"), error)), } } - let table = if rows.is_empty() { + let body = if rows.is_empty() { super::blankslate( "No members yet", maud::html! { "Enroll one with " code { "git ents members add" } "." }, ) } else { - crate::render::list_table(&rows, "username", |id| format!("/members/{id}")) + html! { + @for (username, member) in &rows { + (member_card(username, member, true)) + } + } }; Ok(super::layout_meta( &super::RepoHeader::from_state(&state), @@ -45,7 +53,7 @@ "Members", maud::html! { (crate::render::unreadable_disclosure(&failures)) - (table) + (body) }, )) } @@ -72,7 +80,7 @@ what: format!("member {username}"), })?; let body = match member { - Ok(member) => crate::render::view(&member), + Ok(member) => member_card(&username, &member, false), Err(detail) => crate::render::unreadable(&detail), }; Ok(super::layout_meta( @@ -87,6 +95,85 @@ )) } +/// One member's identity card: the username prominent (a link on the list +/// page, plain on the member's own page), the key type as a badge, the +/// state and provenance as muted badges, and the key material truncated +/// through the middle ([`truncate_middle`]) with the full key line behind +/// a `<details>` toggle -- no digest dependency, so no fingerprint; the +/// truncated material plus the expandable full line is the identity a +/// reader compares. +fn member_card(username: &str, member: &Member, link: bool) -> Markup { + let (key_type, material) = split_key(&member.key); + html! { + div.card.member-card { + div.member-head { + @if link { + a.member-name href={ "/members/" (username) } { (username) } + } @else { + span.member-name { (username) } + } + @if let Some(key_type) = key_type { + span.key-badge { (key_type) } + } + span.badge { (state_label(member.state)) } + span.badge { (provenance_label(member.provenance)) } + } + div.member-key { + code { (truncate_middle(material)) } + details { + summary { "full key" } + pre { (member.key) } + } + } + } + } +} + +/// A member's key line split into its type token (`ssh-ed25519`, ...) and +/// key material -- `(None, whole line)` when the line has no second token +/// to badge (`ents-model` treats the key as opaque text, so this only ever +/// assumes the OpenSSH `type material [comment]` shape when it actually +/// sees one). +fn split_key(key: &str) -> (Option<&str>, &str) { + let mut parts = key.split_whitespace(); + let first = parts.next().unwrap_or(""); + match parts.next() { + Some(material) => (Some(first), material), + None => (None, first), + } +} + +/// Key material truncated through the middle (`AAAA…zM7f`), leaving the +/// start and end a reader actually compares -- the full line stays one +/// `<details>` toggle away. +fn truncate_middle(material: &str) -> String { + const HEAD: usize = 12; + const TAIL: usize = 8; + let count = material.chars().count(); + if count <= HEAD.saturating_add(TAIL).saturating_add(1) { + return material.to_owned(); + } + let head: String = material.chars().take(HEAD).collect(); + let tail: String = material.chars().skip(count.saturating_sub(TAIL)).collect(); + format!("{head}\u{2026}{tail}") +} + +/// [`MemberState`] as its badge text. +fn state_label(state: MemberState) -> &'static str { + match state { + MemberState::Active => "active", + MemberState::Revoked => "revoked", + } +} + +/// [`Provenance`] as its badge text. +fn provenance_label(provenance: Provenance) -> &'static str { + match provenance { + Provenance::AdminRegistered => "admin-registered", + Provenance::SelfAttested => "self-attested", + } +} + /// Every `refs/meta/member/*` ref, with its tip's tree deserialized as a /// [`Member`] -- `Err(detail)` for a ref this build's `#[derive(Facet)]` /// shape could not read back, kept in the listing (not dropped) so @@ -122,3 +209,41 @@ } Ok(out) } + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + + use super::*; + + #[rstest] + #[case::openssh( + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJq4 jdc@host", + Some("ssh-ed25519"), + "AAAAC3NzaC1lZDI1NTE5AAAAIJq4" + )] + #[case::bare_token("opaquekeymaterial", None, "opaquekeymaterial")] + fn split_key_badges_only_a_typed_key_line( + #[case] key: &str, + #[case] key_type: Option<&str>, + #[case] material: &str, + ) { + assert_eq!(split_key(key), (key_type, material)); + } + + #[test] + fn truncate_middle_keeps_the_start_and_end_of_a_long_key() { + let material = "AAAAC3NzaC1lZDI1NTE5AAAAIJq4rB5zM7f"; + let shown = truncate_middle(material); + assert!(shown.starts_with("AAAAC3NzaC1l")); + assert!(shown.ends_with("rB5zM7f")); + assert!(shown.contains('\u{2026}')); + assert_eq!( + truncate_middle("short"), + "short", + "a short token is left whole" + ); + } +}
crates/cli/ents-web/src/pages/mod.rs @@ -2,13 +2,15 @@ //! body, mirroring `git_ents::commands`'s "one module per subcommand //! family" convention on the web side. //! -//! [`dashboard`], [`members`], [`account`], [`effects`], [`redactions`], +//! [`account`], [`effects`], [`redactions`], //! and [`inbox`] are the generic pages: they read a kernel entity and //! render it through [`crate::render`]'s reflection-driven mechanism, -//! never matching on which entity type they were handed. -//! [`toolchains`], [`comments`], and [`issues`] are legitimate custom pages -//! (`ents-kiln`'s recipe provenance and `ents-forge`'s anchor projection -//! and issue threads all need domain-specific rendering no generic +//! never matching on which entity type they were handed. [`dashboard`], +//! [`toolchains`], [`comments`], [`issues`], and [`members`] are +//! legitimate custom pages +//! (`ents-kiln`'s recipe provenance, `ents-forge`'s anchor projection +//! and issue threads, and a member's SSH-key identity card all need +//! domain-specific rendering no generic //! reflection walk should grow special cases for). [`members`], //! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally //! share one `meta` rail item and `META_SECTIONS` rail rather than each