1. KB list
/kb apps/web/app/(protected)/kb/page.tsxLanding for the KB experience. Personal KB at top, then Profile placeholder, then team-grant KBs grouped by team. Toggle between list and Constellation views.
Your Knowledge Bubbles
Yours, your teams', the ones you've been invited to.
Ryan's personal
Your public profile
SageOx Internal
sageox/sageox-app
sageox/scribe
#cast-team
Q3 planning
provisioning…
2. Create a new bubble
/kb/newCustom or repo KB. Personal & channel KBs are not user-created (auto-provisioned). Team-context KBs auto-provision with the team itself, so they're not in the picker either. Fields below the Kind selector adapt based on what's chosen — Custom asks for an owner (yourself or a team you're in); Repo asks for a GitHub account + repo and inherits ownership from the team that owns the repo.
Create a bubble
A bubble is a private space for related thinking — notes, discussions, files, agent memory.
#q3-planning3. Team page — the team entity
/team/[team_id] distinct from any single KB
The team entity is not the same as any of its KBs. The team page is the
rollup of everything the team owns: members, all bubbles, repos, billing, settings.
Its handle is @sageox-internal. The team-context bubble (#sageox-internal)
is just one of those owned KBs — visible below in the constellation widget alongside the team's repo bubbles, custom bubbles, and channel bubbles.
SageOx Internal
Team OwnerThis week
Active work · across all 9 bubbles
Recent discussions · across all 9 bubbles
@sageox-internal is the team entity — members, billing, the rollup
of everything the team owns. #sageox-internal is the team-context KB — one specific bubble
holding the team's shared thinking. Same root slug, different prefix, different surface. Other team-owned bubbles
(#sageox/scribe, #cast-team, #q3-planning) sit alongside it — none are "the team."
for the engineer building /team/[id]
Mental model. A team page is the rollup of an organization's KBs, members, and repos. It is not any single KB. The team-context KB (kb_type='team', aliased_team_id=<team.id>) is one of N bubbles owned by the team; the team page lists it alongside repos, channel KBs, and custom KBs.
Stats tile semantics. "Bubbles" = count of KBs where the team is owner or grantee. "Signals" = murmur count across all those KBs in last 7 days. "Discussions" = recording count + live-now subset. "Coworkers active" = users (incl. AI coworkers) who touched anything in the team this week. These need a single endpoint (GET /api/v1/teams/:team_id/stats?window=7d) returning all four — don't fan out from the client.
Active-work feed. Cross-bubble — aggregates murmurs from every KB the team owns. The murmur API today (/api/v1/teams/:team_id/murmurs) already fans across up to 20 repos; extend that to include all team-owned KBs.
Right rail. The compact bubble constellation in the right rail uses the existing KBConstellation component (apps/web/src/components/kb/KBConstellation.tsx) with the compact variant — see §12f for the encoding.
Anti-patterns to avoid.
- Don't conflate "team settings" with "team-context-KB settings." They live at separate routes (
/team/[id]/settings/*vs/kb/[kb_id]/settings) and store different data. - Don't show
kb_type='team'bubbles in the Create flow — they auto-provision.
Files (best guess). New route apps/web/app/(protected)/team/[team_id]/page.tsx (replace existing if needed). New stats endpoint apps/api-go/internal/handlers/teams/stats.go. Existing component reuse: MurmurFeed, KBConstellation.
3b. Team Settings → Bubbles NEW
/team/[team_id]/settings/bubbles team admin onlyDoesn't exist in code or in V1. Without it, a team admin manages each bubble individually — a 20-tab afternoon for a team of 9 bubbles. This page makes ownership transfer, audit, and deletion tractable.
Bubbles
9 bubbles · 1 team-context · 7 repo · 1 custom · 1 channel
| Bubble | Kind | Members | Last activity | Owner | Status | ||
|---|---|---|---|---|---|---|---|
|
SO
SageOx Internal #sageox-internal |
Team | 7 | 8m ago |
RSRyan |
Active | ⋯ | |
|
SA
sageox-app #sageox/sageox-app |
Repo | 7 | 2m ago |
ABAjit |
Active | ⋯ | |
|
CA
cast #sageox/cast |
Repo | 5 | 12m ago |
RSRyan |
⚠ critical signal | ⋯ | |
|
SC
scribe #sageox/scribe |
Repo | 4 | 4h ago |
ABAjit |
Active | ⋯ | |
|
#
cast-team #cast-team |
Channel | derived | 6h ago |
— (derived) |
Active | ⋯ | |
|
Q3
Q3 planning #q3-planning |
Custom | 5 | 1d ago |
MRMihira |
Active | ⋯ |
⋯):
Transfer ownership · Change visibility · Audit grants · Delete bubble.
Bulk action bar appears on selection: Change visibility · Audit grants · Export audit log · Delete.
Delete is permanent (no archive today, per the data model).
for the engineer building Team Settings → Bubbles
Why this surface matters. Without it, a team admin manages each bubble individually — a 20-tab afternoon for a team of 9 bubbles. This page makes ownership transfer, audit, and deletion tractable.
Auth. Admin-only. Use RequireTeamRole('admin') middleware. Non-admins should not see the route in their team-settings sub-nav (filter EntitySettingsNav.tsx).
Table columns. Bubble (avatar + name + slug) · Kind · Members count (with mini-stack of 3) · Last activity (with act-pulse dot) · Owner (current human accountable) · Status · Actions (⋯ menu).
Row actions.
- Transfer ownership — change
kb.owner_user_idto another team member. Modal confirms the transfer; new owner is notified. - Change visibility — toggle
kb_team_grantsvisibility (private to grantees vs discoverable to team). Confirm modal. - Audit grants — opens a right-side sheet showing who has access, role, granted-by, granted-at, last-active. Sortable, exportable as CSV.
- Delete bubble — permanent, confirmation modal with name-typing required (like GitHub repo delete). No archive — per the data model today.
Bulk operations. Selection checkbox per row; bulk-action bar appears at the bottom (slide-up, fade-in 150ms — single property). Bulk actions: Change visibility, Audit, Export audit log, Delete.
Empty state. "This team has no bubbles yet. The team-context bubble auto-provisions when [conditions]. Create a custom or repo bubble →" — uses the "doesn't exist yet" pattern (§12e+).
Filters. Kind · Visibility · Status · Owner — server-side filtering. Persist last-used filter in localStorage per (user, team).
Files (best guess). Route: apps/web/app/(protected)/team/[team_id]/settings/bubbles/page.tsx. Server endpoints: GET /api/v1/teams/:team_id/kbs, POST /api/v1/kb/:kb_id/transfer, PATCH /api/v1/kb/:kb_id/visibility, DELETE /api/v1/kb/:kb_id. Audit-grants sheet uses the existing kb_team_grants table.
3. Overview — Personal KB
/kb/[kb_id] kb_type = "personal"Auto-provisioned per user. Structurally unshareable — no Members tab, no Connect-Slack widget. isPersonalKB(kb) shows the privacy banner.
Ryan's personal
🔒 Personal OwnerWhat's in here
Active work
2026-05-15T21:18:04Z
4. Overview — Team-context KB
/kb/[kb_id] kb_type = "team"A team entity (with its members and repos) is not the same as its team-context KB. The team-context KB is one of N bubbles a team may eventually have. aliased_team_id gives the 1:1 mapping today; the schema is structurally 1:N.
SageOx Internal
Team AdminConnect
Connect your coding agent (CLI)
Run from your terminal. Your agent gets this bubble's context and can write back to it.
ox initConnect Claude, Cursor, ChatGPT · MCP
One server URL per user. Once connected, your agent can call ListTeams / Search across every bubble you have access to.
https://api.sageox.ai/api/v1/mcpConnect a Slack channel
Pipe a Slack channel into this bubble. Distilled discussions appear in the feed below.
+ Link a channelActive work · across this team's bubbles
2026-05-15T21:26:11Z
2026-05-15T01:42:13Z
2026-05-15T21:22:48Z
2026-05-15T21:24:01Z
2026-05-15T21:18:33Z
2026-05-15T19:31:08Z
2026-05-15T21:16:55Z
Recent discussions
aliased_team_id on the kb table; design substrate in ADR-030.
for the engineer building the team-context KB overview
The marquee surface. This is the most-visited KB page. Every design move here propagates to other kb_type variants.
Header. Bubble avatar (36×36, flat fill per kb_type with monogram) + plain-language title (20px, weight 600, letter-spacing: -0.015em, line-height: 1.2) + KBTypeBadge (small, demoted to slug line in V2) + RoleBadge. Slug line is 12.5px text-muted: #slug · part of @team · members · bubbles. CTA buttons right-aligned; one Copper CTA per view ("Have a discussion" or "Dictate memo" for personal). The avatar must be baseline-aligned with the title — V1 had it top-aligned which made the header read taller than it is.
Tabs. Overview · Members · Settings. Members only when kbAllowsMembers(kb.kb_type) (false for personal/profile). Settings only when viewerRole === 'admin'. Underline indicator: 2px sage-500 at bottom of the active tab. Transition the underline on tab change via a single shared pseudo-element that translateX()'s; don't fade the border-bottom independently per tab — that reads as jump.
Onboarding stepper. Horizontal compact variant. Per-user state stored as kb.metadata.viewer_onboarding_completed_at_<user_id>. Hover any step to see the data-tip explanation. The current step's tip is pinned-open via .show-tip class so first-time users get context without hover. Stepper dismisses via the "Dismiss" link; persist dismissal per (user, kb) in kb.metadata.
Connect widgets. Three cards: CLI (ox init), MCP (single per-user /api/v1/mcp endpoint — explain the ListTeams/Search tool model in the description), Slack channel (link via existing LinkChannelDialog). Adaptive — personal bubbles hide the Slack card; repo bubbles hide it too. Each card: icon tile + plain-language title + 1-line description + snippet/CTA + footer status. One CTA per card. Snippet uses the .copy-ic icon (no "Copy" text label).
Active work (Murmurs feed). Fixed-height container (max-height: 388px) with .decay class for opacity fade. See-more footer with gradient fade-in. Cross-bubble on this page — aggregates from every KB the team owns. Each row: 3-column grid (dot + content + meta), principal-first attribution. See §12i for the full row spec.
Right rail. Members (with avatar stack), Linked repos. Each section can become its own contextual sidebar item in the future. Avoid duplicating the sidebar's contextual section here.
Files. Page: apps/web/app/(protected)/kb/[kb_id]/page.tsx (refactor the existing). Sidebar morphing: apps/web/src/components/layout/nav/LeftNav.tsx + new useContextualNav() hook driven by usePathname().
5. Overview — Repo KB (ledger)
/kb/[kb_id] kb_type = "repo"Tied to a code repository — replaces the old "ledger" concept. Stores commit-derived activity, agent memory, and discussions scoped to one repo.
Connect
Connect your coding agent (CLI)
Auto-detects your current repo and links to this bubble.
cd ~/code/scribe && ox initConnect Claude, Cursor, ChatGPT · MCP
One server URL per user. Access this bubble (and every other bubble you can see) via ListTeams/Search tools.
https://api.sageox.ai/api/v1/mcpActive work
Recent commits
6. Overview — Custom KB
/kb/[kb_id] kb_type = "custom"User-created bubble for any project, initiative, or topic. No required team or repo link; can be granted to a team or invited per-user.
Q3 planning
Custom AdminConnect
Connect your coding agent (CLI)
Pull this bubble's context into your CLI session.
ox initConnect Claude, Cursor, ChatGPT · MCP
One server URL per user. Access this bubble via ListTeams/Search tools.
https://api.sageox.ai/api/v1/mcpWhat's in here
7. Overview — Channel KB
/kb/[kb_id] kb_type = "channel"Distilled from a Slack / Mattermost channel. Membership is derived from a team grant — no direct invites. Header shows the source channel chip; feed is distilled summaries, not raw messages.
#cast-team
Channel Slack · #cast-teamLatest distillations
8. Members
/kb/[kb_id]/membersMember list with role management for admins; revoke action gated on role. Personal & profile KBs don't render this tab at all.
9. Settings
/kb/[kb_id]/settings admin-onlyName/slug editing + feature toggles. Non-admins get a 404. Editing slug is destructive (URL change) — confirms before save.
10. Invite landing (public)
/kb/invite/[token] unauthenticatedPublic, token-gated landing for invite links. Different states: ready, expired/revoked, flag-off, unauthenticated, loading.
You're invited to SageOx Internal
Invited by Ryan Snodgrass · expires May 22, 2026
A bubble is a shared thinking space — discussions, notes, and AI agent memory all in one place.
Sign in or create an account to accept.
11. Personal knowledge settings
/settings/knowledgeWhere users manage their personal & profile KBs, default editor, and global KB preferences. Separate from per-bubble settings.
Knowledge
Your personal bubbles and how agents talk to them.
Personal bubble
Profile bubble
§ 12. Widget gallery
Each component in isolation, every variant shown side-by-side.
12a. KB header + tabs (per kb_type)
SageOx Internal
TeamAdmin#cast-team
ChannelSlack · #cast-team12b. KBTypeBadge — all 6 types
12c. KBCard — list states
12d. ChannelBubbleChip
kb_type === 'channel'. Tooltip on hover shows full source list for N:N mappings.12e. EssenceCard — pre-onboarding explainer
What's a bubble?
A bubble is a private space for related thinking — notes, discussions, files, and agent memory all in one place. Personal bubbles stay with you. Team bubbles are shared. Repo bubbles tie to code.
12e+. "Non-existent / yet-to-be-created" pattern
proposed for sageox-design
A consistent visual vocabulary for things that don't exist yet but could: an unprovisioned profile bubble,
a feature the user could enable, a section with no content yet, a member slot to fill. The pattern reads as
"this is real, just empty" — distinct from disabled (greyed out, no affordance) and from loading (skeleton).
Should be documented in sageox-design/DESIGN.md alongside the existing card & surface conventions.
Yet-to-be-created card
Empty section
Member slot to fill
Feature not enabled
- Border: 1px dashed
--ox-border(vs solid for existing things). - Background: transparent or
--ox-surface-2at 50% opacity — never the full surface-1 fill that real cards use. - Icon: 40% opacity. Reads as "shape of the thing" without claiming presence.
- Title text:
--ox-text-muted, never--ox-text. Reserves bold colors for things that exist. - Always pair with a CTA — non-existence is an invitation, not a reproach. Use Sage 700 for the link (matches link semantics).
- Anti-pattern: do not use a strikethrough, ghost-icon, or low-opacity ON A solid-border card — that reads as "disabled" / "you can't have this" and is a different signal.
12f. Bubble constellation
apps/web/src/components/kb/KBConstellation.tsxCluster view of every bubble you can see. Sized by activity volume, colored by kb_type, pulsing by recency. Hover any bubble for a sparkline + last-activity summary. Already in code; reused here so it's also visible in §1 view-toggle = Constellation.
KBConstellation.tsx): sparkline in hover (v1.5), SSE event pulse (v1.5),
cross-bubble particles for mirrored elements (v2), AI-coworker presence dots (v2), time scrubber + filter-by-coworker (v3).
12g. Onboarding stepper — two variants
Two variants serve different surfaces. Horizontal (compact) sits at the top of the KB overview —
lightweight, hover-revealed help on each step. Vertical (descriptive) matches the existing
GettingStartedWidget.tsx pattern — heavier, denser, surfaces "what this step actually means"
without hover. Both variants share the same completion state, so progress in one reflects in the other.
Stored as kb.metadata.viewer_onboarding_completed_at_<user_id>.
.show-tip) so the user gets context without
having to hover. Other steps reveal on hover. aria-describedby wiring in the React version
makes it screen-reader-accessible — the CSS-only tooltip here is a visual stand-in.
/welcome,
/home, freshly-created bubbles) where the user needs more guidance. The vertical variant
mirrors the existing GettingStartedWidget.tsx on team pages.
12h. Connect widgets (proposed)
Three cards. The Slack card is conditional on kb_type — personal/profile bubbles omit it; repo bubbles hide it by default.
Connect your coding agent (CLI)
Pull this bubble's context into your CLI — Claude Code, Cursor, or any ox-aware agent.
ox initConnect Claude, Cursor, ChatGPT · MCP
One server URL per user. JWT-authenticated. Works with Claude Desktop, Cursor, ChatGPT, and any MCP client.
https://api.sageox.ai/api/v1/mcpConnect a Slack channel
Pipe a channel into this bubble. Distilled discussions appear in the feed; raw messages aren't stored.
+ Link a channel12i. Murmurs — BEFORE vs AFTER
Today's row vs the redesign. AFTER adds: principal avatar + name (the human the agent acted on behalf of), "via {agent}" sliver, KB provenance chip, timestamp tooltip, freshness states (live / recent / stale), importance variants (critical / ambient), human-posted variant (no via fragment).
Before — today
After — redesigned
2026-05-15T17:26:11Z
2026-05-15T01:42:13Z
2026-05-15T21:22:48Z
2026-05-15T21:24:01Z
2026-05-15T21:18:33Z
←
principal_id resolved to the human user the agent acted on behalf of←
agent_type ({claude-code, github-copilot, …}). Absent when a human posted directly.←
kb_type + name. ⎇ org/name for repo, 👥 Team, 📖 Custom, # for Channel.← (
topic + age). Pulse: wip < 4h. Solid: wip 4–24h. Hollow: > 24h.←
timestamp. Both localized + ISO for log-grep paste.←
importance. Copper border for critical; muted/0.7 for ambient.for the engineer rebuilding MurmurItem.tsx
No API changes required. Every field in the new design is already on the wire: principal_id, agent_type, topic, importance, repo_id, timestamp, metadata.files. This is a render-layer change only.
Row grid. grid-template-columns: 16px minmax(0,1fr) auto. Dot · content · meta. Don't let the meta wrap — it's the right-edge anchor for scanning. Time + KB chip stack in the meta column with flex-direction: column; align-items: flex-end.
Principal-first attribution. The byline shows the human the agent acted on behalf of, not the agent. Resolve principal_id → user → avatar + first name. agent_type follows as · via ✦ claude-code. When agent_type is empty (human posted), drop the entire "via …" fragment — don't replace it with "manual" or a person icon. Absence is the signal.
KB provenance chip. Distinguished by icon, not color. ⎇ org/repo for repo, 👥 Team for team-context, 📖 Name for custom, #name for channel. 11px font, mono only for repo paths. Tooltip on hover gives full KB type + activity count.
Freshness states. wip + <4h = pulsing green dot. wip + 4-24h = solid green. Anything >24h = hollow ring (border: 1.5px solid var(--char-500); bg: transparent). The content text drops to text-muted on stale rows. The biggest legibility win — without this, a 4m-old murmur and a 20h-old murmur look identical.
Importance variants. critical → 2px copper-400 left border + content stays default color (don't double-signal). ambient → row opacity 0.7. normal → no extra treatment.
File chips. Max 2 visible, then +N more. Mono, 10px, surface-2 bg, text-muted. Hover expands. Calm mode hides them entirely until row hover.
Timestamp tooltip. Two-line: localized + ISO. <time dateTime={iso}> for a11y and copy-paste. Use Radix Tooltip with delayDuration={300}, fade-only entry. No slide, no scale.
Fixed-height + decay (V2.2). Wrap the feed in .murmur-feed.fixed.decay when it lives on a KB or team overview. Row 1 = 1.0 opacity, row 2 = 0.95, row 3+ = 0.88. .see-more footer with gradient fade-in. Hover any decayed row restores 1.0.
Files. Rewrite apps/web/src/components/murmurs/MurmurItem.tsx. Container: MurmurFeed.tsx takes new props fixed?: boolean, decay?: boolean, seeMoreLink?: string.
12j. Chat interface — "ask the bubble"
ADR-039 Coworker Chat
A primary surface for interrogating a KB. Lives as a right-rail dock on KB overview pages and as a full-page route
/kb/[kb_id]/chat/[thread_id]. The assistant answers grounded in the bubble's content; citations link back
to the source notes, discussions, or messages. BYOK from Phase 1 (per ADR-039) — each user's grants drive the model used.
/ at the start of a message- Quiet head. No model/BYOK/scope info as visible text — that chrome competes with the thread title. A single ⚙ gear icon reveals the model picker (which includes BYOK status, token usage, team-key fallback) on click. Hover tooltip gives the current model at-a-glance.
- Two rails, two voices. The user's "ask" gets a 2px copper right-rail; the assistant's "answer" gets a 2px sage left-rail. No chat bubbles — just typography and rules.
- Citations earn their pixels. Inline superscripts [ex], hover for a card with source title + snippet + Open link. Wikipedia-grade, not chip-row.
- Composer focus is calm. Border lightens to sage on focus. Send button sits ghost until you type. No spring, no slide.
- Recording is a deliberate choice. Off by default. When on, a quiet red strip sits between body and composer. Recorded threads publish to the bubble when ended.
- Scope is a chip. Default to the current bubble. Widen via chip — easier than a settings dialog, more discoverable than a slash command.
12k. Calm-by-default + power-on-demand DS
proposed for sageox-design
Two design principles SageOx should codify in sageox-design/DESIGN.md:
(1) progressive disclosure — non-technical users see plain language by default, with the technical surface
reachable but never in the way; (2) calm-by-default — visual-spatial / hypersensitive users see a UI
that is crisp, predictable, and low-motion. Both audiences get the same product; only the surface area is staged.
The mockup header now exposes a Calm mode toggle (top-right) — try it to see the V2 with all
looping animations suppressed.
Progressive disclosure pattern
Plain title + technical subtitle
--text-muted at one size smaller, with no font-weight emphasis.
Calm mode — quiet, not silent
Layer 1 — OS reduced-motion. prefers-reduced-motion: reduce → nuclear option, all animations + transitions collapse to ~0ms. Honored globally with no opt-in.
Layer 2 — Calm mode (user preference). Per-user toggle, default OFF. Try it at the top of this page. Quiet, not silent:
- Looping animations off — pulse rings, halos, the chat typing bounce, recording strip pulse. Anything that loops in peripheral vision.
- Hover/focus transitions kept at 120ms (was 150). The UI still responds — buttons fade on hover, focus rings still appear. Calm isn't dead.
- Density down — ancillary metadata collapses to hover-only. File chips, "via {agent}" byline, KB chips, sidebar counts, card descriptions dim or hide. Critical signals and color-coded states stay. Signal stays; noise goes quiet.
Layer 3 — Animation budget. No more than 3 looping animations visible at once even when calm mode is OFF. Reviewer fails the PR.
Layer 4 — Fixed-height feeds. Long activity streams (murmurs, discussions, sessions) live in fixed containers with a "See more" footer and subtle opacity decay on older rows. Newest is loudest; everything past the third row settles to ~0.88 opacity.
Color + shape redundancy
Power-on-demand surfaces
Contrast minimums (WCAG AA)
--text-muted and --text-subtle after a contrast audit on --surface-2.
--text on surface-1 · pass--text-muted on surface-1 · pass--text-subtle on surface-1 · pass (was borderline)--sage-500 on surface-1 · pass for UI elements; check against body text usesageox-design/DESIGN.md:
- Plain-language hierarchy: human name leads, technical handle follows in
--text-mutedone size smaller. Applies to KB headers, settings labels, connect-card titles, anywhere a slug/protocol/ID would otherwise lead. - Three-layer motion policy: (a) honor
prefers-reduced-motionglobally, (b) ship a user-level "Calm mode" preference, (c) cap to 3 looping animations on screen at once. - Color + shape redundancy: any color-coded state must pair with shape, position, or letterform. Color alone is never the signal.
- Power-on-demand: every keyboard shortcut has a visible button. Every slash command has a chip equivalent. Every ID has a slug. Power users get density; new users get affordances.
- Contrast lock: all text tokens MUST clear 4.5:1 on their host surface. Add a Storybook check that fails the PR on contrast regression.
- Empty-state vocabulary: "doesn't exist yet" pattern (dashed border + muted + Sage CTA, see §12e+) is the canonical "this is real, just empty" treatment — distinct from disabled and from loading.
for the engineer implementing calm mode + a11y
Four-layer motion + density policy.
- OS reduced-motion (nuclear).
@media (prefers-reduced-motion: reduce)at the top of the global stylesheet. Setsanimation-duration: 0.001ms,animation-iteration-count: 1,transition-duration: 0.001mson*, *::before, *::afterwith!important. No opt-in. - Calm mode (user preference). Body attribute
data-calm="true". Persists in user settings; default false. Targets only LOOPING animations (.act-pulse.live::after,.bubble-cluster .b.pulse::before,.rec-strip .rec-dot,.chat-typing span,.ambient .a-dot.live::after) — setsanimation: none. Keeps hover/focus transitions but slows them to 120ms. ALSO triggers density reduction. - Density reduction (calm mode). Selectors that hide ancillary metadata:
.murmur-fileshidden, byline "via X" hidden, KB chip opacity 0.35, sidebar ambient labels hidden, card descriptions opacity 0.55. Hover the host row to reveal. Bottom-left pill reminds the user "Calm mode · hover any row for full detail". - Animation budget. Enforced by reviewer / lint: no more than 3 looping animations visible at once. A page that has the murmur dot pulse + recording strip + ambient strip live indicator + bubble cluster pulse is already over budget.
Contrast lock. Every text token MUST clear 4.5:1 against its host surface in both modes. V2.1 raised dark-mode --text-muted (was #8f99a3 → now #9aa3ab) and --text-subtle (was #6f767c → now #7d858c) after a contrast audit. Add a Storybook check that fails the PR on contrast regression.
Plain-language hierarchy. Human-readable name leads. Technical handle (slug, ID, protocol name) sits in --text-muted at one size smaller. Applied via the pattern: <span>Distill new content <span class="muted-suffix">· auto-summary</span></span>. Codify as a typography mixin in sageox-design.
Power-on-demand. Every keyboard shortcut MUST have a visible button. Every slash command MUST have a chip equivalent. Every ID MUST have a slug equivalent. Document in DESIGN.md.
Files. Global stylesheet (apps/web/src/index.css). New user setting under /settings/preferences for the Calm mode toggle. Hook: useCalmMode() reads from local state + persists to user prefs API.
12l. State variants
No active work signals
.context/kb-pages-mockup.html · Tokens from packages/ui/styles/tokens.css · Motion per .claude/rules/motion-sageox.md.