diff --git a/Justfile b/Justfile index 00c4fecc2f..189efa69ff 100644 --- a/Justfile +++ b/Justfile @@ -660,6 +660,79 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm) pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}} + +# Run desktop-new in dev mode with the same local relay, services, ports, and identity as `dev`. +desktop-new-dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations + #!/usr/bin/env bash + set -euo pipefail + export PATH="{{justfile_directory()}}/bin:$PATH" + set -o allexport + source .env + set +o allexport + bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}" + relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000 + health_port="${BUZZ_HEALTH_PORT:-8080}" + metrics_port="${BUZZ_METRICS_PORT:-9102}" + if command -v lsof >/dev/null 2>&1; then + for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do + name="${spec%%:*}"; port="${spec##*:}" + if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then + echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2 + lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true + echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2 + exit 1 + fi + done + fi + cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + # Docker Desktop's forwarded MinIO port can stall under the deployment + # probe's 32 concurrent writers. Keep the gate enabled in local dev, using + # the bounded profile already used by the relay test launcher. + export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}" + export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}" + TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") + "${TARGET_DIR}/debug/buzz-relay" & + RELAY_PID=$! + cleanup() { + [[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true + kill "$RELAY_PID" 2>/dev/null || true + } + trap cleanup EXIT + relay_ready=false + for _ in $(seq 1 120); do + if ! kill -0 "$RELAY_PID" 2>/dev/null; then + echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2 + wait "$RELAY_PID" || true + exit 1 + fi + if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then + relay_ready=true + break + fi + sleep 0.5 + done + if [[ "$relay_ready" != true ]]; then + echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2 + exit 1 + fi + [[ -d desktop/node_modules ]] || pnpm --dir desktop install + [[ -d desktop-new/node_modules ]] || pnpm --dir desktop-new install + cd {{desktop_dir}} + source ../scripts/instance-env.sh + # Keep instance-env's worktree-specific identifier, ports, icon, reset URL, + # and identity, but point the native runtime at the independent client. + export BUZZ_TAURI_CONFIG=$(node -e ' + const config = JSON.parse(process.env.BUZZ_TAURI_CONFIG); + config.productName = `Buzz Sessions (${process.env.BUZZ_WORKTREE_LABEL ?? "Dev"})`; + config.build.devUrl = `http://localhost:${process.env.BUZZ_VITE_PORT}${process.env.BUZZ_RESET_WEBVIEW_STATE === "1" ? "?resetDevState=1" : ""}`; + config.build.beforeDevCommand = `exec pnpm --dir ../desktop-new exec vite --port ${process.env.BUZZ_VITE_PORT} --strictPort`; + console.log(JSON.stringify(config)); + ') + INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)") + echo "Starting desktop-new on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}" + FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm) + pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}} + # Run only the desktop app. No relay, database, Docker, migrations, or .env are needed. # The app opens normally and asks for a community before making a relay connection. desktop-standalone *ARGS: _ensure-sidecar-stubs diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e1ab1c7f5..c860fd81ce 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -491,6 +491,13 @@ pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; pub const KIND_STREAM_REMINDER: u32 = 40007; /// A diff/patch message showing file changes (unified diff format). pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; +/// A durable link from a parent channel to a channel-backed Session. +/// +/// The event is stored in the parent (`h` tag). Its content names the child +/// Session channel as `{ "session_channel_id": "" }`. Human discovery +/// and transcript authorization inherit from the parent channel; the child +/// remains an ordinary private stream channel for messaging and agent scope. +pub const KIND_SESSION_LINK: u32 = 40009; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -709,6 +716,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, KIND_STREAM_MESSAGE_DIFF, + KIND_SESSION_LINK, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_CHANNEL_SUMMARY, diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index 8280ca01f8..40f885398c 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -687,6 +687,64 @@ pub async fn is_member( Ok(cnt > 0) } +/// Return whether a pubkey inherits access to a Session through an active +/// parent-channel link. The link must be creator-authenticated and the Session +/// must still be an active private stream channel. +pub async fn has_session_parent_access( + pool: &PgPool, + community_id: CommunityId, + session_channel_id: Uuid, + pubkey: &[u8], +) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let allowed: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 + FROM events session_link + JOIN channels parent + ON parent.community_id = session_link.community_id + AND parent.id = session_link.channel_id + AND parent.deleted_at IS NULL + JOIN channels session_channel + ON session_channel.community_id = session_link.community_id + AND session_channel.id = $2 + AND session_channel.id::text = CASE + WHEN session_link.content IS JSON OBJECT + THEN (session_link.content::json ->> 'session_channel_id') + ELSE NULL + END + AND session_channel.created_by = session_link.pubkey + AND session_channel.visibility = 'private' + AND session_channel.channel_type = 'stream' + AND session_channel.ttl_seconds IS NULL + AND session_channel.archived_at IS NULL + AND session_channel.deleted_at IS NULL + JOIN channel_members parent_member + ON parent_member.community_id = parent.community_id + AND parent_member.channel_id = parent.id + AND parent_member.pubkey = $3 + AND parent_member.role <> 'bot' + AND parent_member.removed_at IS NULL + WHERE session_link.community_id = $1 + AND session_link.kind = $4 + AND session_link.deleted_at IS NULL + ) + "#, + ) + .bind(community_id.as_uuid()) + .bind(session_channel_id) + .bind(pubkey) + .bind(buzz_core::kind::KIND_SESSION_LINK as i32) + .fetch_one(&mut *connection) + .await?; + Ok(allowed) +} + /// Return which of the given (channel, pubkey) combinations are active /// memberships, restricted to non-deleted channels — one statement for any /// batch size (T2b). Semantics per pair match [`is_member`]. @@ -824,10 +882,40 @@ pub async fn get_accessible_channel_ids( SELECT id AS channel_id FROM channels WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL + UNION + SELECT session_channel.id AS channel_id + FROM events session_link + JOIN channels parent + ON parent.community_id = session_link.community_id + AND parent.id = session_link.channel_id + AND parent.deleted_at IS NULL + JOIN channels session_channel + ON session_channel.community_id = session_link.community_id + AND session_channel.id::text = CASE + WHEN session_link.content IS JSON OBJECT + THEN (session_link.content::json ->> 'session_channel_id') + ELSE NULL + END + AND session_channel.created_by = session_link.pubkey + AND session_channel.visibility = 'private' + AND session_channel.channel_type = 'stream' + AND session_channel.ttl_seconds IS NULL + AND session_channel.archived_at IS NULL + AND session_channel.deleted_at IS NULL + JOIN channel_members parent_member + ON parent_member.community_id = parent.community_id + AND parent_member.channel_id = parent.id + AND parent_member.pubkey = $2 + AND parent_member.role <> 'bot' + AND parent_member.removed_at IS NULL + WHERE session_link.community_id = $1 + AND session_link.kind = $3 + AND session_link.deleted_at IS NULL "#, ) .bind(community_id.as_uuid()) .bind(pubkey) + .bind(buzz_core::kind::KIND_SESSION_LINK as i32) .fetch_all(&mut *connection) .await?; @@ -1364,6 +1452,17 @@ impl Db { is_member(&self.pool, community_id, channel_id, pubkey).await } + /// Return whether the pubkey inherits Session access from a parent channel. + #[datastore_span(name = "has_session_parent_access", system = "postgresql")] + pub async fn has_session_parent_access( + &self, + community_id: CommunityId, + session_channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + has_session_parent_access(&self.pool, community_id, session_channel_id, pubkey).await + } + /// Return the active (channel, pubkey) membership pairs among the given /// sets, in one statement. #[datastore_span(name = "membership_pairs", system = "postgresql")] @@ -1601,6 +1700,90 @@ mod postgres_tests { crate::channel::get_channel(pool, CommunityId::from_uuid(community_id), id).await } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn session_parent_access_inherits_to_humans_but_not_bots() { + let pool = setup_pool().await; + let community_id = make_test_community(&pool).await; + let community = CommunityId::from_uuid(community_id); + let creator = random_pubkey(); + let human = random_pubkey(); + let bot = random_pubkey(); + for pubkey in [&creator, &human, &bot] { + ensure_user(&pool, community, pubkey) + .await + .expect("ensure user"); + } + let parent = create_test_channel( + &pool, + community_id, + "parent", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &creator, + None, + ) + .await + .expect("create parent"); + let session = create_test_channel( + &pool, + community_id, + "session", + ChannelType::Stream, + ChannelVisibility::Private, + None, + &creator, + None, + ) + .await + .expect("create session"); + for (pubkey, role) in [(&human, "member"), (&bot, "bot")] { + sqlx::query("INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) VALUES ($1,$2,$3,$4::member_role,$5)") + .bind(community_id) + .bind(parent.id) + .bind(pubkey) + .bind(role) + .bind(&creator) + .execute(&pool) + .await + .expect("add parent member"); + } + // Insert a structurally equivalent stored link directly to isolate the + // authorization SQL from event-signing behavior. + let event_id = random_pubkey(); + sqlx::query("INSERT INTO events (id, community_id, pubkey, created_at, kind, tags, content, sig, channel_id) VALUES ($1,$2,$3,NOW(),$4,$5,$6,$7,$8)") + .bind(event_id) + .bind(community_id) + .bind(&creator) + .bind(buzz_core::kind::KIND_SESSION_LINK as i32) + .bind(serde_json::json!([["h", parent.id.to_string()]])) + .bind(serde_json::json!({"session_channel_id": session.id}).to_string()) + .bind(vec![0_u8; 64]) + .bind(parent.id) + .execute(&pool) + .await + .expect("insert Session link"); + assert!( + has_session_parent_access(&pool, community, session.id, &human) + .await + .expect("human access") + ); + assert!( + !has_session_parent_access(&pool, community, session.id, &bot) + .await + .expect("bot access") + ); + remove_member(&pool, community, parent.id, &human, &creator) + .await + .expect("remove parent member"); + assert!( + !has_session_parent_access(&pool, community, session.id, &human) + .await + .expect("revoked access") + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn get_users_bulk_is_scoped_when_pubkey_exists_in_multiple_communities() { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 37c549610d..45757d55d9 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1531,11 +1531,22 @@ async fn repair_requested_channel_access( if accessible_channels.contains(&channel_id) { continue; } - let is_member = state + let direct_member = state .db .is_member(tenant.community(), channel_id, pubkey_bytes) .await .map_err(|e| internal_error(&format!("channel membership confirmation: {e}")))?; + let is_member = if direct_member { + true + } else { + state + .db + .has_session_parent_access(tenant.community(), channel_id, pubkey_bytes) + .await + .map_err(|e| { + internal_error(&format!("Session parent access confirmation: {e}")) + })? + }; crate::handlers::req::resolve_request_local_access( accessible_channels, channel_id, diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 938674301e..004704744d 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -136,7 +136,31 @@ pub async fn handle_count( .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) .await { - Ok(member) => Some(member), + Ok(direct_member) => { + if direct_member { + Some(true) + } else { + match state + .db + .has_session_parent_access( + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + Ok(inherited) => Some(inherited), + Err(e) => { + warn!(sub_id = %sub_id, "Session parent access confirmation failed: {e}"); + conn.send(RelayMessage::closed( + &sub_id, + "error: database error", + )); + return; + } + } + } + } Err(e) => { warn!(sub_id = %sub_id, "Channel membership confirmation failed: {e}"); conn.send(RelayMessage::closed(&sub_id, "error: database error")); diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 66a8ff9e7c..83af504b3a 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -212,7 +212,17 @@ pub async fn filter_fanout_by_access( .await { Ok(true) => allowed.push((conn_id, sub_id)), - Ok(false) => {} + Ok(false) => match state + .db + .has_session_parent_access(community_id, channel_id, &pubkey) + .await + { + Ok(true) => allowed.push((conn_id, sub_id)), + Ok(false) => {} + Err(e) => { + warn!(%channel_id, "fan-out access filter: Session parent lookup failed: {e}"); + } + }, Err(e) => { warn!(%channel_id, "fan-out access filter: membership lookup failed: {e}"); } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee1d0312be..a0c05da6fa 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -29,12 +29,12 @@ use buzz_core::kind::{ KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PRIVATE_MANAGED_AGENT, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, - KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, - KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_READ_STATE, KIND_REPORT, KIND_SESSION_LINK, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -142,6 +142,55 @@ async fn validate_huddle_lifecycle_event( Ok(()) } +async fn validate_session_link( + tenant: &TenantContext, + state: &AppState, + event: &Event, +) -> Result<(), IngestError> { + if event_kind_u32(event) != KIND_SESSION_LINK { + return Ok(()); + } + let parent_channel_id = extract_channel_id(event).ok_or_else(|| { + IngestError::Rejected("invalid: Session link must name its parent channel".into()) + })?; + let content: serde_json::Value = serde_json::from_str(&event.content) + .map_err(|_| IngestError::Rejected("invalid: Session link content must be JSON".into()))?; + let session_channel_id = content + .get("session_channel_id") + .and_then(serde_json::Value::as_str) + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or_else(|| { + IngestError::Rejected("invalid: Session link must name a session_channel_id".into()) + })?; + if session_channel_id == parent_channel_id { + return Err(IngestError::Rejected( + "invalid: a Session cannot be its own parent channel".into(), + )); + } + let backing = state + .db + .get_channel_for_event_write(tenant.community(), session_channel_id) + .await + .map_err(|error| match error { + buzz_db::DbError::ChannelNotFound(_) => { + IngestError::Rejected("invalid: Session channel not found".into()) + } + error => IngestError::Internal(format!("error: loading Session channel: {error}")), + })?; + if backing.created_by.as_slice() != event.pubkey.to_bytes().as_slice() + || backing.channel_type != "stream" + || backing.visibility != "private" + || backing.ttl_seconds.is_some() + || backing.archived_at.is_some() + { + return Err(IngestError::Rejected( + "invalid: Session link must reference the signer's active private stream channel" + .into(), + )); + } + Ok(()) +} + fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> { for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -515,7 +564,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), + KIND_NIP29_CREATE_GROUP | KIND_CANVAS | KIND_SESSION_LINK => Ok(Scope::ChannelsWrite), KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST => { Ok(Scope::ChannelsRead) } @@ -715,6 +764,7 @@ pub(crate) fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER | KIND_STREAM_MESSAGE_DIFF + | KIND_SESSION_LINK | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE @@ -757,7 +807,16 @@ pub(crate) async fn check_channel_membership( Ok(false) => {} Err(e) => return Err(format!("error: database error: {e}")), } - // Not a member — check if channel is open. + match state + .db + .has_session_parent_access(tenant.community(), ch_id, pubkey_bytes) + .await + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(e) => return Err(format!("error: database error: {e}")), + } + // Not a member and no inherited Session access — check if channel is open. let is_open = match channel { Some(ch) => ch.visibility == "open", None => state @@ -2667,6 +2726,7 @@ async fn ingest_event_inner( } validate_huddle_lifecycle_event(tenant, state, &event, kind_u32).await?; + validate_session_link(tenant, state, &event).await?; if crate::handlers::side_effects::is_admin_kind(kind_u32) { crate::handlers::side_effects::validate_admin_event(tenant, kind_u32, &event, state) @@ -3211,6 +3271,10 @@ async fn ingest_event_inner( }); } + if kind_u32 == KIND_SESSION_LINK { + state.invalidate_all_accessible_channels(tenant); + } + if crate::handlers::side_effects::is_side_effect_kind(kind_u32) { if let Err(e) = crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 9a141e86ff..d8cf96aaa4 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -159,7 +159,30 @@ pub async fn handle_req( .is_member(conn.tenant.community(), ch_id, &pubkey_bytes) .await { - Ok(member) => { + Ok(direct_member) => { + let member = if direct_member { + true + } else { + match state + .db + .has_session_parent_access( + conn.tenant.community(), + ch_id, + &pubkey_bytes, + ) + .await + { + Ok(inherited) => inherited, + Err(e) => { + warn!(conn_id = %conn_id, "Session parent access confirmation failed: {e}"); + conn.send(RelayMessage::closed( + &sub_id, + "error: database error", + )); + return; + } + } + }; if let Some(state_snap) = trace_state.as_ref() { crate::conformance::record_req_authcheck( &state.tracer, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 7282c91342..b79a0ee089 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1327,6 +1327,7 @@ async fn handle_put_user( ) .await?; state.invalidate_membership(tenant, channel_id, &target_pubkey); + state.invalidate_all_accessible_channels(tenant); let actor_hex = hex::encode(&actor_bytes); let target_hex = hex::encode(&target_pubkey); @@ -1390,6 +1391,7 @@ async fn handle_remove_user( .remove_member(tenant.community(), channel_id, &target_pubkey, &actor_bytes) .await?; state.invalidate_membership(tenant, channel_id, &target_pubkey); + state.invalidate_all_accessible_channels(tenant); evict_live_channel_subscriptions(tenant, state, channel_id, &target_pubkey).await; disable_departed_member_workflows(tenant, state, channel_id, &target_pubkey).await; @@ -1744,6 +1746,8 @@ async fn handle_delete_event_side_effect( return Ok(()); // No-op: skip system message to avoid false audit records. } + state.invalidate_all_accessible_channels(tenant); + // Thread counters were decremented in the same transaction — push a fresh // relay-signed 39005 so live badge counts also count *down*. if let Some(root_id) = root_id { diff --git a/desktop-new/.gitignore b/desktop-new/.gitignore new file mode 100644 index 0000000000..9ff0889f30 --- /dev/null +++ b/desktop-new/.gitignore @@ -0,0 +1,14 @@ +node_modules +dist +playwright-report +test-results +*.log +.DS_Store +src/app/routeTree.gen.ts + +# Local-only dev tooling. A config here wraps the committed vite.config.ts to +# add a personal plugin; it is ignored so it cannot be committed by accident. +vite.local.config.ts + +# Isolated local runtime, private keys, and build products +.local/ diff --git a/desktop-new/AGENTS.md b/desktop-new/AGENTS.md new file mode 100644 index 0000000000..24f9c5af20 --- /dev/null +++ b/desktop-new/AGENTS.md @@ -0,0 +1,326 @@ +# AGENTS.md — desktop-new + +This is a **new Buzz desktop client, built from scratch.** It is not a fork of +`desktop/` and not a migration target. The existing client stays untouched; this +one is being built to replace it eventually, starting from the design system +rather than from features. + +For repository-wide rules see the root [AGENTS.md](../AGENTS.md). Everything here +is specific to this client and takes precedence within it. + +--- + +## Read before writing any UI + +1. [DESIGN.md](./DESIGN.md) — the judgement tokens cannot express. Not optional. +2. `src/shared/tokens/registry.ts` — what exists, what each name is for. +3. Run `pnpm dev`, open `/design`, and look at the thing you are about to change. + +--- + +## Getting started + +```bash +pnpm install +pnpm dev # http://localhost:1430 +``` + +`/design` is the design system, rendered from the token registry. `/` is a +placeholder — the app shell and its capabilities come later. + +Port 1430, so it can run alongside the existing client on 1420. + +```bash +pnpm typecheck # tsc --noEmit +pnpm check # biome + the type, contrast, and colour guards +pnpm check:type # the type-system guard alone +pnpm check:contrast # the contrast guard alone +pnpm check:color # the colour-system guard alone +pnpm biome check --write . # auto-fix +``` + +## The guards + +Three scripts, run by `pnpm check`. They read your files as text and look for a +short list of specific mistakes. Nothing depends on them — the app builds and +runs identically without them. They exist because **an agent has no basis for +choosing between two plausible values and will otherwise pick either**, which is +how two labels that should match end up different. + +| Script | Rejects | Why it exists | +|---|---|---| +| `check-type.mjs` | arbitrary text sizes (px **or** rem), `uppercase`, hand-applied `tracking-*`, any font weight but 400/600 | Each one is a defect the existing client already shipped. A px size freezes against keyboard zoom. | +| `check-contrast.mjs` | any text role that fails its **APCA** target (Lc 60 body, Lc 45 meta) on a surface it can sit on, in either mode | Every role against every surface it can reach, in both modes, is more pairings than anyone checks by hand — and the old WCAG 2 ratio disagrees with the eye on dark greys. | +| `check-color.mjs` | opacity used to reach a subtler colour, `color-mix()`, alpha `rgb()`/`hsl()`, a glass fill used without its material, and **a new semantic role that is the same ramp step in both modes** | A faded colour has no name, no light/dark pair, and nothing measurable. The role check is the one that keeps the layer from regrowing: it states which class to write instead. | + +`pnpm test` adds one more, and it is the one to understand before adding a check +of your own. `src/shared/tokens/registry.test.ts` asserts that **every token the +/design pages document actually exists**, and that **no text or border role +registers under Tailwind's shared `--color-*` namespace.** Both bind a +documentation claim to the stylesheet, because both had already broken: +`bg-float-glass` was described, swatched, and never declared, and `text-danger` +resolved to the saturated fill instead of the text step — shipping error text at +APCA Lc 34 while `check-contrast` passed, because it measured a token no class +could reach. **A guard that measures something the browser never resolves is +worse than no guard.** DESIGN.md § Text and borders register in their own +namespaces has the full account. + +Each script's error message states the rule and the fix, so the reasoning lives +next to the code that enforces it. The design arguments behind them are in +DESIGN.md — § Type, § Contrast, § Colour discipline. + +`pnpm census` is the fourth tool and the only one that never fails anything. It +lists every colour role with its readers — `var(--x)` and the Tailwind class +alike, docs counted separately from product code. Run it before adding a role, and +before arguing that one is dead. + +**Every rule has a named escape hatch, and using it is a normal edit.** Add an +entry to `OVERRIDES` (or `EXCEPTIONS`) keyed by path with a one-line reason. The +guards are a note from a designer who looked at the screen, not an authority — +**if you find yourself changing a design to satisfy one, the guard is wrong.** +Edit or delete it. Stale entries are reported so the lists cannot rot. + +The contrast and colour guards parse `tokens.css` rather than a copied list of +values, so they cannot drift from the tokens they audit. + +--- + +## Colour and type systems + +Both are documented in [DESIGN.md](./DESIGN.md) — § Colour structure and § Type. +They live there rather than here because they are design decisions, and because +documenting them in two places is how this file came to claim the colour system +had two layers in one section and three in another. + +The one thing to know before typing: **build from the ramps.** `bg-purple-3`, +`text-red-12`, `bg-neutral-4` — every step is authored per mode, so a step behaves +correctly in light and dark. This is the opposite of the usual Tailwind advice, +and it holds only because of that per-mode authoring. + +**The fifteen semantic roles are the exceptions, not the interface.** A role +exists when light and dark take *different* steps (`bg-panel` is `neutral-1` then +`neutral-3` — no class can say that), or when the name enforces a rule a ramp +cannot (there are deliberately three text levels). `pnpm check:color` rejects a +new role that is the same step in both modes, and names the class to use instead. + +Glass is the one genuinely private layer: a fill without its blur, rim, and lift +is not glass, so it is reachable only through the `glass-primary` / +`glass-secondary` utilities. + +--- + +## Stack, and why + +| Choice | Reason | +|---|---| +| **Base UI** | Behaviour, accessibility, keyboard, and positioning with zero appearance. The visual language is authored here, not inherited then overridden. **Before building any interactive shared component, check Base UI first; when it has the behavior, wrap and compose that primitive rather than recreating its events, focus, positioning, portal, or dismissal logic.** A native element is appropriate only when Base UI has no corresponding primitive or the component is semantically static. | +| **Tailwind v4** | Tokens are defined in CSS via `@theme`; the CSS *is* the config. No JS config file. | +| **Own colour tokens** | Not shadcn. Its vocabulary — `muted-foreground`, `secondary-foreground` — is what made colour illegible in the existing client. | +| **TanStack Router** | File-based routes, same as the existing client. | +| **Tabler Icons** | The only icon set for product UI. Do not add or use Lucide icons. | + +Astryx is a **reference** for token architecture and the agent-docs idea. Not a +dependency. + +--- + +## Growing the system + +**Need something the system doesn't have? Add it to the registry, mark it +`proposed` with an owner, keep working.** No gate, no approval, no separate +mechanism for one-offs. The full procedure is in DESIGN.md § Growing the system. + +The only stop condition: **if you cannot describe it in one sentence, ask.** That +is the signal it is not a role. + +--- + +## Structure + +``` +src/ + app/routes/ file-based routes + shared/ + styles/tokens.css the palette, the roles, the Tailwind registration + styles/typography.css the type ramps and the nine roles + styles/globals.css base styles, and the glass material utilities + styles/components.css the shared components' own styles + styles/product.css product surfaces not yet in a component + styles/design-components.css the /design pages' own furniture + styles/agents.css styles/chips.css + tokens/registry.ts the machine-readable colour + token description + ui/ the shared components (11 so far) + ui/registry.ts the machine-readable component description + identity/ community/ theme/ the ambient facts — see § Capability or shared fact? + chips/ runtime/ + features/ + design-system/ui/ the /design pages +``` + +`features/design-system/ui/primitives.tsx` is **documentation furniture, not a +component library.** The shared primitive layer gets built one component at a +time as the product repeats something — see DESIGN.md § Components. + +--- + +## What is not here yet + +Deliberately, so nobody assumes it was forgotten: + +- **Spacing, radius, and motion tokens.** Their `/design` pages state what is + still to decide rather than pretending to a system. Typography has landed; + spacing is next. +- **Any product component.** No Button, no Dialog, no input layer. +- **Tauri.** This is a web app for now; the native shell comes with the app shell. +- **Relay, auth, event handling.** None of it. When it arrives it comes from the + shared Rust crates, not a reimplementation. +- **The computed paired-text rule.** `--text-on-accent` holds a literal white + until the lightness computation lands, and is marked as an exception. It is the + one text colour that cannot be a ramp step: it follows its *fill*, not the mode, + so white on `purple-9` stays white in dark mode where a neutral step would flip. + The `bg-neutral-11` / `text-neutral-1` pair is governed by the same rule and + measured as a pair in `check-contrast.mjs` even though both are now steps. +- **A general chrome/surface convention.** Two components now carry the axis — + `Tabs` as `variant="chrome" | "panel"` and `IconButton` as a `chrome` variant. + Both spell it `variant`, but nothing enforces that a component appearing on + both backgrounds has the axis at all. See DESIGN.md § Surface and depth. +- **Contrast on glass.** `check-contrast` measures text against the opaque + surface roles only. On a translucent surface the real background is the fill + composited over the gradient, which changes across the screen — measured, dark + mode's brightest glass region puts `text-secondary` at Lc 58 and + `text-tertiary` at Lc 43 against targets of 60 and 45. Documented in DESIGN.md + § Surface and depth with the three candidate fixes; unresolved on purpose, + because it wants real content on screen rather than a token edit. +- **Dockview's theme in dark mode.** `DockWorkspace` passes dockview's + `themeLight` in both modes — which is how an opaque white sheet ended up behind + the glass until `--dv-group-view-background-color` was reset in `product.css`. + (The shell's duplicate `dark` state is fixed: it reads `useColorScheme`, and + `tests/sessions.spec.ts` binds the reload behaviour.) + +--- + +## Architecture + +This client is built on **composable capabilities**. A capability owns a durable product truth: it may be behavior-only, +UI-bearing, or both. A feature is an organizational boundary, a surface/view +owns spatial arrangement, and shared UI owns generic visual and interaction +primitives. + +### Capability or shared fact? + +App-wide things are one of two kinds, and putting the second kind in the first +kind's folder is what turns a shared layer into a dumping ground. + +Ask: **could two parts of the app disagree about this, and would that +disagreement be a bug?** + +- **No — it is a fact.** It answers a question and owns no policy: no fallback, + retry, ordering, readiness, or teardown rules. `useIdentity` (who am I), + `useCommunity` (which relay), `useColorScheme` (light or dark). Facts live in + domain-named folders under `shared/` — `shared/identity/`, + `shared/community/`, `shared/theme/`. There is no `facts/` folder. +- **Yes — it is a capability.** It has policy, so someone must own the single + answer: Navigation, Conversation, Composer. The hook is only the door; the + capability is the room behind it. A hook consumed app-wide is not evidence of + a fact. + +Direction is the invariant: **facts flow up, never down.** A capability or a +surface may read a fact; a fact must never import a capability, a feature, or a +surface. The moment a fact needs to know about Navigation, it is a capability +nobody has named yet. + +**A feature reads a fact through its module, never through the backend adapter.** +Each fact exposes both shapes: `readIdentity()` / `readRelayUrl()` for an async +action, and `useIdentity()` / `useCommunity()` for render. Calling +`runtime.identity()` or `runtime.relayUrl()` from a feature is the defect — the +Agents workspace fetched both directly in two places, so a third and fourth +answer to "who am I" existed with nothing keeping them consistent. Only the facts +layer may call the adapter for a fact. + +A surface must not re-derive a fact it could read. The shell held its own `dark` +boolean beside `useColorScheme` and toggled the same `.dark` class — one concept +with two owners, which is why the appearance choice did not survive a reload. +`tests/sessions.spec.ts` now binds that behaviour. + +Two rules that matter from day one: + +- **A capability may own live state. Any state scoped to a community must + register its teardown** in the same change that adds it. The existing client + learned this the hard way; do not rebuild the problem. +- **Do not create a capability speculatively.** The bar is a durable product + identity and real composition pressure from two surfaces. Building a clean + codebase is not a licence to relax it. + +### Implementation discipline + +These rules turn the general client-layer contract into decisions an agent can +apply while building Desktop New. + +#### State and Effects + +- **Derive values rather than synchronizing copies with Effects.** A selected + item, lookup map, or view condition belongs in render when existing state can + answer it; a second state value that must be kept in sync creates two owners. +- **An Effect exists only to synchronize with an external system.** Relay + subscriptions, Tauri, browser storage, Dockview, and DOM APIs are external. + User actions belong in event handlers or controller actions, never in render + or an Effect that watches for them. +- **Every external synchronization has a complete lifetime.** An Effect returns + its unsubscribe, close, disconnect, or listener cleanup. Every async result + is fenced against the current destination and community before it writes; + switching quickly must never let stale data land. + +#### Controllers and compositions + +- **A hook shares behavior, not automatically live state.** Decide deliberately + whether a controller instance belongs to one placement, one conversation, or + the app. A module-level store or cache is community-scoped state and must earn + that complexity and register teardown. +- **Context distributes an existing controller inside one composition.** It does + not create a second product authority or make a capability global by default. +- **A composition reads controller state and invokes controller actions.** It + does not fetch backend data, call a backend adapter, or recreate policy + locally. If a composition needs a fact, read the domain-named shared fact; if + it needs behavior, name or extend the owning capability. + +#### Reliability and test honesty + +- **Every accepted user action settles visibly.** It completes, fails with an + understandable recovery affordance, or cancels. A draft or pending action + never clears itself into uncertainty. +- **Tests bind the product seam and must be falsifiable.** A test reaches the + controller or composition used in the product and goes red when the guarantee + is removed. Test rapid switching, teardown, failure, retry, recovery, and + successful loading — not only the happy path. + +#### Location while routing is incomplete + +- **Navigation is the only interim location owner.** Until route-authoritative + navigation lands, no feature or surface may introduce a second current-place + state. When route work begins, move Navigation's existing destination model to + the router rather than making the router and controller compete. + +### Navigation capability + +Navigation is a capability, not an AppShell detail. It owns the canonical +destination model, product-level navigation actions, destination resolution, +readiness, restore and fallback policy, and navigation-specific product +compositions. Put these in `features/navigation/` when this work begins. + +- A feature requests a destination in product terms — for example, “open this + channel”, “open this Session”, or “return to Messages”. It does not construct + a URL, mutate browser history, change a Tauri window, or implement its own + fallback/restore rule. +- AppShell places navigation compositions in the workspace and adapts their + arrangement responsively. It does not reinterpret destination meaning or + duplicate navigation policy. +- The Messages navigator, a future top-level lens selector, and a compact + navigator may be separate compositions that consume the same navigation + controller. Do not build a universal `Navigation` component with layout + modes and optional regions. +- Generic components such as `Panel`, `Button`, `SearchField`, and + `NavigatorRow` remain shared UI: they know nothing about Buzz destinations. +- Stop and ask before changing route shape, deep links, Back/Forward behavior, + persisted restoration, community-switch sequencing, or navigation semantics + shared by another client surface. These are product-contract decisions, not + local implementation details. diff --git a/desktop-new/DESIGN.md b/desktop-new/DESIGN.md new file mode 100644 index 0000000000..84c5bd12a8 --- /dev/null +++ b/desktop-new/DESIGN.md @@ -0,0 +1,408 @@ +# DESIGN.md + +How to design well in this client. The token registry says which value to use; this says what tokens cannot express — the judgement a designer makes without thinking and an agent gets wrong without being told. Read it before building a surface. + +Run `pnpm dev` and open `/design` to see the system rendered from the tokens themselves. + +## Posture + +Buzz is a place where people build together and bring their agents into the room. Everyday surfaces stay quiet, crisp, and highly functional; character shows up in identity, guidance, transitions, and ceremony rather than in the chrome of ordinary work. Colour is signal, not decoration. When in doubt, the interface gets out of the way of the conversation. + +## Surface and depth + +- **Panels sit on the backdrop; the backdrop is a gradient.** Everything else is a panel in a different place. The navigation column is not a special kind of surface. +- **A region is separated by a soft fill, not by an outline.** Reach for `bg-inset` before reaching for a border. A bordered box announces its own edges; a filled one lets the content sit in a place. Grouping is the common case, so the quiet treatment is the default one. +- **A border is for a genuine boundary, and there is one weight: `border-primary`.** It is `neutral-4` in light and `neutral-6` in dark. A hairline needs more separation on a dark surface than the same step number supplies: `neutral-4` measured 1.08:1 on a dark panel and 1.09:1 on the floating composer — drawn, and effectively invisible. `neutral-5` looked like the obvious one-step move but is `bg-float` in dark, so a border there would be 1.00:1: the same colour as the surface it is meant to bound. `neutral-6` clears the actual surfaces at 1.35–1.43 on panel/inset and 1.14 on float. **That mode difference is the reason this is a role rather than `border-neutral-4`.** A second weight arrives only with the design that proves a different boundary needs it. Text and borders still hold different values — text at the dark end of the neutral ramp, borders at the light end — so if a divider looks like text, it is pointed at the wrong role. +- **No page-wide gradient behind documentation or dense reading.** The gradient is the product's backdrop for chrome and panels. Behind a column of prose it fights the text and makes contrast position-dependent — such surfaces sit on `bg-panel`. +- **Shadows stay at the threshold of perception.** If a shadow is obvious, it is too strong. The two elevation values are the whole vocabulary. +- **Elevation is carried by shadow in light mode and by lightness in dark mode.** On a near-black background there is nothing darker for a shadow to cast, so a floating surface becomes a step lighter instead. Never reach for a stronger shadow to make something float in dark mode. +- **On a translucent surface, elevation reads as less translucency, not as a lighter colour.** A glass container with a fully opaque child looks layered; the same container with a merely brighter child looks unchanged. +- **Light comes from one direction, and every glass surface agrees on it.** A glass rim is bright along the lit edge and dimmer on the opposite one; that is what makes it read as a material rather than an outline. Two surfaces lit from different directions in the same view look like a mistake. +- **A glass rim is not an outline.** If a surface needs a visible boundary rather than a material edge, it wants a border role, not glass. +- **Glass needs something behind it worth seeing.** Translucency over a flat fill is wasted cost; use it where the gradient, an image, or content actually shows through. +- **Only panels and chrome should sit directly on the gradient as a default.** Text and hairlines on a gradient have position-dependent contrast. Good practice rather than a hard rule — a rotated label pill on the backdrop is fine. +- **A translucent surface has no contrast guarantee, and this one is measured.** `check-contrast` pairs each text role with the *opaque* surface roles, so glass is invisible to it — the surface a person actually reads against is the fill composited over whatever gradient happens to be behind it, which varies by position on screen. Sampled from a rendered dark-mode screenshot, primary glass over Night garden runs from `#162e28` in its quiet regions to `#1e4a3c` where the green glow reaches through. On the darker end everything clears; on the brighter end **`text-secondary` measures Lc 58 and `text-tertiary` Lc 43**, against targets of 60 and 45. Marginal, and only in a region the glow reaches — but real, and no guard can see it. Three ways out, none obviously right: make the gradients' bright stops dimmer where panels sit, raise the glass fill a ramp step under a bright backdrop, or keep meta text off glass. **Deliberately unresolved** — it needs the real product content on screen, not a token edit. +- **A redundant fill on glass is not free — it compounds.** Two identical translucent layers are not one layer: `glass-2` over `glass-2` composites to **0.77 alpha**, a value no token holds. Four panels each set the same fill as the container they exactly covered, so panels meant to be the most translucent surface in the system read as nearly solid. Before giving a region a glass fill, check whether its parent already is glass; if the region covers it, it needs no fill of its own. +- **A component that can sit on either the gradient or a panel says so, with a variant.** `Tabs` takes `chrome` (a glass pill for the app backdrop) or `panel` (an underline for a plain surface); `IconButton` has the same axis as its `chrome` variant. The failure that earned it: the chrome container is `glass-2`, which over a white panel composites to pure white, and its selected pill is `neutral-1` — also pure white. Container and selection became one colour with only a shadow between them, and no guard could see it because the component had no way to state which background it expected. **The fix was never to retint `--bg-chrome-selected`** — that moves the collision rather than removing it. **One component with a variant, not two components:** behaviour, keyboard model, accessibility, props, and the Base UI parts underneath are identical, so a sibling component would duplicate all of it to change how selection is drawn, and the two would drift exactly as the four hand-assembled chrome surfaces did. When adding a component that could appear in both places, give it the axis and put both on its specimen page — the chrome-only specimen is why this defect survived until it appeared on a real screen. + +## State + +- **The interface has three states, plus disabled where it matters: default, hover, selected.** There is no pressed state: pressed is too fleeting to read and makes an interface feel jumpy. +- **Hover means one step more contrast, in whichever direction that surface needs.** A light row darkens, a dark chip lightens. Direction lives in the value. +- **Selected is a persistent statement, not a stronger hover.** It should be legible without a cursor present. +- **A selected item in a toggle group is not interactive.** Clicking it does nothing, so it gets no hover. +- **Disabled communicates unavailability, not quietness.** It is not a fourth level of the emphasis ramp. +- **Never hide the only way out of a state.** Before adding a visibility rule, ask what happens when the state it assumes is wrong, and whether the person can still recover. + +## Emphasis + +- **Three levels of text: normal, lesser, really lesser.** If a fourth seems necessary, the thing wants a different size, weight, or position instead of a fourth colour. +- **Two text colours do most of the work.** Treat the third level as genuinely for metadata. +- **Borders use the same three levels, and they mean the same thing.** Learn the ramp once. +- **Weight and size carry hierarchy before colour does.** Reaching for a louder colour to fix hierarchy usually means the size relationship is wrong. + +## Type + +Two layers, and the same rule as colour: only roles are used when building a +screen. Layer 1 is the raw ramps (`--type-size-*`, `--type-leading-*`, +`--type-tracking-*`, `--type-weight-*`); layer 2 is the roles, which register in +Tailwind's `--text-*` namespace and become utilities like `text-body`. The +authoring lives in `src/shared/styles/typography.css`. + +**Size roles and colour roles never collide**, because they live in different +namespaces: colour registers as `--color-*` and is named for emphasis +(`text-primary`), size registers as `--text-*` and is named for an editorial job +(`text-body`). So `text-primary text-body` is one colour plus one setting, and no +name ever means both. + +Nine roles. Sans: `text-display` 32, `text-title` 24, `text-heading` 16/600, +`text-body-lg` 16, `text-body` 14, `text-body-sm` 12. Mono: `text-mono-lg` 15, +`text-mono` 13, `text-mono-sm` 11. Two faces — `font-sans` (Inter Variable) and +`font-mono` (JetBrains Mono) — both already shipped in every current Buzz client. + +- **A type role carries its whole setting.** Size, line height, letter spacing, and weight are one decision, not four. `text-body` alone produces correctly set text, and its line height is never overridden — that is how two supposedly identical labels drift apart. +- **There are two weights: 400 and 600.** 400 is content — everything read. 600 is structure and emphasis: the thing that names what you are looking at, or the words a sentence leans on. `font-semibold` is what bold means here. +- **Bold body text is two utilities, composed.** `text-body font-semibold`, `text-body-sm font-semibold`. This is the one place a component adds a weight, and it is deliberate: **the size is the paragraph's decision, the weight is the phrase's.** A `text-body-bold` role would fuse them, so an agent emphasising three words would also be re-asserting a size it has no business choosing. Composing also means one rule covers every size instead of doubling the ramp. +- **500 and 700 are not in the system.** 500 was measured against 400 at body size and does not read as intent in a scanned list — subtle enough to miss, heavy enough to muddy a column. 700 is louder than anything here needs. Weight is not a ramp; it is two values with two jobs. +- **State is not weight.** Selected, active, and unread are said with colour, a fill, or a dot — all three already exist in the colour system. Reserve 600 for structure and emphasis, or it stops meaning either. +- **The guard has a named escape hatch, and using it is normal.** `scripts/check-type.mjs` rejects other weights because an agent has no basis for preferring `font-medium` to `font-semibold` and will otherwise pick either. A designer who finds a genuine optical exception adds it to `OVERRIDES` with a reason and moves on — that is an ordinary edit, not an escalation. The point is that the next person reads a decision instead of guessing at an accident. If overrides start accumulating in one direction, the system is missing a role; fix the system rather than adding a tenth entry. +- **Roles are named for the job the text does, never for its size.** `text-title`, not `text-28`. A size name is a value in disguise and goes stale the moment the ramp moves. +- **The ramp is short because the product is.** Across 73 real Buzz screens, 90% of all text is one size and two cover 93%; 16/18/20/22 together were 1.5%, scattered and inconsistent. So there is nothing between 16 and 24, and that gap is deliberate — an app has panels with names, not a document outline. Four steps above body invited a hierarchy the product does not have. +- **A heading is body-large in a different weight.** `text-heading` and `text-body-lg` are both 16px, separated by weight alone — which is what the shipping client does at 16/600, and the only text on a channel screen larger than a message. In a dense app a section name needs to be identifiable, not loud. +- **12px is the floor.** Nothing readable goes below it: the most compressed text in Berd — a timestamp in a hover slot, a model name in a 24px pill — is 12px. If something must be smaller it is not text, it is a glyph inside a component, and that component owns the size as a documented exception. Do not add a general-purpose smaller step. +- **Mono is one step below its sans partner, always.** 11↔12, 13↔14, 15↔16. At equal size a monospace face reads larger than Inter and pulls the eye off the sentence, so the correction is a rule rather than a judgement: pick the sans size, step down. Mono never exceeds body size in ordinary interface text; the one exception is a code or key a person must transcribe, which is what `text-mono-lg` is for. +- **Mono roles are named for the setting, not the content.** `text-mono`, not `text-code` — most mono in a product is a pubkey, a path, a branch name, or a hex value. Calling the role `code` made it read as a lie everywhere except an actual code block. +- **Never all-caps, and never tracked-out labels.** A capitalised label is harder to read than its sentence-case version and reads as enterprise chrome. A quiet label earns its quietness from size and colour — `text-body-sm` on `text-tertiary` — rather than from being shouted. There is deliberately no uppercase utility in this system. +- **Every size is relative.** Nothing may be expressed in px: fixed pixel text freezes against keyboard zoom and ignores the person's font-size preference. The existing client shipped a regression from exactly this. Everything derives from one virtual rem, so zoom and the font-size preference both work by construction — which is why an arbitrary rem literal is rejected too. It zooms correctly and still re-fragments the scale. +- **Tracking is an optical correction, not a style.** Inter needs progressively tighter spacing as it grows. The ramp already applies it per step; do not add tracking by hand. + +## Both modes + +- **Design in both modes, not in light and then dark.** Dark is not a filter applied afterwards: elevation, glass, and accent text all behave differently there. +- **Accent text moves in opposite directions between modes.** Darker than its fill on a light background, lighter on a dark one. +- **A tint is a pale wash in light mode and a deep one in dark.** The name describes the job, not the lightness. +- **Check the pairing, not the swatch.** A colour is only right in the context of what sits on it and behind it. +- **Every dark value in this system is authored rather than observed.** The design exploration it came from is light-only. Treat anything that looks wrong in dark as a finding. + +## Density and rhythm + +- **Dense data renders as rows with dividers, edge to edge.** Wrapping every list item in its own card is the most common way a functional surface becomes a marketing page. +- **Content that separates itself needs no divider, and no container.** A divider is for uniform rows where the eye needs a line to track along. When each entry already carries a visible difference — a colour swatch, a type specimen, an avatar — the content is the separator, and adding a rule or a card on top is redundant structure. Space alone is enough. +- **Never judge a value against a surface it will not be used on.** A swatch on a grey fill, or a type specimen in a tinted box, is being evaluated in a context the product will never reproduce. Samples sit on the page. The one exception is a value that needs a backdrop to exist at all — translucency needs something behind it, and a white surface swatch needs a hairline or it renders as nothing. +- **Cards are for widgets, galleries, and settings groups.** A card is a bordered, padded region on the page, not a different depth. +- **A card carries no default fill.** It sits on `bg-panel` and is grouped by a hairline or by spacing. Fill on a card is reserved for `bg-hover`, and only where the card is actually clickable — so a filled card always means *you are pointing at this*, never merely *this is a box*. This is why there is no `bg-card` role: a card with no default fill needs no name. It also rules out the cards-in-cards look, where a filled card inside a filled panel reads as a stack of empty text fields. Reaching for `bg-inset` here is the specific mistake — `inset` means pushed in, like an input or a code block, which is the opposite gesture from grouping. +- **Pick the frame before the content.** Decide what the surface is — a list, a reading column, a workspace — before filling it. +- **Whitespace is generous by default.** Crowding reads as a different product. + +## Motion + +- **Direct manipulation follows the pointer exactly, with no easing.** Smoothing during a drag or resize reads as lag. Spring physics belongs to what happens after release. +- **A drag gesture must not select text in whatever it passes over.** +- **Never animate blur.** Re-blurring a large surface every frame is expensive enough to feel. Animate opacity instead. +- **Motion explains a change; it does not decorate one.** If removing an animation loses no information, remove it. + +## Colour structure + +Two public layers, plus components: screens normally write a palette step, and use a role only for a decision a step cannot express. + +| Layer | Example | What it is | +|---|---|---| +| **0 palette** | `--purple-9`, `--neutral-4` | Every hue, twelve steps, authored per mode. The only place a literal lives. **Public: a screen writes `bg-purple-9`.** | +| **1 roles** | `--bg-panel` | The fifteen cases a step cannot express. Public too. | +| **2 components** | `bg-panel`, `bg-neutral-4` | Tailwind utilities, from either layer. | + +**Screens are built from the ramps.** This reverses the rule this file used to +state, and the reversal turns on one fact: **every palette step is authored per +mode.** `neutral-4` is `#e8e8e8` in light and `#232323` in dark, so a component +naming the step behaves correctly in both. That is what makes a raw step safe +here and unsafe in stock Tailwind, where `neutral-200` is a single literal — +naming it there really does break dark mode, and a semantic layer really is the +only fix. + +Once a step is mode-aware, **a role whose light and dark values are the same step +is a name in front of a number**, and a name in front of a number hides the +decision instead of recording it. `bg-accent` was `purple-9`; `text-error` was +`red-12`. Nineteen roles were exactly that and are gone. + +### When a name is earned + +Three cases, and `pnpm check:color` enforces the first two by rejecting any new +role that fails them: + +1. **Light and dark take different steps.** `bg-panel` is `neutral-1` in light and + `neutral-3` in dark. No single class can say that, so the name is load-bearing. + The four structural surfaces are all of this kind. +2. **The name enforces a rule a ramp cannot state.** There are deliberately three + levels of text and one border weight. `text-neutral-11` looks reasonable and is + how a fourth level appears without anyone deciding, so `text-secondary` stays + even though its step is identical in both modes. +3. **Morgan sees a repeated pattern and asks for one.** A tinted callout that + turns up on four screens earns a name — for the *pattern*, not the colour. This + is the only route by which the role layer grows, and it is deliberately manual. + +`neutral` is a hue like any other — the same twelve steps, the same naming. There +is no separate grey ramp and no `palette-` prefix: a step is `--neutral-4`, the +way Tailwind names a colour. + +**Tailwind's default palette is deleted** with `--color-*: initial`, so +`text-gray-500` does not exist. It is a build error, not a style choice. + +**There used to be a families layer** — `--accent-fill`, `--danger-tint`, thirty +steps in five families, sitting between the palette and the roles. It was +deleted. Every one of its thirty steps had exactly *one* reader, so it renamed a +colour rather than abstracting one, and answering "what colour is this button" +meant reading three lines in two places +(`bg-accent` → `accent-fill` → `palette-purple-9`). The naming survives where it +was always clearest — in the role names, which say *what the colour is for* +(`bg-accent-tint` is a background) rather than restating a job (`accent fill` +does not tell you where to put it). + +**The role layer was then cut from 54 names to 15, by the same test.** A census +counted every reader of every role — both `var(--x)` in a stylesheet and the +Tailwind class each role registers as, with the /design pages counted separately +from product code, since a page displaying a swatch proves only that the token +exists. Eighteen roles had no reader anywhere and six were read only by the docs +that documented them; the rest went once palette steps became reachable as +classes and the "same step in both modes" test above disqualified them. + +**The argument that lost is worth recording, because it is a good one.** A role is +a slot whose hue can change, so `bg-accent-tint` survives a retint where +`purple-3` does not. It lost to a fact: the accent hue *did* change, to Tailwind +purple, and it was five values in the ramp rather than a rename. The ramp is the +slot. A role in front of it only adds a hop. + +The palette stays because it is where **light and dark are reconciled**. A hue's +dark steps are not its light steps dimmed — purple's step 12 is near-black in +light and near-white in dark; step 3 is a lilac wash in light and a deep plum in +dark. Only step 9 is identical. Two hand-authored ramps under one name is what +lets a role be a single line and still behave in both modes, and what keeps +`.dark` to a restatement of values rather than the 50 hand-picked colours it used +to hold — which is where an accent tint and a categorical purple drifted into two +different purples in dark. + +To retint, change the ramp. The accent moved to Tailwind purple in five values, +which is the demonstration that **the ramp is the slot** — no rename, nothing +above it needed to know. + +### Which step, for what + +Twelve steps mean the same twelve jobs in every hue, so this is the map from a job +to a step. It used to generate roles; now it tells you which class to write: + +| step | job | example | +|---|---|---| +| 3 | a tinted surface | `bg-purple-3` | +| 4 | that tint, hovered | `bg-purple-4` | +| 8 | border, focus ring | `border-purple-8` | +| 9 | solid fill | `bg-purple-9` | +| 10 | that fill, hovered | `bg-purple-10` | +| 12 | coloured text on a neutral surface | `text-purple-12` | + +Adding a hue is mechanical — generate twelve steps, and the map above already +answers which one is the button. `cyan` and `orange` are authored and unused, so +that half is proven. + +**Being mechanical is exactly why it must not run ahead of the product.** This map +is how four status identities came to exist: twenty roles from one line of a +lookup table, nineteen of which nothing ever read. It tells you which step to take +*once a design needs the colour*. It is not a licence to pre-generate a set. + +**It is a good default, not a guarantee — measure the pair you actually use.** +Step 10 crosses over, darker than step 9 in light and lighter in dark, so a hover +reads as a press in light mode and a lift in dark with no special-casing. But +green's and blue's step 10 lift *too* far in dark mode and drop white text below +the APCA target. Likewise step 12 is the safe text step and step 11 is the +tempting one: red-11 is more obviously red and fails the Lc 60 body target on a +dark panel (59.7) and the dark composer (57.5), which is why error text is +`text-red-12`. A step used for text goes into `TEXT_ROLES` in +`scripts/check-contrast.mjs` so the guard measures what screens actually write. + +Palette values are Radix Colors (MIT), transcribed rather than depended on — +Radix is not on Block's Tech Radar, so this is a values-only copy with no +package. Its twelve-step contract is the one this system already described in +comments, step for step. Two deliberate divergences, both documented in +`tokens.css`: `text-*` roles take step 12 rather than the 11 Radix names +"low-contrast text" (Radix sizes 11 for WCAG 4.5:1; every hue's step 11 measured +Lc 55–61 against this system's Lc 60 target), and the **neutral ramp** is +hand-authored in both modes because it was sized against the real panel stack +rather than taken from an even ramp. + +### Naming grammar + +``` +-[-][-][-] +``` + +Fixed order, so there is one correct spelling: `--bg-glass-primary-hover` is +legal, `--bg-glass-hover-primary` is not. One modifier, one material, one state per name. +This governs the fifteen roles; a ramp class is `--` and has +no grammar to get wrong, which is part of its appeal. + +Every word a token may be built from is listed in `VOCABULARY` in the registry +and on `/design/vocabulary`. Combining them freely is routine. Introducing a new +word is allowed but is the thing the audit reports on its own line — use an +existing word if one fits. + +### Text and borders register in their own namespaces + +**`--color-x` is not one utility. It is all of them.** One such line defines +`bg-x`, `text-x`, `border-x`, `ring-x` and the rest, every one pointing at the +same value. So the moment two roles differ only by *which prefix uses them*, that +namespace picks one and silently drops the other. + +This has now shipped twice, and both times the symptom looked like a design +mistake rather than a registration one. + +**Borders, first.** Text and borders shared the emphasis names while holding +different values — text at the dark end of the neutral ramp, borders at the light +end. Registered under `--color-*`, `border-primary` resolved to the *text* colour +and every hairline drew at near-black. It is why the first design system site had +black dividers while the tokens said `#d4d4d4`. Fixed with `--border-color-*`. + +**Text colour, second, and worse.** `--color-danger: var(--bg-danger)` also +defined `text-danger`, so error text rendered in red-9 — the saturated *fill* — +instead of red-12. On a dark panel that measured **APCA Lc 34 against a target of +60**, on real error messages, for months. And `check-contrast` passed the entire +time, because it measured `--text-danger`: a token that was declared, documented, +audited, and which no class could reach. + +So the rule, and it is a rule rather than a caution: + +> A text role registers as `--text-color-*`. A border role registers as +> `--border-color-*`. Only backgrounds use the shared `--color-*`. + +`src/shared/tokens/registry.test.ts` binds this to the file — a text or border +role registered under `--color-*` fails there now, rather than after shipping. + +**The general lesson is about the guards, not the namespaces.** A guard that +measures a token nothing resolves to is worse than no guard: it reports the +system is fine and is not wrong about the token, only about whether anything uses +it. When adding a check, verify it measures the value the *browser* computes. + +## Colour discipline + +- **Colour is signal.** Status, authorship, presence, and mentions earn colour. Ordinary structure does not. +- **Name colours after colour jobs, never after the thing on screen.** If the name is an interface element — mention, unread, badge, sidebar — it belongs in the component, assembled from roles that already exist. +- **A colour is used one of two ways: solid or tint.** Solid carries an action and takes its paired text; tint carries a meaning and takes coloured text. There is deliberately nothing between them. +- **Accent is signal, never structure.** Reaching for an accent surface where a neutral one belongs is the most common way a functional screen starts to look decorated. +- **Never use a status colour decoratively.** A green that does not mean success teaches people to stop trusting green. +- **There are no status roles, and that is deliberate.** Danger, success, warning, and info existed as four identities of five roles each — the accent's shape copied four times, generated from one line of a lookup table. **Nineteen of the twenty had no reader outside the page that displayed them.** They were invented by symmetry rather than by need, and the symmetry actively hid the decision: the red ramp alone offers red-3, red-8, red-9 and red-12 for "an error", and a set of ready-made names made that look settled when it never was. The proof is that `text-danger` shipped resolving to the wrong red and no design had ever looked closely enough to notice. **Status colour gets designed on the screen that needs it.** Until then the ramps are right there — pick a step, measure it, and once two screens pick the same one it has earned a name. +- **Write the step, not a name for the step.** Error text is `text-red-12` and the running-agent dot is `bg-green-9`, written where they are used. Both briefly had semantic names and both were one step, identical in both modes — a name in front of a number. The name comes back if the pattern repeats across screens, and it will be named for the pattern. +- **Measure the step, do not reason about it.** Red-11 is the more obviously red choice for error text and was the first pick; measured against every surface the text actually lands on, it fails the Lc 60 body target on a dark panel (59.7) and the dark composer (57.5). Two of five surfaces — invisible to judgement, decisive on inspection. Red-12 clears all five at 82–97. +- **Categorical colours are the one place appearance-naming would be allowed.** Telling two projects apart genuinely is a choice about appearance, so a hue name is honest there. No such roles exist yet — the palette carries eight hues, and a categorical role gets named when a feature actually needs to distinguish things, not before. +- **Opacity is not how you reach a subtler colour.** If a tint looks too strong, take a different palette step — do not dim a stronger one. `purple-950/50` composites to a real, correct colour, which is exactly the trap: it is a colour decision with no name, no light/dark pair, and nothing the contrast guard can measure. A missing shade is a missing palette step, and adding one is an ordinary reviewed edit. `scripts/check-color.mjs` enforces this. +- **Transparency is a different axis from shade, and it has its own tokens.** `glass-*` exists for surfaces something must show through. Alpha baked into a named value at the palette layer is the system working; alpha applied to a token in a component is not. +- **A dark value is authored, never derived.** A hue's dark steps are not its light steps darkened or dimmed — Tailwind's purple gets *more* saturated as it descends, so a dark tint drawn from its bottom end reads as oversaturated. This is why the palette holds two authored ramps per hue rather than one ramp and a transform. +- **Two tokens doing the same job must resolve to the same step, not merely to the same value.** Matching literals drift; a shared reference cannot. An accent tint and a hand-picked categorical purple were the same colour in light mode and two different colours in dark, and nothing caught it because both held their own value. + +## Contrast + +Buzz judges contrast with **APCA** (the perceptual algorithm in the WCAG 3 +draft), not the WCAG 2 ratio. Target **Lc 60** for body text, Lc 45 for large +or non-essential text. This is a deliberate position, taken with evidence, and +it is the rule a generated theme is measured against. + +- **Why.** The WCAG 2 ratio underweights blue and ignores polarity, so it + systematically recommends dark text on saturated mid-tone fills where light + text is plainly more readable. Measured: white on `#3b82f6` scores WCAG 3.68 + (fail) but APCA Lc 69 (pass); black on the same fill scores WCAG 5.71 (pass) + but Lc 40 — badly unreadable. Apple ships white on `#0088ff`–`#3daefc` in + Messages at WCAG 2.4–3.5, and Tailwind, Bootstrap, and Radix all ship white + on their primary blue below or near the WCAG threshold. Three independent + signals agree with the eye; one number disagrees with all of them. +- **APCA is not the looser choice.** It is stricter wherever WCAG 2 is + permissive: red on black (WCAG 5.25 pass, Lc 38 fail) and every dark-mode + mid-grey. Adopting it tightens more pairings than it relaxes. +- **Report both.** WCAG 2 is what an audit measures and what regulators + recognise today. Design to APCA, and know the WCAG number before shipping a + surface that will be scanned. Where they disagree, say so in the change. +- **Constrain the fill, never degrade the text.** If neither black nor white + carries a fill legibly, the fill is wrong — it is not a valid solid. Move the + fill's lightness and keep the hue; do not settle for the less-bad text. +- **A paired text token is derived, not authored.** `text-on-*` is a function of + its fill, so it is generated with the fill and never hand-set. Every hand-set + pairing in this system has been wrong at least once. +- **One implementation of the rule.** Desktop, mobile, and web must not each + compute their own pairing; they diverge and the same defect ships three times. +- **Size a text step against the worst surface it can land on**, not the most + common one. `bg-float` is the lightest dark surface, so it is the binding case + in dark mode; a step that only clears the target on `bg-inset` fails wherever + a popover opens. +- **Dark mode is not light mode inverted.** APCA is polarity-asymmetric: + light-on-dark needs more separation than the same WCAG ratio suggests. The + dark ramp's text steps are therefore lighter than a mirrored ramp would put + them — steps 9 and 10 sit above where linear spacing would. +- **`text-disabled` is deliberately below target.** Low contrast is the signal + that a control is unavailable. Never put information a person needs there. +- **`pnpm check:contrast` enforces this.** Every text role is measured against + every surface it can sit on, in both modes, parsed from `tokens.css` so the + check cannot drift from the tokens. Exceptions live in that script with a + stated reason, which keeps the list short and arguable. +- **A tint's hover is the hardest surface an identity has**, so a `text-*` role is + sized against that rather than against the neutral panel. Every failure the + audit found on a coloured surface was on a tint-hover, never at rest. +- **Hairline dividers are not held to a contrast target.** WCAG's 3:1 non-text + rule covers boundaries needed to identify a *control* or its state, not + grouping lines. Buzz's borders measure 1.2–1.8:1, which is where Radix and + Apple ship theirs; raising them would draw the box the fill already implies. + When the input layer lands, a control's own outline is a different question and + does need the 3:1 treatment. + +## Writing + +- **Every word earns its place.** Prefer the shortest phrasing that stays accurate. +- **Labels say what happens, not what the thing is called internally.** +- **Empty states say what this place is for and what to do next.** An empty state is a first impression, not an error. +- **Errors say what happened and what to do about it.** A message the person cannot act on is decoration. + +## Accessibility + +- **Every interactive element has explicit assistive semantics, and one owner per label.** Two widgets claiming the same label produces duplicate screen-reader stops. +- **Contrast comes from the paired token, not from judgement.** Where a background is not neutral, its text is named for it. +- **Keyboard, pointer, and shortcut paths must not diverge.** When adding an input handler, enumerate the ways a person can reach it and check the ones that are not the mouse. +- **Focus is always visible.** The focus ring is part of the design, not an artefact to suppress. +- **Colour is never the only carrier of meaning.** Pair it with text, shape, or position. + +## Responsiveness + +- **Design for narrow, intermediate, and wide, not just wide.** Intermediate widths are where layouts usually break. +- **Text scales with the person's preference and with zoom.** Anything readable uses relative units; fixed pixel text freezes and breaks zoom. + +## Growing the system + +Need something the system doesn't have? **Add it, mark it `proposed`, keep working.** There is no gate and no separate mechanism for one-offs — the moment the legal path is slower than writing a raw value, the system starts being bypassed. + +1. Search the component list, then the role list, by intent — not by colour. +2. If the decision is one ramp step in both modes, write that step directly. `bg-purple-3`, `text-red-12`, `border-purple-8`: the steps are public and mode-aware. +3. A state of an existing role — add the `-hover`, `-selected`, or `-disabled` sibling with both values, only if that state cannot be one step in both modes. +4. A material variant — add a named utility that carries its inseparable parts together. Glass is the example: its fill is deliberately not reachable alone, because fill without blur, rim, and lift is not glass. +5. A new role using existing words — only where one step cannot express both modes, or where the name enforces a rule. Add the name, both values, a one-sentence description, and an owner. +6. A new hue — generate its ramp. Never write a raw literal in a component; the palette is where literals live. +7. A new vocabulary word — allowed, but it is the thing the audit reports on its own line, so use an existing word if one fits. +8. **If none fit, stop and ask.** The answer is a proposed decision, not a raw value or an undocumented local exception. + +Every addition lands in `src/shared/tokens/registry.ts` in the same change that needed it. Promotion from `proposed` to `core` is a metadata change, not a rename. + +## Components + +- **Compose existing components freely. Never reimplement one.** +- **Focus is keyboard-only visual navigation.** Pointer focus stays quiet; keyboard navigation gives the focused control—not its container—a visible focus ring. Browsers treat clicked text fields as `:focus-visible`, so the app-root keyboard-navigation fact gates every focus treatment. Never add a `:focus-within` focus ring to a container: it duplicates the child control's signal and makes pointer focus noisy. +- **Base UI is the behavior layer.** Before writing an interactive shared component, inspect Base UI for the matching primitive. When one exists, wrap and compose it; Base UI owns focus, keyboard behavior, positioning, portals, and dismissal, while Buzz owns the visual language and product semantics. Reach for native elements only when Base UI has no matching primitive or the component is semantically static. +- **Need a variant that doesn't exist? Add it, mark it proposed.** If a variant almost fits but you would cancel several of its states, the base is wrong for the job and the system is missing a variant. +- **Never add a boolean prop for a visual difference.** Variants are enumerable, so an agent can read the list and pick; booleans multiply, and nobody designed most of the combinations. New props are for data and behaviour, not appearance. +- **Used by one feature? It lives in that feature's folder.** Used by two? Propose it as shared. The folder is the namespace. + +## Using the system + +- **Use an existing component before creating one, and an existing role before adding one.** +- **A new visual treatment that repeats belongs in the system, not in the feature.** +- **If a shared role fails in a real context, repair the role — never work around it locally.** A documentation specimen frame needed a border but `border-primary` was neutral-4 in both modes, which measured 1.08:1 on the dark page. The wrong response was the one we made first: name `neutral-6` directly and call documentation furniture a special case. The right response was to ask whether the one shared boundary role was wrong, measure it on every surface it reaches, and make it `neutral-4` light / `neutral-6` dark. The frame then returned to `border-primary`, and every product divider improved with it. **A local exception is evidence the shared decision is incomplete, not a licence to bypass it.** +- **When choosing a colour, surface, or boundary, use this order.** + 1. **Is there already a component for the thing?** Use it. Its variants are the decisions already made. Do not assemble its fill, border, shadow, or states yourself. + 2. **If the component has no variant for its background, add a variant rather than a sibling component.** `Tabs` is `chrome` (glass pill on the gradient) or `panel` (underline on a plain surface): same behaviour, different appearance. A second component would duplicate its keyboard and accessibility contract just to change styling. + 3. **If building a surface directly, choose its job before its colour.** Backdrop → `bg-app`; opaque region on it → `bg-panel`; region pushed in → `bg-inset`; thing floating above → `bg-float`; something the backdrop should show through → `glass-primary` or `glass-secondary`. Do not use a border to do the work of an inset fill. + 4. **If drawing a boundary, use `border-primary`.** It is the one shared hairline, already authored for both modes. If it does not read in the actual context, measure that context and fix this role — do not name a neutral step at the call site. + 5. **If choosing an accent or status colour, choose a ramp step and measure it.** `bg-purple-9`, `bg-purple-3`, `text-red-12`, `border-purple-8`: every step responds to mode. Do not invent a semantic name for one choice; a repeated *pattern* earns a name when Morgan says it does. + 6. **If the choice cannot be expressed by one step in both modes, make a proposed role with both values and a one-sentence job.** If the name only restates one step, it has not earned a role. If it is a whole treatment — glass is the example — make a utility that carries every inseparable part together. + 7. **If none of this feels clearly right, stop and ask.** Choosing a raw literal or a local exception is never the escape hatch. The system is deliberately allowed to grow; uncertainty is evidence of a missing decision, not a prompt to hide one. +- **If a screen looks right but breaks these rules, the rules are probably wrong — say so.** This document is meant to be argued with, not worked around. diff --git a/desktop-new/MAINTAINING_DESIGN_SYSTEM.md b/desktop-new/MAINTAINING_DESIGN_SYSTEM.md new file mode 100644 index 0000000000..22e4e70481 --- /dev/null +++ b/desktop-new/MAINTAINING_DESIGN_SYSTEM.md @@ -0,0 +1,75 @@ +# Maintaining the design system + +## The point + +The system helps Buzz stay clear and coherent as it grows. It should make the common choice easy, while leaving room for a design to be specific, surprising, or new. It is a shared memory of decisions that have been made—not a gate a designer needs to pass through. + +When the system cannot express the right design, change the system. Do not distort the design to satisfy an old rule. + +## Start with the work in front of you + +Build the screen or interaction you are trying to make. Use the existing colors, type, surfaces, and components when they fit. Look at the result in context, not just in a token table. + +A repeated need is evidence. One-off work is evidence too: it may reveal that a ramp step is wrong, a component needs another supported state, or a rule no longer reflects the product. + +## Color: use the ramps first + +Each color ramp has a version for light mode and a version for dark mode. A step has the same job in both: a quiet neutral surface, a selected row, a strong accent, or readable colored text. That lets a screen use a normal ramp step such as `neutral-2` and still work in both modes. + +A semantic color name is earned when a plain ramp step cannot tell the truth: + +- Light and dark need different steps for the same job. A panel, for example, needs a different relationship to its surroundings in each mode. +- A name protects a real rule. The three text levels and the single shared border weight are examples. +- A pattern has appeared enough times that giving it one name makes the product easier to understand and keep consistent. + +Do not create a semantic name just to avoid choosing a ramp step. If a name points to the same step in both modes, it is usually hiding a decision rather than recording one. + +## Refine the ramp when the product teaches us something + +The ramps are not sacred. They began as a useful starting point and should change when real product work gives us better evidence. + +The lighter search-field neutral is a good example. A real prototype showed that the old value was too heavy, so we corrected the neutral ramp instead of adding a search-only color or a fractional stop. That improvement now benefits every quiet compact surface that uses the same step. + +Before adding a new step or a new role, ask: + +1. Is an existing step already the right answer? +2. If not, is the ramp itself missing the right relationship? +3. Does light mode need one step and dark mode another? If so, give that relationship a semantic name. +4. Will people see this pattern again, or is it one moment in one screen? + +## Components grow from real repetition + +A shared component is a promise: the same interaction and visual language will work the same way wherever it appears. + +Build the first version where it is needed. Once another real use appears, decide what is truly shared: + +- a generic building block belongs in the design system; +- Buzz-specific behavior belongs with the product capability that owns it; +- a complete arrangement should only become reusable when another surface needs that exact arrangement. + +Do not build a catalogue in advance. Small, proven components are more flexible than a large component with a long list of switches. + +## Keep the important states visible + +Every interactive piece should have a clear default, hover, selected state where it applies, and disabled state where it matters. Selection is a lasting statement, not just a stronger hover. Keyboard focus should be visible, and the same action should work with a pointer or keyboard. + +Check narrow, medium, and wide layouts. Check light and dark mode together. A decision that works only in a component specimen is not finished. + +## What the guards are for + +The automated checks protect decisions that are easy to accidentally undo: readable contrast, the shared type scale, and the color system’s light/dark structure. + +They are guardrails, not judges. Each one has a named exception list with room to explain why a specific design needs to differ. Adding an exception is normal when it reflects a deliberate design choice. If the exceptions start pointing in one direction, improve or remove the rule instead of accumulating workarounds. + +A passing check means the system is internally consistent. Looking at the actual interface tells us whether the system is good. We need both. + +## A good change leaves a trace + +When you change the system, make the decision easy to find: + +- update the relevant token, component, or guidance; +- show the result on the design-system site where useful; +- use the new thing in real product work, not only in documentation; +- note the reason in plain language close to the decision. + +The goal is not to freeze Buzz into a style. It is to give every future designer and builder a clear starting point—and the confidence to improve it when the work asks for more. diff --git a/desktop-new/biome.json b/desktop-new/biome.json new file mode 100644 index 0000000000..12e778e4a9 --- /dev/null +++ b/desktop-new/biome.json @@ -0,0 +1,3 @@ +{ + "extends": ["../biome.json"] +} diff --git a/desktop-new/index.html b/desktop-new/index.html new file mode 100644 index 0000000000..3859bee835 --- /dev/null +++ b/desktop-new/index.html @@ -0,0 +1,12 @@ + + + + + + Buzz — Design System + + +
+ + + diff --git a/desktop-new/package.json b/desktop-new/package.json new file mode 100644 index 0000000000..d948189bd6 --- /dev/null +++ b/desktop-new/package.json @@ -0,0 +1,55 @@ +{ + "name": "buzz-desktop-new", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview", + "lint": "biome lint .", + "check": "biome check . && node scripts/check-type.mjs && node scripts/check-contrast.mjs && node scripts/check-color.mjs", + "format": "biome format --write .", + "check:type": "node scripts/check-type.mjs", + "check:contrast": "node scripts/check-contrast.mjs", + "test": "vitest run src", + "test:e2e": "playwright test", + "tauri:dev": "node ../desktop/scripts/tauri-command.mjs dev --config ../desktop-new/tauri.conf.json --config ../desktop/src-tauri/tauri.dev.conf.json", + "check:color": "node scripts/check-color.mjs", + "census": "node scripts/token-consumers.mjs" + }, + "dependencies": { + "@base-ui/react": "1.7.0", + "@emoji-mart/data": "^1.2.1", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.3.0", + "@tabler/icons-react": "^3.46.0", + "@tanstack/react-router": "^1.168.10", + "@tauri-apps/api": "~2.11.1", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-link": "^3.22.5", + "@tiptap/extension-placeholder": "^3.22.5", + "@tiptap/pm": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", + "dockview-react": "8.2.0", + "flexlayout-react": "0.10.8", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tiptap-markdown": "^0.9.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/postcss": "^4.3.0", + "@tanstack/router-plugin": "^1.167.12", + "@types/node": "25.6.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.0", + "tailwindcss": "^4.3.0", + "typescript": "~6.0.0", + "vite": "^8.0.0", + "vitest": "^4.1.11" + } +} diff --git a/desktop-new/playwright.config.ts b/desktop-new/playwright.config.ts new file mode 100644 index 0000000000..709790c75d --- /dev/null +++ b/desktop-new/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + use: { baseURL: "http://127.0.0.1:1430/?mock=1" }, + webServer: { + command: "pnpm dev --host 127.0.0.1", + url: "http://127.0.0.1:1430/?mock=1", + reuseExistingServer: false, + }, +}); diff --git a/desktop-new/postcss.config.js b/desktop-new/postcss.config.js new file mode 100644 index 0000000000..c2ddf74822 --- /dev/null +++ b/desktop-new/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/desktop-new/public/design-system/morgan.png b/desktop-new/public/design-system/morgan.png new file mode 100644 index 0000000000..2effb1eda7 Binary files /dev/null and b/desktop-new/public/design-system/morgan.png differ diff --git a/desktop-new/scripts/apca.mjs b/desktop-new/scripts/apca.mjs new file mode 100644 index 0000000000..d31a3895d9 --- /dev/null +++ b/desktop-new/scripts/apca.mjs @@ -0,0 +1,99 @@ +/** + * APCA 0.1.9 — the perceptual contrast algorithm in the WCAG 3 draft. + * + * Buzz judges contrast with this rather than the WCAG 2 ratio; see + * DESIGN.md § Contrast for why, with the measured evidence. + * + * Validated against the published reference values: + * black on white -> Lc 106.04 + * white on black -> Lc -107.88 + * #888 on white -> Lc 63.06 + * `scripts/check-contrast.mjs` asserts these on every run, so a bad edit to the + * maths fails loudly instead of silently shifting every verdict. + */ + +const EXP = 2.4; +const R_CO = 0.2126729; +const G_CO = 0.7151522; +const B_CO = 0.072175; + +const BLACK_THRESHOLD = 0.022; +// A named constant from the APCA specification that happens to read like an +// approximation of Math.SQRT2. It is not one — do not "correct" it. +// biome-ignore lint/suspicious/noApproximativeNumericConstant: APCA spec value, not Math.SQRT2 +const BLACK_CLAMP = 1.414; +const SCALE = 1.14; +const DELTA_Y_MIN = 0.1; +const LO_OFFSET = 0.027; + +// Dark text on a light background. +const BOW_TEXT = 0.57; +const BOW_BG = 0.56; +// Light text on a dark background — different exponents, because perception is +// not symmetric between the two polarities. This asymmetry is the whole reason +// APCA disagrees with WCAG 2 about dark mode. +const WOB_TEXT = 0.62; +const WOB_BG = 0.65; + +/** Parses `#rgb` / `#rrggbb` into 0–255 channels. */ +export function parseHex(hex) { + const raw = hex.trim().replace(/^#/, ""); + const full = + raw.length === 3 + ? raw + .split("") + .map((c) => c + c) + .join("") + : raw; + if (!/^[0-9a-fA-F]{6}$/.test(full)) { + throw new Error(`not an opaque hex colour: ${hex}`); + } + return [0, 2, 4].map((i) => Number.parseInt(full.slice(i, i + 2), 16)); +} + +/** Screen luminance, APCA's variant — a simple power curve, no sRGB kink. */ +function screenLuminance(hex) { + const [r, g, b] = parseHex(hex).map((c) => (c / 255) ** EXP); + return R_CO * r + G_CO * g + B_CO * b; +} + +/** Lifts near-black so tiny luminance differences don't overstate contrast. */ +function clampBlack(y) { + return y < BLACK_THRESHOLD ? y + (BLACK_THRESHOLD - y) ** BLACK_CLAMP : y; +} + +/** + * Lightness contrast between text and its background. + * + * Returns a signed value: positive for dark-on-light, negative for + * light-on-dark. Compare `Math.abs()` against a target. + */ +export function apcaContrast(textHex, backgroundHex) { + const text = clampBlack(screenLuminance(textHex)); + const background = clampBlack(screenLuminance(backgroundHex)); + + if (Math.abs(background - text) < DELTA_Y_MIN) return 0; + + if (background > text) { + const c = (background ** BOW_BG - text ** BOW_TEXT) * SCALE; + return c < DELTA_Y_MIN ? 0 : (c - LO_OFFSET) * 100; + } + const c = (background ** WOB_BG - text ** WOB_TEXT) * SCALE; + return c > -DELTA_Y_MIN ? 0 : (c + LO_OFFSET) * 100; +} + +/** WCAG 2 relative luminance — reported alongside APCA, never used to decide. */ +function relativeLuminance(hex) { + const [r, g, b] = parseHex(hex).map((c) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +/** WCAG 2 contrast ratio, for the number an audit will ask about. */ +export function wcagRatio(a, b) { + const la = relativeLuminance(a); + const lb = relativeLuminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} diff --git a/desktop-new/scripts/check-color.mjs b/desktop-new/scripts/check-color.mjs new file mode 100644 index 0000000000..385a7b37dc --- /dev/null +++ b/desktop-new/scripts/check-color.mjs @@ -0,0 +1,385 @@ +#!/usr/bin/env node +/** + * Colour-system guard. + * + * One rule, and it exists because of a specific near-miss: the accent tint + * looked right in light mode at Tailwind's `purple-100`, but its dark + * counterpart looked oversaturated, and the obvious fix was to write + * `purple-950/50` — dim a too-strong colour until it looks subtle. + * + * That value was not wrong. `purple-950/50` composites to roughly the same + * muted deep purple the palette now holds as a step, and two independent + * attempts landed within a few percent of it. The problem is where it lives: an + * opacity expression inside a component is a colour decision with no name, no + * light/dark pair, and no way for the contrast guard to measure it. + * + * So the rule is not "never use opacity". It is: **opacity is not how you reach + * a lighter or subtler colour.** If a step is missing, add it to the palette — + * that is a reviewed diff instead of a value buried in a class list. + * + * Genuine translucency, where something behind must show through, is a + * different axis and has its own tokens: `glass-*`, and the `--texture-*` and + * shadow values that bake alpha into a literal. + * + * It also audits the layering itself: that the palette is the only place a + * literal lives, that an identity role references a palette step rather than + * holding its own value, and that two roles doing the same job resolve to the + * same step rather than merely to the same value today. That last one is not + * hypothetical — `accent-2` and `tint-purple` were the same purple in light mode + * and two different purples in dark, and nothing caught it because both were + * hand-picked. + * + * Run: pnpm check:color + */ + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC = new URL("../src", import.meta.url).pathname; +const TOKENS_FILE = new URL("../src/shared/styles/tokens.css", import.meta.url) + .pathname; + +/** + * Colour utilities that may not carry an opacity modifier. + * + * Deliberately the colour-bearing prefixes only. `opacity-50` on a whole + * element is a legitimate way to fade a thing out, and `bg-black/50` on a + * scrim is genuine translucency — the first is not a colour choice and the + * second is caught by review, not by this guard. + */ +const COLOR_PREFIXES = [ + "bg", + "text", + "border", + "ring", + "fill", + "stroke", + "shadow", + "outline", + "divide", + "decoration", + "accent", + "caret", + "from", + "via", + "to", +]; + +/** + * `bg-accent-tint/50`, `text-primary/80`. The slash-number form is Tailwind's + * opacity modifier, and on a colour utility it always means "I want a different + * shade of this". + */ +const OPACITY_MODIFIER = new RegExp( + String.raw`\b(?:${COLOR_PREFIXES.join("|")})-[a-z0-9-]+\/(\d{1,3}|\[[^\]]+\])`, + "g", +); + +/** + * `color-mix(in srgb, var(--x) 50%, transparent)` and friends: the same move + * spelled in CSS. Also catches raw `rgba()`/`hsla()` with a fractional alpha on + * a colour property. + */ +const CSS_MIXERS = [ + { pattern: /color-mix\s*\(/g, what: "color-mix()" }, + { pattern: /\brgba?\([^)]*\/\s*0?\.\d+\s*\)/g, what: "rgb() with alpha" }, + { pattern: /\brgba\([^)]*,\s*0?\.\d+\s*\)/g, what: "rgba()" }, + { pattern: /\bhsla?\([^)]*[,/]\s*0?\.\d+\s*\)/g, what: "hsl() with alpha" }, +]; + +/** Every hue in the palette. `neutral` is a hue like any other. */ +const PALETTE_HUES = [ + "neutral", + "purple", + "red", + "green", + "amber", + "blue", + "cyan", + "orange", +]; + +/** + * **Palette steps are public now. Only glass is private.** + * + * This rule used to reject `var(--neutral-4)` in a component, on the reasoning + * that a screen should say what a thing *is* rather than which colour it takes. + * That reasoning was imported from Tailwind, where `neutral-800` is a single + * literal and naming it really does break dark mode. + * + * It does not hold here. **Every step in this palette is authored per mode**, so + * `neutral-4` is one value in light and another in dark and a component naming it + * behaves correctly in both. Once that is true, a role whose light and dark + * values are the same step is a name in front of a number — and a name in front + * of a number hides the decision rather than recording it. Nineteen roles were + * exactly that. + * + * Glass stays private for a different reason, which has nothing to do with + * naming: a glass fill without its blur, rim, and lift is not glass. It is + * reachable only through the `glass-primary` / `glass-secondary` utilities, which + * carry the whole material. That is enforced below. + * + * Hues are enumerated rather than matched as `[a-z]+` because a palette step and + * a non-colour token are the same shape: `--neutral-4` and `--space-4`. + */ +const PRIVATE_TOKEN = /var\(\s*--glass-\d+\s*\)/g; + +/** + * Files exempt from the private-token rule, with the reason. + * + * The token file defines the layers, so it necessarily references them. The + * design-system pages exist to *show* the ramps, so they resolve token names on + * purpose — that is their subject matter, not a shortcut. + */ +const PRIVATE_TOKEN_ALLOWED = new Map([ + ["shared/styles/tokens.css", "Defines the layers it references."], + [ + "shared/styles/globals.css", + "The glass materials live here; pairing each fill with its blur and rim is their job.", + ], + [ + "shared/tokens/registry.ts", + "Documents the ramps; the token names are its content.", + ], + [ + "features/design-system/useResolvedToken.ts", + "Resolves ramp steps so the design-system pages can display them.", + ], +]); + +/** + * Files where building a colour value IS the job, with the reason. + * + * The token file is the one place alpha is legitimately baked into a literal — + * glass fills, dot textures, shadow colours. That is the documented other side + * of the rule: transparency for genuine see-through lives inside a value, at + * the bottom layer, named. + */ +const MIXER_ALLOWED = new Map([ + [ + "shared/styles/tokens.css", + "Bakes alpha into named values (glass, texture, shadow) — the layer where that belongs.", + ], +]); + +/** Specific `path:line` escapes, each with a reason. */ +const OVERRIDES = new Map(); + +const failures = []; + +function walk(dir) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + walk(full); + continue; + } + if (!/\.(tsx?|css)$/.test(entry)) continue; + check(full); + } +} + +function check(file) { + const rel = relative(SRC, file); + const lines = readFileSync(file, "utf8").split("\n"); + + lines.forEach((line, index) => { + const at = `${rel}:${index + 1}`; + if (OVERRIDES.has(at)) return; + // A comment explaining the rule is not a violation of it. + const code = line.replace(/\/\*.*?\*\//g, "").replace(/\/\/.*$/, ""); + + for (const match of code.matchAll(OPACITY_MODIFIER)) { + failures.push({ + at, + found: match[0], + why: "Opacity is not how you reach a subtler colour. Add a palette step, or use a different step.", + }); + } + + for (const { pattern, what } of MIXER_ALLOWED.has(rel) ? [] : CSS_MIXERS) { + for (const match of code.matchAll(pattern)) { + failures.push({ + at, + found: match[0], + why: `${what} builds a colour instead of naming one. Add a palette step; bake alpha into a value only for genuine translucency.`, + }); + } + } + + if (!PRIVATE_TOKEN_ALLOWED.has(rel)) { + for (const match of code.matchAll(PRIVATE_TOKEN)) { + failures.push({ + at, + found: match[0], + why: "A glass fill on its own is not glass. Use the `glass-primary` or `glass-secondary` utility, which carries the blur, rim, and lift with it.", + }); + } + } + }); +} + +walk(SRC); +auditLayers(); + +/** + * The layering rules, measured against `tokens.css` rather than a copied list, + * so this cannot drift from the system it audits. + */ +function auditLayers() { + const css = readFileSync(TOKENS_FILE, "utf8"); + const rel = "shared/styles/tokens.css"; + + /** Every `:root` or `.dark` block, concatenated. One per layer, so several. */ + const blocksFor = (selector) => { + const out = []; + const pattern = new RegExp(`${selector.replace(".", "\\.")}\\s*\\{`, "g"); + for (const match of css.matchAll(pattern)) { + let depth = 1; + let i = match.index + match[0].length; + const start = i; + while (i < css.length && depth > 0) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}") depth -= 1; + i += 1; + } + out.push(css.slice(start, i - 1)); + } + return out.join("\n"); + }; + + const modes = { light: blocksFor(":root"), dark: blocksFor(".dark") }; + const read = (block, name) => + new RegExp(`^\\s*${name}:\\s*([^;]+);`, "m").exec(block)?.[1].trim(); + + const HUES = PALETTE_HUES; + // Backdrop scenes are intentionally mode-specific at the palette layer, but + // their semantic gradient slot must be complete and paired. A slot with a + // missing counterpart would silently turn an appearance preference into an + // invalid state on a mode change, so verify the production CSS seam here. + const BACKDROP_PAIRS = [ + ["--gradient-1", "--gradient-sky-field", "--gradient-night-garden"], + ["--gradient-2", "--gradient-peach-field", "--gradient-signal-flare"], + ["--gradient-3", "--gradient-blue-hour", "--gradient-electric-dusk"], + ["--gradient-4", "--gradient-orchid-field", "--gradient-ultraviolet"], + ]; + for (const [slot, lightScene, darkScene] of BACKDROP_PAIRS) { + for (const [mode, scene] of [ + ["light", lightScene], + ["dark", darkScene], + ]) { + if (!read(modes[mode], scene)) { + failures.push({ + at: `${rel} (${mode})`, + found: scene, + why: "Missing named backdrop treatment for this semantic gradient slot.", + }); + } + if (read(modes[mode], slot) !== `var(${scene})`) { + failures.push({ + at: `${rel} (${mode})`, + found: `${slot}: ${read(modes[mode], slot) ?? "missing"}`, + why: `Must pair this slot with ${scene} in ${mode} mode.`, + }); + } + } + } + + // 1. Every palette step exists in both modes and holds a literal. + for (const hue of HUES) { + for (let step = 1; step <= 12; step += 1) { + const name = `--${hue}-${step}`; + for (const [mode, block] of Object.entries(modes)) { + const value = read(block, name); + if (!value) { + failures.push({ + at: `${rel} (${mode})`, + found: name, + why: "Missing. Every palette step must be authored in both modes.", + }); + } else if (!/^#[0-9a-f]{6}$/i.test(value)) { + failures.push({ + at: `${rel} (${mode})`, + found: `${name}: ${value}`, + why: "A palette step holds a literal. Values live here and nowhere else.", + }); + } + } + } + } + + // 2. THE ROLES THAT REMAIN MUST BE MODE-ASYMMETRIC. + // + // This replaces four checks that audited the identity families — that each + // referenced a palette step, that none was missing, that no two shared a step, + // that dark never restated one. All four are gone because their subject is: + // nineteen roles were deleted once palette steps became reachable as classes, + // and the ones left are the four surfaces plus emphasis. + // + // The invariant now worth enforcing is the TEST FOR WHETHER A ROLE IS EARNED. + // A surface role exists precisely because light and dark take *different* ramp + // steps, so no single class can express it. If someone adds a surface role + // whose two modes agree, the name is doing nothing and a class would say it — + // that is the mistake this catches, in the same shape it already happened. + // + // The emphasis roles are deliberately exempt: they are the same step in both + // modes and stay because the *name* enforces a rule a ramp cannot state, which + // is that there are three levels of text and one border weight. + const NAME_ENFORCES_A_RULE = new Set([ + "--text-primary", + "--text-secondary", + "--text-tertiary", + "--text-disabled", + "--border-primary", + "--text-on-accent", + ]); + + const roleNames = [ + ...new Set( + [...modes.light.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm)].map( + (m) => m[1], + ), + ), + ]; + + for (const name of roleNames) { + if (NAME_ENFORCES_A_RULE.has(name)) continue; + // Glass materials are a bundled treatment, not a surface step, and `bg-app` + // swaps a whole gradient rather than a step. + if (name.includes("glass") || name === "--bg-app") continue; + + const light = read(modes.light, name); + const dark = read(modes.dark, name); + if (dark && dark !== light) continue; // earns its name + + failures.push({ + at: rel, + found: `${name}: ${light}`, + why: `Same value in both modes, so this name is in front of a number. Write ${light?.replace(/var\(--(.+)\)/, "$1") ?? "the step"} where it is used, or add it to NAME_ENFORCES_A_RULE with the rule it enforces.`, + }); + } +} + +if (failures.length === 0) { + console.log( + "✓ Colour: layers intact, no colour built by opacity, no private token used", + ); + for (const [path, reason] of new Map([ + ...PRIVATE_TOKEN_ALLOWED, + ...MIXER_ALLOWED, + ])) { + console.log(` (exception) ${path} — ${reason}`); + } + process.exit(0); +} + +console.error(`✗ Colour: ${failures.length} violation(s)\n`); +for (const { at, found, why } of failures) { + console.error(` ${at}`); + console.error(` ${found}`); + console.error(` ${why}\n`); +} +console.error( + "Add the step to the palette in tokens.css, or add a documented override\nin scripts/check-color.mjs with a reason. See DESIGN.md § Colour discipline.", +); +process.exit(1); diff --git a/desktop-new/scripts/check-contrast.mjs b/desktop-new/scripts/check-contrast.mjs new file mode 100644 index 0000000000..bf3f4ae8fe --- /dev/null +++ b/desktop-new/scripts/check-contrast.mjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +/** + * Fails the build when a text role cannot be read on a surface it is used on. + * + * This exists because the token table found four broken pairings by rendering + * them, and a defect found by looking is a defect that ships until someone + * looks. The table stays as the readable view; this is the check that runs. + * + * It parses `tokens.css` rather than a duplicate list of values, so it cannot + * drift from the system it audits: resolve each role through its `var()` chain + * to a literal, per mode, then measure every pairing the roles allow. + * + * Judged with APCA, per DESIGN.md § Contrast. The WCAG 2 ratio is reported for + * context and never decides — it is the standard that calls #8f8f8f on #1c1c1c + * a pass at 5.27:1 while APCA scores it Lc 40. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { apcaContrast, wcagRatio } from "./apca.mjs"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const TOKENS = join(ROOT, "src/shared/styles/tokens.css"); + +/** Body text. Anything a person must read to use the product. */ +const TARGET_BODY = 60; +/** Large or non-essential text: timestamps, counts, meta. */ +const TARGET_META = 45; + +/** + * Pairings that are deliberately below target, with the reason. + * + * An exception must name a role and say why, so the list stays short and + * arguable rather than becoming a place to hide failures. + * + * A key is either a role — exempt on every surface — or `role on surface`, + * exempt for that one pairing. Prefer the narrow form: a role-wide exemption + * for a shortfall on one surface also silences a genuine failure on the next + * surface that role reaches, which is the failure mode this list must not have. + */ +const EXCEPTIONS = new Map([ + [ + "--text-disabled", + "Low contrast is the signal that a control is unavailable; WCAG exempts inactive controls. Never carries information a person needs.", + ], +]); + +/** Roles measured at the meta target rather than the body target. */ +const META_ROLES = new Set(["--text-tertiary"]); + +/** + * Neutral surfaces any text may sit on. + * + * `--bg-hover` was a role and is now written as `--neutral-4`, so it is named by + * its step here. It still belongs in this list: a row under the cursor is a + * surface text sits on, whatever it is called. + */ +const SURFACES = ["--bg-panel", "--bg-float", "--neutral-2", "--neutral-4"]; + +/** + * Text that must be readable on every neutral surface. + * + * **The palette steps in this list are the point, not an oversight.** Once a + * screen may write `text-red-12` directly, the guard has to measure the step a + * screen actually uses — otherwise it audits names nobody types. `--text-error` + * and `--text-accent` used to be here; deleting the roles without moving their + * steps into this list would have left the guard passing while measuring + * nothing, which is precisely the failure that shipped error text at Lc 34. + * + * A step joins this list when a screen starts using it for text. `red-12` is + * here because three error messages use it; `red-11` is not, because nothing + * does — and it is the step that fails, at 59.7 on a dark panel. + */ +const TEXT_ROLES = [ + "--text-primary", + "--text-secondary", + "--text-tertiary", + "--text-disabled", + "--purple-12", // accent text: links, active nav, chip labels + "--red-12", // error text: failed session start, rejected form +]; + +/** + * Paired text and the fills it is defined against — including each fill's hover. + * + * The hover was the gap. A solid button had no `bg-*-hover`, so hovering one + * fell through to the neutral `bg-hover` and painted white paired text on light + * grey — Lc 13, invisible, and this guard could not see it because it only + * measured the resting fill. A paired text colour is only correct if it clears + * every fill it can actually sit on, and hover is one of them. + */ +const PAIRS = [ + ["--text-on-accent", "--purple-9"], + ["--text-on-accent", "--purple-10"], + // `bg-neutral-11` with `text-neutral-1` — the inverse pair, written as steps + // now that the roles are gone. Still measured as a pair, because the text + // follows the fill: move the fill and this has to be re-measured. + ["--neutral-1", "--neutral-11"], +]; + +/** + * Tint surfaces and the coloured text that sits on them, as steps. + * + * DESIGN.md: "tint carries a meaning and takes coloured text". The accent tint + * and its hover are both real surfaces — a chip at rest and a chip under the + * cursor — and the hover is the harder one, which is where the gap was. + */ +const TINT_PAIRS = [ + ["--purple-12", "--purple-3"], + ["--purple-12", "--purple-4"], +]; + +const css = readFileSync(TOKENS, "utf8"); + +/** + * Collects declarations per mode, honouring source order so a later block wins + * exactly as the cascade would. `.dark` inherits `:root` and overrides it, so + * dark is light-then-dark rather than dark alone. + */ +function declarationsByMode() { + const light = new Map(); + const dark = new Map(); + // Split on top-level selector blocks and track which we are inside. + const blocks = css.matchAll(/(:root|\.dark)\s*\{([^}]*)\}/g); + for (const [, selector, body] of blocks) { + for (const [, name, value] of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + const v = value.trim(); + if (selector === ":root") { + light.set(name, v); + // Dark starts from the light value and is overridden below. + dark.set(name, v); + } else { + dark.set(name, v); + } + } + } + return { light, dark }; +} + +/** Follows a `var()` chain to its literal, or null if it is not a plain colour. */ +function resolve(map, name, depth = 0) { + if (depth > 10) return null; + const value = map.get(name); + if (!value) return null; + const single = /^var\(\s*(--[\w-]+)\s*\)$/.exec(value); + if (single) return resolve(map, single[1], depth + 1); + return /^#[0-9a-fA-F]{3,6}$/.test(value) ? value : null; +} + +const modes = declarationsByMode(); +const failures = []; +const skipped = []; +/** + * Pairing-scoped exceptions this run actually needed. An exception nobody hits + * is either fixed or wrong, and either way should not sit in the list + * unchallenged — same reasoning as the type guard's stale-override report. + */ +const claimedExceptions = new Set(); + +for (const [mode, map] of Object.entries(modes)) { + const check = (textRole, surfaceRole) => { + const text = resolve(map, textRole); + const surface = resolve(map, surfaceRole); + // A translucent or gradient value cannot be measured against one colour; + // record it rather than passing it silently. + if (!text || !surface) { + skipped.push(`${mode}: ${textRole} on ${surfaceRole}`); + return; + } + const lc = Math.abs(apcaContrast(text, surface)); + const target = META_ROLES.has(textRole) ? TARGET_META : TARGET_BODY; + if (lc >= target) return; + // Role-wide first, then the narrow `role on surface` form. + if (EXCEPTIONS.has(textRole)) return; + if (EXCEPTIONS.has(`${textRole} on ${surfaceRole}`)) { + claimedExceptions.add(`${textRole} on ${surfaceRole}`); + return; + } + failures.push({ + mode, + textRole, + text, + surfaceRole, + surface, + lc, + target, + wcag: wcagRatio(text, surface), + }); + }; + + for (const role of TEXT_ROLES) { + for (const surface of SURFACES) check(role, surface); + } + for (const [role, fill] of PAIRS) check(role, fill); + for (const [text, tint] of TINT_PAIRS) check(text, tint); +} + +// Guard the maths itself: if these drift, every verdict above is wrong. +const REFERENCE = [ + ["#000000", "#ffffff", 106.04], + ["#ffffff", "#000000", -107.88], + ["#888888", "#ffffff", 63.06], +]; +for (const [text, bg, expected] of REFERENCE) { + const got = apcaContrast(text, bg); + if (Math.abs(got - expected) > 0.05) { + console.error( + `✗ APCA implementation drifted: ${text} on ${bg} gave ${got.toFixed(2)}, expected ${expected}`, + ); + process.exit(1); + } +} + +if (skipped.length > 0) { + console.log(`ℹ ${skipped.length} pairing(s) not measurable as flat colour:`); + for (const s of skipped) console.log(` ${s}`); +} + +if (failures.length > 0) { + console.error( + `\n✗ Contrast: ${failures.length} pairing(s) below their APCA target\n`, + ); + for (const f of failures) { + console.error( + ` ${f.mode.padEnd(5)} ${f.textRole} (${f.text}) on ${f.surfaceRole} (${f.surface})`, + ); + console.error( + ` Lc ${f.lc.toFixed(1)} — needs ${f.target} [WCAG ${f.wcag.toFixed(2)}:1]`, + ); + } + console.error( + "\nRaise the ramp step the role points at, or add a documented exception\nin scripts/check-contrast.mjs with a reason. See DESIGN.md § Contrast.\n", + ); + process.exit(1); +} + +console.log("✓ Contrast: every text role clears its APCA target in both modes"); +for (const [role, why] of EXCEPTIONS) { + console.log(` (exception) ${role} — ${why.split(";")[0]}`); +} + +const stale = [...EXCEPTIONS.keys()].filter( + (key) => key.includes(" on ") && !claimedExceptions.has(key), +); +if (stale.length > 0) { + console.log("\nℹ Pairing exceptions no longer needed — delete them:"); + for (const key of stale) console.log(` ${key}`); +} diff --git a/desktop-new/scripts/check-type.mjs b/desktop-new/scripts/check-type.mjs new file mode 100644 index 0000000000..fb08f998e3 --- /dev/null +++ b/desktop-new/scripts/check-type.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +/** + * Type-system guard. + * + * Four rules from DESIGN.md § Type, enforced rather than trusted. Each one has + * already cost the existing client real work: + * + * 1. No arbitrary text sizes — `text-[15px]`, `text-[0.9rem]`, `font-size:`. + * Fixed px froze against keyboard zoom and shipped a message-timeline + * regression; arbitrary rem re-fragments the scale we just consolidated. + * 2. No `uppercase` / `tracking-*` utilities. All-caps labels are less legible + * and read as enterprise chrome, and tracking is corrected per ramp step. + * 3. No size role paired with a leading utility. A role carries its own line + * height; overriding it is how two identical labels drift apart. + * 4. No weight outside 400 and 600. `font-semibold` is bold. + * + * What this guard is for: an agent building a screen has no reason to prefer + * `font-medium` over `font-semibold`, so left unguided it picks either, and the + * product ends up with four weights that mean nothing. The guard removes the + * choice where there is no judgement to apply. + * + * What it is NOT for: stopping a designer. Every rule has a named override with + * a reason, and adding one is a normal edit — not an escalation. A guard that + * blocks real work gets deleted; a guard that asks you to say why does not. + * + * Run: pnpm check:type + */ + +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const SRC = new URL("../src", import.meta.url).pathname; + +/** Size roles a component may use. Kept in sync with typography.css. */ +const SIZE_ROLES = [ + "display", + "title", + "heading", + "body-lg", + "body-sm", + "body", + "mono-lg", + "mono-sm", + "mono", +]; + +/** + * Roles that existed before the ramp was sized from the product, with what to + * use instead. Kept as a rule rather than deleted quietly: the names read + * plausible, so a stale one would otherwise resolve to nothing and render at the + * inherited size — a silent failure that looks like a design choice. + */ +const RETIRED_ROLES = new Map([ + ["subheading", "text-heading, or text-body-lg if it is prose"], + ["label", "text-body, or text-body-sm in dense chrome"], + ["caption", "text-body-sm"], + ["meta", "text-body-sm"], + ["code", "text-mono"], +]); + +/** + * Deliberate exceptions, as `relativePath:matchedText`. + * + * The escape hatch, and it is meant to be used. A one-off where another weight + * genuinely looks better is a real thing; the only requirement is that it says + * so out loud, so the next person reads a decision instead of guessing at an + * accident. Matching the text rather than a line number keeps entries stable + * when unrelated edits move code around. + * + * If this list starts growing in one direction — several `font-medium` entries, + * say — that is the signal the system is missing a role, not that the rule is + * wrong. Fix the system at that point rather than adding a tenth override. + */ +const OVERRIDES = new Map([ + [ + "shared/styles/flex-workspace.css:font-size:", + "FlexLayout's public theme variable forwards the existing text-body role; no new text size is authored.", + ], +]); + +const RULES = [ + { + id: "arbitrary-text-size", + // text-[...] with any unit, plus raw CSS font-size declarations. + pattern: /\btext-\[[^\]]*(?:px|rem|em|pt|%)[^\]]*\]|font-size\s*:/g, + message: + "arbitrary text size — use a named role (text-body, text-label, …). px freezes against zoom; arbitrary rem re-fragments the scale.", + }, + { + id: "uppercase", + // Only inside a className/class string — otherwise prose describing the + // rule trips it, and a guard that cries wolf gets ignored. + pattern: /class(?:Name)?=(?:"|'|`)[^"'`]*\buppercase\b/g, + message: + "all-caps text — DESIGN.md § Type forbids it. A quiet label uses text-meta on text-tertiary instead.", + }, + { + id: "manual-tracking", + pattern: /\btracking-(?:tighter|tight|normal|wide|wider|widest|\[)/g, + message: + "hand-applied tracking — the ramp already corrects tracking per step.", + }, + { + id: "role-plus-leading", + // A size role sharing a class attribute with a line-height utility. A role + // carries its own leading, so overriding it here is how two supposedly + // identical labels drift apart. + // + // Weight is deliberately NOT in this rule: `text-body font-semibold` is the + // documented way to bold body text, because size is the paragraph's + // decision and weight is the phrase's. Weight is policed by name below. + pattern: new RegExp( + `class(?:Name)?=(?:"|'|\`)[^"'\`]*\\btext-(?:${SIZE_ROLES.join("|")})\\b[^"'\`]*\\bleading-`, + "g", + ), + message: + "size role paired with a leading utility — a role already carries its own line height.", + }, + { + id: "off-ramp-weight", + // The system has two weights: 400 (content) and 600 (structure and + // emphasis). 500 was measured against 400 at body size and does not read as + // intent in a scanned list — it is heavy enough to muddy a column and too + // subtle to signal. 700 was rejected as louder than anything Buzz needs. + // The rest have never had a use. + // + // `font-semibold` and `font-normal` are absent from this list on purpose: + // they are the two legal weights. + pattern: /\bfont-(?:thin|extralight|light|medium|bold|extrabold|black)\b/g, + message: + "off-ramp font weight — the system is 400 and 600. Bold is font-semibold. If a one-off genuinely needs another weight, add it to OVERRIDES with a reason.", + }, + { + id: "retired-role", + // A retired size role. Tailwind emits no utility for these, so the element + // silently renders at whatever it inherits — right often enough to pass a + // glance, wrong wherever the inherited size differs. + pattern: new RegExp( + `\\btext-(?:${[...RETIRED_ROLES.keys()].join("|")})\\b`, + "g", + ), + message: "retired size role", + detail: (matched) => { + const role = matched.replace("text-", ""); + return `use ${RETIRED_ROLES.get(role)} — this role no longer exists, so it resolves to nothing and inherits a size instead.`; + }, + }, +]; + +function walk(dir) { + return readdirSync(dir).flatMap((entry) => { + const full = join(dir, entry); + if (statSync(full).isDirectory()) return walk(full); + return /\.(tsx?|css)$/.test(full) ? [full] : []; + }); +} + +const failures = []; +/** Overrides actually hit this run, so stale entries can be reported. */ +const used = new Set(); + +for (const file of walk(SRC)) { + const source = readFileSync(file, "utf8"); + // The typography ramp itself is the one place literals are legal — it is + // layer 1, where values live by design. + const isRamp = file.endsWith("typography.css"); + const lines = source.split("\n"); + + lines.forEach((line, index) => { + // Skip comment-only lines: the docs quote the very patterns they forbid. + const trimmed = line.trim(); + if ( + trimmed.startsWith("*") || + trimmed.startsWith("//") || + trimmed.startsWith("/*") + ) { + return; + } + + for (const rule of RULES) { + if (isRamp && rule.id === "arbitrary-text-size") continue; + rule.pattern.lastIndex = 0; + const match = rule.pattern.exec(line); + if (!match) continue; + + const rel = relative(SRC, file); + if (OVERRIDES.has(`${rel}:${match[0]}`)) { + used.add(`${rel}:${match[0]}`); + continue; + } + + const detail = rule.detail ? rule.detail(match[0]) : rule.message; + failures.push( + `${relative(process.cwd(), file)}:${index + 1} ${rule.id}\n ${trimmed}\n → ${detail}`, + ); + } + }); +} + +// A stale override is worse than none: it silently permits whatever moves into +// its place. Report them rather than letting the list rot. +const stale = [...OVERRIDES.keys()].filter((k) => !used.has(k)); +if (stale.length > 0) { + console.warn( + `⚠ ${stale.length} unused override${stale.length === 1 ? "" : "s"} in check-type.mjs — remove ${stale.length === 1 ? "it" : "them"}:`, + ); + for (const k of stale) console.warn(` ${k}`); +} + +if (failures.length > 0) { + console.error( + `\n✗ Type system: ${failures.length} violation${failures.length === 1 ? "" : "s"}\n`, + ); + console.error(`${failures.join("\n\n")}\n`); + process.exit(1); +} + +console.log("✓ Type system: no violations"); diff --git a/desktop-new/scripts/token-consumers.mjs b/desktop-new/scripts/token-consumers.mjs new file mode 100644 index 0000000000..fc81abd9f4 --- /dev/null +++ b/desktop-new/scripts/token-consumers.mjs @@ -0,0 +1,143 @@ +/** + * Census: who actually reads each colour role? + * + * Not a guard — it never fails a build, and it is not in `pnpm check`. Run it by + * hand when deciding whether a role earns its name: + * + * node scripts/token-consumers.mjs + * + * It is deliberately advisory. "Zero consumers" is an argument for deleting a + * role, not a verdict: a token can be legitimately unused for a week because the + * screen that needs it is half-built. What it does do is make the argument + * checkable, which is how the role layer went from 54 names to 29 — eighteen had + * no reader anywhere, and six were read only by the pages documenting them. + * + * It counts two kinds of reader, because a role can be reached two ways: + * + * 1. `var(--bg-info)` in a stylesheet + * 2. the Tailwind utility the role registers as — `bg-info` in a component + * + * Documentation is counted separately from product code, and that separation is + * the point: a /design page rendering a swatch of `bg-info` proves the token + * exists and nothing more. Nineteen of the twenty deleted status roles were + * "used" in exactly that sense. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = new URL("..", import.meta.url).pathname; +const SRC = join(ROOT, "src"); +const TOKENS = join(SRC, "shared/styles/tokens.css"); + +/** Files that describe the system rather than use it. */ +const isDocs = (path) => + path.includes("features/design-system/") || + path.includes("shared/styles/design-components.css") || + path.includes("shared/tokens/registry.ts"); + +function walk(dir, out = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.(css|tsx?|ts)$/.test(full)) out.push(full); + } + return out; +} + +const css = readFileSync(TOKENS, "utf8"); + +// Role declarations live outside `@theme`; registrations live inside it. +const themeStart = css.indexOf("@theme inline {"); +const roleBlock = css.slice(0, themeStart); +const themeBlock = css.slice(themeStart); + +const roles = new Set( + [...roleBlock.matchAll(/^\s*(--(?:bg|text|border)-[a-z0-9-]+):/gm)].map( + (m) => m[1], + ), +); + +// What Tailwind class does each role register as? `--color-info: var(--bg-info)` +// means `bg-info` / `text-info` reach `--bg-info`. +// Order matters: `--text-color-error` must match the `text-color` namespace, not +// `text` with the suffix `color-error`. Longest prefix first. +const NAMESPACES = ["text-color", "border-color", "color"]; +const registrations = new Map(); // role -> [{ ns, suffix }] +for (const [, ns, suffix, role] of themeBlock.matchAll( + new RegExp( + String.raw`^\s*--(${NAMESPACES.join("|")})-([a-z0-9-]+):\s*var\((--[a-z0-9-]+)\)`, + "gm", + ), +)) { + if (!registrations.has(role)) registrations.set(role, []); + registrations.get(role).push({ ns, suffix }); +} + +const files = walk(SRC).filter((f) => f !== TOKENS); +const counts = new Map([...roles].map((r) => [r, { product: [], docs: [] }])); + +for (const file of files) { + const text = readFileSync(file, "utf8"); + const rel = relative(SRC, file); + const bucket = isDocs(file) ? "docs" : "product"; + + for (const role of roles) { + const hits = []; + if (text.includes(`var(${role})`)) hits.push("var()"); + + for (const { ns, suffix } of registrations.get(role) ?? []) { + // `--color-info` is reachable as bg-info, text-info, border-info… + const prefixes = + ns === "color" + ? [ + "bg", + "text", + "border", + "fill", + "stroke", + "ring", + "outline", + "shadow", + "from", + "to", + "via", + ] + : ns === "border-color" + ? ["border"] + : ["text"]; // text-color + for (const p of prefixes) { + // Word-bounded so `bg-info` does not match `bg-info-tint`. + if (new RegExp(`\\b(?:[a-z-]+:)?${p}-${suffix}\\b(?!-)`).test(text)) { + hits.push(`${p}-${suffix}`); + break; + } + } + } + + if (hits.length) + counts + .get(role) + [bucket].push(`${rel} (${[...new Set(hits)].join(", ")})`); + } +} + +const dead = []; +const docsOnly = []; +const live = []; +for (const [role, { product, docs }] of counts) { + if (product.length) live.push([role, product]); + else if (docs.length) docsOnly.push([role, docs]); + else dead.push(role); +} + +console.log(`\n${roles.size} roles declared.\n`); +console.log(`── Zero readers anywhere (${dead.length}) ──`); +for (const role of dead.sort()) console.log(` ${role}`); +console.log(`\n── Read only by the docs (${docsOnly.length}) ──`); +for (const [role, where] of docsOnly.sort()) + console.log(` ${role} ← ${where.join("; ")}`); +console.log(`\n── Read by product code (${live.length}) ──`); +for (const [role, where] of live.sort((a, b) => a[1].length - b[1].length)) { + console.log(` ${role} ×${where.length}`); + for (const w of where) console.log(` ${w}`); +} diff --git a/desktop-new/src/app/routes/__root.tsx b/desktop-new/src/app/routes/__root.tsx new file mode 100644 index 0000000000..f17fcdad20 --- /dev/null +++ b/desktop-new/src/app/routes/__root.tsx @@ -0,0 +1,3 @@ +import { Outlet, createRootRoute } from "@tanstack/react-router"; + +export const Route = createRootRoute({ component: () => }); diff --git a/desktop-new/src/app/routes/design.tsx b/desktop-new/src/app/routes/design.tsx new file mode 100644 index 0000000000..bc2b1f366b --- /dev/null +++ b/desktop-new/src/app/routes/design.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { DesignSystemLayout } from "@/features/design-system/ui/DesignSystemLayout"; + +export const Route = createFileRoute("/design")({ + component: DesignSystemLayout, +}); diff --git a/desktop-new/src/app/routes/design/agents-guide.tsx b/desktop-new/src/app/routes/design/agents-guide.tsx new file mode 100644 index 0000000000..490a3ed173 --- /dev/null +++ b/desktop-new/src/app/routes/design/agents-guide.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SystemDocumentPage } from "@/features/design-system/ui/SystemDocumentPage"; + +export const Route = createFileRoute("/design/agents-guide")({ + component: () => , +}); diff --git a/desktop-new/src/app/routes/design/color.tsx b/desktop-new/src/app/routes/design/color.tsx new file mode 100644 index 0000000000..2c3ac7ed26 --- /dev/null +++ b/desktop-new/src/app/routes/design/color.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ColourPage } from "@/features/design-system/ui/ColourPage"; + +export const Route = createFileRoute("/design/color")({ + component: ColourPage, +}); diff --git a/desktop-new/src/app/routes/design/color_.table.tsx b/desktop-new/src/app/routes/design/color_.table.tsx new file mode 100644 index 0000000000..a7f650fab7 --- /dev/null +++ b/desktop-new/src/app/routes/design/color_.table.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ColorTablePage } from "@/features/design-system/ui/ColorTablePage"; + +/** + * `color_.table` rather than `color.table`: the trailing underscore keeps the + * `/design/color/table` URL while opting out of nesting inside the colour route. + * Without it, `/design/color` becomes a layout route whose component must render + * an `` — and since ColourPage does not, the parent page rendered in + * place of this one. + */ +export const Route = createFileRoute("/design/color_/table")({ + component: ColorTablePage, +}); diff --git a/desktop-new/src/app/routes/design/components.tsx b/desktop-new/src/app/routes/design/components.tsx new file mode 100644 index 0000000000..e6b185b224 --- /dev/null +++ b/desktop-new/src/app/routes/design/components.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ComponentsPage } from "@/features/design-system/ui/ComponentsPage"; + +export const Route = createFileRoute("/design/components")({ + component: ComponentsPage, +}); diff --git a/desktop-new/src/app/routes/design/components_.$component.tsx b/desktop-new/src/app/routes/design/components_.$component.tsx new file mode 100644 index 0000000000..cd4df735c4 --- /dev/null +++ b/desktop-new/src/app/routes/design/components_.$component.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ComponentDetailPage } from "@/features/design-system/ui/ComponentDetailPage"; + +export const Route = createFileRoute("/design/components_/$component")({ + component: ComponentRoute, +}); + +function ComponentRoute() { + const { component } = Route.useParams(); + return ; +} diff --git a/desktop-new/src/app/routes/design/components_.base-ui.tsx b/desktop-new/src/app/routes/design/components_.base-ui.tsx new file mode 100644 index 0000000000..b6843941f3 --- /dev/null +++ b/desktop-new/src/app/routes/design/components_.base-ui.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { BaseUiPage } from "@/features/design-system/ui/BaseUiPage"; + +export const Route = createFileRoute("/design/components_/base-ui")({ + component: BaseUiPage, +}); diff --git a/desktop-new/src/app/routes/design/design-guide.tsx b/desktop-new/src/app/routes/design/design-guide.tsx new file mode 100644 index 0000000000..f2e62fb58e --- /dev/null +++ b/desktop-new/src/app/routes/design/design-guide.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SystemDocumentPage } from "@/features/design-system/ui/SystemDocumentPage"; + +export const Route = createFileRoute("/design/design-guide")({ + component: () => , +}); diff --git a/desktop-new/src/app/routes/design/elevation.tsx b/desktop-new/src/app/routes/design/elevation.tsx new file mode 100644 index 0000000000..916394b20d --- /dev/null +++ b/desktop-new/src/app/routes/design/elevation.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ElevationPage } from "@/features/design-system/ui/ElevationPage"; + +export const Route = createFileRoute("/design/elevation")({ + component: ElevationPage, +}); diff --git a/desktop-new/src/app/routes/design/glass.tsx b/desktop-new/src/app/routes/design/glass.tsx new file mode 100644 index 0000000000..25516b947c --- /dev/null +++ b/desktop-new/src/app/routes/design/glass.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { GlassPage } from "@/features/design-system/ui/GlassPage"; + +export const Route = createFileRoute("/design/glass")({ + component: GlassPage, +}); diff --git a/desktop-new/src/app/routes/design/index.tsx b/desktop-new/src/app/routes/design/index.tsx new file mode 100644 index 0000000000..cda74074ef --- /dev/null +++ b/desktop-new/src/app/routes/design/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { OverviewPage } from "@/features/design-system/ui/OverviewPage"; + +export const Route = createFileRoute("/design/")({ component: OverviewPage }); diff --git a/desktop-new/src/app/routes/design/maintaining.tsx b/desktop-new/src/app/routes/design/maintaining.tsx new file mode 100644 index 0000000000..1c2c4c9b5a --- /dev/null +++ b/desktop-new/src/app/routes/design/maintaining.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { SystemDocumentPage } from "@/features/design-system/ui/SystemDocumentPage"; + +export const Route = createFileRoute("/design/maintaining")({ + component: () => , +}); diff --git a/desktop-new/src/app/routes/design/motion.tsx b/desktop-new/src/app/routes/design/motion.tsx new file mode 100644 index 0000000000..427ec983e7 --- /dev/null +++ b/desktop-new/src/app/routes/design/motion.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { MotionPage } from "@/features/design-system/ui/MotionPage"; + +export const Route = createFileRoute("/design/motion")({ + component: MotionPage, +}); diff --git a/desktop-new/src/app/routes/design/radius.tsx b/desktop-new/src/app/routes/design/radius.tsx new file mode 100644 index 0000000000..a9c0904ecd --- /dev/null +++ b/desktop-new/src/app/routes/design/radius.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { RadiusPage } from "@/features/design-system/ui/RadiusPage"; + +export const Route = createFileRoute("/design/radius")({ + component: RadiusPage, +}); diff --git a/desktop-new/src/app/routes/design/spacing.tsx b/desktop-new/src/app/routes/design/spacing.tsx new file mode 100644 index 0000000000..94387c09f8 --- /dev/null +++ b/desktop-new/src/app/routes/design/spacing.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { SpacingPage } from "@/features/design-system/ui/SpacingPage"; + +export const Route = createFileRoute("/design/spacing")({ + component: SpacingPage, +}); diff --git a/desktop-new/src/app/routes/design/typography.tsx b/desktop-new/src/app/routes/design/typography.tsx new file mode 100644 index 0000000000..f78373a912 --- /dev/null +++ b/desktop-new/src/app/routes/design/typography.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { TypographyPage } from "@/features/design-system/ui/TypographyPage"; + +export const Route = createFileRoute("/design/typography")({ + component: TypographyPage, +}); diff --git a/desktop-new/src/app/routes/index.tsx b/desktop-new/src/app/routes/index.tsx new file mode 100644 index 0000000000..efc9299dae --- /dev/null +++ b/desktop-new/src/app/routes/index.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { AppShell } from "@/features/sessions/ui/AppShell"; + +export const Route = createFileRoute("/")({ component: AppShell }); diff --git a/desktop-new/src/app/routes/spike.composer.tsx b/desktop-new/src/app/routes/spike.composer.tsx new file mode 100644 index 0000000000..60dc4150f9 --- /dev/null +++ b/desktop-new/src/app/routes/spike.composer.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { ComposerSpikePage } from "@/features/composer-spike/ui/ComposerSpikePage"; + +export const Route = createFileRoute("/spike/composer")({ + component: ComposerSpikePage, +}); diff --git a/desktop-new/src/app/useKeyboardFocusVisibility.ts b/desktop-new/src/app/useKeyboardFocusVisibility.ts new file mode 100644 index 0000000000..dff36d37bd --- /dev/null +++ b/desktop-new/src/app/useKeyboardFocusVisibility.ts @@ -0,0 +1,48 @@ +import { useEffect } from "react"; + +const KEYBOARD_NAVIGATION_KEYS = new Set([ + "Tab", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", +]); + +/** + * Makes keyboard focus visible without treating a pointer click in a text field + * as keyboard navigation. Browsers intentionally match :focus-visible for text + * inputs even after a click, so this app-level modality fact gates the visual + * treatment consistently for every control. + */ +export function useKeyboardFocusVisibility() { + useEffect(() => { + const root = document.documentElement; + const useKeyboardNavigation = (event: KeyboardEvent) => { + if ( + KEYBOARD_NAVIGATION_KEYS.has(event.key) && + !event.metaKey && + !event.ctrlKey && + !event.altKey + ) { + root.dataset.keyboardNavigation = ""; + } + }; + const usePointerNavigation = () => { + delete root.dataset.keyboardNavigation; + }; + + window.addEventListener("keydown", useKeyboardNavigation, true); + window.addEventListener("pointerdown", usePointerNavigation, true); + window.addEventListener("touchstart", usePointerNavigation, true); + return () => { + window.removeEventListener("keydown", useKeyboardNavigation, true); + window.removeEventListener("pointerdown", usePointerNavigation, true); + window.removeEventListener("touchstart", usePointerNavigation, true); + delete root.dataset.keyboardNavigation; + }; + }, []); +} diff --git a/desktop-new/src/features/agent-activity/activityProjection.test.ts b/desktop-new/src/features/agent-activity/activityProjection.test.ts new file mode 100644 index 0000000000..bbf36e028a --- /dev/null +++ b/desktop-new/src/features/agent-activity/activityProjection.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { reduceActivity } from "./activityProjection"; + +const event = (kind: string, status: string) => ({ + seq: 1, + timestamp: "2026-09-08T03:00:00.000Z", + kind, + channelId: "design", + sessionId: "session", + turnId: "turn", + agentPubkey: "agent", + payload: { agentName: "Vogue", title: kind, status }, +}); + +describe("reduceActivity", () => { + it("keeps a turn working after an individual tool completes", () => { + const started = reduceActivity(new Map(), event("turn_started", "running")); + const updated = reduceActivity( + started, + event("tool_call_completed", "completed"), + ); + + expect([...updated.values()][0]?.status).toBe("running"); + }); + + it("settles only when the turn itself reaches a terminal event", () => { + const started = reduceActivity(new Map(), event("turn_started", "running")); + const completed = reduceActivity( + started, + event("turn_completed", "completed"), + ); + + expect([...completed.values()][0]?.status).toBe("completed"); + }); +}); diff --git a/desktop-new/src/features/agent-activity/activityProjection.ts b/desktop-new/src/features/agent-activity/activityProjection.ts new file mode 100644 index 0000000000..ebe510b7f5 --- /dev/null +++ b/desktop-new/src/features/agent-activity/activityProjection.ts @@ -0,0 +1,142 @@ +import type { ObserverEvent } from "@/shared/runtime/observerEvent"; +import type { ActivityItem, ActivityStatus, AgentTurn } from "./types"; + +const TERMINAL_KINDS: Record = { + turn_completed: "completed", + turn_failed: "failed", + turn_cancelled: "cancelled", + permission_requested: "needs_you", + turn_unavailable: "unavailable", +}; + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function statusFor(event: ObserverEvent): ActivityStatus { + if (TERMINAL_KINDS[event.kind]) return TERMINAL_KINDS[event.kind]; + const payloadStatus = text(event.payload.status); + if ( + payloadStatus && + [ + "pending", + "running", + "completed", + "failed", + "cancelled", + "needs_you", + "unavailable", + ].includes(payloadStatus) + ) { + return payloadStatus as ActivityStatus; + } + return event.kind.includes("completed") ? "completed" : "running"; +} + +function itemLabel(event: ObserverEvent): string { + return ( + text(event.payload.title) ?? + text(event.payload.toolCallTitle) ?? + text(event.payload.toolName) ?? + text(event.payload.name) ?? + event.kind.replaceAll("_", " ") + ); +} + +function itemId(event: ObserverEvent): string { + return ( + text(event.payload.toolCallId) ?? + text(event.payload.messageId) ?? + `${event.kind}:${event.seq}` + ); +} + +function eventToItem(event: ObserverEvent): ActivityItem { + return { + id: itemId(event), + label: itemLabel(event), + detail: + text(event.payload.summary) ?? + text(event.payload.outcome) ?? + text(event.payload.error), + status: statusFor(event), + timestamp: event.timestamp, + }; +} + +/** + * Projects one owner-private observer event into a stable agent turn. The + * capability owns this projection once; conversations only read its result. + */ +export function reduceActivity( + current: Map, + event: ObserverEvent, + fallbackAgentName = "Agent", +): Map { + if (!event.channelId || !event.agentPubkey || !event.turnId) return current; + const sessionId = event.sessionId ?? "unknown-session"; + const key = [ + event.channelId, + event.agentPubkey, + sessionId, + event.turnId, + ].join(":"); + const next = new Map(current); + const previous = next.get(key); + const incoming = eventToItem(event); + const items = previous ? [...previous.items] : []; + const existingIndex = items.findIndex((item) => item.id === incoming.id); + if (existingIndex >= 0) items[existingIndex] = incoming; + else items.push(incoming); + items.sort((left, right) => left.timestamp.localeCompare(right.timestamp)); + // A completed tool call is evidence within an active turn, not completion + // of the turn itself. Only a terminal turn event may replace its aggregate + // state; otherwise the dock would disappear midway through an agent's work. + const isTerminalTurnEvent = event.kind in TERMINAL_KINDS; + const turnStatus = isTerminalTurnEvent + ? statusFor(event) + : previous + ? previous.status + : statusFor(event); + + next.set(key, { + key, + agentPubkey: event.agentPubkey, + agentName: text(event.payload.agentName) ?? fallbackAgentName, + sessionId, + turnId: event.turnId, + channelId: event.channelId, + status: turnStatus, + items, + }); + return next; +} + +/** The most recent activity item is the compact dock's truthful summary. */ +export function latestActivityItem(turn: AgentTurn): ActivityItem | undefined { + return turn.items.at(-1); +} + +export function partitionActivity(turn: AgentTurn): { + visibleItems: ActivityItem[]; + hiddenItems: ActivityItem[]; +} { + const recentIds = new Set(turn.items.slice(-3).map((item) => item.id)); + const urgentIds = new Set( + turn.items + .filter((item) => + ["failed", "needs_you", "cancelled", "unavailable"].includes( + item.status, + ), + ) + .map((item) => item.id), + ); + return { + visibleItems: turn.items.filter( + (item) => recentIds.has(item.id) || urgentIds.has(item.id), + ), + hiddenItems: turn.items.filter( + (item) => !recentIds.has(item.id) && !urgentIds.has(item.id), + ), + }; +} diff --git a/desktop-new/src/features/agent-activity/types.ts b/desktop-new/src/features/agent-activity/types.ts new file mode 100644 index 0000000000..ec4ca9449d --- /dev/null +++ b/desktop-new/src/features/agent-activity/types.ts @@ -0,0 +1,28 @@ +export type ActivityStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled" + | "needs_you" + | "unavailable"; + +export type ActivityItem = { + id: string; + label: string; + detail?: string; + status: ActivityStatus; + timestamp: string; +}; + +/** Owner-private activity from an agent turn. Not a shared conversation event. */ +export type AgentTurn = { + key: string; + agentPubkey: string; + agentName: string; + sessionId: string; + turnId: string; + channelId: string; + status: ActivityStatus; + items: ActivityItem[]; +}; diff --git a/desktop-new/src/features/agent-activity/ui/AgentActivityRail.tsx b/desktop-new/src/features/agent-activity/ui/AgentActivityRail.tsx new file mode 100644 index 0000000000..334685ec0f --- /dev/null +++ b/desktop-new/src/features/agent-activity/ui/AgentActivityRail.tsx @@ -0,0 +1,33 @@ +import { ShimmerText } from "@/shared/ui/ShimmerText"; + +import { latestActivityItem } from "../activityProjection"; +import type { AgentTurn } from "../types"; + +/** + * Compact, non-blocking status beneath a Conversation Composer. + * + * It renders only active turns. ConversationDock owns its space; this component + * owns the activity's readable, motion-safe working treatment. + */ +export function AgentActivityRail({ turns }: { turns: AgentTurn[] }) { + if (turns.length === 0) return null; + + return ( +
+ {turns.map((turn) => { + const current = latestActivityItem(turn); + const label = `${turn.agentName} is working${ + current ? ` · ${current.label}` : "" + }`; + return ( + + {label} + + ); + })} +
+ ); +} diff --git a/desktop-new/src/features/agent-activity/ui/AgentWorkArea.tsx b/desktop-new/src/features/agent-activity/ui/AgentWorkArea.tsx new file mode 100644 index 0000000000..dd9d4654e2 --- /dev/null +++ b/desktop-new/src/features/agent-activity/ui/AgentWorkArea.tsx @@ -0,0 +1,135 @@ +import "./work.css"; +import { IconAlertCircle, IconLock } from "@tabler/icons-react"; +import { useState } from "react"; +import { Accordion } from "@/shared/ui/Accordion"; +import { Avatar } from "@/shared/ui/Avatar"; +import { WorkStep } from "./WorkStep"; +import type { AgentWorkPresentation } from "./workPresentation"; + +/** + * A stable, owner-private place for one agent's work in an exchange. + * Adapts Berd AgentWorkPanel's active window / previous steps / settled + * disclosure anatomy. Parsing, ordering and visibility stay with the owner; + * no ACP decoding, transcript store, or completion-remount effect is copied. + * Published answers are deliberately not accepted here. + */ +export function AgentWorkArea({ work }: { work: AgentWorkPresentation }) { + // Remember user disclosure choices within this work identity. A transition + // to completed defaults closed without synchronizing state through an Effect. + const [expanded, setExpanded] = useState<{ + status: string; + values: string[]; + } | null>(null); + const active = work.status === "running"; + const count = work.earlierSteps.length + work.visibleSteps.length; + const steps = (items: AgentWorkPresentation["visibleSteps"]) => ( +
    + {items.map((step) => ( + + ))} +
+ ); + return ( +
+
+ + {work.agent.name} + + {work.statusLabel} + + + +
+
+ {active ? ( +
+ {work.earlierSteps.length ? ( +
+ +
+ ) : null} + {steps(work.visibleSteps)} +
+ ) : count ? ( + + setExpanded({ status: work.status, values }) + } + items={[ + { + value: "work", + title: `${count} steps · ${work.summary}`, + content: ( +
+ {steps([...work.earlierSteps, ...work.visibleSteps])} +
+ ), + }, + ]} + /> + ) : ( +

{work.summary}

+ )} + {work.notice ? ( +
+
+ ) : null} + {work.artifacts?.length ? ( +
+ {work.artifacts.map((artifact) => ( + + ))} +
+ ) : null} +
+
+ ); +} diff --git a/desktop-new/src/features/agent-activity/ui/WorkStep.tsx b/desktop-new/src/features/agent-activity/ui/WorkStep.tsx new file mode 100644 index 0000000000..57a2c96f8d --- /dev/null +++ b/desktop-new/src/features/agent-activity/ui/WorkStep.tsx @@ -0,0 +1,80 @@ +import "./work.css"; +import { + IconCheck, + IconCircle, + IconCircleDashed, + IconExclamationCircle, + IconMessageCircle, + IconSparkles, + IconTerminal2, +} from "@tabler/icons-react"; +import { ShimmerText } from "@/shared/ui/ShimmerText"; +import { Accordion } from "@/shared/ui/Accordion"; +import type { WorkStepPresentation } from "./workPresentation"; + +/** One prepared activity step with optional, keyboard-accessible tool output. */ +export function WorkStep({ + step, + shimmer = false, +}: { + step: WorkStepPresentation; + shimmer?: boolean; +}) { + const title = shimmer ? {step.title} : step.title; + const Icon = + step.status === "unavailable" + ? IconCircle + : step.status === "failed" + ? IconExclamationCircle + : step.status === "running" + ? IconCircleDashed + : step.kind === "thought" + ? IconSparkles + : step.kind === "progress" + ? IconMessageCircle + : IconCheck; + return ( +
  • + +
    + {step.detail ? ( + +
    +
    +
    +                      {step.detail}
    +                    
    +
    + ), + }, + ]} + /> + ) : ( +

    {title}

    + )} + {step.status === "unavailable" ? ( + + Outcome unconfirmed + + ) : null} + {step.status === "failed" ? ( + Failed + ) : null} + +
  • + ); +} diff --git a/desktop-new/src/features/agent-activity/ui/work.css b/desktop-new/src/features/agent-activity/ui/work.css new file mode 100644 index 0000000000..e8c46004b9 --- /dev/null +++ b/desktop-new/src/features/agent-activity/ui/work.css @@ -0,0 +1,124 @@ +@layer components { + .agent-work-area { + min-width: 0; + } + .agent-work-identity { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; + } + .agent-work-privacy { + display: inline-flex; + align-items: center; + gap: var(--space-1); + margin-inline-start: auto; + } + .agent-work-content { + padding-inline-start: calc(var(--space-6) + var(--space-2)); + margin-top: var(--space-1); + min-width: 0; + } + .agent-work-content > p { + margin: var(--space-2) 0; + } + .work-steps { + list-style: none; + margin: 0; + padding: 0; + } + .work-step { + display: grid; + grid-template-columns: var(--space-5) minmax(0, 1fr); + column-gap: var(--space-2); + min-width: 0; + } + .work-step-mark { + display: flex; + justify-content: center; + position: relative; + padding-top: var(--space-2); + color: var(--text-tertiary); + } + /* Berd's continuous work spine: one rail spans the expanded history and + recent steps. Icons mask it with the transcript's own surface. */ + .work-sequence { + position: relative; + } + .work-sequence::before { + content: ""; + position: absolute; + inset-block: var(--space-2) var(--space-4); + inset-inline-start: calc(var(--space-5) / 2); + border-inline-start: 1px solid var(--border-primary); + } + .work-sequence:has(> .work-history)::before { + inset-block-start: calc(var(--space-6) + var(--space-3)); + } + .work-step-mark > svg { + position: relative; + background: var(--bg-panel); + box-shadow: 0 0 0 var(--space-1) var(--bg-panel); + } + .work-step[data-status="failed"] .work-step-mark { + color: var(--red-12); + } + .work-step[data-status="running"] .work-step-mark { + color: var(--purple-12); + } + .work-step-body { + min-width: 0; + } + .work-step-copy { + margin: 0; + padding-block: var(--space-2); + } + .work-tool-output { + padding: var(--space-3); + background: var(--bg-inset); + border-radius: var(--radius-row); + min-width: 0; + } + .work-tool-output-label { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-2); + } + .work-tool-output pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .work-notice { + display: flex; + align-items: flex-start; + gap: var(--space-2); + margin-block: var(--space-3); + color: var(--text-secondary); + } + .work-notice > svg { + flex-shrink: 0; + margin-top: var(--space-1); + } + .work-notice p { + margin: 0; + } + .work-notice p + p { + margin-top: var(--space-1); + } + .work-notice-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-2); + } + .work-artifacts { + margin-top: var(--space-2); + } + @media (max-width: 40rem) { + .agent-work-content { + padding-inline-start: var(--space-2); + } + } +} diff --git a/desktop-new/src/features/agent-activity/ui/workPresentation.ts b/desktop-new/src/features/agent-activity/ui/workPresentation.ts new file mode 100644 index 0000000000..8499580bda --- /dev/null +++ b/desktop-new/src/features/agent-activity/ui/workPresentation.ts @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; + +/** Prepared UI input, not an observer event or a second runtime model. */ +export type WorkStepPresentation = { + id: string; + title: string; + kind: "tool" | "progress" | "thought"; + status: "running" | "completed" | "failed" | "unavailable"; + detail?: string; +}; + +/** The activity owner supplies ordering, visibility, summaries, and actions. */ +export type AgentWorkPresentation = { + id: string; + agent: { name: string; avatar?: string; fallback: string }; + status: + | "running" + | "completed" + | "waiting" + | "needs-you" + | "failed" + | "cancelled" + | "unavailable"; + statusLabel: string; + summary: string; + earlierSteps: readonly WorkStepPresentation[]; + visibleSteps: readonly WorkStepPresentation[]; + notice?: { title: string; description: string; actions?: ReactNode }; + artifacts?: readonly { id: string; name: string; preview: ReactNode }[]; +}; diff --git a/desktop-new/src/features/agent-activity/useAgentActivity.ts b/desktop-new/src/features/agent-activity/useAgentActivity.ts new file mode 100644 index 0000000000..ba16727b7b --- /dev/null +++ b/desktop-new/src/features/agent-activity/useAgentActivity.ts @@ -0,0 +1,47 @@ +import { useEffect, useMemo, useState } from "react"; +import { runtime } from "@/shared/runtime/client"; +import type { ObserverEvent } from "@/shared/runtime/observerEvent"; +import { reduceActivity } from "./activityProjection"; +import type { AgentTurn } from "./types"; + +/** + * The one app-scoped ingestion point for owner-private observer telemetry. + * + * Mount this once above conversation placements. Consumers receive a read model + * filtered to their current channel rather than starting their own observer + * subscription. + */ +export function useAgentActivity() { + const [turnsByKey, setTurnsByKey] = useState>( + () => new Map(), + ); + + useEffect(() => { + let disposed = false; + let stop: (() => void) | undefined; + + void runtime + .observe((event: ObserverEvent) => { + if (!disposed) + setTurnsByKey((current) => reduceActivity(current, event)); + }) + .then((cleanup) => { + if (disposed) cleanup(); + else stop = cleanup; + }); + + return () => { + disposed = true; + stop?.(); + }; + }, []); + + const turns = useMemo(() => [...turnsByKey.values()], [turnsByKey]); + + return { + /** Reads activity for a conversation; it never owns the observer stream. */ + forChannel(channelId: string) { + return turns.filter((turn) => turn.channelId === channelId); + }, + }; +} diff --git a/desktop-new/src/features/agents/runtime.ts b/desktop-new/src/features/agents/runtime.ts new file mode 100644 index 0000000000..ba5b3714c3 --- /dev/null +++ b/desktop-new/src/features/agents/runtime.ts @@ -0,0 +1,180 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import type { AgentRuntime, ManagedAgent } from "./types"; + +type RawManagedAgent = { + pubkey: string; + name: string; + runtime?: string | null; + agent_command: string; + system_prompt?: string | null; + model?: string | null; + provider?: string | null; + status: ManagedAgent["status"]; + last_error?: string | null; +}; + +type RawRuntime = { + id: string; + label: string; + availability: AgentRuntime["availability"]; + command: string | null; + auth_status: AgentRuntime["authStatus"]; + install_hint: string; + login_hint?: string | null; +}; + +const mockAgents: ManagedAgent[] = [ + { + pubkey: "vogue-agent", + name: "Vogue", + runtime: "goose", + agentCommand: "goose", + systemPrompt: "Shape product interfaces with clarity and taste.", + model: "Claude Fable 5", + provider: "Databricks", + status: "running", + lastError: null, + }, +]; + +function agent(raw: RawManagedAgent): ManagedAgent { + return { + pubkey: raw.pubkey, + name: raw.name, + runtime: raw.runtime ?? null, + agentCommand: raw.agent_command, + systemPrompt: raw.system_prompt ?? null, + model: raw.model ?? null, + provider: raw.provider ?? null, + status: raw.status, + lastError: raw.last_error ?? null, + }; +} + +function catalogEntry(raw: RawRuntime): AgentRuntime { + return { + id: raw.id, + label: raw.label, + availability: raw.availability, + command: raw.command, + authStatus: raw.auth_status, + installHint: raw.install_hint, + loginHint: raw.login_hint ?? null, + }; +} + +export const agentRuntime = { + async list(): Promise { + if (!isTauri()) return [...mockAgents]; + return (await invoke("list_managed_agents")).map(agent); + }, + + async catalog(): Promise { + if (!isTauri()) { + return [ + { + id: "goose", + label: "Goose", + availability: "available", + command: "goose", + authStatus: { status: "not_applicable" }, + installHint: "", + loginHint: null, + }, + ]; + } + return ( + await invoke("discover_acp_providers", { force: false }) + ).map(catalogEntry); + }, + + async create(input: { + name: string; + instructions: string; + runtime: AgentRuntime; + relayUrl?: string; + signerPubkey?: string; + }): Promise { + if (!input.runtime.command) throw new Error("This runtime is not ready."); + if (!isTauri()) { + const created: ManagedAgent = { + pubkey: crypto.randomUUID(), + name: input.name, + runtime: input.runtime.id, + agentCommand: input.runtime.command, + systemPrompt: input.instructions, + model: null, + provider: null, + status: "running", + lastError: null, + }; + mockAgents.unshift(created); + return created; + } + const response = await invoke<{ + agent: RawManagedAgent; + spawn_error?: string | null; + }>("create_managed_agent", { + input: { + name: input.name, + relayUrl: null, + expectedRelayUrl: input.relayUrl ?? null, + expectedSignerPubkey: input.signerPubkey ?? null, + acpCommand: "buzz-acp", + agentCommand: input.runtime.command, + harnessOverride: true, + agentArgs: [], + mcpCommand: null, + turnTimeoutSeconds: null, + idleTimeoutSeconds: null, + maxTurnDurationSeconds: null, + parallelism: null, + systemPrompt: input.instructions, + avatarUrl: null, + model: null, + provider: null, + envVars: {}, + spawnAfterCreate: true, + startOnAppLaunch: false, + backend: { type: "local" }, + respondTo: "owner-only", + respondToAllowlist: [], + relayMesh: null, + }, + }); + if (response.spawn_error) throw new Error(response.spawn_error); + return agent(response.agent); + }, + + async start( + pubkey: string, + scope?: { relayUrl: string; signerPubkey: string }, + ): Promise { + if (!isTauri()) { + const current = mockAgents.find((item) => item.pubkey === pubkey); + if (!current) throw new Error("Agent not found."); + current.status = "running"; + return { ...current }; + } + return agent( + await invoke("start_managed_agent", { + pubkey, + expectedRelayUrl: scope?.relayUrl ?? null, + expectedSignerPubkey: scope?.signerPubkey ?? null, + replayFloorUnix: null, + }), + ); + }, + + async stop(pubkey: string): Promise { + if (!isTauri()) { + const current = mockAgents.find((item) => item.pubkey === pubkey); + if (!current) throw new Error("Agent not found."); + current.status = "stopped"; + return { ...current }; + } + return agent( + await invoke("stop_managed_agent", { pubkey }), + ); + }, +}; diff --git a/desktop-new/src/features/agents/types.ts b/desktop-new/src/features/agents/types.ts new file mode 100644 index 0000000000..1019943f42 --- /dev/null +++ b/desktop-new/src/features/agents/types.ts @@ -0,0 +1,37 @@ +export type AgentStatus = "running" | "stopped" | "deployed" | "not_deployed"; + +export type ManagedAgent = { + pubkey: string; + name: string; + runtime: string | null; + agentCommand: string; + systemPrompt: string | null; + model: string | null; + provider: string | null; + status: AgentStatus; + lastError: string | null; +}; + +export type RuntimeAvailability = + | "available" + | "adapter_missing" + | "adapter_outdated" + | "cli_missing" + | "not_installed"; + +export type RuntimeAuthStatus = + | { status: "logged_in" } + | { status: "logged_out" } + | { status: "config_invalid"; diagnostic: string } + | { status: "not_applicable" } + | { status: "unknown" }; + +export type AgentRuntime = { + id: string; + label: string; + availability: RuntimeAvailability; + command: string | null; + authStatus: RuntimeAuthStatus; + installHint: string; + loginHint: string | null; +}; diff --git a/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx b/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx new file mode 100644 index 0000000000..8b40d76abf --- /dev/null +++ b/desktop-new/src/features/agents/ui/AgentsWorkspace.tsx @@ -0,0 +1,345 @@ +import { + IconPlayerPlay, + IconPlayerStop, + IconPlus, + IconRobot, +} from "@tabler/icons-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { readRelayUrl } from "@/shared/community/useCommunity"; +import { readIdentity } from "@/shared/identity/useIdentity"; +import { agentRuntime } from "../runtime"; +import type { AgentRuntime, ManagedAgent } from "../types"; + +/** + * The community and signer a managed agent is launched against. Read from the + * shared facts rather than the backend adapter, so this feature cannot disagree + * with the rest of the app about who is running it or where. + */ +async function readAgentScope() { + const [relayUrl, identity] = await Promise.all([ + readRelayUrl(), + readIdentity(), + ]); + return { relayUrl, signerPubkey: identity.pubkey }; +} + +function runtimeReady(runtime: AgentRuntime) { + return ( + runtime.availability === "available" && + (runtime.authStatus.status === "logged_in" || + runtime.authStatus.status === "not_applicable") + ); +} + +export function AgentsNavigator({ + agents, + selectedPubkey, + onSelect, + onNew, +}: { + agents: ManagedAgent[]; + selectedPubkey?: string; + onSelect: (pubkey: string) => void; + onNew: () => void; +}) { + return ( + + ); +} + +function AgentCreation({ + runtimes, + onCreated, +}: { + runtimes: AgentRuntime[]; + onCreated: (agent: ManagedAgent) => void; +}) { + const available = runtimes.filter(runtimeReady); + const [name, setName] = useState(""); + const [instructions, setInstructions] = useState(""); + const [runtimeId, setRuntimeId] = useState(available[0]?.id ?? ""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const selected = runtimes.find((runtime) => runtime.id === runtimeId); + + async function create() { + if (!selected || !runtimeReady(selected)) return; + setSaving(true); + setError(null); + try { + const scope = await readAgentScope(); + onCreated( + await agentRuntime.create({ + name: name.trim(), + instructions: instructions.trim(), + runtime: selected, + ...scope, + }), + ); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setSaving(false); + } + } + + return ( +
    +
    +

    New agent

    +
    +
    + +