mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
Merge main with desktop-new UI work
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
73
Justfile
73
Justfile
@@ -660,6 +660,79 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
|
||||
FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm)
|
||||
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
|
||||
# Run desktop-new in dev mode with the same local relay, services, ports, and identity as `dev`.
|
||||
desktop-new-dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
export PATH="{{justfile_directory()}}/bin:$PATH"
|
||||
set -o allexport
|
||||
source .env
|
||||
set +o allexport
|
||||
bind_addr="${BUZZ_BIND_ADDR:-0.0.0.0:3000}"
|
||||
relay_port="${bind_addr##*:}"; [[ -n "$relay_port" ]] || relay_port=3000
|
||||
health_port="${BUZZ_HEALTH_PORT:-8080}"
|
||||
metrics_port="${BUZZ_METRICS_PORT:-9102}"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
for spec in "relay:$relay_port" "health:$health_port" "metrics:$metrics_port"; do
|
||||
name="${spec%%:*}"; port="${spec##*:}"
|
||||
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "Error: $name port $port is already in use; refusing to launch desktop against a stale relay." >&2
|
||||
lsof -nP -iTCP:"$port" -sTCP:LISTEN >&2 || true
|
||||
echo "Stop the process above (often a stale buzz-relay) and rerun: just dev" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay
|
||||
# Docker Desktop's forwarded MinIO port can stall under the deployment
|
||||
# probe's 32 concurrent writers. Keep the gate enabled in local dev, using
|
||||
# the bounded profile already used by the relay test launcher.
|
||||
export BUZZ_GIT_PROBE_WRITERS="${BUZZ_GIT_PROBE_WRITERS:-8}"
|
||||
export BUZZ_GIT_PROBE_ROUNDS="${BUZZ_GIT_PROBE_ROUNDS:-2}"
|
||||
TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory")
|
||||
"${TARGET_DIR}/debug/buzz-relay" &
|
||||
RELAY_PID=$!
|
||||
cleanup() {
|
||||
[[ -n "${INSTANCE_ID:-}" ]] && ../scripts/cleanup-instance-agents.sh "$INSTANCE_ID" || true
|
||||
kill "$RELAY_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
relay_ready=false
|
||||
for _ in $(seq 1 120); do
|
||||
if ! kill -0 "$RELAY_PID" 2>/dev/null; then
|
||||
echo "Error: buzz-relay exited during startup; refusing to launch desktop." >&2
|
||||
wait "$RELAY_PID" || true
|
||||
exit 1
|
||||
fi
|
||||
if curl --silent --fail --max-time 1 "http://127.0.0.1:${health_port}/_readiness" >/dev/null; then
|
||||
relay_ready=true
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
if [[ "$relay_ready" != true ]]; then
|
||||
echo "Error: buzz-relay did not become healthy within 60 seconds; refusing to launch desktop." >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -d desktop/node_modules ]] || pnpm --dir desktop install
|
||||
[[ -d desktop-new/node_modules ]] || pnpm --dir desktop-new install
|
||||
cd {{desktop_dir}}
|
||||
source ../scripts/instance-env.sh
|
||||
# Keep instance-env's worktree-specific identifier, ports, icon, reset URL,
|
||||
# and identity, but point the native runtime at the independent client.
|
||||
export BUZZ_TAURI_CONFIG=$(node -e '
|
||||
const config = JSON.parse(process.env.BUZZ_TAURI_CONFIG);
|
||||
config.productName = `Buzz Sessions (${process.env.BUZZ_WORKTREE_LABEL ?? "Dev"})`;
|
||||
config.build.devUrl = `http://localhost:${process.env.BUZZ_VITE_PORT}${process.env.BUZZ_RESET_WEBVIEW_STATE === "1" ? "?resetDevState=1" : ""}`;
|
||||
config.build.beforeDevCommand = `exec pnpm --dir ../desktop-new exec vite --port ${process.env.BUZZ_VITE_PORT} --strictPort`;
|
||||
console.log(JSON.stringify(config));
|
||||
')
|
||||
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.BUZZ_TAURI_CONFIG).identifier)")
|
||||
echo "Starting desktop-new on Vite port ${BUZZ_VITE_PORT}, relay ${BUZZ_RELAY_URL}"
|
||||
FEATURES=(); [[ -n "{{mesh}}" ]] && FEATURES=(--features mesh-llm)
|
||||
pnpm exec tauri dev ${FEATURES[@]+"${FEATURES[@]}"} --config "$BUZZ_TAURI_CONFIG" {{ARGS}}
|
||||
|
||||
# Run only the desktop app. No relay, database, Docker, migrations, or .env are needed.
|
||||
# The app opens normally and asks for a community before making a relay connection.
|
||||
desktop-standalone *ARGS: _ensure-sidecar-stubs
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
14
desktop-new/.gitignore
vendored
Normal 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
326
desktop-new/AGENTS.md
Normal 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
408
desktop-new/DESIGN.md
Normal 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.
|
||||
75
desktop-new/MAINTAINING_DESIGN_SYSTEM.md
Normal file
75
desktop-new/MAINTAINING_DESIGN_SYSTEM.md
Normal 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
3
desktop-new/biome.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../biome.json"]
|
||||
}
|
||||
12
desktop-new/index.html
Normal file
12
desktop-new/index.html
Normal 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
55
desktop-new/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
desktop-new/playwright.config.ts
Normal file
11
desktop-new/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
5
desktop-new/postcss.config.js
Normal file
5
desktop-new/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
BIN
desktop-new/public/design-system/morgan.png
Normal file
BIN
desktop-new/public/design-system/morgan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 414 KiB |
99
desktop-new/scripts/apca.mjs
Normal file
99
desktop-new/scripts/apca.mjs
Normal 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);
|
||||
}
|
||||
385
desktop-new/scripts/check-color.mjs
Normal file
385
desktop-new/scripts/check-color.mjs
Normal 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);
|
||||
250
desktop-new/scripts/check-contrast.mjs
Normal file
250
desktop-new/scripts/check-contrast.mjs
Normal 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}`);
|
||||
}
|
||||
218
desktop-new/scripts/check-type.mjs
Normal file
218
desktop-new/scripts/check-type.mjs
Normal 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");
|
||||
143
desktop-new/scripts/token-consumers.mjs
Normal file
143
desktop-new/scripts/token-consumers.mjs
Normal 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}`);
|
||||
}
|
||||
3
desktop-new/src/app/routes/__root.tsx
Normal file
3
desktop-new/src/app/routes/__root.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({ component: () => <Outlet /> });
|
||||
7
desktop-new/src/app/routes/design.tsx
Normal file
7
desktop-new/src/app/routes/design.tsx
Normal 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,
|
||||
});
|
||||
6
desktop-new/src/app/routes/design/agents-guide.tsx
Normal file
6
desktop-new/src/app/routes/design/agents-guide.tsx
Normal 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" />,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/color.tsx
Normal file
7
desktop-new/src/app/routes/design/color.tsx
Normal 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,
|
||||
});
|
||||
14
desktop-new/src/app/routes/design/color_.table.tsx
Normal file
14
desktop-new/src/app/routes/design/color_.table.tsx
Normal 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,
|
||||
});
|
||||
6
desktop-new/src/app/routes/design/components.tsx
Normal file
6
desktop-new/src/app/routes/design/components.tsx
Normal 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,
|
||||
});
|
||||
11
desktop-new/src/app/routes/design/components_.$component.tsx
Normal file
11
desktop-new/src/app/routes/design/components_.$component.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
6
desktop-new/src/app/routes/design/design-guide.tsx
Normal file
6
desktop-new/src/app/routes/design/design-guide.tsx
Normal 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" />,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/elevation.tsx
Normal file
7
desktop-new/src/app/routes/design/elevation.tsx
Normal 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,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/glass.tsx
Normal file
7
desktop-new/src/app/routes/design/glass.tsx
Normal 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,
|
||||
});
|
||||
5
desktop-new/src/app/routes/design/index.tsx
Normal file
5
desktop-new/src/app/routes/design/index.tsx
Normal 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 });
|
||||
6
desktop-new/src/app/routes/design/maintaining.tsx
Normal file
6
desktop-new/src/app/routes/design/maintaining.tsx
Normal 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" />,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/motion.tsx
Normal file
7
desktop-new/src/app/routes/design/motion.tsx
Normal 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,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/radius.tsx
Normal file
7
desktop-new/src/app/routes/design/radius.tsx
Normal 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,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/spacing.tsx
Normal file
7
desktop-new/src/app/routes/design/spacing.tsx
Normal 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,
|
||||
});
|
||||
7
desktop-new/src/app/routes/design/typography.tsx
Normal file
7
desktop-new/src/app/routes/design/typography.tsx
Normal 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,
|
||||
});
|
||||
4
desktop-new/src/app/routes/index.tsx
Normal file
4
desktop-new/src/app/routes/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AppShell } from "@/features/sessions/ui/AppShell";
|
||||
|
||||
export const Route = createFileRoute("/")({ component: AppShell });
|
||||
7
desktop-new/src/app/routes/spike.composer.tsx
Normal file
7
desktop-new/src/app/routes/spike.composer.tsx
Normal 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,
|
||||
});
|
||||
48
desktop-new/src/app/useKeyboardFocusVisibility.ts
Normal file
48
desktop-new/src/app/useKeyboardFocusVisibility.ts
Normal 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;
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
142
desktop-new/src/features/agent-activity/activityProjection.ts
Normal file
142
desktop-new/src/features/agent-activity/activityProjection.ts
Normal 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),
|
||||
),
|
||||
};
|
||||
}
|
||||
28
desktop-new/src/features/agent-activity/types.ts
Normal file
28
desktop-new/src/features/agent-activity/types.ts
Normal 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[];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
135
desktop-new/src/features/agent-activity/ui/AgentWorkArea.tsx
Normal file
135
desktop-new/src/features/agent-activity/ui/AgentWorkArea.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
desktop-new/src/features/agent-activity/ui/WorkStep.tsx
Normal file
80
desktop-new/src/features/agent-activity/ui/WorkStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
desktop-new/src/features/agent-activity/ui/work.css
Normal file
124
desktop-new/src/features/agent-activity/ui/work.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }[];
|
||||
};
|
||||
47
desktop-new/src/features/agent-activity/useAgentActivity.ts
Normal file
47
desktop-new/src/features/agent-activity/useAgentActivity.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
180
desktop-new/src/features/agents/runtime.ts
Normal file
180
desktop-new/src/features/agents/runtime.ts
Normal 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 }),
|
||||
);
|
||||
},
|
||||
};
|
||||
37
desktop-new/src/features/agents/types.ts
Normal file
37
desktop-new/src/features/agents/types.ts
Normal 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;
|
||||
};
|
||||
345
desktop-new/src/features/agents/ui/AgentsWorkspace.tsx
Normal file
345
desktop-new/src/features/agents/ui/AgentsWorkspace.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
}
|
||||
146
desktop-new/src/features/composer-spike/ui/ComposerSpikePage.tsx
Normal file
146
desktop-new/src/features/composer-spike/ui/ComposerSpikePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
desktop-new/src/features/composer/chipNode.ts
Normal file
134
desktop-new/src/features/composer/chipNode.ts
Normal 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 };
|
||||
}
|
||||
32
desktop-new/src/features/composer/ui/ChipNodeView.tsx
Normal file
32
desktop-new/src/features/composer/ui/ChipNodeView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
65
desktop-new/src/features/composer/ui/ComposerLinkDialog.tsx
Normal file
65
desktop-new/src/features/composer/ui/ComposerLinkDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
desktop-new/src/features/composer/ui/EmojiPicker.tsx
Normal file
333
desktop-new/src/features/composer/ui/EmojiPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
421
desktop-new/src/features/composer/ui/MessageComposer.tsx
Normal file
421
desktop-new/src/features/composer/ui/MessageComposer.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
57
desktop-new/src/features/composer/useComposerDrafts.ts
Normal file
57
desktop-new/src/features/composer/useComposerDrafts.ts
Normal 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 };
|
||||
}
|
||||
96
desktop-new/src/features/conversation/channelWindow.test.ts
Normal file
96
desktop-new/src/features/conversation/channelWindow.test.ts
Normal 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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
128
desktop-new/src/features/conversation/channelWindow.ts
Normal file
128
desktop-new/src/features/conversation/channelWindow.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
29
desktop-new/src/features/conversation/ui/MessageContent.tsx
Normal file
29
desktop-new/src/features/conversation/ui/MessageContent.tsx
Normal 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} />;
|
||||
}
|
||||
32
desktop-new/src/features/conversation/ui/SessionExchange.tsx
Normal file
32
desktop-new/src/features/conversation/ui/SessionExchange.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
desktop-new/src/features/conversation/ui/conversation.css
Normal file
83
desktop-new/src/features/conversation/ui/conversation.css
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
desktop-new/src/features/conversation/useConversation.ts
Normal file
203
desktop-new/src/features/conversation/useConversation.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
253
desktop-new/src/features/design-system/ui/BaseUiPage.tsx
Normal file
253
desktop-new/src/features/design-system/ui/BaseUiPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
desktop-new/src/features/design-system/ui/BentoSpecimen.tsx
Normal file
34
desktop-new/src/features/design-system/ui/BentoSpecimen.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
322
desktop-new/src/features/design-system/ui/ColorTablePage.tsx
Normal file
322
desktop-new/src/features/design-system/ui/ColorTablePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
desktop-new/src/features/design-system/ui/ColourPage.tsx
Normal file
272
desktop-new/src/features/design-system/ui/ColourPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
331
desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx
Normal file
331
desktop-new/src/features/design-system/ui/ComponentAnatomy.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
desktop-new/src/features/design-system/ui/ComponentsPage.tsx
Normal file
54
desktop-new/src/features/design-system/ui/ComponentsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
200
desktop-new/src/features/design-system/ui/DesignSystemLayout.tsx
Normal file
200
desktop-new/src/features/design-system/ui/DesignSystemLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
desktop-new/src/features/design-system/ui/ElevationPage.tsx
Normal file
62
desktop-new/src/features/design-system/ui/ElevationPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
126
desktop-new/src/features/design-system/ui/GlassPage.tsx
Normal file
126
desktop-new/src/features/design-system/ui/GlassPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
desktop-new/src/features/design-system/ui/MarkdownPage.tsx
Normal file
90
desktop-new/src/features/design-system/ui/MarkdownPage.tsx
Normal 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>;
|
||||
}
|
||||
43
desktop-new/src/features/design-system/ui/MotionPage.tsx
Normal file
43
desktop-new/src/features/design-system/ui/MotionPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
desktop-new/src/features/design-system/ui/OverviewPage.tsx
Normal file
88
desktop-new/src/features/design-system/ui/OverviewPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
39
desktop-new/src/features/design-system/ui/RadiusPage.tsx
Normal file
39
desktop-new/src/features/design-system/ui/RadiusPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
desktop-new/src/features/design-system/ui/SpacingPage.tsx
Normal file
38
desktop-new/src/features/design-system/ui/SpacingPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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]} />;
|
||||
}
|
||||
137
desktop-new/src/features/design-system/ui/TypographyPage.tsx
Normal file
137
desktop-new/src/features/design-system/ui/TypographyPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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("");
|
||||
}
|
||||
945
desktop-new/src/features/design-system/ui/componentSpecimens.tsx
Normal file
945
desktop-new/src/features/design-system/ui/componentSpecimens.tsx
Normal 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,
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user