roots: brighten both theme schemes and card the member identities
commit
9330f14roots: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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