Merge main with desktop-new UI work

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-09 18:36:58 -07:00
192 changed files with 22685 additions and 84 deletions

View File

@@ -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

View File

@@ -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": "<uuid>" }`. 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,

View File

@@ -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<bool> {
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<bool> {
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() {

View File

@@ -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,

View File

@@ -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"));

View File

@@ -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}");
}

View File

@@ -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<Scope, &'static s
Ok(Scope::ChannelsWrite)
}
}
KIND_NIP29_CREATE_GROUP | KIND_CANVAS => 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)

View File

@@ -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,

View File

@@ -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 {

14
desktop-new/.gitignore vendored Normal file
View File

@@ -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/

326
desktop-new/AGENTS.md Normal file
View File

@@ -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.

408
desktop-new/DESIGN.md Normal file
View File

@@ -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
```
<property>-<role>[-<modifier>][-<material>][-<state>]
```
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 `<property>-<hue>-<step>` 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.

View File

@@ -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.

3
desktop-new/biome.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["../biome.json"]
}

12
desktop-new/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Buzz — Design System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

55
desktop-new/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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,
},
});

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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}`);
}

View File

@@ -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");

View File

@@ -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}`);
}

View File

@@ -0,0 +1,3 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
export const Route = createRootRoute({ component: () => <Outlet /> });

View File

@@ -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,
});

View File

@@ -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: () => <SystemDocumentPage document="agents" />,
});

View File

@@ -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,
});

View File

@@ -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 `<Outlet />` — and since ColourPage does not, the parent page rendered in
* place of this one.
*/
export const Route = createFileRoute("/design/color_/table")({
component: ColorTablePage,
});

View File

@@ -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,
});

View File

@@ -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 <ComponentDetailPage slug={component} />;
}

View File

@@ -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,
});

View File

@@ -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: () => <SystemDocumentPage document="design" />,
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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 });

View File

@@ -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: () => <SystemDocumentPage document="maintaining" />,
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { AppShell } from "@/features/sessions/ui/AppShell";
export const Route = createFileRoute("/")({ component: AppShell });

View File

@@ -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,
});

View File

@@ -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;
};
}, []);
}

View File

@@ -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");
});
});

View File

@@ -0,0 +1,142 @@
import type { ObserverEvent } from "@/shared/runtime/observerEvent";
import type { ActivityItem, ActivityStatus, AgentTurn } from "./types";
const TERMINAL_KINDS: Record<string, ActivityStatus> = {
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<string, AgentTurn>,
event: ObserverEvent,
fallbackAgentName = "Agent",
): Map<string, AgentTurn> {
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),
),
};
}

View File

@@ -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[];
};

View File

@@ -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 (
<div className="agent-activity-rail" role="status">
{turns.map((turn) => {
const current = latestActivityItem(turn);
const label = `${turn.agentName} is working${
current ? ` · ${current.label}` : ""
}`;
return (
<span
key={turn.key}
className="agent-activity-rail-status text-body-sm"
>
<ShimmerText>{label}</ShimmerText>
</span>
);
})}
</div>
);
}

View File

@@ -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"]) => (
<ol className="work-steps">
{items.map((step) => (
<WorkStep
key={step.id}
step={step}
shimmer={active && step.id === work.visibleSteps.at(-1)?.id}
/>
))}
</ol>
);
return (
<section
className="agent-work-area"
aria-label={`${work.agent.name} activity`}
data-work-id={work.id}
>
<header className="agent-work-identity">
<span aria-hidden="true">
<Avatar
size="small"
src={work.agent.avatar}
alt=""
fallback={work.agent.fallback}
/>
</span>
<span className="text-body text-primary">{work.agent.name}</span>
<span className="sr-only" role="status">
{work.statusLabel}
</span>
<span className="agent-work-privacy text-body-sm text-tertiary">
<IconLock size={12} aria-hidden="true" />
Only you
</span>
</header>
<div className="agent-work-content">
{active ? (
<div className="work-sequence">
{work.earlierSteps.length ? (
<div className="work-history">
<Accordion
variant="activity"
items={[
{
value: "earlier",
title: `${work.earlierSteps.length} earlier ${work.earlierSteps.length === 1 ? "step" : "steps"}`,
content: steps(work.earlierSteps),
},
]}
/>
</div>
) : null}
{steps(work.visibleSteps)}
</div>
) : count ? (
<Accordion
variant="activity"
value={expanded?.status === work.status ? expanded.values : []}
onValueChange={(values) =>
setExpanded({ status: work.status, values })
}
items={[
{
value: "work",
title: `${count} steps · ${work.summary}`,
content: (
<div className="work-sequence">
{steps([...work.earlierSteps, ...work.visibleSteps])}
</div>
),
},
]}
/>
) : (
<p className="text-body text-secondary">{work.summary}</p>
)}
{work.notice ? (
<div className="work-notice" role="note">
<IconAlertCircle size={16} aria-hidden="true" />
<div>
<p className="text-body text-primary">{work.notice.title}</p>
<p className="text-body-sm text-secondary">
{work.notice.description}
</p>
{work.notice.actions ? (
<div className="work-notice-actions">{work.notice.actions}</div>
) : null}
</div>
</div>
) : null}
{work.artifacts?.length ? (
<div className="work-artifacts">
{work.artifacts.map((artifact) => (
<Accordion
key={artifact.id}
variant="activity"
items={[
{
value: artifact.id,
title: artifact.name,
content: artifact.preview,
},
]}
/>
))}
</div>
) : null}
</div>
</section>
);
}

View File

@@ -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 ? <ShimmerText>{step.title}</ShimmerText> : 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 (
<li
className="work-step"
data-status={step.status}
aria-label={`${step.title}, ${step.status}`}
>
<span className="work-step-mark" aria-hidden="true">
<Icon size={16} stroke={1.6} />
</span>
<div className="work-step-body">
{step.detail ? (
<Accordion
variant="activity"
items={[
{
value: step.id,
title,
content: (
<div className="work-tool-output">
<div className="work-tool-output-label text-body-sm text-secondary">
<IconTerminal2 size={14} aria-hidden="true" /> Tool output
</div>
<pre className="text-mono-sm text-secondary">
{step.detail}
</pre>
</div>
),
},
]}
/>
) : (
<p className="work-step-copy text-body text-secondary">{title}</p>
)}
{step.status === "unavailable" ? (
<span className="text-body-sm text-secondary">
Outcome unconfirmed
</span>
) : null}
{step.status === "failed" ? (
<span className="text-body-sm text-red-12">Failed</span>
) : null}
</div>
</li>
);
}

View File

@@ -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);
}
}
}

View File

@@ -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 }[];
};

View File

@@ -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<Map<string, AgentTurn>>(
() => 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);
},
};
}

View File

@@ -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<ManagedAgent[]> {
if (!isTauri()) return [...mockAgents];
return (await invoke<RawManagedAgent[]>("list_managed_agents")).map(agent);
},
async catalog(): Promise<AgentRuntime[]> {
if (!isTauri()) {
return [
{
id: "goose",
label: "Goose",
availability: "available",
command: "goose",
authStatus: { status: "not_applicable" },
installHint: "",
loginHint: null,
},
];
}
return (
await invoke<RawRuntime[]>("discover_acp_providers", { force: false })
).map(catalogEntry);
},
async create(input: {
name: string;
instructions: string;
runtime: AgentRuntime;
relayUrl?: string;
signerPubkey?: string;
}): Promise<ManagedAgent> {
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<ManagedAgent> {
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<RawManagedAgent>("start_managed_agent", {
pubkey,
expectedRelayUrl: scope?.relayUrl ?? null,
expectedSignerPubkey: scope?.signerPubkey ?? null,
replayFloorUnix: null,
}),
);
},
async stop(pubkey: string): Promise<ManagedAgent> {
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<RawManagedAgent>("stop_managed_agent", { pubkey }),
);
},
};

View File

@@ -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;
};

View File

@@ -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 (
<aside className="workspace-navigator" aria-label="Agents">
<header className="panel-heading panel-heading-between">
<span className="text-heading text-primary">Agents</span>
<button
type="button"
className="navigation-item-action always-visible"
onClick={onNew}
aria-label="New agent"
>
<IconPlus size={15} stroke={1.7} aria-hidden="true" />
</button>
</header>
<div className="navigator-list">
{agents.map((agent) => (
<button
type="button"
className="agent-navigation-item"
data-selected={selectedPubkey === agent.pubkey || undefined}
key={agent.pubkey}
onClick={() => onSelect(agent.pubkey)}
>
<span className="agent-avatar">
<IconRobot size={15} stroke={1.5} aria-hidden="true" />
</span>
<span className="agent-row-copy">
<span className="text-body text-primary">{agent.name}</span>
<span className="text-body-sm text-tertiary">
{agent.status === "running" ? "Running" : "Stopped"}
</span>
</span>
<span
className="agent-presence"
data-running={agent.status === "running" || undefined}
aria-hidden="true"
/>
</button>
))}
</div>
</aside>
);
}
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<string | null>(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 (
<main className="agent-main-panel agent-create-panel">
<header className="panel-heading">
<h1 className="text-heading text-primary">New agent</h1>
</header>
<div className="agent-form">
<label className="field-stack">
<span className="text-body-sm text-secondary">Name</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Scout"
/>
</label>
<label className="field-stack">
<span className="text-body-sm text-secondary">Instructions</span>
<textarea
value={instructions}
onChange={(event) => setInstructions(event.target.value)}
placeholder="Describe its role, boundaries, and how it should respond."
/>
</label>
<label className="field-stack">
<span className="text-body-sm text-secondary">Runs with</span>
<select
value={runtimeId}
onChange={(event) => setRuntimeId(event.target.value)}
>
{runtimes.map((runtime) => (
<option
key={runtime.id}
value={runtime.id}
disabled={!runtimeReady(runtime)}
>
{runtime.label}
{runtimeReady(runtime) ? "" : " — needs setup"}
</option>
))}
</select>
</label>
{selected && !runtimeReady(selected) ? (
<p className="setup-callout text-body text-secondary" role="status">
{selected.loginHint ||
selected.installHint ||
"This runtime needs setup before it can start an agent."}
</p>
) : null}
{error ? (
<p className="text-body text-red-12" role="alert">
{error}
</p>
) : null}
<button
type="button"
className="primary-button agent-create-action"
disabled={
!name.trim() ||
!instructions.trim() ||
!selected ||
!runtimeReady(selected) ||
saving
}
onClick={() => void create()}
>
{saving ? "Creating…" : "Create and start"}
</button>
</div>
</main>
);
}
function AgentDetail({
agent,
onChange,
}: {
agent: ManagedAgent;
onChange: (agent: ManagedAgent) => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function toggle() {
setBusy(true);
setError(null);
try {
if (agent.status === "running") {
onChange(await agentRuntime.stop(agent.pubkey));
} else {
onChange(
await agentRuntime.start(agent.pubkey, await readAgentScope()),
);
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setBusy(false);
}
}
return (
<main className="agent-main-panel">
<header className="agent-profile-header">
<span className="agent-profile-avatar">
<IconRobot size={24} stroke={1.4} aria-hidden="true" />
</span>
<div className="agent-profile-title">
<h1 className="text-heading text-primary">{agent.name}</h1>
<span className="text-body-sm text-secondary">
{agent.status === "running" ? "Running" : "Stopped"}
</span>
</div>
<button
type="button"
className="quiet-button agent-lifecycle-action"
onClick={() => void toggle()}
disabled={busy}
>
{agent.status === "running" ? (
<IconPlayerStop size={15} aria-hidden="true" />
) : (
<IconPlayerPlay size={15} aria-hidden="true" />
)}
{busy ? "Working…" : agent.status === "running" ? "Stop" : "Start"}
</button>
</header>
<div className="agent-profile-body">
<section>
<p className="text-body-sm text-tertiary">Instructions</p>
<p className="text-body text-primary">
{agent.systemPrompt || "No instructions yet."}
</p>
</section>
<section className="agent-runtime-summary">
<div>
<p className="text-body-sm text-tertiary">Runs with</p>
<p className="text-body text-primary">
{agent.runtime || agent.agentCommand}
</p>
</div>
<div>
<p className="text-body-sm text-tertiary">Model</p>
<p className="text-body text-secondary">
{agent.model || "Runtime default"}
</p>
</div>
</section>
{error || agent.lastError ? (
<p className="setup-callout text-body text-red-12" role="alert">
{error || agent.lastError}
</p>
) : null}
</div>
</main>
);
}
export function useAgentsWorkspace() {
const [agents, setAgents] = useState<ManagedAgent[]>([]);
const [runtimes, setRuntimes] = useState<AgentRuntime[]>([]);
const [selectedPubkey, setSelectedPubkey] = useState<string>();
const [creating, setCreating] = useState(false);
const refresh = useCallback(async () => {
const [nextAgents, nextRuntimes] = await Promise.all([
agentRuntime.list(),
agentRuntime.catalog(),
]);
setAgents(nextAgents);
setRuntimes(nextRuntimes);
setSelectedPubkey((current) => current ?? nextAgents[0]?.pubkey);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const selected = useMemo(
() => agents.find((agent) => agent.pubkey === selectedPubkey),
[agents, selectedPubkey],
);
return {
navigator: (
<AgentsNavigator
agents={agents}
selectedPubkey={selectedPubkey}
onSelect={(pubkey) => {
setCreating(false);
setSelectedPubkey(pubkey);
}}
onNew={() => setCreating(true)}
/>
),
content: creating ? (
<AgentCreation
runtimes={runtimes}
onCreated={(created) => {
setAgents((current) => [created, ...current]);
setSelectedPubkey(created.pubkey);
setCreating(false);
}}
/>
) : selected ? (
<AgentDetail
agent={selected}
onChange={(changed) =>
setAgents((current) =>
current.map((item) =>
item.pubkey === changed.pubkey ? changed : item,
),
)
}
/>
) : (
<main className="empty-workspace-panel">
<IconRobot size={22} aria-hidden="true" />
<h1 className="text-heading text-primary">Create your first agent</h1>
<button
type="button"
className="primary-button"
onClick={() => setCreating(true)}
>
Create agent
</button>
</main>
),
};
}

View File

@@ -0,0 +1,146 @@
import { type MouseEvent as ReactMouseEvent, useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { CHIP_NODE_NAME, ChipNode } from "@/features/composer/chipNode";
import type { ChipAddress } from "@/shared/chips/address";
import { resetChipFaces, chipFaces } from "@/shared/chips/faceResolver";
declare global {
interface Window {
/** Spike-only: lets a test read live editor state without render timing. */
__SPIKE_READ__?: () => {
json: unknown;
text: string;
addresses: string[];
};
/** Spike-only: renames an identity the way a profile update would. */
__SPIKE_RENAME__?: (address: ChipAddress, label: string) => void;
/** Spike-only: simulates switching community. */
__SPIKE_RESET_FACES__?: () => void;
}
}
const MORGAN: ChipAddress = { kind: "person", id: "pk-morgan" };
const ALEX: ChipAddress = { kind: "person", id: "pk-alex" };
/**
* Harness for the chip composer's editing contract.
*
* Not a product surface. It mounts the real ChipNode, the real node view, and
* the real shared InlineChip so browser tests bind production seams rather
* than a test-only stand-in. Delete it once the product composer exists,
* folding its assertions into that composer's tests.
*/
export function ComposerSpikePage() {
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: false, link: false }),
ChipNode,
],
content: { type: "doc", content: [{ type: "paragraph" }] },
editorProps: {
attributes: {
"data-testid": "spike-editor",
"aria-label": "Chip composer harness",
class: "spike-editor",
},
},
});
// Seed the faces these chips resolve to. The product resolves them from real
// identity; the harness only needs them present. Deliberately no cleanup:
// resetting on unmount would make a community-reset test tear down the very
// harness it is measuring.
useEffect(() => {
chipFaces.put(MORGAN, { label: "Morgan", loading: false, resolved: true });
chipFaces.put(ALEX, { label: "Alex", loading: false, resolved: true });
}, []);
// Read live editor state on demand. A React-rendered readout lags editor
// transactions, which made an earlier harness look like a browser defect.
useEffect(() => {
if (!editor) return;
window.__SPIKE_READ__ = () => {
const json = editor.getJSON() as {
content?: {
content?: { type: string; attrs?: Record<string, unknown> }[];
}[];
};
const inline = json.content?.[0]?.content ?? [];
return {
json,
text: editor.getText(),
addresses: inline
.filter((node) => node.type === CHIP_NODE_NAME)
.map((node) => `${node.attrs?.kind}/${node.attrs?.id}`),
};
};
window.__SPIKE_RENAME__ = (address, label) =>
chipFaces.put(address, { label, loading: false, resolved: true });
window.__SPIKE_RESET_FACES__ = () => resetChipFaces();
return () => {
window.__SPIKE_READ__ = undefined;
window.__SPIKE_RENAME__ = undefined;
window.__SPIKE_RESET_FACES__ = undefined;
};
}, [editor]);
if (!editor) return null;
// Insert controls must not take focus, or the caret leaves the editor and
// every keyboard assertion afterwards measures the wrong thing. The real
// chip picker owes the same.
const keepFocus = (event: ReactMouseEvent) => event.preventDefault();
const insertChip = (address: ChipAddress) =>
editor
.chain()
.focus()
.insertContent({ type: CHIP_NODE_NAME, attrs: address })
.run();
return (
<div className="flex flex-col gap-4 p-8">
<h1 className="text-title text-primary">Chip composer harness</h1>
<p className="text-body text-secondary">
Exercises the production chip node, node view, and shared InlineChip.
Temporary.
</p>
<div className="flex gap-2">
<button
type="button"
data-testid="insert-morgan"
onMouseDown={keepFocus}
onClick={() => insertChip(MORGAN)}
className="rounded-md bg-neutral-2 px-3 py-1 text-body text-primary"
>
Insert Morgan
</button>
<button
type="button"
data-testid="insert-alex"
onMouseDown={keepFocus}
onClick={() => insertChip(ALEX)}
className="rounded-md bg-neutral-2 px-3 py-1 text-body text-primary"
>
Insert Alex
</button>
<button
type="button"
data-testid="clear"
onMouseDown={keepFocus}
onClick={() => editor.chain().focus().clearContent(true).run()}
className="rounded-md bg-neutral-2 px-3 py-1 text-body text-primary"
>
Clear
</button>
</div>
<div className="rounded-md border border-primary bg-panel p-3">
<EditorContent editor={editor} />
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { chipFaces } from "@/shared/chips/faceResolver";
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import {
formatChipAddress,
isChipKind,
type ChipAddress,
} from "@/shared/chips/address";
import { ChipNodeView } from "./ui/ChipNodeView";
export const CHIP_NODE_NAME = "chip";
/**
* A chip in the composer document.
*
* The document stores an address — a kind and an identity — and nothing else.
* No display name, because a name in the document would make every profile
* rename an edit to the person's unsent message, dirtying the draft and
* polluting undo history. The face is resolved at render time instead.
*
* Two flags carry the interaction contract, both verified by the composition
* spike (see the Desktop New plan):
*
* - `selectable: false` is load-bearing. With it true, an arrow key selects the
* chip and the very next keystroke REPLACES it, so the chip silently
* disappears mid-sentence. TipTap's own mention extension ships false for the
* same reason.
* - `atom: true` is currently redundant — ProseMirror computes `isAtom` as
* `isLeaf || spec.atom` and a contentless node is already a leaf — and is
* kept as a statement of intent. It becomes load-bearing if a chip ever
* holds content.
*
* Whole-chip deletion needs no keyboard handler: Backspace from just after a
* chip removes it entirely, natively. The spike proved this by removing a
* hand-written handler and re-running.
*/
export const ChipNode = Node.create({
name: CHIP_NODE_NAME,
group: "inline",
inline: true,
atom: true,
selectable: false,
addAttributes() {
return {
kind: {
default: "person",
parseHTML: (element) =>
(element as HTMLElement).getAttribute("data-kind") ?? "person",
renderHTML: (attributes) => ({ "data-kind": String(attributes.kind) }),
},
id: {
default: "",
parseHTML: (element) =>
(element as HTMLElement).getAttribute("data-id") ?? "",
renderHTML: (attributes) => ({ "data-id": String(attributes.id) }),
},
};
},
parseHTML() {
return [{ tag: "span[data-chip]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
"data-chip": "",
"data-kind": undefined,
}),
];
},
/**
* The plain-text projection of a chip is its address, never its label.
*
* This is what a sent message carries and what an agent reads. A reader that
* knows nothing about chips still receives a meaningful `buzz://` link rather
* than broken markup, so the reference degrades rather than disappearing.
*/
renderText({ node }) {
const address = chipAddressFromAttrs(node.attrs);
return address ? formatChipAddress(address) : "";
},
addStorage() {
return {
markdown: {
serialize(
state: { write: (text: string) => void },
node: { attrs: Record<string, unknown> },
) {
const address = chipAddressFromAttrs(node.attrs);
if (address)
state.write(
`[${chipFaces.get(address).label.replace(/[[\]\\]/g, "")} ](${formatChipAddress(address)})`,
);
},
parse: {
updateDOM(element: HTMLElement) {
for (const link of element.querySelectorAll('a[href^="buzz://"]')) {
const match = link
.getAttribute("href")
?.match(/^buzz:\/\/(agent|person)\/([a-f0-9]{64})$/);
if (!match) continue;
const chip = document.createElement("span");
chip.setAttribute("data-chip", "");
chip.setAttribute("data-kind", match[1]);
chip.setAttribute("data-id", match[2]);
link.replaceWith(chip);
}
},
},
},
};
},
addNodeView() {
return ReactNodeViewRenderer(ChipNodeView);
},
});
/** Reads an address off node attributes, or null when they are not a valid one. */
export function chipAddressFromAttrs(
attrs: Record<string, unknown>,
): ChipAddress | null {
const kind = String(attrs.kind ?? "");
const id = String(attrs.id ?? "");
if (!isChipKind(kind) || id.length === 0) return null;
return { kind, id };
}

View File

@@ -0,0 +1,32 @@
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
import { InlineChip } from "@/shared/ui/InlineChip";
import { chipAddressFromAttrs } from "../chipNode";
/**
* Mounts the shared InlineChip inside the editor document.
*
* This is the seam that lets one component serve both the composer and the
* conversation. It is deliberately thin: it reads the address off the node and
* renders, holding no state of its own, so there is nothing here to drift from
* the read-only rendering path.
*
* The chip is not interactive inside the composer. While writing, a click's
* meaning is "put my caret here", not "open this person's profile" — and a
* focusable control inside a contenteditable region takes the caret out of the
* editor. Detail-on-click belongs to the conversation, where a click has no
* competing meaning.
*/
export function ChipNodeView({ node }: ReactNodeViewProps) {
const address = chipAddressFromAttrs(node.attrs);
return (
// Note: TipTap's `NodeViewWrapper` spreads its own props onto the rendered
// element, so `as` also lands as a literal DOM attribute. Cosmetic and
// upstream; not worth patching the dependency over.
<NodeViewWrapper as="span" className="chip-node-view">
{address ? <InlineChip address={address} interactive={false} /> : null}
</NodeViewWrapper>
);
}

View File

@@ -0,0 +1,93 @@
import {
IconBold,
IconCode,
IconItalic,
IconLink,
IconList,
IconListNumbers,
IconQuote,
IconStrikethrough,
IconX,
} from "@tabler/icons-react";
import { type AnimationEvent, useEffect, useRef } from "react";
import { IconButton } from "@/shared/ui/IconButton";
export type ComposerFormat =
| "bold"
| "italic"
| "strike"
| "code"
| "quote"
| "bullet"
| "number"
| "link";
const COMMANDS = [
{ format: "bold", label: "Bold", icon: IconBold },
{ format: "italic", label: "Italic", icon: IconItalic },
{ format: "strike", label: "Strikethrough", icon: IconStrikethrough },
{ format: "code", label: "Code", icon: IconCode },
{ format: "quote", label: "Quote", icon: IconQuote },
{ format: "bullet", label: "Bulleted list", icon: IconList },
{ format: "number", label: "Numbered list", icon: IconListNumbers },
{ format: "link", label: "Link", icon: IconLink },
] as const;
/** Composer-owned commands. The editor owns selection, active state, and edits. */
export function ComposerFormattingBar({
activeFormats = [],
className,
disabled,
onFormat,
onCommandPointerDown,
onClose,
onAnimationEnd,
focusOnMount = false,
}: {
activeFormats?: readonly ComposerFormat[];
className?: string;
focusOnMount?: boolean;
onAnimationEnd?: (event: AnimationEvent<HTMLFieldSetElement>) => void;
disabled?: boolean;
onFormat?: (format: ComposerFormat) => void;
onCommandPointerDown?: () => void;
onClose: () => void;
}) {
const closeButton = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (focusOnMount) closeButton.current?.focus();
}, [focusOnMount]);
return (
<fieldset
className={`composer-formatting-bar ${className ?? ""}`}
aria-label="Formatting"
onAnimationEnd={onAnimationEnd}
>
<IconButton
ref={closeButton}
aria-label="Close formatting"
icon={<IconX size={16} aria-hidden="true" />}
size="toolbar"
variant="ghost"
onClick={onClose}
/>
<span className="composer-formatting-divider" aria-hidden="true" />
{COMMANDS.map(({ format, label, icon: Icon }) => (
<IconButton
key={format}
aria-label={label}
aria-pressed={activeFormats.includes(format)}
icon={<Icon size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
disabled={disabled || !onFormat}
onMouseDown={(event) => {
onCommandPointerDown?.();
event.preventDefault();
}}
onClick={() => onFormat?.(format)}
/>
))}
</fieldset>
);
}

View File

@@ -0,0 +1,65 @@
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
import { useState } from "react";
import { Button } from "@/shared/ui/Button";
/** Composer-local link entry. It returns a label and URL; the editor applies them. */
export function ComposerLinkDialog({
open,
onOpenChange,
onSubmit,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (link: { label: string; url: string }) => void;
}) {
const [label, setLabel] = useState("");
const [url, setUrl] = useState("");
return (
<BaseDialog.Root open={open} onOpenChange={onOpenChange}>
<BaseDialog.Portal>
<BaseDialog.Backdrop className="composer-link-backdrop" />
<BaseDialog.Popup className="composer-link-dialog">
<BaseDialog.Title className="text-heading text-primary">
Add link
</BaseDialog.Title>
<form
onSubmit={(event) => {
event.preventDefault();
if (!url.trim()) return;
onSubmit({ label: label.trim() || url.trim(), url: url.trim() });
setLabel("");
setUrl("");
onOpenChange(false);
}}
>
<label className="text-body text-primary">
Link text
<input
value={label}
onChange={(event) => setLabel(event.target.value)}
/>
</label>
<label className="text-body text-primary">
Address
<input
required
type="url"
value={url}
onChange={(event) => setUrl(event.target.value)}
/>
</label>
<div className="composer-link-actions">
<BaseDialog.Close
render={<Button variant="ghost">Cancel</Button>}
/>
<Button type="submit" variant="primary">
Add link
</Button>
</div>
</form>
</BaseDialog.Popup>
</BaseDialog.Portal>
</BaseDialog.Root>
);
}

View File

@@ -0,0 +1,333 @@
import { Popover as BasePopover } from "@base-ui/react/popover";
import data from "@emoji-mart/data";
import {
IconMoodSmile,
IconPhoto,
IconSearch,
IconX,
} from "@tabler/icons-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { IconButton } from "@/shared/ui/IconButton";
import { Tabs } from "@/shared/ui/Tabs";
type PickerTab = "emoji" | "gifs";
type CustomEmoji = { name: string; src: string };
const CUSTOM: readonly CustomEmoji[] = [
{
name: "party-parrot",
src: "https://em-content.zobj.net/source/twitter/376/parrot_1f99c.png",
},
{
name: "buzz",
src: "https://em-content.zobj.net/source/twitter/376/honeybee_1f41d.png",
},
{
name: "ship-it",
src: "https://em-content.zobj.net/source/twitter/376/rocket_1f680.png",
},
{
name: "coffee",
src: "https://em-content.zobj.net/source/twitter/376/hot-beverage_2615.png",
},
];
const GIFS = [
"https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExNHoyNGl0Z3Q4a3Jkdm1teW93MjVqM2VtY2E2dTQxdmNxdG55dW1nZzVpeSZlcD12MV9naWZzX3NlYXJjaCZjdD1n/3oKIPwoeGErMmaI43S/giphy.gif",
"https://media.giphy.com/media/v1.Y2lkPTc5MGI3NjExNHoyNGl0Z3Q4a3Jkdm1teW93MjVqM2VtY2E2dTQxdmNxdG55dW1nZzVpeSZlcD12MV9naWZzX3NlYXJjaCZjdD1n/l0MYt5jPR6QX5pnqM/giphy.gif",
];
const CATEGORIES = [
{ id: "frequent", label: "Frequently used", icon: "+" },
{ id: "smileys", label: "Smileys & people", icon: "☺" },
{ id: "animals", label: "Animals & nature", icon: "♞" },
{ id: "food", label: "Food & drink", icon: "◉" },
{ id: "travel", label: "Travel & places", icon: "⌂" },
{ id: "objects", label: "Objects", icon: "♢" },
{ id: "symbols", label: "Symbols", icon: "*" },
] as const;
type EmojiCategory = (typeof CATEGORIES)[number]["id"];
const FREQUENT = [
"👍",
"🎉",
"👀",
"🙏",
"💜",
"🔥",
"✅",
"🚀",
"💡",
"😂",
"😊",
"🤔",
];
/** UI-only composer picker: data is seeded until community and GIF capabilities arrive. */
export function EmojiPicker({
disabled,
onInsert,
}: {
disabled?: boolean;
onInsert: (emoji: string) => void;
}) {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<PickerTab>("emoji");
const [query, setQuery] = useState("");
const [category, setCategory] = useState<EmojiCategory>("frequent");
const [preview, setPreview] = useState<{
emoji: string;
name: string;
src?: string;
} | null>(null);
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const input = useRef<HTMLInputElement>(null);
useEffect(
() => () => {
if (previewTimer.current) clearTimeout(previewTimer.current);
},
[],
);
function previewAfterPause(value: {
emoji: string;
name: string;
src?: string;
}) {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = setTimeout(() => setPreview(value), 450);
}
function clearPreview() {
if (previewTimer.current) clearTimeout(previewTimer.current);
previewTimer.current = null;
setPreview(null);
}
const matches = useMemo(() => {
const term = query.trim().toLowerCase();
if (!term && category === "frequent")
return FREQUENT.map((emoji) => ({ emoji, name: "Emoji name" }));
const emojis = (
data as unknown as {
emojis: Record<
string,
{ name: string; skins?: { native: string }[]; keywords?: string[] }
>;
}
).emojis;
return Object.values(emojis)
.filter((emoji) => {
const keywords = [emoji.name, ...(emoji.keywords ?? [])];
return term ? keywords.some((value) => value.includes(term)) : true;
})
.slice(0, 42)
.map((emoji) => ({
emoji: emoji.skins?.[0]?.native ?? "",
name: emoji.name,
}))
.filter((emoji) => Boolean(emoji.emoji));
}, [category, query]);
const custom = CUSTOM.filter((emoji) =>
emoji.name.includes(query.trim().toLowerCase()),
);
const standard = matches;
function choose(value: string) {
onInsert(value);
setOpen(false);
setQuery("");
}
return (
<BasePopover.Root
modal={false}
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery("");
}}
>
<BasePopover.Trigger
render={
<IconButton
aria-label="Choose emoji or GIF"
disabled={disabled}
icon={<IconMoodSmile size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
onMouseDown={(event) => event.preventDefault()}
/>
}
/>
<BasePopover.Portal>
<BasePopover.Positioner side="top" align="start" sideOffset={8}>
<BasePopover.Popup
className="composer-expression-picker"
initialFocus={false}
>
<Tabs
label="Expression type"
value={tab}
onValueChange={setTab}
variant="panel"
items={[
{
value: "emoji",
label: "Emoji",
icon: <IconMoodSmile size={16} />,
},
{ value: "gifs", label: "GIFs", icon: <IconPhoto size={16} /> },
]}
/>
{tab === "emoji" ? (
<div className="composer-emoji-content">
<label className="composer-expression-search">
<IconSearch size={16} aria-hidden="true" />
<input
ref={input}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search emoji"
aria-label="Search emoji"
/>
<IconButton
aria-label="Clear emoji search"
icon={<IconX size={14} aria-hidden="true" />}
size="compact"
variant="ghost"
disabled={!query}
onClick={() => {
setQuery("");
input.current?.focus();
}}
/>
</label>
{!query ? (
<div
className="composer-emoji-categories"
role="tablist"
aria-label="Emoji categories"
>
{CATEGORIES.map((item) => (
<button
key={item.id}
role="tab"
aria-selected={category === item.id}
aria-label={item.label}
type="button"
onClick={() => setCategory(item.id)}
>
{item.icon}
</button>
))}
</div>
) : null}
{custom.length ? (
<section>
<h3 className="text-body-sm text-tertiary">Custom</h3>
<div className="composer-emoji-grid">
{custom.map((emoji) => (
<button
key={emoji.name}
type="button"
className="composer-emoji-choice"
aria-label={`Insert :${emoji.name}:`}
onClick={() => choose(`:${emoji.name}:`)}
onPointerEnter={() =>
previewAfterPause({
emoji: `:${emoji.name}:`,
name: emoji.name,
src: emoji.src,
})
}
onPointerLeave={clearPreview}
onFocus={() =>
previewAfterPause({
emoji: `:${emoji.name}:`,
name: emoji.name,
src: emoji.src,
})
}
onBlur={clearPreview}
>
<img src={emoji.src} alt="" />
</button>
))}
</div>
</section>
) : null}
<section>
<h3 className="text-body-sm text-tertiary">
{query
? "Results"
: CATEGORIES.find((item) => item.id === category)?.label}
</h3>
<div className="composer-emoji-grid">
{standard.map(({ emoji, name }) => (
<button
key={emoji}
type="button"
className="composer-emoji-choice"
aria-label={`Insert ${emoji}`}
onClick={() => choose(emoji)}
onPointerEnter={() =>
previewAfterPause({ emoji, name })
}
onPointerLeave={clearPreview}
onFocus={() => previewAfterPause({ emoji, name })}
onBlur={clearPreview}
>
{emoji}
</button>
))}
</div>
</section>
<div className="composer-emoji-preview" aria-live="polite">
{preview ? (
<>
{preview.src ? (
<img src={preview.src} alt="" />
) : (
<span
className="composer-emoji-preview-glyph"
aria-hidden="true"
>
{preview.emoji}
</span>
)}
<div>
<strong className="text-body-lg text-primary">
{preview.name}
</strong>
</div>
</>
) : null}
</div>
</div>
) : (
<div className="composer-gif-content">
<label className="composer-expression-search">
<IconSearch size={16} aria-hidden="true" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search GIFs"
aria-label="Search GIFs"
/>
</label>
<div className="composer-gif-grid">
{GIFS.map((src, index) => (
<button
key={src}
type="button"
aria-label={`Choose GIF ${index + 1}`}
onClick={() => setOpen(false)}
>
<img src={src} alt="" />
</button>
))}
</div>
<p className="text-body-sm text-tertiary">
GIF search is a design preview.
</p>
</div>
)}
</BasePopover.Popup>
</BasePopover.Positioner>
</BasePopover.Portal>
</BasePopover.Root>
);
}

View File

@@ -0,0 +1,421 @@
import { ChipNode } from "../chipNode";
import { Select } from "@/shared/ui/Select";
import {
IconArrowUp,
IconLetterCase,
IconMicrophone,
IconPaperclip,
IconPlayerStopFilled,
} from "@tabler/icons-react";
import { EditorContent, useEditor } from "@tiptap/react";
import Link from "@tiptap/extension-link";
import { Extension } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown as TiptapMarkdown } from "tiptap-markdown";
import { type FormEvent, useEffect, useId, useRef, useState } from "react";
import { IconButton } from "@/shared/ui/IconButton";
import {
ComposerFormattingBar,
type ComposerFormat,
} from "./ComposerFormattingBar";
import { ComposerLinkDialog } from "./ComposerLinkDialog";
import { EmojiPicker } from "./EmojiPicker";
const FORMATS: readonly ComposerFormat[] = [
"bold",
"italic",
"strike",
"code",
"quote",
"bullet",
"number",
];
function markdown(editor: NonNullable<ReturnType<typeof useEditor>>) {
// tiptap-markdown has no public TypeScript storage shape.
const storage = editor.storage as unknown as {
markdown?: { getMarkdown?: () => string };
};
const value = storage.markdown?.getMarkdown?.() ?? editor.getText();
return value.replace(/\\\n/g, "\n");
}
/** The first production Composer composition, with Markdown-compatible formatting. */
export function MessageComposer({
disabled,
draft,
onDraftChange,
onSend,
placeholder = "Message the session",
responseControl,
autoFocus = true,
defaultFormattingOpen = false,
recipients = [],
onDelivered,
}: {
onDelivered?: (content: string) => void;
recipients?: readonly { pubkey: string; name: string; isAgent: boolean }[];
autoFocus?: boolean;
defaultFormattingOpen?: boolean;
disabled?: boolean;
draft: string;
onDraftChange: (value: string) => void;
onSend: (content: string, recipients?: string[]) => Promise<void>;
placeholder?: string;
responseControl?: { state: "responding" | "stopping"; onStop?: () => void };
}) {
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formattingOpen, setFormattingOpen] = useState(defaultFormattingOpen);
const [formattingLeaving, setFormattingLeaving] = useState(false);
const [linkOpen, setLinkOpen] = useState(false);
const [, setEditorVersion] = useState(0);
const errorId = useId();
const applyingContent = useRef(false);
const submitting = useRef(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const formattingSelection = useRef<{ from: number; to: number } | null>(null);
const latest = useRef({ draft, onDraftChange, onSend, disabled, sending });
const submitRef = useRef<() => void>(() => {});
latest.current = { draft, onDraftChange, onSend, disabled, sending };
const editor = useEditor({
extensions: [
ChipNode,
StarterKit.configure({
hardBreak: { keepMarks: true },
heading: false,
link: false,
trailingNode: false,
}),
Extension.create({
name: "submitOnEnter",
addKeyboardShortcuts() {
return {
Enter: () => {
submitRef.current();
return true;
},
};
},
}),
Link.configure({
openOnClick: false,
autolink: true,
linkOnPaste: false,
}),
Placeholder.configure({ placeholder }),
TiptapMarkdown.configure({
html: false,
breaks: true,
transformCopiedText: true,
transformPastedText: true,
}),
],
content: draft,
editable: !disabled,
onCreate: ({ editor: current }) => {
current.view.dom.setAttribute("role", "textbox");
current.view.dom.setAttribute("aria-multiline", "true");
},
editorProps: {
attributes: {
class: "message-composer-editor",
"aria-label": placeholder,
},
},
onUpdate: ({ editor: current }) => {
if (applyingContent.current) return;
setError(null);
latest.current.onDraftChange(markdown(current));
},
});
useEffect(() => {
if (!editor) return;
const update = () => setEditorVersion((version) => version + 1);
editor.on("transaction", update);
return () => {
editor.off("transaction", update);
};
}, [editor]);
useEffect(() => {
if (autoFocus) editor?.commands.focus("end");
}, [autoFocus, editor]);
useEffect(() => {
editor?.setEditable(!disabled);
}, [disabled, editor]);
useEffect(() => {
if (!editor || markdown(editor) === draft) return;
applyingContent.current = true;
editor.commands.setContent(draft, { emitUpdate: false });
applyingContent.current = false;
}, [draft, editor]);
async function submit(event?: FormEvent) {
event?.preventDefault();
const content = editor ? markdown(editor).trim() : draft.trim();
if (!content || submitting.current || disabled) return;
submitting.current = true;
setSending(true);
setError(null);
try {
const selectedKeys: string[] = [];
editor?.state.doc.descendants((node) => {
if (
node.type.name === "chip" &&
["agent", "person"].includes(node.attrs.kind)
)
selectedKeys.push(node.attrs.id);
});
await onSend(content, selectedKeys);
onDelivered?.(content);
if (mounted.current && latest.current.draft.trim() === content) {
if (!onDelivered) onDraftChange("");
if (mounted.current) editor?.commands.clearContent(true);
}
} catch (caught) {
if (mounted.current)
setError(
caught instanceof Error
? caught.message
: "Your message was not sent.",
);
} finally {
submitting.current = false;
if (mounted.current) {
setSending(false);
editor?.commands.focus("end");
}
}
}
submitRef.current = () => {
void submit();
};
function closeFormatting() {
setFormattingLeaving(true);
}
function insertEmoji(emoji: string) {
editor?.chain().focus().insertContent(emoji).run();
}
function rememberFormattingSelection() {
if (!editor || editor.state.selection.empty) return;
formattingSelection.current = {
from: editor.state.selection.from,
to: editor.state.selection.to,
};
}
function format(format: ComposerFormat) {
if (format === "link") {
setLinkOpen(true);
return;
}
const chain = editor?.chain();
if (!chain) return;
const range = formattingSelection.current;
formattingSelection.current = null;
if (range) chain.setTextSelection(range);
if (format === "bold") chain.toggleBold().run();
if (format === "italic") chain.toggleItalic().run();
if (format === "strike") chain.toggleStrike().run();
if (format === "code") chain.toggleCode().run();
if (format === "quote") chain.toggleBlockquote().run();
if (format === "bullet") chain.toggleBulletList().run();
if (format === "number") chain.toggleOrderedList().run();
}
function insertLink({ label, url }: { label: string; url: string }) {
editor
?.chain()
.focus()
.extendMarkRange("link")
.setLink({ href: url })
.insertContent(label)
.run();
}
const unavailable = Boolean(disabled || sending);
const canSend = Boolean(draft.trim()) && !unavailable;
const state = disabled
? "disabled"
: sending
? "sending"
: error
? "failed"
: "ready";
const activeFormats = FORMATS.filter((format) => {
const names: Record<Exclude<ComposerFormat, "link">, string> = {
bold: "bold",
italic: "italic",
strike: "strike",
code: "code",
quote: "blockquote",
bullet: "bulletList",
number: "orderedList",
};
return format !== "link" && editor?.isActive(names[format]);
});
return (
<>
<form className="message-composer" data-state={state} onSubmit={submit}>
{recipients.length ? (
<Select
label="Address"
value=""
onValueChange={(pubkey) => {
const person = recipients.find(
(candidate) => candidate.pubkey === pubkey,
);
if (!person || unavailable) return;
editor
?.chain()
.focus()
.insertContent([
{
type: "chip",
attrs: {
kind: person.isAgent ? "agent" : "person",
id: pubkey,
},
},
{ type: "text", text: " " },
])
.run();
}}
groups={[
{
label: "People and agents in this Session",
options: recipients.map((person) => ({
value: person.pubkey,
label: person.name,
})),
},
]}
/>
) : null}
<div className="message-composer-input">
<EditorContent editor={editor} />
{error ? (
<p
id={errorId}
className="message-composer-feedback text-body-sm text-red-12"
role="alert"
>
{error} Your message is still here — try again.
</p>
) : sending ? (
<p
className="message-composer-feedback text-body-sm text-secondary"
role="status"
>
Sending message…
</p>
) : null}
</div>
<div className="message-composer-actions">
<div className="composer-toolbar-presence">
{formattingOpen ? (
<ComposerFormattingBar
activeFormats={activeFormats}
onCommandPointerDown={rememberFormattingSelection}
disabled={unavailable}
onFormat={format}
onClose={closeFormatting}
className={
formattingLeaving
? "composer-toolbar-exit"
: "composer-toolbar-enter"
}
onAnimationEnd={() => {
if (!formattingLeaving) return;
setFormattingOpen(false);
setFormattingLeaving(false);
editor?.commands.focus();
}}
/>
) : (
<div className="message-composer-actions-start composer-toolbar-enter">
<IconButton
aria-label="Attach file"
disabled={unavailable}
icon={
<IconPaperclip size={16} stroke={2} aria-hidden="true" />
}
size="toolbar"
variant="ghost"
/>
<EmojiPicker disabled={unavailable} onInsert={insertEmoji} />
<IconButton
aria-label="Toggle formatting"
aria-expanded={formattingOpen}
disabled={unavailable}
onMouseDown={rememberFormattingSelection}
onClick={() => {
setFormattingLeaving(false);
setFormattingOpen(true);
}}
icon={
<IconLetterCase size={16} stroke={2} aria-hidden="true" />
}
size="toolbar"
variant="ghost"
/>
</div>
)}
</div>
<div className="message-composer-actions-end">
<IconButton
aria-label="Record voice note"
disabled={unavailable}
icon={<IconMicrophone size={16} stroke={2} aria-hidden="true" />}
size="toolbar"
variant="ghost"
/>
{responseControl ? (
<IconButton
aria-label={
responseControl.state === "stopping"
? "Stopping response"
: "Stop response"
}
aria-busy={responseControl.state === "stopping" || undefined}
icon={<IconPlayerStopFilled size={14} aria-hidden="true" />}
shape="round"
size="toolbar"
variant="tint"
disabled={responseControl.state === "stopping"}
onClick={responseControl.onStop}
/>
) : (
<IconButton
type="submit"
aria-label={sending ? "Sending message" : "Send message"}
icon={<IconArrowUp size={16} stroke={2} aria-hidden="true" />}
shape="round"
size="toolbar"
variant="tint"
disabled={!canSend}
/>
)}
</div>
</div>
</form>
<ComposerLinkDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onSubmit={insertLink}
/>
</>
);
}

View File

@@ -0,0 +1,57 @@
import { useCallback, useState } from "react";
export type ComposerDrafts = {
readDraft: (destinationId: string) => string;
writeDraft: (destinationId: string, value: string) => void;
clearDraft: (destinationId: string) => void;
settleDraft: (destinationId: string, sentContent: string) => void;
};
/**
* Owns drafts by product destination rather than by a mounted text field.
*
* The AppShell creates this once for the current application composition, so a
* conversation can unmount while its draft remains ready when the person
* returns. Persisting across an application restart is a later product
* decision; this controller deliberately does not invent it.
*/
export function useComposerDrafts(): ComposerDrafts {
const [drafts, setDrafts] = useState<Map<string, string>>(() => new Map());
const readDraft = useCallback(
(destinationId: string) => drafts.get(destinationId) ?? "",
[drafts],
);
const writeDraft = useCallback((destinationId: string, value: string) => {
setDrafts((current) => {
const next = new Map(current);
if (value) next.set(destinationId, value);
else next.delete(destinationId);
return next;
});
}, []);
const clearDraft = useCallback((destinationId: string) => {
setDrafts((current) => {
if (!current.has(destinationId)) return current;
const next = new Map(current);
next.delete(destinationId);
return next;
});
}, []);
const settleDraft = useCallback(
(destinationId: string, sentContent: string) => {
setDrafts((current) => {
if (current.get(destinationId)?.trim() !== sentContent.trim())
return current;
const next = new Map(current);
next.delete(destinationId);
return next;
});
},
[],
);
return { readDraft, writeDraft, clearDraft, settleDraft };
}

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { mergeMessages, projectChannelWindow } from "./channelWindow";
const event = (
kind: number,
id: string,
content = "content",
createdAt = 100,
) => ({
id,
pubkey: "author",
content,
created_at: createdAt,
kind,
tags:
kind === 39006
? [
["h", "channel"],
["d", "channel:head"],
]
: [["h", "channel"]],
});
describe("projectChannelWindow", () => {
it("keeps protocol overlays and system events out of the transcript", () => {
const projection = projectChannelWindow(
[
event(40099, "system", '{"type":"channel_created"}'),
event(9, "message", "Hello"),
event(39005, "summary", '{"reply_count":2}'),
event(39006, "bounds", '{"has_more":false,"next_cursor":null}'),
],
{ channelId: "channel" },
);
expect(projection.messages.map(({ id }) => id)).toEqual(["message"]);
expect(projection.systemEvents.map(({ id }) => id)).toEqual(["system"]);
expect(projection.threadSummaries.map(({ id }) => id)).toEqual(["summary"]);
expect(projection.bounds).toEqual({ hasMore: false, nextCursor: null });
});
it("rejects ambiguous or internally inconsistent bounds", () => {
expect(() =>
projectChannelWindow(
[
event(39006, "one", '{"has_more":false,"next_cursor":null}'),
event(39006, "two", '{"has_more":false,"next_cursor":null}'),
],
{ channelId: "channel" },
),
).toThrow("more than one bounds");
expect(() =>
projectChannelWindow(
[event(39006, "bounds", '{"has_more":true,"next_cursor":null}')],
{ channelId: "channel" },
),
).toThrow("require next_cursor");
expect(() =>
projectChannelWindow([event(9, "message")], { channelId: "channel" }),
).toThrow("missing bounds");
});
it("uses the event ID as the same-second ordering tie-breaker", () => {
const projection = projectChannelWindow(
[
event(9, "b", "second", 100),
event(9, "a", "first", 100),
event(39006, "bounds", '{"has_more":false,"next_cursor":null}'),
],
{ channelId: "channel" },
);
expect(projection.messages.map(({ id }) => id)).toEqual(["a", "b"]);
});
});
describe("mergeMessages", () => {
it("deduplicates the live overlay and keeps total relay order", () => {
const result = mergeMessages(
[
{ ...event(9, "b", "b", 100), createdAt: 100 },
{ ...event(9, "c", "c", 101), createdAt: 101 },
],
[
{ ...event(9, "a", "a", 100), createdAt: 100 },
{ ...event(9, "c", "fresh c", 101), createdAt: 101 },
],
);
expect(result.map(({ id, content }) => [id, content])).toEqual([
["a", "a"],
["b", "b"],
["c", "fresh c"],
]);
});
});

View File

@@ -0,0 +1,128 @@
import type { Message } from "@/features/sessions/types";
import type {
ChannelWindowCursor,
ChannelWindowRequest,
RawChannelEvent,
} from "@/shared/runtime/channelWindow";
export const CHANNEL_MESSAGE_KINDS = new Set([9, 40002, 40008]);
export const CHANNEL_SYSTEM_KIND = 40099;
export const THREAD_SUMMARY_KIND = 39005;
export const WINDOW_BOUNDS_KIND = 39006;
export type ChannelWindowBounds = {
hasMore: boolean;
nextCursor: ChannelWindowCursor | null;
};
export type ConversationWindowRequest = ChannelWindowRequest;
export type ChannelWindow = {
messages: Message[];
systemEvents: RawChannelEvent[];
threadSummaries: RawChannelEvent[];
bounds: ChannelWindowBounds | null;
};
function compareMessages(left: Message, right: Message) {
return left.createdAt - right.createdAt || left.id.localeCompare(right.id);
}
function expectedBoundsKey({ channelId, cursor }: ConversationWindowRequest) {
return `${channelId}:${cursor ? `${cursor.createdAt}:${cursor.id}` : "head"}`;
}
function parseBounds(
event: RawChannelEvent,
request: ConversationWindowRequest,
): ChannelWindowBounds {
const requestKey = event.tags.find(([name]) => name === "d")?.[1];
if (requestKey !== expectedBoundsKey(request)) {
throw new Error("Channel window bounds do not match their request.");
}
let content: unknown;
try {
content = JSON.parse(event.content);
} catch {
throw new Error("Channel window bounds are not valid JSON.");
}
if (typeof content !== "object" || content === null) {
throw new Error("Channel window bounds are not an object.");
}
const { has_more: hasMore, next_cursor: nextCursor } = content as Record<
string,
unknown
>;
if (typeof hasMore !== "boolean") {
throw new Error("Channel window bounds are missing has_more.");
}
if (nextCursor === null) {
if (hasMore) throw new Error("Channel window bounds require next_cursor.");
return { hasMore, nextCursor: null };
}
if (typeof nextCursor !== "object" || nextCursor === null) {
throw new Error("Channel window next_cursor is invalid.");
}
const { created_at: createdAt, id } = nextCursor as Record<string, unknown>;
if (
typeof createdAt !== "number" ||
!Number.isInteger(createdAt) ||
typeof id !== "string"
) {
throw new Error("Channel window next_cursor is invalid.");
}
if (!hasMore)
throw new Error("Channel window bounds must not have next_cursor.");
return { hasMore, nextCursor: { createdAt, id } };
}
/**
* Partitions the relay's channel-window response before any ordering logic.
* Bounds and thread summaries describe rows; they never become rows themselves.
*/
export function projectChannelWindow(
events: RawChannelEvent[],
request: ConversationWindowRequest,
): ChannelWindow {
const messages: Message[] = [];
const systemEvents: RawChannelEvent[] = [];
const threadSummaries: RawChannelEvent[] = [];
let bounds: ChannelWindowBounds | null = null;
for (const event of events) {
if (CHANNEL_MESSAGE_KINDS.has(event.kind)) {
messages.push({
id: event.id,
pubkey: event.pubkey,
content: event.content,
createdAt: event.created_at,
kind: event.kind,
tags: event.tags,
});
} else if (event.kind === CHANNEL_SYSTEM_KIND) {
systemEvents.push(event);
} else if (event.kind === THREAD_SUMMARY_KIND) {
threadSummaries.push(event);
} else if (event.kind === WINDOW_BOUNDS_KIND) {
if (bounds)
throw new Error("Channel window returned more than one bounds event.");
bounds = parseBounds(event, request);
}
}
if (!bounds) {
// An accessible channel window always carries exactly one bounds overlay.
// Without it the client cannot honestly know whether history is exhausted,
// so it must not turn a malformed/degraded response into a plausible page.
throw new Error("Channel window response is missing bounds.");
}
messages.sort(compareMessages);
return { messages, systemEvents, threadSummaries, bounds };
}
export function mergeMessages(history: Message[], live: Message[]): Message[] {
const byId = new Map(history.map((message) => [message.id, message]));
for (const message of live) byId.set(message.id, message);
return [...byId.values()].sort(compareMessages);
}

View File

@@ -0,0 +1,54 @@
import type { AgentTurn } from "@/features/agent-activity/types";
import { AgentActivityRail } from "@/features/agent-activity/ui/AgentActivityRail";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
/** Content-sized authoring dock. Activity fades into reserved space without resizing the conversation. */
export function ConversationComposerDock({
draft,
onDraftChange,
onSend,
turns,
placeholder,
responseControl,
autoFocus,
recipients,
onDelivered,
}: {
onDelivered?: (content: string) => void;
recipients?: readonly { pubkey: string; name: string; isAgent: boolean }[];
autoFocus?: boolean;
draft: string;
onDraftChange: (value: string) => void;
onSend: (content: string, recipients?: string[]) => Promise<void>;
turns: AgentTurn[];
placeholder?: string;
responseControl?: {
state: "responding" | "stopping";
onStop?: () => void;
};
}) {
const workingTurns = turns.filter(
(turn) => turn.status === "pending" || turn.status === "running",
);
return (
<div
className="conversation-composer-dock"
data-activity={workingTurns.length > 0 || undefined}
>
<MessageComposer
autoFocus={autoFocus}
recipients={recipients}
onDelivered={onDelivered}
draft={draft}
onDraftChange={onDraftChange}
onSend={onSend}
placeholder={placeholder}
responseControl={responseControl}
/>
<div className="conversation-composer-activity-slot text-body-sm">
<AgentActivityRail turns={workingTurns} />
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import "./conversation.css";
import type { ReactNode } from "react";
import { Avatar } from "@/shared/ui/Avatar";
import { Button } from "@/shared/ui/Button";
/** Resolved shared message presentation; no name lookup, sending or persistence. */
export type ConversationMessagePresentation = {
id: string;
author: {
name: string;
fallback: string;
avatar?: string;
kind: "human" | "agent";
};
time: string;
dateTime: string;
content: ReactNode;
reply?: { author: string; excerpt: string; onOpen: () => void };
delivery?: { label: string; failed?: boolean; onRetry?: () => void };
actions?: ReactNode;
};
/** A shared contribution stays readable independently of private agent work. */
export function ConversationMessage({
message,
}: {
message: ConversationMessagePresentation;
}) {
return (
<article
className="conversation-message"
aria-label={`${message.author.name}, ${message.time}`}
data-message-id={message.id}
>
<span aria-hidden="true">
<Avatar
src={message.author.avatar}
alt=""
fallback={message.author.fallback}
/>
</span>
<div className="conversation-message-body">
<header className="conversation-message-heading">
<span className="text-body font-semibold text-primary">
{message.author.name}
</span>
{message.author.kind === "agent" ? (
<span className="text-body-sm text-tertiary">Agent</span>
) : null}
<time
className="text-body-sm text-tertiary"
dateTime={message.dateTime}
>
{message.time}
</time>
</header>
{message.reply ? (
<div className="conversation-reply">
<Button
variant="ghost"
size="compact"
onClick={message.reply.onOpen}
>
Replying to {message.reply.author}: {message.reply.excerpt}
</Button>
</div>
) : null}
<div className="conversation-message-content text-body text-primary">
{message.content}
</div>
{message.delivery ? (
<div
className="conversation-delivery text-body-sm"
data-failed={message.delivery.failed || undefined}
role="status"
>
<span>{message.delivery.label}</span>
{message.delivery.onRetry ? (
<Button
size="compact"
variant="ghost"
onClick={message.delivery.onRetry}
>
Retry send
</Button>
) : null}
</div>
) : null}
{message.actions ? (
<div className="conversation-message-actions">{message.actions}</div>
) : null}
</div>
</article>
);
}

View File

@@ -0,0 +1,50 @@
import "./conversation.css";
import { IconAlertCircle, IconMessageCircle } from "@tabler/icons-react";
import { Button } from "@/shared/ui/Button";
import { ShimmerText } from "@/shared/ui/ShimmerText";
/** Empty, loading, and failed reads are distinct; actions belong to the caller. */
export function ConversationState({
state,
onRetry,
}: {
state: "loading" | "empty" | "failed" | "read-only";
onRetry?: () => void;
}) {
const copy = {
loading: ["Loading conversation", "Your messages will appear here."],
empty: [
"A place to work things through",
"Send a message and bring an agent into the conversation.",
],
failed: [
"Couldn’t load this conversation",
"Try again to load the messages.",
],
"read-only": [
"You can read this conversation",
"You don’t have permission to send messages here.",
],
}[state];
return (
<div
className="conversation-state"
role={state === "failed" ? "alert" : "status"}
>
{state === "failed" ? (
<IconAlertCircle size={20} aria-hidden="true" />
) : (
<IconMessageCircle size={20} aria-hidden="true" />
)}
<p className="text-body text-primary">
{state === "loading" ? <ShimmerText>{copy[0]}</ShimmerText> : copy[0]}
</p>
<p className="text-body-sm text-secondary">{copy[1]}</p>
{state === "failed" && onRetry ? (
<Button size="compact" onClick={onRetry}>
Retry loading
</Button>
) : null}
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
import { useEffect } from "react";
import { ChipNode } from "@/features/composer/chipNode";
/** Safe formatted content for the local conversation loop; raw HTML is disabled. */
export function MessageContent({ content }: { content: string }) {
const editor = useEditor({
extensions: [
StarterKit.configure({
link: {
openOnClick: true,
protocols: ["buzz"],
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
},
}),
ChipNode,
Markdown.configure({ html: false, breaks: true }),
],
content,
editable: false,
editorProps: { attributes: { "aria-label": "Message content" } },
});
useEffect(() => {
editor?.commands.setContent(content, { emitUpdate: false });
}, [editor, content]);
return <EditorContent editor={editor} />;
}

View File

@@ -0,0 +1,32 @@
import { AgentWorkArea } from "@/features/agent-activity/ui/AgentWorkArea";
import type { AgentWorkPresentation } from "@/features/agent-activity/ui/workPresentation";
import {
ConversationMessage,
type ConversationMessagePresentation,
} from "./ConversationMessage";
/** One prompt, stable peer-agent areas, then shared answers in supplied order. */
export function SessionExchange({
prompt,
work,
replies,
}: {
prompt: ConversationMessagePresentation;
/** Ordered by the activity owner; never sorted by latest activity in the view. */
work: readonly AgentWorkPresentation[];
replies: readonly ConversationMessagePresentation[];
}) {
return (
<section className="session-exchange" aria-label="Session exchange">
<ConversationMessage message={prompt} />
<div className="session-exchange-work">
{work.map((area) => (
<AgentWorkArea key={area.id} work={area} />
))}
</div>
{replies.map((message) => (
<ConversationMessage key={message.id} message={message} />
))}
</section>
);
}

View File

@@ -0,0 +1,83 @@
@layer components {
.session-exchange {
display: grid;
gap: var(--space-6);
min-width: 0;
}
.conversation-message {
display: flex;
align-items: flex-start;
gap: var(--space-3);
min-width: 0;
}
.conversation-message-body {
flex: 1;
min-width: 0;
}
.conversation-message-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.conversation-message-content {
overflow-wrap: anywhere;
}
.conversation-message-content :where(p, ul) {
margin: 0;
}
.conversation-message-content :where(p, ul) + :where(p, ul) {
margin-top: var(--space-3);
}
.conversation-message-content ul {
list-style: disc;
padding-inline-start: var(--space-5);
}
.conversation-message-content li + li {
margin-top: var(--space-1);
}
.conversation-message-content strong {
font-weight: 600;
}
.conversation-message-actions,
.conversation-delivery {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.conversation-delivery {
color: var(--text-secondary);
}
.conversation-delivery[data-failed] {
color: var(--red-12);
}
.conversation-reply {
margin-bottom: var(--space-2);
overflow-wrap: anywhere;
}
.session-exchange-work {
display: grid;
gap: var(--space-5);
padding-inline-start: calc(var(--space-6) + var(--space-3));
min-width: 0;
}
.conversation-state {
display: grid;
justify-items: center;
text-align: center;
gap: var(--space-3);
padding: var(--space-6);
color: var(--text-secondary);
}
.conversation-state p {
margin: 0;
}
@media (max-width: 40rem) {
.session-exchange-work {
padding-inline-start: var(--space-3);
}
}
}

View File

@@ -0,0 +1,203 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
Channel,
Identity,
Message,
Participant,
} from "@/features/sessions/types";
import { runtime } from "@/shared/runtime/client";
import { mergeMessages, projectChannelWindow } from "./channelWindow";
type SendOperation = {
localId: string;
content: string;
message: Message;
};
function messageError(caught: unknown) {
return caught instanceof Error ? caught.message : String(caught);
}
/**
* The behavioral owner of one shared conversation.
*
* History is fetched through the relay's window contract. Live delivery is a
* separate overlay, reconciled with a fresh head read after subscription setup
* settles so the interval between the two cannot lose a message.
*/
export function useConversation(channel: Channel, identity: Identity) {
const generation = useRef(0);
const [history, setHistory] = useState<Message[]>([]);
const [live, setLive] = useState<Message[]>([]);
const [participants, setParticipants] = useState<Participant[]>([]);
const [operations, setOperations] = useState<SendOperation[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const loadHead = useCallback(
async (expectedGeneration: number) => {
const [window, nextParticipants] = await Promise.all([
runtime
.channelWindow({ channelId: channel.id })
.then((events) =>
projectChannelWindow(events, { channelId: channel.id }),
),
runtime.participants(channel.id),
]);
if (generation.current !== expectedGeneration) return;
setHistory(window.messages);
setParticipants(nextParticipants);
setError(null);
},
[channel.id],
);
const refresh = useCallback(async () => {
const expectedGeneration = generation.current;
setLoading(true);
try {
await loadHead(expectedGeneration);
} catch (caught) {
if (generation.current === expectedGeneration)
setError(messageError(caught));
} finally {
if (generation.current === expectedGeneration) setLoading(false);
}
}, [loadHead]);
useEffect(() => {
const expectedGeneration = generation.current + 1;
generation.current = expectedGeneration;
let disposed = false;
let stop: (() => void) | undefined;
setHistory([]);
setLive([]);
setParticipants([]);
setOperations([]);
setError(null);
setLoading(true);
const settleInitialLoad = async () => {
try {
await loadHead(expectedGeneration);
} catch (caught) {
if (!disposed && generation.current === expectedGeneration) {
setError(messageError(caught));
}
} finally {
if (!disposed && generation.current === expectedGeneration) {
setLoading(false);
}
}
};
void settleInitialLoad();
void runtime
.subscribeMessages(channel.id, (incoming) => {
if (disposed || generation.current !== expectedGeneration) return;
setLive((current) => mergeMessages(current, [incoming]));
})
.then(
(cleanup) => {
if (disposed) {
cleanup();
return;
}
stop = cleanup;
// The live subscription begins at now. Re-read the head after it is
// armed to close the history/subscription establishment interval.
return loadHead(expectedGeneration).catch((caught) => {
if (!disposed && generation.current === expectedGeneration) {
setError(`Live updates unavailable: ${messageError(caught)}`);
}
});
},
(caught) => {
if (!disposed && generation.current === expectedGeneration) {
setError(`Live updates unavailable: ${messageError(caught)}`);
}
// The head is still authoritative when the live socket is not.
return loadHead(expectedGeneration).catch(() => undefined);
},
);
return () => {
disposed = true;
generation.current += 1;
stop?.();
};
}, [channel.id, loadHead]);
const messages = useMemo(() => {
const accepted = mergeMessages(history, live);
const pending = operations.map(({ message }) => message);
return [...accepted, ...pending].sort(
(left, right) =>
left.createdAt - right.createdAt || left.id.localeCompare(right.id),
);
}, [history, live, operations]);
const send = useCallback(
async (content: string, retryLocalId?: string) => {
const expectedGeneration = generation.current;
const existing = retryLocalId
? operations.find((operation) => operation.localId === retryLocalId)
: undefined;
const localId = existing?.localId ?? `pending-${crypto.randomUUID()}`;
const payload = existing?.content ?? content;
const pending: Message = {
id: localId,
pubkey: identity.pubkey,
content: payload,
createdAt: Math.floor(Date.now() / 1000),
kind: 9,
tags: [["h", channel.id]],
pending: navigator.onLine ? "sending" : "waiting",
};
setOperations((current) => [
...current.filter((operation) => operation.localId !== localId),
{ localId, content: payload, message: pending },
]);
try {
const accepted = await runtime.sendMessage(channel.id, payload);
if (generation.current !== expectedGeneration) return;
setOperations((current) =>
current.filter((operation) => operation.localId !== localId),
);
setLive((current) => mergeMessages(current, [accepted]));
} catch (caught) {
if (generation.current !== expectedGeneration) return;
setOperations((current) =>
current.map((operation) =>
operation.localId === localId
? {
...operation,
message: {
...operation.message,
pending: "failed",
error: messageError(caught),
},
}
: operation,
),
);
throw caught;
}
},
[channel.id, identity.pubkey, operations],
);
const retrySend = useCallback(
async (localId: string) => {
const operation = operations.find(
(current) => current.localId === localId,
);
if (operation) await send(operation.content, localId);
},
[operations, send],
);
return { messages, participants, loading, error, refresh, send, retrySend };
}

View File

@@ -0,0 +1,37 @@
import { Link } from "@tanstack/react-router";
import { baseUiBackingSentence } from "./baseUiBackingSentence";
/** Inline inheritance note; links point to the actual owning library. */
export function BaseUiBackingLine({ slug }: { slug: string }) {
const segments = baseUiBackingSentence(slug);
if (!segments.length) return null;
return (
<>
{" "}
{segments.map((segment, index) =>
segment.kind === "text" ? (
// biome-ignore lint/suspicious/noArrayIndexKey: static sentence segments retain their order
<span key={index}>{segment.value}</span>
) : segment.external ? (
<a
key={segment.href}
href={segment.href}
target="_blank"
rel="noreferrer"
className="text-purple-12 underline decoration-1 underline-offset-2"
>
{segment.value}
</a>
) : (
<Link
key={segment.href}
to={segment.href}
className="text-purple-12 underline decoration-1 underline-offset-2"
>
{segment.value}
</Link>
),
)}
</>
);
}

View File

@@ -0,0 +1,253 @@
import { Link } from "@tanstack/react-router";
import {
BASE_UI_DOCS_ROOT,
type BaseUiPart,
baseUiDocsUrl,
COMPONENTS,
type ComponentDefinition,
resolveBaseUiBacking,
} from "@/shared/ui/registry";
import { Note, PageHeader, Section } from "./primitives";
/**
* What each component is built on, read from the registry rather than written
* down here — `src/shared/ui/registry.test.ts` binds those entries to the
* imports in the component files, so a component that gains or drops a Base UI
* part changes this page by failing that test first.
*
* A dash is an answer, not a gap. A header, a section, and a surface have no
* behaviour to inherit, so wrapping them in a Base UI part would add a
* dependency and buy nothing. The column worth watching is the third one: a
* component with no part of its own and no inherited part is one that had to
* author its own keyboard, focus, and assistive semantics by hand.
*/
type Backing = ReturnType<typeof resolveBaseUiBacking>;
function DocsLink({ part }: { part: BaseUiPart }) {
return (
<a
href={baseUiDocsUrl(part)}
target="_blank"
rel="noreferrer"
className="text-purple-12 underline decoration-1 underline-offset-2"
>
{part.name}
</a>
);
}
/**
* The dash the question asks for, plus the word it means. A bare em dash is
* announced as punctuation or skipped entirely, so the cell would read as empty
* to a screen reader — which is the one reading where "no Base UI part" and "we
* forgot to fill this in" become indistinguishable.
*/
function Dash() {
return (
<>
<span aria-hidden="true" className="text-tertiary">
—
</span>
<span className="sr-only">None</span>
</>
);
}
function OwnCell({ backing }: { backing: Backing }) {
if (backing.own.length === 0) return <Dash />;
return (
<span className="flex flex-col gap-1">
{backing.own.map((part) => (
<code key={part.name} className="text-mono">
<DocsLink part={part} />
</code>
))}
</span>
);
}
function InheritedCell({ backing }: { backing: Backing }) {
if (backing.inherited.length === 0) return <Dash />;
return (
<span className="flex flex-col gap-1">
{backing.inherited.map(({ part, through }) => (
<span key={part.name} className="flex flex-col">
<code className="text-mono">
<DocsLink part={part} />
</code>
<span className="text-body-sm text-tertiary">via {through}</span>
</span>
))}
</span>
);
}
function BackingRow({
component,
backing,
}: {
component: ComponentDefinition;
backing: Backing;
}) {
return (
<tr className="border-primary border-b align-top">
<td className="py-3 pr-4">
{/* `break-words`: the column is a third of the width, and a long
single-word component name overflows that third at a narrow
viewport — without this it overlaps the next column. */}
<Link
to="/design/components/$component"
params={{ component: component.slug }}
className="break-words text-body text-primary hover:text-purple-12"
>
{component.name}
</Link>
</td>
<td className="py-3 pr-4 text-body">
<OwnCell backing={backing} />
</td>
<td className="py-3 text-body">
<InheritedCell backing={backing} />
</td>
</tr>
);
}
export function BaseUiPage() {
const rows = COMPONENTS.map((component) => ({
component,
backing: resolveBaseUiBacking(component.slug),
}));
const backed = rows.filter(
(row) => row.backing.own.length > 0 || row.backing.inherited.length > 0,
);
const native = rows.filter(
(row) => row.backing.own.length === 0 && row.backing.inherited.length === 0,
);
const parts = new Set(
rows.flatMap((row) => [
...row.backing.own.map((part) => part.name),
...row.backing.inherited.map((entry) => entry.part.name),
]),
);
return (
<>
<PageHeader
title="Base UI backing"
intro="Which components inherit behaviour from Base UI, and which author it themselves. Read from the component registry, which is bound to the imports in the component files — so a component that gains or drops a Base UI part cannot leave this page saying otherwise."
/>
<Section
title="Every component"
description="The second column is what the file imports itself. The third is what reaches it through a component it composes — IconButton imports nothing from Base UI, but it renders a Buzz Button, so Base UI's Button is still underneath it."
>
<table className="w-full table-fixed border-collapse text-left">
<caption className="sr-only">
Each Buzz component, the Base UI part it imports, and the part it
inherits through composition
</caption>
<colgroup>
<col className="w-2/5" />
<col className="w-[30%]" />
<col className="w-[30%]" />
</colgroup>
<thead>
<tr className="border-primary border-b">
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Component
</th>
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Base UI part
</th>
<th scope="col" className="py-2 text-body text-tertiary">
Inherited
</th>
</tr>
</thead>
<tbody>
{rows.map(({ component, backing }) => (
<BackingRow
key={component.slug}
component={component}
backing={backing}
/>
))}
</tbody>
</table>
</Section>
<Section
title="The count"
description="Stated so the balance is visible rather than counted by hand each time someone asks."
>
<dl className="flex flex-wrap gap-x-12 gap-y-4 rounded-xl bg-neutral-2 px-5 py-4">
<div className="flex flex-col gap-1">
<dt className="text-body-sm text-tertiary">Backed by Base UI</dt>
<dd className="text-body text-primary">
{backed.length} of {rows.length}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-body-sm text-tertiary">Native elements only</dt>
<dd className="text-body text-primary">
{native.length} of {rows.length}
</dd>
</div>
<div className="flex flex-col gap-1">
<dt className="text-body-sm text-tertiary">
Distinct parts in use
</dt>
<dd className="text-body text-primary">{parts.size}</dd>
</div>
</dl>
</Section>
<Section
title="Where a dash is the right answer"
description="These author their own markup, and that is the decision — not an omission. A wrapper around a native header buys a dependency and no behaviour."
>
<div className="flex flex-col">
{native.map(({ component }) => (
<div
key={component.slug}
className="border-primary border-b py-3 last:border-b-0"
>
<Link
to="/design/components/$component"
params={{ component: component.slug }}
className="text-body text-primary hover:text-purple-12"
>
{component.name}
</Link>
<span className="mt-0.5 block text-body-sm text-secondary">
{component.behavior}
</span>
</div>
))}
</div>
</Section>
<Note>
A dash in the third column too means the component owns its keyboard,
focus, and assistive semantics outright — InlineChip is the case to
watch, because it switches between a control and an image role and has
to get both right without help. See{" "}
<a
href={BASE_UI_DOCS_ROOT}
target="_blank"
rel="noreferrer"
className="text-purple-12 underline"
>
the Base UI component index
</a>{" "}
for a part that could take that work over.
</Note>
</>
);
}

View File

@@ -0,0 +1,34 @@
import { BentoWorkspace } from "@/shared/ui/BentoWorkspace";
import { Section } from "./primitives";
/**
* The workspace, with deliberately empty panels.
*
* Nothing is inside them because the subject here is the *arrangement* — the
* drag, the drop targets, the gutter, the tab strip — and panel content would
* only make that harder to judge. The gradient is not decoration either: a
* panel's edge, shadow, and separation are only legible on the backdrop it
* actually sits on.
*/
export function BentoSpecimen() {
return (
<>
<Section
title="Arrangement"
description="Drag a header toward a panel edge. The highlight shows where it will land above, below, left, or right. Resize continuously in the gaps. Navigation stays at the upper left, with room for a panel below. Highlights appear only where both panels fit; stacking is off."
>
<div className="bento-stage">
<BentoWorkspace
anchoredPanelId="navigation"
panels={[
{ id: "navigation", title: "Navigation" },
{ id: "one", title: "One" },
{ id: "two", title: "Two" },
{ id: "three", title: "Three" },
]}
/>
</div>
</Section>
</>
);
}

View File

@@ -0,0 +1,322 @@
import { Link } from "@tanstack/react-router";
import { Fragment, useState } from "react";
import {
describeValueKind,
humanizeVariable,
isHex,
useResolvedTokens,
} from "@/features/design-system/useResolvedToken";
import {
BACKDROP_CHOICES,
BACKDROP_TREATMENTS,
PALETTE,
RAMPS,
ROLE_GROUPS,
} from "@/shared/tokens/registry";
import { Tabs } from "@/shared/ui/Tabs";
import { Note, PageHeader, Section } from "./primitives";
/**
* Every token in one table: the name you type, the base token it resolves
* through, and the value it actually paints.
*
* The values are read from the live cascade rather than typed into the registry,
* so this table reports what the product would really render and cannot drift
* from `tokens.css`. It also means it re-resolves in dark mode, where the same
* names hold different values — which is the whole point of the role layer.
*/
interface TableRow {
/** The Tailwind class where one exists, else the custom property. */
token: string;
variable: string;
/** Which layer this row belongs to. */
layer: "role" | "ramp" | "backdrop" | "choice";
group: string;
}
function collectRows(): TableRow[] {
const rows: TableRow[] = [];
for (const group of ROLE_GROUPS) {
for (const role of group.roles) {
rows.push({
token: role.token,
variable: role.variable,
layer: "role",
group: group.name,
});
}
}
// The palette was missing from this table entirely — 96 steps across eight
// hues, the layer every role resolves *to*, and the one place a literal
// lives. `RAMPS` holds only glass, so listing it alone showed five of the
// hundred-and-one steps that exist.
for (const hue of PALETTE) {
for (const step of hue.steps) {
rows.push({
token: step.variable.replace(/^--/, ""),
variable: step.variable,
layer: "ramp",
group: hue.usedBy ? `${hue.id} — ${hue.usedBy}` : hue.id,
});
}
}
for (const ramp of RAMPS) {
for (const step of ramp.steps) {
rows.push({
token: step.variable.replace(/^--/, ""),
variable: step.variable,
layer: "ramp",
group: `${ramp.name} ramp`,
});
}
}
for (const treatment of BACKDROP_TREATMENTS) {
rows.push({
token: treatment.name,
variable: treatment.variable,
layer: "backdrop",
group: `${treatment.mode} backdrop treatments`,
});
}
for (const choice of BACKDROP_CHOICES) {
rows.push({
token: choice.token,
variable: choice.variable,
layer: "choice",
group: "Semantic backdrop choices",
});
}
return rows;
}
function ValueSwatch({ value }: { value: string }) {
return (
<span
aria-hidden="true"
className="inline-block h-4 w-4 shrink-0 rounded border border-primary align-middle"
style={{ background: value }}
/>
);
}
const VIEWS = [
{ value: "roles", label: "Roles" },
{ value: "choices", label: "Backdrop choices" },
{ value: "ramps", label: "Palette & ramps" },
] as const;
type View = (typeof VIEWS)[number]["value"];
export function ColorTablePage() {
/* Two tabs rather than two stacked sections. The palette is 96 steps and the
roles are 55, so one page meant scrolling past a hundred rows to reach the
other layer — and the ramps were the half nobody could find. Tabs also state
the distinction the system cares about: what you may type, and what it
resolves to. */
const [view, setView] = useState<View>("roles");
const rows = collectRows();
const resolved = useResolvedTokens(rows.map((row) => row.variable));
const roleRows = rows.filter((row) => row.layer === "role");
const backdropChoiceRows = rows.filter((row) => row.layer === "choice");
const rampRows = rows.filter(
(row) => row.layer === "ramp" || row.layer === "backdrop",
);
return (
<>
<PageHeader
title="Token table"
intro="Every colour token in one list: the name you type, the base token it resolves through, and the value it actually paints. Values are read from the live cascade rather than written down, so this table cannot drift from the system — and it re-resolves when you switch modes."
/>
<div className="mb-6">
{/* `panel`, because the /design pages sit on `bg-panel` rather than the
app gradient. This page is what surfaced the need for the variant:
the chrome pill's glass container composites to white on a white
page, and its selected pill is white too. */}
<Tabs
value={view}
items={VIEWS}
label="Token layer"
onValueChange={setView}
variant="panel"
/>
</div>
{view === "roles" ? (
<>
<Note>
Build screens from the <strong>Ramps</strong> — every step is
authored per mode, so <code>bg-neutral-4</code> behaves in both.
These fifteen roles are the exceptions: each one either takes a
different step in light and dark, so no class can say it, or its
name enforces a rule a ramp cannot. The reasoning lives on the{" "}
<Link to="/design/color" className="text-purple-12 underline">
colour page
</Link>
.
</Note>
<Section
title="Roles"
description="Grouped as they are in the system. The base column is what the role points at; the value column is where that chain ends."
>
<TokenTable rows={roleRows} resolved={resolved} showGroups />
</Section>
</>
) : view === "choices" ? (
<>
<Note>
Each numbered choice is a stable selection rather than a scene name:
<code className="text-mono"> gradient-1 </code> resolves to Sky
field in light mode and Night garden in dark. Product surfaces keep
using <code className="text-mono">bg-app</code>; an appearance
preference can select another numbered choice without knowing which
mode is active.
</Note>
<Section
title="Semantic backdrop choices"
description="Four paired selections. The base column exposes the named treatment currently selected for this mode."
>
<TokenTable
rows={backdropChoiceRows}
resolved={resolved}
showGroups
/>
</Section>
</>
) : (
<>
<Note>
The palette is where every literal lives, and{" "}
<code className="text-mono">neutral</code> is a hue like any other.
It also contains named backdrop treatments: complete, mode-specific
visual compositions that semantic backdrop choices pair together. A
component referencing either directly is a bug.
</Note>
<Section
title="Palette values and ramps"
description="Every hue has twelve authored steps, alongside the named light and dark backdrop treatments and the glass translucency ramp. These hold raw values, which is why the base column is empty for them."
>
<TokenTable rows={rampRows} resolved={resolved} showGroups />
</Section>
</>
)}
</>
);
}
function TokenTable({
rows,
resolved,
showGroups,
}: {
rows: TableRow[];
resolved: ReturnType<typeof useResolvedTokens>;
showGroups?: boolean;
}) {
let lastGroup: string | null = null;
return (
/* `table-fixed` with three equal columns: the natural `auto` layout gives
the value column most of the width, because one gradient literal is longer
than every other cell in the table combined. Fixed makes the thirds hold
regardless of content, and cells wrap instead of scrolling. */
<table className="w-full table-fixed border-collapse text-left">
<caption className="sr-only">
Colour tokens, the base token each resolves through, and the value it
paints
</caption>
<colgroup>
<col className="w-1/3" />
<col className="w-1/3" />
<col className="w-1/3" />
</colgroup>
<thead>
<tr className="border-primary border-b">
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Token
</th>
<th scope="col" className="py-2 pr-4 text-body text-tertiary">
Base
</th>
<th scope="col" className="py-2 text-body text-tertiary">
Value
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const token = resolved.get(row.variable);
const headingRow =
showGroups && row.group !== lastGroup ? row.group : null;
lastGroup = row.group;
return (
<Fragment key={row.variable}>
{headingRow ? (
<tr>
<th
scope="colgroup"
colSpan={3}
className="pt-6 pb-1 text-body-sm text-tertiary"
>
{headingRow}
</th>
</tr>
) : null}
<tr className="border-primary border-b">
<td className="py-2.5 pr-4 align-top">
<code className="break-words text-mono text-primary">
{row.token}
</code>
</td>
<td className="py-2.5 pr-4 align-top">
{token?.pointsAtVariable ? (
<code className="break-words text-mono text-secondary">
{humanizeVariable(token.pointsAtVariable)}
</code>
) : (
<span className="text-body-sm text-tertiary">—</span>
)}
</td>
<td className="py-2.5 align-top">
<span className="flex min-w-0 items-start gap-2">
<span className="mt-0.5 shrink-0">
<ValueSwatch value={token?.value ?? "transparent"} />
</span>
<span className="flex min-w-0 flex-col gap-0.5">
{/* `break-all`, not `break-words`: a gradient literal is
one unbroken token with no spaces to break at, so
word-boundary wrapping would overflow the column. */}
<code className="break-all text-mono text-secondary">
{token?.value ?? "…"}
</code>
{token && !isHex(token.value) ? (
<span className="text-body-sm text-tertiary">
{describeValueKind(token.value)}
</span>
) : null}
</span>
</span>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,272 @@
import {
BACKDROP_CHOICES,
BACKDROP_TREATMENTS,
EXCEPTIONS,
PALETTE,
RAMPS,
ROLE_GROUPS,
type BackdropTreatment,
type Role,
} from "@/shared/tokens/registry";
import { Note, PageHeader, Section, Swatch } from "./primitives";
/**
* A role, sitting directly on the page.
*
* No card and no divider: the swatch is its own separator, and a colour judged
* on a grey card is not being judged on the surface it will actually be used on.
* The hairline stays on the swatch itself — `bg-panel` is white on a white page,
* so without it the most-used role in the system renders as nothing. That is a
* genuine boundary rather than decoration.
*/
function BackdropTreatmentRow({ treatment }: { treatment: BackdropTreatment }) {
const paired = BACKDROP_TREATMENTS.find(
(candidate) => candidate.id === treatment.pairedWith,
);
return (
<div className="flex items-start gap-4 py-2.5">
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-primary"
style={{ background: `var(${treatment.variable})` }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<code className="text-body text-primary">{treatment.name}</code>
<span className="text-body-sm text-tertiary">
{treatment.mode} only
</span>
</div>
<p className="text-body-sm text-secondary">{treatment.description}</p>
<p className="text-body-sm text-tertiary">
Pairs with {paired?.name ?? treatment.pairedWith} in {paired?.mode}
</p>
</div>
</div>
);
}
function RoleRow({ role }: { role: Role }) {
return (
<div className="flex items-start gap-4 py-2.5">
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-primary"
style={{ background: `var(${role.variable})` }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<code className="text-body text-primary">{role.token}</code>
<span className="text-body-sm text-tertiary">{role.pointsAt}</span>
{role.status !== "core" ? (
<span className="rounded-full bg-purple-3 px-2 py-0.5 text-body-sm text-purple-12">
{role.status}
{role.owner ? ` · ${role.owner}` : ""}
</span>
) : null}
</div>
<p className="text-body-sm text-secondary">{role.use}</p>
{role.exception ? (
<p className="text-body-sm text-tertiary">
Exception: {role.exception}
</p>
) : null}
</div>
</div>
);
}
export function ColourPage() {
return (
<>
<PageHeader
title="Colour"
intro="Four layers, and only the role layer is used when building a screen. The palette holds raw values — colour ramps and named backdrop treatments. Semantic tokens hold stable choices. Roles hold interface meanings. Everything below is rendered from the token registry, so a token added there appears here automatically and this page cannot drift from the system."
/>
<Section
title="Layer 0 — the palette"
description="Every hue, twelve steps, authored per mode — the only place a literal colour lives. It exists because the layer above it was 114 hand-picked values with nothing keeping two tokens that do the same job in agreement, and they drifted. Dark steps are authored for dark surfaces rather than derived by dimming light ones, so a subtler deep colour is a step you pick instead of an opacity you write."
>
<div className="flex flex-col gap-6">
{PALETTE.map((hue) => (
<div key={hue.id} className="flex flex-col gap-2">
<div className="flex flex-wrap items-baseline gap-2">
<h3 className="text-body text-primary">{hue.id}</h3>
<span className="text-body-sm text-tertiary">
{hue.usedBy ? `drawn from by ${hue.usedBy}` : "unassigned"}
</span>
</div>
{/* Each step carries what it is for, not just its number. The
registry has always held this text and no page rendered it,
which is how it drifted: it still described the generic scale
the values came from — step 4 as "component hover" — long after
this product had made step 4 its one border weight. Unrendered
documentation cannot be checked by looking. */}
<div className="flex gap-1">
{hue.steps.map((step) => (
<div
key={step.variable}
className="flex min-w-0 flex-1 flex-col gap-1"
>
<div
className="h-10 rounded-md border border-primary"
style={{ background: `var(${step.variable})` }}
/>
<span className="text-center text-body-sm text-tertiary">
{step.step}
</span>
<span className="text-center text-body-sm text-tertiary">
{step.job}
</span>
</div>
))}
</div>
</div>
))}
</div>
</Section>
<Section
title="Layer 0 — backdrop treatments"
description="These are named visual compositions, not colour ramps: each holds the exact colours, geometry, falloff, and sometimes a vignette that make a scene. A treatment exists only in the mode where its name is true; its paired counterpart is a different named scene, not a dimmed copy. Components never reference these."
>
<div className="flex flex-col gap-6">
{(["light", "dark"] as const).map((mode) => (
<div key={mode} className="flex flex-col gap-2">
<h3 className="text-body text-primary">{mode} mode</h3>
<div className="flex flex-col">
{BACKDROP_TREATMENTS.filter(
(treatment) => treatment.mode === mode,
).map((treatment) => (
<BackdropTreatmentRow
key={treatment.id}
treatment={treatment}
/>
))}
</div>
</div>
))}
</div>
</Section>
<Section
title="Layer 1 — semantic backdrop choices"
description="A numbered choice is the stable cross-mode selection: choose one slot and its named light and dark treatments travel together. An appearance setting selects gradient-1 through gradient-4; product surfaces still use bg-app."
>
<div className="flex flex-col">
{BACKDROP_CHOICES.map((choice) => {
const light = BACKDROP_TREATMENTS.find(
(treatment) => treatment.id === choice.lightTreatment,
);
const dark = BACKDROP_TREATMENTS.find(
(treatment) => treatment.id === choice.darkTreatment,
);
return (
<div
key={choice.variable}
className="flex items-start gap-4 py-2.5"
>
<div
className="mt-0.5 h-9 w-16 shrink-0 rounded-md border border-primary"
style={{ background: `var(${choice.variable})` }}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<code className="text-body text-primary">{choice.token}</code>
<p className="text-body-sm text-secondary">{choice.use}</p>
<p className="text-body-sm text-tertiary">
{light?.name} light / {dark?.name} dark
</p>
</div>
</div>
);
})}
</div>
</Section>
<Section
title="Layer 0 — translucency"
description="Glass is the one ramp that is not a hue: each step is the mode\u2019s own surface colour at an increasing opacity, so a hover moves one step up rather than holding its own literal. It stays separate because genuine translucency \u2014 something behind showing through \u2014 is a different axis from colour, and it is the one place alpha is legitimately baked into a value. Components never reference these."
>
<div className="flex flex-col gap-8">
{RAMPS.map((ramp) => (
<div key={ramp.id} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<h3 className="text-body text-primary">{ramp.name}</h3>
<p className="max-w-2xl text-body-sm text-secondary">
{ramp.description}
</p>
</div>
<div
className={`grid gap-3 ${
ramp.steps.length > 6
? "grid-cols-4 sm:grid-cols-6"
: "grid-cols-3 sm:grid-cols-5"
} ${ramp.translucent ? "rounded-xl bg-app p-4" : ""}`}
>
{ramp.steps.map((step) => (
<Swatch
key={step.variable}
variable={step.variable}
label={`${ramp.id} ${step.step}`}
sublabel={step.job}
translucent={ramp.translucent}
/>
))}
</div>
</div>
))}
</div>
</Section>
<Section
title="Layer 2 — the roles"
description="Fifteen names, and each had to earn one. A role exists when light and dark take different ramp steps, so no single class is correct in both \u2014 or when the name enforces a rule a ramp cannot state, like there being exactly three levels of text. Everything else is written as a ramp step, because a name in front of a number hides the choice instead of recording it."
>
<div className="flex flex-col gap-8">
{ROLE_GROUPS.map((group) => (
<div key={group.id} className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<h3 className="text-body text-primary">{group.name}</h3>
<p className="max-w-2xl text-body-sm text-secondary">
{group.description}
</p>
</div>
<div className="mt-1 flex flex-col">
{group.roles.map((role) => (
<RoleRow key={role.token} role={role} />
))}
</div>
</div>
))}
</div>
</Section>
<Section
title="Deliberate exceptions"
description="Literal values exist only in the palette, and nothing above it holds one — except these. The list is short and complete on purpose: a vague exception policy is how a layered system quietly erodes."
>
{/* No swatch here to do the separating, so these entries keep a little
structure — the token name leads and the spacing groups it with its
reason. Still no card: this is prose, not data. */}
<div className="flex flex-col gap-5">
{EXCEPTIONS.map((exception) => (
<div key={exception.name} className="flex flex-col gap-1">
<code className="text-mono text-purple-12">{exception.name}</code>
<p className="max-w-2xl text-body-sm text-secondary">
{exception.why}
</p>
</div>
))}
</div>
</Section>
<Note>
Every dark value in this system is authored rather than observed — the
design exploration it was derived from is light-only. Toggle the mode in
the sidebar and treat anything that looks wrong as a finding, not a
given.
</Note>
</>
);
}

View File

@@ -0,0 +1,331 @@
import { useEffect, useState } from "react";
import {
type ColorScheme,
useColorScheme,
} from "@/shared/theme/useColorScheme";
/**
* What a component is made of, read from the running component.
*
* The question this answers is "which token draws this part" — the one a specimen
* cannot answer, because a picture of a control does not say whether that pill is
* `bg-chrome-selected` or a hardcoded white.
*
* It reports the token and stops there. The value each token resolves to is the
* token table's subject, and repeating it here made every cell two lines of which
* only one was the answer.
*
* **It reads the live DOM rather than a hand-written table**, for the same reason
* the token table does: a typed-out list of parts and tokens is a second source
* of truth that goes stale the first time someone edits the stylesheet, and goes
* stale silently. Here, a part whose declaration changes reports the new value on
* the next render, and a part that stops existing reports that it is missing.
*
* The trade is that it can only describe what is currently on screen. That is
* why each row names a selector: the row is a claim that this part exists, and a
* missing part is shown as missing rather than omitted.
*/
/** A property worth reporting, and the CSS property it reads. */
const PROPERTIES = [
{ id: "background", label: "Fill", css: "background-color" },
{ id: "color", label: "Text", css: "color" },
{ id: "border", label: "Border", css: "border-color" },
{ id: "shadow", label: "Shadow", css: "box-shadow" },
{ id: "blur", label: "Blur", css: "backdrop-filter" },
{ id: "radius", label: "Radius", css: "border-radius" },
] as const;
type PropertyId = (typeof PROPERTIES)[number]["id"];
export interface AnatomyPart {
/** What this part is, in the product's words. */
name: string;
/** A CSS selector for it, resolved inside the specimen root. */
selector: string;
/** Which properties are worth reporting for this part. */
show: readonly PropertyId[];
/** Why this part looks the way it does, when that is not obvious. */
note?: string;
}
interface Measured {
/**
* The colour scheme these values were read in, so a stale read is visible.
*
* Carried on the row rather than merely listed as an effect dependency, so the
* re-measure is a real data flow: a row states the mode it was read in.
*/
scheme: ColorScheme;
name: string;
note?: string;
present: boolean;
/** The token each reported property is drawn from. */
values: Partial<Record<PropertyId, string>>;
}
/**
* Finds the declaration a rule made for a property, so the table can show
* `var(--bg-chrome-selected)` next to the `#ffffff` it paints.
*
* Computed style alone cannot answer this: by the time it is readable the
* `var()` is resolved and the token name is gone. So the stylesheets are walked
* for the last rule that matches this element and sets this property, which is
* the same approach `useResolvedToken` takes for tokens.
*/
function declaredValue(element: Element, property: string): string | null {
// `background` is the shorthand an author actually writes; `border-radius` and
// `box-shadow` are their own thing. Longhand first so the more specific
// declaration wins when a rule sets both.
const names =
property === "background-color"
? ["background-color", "background"]
: [property];
let found: string | null = null;
let bestRank = -1;
for (const [index, rule] of styleRules().entries()) {
let matches = false;
try {
matches = element.matches(rule.selector);
} catch {
continue; // a selector this browser cannot match
}
if (!matches) continue;
for (const name of names) {
const value = new RegExp(
String.raw`(?:^|;)\s*${name}\s*:\s*([^;]+)`,
"i",
).exec(rule.body)?.[1];
if (!value) continue;
// Specificity first, then source order — the actual cascade. Ranking by
// source order alone was wrong the moment a variant rule outranked a
// shared one: `.buzz-tabs[data-variant="panel"] .buzz-tabs-tab:not(...)`
// beats `.buzz-tabs-tab[data-selected]`, so a selected tab was reported
// as the unselected colour. The table looked plausible and was wrong,
// which is the failure mode it exists to prevent.
const rank = specificity(rule.selector) * 100000 + index;
if (rank > bestRank) {
bestRank = rank;
found = value.trim();
}
}
}
return found;
}
/**
* A selector's specificity as one comparable number.
*
* Only the b and c columns matter here — these are stylesheet rules on classes,
* attributes, and pseudo-classes, with no inline styles or IDs in play. `:not()`
* contributes its argument's specificity rather than its own, which is why it can
* silently strengthen a rule that reads like a narrowing.
*/
function specificity(selector: string): number {
const inner = selector.replace(/:not\(([^)]*)\)/g, " $1 ");
const classes = (inner.match(/\.[\w-]+/g) ?? []).length;
const attributes = (inner.match(/\[[^\]]+\]/g) ?? []).length;
const pseudoClasses = (inner.match(/:(?!:)[\w-]+/g) ?? []).length;
const elements = (inner.match(/(?:^|[\s>+~])[a-z][\w-]*/gi) ?? []).length;
return (classes + attributes + pseudoClasses) * 256 + elements;
}
/**
* Every style rule on the page, as `{ selector, body }` text.
*
* **Parsed from the stylesheet text rather than read through `sheet.cssRules`**,
* which is the obvious approach and does not work: Vite injects app CSS by
* setting `textContent` on a `<style>` tag at dev time, and those rules are not
* reachable through the CSSOM — a walk over `document.styleSheets` found 84 rules
* and matched none of this component's, while the same CSS was plainly present in
* a `<style>` tag. Reading the text works in dev and in a production build.
*
* Cached per call site, because parsing every rule for every property of every
* part is otherwise quadratic on a page with a few tables.
*/
let ruleCache: {
key: number;
rules: { selector: string; body: string }[];
} | null = null;
function styleRules(): { selector: string; body: string }[] {
const sheets = Array.from(document.querySelectorAll("style"));
const key = sheets.reduce(
(total, tag) => total + (tag.textContent?.length ?? 0),
0,
);
if (ruleCache?.key === key) return ruleCache.rules;
const rules: { selector: string; body: string }[] = [];
for (const tag of sheets) {
const css = tag.textContent ?? "";
// Flat rules only: a selector, then a body with no nested braces. Enough for
// this stylesheet, and it skips `@media`/`@theme` bodies rather than
// mis-parsing them.
for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selector = match[1].trim();
if (!selector || selector.startsWith("@")) continue;
for (const one of selector.split(",")) {
rules.push({ selector: one.trim(), body: match[2] });
}
}
}
ruleCache = { key, rules };
return rules;
}
/**
* `var(--bg-glass-primary)` → `bg-glass-primary`.
*
* The token name is the answer this table exists to give, so it is shown bare.
* A declaration that composes several tokens — the glass rim is two inset
* shadows — keeps its structure, because that composition *is* the answer there.
*/
function tokenNames(declared: string): string {
const refs = [...declared.matchAll(/var\(\s*--([\w-]+)\s*\)/g)].map(
(match) => match[1],
);
if (refs.length === 0) return declared;
if (refs.length === 1 && /^var\(\s*--[\w-]+\s*\)$/.test(declared.trim())) {
return refs[0];
}
return declared
.replace(/var\(\s*--([\w-]+)\s*\)/g, "$1")
.replace(/\s+/g, " ");
}
/** Values that mean "this property is not part of how the element looks". */
function isAbsent(painted: string): boolean {
return (
painted === "none" ||
painted === "rgba(0, 0, 0, 0)" ||
painted === "transparent" ||
painted === "0px"
);
}
export function ComponentAnatomy({
parts,
scope,
caption,
}: {
parts: readonly AnatomyPart[];
/** A selector for the specimen this describes, so two variants can differ. */
scope: string;
caption?: string;
}) {
const [rows, setRows] = useState<Measured[] | null>(null);
// The shared colour-scheme fact. This used to be a local MutationObserver on
// `<html>`, because `useColorScheme` held per-caller state and reading it here
// would have produced a second answer that never heard the layout's toggle.
// The fact now has one value, so the workaround is gone.
const { scheme } = useColorScheme();
useEffect(() => {
// After paint, so Base UI has mounted and the indicator has its size.
const frame = requestAnimationFrame(() => {
const root = document.querySelector(scope);
setRows(
parts.map((part) => {
const element = root?.querySelector(part.selector) ?? null;
if (!element) {
return {
scheme,
name: part.name,
note: part.note,
present: false,
values: {},
};
}
const computed = getComputedStyle(element);
const values: Measured["values"] = {};
for (const property of PROPERTIES) {
if (!part.show.includes(property.id)) continue;
// Computed style answers only "is this part of how the element
// looks" — a transparent fill or `box-shadow: none` gets no row. The
// reported value is the token, read from the declaration.
if (isAbsent(computed.getPropertyValue(property.css).trim()))
continue;
const declared = declaredValue(element, property.css);
if (!declared) continue;
values[property.id] = tokenNames(declared);
}
return {
scheme,
name: part.name,
note: part.note,
present: true,
values,
};
}),
);
});
return () => cancelAnimationFrame(frame);
}, [parts, scope, scheme]);
const columns = PROPERTIES.filter((property) =>
rows?.some((row) => row.values[property.id]),
);
return (
<div className="flex min-w-0 flex-col gap-3">
{caption ? (
<p className="text-body-sm text-secondary">{caption}</p>
) : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left">
<thead>
<tr className="border-primary border-b">
<th className="py-2 pr-6 text-body-sm text-tertiary font-normal">
Part
</th>
{columns.map((property) => (
<th
key={property.id}
className="py-2 pr-6 text-body-sm text-tertiary font-normal"
>
{property.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows?.map((row) => (
<tr key={row.name} className="border-primary border-b">
<td className="py-3 pr-6 align-top">
<span className="text-body text-primary">{row.name}</span>
{row.note ? (
<span className="block text-body-sm text-tertiary">
{row.note}
</span>
) : null}
{row.present ? null : (
<span className="block text-body-sm text-red-12">
Not found in the specimen
</span>
)}
</td>
{columns.map((property) => {
const value = row.values[property.id];
return (
<td key={property.id} className="py-3 pr-6 align-top">
{value ? (
<code className="text-mono-sm text-primary">
{value}
</code>
) : (
<span className="text-body-sm text-tertiary">—</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,28 @@
import { ComposerStateGallery } from "./ComposerStateGallery";
import { COMPONENTS } from "@/shared/ui/registry";
import { BaseUiBackingLine } from "./BaseUiBackingLine";
import { COMPONENT_SPECIMENS } from "./componentSpecimens";
export function ComponentDetailPage({ slug }: { slug: string }) {
const component = COMPONENTS.find((candidate) => candidate.slug === slug);
if (!component) return null;
const Specimen =
component.slug === "composer"
? ComposerStateGallery
: COMPONENT_SPECIMENS[component.slug];
return (
<>
<header className="component-page-heading">
<h1 className="text-title text-primary">{component.name}</h1>
<p className="text-body text-tertiary">
{component.slug === "composer"
? "Write a message, choose its destination, and explore the composer’s controls."
: component.purpose}
<BaseUiBackingLine slug={component.slug} />
</p>
</header>
{Specimen ? <Specimen /> : null}
</>
);
}

View File

@@ -0,0 +1,54 @@
import { Link } from "@tanstack/react-router";
import { COMPONENTS } from "@/shared/ui/registry";
import { COMPONENT_SPECIMENS } from "./componentSpecimens";
export function ComponentsPage() {
const topLevelComponents = COMPONENTS.filter(
(component) =>
component.collection === "components" && component.parent === undefined,
);
return (
<>
<header className="component-page-heading">
<h1 className="text-title text-primary">Components</h1>
</header>
<div className="component-overview-grid">
{topLevelComponents.map((component) => {
const Specimen = COMPONENT_SPECIMENS[component.slug];
const children = COMPONENTS.filter(
(candidate) => candidate.parent === component.slug,
);
return (
<article key={component.slug} className="component-overview-item">
<div className="component-overview-preview">
{Specimen ? <Specimen /> : null}
</div>
<Link
to="/design/components/$component"
params={{ component: component.slug }}
className="text-body text-primary"
>
{component.name}
</Link>
{children.length > 0 ? (
<nav aria-label={`${component.name} components`}>
{children.map((child) => (
<Link
key={child.slug}
to="/design/components/$component"
params={{ component: child.slug }}
className="text-body-sm text-secondary hover:text-primary"
>
{child.name}
</Link>
))}
</nav>
) : null}
</article>
);
})}
</div>
</>
);
}

View File

@@ -0,0 +1,74 @@
import { useState } from "react";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
import { Select } from "@/shared/ui/Select";
// Exact copy from Figma uhFH3LPsy6HMqzoATFgWKl, frame 809:12505.
const CONTEXTS = [
{
group: "Channel",
value: "channel-start",
label: "Channel · New message",
copy: "Send a message in #buzz-design",
},
{
group: "Channel",
value: "channel-reply",
label: "Channel · Reply",
copy: "Reply in #buzz-design",
},
{
group: "Session",
value: "session-start",
label: "Session · New session",
copy: "Start a new session",
},
{
group: "Session",
value: "session-reply",
label: "Session · Reply",
copy: "Reply in this session",
},
{
group: "Direct message",
value: "dm-start",
label: "Direct message · New message",
copy: "Start a new direct message",
},
{
group: "Direct message",
value: "dm-reply",
label: "Direct message · Reply",
copy: "Reply in this direct message",
},
] as const;
const GROUPS = ["Channel", "Session", "Direct message"].map((label) => ({
label,
options: CONTEXTS.filter((context) => context.group === label),
}));
/** Documentation-only context selection; real destinations own product copy. */
export function ComposerCopyPreview() {
const [value, setValue] = useState<string>("channel-start");
const [draft, setDraft] = useState("");
const context =
CONTEXTS.find((candidate) => candidate.value === value) ?? CONTEXTS[0];
return (
<>
<Select
label="Preview context"
value={value}
groups={GROUPS}
onValueChange={setValue}
/>
<div className="composer-playground-stage">
<MessageComposer
autoFocus={false}
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder={context.copy}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,137 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
import { Button } from "@/shared/ui/Button";
import { ComposerCopyPreview } from "./ComposerCopyPreview";
function Example({
title,
description,
children,
}: {
title: string;
description: string;
children: ReactNode;
}) {
return (
<section className="component-specimen-group" aria-label={title}>
<h3 className="text-heading text-primary">{title}</h3>
<p className="text-body text-tertiary">{description}</p>
{children}
</section>
);
}
function DraftExample({
initial = "",
formatting = false,
disabled = false,
responseControl,
}: {
initial?: string;
formatting?: boolean;
disabled?: boolean;
responseControl?: { state: "responding" | "stopping" };
}) {
const [draft, setDraft] = useState(initial);
return (
<MessageComposer
autoFocus={false}
defaultFormattingOpen={formatting}
disabled={disabled}
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder="Reply in this session"
responseControl={responseControl}
/>
);
}
/** Exercise the real submit path instead of painting a pretend sending/error state. */
function SubmissionExample() {
const [draft, setDraft] = useState("Here is the updated design.");
const [pending, setPending] = useState(false);
const deferred = useRef<{
resolve: () => void;
reject: (error: Error) => void;
} | null>(null);
useEffect(
() => () => {
deferred.current?.resolve();
},
[],
);
function settle(fail: boolean) {
if (fail) deferred.current?.reject(new Error("Example send failure"));
else deferred.current?.resolve();
deferred.current = null;
setPending(false);
}
return (
<>
<MessageComposer
autoFocus={false}
draft={draft}
onDraftChange={setDraft}
onSend={() => {
setPending(true);
return new Promise<void>((resolve, reject) => {
deferred.current = { resolve, reject };
});
}}
placeholder="Reply in this session"
/>
<div className="component-specimen-row">
<Button
size="compact"
disabled={!pending}
onClick={() => settle(false)}
>
Complete example send
</Button>
<Button size="compact" disabled={!pending} onClick={() => settle(true)}>
Fail example send
</Button>
</div>
</>
);
}
/** One interactive playground, with only hard-to-reach states shown separately. */
export function ComposerStateGallery() {
return (
<div className="component-specimen-stack">
<section
className="component-specimen-group"
aria-label="Composer playground"
>
<ComposerCopyPreview />
</section>
<h2 className="text-heading text-primary">States</h2>
<Example
title="Sending and failed"
description="A message is being sent; if sending fails, the draft stays here so you can try again."
>
<SubmissionExample />
</Example>
<Example
title="Unavailable"
description="Writing and sending are disabled in this conversation."
>
<DraftExample disabled />
</Example>
<Example
title="Responding"
description="An agent is generating a response. Select Stop to cancel it."
>
<DraftExample responseControl={{ state: "responding" }} />
</Example>
<Example
title="Stopping"
description="Stop has been requested. The control is disabled while the agent finishes stopping."
>
<DraftExample responseControl={{ state: "stopping" }} />
</Example>
</div>
);
}

View File

@@ -0,0 +1,404 @@
import { PreparedMessagePreview } from "./PreparedMessagePreview";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
import {
IconCheck,
IconCopy,
IconMessageCircle,
IconThumbUp,
} from "@tabler/icons-react";
import { useState } from "react";
import { AgentWorkArea } from "@/features/agent-activity/ui/AgentWorkArea";
import { WorkStep } from "@/features/agent-activity/ui/WorkStep";
import type { AgentWorkPresentation } from "@/features/agent-activity/ui/workPresentation";
import { ConversationMessage } from "@/features/conversation/ui/ConversationMessage";
import { ConversationState } from "@/features/conversation/ui/ConversationState";
import { SessionExchange } from "@/features/conversation/ui/SessionExchange";
import { Button } from "@/shared/ui/Button";
import { ConversationHeader } from "@/shared/ui/ConversationHeader";
import { IconButton } from "@/shared/ui/IconButton";
import { Select } from "@/shared/ui/Select";
import {
RIVET_REPLY,
RIVET_WORK,
SESSION_PROMPT,
VOGUE_REPLY,
VOGUE_WORK,
completedWork,
} from "./conversationFixtures";
import "./conversationSpecimens.css";
type Scenario =
| "working"
| "one-finished"
| "finished"
| "approval"
| "failed"
| "waiting"
| "cancelled"
| "unavailable";
const SCENARIOS = [
{
label: "Session states",
options: [
{ value: "working", label: "Two agents working" },
{ value: "one-finished", label: "One agent finished" },
{ value: "finished", label: "Both finished" },
{ value: "approval", label: "Permission needed" },
{ value: "failed", label: "Work failed" },
{ value: "waiting", label: "Waiting to start" },
{ value: "cancelled", label: "Stopped" },
{ value: "unavailable", label: "Activity unavailable" },
],
},
];
/** Fixtures alone own these transitions. They never invoke or imitate a runtime. */
function useWorkExample() {
const [scenario, setScenario] = useState<Scenario>("working");
const [decision, setDecision] = useState<string | null>(null);
function select(value: string) {
if (SCENARIOS[0].options.some((option) => option.value === value)) {
setScenario(value as Scenario);
setDecision(null);
}
}
let vogue = VOGUE_WORK;
let rivet: AgentWorkPresentation = RIVET_WORK;
if (["one-finished", "finished"].includes(scenario))
vogue = completedWork(VOGUE_WORK);
if (scenario === "finished") rivet = completedWork(RIVET_WORK);
if (scenario === "approval")
rivet = {
...RIVET_WORK,
status:
decision === "Allowed in this preview"
? "running"
: decision
? "cancelled"
: "needs-you",
statusLabel:
decision === "Allowed in this preview"
? "Working"
: decision
? "Stopped"
: "Needs you",
notice: {
title: decision ?? "Allow Rivet to run the checks?",
description:
"Run the local test suite for this Session. This example does not execute commands.",
actions: decision ? undefined : (
<>
<Button
size="compact"
onClick={() => setDecision("Allowed in this preview")}
>
Allow once
</Button>
<Button
size="compact"
variant="ghost"
onClick={() => setDecision("Declined in this preview")}
>
Decline
</Button>
</>
),
},
};
if (scenario === "failed")
rivet = {
...RIVET_WORK,
status: "failed",
statusLabel: "Failed",
notice: {
title: "The checks couldn’t finish",
description:
"The working folder was unavailable. The conversation and earlier steps are still here.",
actions: (
<Button size="compact" onClick={() => select("working")}>
Retry in preview
</Button>
),
},
};
if (scenario === "waiting")
rivet = {
...RIVET_WORK,
status: "waiting",
statusLabel: "Waiting",
summary: "Waiting for the agent to start. No activity yet.",
earlierSteps: [],
visibleSteps: [],
};
if (scenario === "cancelled")
rivet = {
...RIVET_WORK,
status: "cancelled",
statusLabel: "Stopped",
notice: {
title: "You stopped this work",
description:
"Completed steps remain available. No final answer was posted.",
},
};
if (scenario === "unavailable")
rivet = {
...RIVET_WORK,
status: "unavailable",
statusLabel: "Unavailable",
notice: {
title: "Live activity is unavailable",
description:
"The agent may still be working. This is not a completion signal.",
actions: (
<Button size="compact" onClick={() => select("working")}>
Reconnect in preview
</Button>
),
},
};
if (rivet.status !== "running" && rivet.status !== "completed") {
rivet = {
...rivet,
visibleSteps: rivet.visibleSteps.filter(
(step) => step.status !== "running",
),
};
}
const replies =
scenario === "finished"
? [VOGUE_REPLY, RIVET_REPLY]
: scenario === "one-finished"
? [VOGUE_REPLY]
: [];
return { scenario, select, work: [vogue, rivet], replies };
}
export function ConversationSpecimen() {
const example = useWorkExample();
const [draft, setDraft] = useState("");
const [sent, setSent] = useState<{ id: string; content: string }[]>([]);
return (
<div className="component-specimen-stack">
<div className="conversation-preview-controls">
<Select
label="Preview"
value={example.scenario}
groups={SCENARIOS}
onValueChange={example.select}
/>
<span className="text-body-sm text-tertiary">
Illustrative content · no live agents
</span>
</div>
<section className="conversation-preview" aria-label="Session playground">
<ConversationHeader
title="Make room for the work"
icon={<IconMessageCircle size={18} />}
context="Session in #design"
/>
<div className="conversation-preview-transcript">
<p className="conversation-date text-body-sm text-tertiary">
Today · September 8
</p>
<SessionExchange
prompt={SESSION_PROMPT}
work={example.work}
replies={example.replies}
/>
{sent.map(({ content, id }) => (
<ConversationMessage
key={id}
message={{
...SESSION_PROMPT,
id,
content: <PreparedMessagePreview content={content} />,
delivery: { label: "Sent in this preview only" },
}}
/>
))}
</div>
<div className="conversation-preview-footer">
<MessageComposer
autoFocus={false}
draft={draft}
onDraftChange={setDraft}
onSend={async (content) => {
setSent((current) => [
...current,
{ id: crypto.randomUUID(), content },
]);
}}
placeholder="Reply in this session"
/>
<p className="text-body-sm text-tertiary">
Local preview only. Messages are not saved or sent to agents.
</p>
</div>
</section>
<section className="component-specimen-group">
<h2 className="text-heading text-primary">Built from product pieces</h2>
<p className="text-body text-secondary">
Messages carry the shared conversation. Agent work keeps a stable
place under the prompt. Replies arrive below it without collapsing.
Each piece has its own page in Product UI.
</p>
</section>
</div>
);
}
export function AgentWorkSpecimen() {
const example = useWorkExample();
return (
<div className="component-specimen-stack">
<Select
label="Preview"
value={example.scenario}
groups={SCENARIOS}
onValueChange={example.select}
/>
<div className="work-area-specimen">
<AgentWorkArea work={example.work[1]} />
</div>
<p className="text-body-sm text-secondary">
Earlier steps and tool output open independently. Notices and artifact
access remain outside the completed-work disclosure.
</p>
</div>
);
}
export function WorkStepSpecimen() {
return (
<div className="component-specimen-stack">
<ol className="work-steps">
{[
...RIVET_WORK.visibleSteps,
{
id: "failed-step",
title: "Couldn’t read the working folder",
kind: "tool" as const,
status: "failed" as const,
detail: "Working folder unavailable.\nNo files were changed.",
},
].map((step) => (
<WorkStep key={step.id} step={step} />
))}
</ol>
<p className="text-body-sm text-secondary">
Progress, thought summary, tool output, running, and failed. Content is
prepared by Agent Activity; these rows do not parse tool events.
</p>
</div>
);
}
export function ConversationMessageSpecimen() {
const [liked, setLiked] = useState(false);
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
const [failed, setFailed] = useState(true);
const [reply, setReply] = useState(false);
return (
<div className="component-specimen-stack">
<ConversationMessage
message={{
...VOGUE_REPLY,
actions: (
<>
<Button
variant={liked ? "quiet" : "ghost"}
size="compact"
aria-pressed={liked}
aria-label={`Thumbs up, ${liked ? 2 : 1} reactions`}
onClick={() => setLiked(!liked)}
>
<IconThumbUp size={16} aria-hidden="true" />
{liked ? "2" : "1"}
</Button>
<IconButton
size="compact"
aria-label="Reply to Vogue"
icon={<IconMessageCircle size={16} />}
onClick={() => setReply(!reply)}
/>
<IconButton
size="compact"
aria-label={copied ? "Message copied" : "Copy message"}
icon={copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
onClick={() => {
setCopyError(false);
void navigator.clipboard
.writeText(
"Let the conversation lead. Give each of us a small, steady place to work, then let that work fold away when we’re done.",
)
.then(
() => setCopied(true),
() => setCopyError(true),
);
}}
/>
</>
),
}}
/>
{copyError ? (
<p role="alert" className="text-body-sm text-red-12">
Couldn’t copy. You can select the message text instead.
</p>
) : null}
{reply ? (
<p role="status" className="text-body-sm text-secondary">
Reply target selected: Vogue. Session reply behavior is still being
designed.
</p>
) : null}
<ConversationMessage
message={{
...SESSION_PROMPT,
id: "delivery-example",
content: <p>Let’s try this with two agents first.</p>,
delivery: {
label: failed
? "Not sent. Your message is still here."
: "Sent in this preview",
failed,
onRetry: failed ? () => setFailed(false) : undefined,
},
}}
/>
<p className="text-body-sm text-secondary">
Shared agent answer, message actions, selected reaction, and failed-send
recovery. Reactions and retry are local examples.
</p>
</div>
);
}
export function ConversationStateSpecimen() {
const [state, setState] = useState<
"loading" | "empty" | "failed" | "read-only"
>("failed");
return (
<div className="component-specimen-stack">
<Select
label="Preview"
value={state}
onValueChange={(value) => setState(value as typeof state)}
groups={[
{
label: "Conversation states",
options: ["loading", "empty", "failed", "read-only"].map(
(value) => ({ value, label: value }),
),
},
]}
/>
<ConversationState state={state} onRetry={() => setState("empty")} />
<p className="text-body-sm text-secondary">
Retry switches to an empty fixture, not a live request.
</p>
</div>
);
}

View File

@@ -0,0 +1,200 @@
import { IconMoon, IconSun } from "@tabler/icons-react";
import { Link, Outlet } from "@tanstack/react-router";
import { Fragment, type ReactNode } from "react";
import { useColorScheme } from "@/shared/theme/useColorScheme";
import { Accordion } from "@/shared/ui/Accordion";
import { IconButton } from "@/shared/ui/IconButton";
import { COMPONENTS } from "@/shared/ui/registry";
type NavItem = [label: string, to: string, children?: NavItem[]];
type NavSection = { heading: string; items: NavItem[] };
function componentNavItems(
collection: "components" | "product-ui",
parent?: string,
): NavItem[] {
return COMPONENTS.filter(
(component) =>
component.collection === collection && component.parent === parent,
).map((component) => [
component.name,
`/design/components/${component.slug}`,
componentNavItems(collection, component.slug),
]);
}
const SECTIONS: NavSection[] = [
{
heading: "Foundations",
items: [
["Color", "/design/color", [["Token table", "/design/color/table"]]],
["Typography", "/design/typography"],
["Spacing", "/design/spacing"],
["Radius", "/design/radius"],
["Elevation", "/design/elevation"],
["Glass", "/design/glass"],
["Motion", "/design/motion"],
["Base UI backing", "/design/components/base-ui"],
],
},
{
heading: "System",
items: [
["Maintaining the system", "/design/maintaining"],
["DESIGN.md", "/design/design-guide"],
["AGENTS.md", "/design/agents-guide"],
],
},
{
heading: "Components",
items: [
["Overview", "/design/components", componentNavItems("components")],
],
},
{
heading: "Product UI",
items: componentNavItems("product-ui").filter(
([, to]) =>
![
"/design/components/composer",
"/design/components/workspace",
"/design/components/flex-workspace",
].includes(to),
),
},
];
function NavLink({
to,
exact,
children,
}: {
to: string;
exact?: boolean;
children: ReactNode;
}) {
return (
<Link
to={to}
activeOptions={exact ? { exact: true } : undefined}
className="design-system-nav-link text-body text-primary"
activeProps={{
className:
"design-system-nav-link design-system-nav-link-active text-body text-primary",
}}
>
{children}
</Link>
);
}
function NavItems({ items, depth = 0 }: { items: NavItem[]; depth?: number }) {
return (
<div className="design-system-nav-items">
{items.map(([label, to, children]) => (
<Fragment key={to}>
<div className={depth ? "design-system-nav-child" : undefined}>
<NavLink
to={to}
exact={children !== undefined && children.length > 0}
>
{label}
</NavLink>
</div>
{children?.length ? (
<NavItems items={children} depth={depth + 1} />
) : null}
</Fragment>
))}
</div>
);
}
export function DesignSystemLayout() {
const { scheme, toggle } = useColorScheme();
return (
<div className="design-system-shell">
<nav aria-label="Design system" className="design-system-nav">
<Link
to="/design"
className="design-system-nav-title design-system-nav-link text-body text-primary"
>
Buzz Design System
</Link>
<div aria-hidden="true" className="design-system-nav-rule" />
<div className="design-system-nav-sections">
{SECTIONS.map((section) => (
<section
className="design-system-nav-section"
key={section.heading}
>
<h2 className="text-body text-tertiary">{section.heading}</h2>
<NavItems items={section.items} />
{section.heading === "Product UI" ? (
<Accordion
variant="navigation"
defaultValue={["workspace", "chat"]}
items={[
{
value: "workspace",
title: "Workspace",
content: (
<NavItems
items={["workspace", "flex-workspace"].map((slug) => {
const component = COMPONENTS.find(
(item) => item.slug === slug,
);
return [
component?.name ?? slug,
`/design/components/${slug}`,
];
})}
/>
),
},
{
value: "chat",
title: "Chat",
content: (
<NavItems
items={COMPONENTS.filter(
(component) =>
component.slug === "composer" ||
component.parent === "composer",
).map((component) => [
component.name,
`/design/components/${component.slug}`,
])}
/>
),
},
]}
/>
) : null}
</section>
))}
</div>
</nav>
<main className="design-system-content">
<IconButton
aria-label={scheme === "dark" ? "Use light mode" : "Use dark mode"}
icon={
scheme === "dark" ? (
<IconSun size={16} aria-hidden="true" />
) : (
<IconMoon size={16} aria-hidden="true" />
)
}
size="toolbar"
onClick={toggle}
/>
<div className="design-system-reading-column">
<Outlet />
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { BLUR, ELEVATION } from "@/shared/tokens/registry";
import { Note, PageHeader, Section } from "./primitives";
export function ElevationPage() {
return (
<>
<PageHeader
title="Elevation"
intro="Two values, taken from the design exploration unchanged. Depth comes from hairline borders and shadows at the threshold of perception rather than from many surface colours — if a shadow is obvious, it is too strong."
/>
<Section title="The values">
<div className="flex flex-wrap gap-6 rounded-xl bg-app p-8">
{ELEVATION.map((level) => (
<div key={level.token} className="flex flex-col gap-2">
<div
className="flex h-24 w-44 items-center justify-center rounded-xl bg-panel"
style={{ boxShadow: `var(${level.variable})` }}
>
<code className="text-body-sm text-primary">{level.token}</code>
</div>
<span className="max-w-44 text-body-sm text-secondary">
{level.use}
</span>
</div>
))}
</div>
</Section>
{/* Blur lives here rather than on the glass page: it is a depth cue like
a shadow, and the glass materials already carry their own blur, so
showing the amounts beside the shadows keeps every depth value in one
place. */}
<Section
title="Blur"
description="Depth behind a translucent surface. The glass materials each carry one of these, so a screen picks a material rather than a blur amount."
>
<div className="glass-scene flex flex-wrap gap-3 rounded-xl p-6">
{BLUR.map((blur) => (
<div
key={blur.token}
className="glass-primary flex flex-col gap-1 rounded-xl px-5 py-4"
style={{ backdropFilter: `blur(${blur.value})` }}
>
<code className="text-mono-sm text-primary">{blur.token}</code>
<span className="text-body-sm text-tertiary">{blur.value}</span>
</div>
))}
</div>
</Section>
<Note>
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 — which is
why `bg-float` and `bg-panel` share a light value and diverge in dark.
Never reach for a stronger shadow to make something float in dark mode.
</Note>
</>
);
}

View File

@@ -0,0 +1,22 @@
type FoundationItem = {
token: string;
value: string;
use: string;
};
export function FoundationScale({ items }: { items: FoundationItem[] }) {
return (
<div className="flex flex-col">
{items.map((item) => (
<div
key={item.token}
className="grid gap-2 border-primary border-b py-3 last:border-b-0 sm:grid-cols-[10rem_7rem_1fr]"
>
<code className="text-mono text-primary">{item.token}</code>
<span className="text-body-sm text-tertiary">{item.value}</span>
<span className="text-body text-secondary">{item.use}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { RAMPS } from "@/shared/tokens/registry";
import { PageHeader, Section } from "./primitives";
const GLASS = RAMPS.find((ramp) => ramp.id === "glass");
const MATERIALS = [
{
utility: "glass-primary",
spec: "glass-2 · blur-md · rim",
use: "On the backdrop",
},
{
utility: "glass-secondary",
spec: "glass-4 · blur-lg · rim · shadow-sm",
use: "Over glass",
},
];
/**
* A material, rendered as itself over the backdrop.
*
* The label sits inside the specimen rather than in a caption beneath it: the
* subject is a translucent surface, so text on it is part of what is being
* judged — whether it stays legible with the gradient reading through.
*/
function Material({ material }: { material: (typeof MATERIALS)[number] }) {
return (
<div
className={`${material.utility} flex min-h-[8rem] flex-col justify-between gap-6 rounded-xl px-5 py-4`}
>
<span className="text-body-sm text-secondary">{material.use}</span>
<div className="flex flex-col gap-1">
<code className="text-mono text-primary">{material.utility}</code>
<span className="text-body-sm text-tertiary">{material.spec}</span>
</div>
</div>
);
}
export function GlassPage() {
return (
<>
<PageHeader
title="Glass"
intro="Two materials, named by how high they sit. Each one is a fill, a blur, a rim, and sometimes a shadow, applied as a single utility so it cannot arrive in pieces."
/>
<Section
title="The materials"
description="Over the app backdrop, because translucency can only be judged against what shows through it."
>
<div className="glass-scene grid gap-4 rounded-xl p-6 sm:grid-cols-2">
{MATERIALS.map((material) => (
<Material key={material.utility} material={material} />
))}
</div>
</Section>
<Section
title="Interactive"
description="The same materials with a hover, for glass you can click. Hover moves one step up the ramp and never changes blur."
>
<div className="glass-scene flex flex-wrap gap-3 rounded-xl p-6">
<button
type="button"
className="glass-primary-interactive rounded-full px-5 py-2.5 text-body text-primary transition-colors"
>
glass-primary-interactive
</button>
<button
type="button"
className="glass-secondary-interactive rounded-full px-5 py-2.5 text-body text-primary transition-colors"
>
glass-secondary-interactive
</button>
</div>
</Section>
{GLASS ? (
<Section
title="The ramp"
description="Each step is the mode's own surface colour at an increasing opacity, which is what lets a hover move one step up instead of holding its own literal. Fills only — the rim is separate."
>
{/* One continuous strip rather than five separate swatches: the
subject is a progression, and gaps between cards let the backdrop
re-enter between them so each step is read against a different
part of the gradient instead of against its neighbours.
Deliberately not the shared `Swatch` — its hairline border reads
as the glass rim on a translucent fill. */}
<div className="glass-scene rounded-xl p-6">
<div className="flex overflow-hidden rounded-xl">
{GLASS.steps.map((step) => (
<div
key={step.variable}
className="blur-chrome flex min-w-0 flex-1 flex-col justify-end gap-1 px-3 py-4"
style={{ background: `var(${step.variable})` }}
>
<code className="truncate text-mono-sm text-primary">
glass {step.step}
</code>
<span className="truncate text-body-sm text-tertiary">
{step.job}
</span>
</div>
))}
</div>
</div>
</Section>
) : null}
<Section
title="The rim"
description="Real glass catches light along one edge and falls away on the opposite one, so the rim is a directional pair sharing one fixed light direction. Two inset shadows rather than a border, because a CSS border cannot hold a gradient and keep its radius."
>
<div className="glass-scene rounded-xl p-6">
<div className="glass-primary rounded-xl px-5 py-4">
<code className="whitespace-pre text-mono-sm text-primary">
{`inset 0 1px 0 var(--rim-lit)\ninset 0 -1px 0 var(--rim-shade)`}
</code>
</div>
</div>
</Section>
</>
);
}

View File

@@ -0,0 +1,90 @@
import type { ReactNode } from "react";
function inline(text: string): ReactNode[] {
let occurrence = 0;
return text.split(/(`[^`]+`|\*\*[^*]+\*\*)/).map((part) => {
if (part.startsWith("`") && part.endsWith("`")) {
occurrence += 1;
return <code key={`${part}-${occurrence}`}>{part.slice(1, -1)}</code>;
}
if (part.startsWith("**") && part.endsWith("**")) {
occurrence += 1;
return <strong key={`${part}-${occurrence}`}>{part.slice(2, -2)}</strong>;
}
return part;
});
}
/** A deliberately small reader for the three maintained, human-facing docs.
* These documents are prose first; this is not a general Markdown product
* renderer. Unsupported constructs remain readable text rather than creating a
* second documentation format that can drift from the source file. */
export function MarkdownPage({ source }: { source: string }) {
const lines = source.split("\n");
const content: ReactNode[] = [];
let paragraph: string[] = [];
let list: string[] = [];
const flushParagraph = () => {
if (paragraph.length) {
content.push(
<p
className="design-doc-paragraph text-body text-secondary"
key={`p-${content.length}`}
>
{inline(paragraph.join(" "))}
</p>,
);
paragraph = [];
}
};
const flushList = () => {
if (list.length) {
content.push(
<ul className="design-doc-list" key={`l-${content.length}`}>
{list.map((item) => (
<li className="text-body text-secondary" key={item}>
{inline(item)}
</li>
))}
</ul>,
);
list = [];
}
};
for (const line of lines) {
if (line.startsWith("# ")) {
flushParagraph();
flushList();
content.push(
<header className="design-doc-title" key={`h1-${content.length}`}>
<h1 className="text-title text-primary">{line.slice(2)}</h1>
</header>,
);
} else if (line.startsWith("## ")) {
flushParagraph();
flushList();
content.push(
<h2
className="design-doc-heading text-heading text-primary"
key={`h2-${content.length}`}
>
{line.slice(3)}
</h2>,
);
} else if (line.startsWith("- ")) {
flushParagraph();
list.push(line.slice(2));
} else if (line.trim() === "") {
flushParagraph();
flushList();
} else if (!line.startsWith("|") && !line.startsWith("```")) {
paragraph.push(line.trim());
}
}
flushParagraph();
flushList();
return <article className="design-doc">{content}</article>;
}

View File

@@ -0,0 +1,43 @@
import { MOTION } from "@/shared/tokens/registry";
import { FoundationScale } from "./FoundationScale";
import { PageHeader, Section } from "./primitives";
export function MotionPage() {
return (
<>
<PageHeader
title="Motion"
status="forming"
intro="State changes are quick and geometry settles deliberately. Direct manipulation itself remains immediate: a panel follows the pointer with no smoothing, then uses the settling curve only after release."
/>
<Section title="Roles">
<FoundationScale
items={MOTION.map((item) => ({
token: item.token,
value: item.value,
use: item.use,
}))}
/>
</Section>
<Section
title="Rules"
description="The values are small; the constraints are the system."
>
<ul className="flex list-disc flex-col gap-2 pl-5">
{[
"Direct manipulation follows the pointer exactly. Easing begins only after release.",
"Never animate blur. Change opacity when glass enters or leaves.",
"Reduced motion makes every transition immediate without changing state.",
"Motion explains selection, entry, exit, or changed geometry; it never decorates still content.",
].map((rule) => (
<li key={rule} className="text-body text-secondary">
{rule}
</li>
))}
</ul>
</Section>
</>
);
}

View File

@@ -0,0 +1,88 @@
import { Link } from "@tanstack/react-router";
import { GRAMMAR } from "@/shared/tokens/registry";
import { PageHeader, Section } from "./primitives";
const LAYERS: Array<[string, string, string]> = [
["Layer 0", "a hue", "a theme package, or a person's preference"],
["Layer 1", "--accent-1…12 --neutral-1…12 --glass-1…5", "private ramps"],
["Layer 2", "--bg-purple-9: var(--accent-9)", "public roles, point at steps"],
["Layer 3", "bg-purple-9", "components, roles only"],
];
export function OverviewPage() {
return (
<>
<PageHeader
title="Buzz Design System"
intro="A colour system small enough to hold in your head and precise enough that an agent picks the right value unsupervised. Structural colour is named and closed, because there are only a few right answers. Accent colour is a slot a theme or a person fills, because it should change without touching a component."
/>
<Section
title="The layers"
description="Only the role layer is ever used when building a screen. That separation is what lets the entire look change without editing a component."
>
<div className="flex flex-col gap-2 rounded-xl bg-neutral-2 px-6 py-5">
{LAYERS.map(([layer, what, why]) => (
<div key={layer} className="flex flex-wrap items-baseline gap-x-4">
<span className="w-16 shrink-0 text-body text-tertiary">
{layer}
</span>
<code className="min-w-0 flex-1 text-body-sm text-primary">
{what}
</code>
<span className="text-body-sm text-secondary">{why}</span>
</div>
))}
</div>
</Section>
<Section title="The grammar">
<div className="rounded-lg bg-neutral-11 px-5 py-4">
<code className="text-body text-neutral-1">{GRAMMAR}</code>
</div>
<p className="text-body-sm text-secondary">
The order is fixed, so there is one correct spelling. The practical
guidance for using and evolving it lives in{" "}
<Link to="/design/maintaining" className="text-purple-12 underline">
Maintaining the system
</Link>
.
</p>
</Section>
<Section
title="Where this stands"
description="Colour and typography are complete and ready to build against. Spacing, radius, and motion are not started — their pages say what is still to decide rather than pretending to a system that does not exist yet. Components come one at a time, as the product repeats something."
>
<div className="flex flex-wrap gap-2">
<Link
to="/design/color"
className="rounded-lg bg-purple-9 px-4 py-2 text-body text-on-accent transition-opacity hover:opacity-90"
>
Color
</Link>
<Link
to="/design/typography"
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Typography
</Link>
<Link
to="/design/glass"
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Glass
</Link>
<Link
to="/design/maintaining"
className="rounded-lg bg-neutral-2 px-4 py-2 text-body text-secondary transition-colors hover:bg-neutral-4 hover:text-primary"
>
Maintaining the system
</Link>
</div>
</Section>
</>
);
}

View File

@@ -0,0 +1,18 @@
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "tiptap-markdown";
/**
* Documentation-only round trip through the composer's existing parser.
* This is not Rich Content or a new product formatting implementation.
* Each immutable preview message mounts once with its submitted content.
*/
export function PreparedMessagePreview({ content }: { content: string }) {
const editor = useEditor({
extensions: [StarterKit, Markdown.configure({ html: false })],
content,
editable: false,
editorProps: { attributes: { "aria-label": "Preview message content" } },
});
return <EditorContent editor={editor} />;
}

View File

@@ -0,0 +1,39 @@
import { RADII } from "@/shared/tokens/registry";
import { FoundationScale } from "./FoundationScale";
import { PageHeader, Section } from "./primitives";
export function RadiusPage() {
return (
<>
<PageHeader
title="Radius"
status="forming"
intro="Four corner roles reproduce the Messages frame without turning every measured curve into a choice: dense rows, controls, major panels, and fully round identity or chrome."
/>
<Section title="Roles">
<FoundationScale
items={RADII.map((item) => ({
token: item.token,
value: item.value,
use: item.use,
}))}
/>
</Section>
<Section title="Relationship">
<div className="component-radius-demo bg-app">
{RADII.map((item) => (
<div
key={item.token}
className="component-radius-sample bg-panel text-body-sm text-secondary"
style={{ borderRadius: `var(${item.variable})` }}
>
{item.token}
</div>
))}
</div>
</Section>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { SPACE, SPACE_ROLES } from "@/shared/tokens/registry";
import { FoundationScale } from "./FoundationScale";
import { PageHeader, Section } from "./primitives";
export function SpacingPage() {
return (
<>
<PageHeader
title="Spacing"
status="forming"
intro="A short six-step rhythm extracted from the Messages frame. The first roles capture only the relationships repeated by the workspace and its dense navigator; new roles arrive when another real component earns them."
/>
<Section
title="Scale"
description="Components use these steps through roles where the distance carries product meaning."
>
<FoundationScale
items={SPACE.map((item) => ({
token: `space ${item.step}`,
value: item.value,
use: item.use,
}))}
/>
</Section>
<Section title="Roles">
<FoundationScale
items={SPACE_ROLES.map((item) => ({
token: item.token,
value: item.pointsAt,
use: item.use,
}))}
/>
</Section>
</>
);
}

View File

@@ -0,0 +1,18 @@
import agentsSource from "../../../../AGENTS.md?raw";
import designSource from "../../../../DESIGN.md?raw";
import maintainingSource from "../../../../MAINTAINING_DESIGN_SYSTEM.md?raw";
import { MarkdownPage } from "./MarkdownPage";
const DOCUMENTS = {
maintaining: maintainingSource,
design: designSource,
agents: agentsSource,
} as const;
export function SystemDocumentPage({
document,
}: {
document: keyof typeof DOCUMENTS;
}) {
return <MarkdownPage source={DOCUMENTS[document]} />;
}

View File

@@ -0,0 +1,137 @@
import {
TYPE_FAMILIES,
TYPE_RAMPS,
TYPE_ROLES,
type TypeRole,
} from "@/shared/tokens/registry";
import { Note, PageHeader, Row, Rows, Section, Specimens } from "./primitives";
/**
* Every specimen below is set in the role it documents, so the page is the
* system rather than a description of it. A role that reads badly here reads
* badly in the product.
*/
function RoleSpecimen({ role }: { role: TypeRole }) {
return (
<div className="flex flex-col gap-2">
<p
className={`${role.token} ${role.mono ? "font-mono" : ""} text-primary`}
>
{role.mono ? "createChannel(name, members)" : "Bring your agents in"}
</p>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<code className="text-mono text-purple-12">{role.token}</code>
<span className="text-body-sm text-tertiary">{role.pointsAt}</span>
<span className="text-body-sm text-tertiary">
{role.size} / {role.lineHeight} / {role.tracking} / {role.weight}
</span>
</div>
<p className="max-w-xl text-body-sm text-secondary">{role.use}</p>
</div>
);
}
export function TypographyPage() {
return (
<>
<PageHeader
title="Typography"
intro="Nine sizes, ten roles, two faces. A role carries its whole setting — size, line height, letter spacing, weight — because those four are one decision rather than four, so text-body alone produces correctly set text and there is nothing left to get wrong."
/>
<Section
title="The faces"
description="Both already ship in every current Buzz client, so this is a decision to keep rather than to make. Inter is drawn for interface text at small sizes, which is most of this product."
>
<Specimens>
{TYPE_FAMILIES.map((family) => (
<div key={family.token} className="flex flex-col gap-1.5">
<p
className={`text-heading text-primary ${
family.token === "font-mono" ? "font-mono" : "font-sans"
}`}
>
{family.name}
</p>
<code className="text-mono text-purple-12">{family.token}</code>
<p className="max-w-xl text-body-sm text-secondary">
{family.use}
</p>
</div>
))}
</Specimens>
</Section>
<Section
title="The roles"
description="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. Each specimen is set in the role it documents."
>
<Specimens>
{TYPE_ROLES.map((role) => (
<RoleSpecimen key={role.token} role={role} />
))}
</Specimens>
</Section>
{TYPE_RAMPS.map((ramp) => (
<Section key={ramp.id} title={ramp.name} description={ramp.description}>
<Rows>
{ramp.steps.map((step) => (
<Row key={`${ramp.id}-${step.step}`}>
<div className="flex flex-wrap items-baseline gap-x-4">
<code className="w-28 shrink-0 text-mono text-primary">
{ramp.id} {step.step}
</code>
<span className="w-20 shrink-0 text-body-sm text-secondary">
{step.value}
</span>
<span className="text-body-sm text-tertiary">{step.job}</span>
</div>
</Row>
))}
</Rows>
</Section>
))}
<Section
title="Two rules"
description="Both are inherited rather than invented — the existing client learned each of them the expensive way."
>
<Rows>
<Row>
<p className="text-body text-primary">
Every size is relative. Never px.
</p>
<p className="mt-1.5 max-w-xl text-body-sm text-secondary">
Fixed pixel text freezes against keyboard zoom and ignores the
person's font-size preference. The current client shipped a
message-timeline regression from exactly this and now has a CI
guard rejecting arbitrary size literals. This ramp derives
entirely from one virtual rem, so both dials work by construction.
</p>
</Row>
<Row>
<p className="text-body text-primary">
No all-caps, and no tracked-out labels.
</p>
<p className="mt-1.5 max-w-xl text-body-sm text-secondary">
A capitalised label is harder to read than the sentence-case
version and reads as enterprise chrome. Section labels earn their
quietness from size and colour — text-body-sm on text-tertiary —
rather than from being shouted. There is no uppercase utility in
this system.
</p>
</Row>
</Rows>
</Section>
<Note>
The Cash Sans and BlockUI type variables that appear in the design
exploration are contamination from another Figma library linked into
that file. They exist nowhere in any Buzz codebase and need no cleanup —
only a decision not to inherit them.
</Note>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { COMPONENTS } from "@/shared/ui/registry";
import {
baseUiBackingSentence,
flattenSentence,
} from "./baseUiBackingSentence";
const sentenceFor = (slug: string) =>
flattenSentence(baseUiBackingSentence(slug));
describe("inline inheritance description", () => {
it("names direct Base UI imports", () => {
expect(sentenceFor("button")).toBe("Inherits Base UI Button.");
expect(sentenceFor("avatar")).toBe("Inherits Base UI Avatar.");
expect(sentenceFor("tabs")).toBe("Inherits Base UI Tabs.");
});
it("names Buzz owners for indirect backing without duplicating them", () => {
expect(sentenceFor("composer")).toBe("Inherits Button.");
expect(sentenceFor("icon-button")).toBe("Inherits Button.");
expect(sentenceFor("inline-chip")).toBe("Inherits PreviewCard.");
});
it("combines both libraries in one sentence", () => {
expect(sentenceFor("search-field")).toBe(
"Inherits Base UI Field, Base UI Input and Button.",
);
});
it("adds no absence commentary for native components", () => {
expect(sentenceFor("panel-header")).toBe("");
});
it("links Buzz components internally and Base UI externally", () => {
expect(baseUiBackingSentence("composer")).toContainEqual({
kind: "link",
value: "Button",
href: "/design/components/button",
external: false,
});
expect(baseUiBackingSentence("button")).toContainEqual({
kind: "link",
value: "Base UI Button",
href: "https://base-ui.com/react/components/button",
external: true,
});
});
it("keeps every nonempty note concise and punctuated", () => {
for (const component of COMPONENTS) {
const sentence = sentenceFor(component.slug);
if (!sentence) continue;
expect(sentence.startsWith("Inherits ")).toBe(true);
expect(sentence.endsWith(".")).toBe(true);
expect(sentence).not.toContain("..");
expect(sentence).not.toContain(" ");
expect(sentence).not.toContain("through");
}
});
});

View File

@@ -0,0 +1,53 @@
import {
baseUiDocsUrl,
COMPONENTS,
resolveBaseUiBacking,
} from "@/shared/ui/registry";
export type SentenceSegment =
| { kind: "text"; value: string }
| { kind: "link"; value: string; href: string; external: boolean };
/** Direct Base UI imports link upstream; inherited backing links to its Buzz owner. */
export function baseUiBackingSentence(slug: string): SentenceSegment[] {
const { own, inherited } = resolveBaseUiBacking(slug);
const links: Extract<SentenceSegment, { kind: "link" }>[] = own.map(
(part) => ({
kind: "link",
value: `Base UI ${part.name}`,
href: baseUiDocsUrl(part),
external: true,
}),
);
const seen = new Set<string>();
for (const entry of inherited) {
const component = COMPONENTS.find(
(candidate) => candidate.name === entry.through,
);
if (!component || seen.has(component.slug)) continue;
seen.add(component.slug);
links.push({
kind: "link",
value: component.name,
href: `/design/components/${component.slug}`,
external: false,
});
}
if (!links.length) return [];
const segments: SentenceSegment[] = [{ kind: "text", value: "Inherits " }];
links.forEach((link, index) => {
if (index)
segments.push({
kind: "text",
value: index === links.length - 1 ? " and " : ", ",
});
segments.push(link);
});
segments.push({ kind: "text", value: "." });
return segments;
}
/** Plain text of the same segments used by the rendered description. */
export function flattenSentence(segments: readonly SentenceSegment[]): string {
return segments.map((segment) => segment.value).join("");
}

View File

@@ -0,0 +1,945 @@
import { FlexWorkspace } from "@/shared/ui/FlexWorkspace";
import {
ConversationSpecimen,
AgentWorkSpecimen,
WorkStepSpecimen,
ConversationMessageSpecimen,
ConversationStateSpecimen,
} from "./ConversationSpecimen";
import { BentoSpecimen } from "./BentoSpecimen";
import {
IconDots,
IconHash,
IconMessageCircle,
IconPlus,
IconUsers,
IconSettings,
} from "@tabler/icons-react";
import type { ReactNode } from "react";
import { useState } from "react";
import { Switch } from "@/shared/ui/Switch";
import { Accordion } from "@/shared/ui/Accordion";
import { Select } from "@/shared/ui/Select";
import { Avatar } from "@/shared/ui/Avatar";
import { InlineChip } from "@/shared/ui/InlineChip";
import { Button } from "@/shared/ui/Button";
import { ConversationHeader } from "@/shared/ui/ConversationHeader";
import { IconButton } from "@/shared/ui/IconButton";
import { NavigationItem } from "@/shared/ui/NavigationItem";
import { NavigationSection } from "@/shared/ui/NavigationSection";
import { PanelHeader } from "@/shared/ui/PanelHeader";
import { SearchField } from "@/shared/ui/SearchField";
import { Tabs } from "@/shared/ui/Tabs";
import { ComponentAnatomy } from "./ComponentAnatomy";
import {
ActivityRailSpecimen,
FormattingBarSpecimen,
MessageComposerSpecimen,
} from "./composerSpecimens";
import { Panel } from "@/shared/ui/Panel";
import { PreviewCard } from "@/shared/ui/PreviewCard";
import type { ChipAddress } from "@/shared/chips/address";
import { chipFaces } from "@/shared/chips/faceResolver";
const DESTINATIONS = [
{ value: "home", label: "Home" },
{ value: "messages", label: "Messages" },
{ value: "projects", label: "Projects" },
] as const;
type Destination = (typeof DESTINATIONS)[number]["value"];
function SpecimenFrame({
children,
backdrop = false,
}: {
children: ReactNode;
backdrop?: boolean;
}) {
return (
<div
className="component-specimen-frame"
data-backdrop={backdrop || undefined}
>
{children}
</div>
);
}
function SpecimenGroup({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">{label}</h2>
<SpecimenFrame>{children}</SpecimenFrame>
</section>
);
}
/**
* One sample with the prop that produced it printed underneath.
*
* A row of four icon buttons is unreadable without this — they differ only by
* fill, so there is no way to tell which one is `solid` and which is `chrome`,
* and the page is documentation. The caption names the prop as you would type
* it (`variant="solid"`), not a prose paraphrase, so reading the page tells you
* what to write.
*/
function Specimen({
prop,
children,
}: {
/** As typed in JSX, e.g. `variant="quiet"`. Omit for a default. */
prop?: string;
children: ReactNode;
}) {
return (
<div className="component-specimen">
{children}
<code className="component-specimen-prop text-mono-sm text-tertiary">
{prop ?? "default"}
</code>
</div>
);
}
function ButtonSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Variants">
<div className="component-specimen-row">
<Specimen prop='variant="primary"'>
<Button variant="primary">Save</Button>
</Specimen>
<Specimen prop='variant="quiet"'>
<Button variant="quiet">Save</Button>
</Specimen>
<Specimen prop='variant="ghost"'>
<Button variant="ghost">Save</Button>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="Sizes">
<div className="component-specimen-row">
<Specimen prop='size="compact"'>
<Button variant="quiet" size="compact">
Save
</Button>
</Specimen>
<Specimen prop='size="default"'>
<Button variant="quiet">Save</Button>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="States">
<div className="component-specimen-row">
<Specimen>
<Button variant="primary">Save</Button>
</Specimen>
<Specimen prop="disabled">
<Button variant="quiet" disabled>
Save
</Button>
</Specimen>
</div>
</SpecimenGroup>
</div>
);
}
function IconButtonSpecimen() {
const icons = {
add: <IconPlus size={16} stroke={1.7} aria-hidden="true" />,
more: <IconDots size={16} stroke={1.7} aria-hidden="true" />,
settings: <IconSettings size={16} stroke={1.7} aria-hidden="true" />,
};
return (
<div className="component-specimen-stack">
{/* `chrome` sits on the app backdrop, because it is a translucent glass
fill — on a flat panel it has nothing to be translucent over and reads
as a plain grey. */}
<SpecimenGroup label="Variants">
<div className="component-specimen-row">
<Specimen prop='variant="quiet"'>
<IconButton
aria-label="Quiet add"
icon={icons.add}
variant="quiet"
/>
</Specimen>
<Specimen prop='variant="ghost"'>
<IconButton
aria-label="Ghost more"
icon={icons.more}
variant="ghost"
/>
</Specimen>
<Specimen prop='variant="solid"'>
<IconButton
aria-label="Solid add"
icon={icons.add}
variant="solid"
/>
</Specimen>
<Specimen prop='variant="tint" shape="round"'>
<IconButton
aria-label="Round tinted send"
icon={icons.add}
shape="round"
variant="tint"
/>
</Specimen>
</div>
</SpecimenGroup>
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Chrome, over the workspace backdrop
</h2>
<SpecimenFrame backdrop>
<div className="component-specimen-row">
<Specimen prop='variant="chrome"'>
<IconButton
aria-label="Chrome settings"
icon={icons.settings}
variant="chrome"
/>
</Specimen>
</div>
</SpecimenFrame>
</section>
{/* Shown on `quiet`, not the default `ghost`: the three sizes differ only
in hit area (30 / 36 / 40px), and with no fill they render as three
identical 16px glyphs — the page would claim to show a size ramp while
showing nothing. A fill makes the box the sample. */}
<SpecimenGroup label="Sizes">
<div className="component-specimen-row">
<Specimen prop='size="compact"'>
<IconButton
aria-label="Compact settings"
icon={icons.settings}
variant="quiet"
size="compact"
/>
</Specimen>
<Specimen prop='size="toolbar"'>
<IconButton
aria-label="Toolbar settings"
icon={icons.settings}
variant="quiet"
size="toolbar"
/>
</Specimen>
<Specimen prop='size="default"'>
<IconButton
aria-label="Default settings"
icon={icons.settings}
variant="quiet"
/>
</Specimen>
<Specimen prop='size="large"'>
<IconButton
aria-label="Large settings"
icon={icons.settings}
variant="quiet"
size="large"
/>
</Specimen>
</div>
</SpecimenGroup>
<SpecimenGroup label="States">
<div className="component-specimen-row">
<Specimen>
<IconButton
aria-label="Enabled add"
icon={icons.add}
variant="quiet"
/>
</Specimen>
<Specimen prop="disabled">
<IconButton
aria-label="Disabled add"
icon={icons.add}
variant="quiet"
disabled
/>
</Specimen>
{(["tint", "solid"] as const).map((variant) => (
<Specimen
key={variant}
prop={`variant="${variant}" shape="round" disabled`}
>
<IconButton
aria-label={`Disabled round ${variant}`}
icon={icons.add}
shape="round"
variant={variant}
disabled
/>
</Specimen>
))}
</div>
</SpecimenGroup>
</div>
);
}
function AvatarSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Sizes">
<div className="component-specimen-row">
<Specimen prop='size="small"'>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
size="small"
/>
</Specimen>
<Specimen prop='size="default"'>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
/>
</Specimen>
<Specimen prop='size="large"'>
<Avatar
src="/design-system/morgan.png"
alt="Morgan Martin"
fallback="Morgan"
size="large"
/>
</Specimen>
</div>
</SpecimenGroup>
{/* No `src`, so the fallback initial shows. Same three sizes, because a
fallback has to hold the ramp as well as an image does. */}
<SpecimenGroup label="Fallback, with no src">
<div className="component-specimen-row">
<Specimen prop='size="small"'>
<Avatar alt="Cynthia Chen" fallback="Cynthia" size="small" />
</Specimen>
<Specimen prop='size="default"'>
<Avatar alt="Cynthia Chen" fallback="Cynthia" />
</Specimen>
<Specimen prop='size="large"'>
<Avatar alt="Cynthia Chen" fallback="Cynthia" size="large" />
</Specimen>
</div>
</SpecimenGroup>
</div>
);
}
function PanelSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Panel — the surface by itself">
{/* No invented header or copy inside this specimen. Panel owns a surface,
not what a product chooses to put on it, so an empty Panel is the
honest live component. The gradient is the surrounding context it is
designed to sit on, not content supplied by the specimen. */}
<div className="component-single-surface-demo">
<Panel aria-label="Panel surface" />
</div>
</SpecimenGroup>
</div>
);
}
/**
* The parts of Tabs, per variant.
*
* Two lists rather than one, because the interesting fact is that the *same*
* parts are drawn differently — the container and the tab carry no fill at all
* in `panel`, and the indicator changes from a pill to a bar.
*/
const TABS_CHROME_PARTS = [
{
name: "Container",
selector: ".buzz-tabs",
show: ["background", "shadow", "blur", "radius"],
note: "The glass material: fill, blur, and rim arrive together as glass-primary.",
},
{
name: "Selection",
selector: ".buzz-tabs-indicator",
show: ["background", "shadow", "radius"],
note: "An opaque pill behind the selected tab. Opaque because on glass, elevation reads as less translucency.",
},
{
name: "Selected tab",
selector: ".buzz-tabs-tab[data-selected]",
show: ["color", "background"],
note: "Draws no fill of its own — the pill behind it is the selection.",
},
{
name: "Unselected tab",
selector: ".buzz-tabs-tab:not([data-selected])",
show: ["color", "background"],
},
] as const;
const TABS_PANEL_PARTS = [
{
name: "Container",
selector: ".buzz-tabs",
show: ["background", "shadow", "blur", "radius"],
note: "Nothing drawn. On a panel the tabs are text and the bar is the only mark.",
},
{
name: "Selection",
selector: ".buzz-tabs-indicator",
show: ["background", "radius"],
note: "A 2px bar on the bottom edge, spanning the selected tab.",
},
{
name: "Selected tab",
selector: ".buzz-tabs-tab[data-selected]",
show: ["color", "background"],
note: "Same size as unselected — selection is colour and the bar, never weight, which would reflow the row.",
},
{
name: "Unselected tab",
selector: ".buzz-tabs-tab:not([data-selected])",
show: ["color", "background"],
},
] as const;
function TabsSpecimen() {
const [destination, setDestination] = useState<Destination>("messages");
const [panelDestination, setPanelDestination] =
useState<Destination>("messages");
const [iconDestination, setIconDestination] = useState<Destination>("home");
const iconItems = DESTINATIONS.map((item) => ({
...item,
icon: <IconMessageCircle size={16} stroke={1.7} aria-hidden="true" />,
}));
return (
<div className="component-specimen-stack">
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Chrome — a glass pill, for the app backdrop
</h2>
<SpecimenFrame backdrop>
<div data-anatomy="tabs-chrome">
<Tabs
value={destination}
items={DESTINATIONS}
label="Prototype destinations"
onValueChange={setDestination}
/>
</div>
</SpecimenFrame>
<ComponentAnatomy
scope='[data-anatomy="tabs-chrome"]'
caption="Which token draws each part of the chrome variant, read from the specimen above."
parts={TABS_CHROME_PARTS}
/>
</section>
{/* On a panel rather than the backdrop, because that is the whole point of
the variant — and because this specimen showing only the chrome version
on only the gradient is why the panel failure went unnoticed until it
appeared on a real page. */}
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Panel — an underline, for a plain surface
</h2>
<SpecimenFrame>
<div data-anatomy="tabs-panel">
<Tabs
value={panelDestination}
items={DESTINATIONS}
label="Prototype destinations on a panel"
onValueChange={setPanelDestination}
variant="panel"
/>
</div>
</SpecimenFrame>
<ComponentAnatomy
scope='[data-anatomy="tabs-panel"]'
caption="The same parts in the panel variant. The container and the tab draw nothing at all, which is what makes this legible on a plain surface: there is no second fill to collide with the first."
parts={TABS_PANEL_PARTS}
/>
</section>
<section className="component-specimen-group">
<h2 className="text-body-sm text-tertiary">
Chrome, with icons and a trailing action outside the selection
</h2>
<SpecimenFrame backdrop>
<Tabs
value={iconDestination}
items={iconItems}
label="Prototype destinations with icons"
onValueChange={setIconDestination}
trailingAction={
<IconButton
aria-label="Create"
icon={<IconPlus size={16} stroke={1.7} aria-hidden="true" />}
size="compact"
/>
}
/>
</SpecimenFrame>
</section>
</div>
);
}
function ConversationHeaderSpecimen() {
const people = (
<IconButton
aria-label="View conversation participants"
icon={<IconUsers size={16} stroke={1.7} aria-hidden="true" />}
size="compact"
/>
);
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Channel — identity and people action">
<ConversationHeader
icon={<IconHash size={16} stroke={1.7} />}
title="design"
actions={people}
/>
</SpecimenGroup>
<SpecimenGroup label="Session — title status and contextual action">
<ConversationHeader
icon={<IconMessageCircle size={16} stroke={1.7} />}
title="Review agent setup"
metadata={<span className="device-label">On this device</span>}
actions={
<>
{people}
<Button variant="quiet" size="compact">
Start session
</Button>
</>
}
/>
</SpecimenGroup>
<SpecimenGroup label="Origin context — only when the title alone is ambiguous">
<ConversationHeader
icon={<IconMessageCircle size={16} stroke={1.7} />}
title="New session"
context={
<>
<IconHash size={13} stroke={1.6} aria-hidden="true" /> From design
</>
}
/>
</SpecimenGroup>
<SpecimenGroup label="Narrow — full context and actions remain available">
<div className="w-full max-w-xs">
<ConversationHeader
title="Review the conversation hierarchy"
metadata="On this device"
context="From design"
actions={
<>
{people}
<Button variant="quiet" size="compact">
Start session
</Button>
</>
}
/>
</div>
</SpecimenGroup>
</div>
);
}
function PanelHeaderSpecimen() {
const actions = (
<IconButton
aria-label="More conversation actions"
icon={<IconDots size={16} stroke={1.7} aria-hidden="true" />}
size="compact"
/>
);
return (
<div className="component-specimen-stack">
{/* PanelHeader is the header row itself — optional icon, title, and actions.
Panel is intentionally not wrapped around it: Panel owns the surface and
PanelHeader owns neither its container nor the content below it. The end
control is the existing IconButton component, composed through `actions`
rather than rebuilt as a raw button. */}
<SpecimenGroup label="Default — icon, title, and IconButton action">
<PanelHeader
title="Conversation"
icon={<IconMessageCircle size={16} stroke={1.7} aria-hidden="true" />}
actions={actions}
/>
</SpecimenGroup>
<SpecimenGroup label="Compact — title and IconButton action">
<PanelHeader variant="compact" title="Thread" actions={actions} />
</SpecimenGroup>
</div>
);
}
function SearchFieldSpecimen() {
const [emptyQuery, setEmptyQuery] = useState("");
const [filledQuery, setFilledQuery] = useState("design");
return (
<div className="component-specimen-stack">
<SpecimenGroup label='value="" — the clear action is absent'>
<div className="component-field-demo">
<SearchField
value={emptyQuery}
onValueChange={setEmptyQuery}
label="Find a channel"
placeholder="Search"
/>
</div>
</SpecimenGroup>
<SpecimenGroup label='value="design" — the clear action appears'>
<div className="component-field-demo">
<SearchField
value={filledQuery}
onValueChange={setFilledQuery}
label="Find a channel"
placeholder="Search"
/>
</div>
</SpecimenGroup>
</div>
);
}
function NavigationSectionSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label='label="Pinned", with rows as children'>
<div className="component-navigation-section-demo">
<NavigationSection label="Pinned">
<NavigationItem
label="buzz-design"
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
/>
<NavigationItem
label="desktop-new"
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
/>
</NavigationSection>
</div>
</SpecimenGroup>
<SpecimenGroup label="Two sections adjacent — the gap between them is the grouping">
<div className="component-navigation-section-demo">
<NavigationSection label="Projects">
<NavigationItem label="berd-main" />
</NavigationSection>
<NavigationSection label="Personal">
<NavigationItem label="design-system" />
</NavigationSection>
</div>
</SpecimenGroup>
</div>
);
}
function NavigationItemSpecimen() {
const [selected, setSelected] = useState("buzz-design");
return (
<div className="component-specimen-stack">
{/* Interactive: clicking moves `selected`, so the selected fill and the
unselected rest state are both visible at once and comparable. */}
<SpecimenGroup label="Selected, and trailing for metadata — click to move the selection">
<div className="component-navigation-section-demo">
<NavigationItem
label="buzz-design"
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
selected={selected === "buzz-design"}
onClick={() => setSelected("buzz-design")}
/>
<NavigationItem
label="desktop-new"
icon={<IconHash size={16} stroke={1.7} aria-hidden="true" />}
trailing={<span className="text-body-sm">3</span>}
selected={selected === "desktop-new"}
onClick={() => setSelected("desktop-new")}
/>
</div>
</SpecimenGroup>
<SpecimenGroup label="Inset — one level of nesting under a row">
<div className="component-navigation-section-demo">
<NavigationItem
label="Session interaction model"
icon={
<IconMessageCircle size={16} stroke={1.7} aria-hidden="true" />
}
inset
selected={selected === "session"}
onClick={() => setSelected("session")}
/>
</div>
</SpecimenGroup>
</div>
);
}
const CHIP_PERSON: ChipAddress = { kind: "person", id: "pk-morgan" };
const CHIP_AGENT: ChipAddress = { kind: "agent", id: "pk-morgarita" };
const CHIP_CHANNEL: ChipAddress = { kind: "channel", id: "ch-buzz-team" };
const CHIP_MESSAGE: ChipAddress = { kind: "message", id: "ev-thread-root" };
const CHIP_LINK: ChipAddress = {
kind: "link",
id: "https://github.com/block/buzz",
};
const CHIP_UNRESOLVED: ChipAddress = {
kind: "person",
id: "9f2c4a1b7e5d8306a4b2c1d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9",
};
const CHIP_LONG: ChipAddress = { kind: "person", id: "pk-long" };
const CHIP_KIND_ROWS = [
{ label: "Person", address: CHIP_PERSON },
{ label: "Agent", address: CHIP_AGENT },
{ label: "Channel", address: CHIP_CHANNEL },
{ label: "Message", address: CHIP_MESSAGE },
{ label: "Link", address: CHIP_LINK },
] as const;
/**
* Seeds faces so the specimens show resolved chips. The product resolves these
* from real identity; the page only needs the shapes to be visible.
*/
function seedChipFaces() {
chipFaces.put(CHIP_PERSON, {
label: "Morgan Martin",
loading: false,
resolved: true,
});
chipFaces.put(CHIP_AGENT, {
label: "Morgarita",
loading: false,
resolved: true,
});
chipFaces.put(CHIP_CHANNEL, {
label: "buzz-team",
loading: false,
resolved: true,
});
chipFaces.put(CHIP_MESSAGE, {
label: "buzz-team",
loading: false,
resolved: true,
});
chipFaces.put(CHIP_LINK, {
label: "github.com/block/buzz",
loading: false,
resolved: true,
});
chipFaces.put(CHIP_LONG, {
label: "A deliberately very long display name that must truncate",
loading: false,
resolved: true,
});
}
function PreviewCardSpecimen() {
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Default">
<p className="text-body text-primary">
<PreviewCard
trigger={
<button type="button" className="buzz-preview-card-example">
Morgan Martin
</button>
}
>
<span className="buzz-preview-card-kind text-body-sm text-tertiary">
Person
</span>
<span className="buzz-preview-card-name text-body text-primary">
Morgan Martin
</span>
<span className="text-body-sm text-secondary">
Product designer
</span>
</PreviewCard>{" "}
is reviewing the first Composer composition.
</p>
</SpecimenGroup>
</div>
);
}
function InlineChipSpecimen() {
const [activated, setActivated] = useState<string | null>(null);
seedChipFaces();
return (
<div className="component-specimen-stack">
<SpecimenGroup label="Kinds">
{/* Caption under the sample, not in a fixed-width column beside it: a
label column wide enough for `kind: "channel"` left the link chip
nowhere to truncate and pushed it off a 380px viewport. */}
<div className="component-specimen-row">
{CHIP_KIND_ROWS.map((row) => (
<Specimen key={row.label} prop={`kind: "${row.address.kind}"`}>
<InlineChip address={row.address} />
</Specimen>
))}
</div>
</SpecimenGroup>
<SpecimenGroup label="In a sentence">
<p className="text-body text-primary">
Asked <InlineChip address={CHIP_PERSON} /> and{" "}
<InlineChip address={CHIP_AGENT} /> to look at{" "}
<InlineChip address={CHIP_MESSAGE} /> in{" "}
<InlineChip address={CHIP_CHANNEL} />, alongside{" "}
<InlineChip address={CHIP_LINK} /> before the review.
</p>
</SpecimenGroup>
<SpecimenGroup label="Resolved preview">
<p className="text-body text-primary">
Hover or focus <InlineChip address={CHIP_PERSON} /> to reveal its
quiet preview. It supplies context without opening another surface.
</p>
</SpecimenGroup>
<SpecimenGroup label="Inert rendering">
<p className="text-body text-primary">
A surface that cannot honestly reveal reference detail renders an
inert chip: <InlineChip address={CHIP_PERSON} interactive={false} />
</p>
</SpecimenGroup>
<SpecimenGroup label="Unresolved reference">
<div className="flex flex-col gap-3">
<div className="component-specimen-row">
<InlineChip address={CHIP_UNRESOLVED} />
</div>
<p className="max-w-md text-body-sm text-tertiary">
No name to show and nothing to open, so it reads as inert and
announces itself as unresolved rather than speaking an identity
fragment aloud.
</p>
</div>
</SpecimenGroup>
<SpecimenGroup label="Long label truncates">
<div className="component-specimen-row">
<InlineChip address={CHIP_LONG} />
</div>
</SpecimenGroup>
<SpecimenGroup label="Wrapping across lines">
<p className="max-w-xs text-body text-primary">
A chip sits in the text flow, so a line break falls before or after it
and never inside it: <InlineChip address={CHIP_PERSON} />{" "}
<InlineChip address={CHIP_AGENT} /> <InlineChip address={CHIP_LINK} />
</p>
</SpecimenGroup>
<SpecimenGroup label="Activation">
<div className="component-specimen-row">
<InlineChip
address={CHIP_PERSON}
onActivate={(address) => setActivated(address.id)}
/>
<span className="text-body-sm text-tertiary">
{activated ? `Opened ${activated}` : "Not activated"}
</span>
</div>
</SpecimenGroup>
</div>
);
}
function SwitchSpecimen() {
const [checked, setChecked] = useState(false);
return (
<div className="component-specimen-stack">
<Switch
checked={checked}
onCheckedChange={setChecked}
label="Show agent activity"
/>
<Switch checked label="Show agent activity" />
<Switch disabled label="Show agent activity" />
</div>
);
}
function SelectSpecimen() {
const [value, setValue] = useState("channel");
return (
<Select
label="Preview context"
value={value}
onValueChange={setValue}
groups={[
{
label: "Destination",
options: [
{ value: "channel", label: "Channel" },
{ value: "session", label: "Session" },
{ value: "dm", label: "Direct message" },
],
},
]}
/>
);
}
export const COMPONENT_SPECIMENS: Record<string, () => ReactNode> = {
"flex-workspace": FlexWorkspace,
conversation: ConversationSpecimen,
"conversation-message": ConversationMessageSpecimen,
"conversation-state": ConversationStateSpecimen,
"agent-work-area": AgentWorkSpecimen,
"work-step": WorkStepSpecimen,
workspace: BentoSpecimen,
button: ButtonSpecimen,
"icon-button": IconButtonSpecimen,
avatar: AvatarSpecimen,
"preview-card": PreviewCardSpecimen,
"inline-chip": InlineChipSpecimen,
panel: PanelSpecimen,
tabs: TabsSpecimen,
select: SelectSpecimen,
switch: SwitchSpecimen,
accordion: () => (
<Accordion
items={[
{
value: "purpose",
title: "When to use an accordion",
content: (
<p className="text-body">
Use a disclosure for supporting content that does not need to be
visible all the time.
</p>
),
},
{
value: "behavior",
title: "Keyboard behavior",
content: (
<p className="text-body">
Focus a heading and press Enter or Space to expand it.
</p>
),
},
]}
/>
),
"conversation-header": ConversationHeaderSpecimen,
"panel-header": PanelHeaderSpecimen,
"search-field": SearchFieldSpecimen,
"navigation-section": NavigationSectionSpecimen,
"navigation-item": NavigationItemSpecimen,
composer: MessageComposerSpecimen,
"composer-formatting-bar": FormattingBarSpecimen,
"activity-rail": ActivityRailSpecimen,
};

View File

@@ -0,0 +1,90 @@
import { Switch } from "@/shared/ui/Switch";
import { useState } from "react";
import type { AgentTurn } from "@/features/agent-activity/types";
import { ConversationComposerDock } from "@/features/conversation/ui/ConversationComposerDock";
import { MessageComposer } from "@/features/composer/ui/MessageComposer";
export const WORKING_TURN: AgentTurn = {
key: "design:vogue:session:turn",
agentPubkey: "vogue",
agentName: "Vogue",
sessionId: "session",
turnId: "turn",
channelId: "design",
status: "running",
items: [
{
id: "review",
label: "Reviewing the interaction plan",
status: "running",
timestamp: "2026-09-08T03:00:00.000Z",
},
],
};
export function ComposerSpecimen({
turns = [],
responseControl,
}: {
turns?: AgentTurn[];
responseControl?: { state: "responding" | "stopping" };
}) {
const [draft, setDraft] = useState("");
return (
<div className="composer-page-specimen">
<ConversationComposerDock
autoFocus={false}
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder="Reply in this session"
responseControl={responseControl}
turns={turns}
/>
</div>
);
}
export function ActivityRailSpecimen() {
const [working, setWorking] = useState(true);
return (
<div className="component-specimen-stack">
<Switch
checked={working}
onCheckedChange={setWorking}
label="Show agent activity"
/>
<ComposerSpecimen turns={working ? [WORKING_TURN] : []} />
</div>
);
}
export function MessageComposerSpecimen() {
const [draft, setDraft] = useState("");
return (
<MessageComposer
autoFocus={false}
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder="Reply in this session"
/>
);
}
/** The formatting bar is never useful alone: mount it through its real composer. */
export function FormattingBarSpecimen() {
const [draft, setDraft] = useState("Select some text, then format it.");
return (
<MessageComposer
autoFocus={false}
defaultFormattingOpen
draft={draft}
onDraftChange={setDraft}
onSend={async () => undefined}
placeholder="Reply in this session"
/>
);
}

Some files were not shown because too many files have changed in this diff Show More