Messages
Text, widgets, attachments, and input patterns — chat and terminal side by side
Message Anatomy
Ironclad rule — applies to every message in both chat and terminal, regardless of sender.
- .chat-link-preview/Link preview card (icon, title, description, domain)
- .term-changeset/Commit summary (hash, message, files)
- .term-activity/Activity / progress card (title, bar, badge)
- .chat-widget/Interactive preview — map, dashboard, poll
- .attach-artifact--lg/Large artifact pill (created app, deployed project)
- .term-steps/Step progress list (pending → active → done)
- .term-options/Option picker (numbered choices)
- Order is fixed: attachments → text → widget. Top to bottom. Never rearranged.
- Attachments are always above the text. Media thumbnails scroll horizontally. File pills stack vertically (max 320px). Media first, file pills after.
- Text is markdown (rendered as prose) or plain text. One text block per message. Large code fences from markdown may render as standalone
.term-blockcards outside the bubble for readability — they are still text content and do not consume the widget slot. A message can have both a code block and a widget. - Widget is at most one per message. Any type from the widget list above. Placed below text. An artifact and a changeset cannot coexist in one message — both are widgets, split into two messages.
- Artifact class naming: artifact pills reuse
.chat-attach-files/.attach-artifact--lgCSS classes for pill styling, but in the data model they are widgets (widget: .artifact(...), NOTattachments[]). Render artifacts in the widget slot (after text), not the attachment slot (before text). The CSS class name is a styling convenience — it does not reflect data model position. - Minimum content: a message must have at least one of the three parts. Empty messages are invalid.
- Media bubble modifier:
.chat-msg-bubble--mediais used for both media-only and media+caption cases. Without text: image fills the entire bubble edge-to-edge, 0 padding. With caption text: image edge-to-edge at top, caption below with padding. The CSS distinguishes via child presence (:has(p)/:last-child) — native platforms must branch explicitly based on whether a caption exists. - Chat bubble content: for all senders, attachments + text are inside the bubble. Widget is always a sibling below the bubble. The bubble is the content container — its visual style changes per sender (transparent for agent, systemFill for peer, chatBubbleUserBackground for outgoing), but the content structure is identical.
- Chat agent: bubble has transparent background and minimal padding (
space2vertical, 0 horizontal). Media scrolls full width (agent bubble is 100% wide so carousel bleeds to edges). The bubble view exists for layout but is visually invisible. Agent messages use 100% width (not 85%) — the only sender type exempt from the 85% max. On Web, the CSS reference flattens agent attachments as siblings for convenience — the visual result is identical since the bubble is transparent. Native implementations should keep attachments inside the bubble container. - Chat peer/user: attachments + text inside bubble. Widget as sibling below. Outgoing (user) widgets right-align with the bubble; incoming (peer) widgets left-align. Same content structure as agent — only bubble styling differs.
- Bubble width: determined by the widest child — media, text, or file pills — capped at max bubble width (85% for peer/user, 100% for agent). The bubble grows to fit its widest content, never wider than the cap. Widgets are always outside the bubble and size independently — but align to the same edge as the bubble (right for outgoing, left for incoming). Widgets without an explicit max-width (changeset, activity, steps, options) should not exceed the max bubble width for that sender type (85% for peer/user, 100% for agent) — this applies whether or not a bubble is present in the message.
- Terminal: inline flow — attach-row → text lines/prose → card.
- Folds (tool activity): inline metadata — not attachments, text, or widgets. Folds sit between attachments and text: the full chat order is attachments → folds → text → widget. Folds stack tight (0 gap between folds, 0 gap from last fold to text). Unlimited count. They don't occupy any of the three anatomy slots.
- Terminal anatomy differs from chat. Terminal has no "message" boundary — output is a continuous stream. Fixed order: prompt → folds → steps → prose → changeset / activity → artifact. Folds and steps appear before prose because terminal output is chronological: the agent reads files (fold), plans work (steps), then writes output (prose) and commits (changeset / activity). Chat anatomy (attachments → text → widget) applies to discrete messages — the "max 1 widget per message" rule applies to chat only. Terminal flow applies to continuous output blocks between prompts — a single terminal response can contain both a changeset and an artifact because they are separate output events in the stream, not coexisting widgets in one message.
- Bubble tails (standalone): outgoing = tail at bottom-right (
chatTailRadius). Peer = tail at top-left. Agent = no tail (transparent bubble, no visible corners). Tail is a single corner set tochatTailRadius(6px) while all other corners usechatBubbleRadius(18px). - Capsule variant: single-line peer or user messages with no attachments can use capsule shape (
chatCapsuleRadius, 24px). Messages with attachments inside the bubble are multi-content — always use regular bubble. Text-only + widget (widget is outside) can use capsule if the text fits on one line. Not limited to grouped messages. If text exceeds max bubble width, fall back to regular bubble — never truncate. Capsule eligibility is a code decision: the rendering layer must measure text width before rendering and choose capsule vs regular bubble — CSS cannot auto-switch. Capsule tails follow the same rules as regular bubbles (outgoing = bottom-right, peer = top-left). Agent messages should not use capsule — the transparent bubble makes the capsule radius invisible, and the only effect (white-space: nowrap) provides no value when agent width is 100%. - Agent grouping: agent messages never group. Each agent message is standalone with its own name label and potential folds/widgets. Grouping applies only to user and peer messages (same sender, <60s apart). First in group: shows name label (no timestamp). Subsequent: name and timestamp hidden. Last in group: timestamp restored. Avatar: layout-preserved but visually hidden (
visibility: hidden) on grouped messages — maintains horizontal alignment with the first message's visible avatar. - Outgoing widgets: widgets outside the bubble always use their own default styling — they do NOT inherit outgoing blue color scheme. Each widget type defines its own background (
tertiarySystemFillfor changeset/activity/options,secondarySystemGroupedBackgroundfor link preview/widget card). Only content inside the bubble adapts to outgoing colors.
Dashboard deployed with updated colors.
- iOS Message model:
attachments: [Attachment],text: AttributedString?,widget: Widget?. Render inVStackin this exact order. Agent bubble:UIViewwith.clearbackground — do not remove the view, it anchors text layout. VoiceOver should not announce it as a separate container. - Android Message data class:
attachments: List<Attachment>,text: AnnotatedString?,widget: Widget?. Column composable, top to bottom. - Web Message container: flex column. Slots render conditionally.
.chat-msg-attachments→.chat-msg-bubble→.chat-widget/.chat-link-preview/.term-changeset. Outgoing:align-items: flex-end— all children (bubble + widgets) right-align. The CSS reference uses.chat-demoas a wrapper for chat context overrides — native implementations apply these values directly (see table below). - All This order is non-negotiable. Every message, every sender, every context. Violations break scanning patterns.
- All 85% max-width applies to the message row container (not the bubble). All children — bubble and widgets — are constrained by the row. Agent messages force
width: 100%(not just max-width — the row does not shrink to content). Peer/user rows aremax-width: 85%and shrink to fit their widest child. - All Incoming bubble cascade: all incoming messages (agent + peer) start with transparent background and minimal padding (space2 / 0). Peer then overrides to systemFill + standard padding (space12 / space16). On native, apply incoming defaults first, then peer overrides — do not treat agent and peer as independent branches. If the code checks "incoming" and returns early, peer never gets its fill.
- All Outgoing widget color isolation: apply
chatBubbleUserBackgroundandchatBubbleUserTextto the bubble view only, never to the message container. Widgets are siblings of the bubble and must retain their own default color scheme (each widget type defines its own background). If outgoing color is applied at the container level, widgets inherit white text on non-blue backgrounds. - All Terminal components in chat context use different font and radius values. The CSS reference uses
.chat-demoas the context selector for these overrides — this is a reference-page artifact. In production, native platforms must apply these overrides based on their own context detection (e.g. a chat view controller vs terminal view controller). Web production code should use a comparable wrapper class. Values:
| Component | Terminal | Chat |
|---|---|---|
| Fold summary | fontMono / typeCode | fontBody / caption1 |
| Steps | fontMono / typeCode | fontBody / caption1 |
| Options | fontMono / typeCode | fontBody / caption1 |
| Changeset | fontMono / typeCode | fontBody / caption1 |
| Activity card | fontMono / typeCode | fontBody / caption1 |
| Pill (attach-artifact) | fontMono / r0 | fontBody / r10 |
| Changeset radius | radius12 | radius14 |
| Activity radius | radius12 | radius14 |
| Standalone code block (.term-block) | radius10 | radius14 |
| Inline pre (inside prose in bubble) | — | radius14 (incoming/peer — concentric with bubble) / radius6 (outgoing — tighter inset) |
| Option radius | 0 | radius10 |
Attachment Rules
How media and file attachments render inside messages — depends on count, text presence, and sender type.
- Single media, no text: image fills the bubble. No padding. Bubble shape = image shape (clipped by border-radius). This is the "photo message" pattern.
- Single media + text: image still edge-to-edge at top. Text below with horizontal padding. Bubble width driven by media width (280px default), capped at 85%.
- Multiple media: horizontal scroll. Each thumb is 70% of bubble width so 30% of the next item peeks — the visual cue that scrolling is available.
- File pills: always vertical list, below media (if any), above text. Container max 320px. Inside bubble: pills stretch to bubble width. Agent (flat): pills shrink to content. Rounded (radius10).
- Agent: no bubble — media and files are flat siblings. Media carousel bleeds to full chat width. Pills and artifacts shrink to content width. Media-only agent message (no text, no widget) = just the media, no visible bubble wrapper.
- Mixed media + files: media carousel first, file list second, text third. Always this order (anatomy rule).
- Terminal geometry: terminal pills, thumbs, and option rows are square-cornered (0 radius). Chat uses rounded corners (radius10 for pills, radius20 for large artifacts) concentric with bubbles. This split is intentional — terminal surfaces are utilitarian and grid-aligned; chat surfaces are organic and conversational.
- iOS Single media:
AsyncImagewith.clipShape()matching bubble shape. Multiple:UICollectionViewwith paging-like scroll (70% width cells). - Android Single media:
AsyncImagewithModifier.clip(). Multiple:LazyRowwithArrangement.spacedBy(4.dp), item width =0.7f * maxWidth. - Web Single:
width: 100%inside bubble. Multiple:flex-shrink: 0; width: 70%per thumb,overflow-x: autoon container. - All Peek pattern is critical — if the user can't see the next item, they won't know to scroll. 70% is the sweet spot: enough to show the current image, enough peek to hint at more.
Messages
- .chat-msg--incoming/Agent = transparent bubble (no fill, space2 / 0 padding — bubble view exists for text layout but is visually invisible). Peer = filled bubble (systemFill). The visual distinction tells users AI vs human.
- .chat-msg--outgoing/User messages. Blue bubble, right-aligned. Widgets right-align with bubble. Capsule for single-line.
- .term-prompt-user/Peer username prefix in terminal. Uses
systemBlue— a distinct token fromchatBubbleUserBackground. Both signal human presence (as opposed to agent/system) but are intentionally different colors: outgoing blue for "me", systemBlue for "peer". - Standalone tails: outgoing = bottom-right corner at
chatTailRadius(6px). Peer = top-left corner. Agent = no tail (transparent bubble). - Grouped tails: tails face inside the group. Outgoing: first = same as standalone (BR=tail). Middle = both right corners are tail (TR=tail, BR=tail). Last = tail moves up (TR=tail, BR→full). Peer: first ≠ standalone — tail moves from TL to BL (TL→full, BL=tail). Middle = both left corners are tail (TL=tail, BL=tail). Last = same as standalone (TL=tail, BL→full). All non-tail corners stay at
chatBubbleRadius(orchatCapsuleRadiusfor capsules). See Grouping spec card for complete per-corner values.
- iOS Chat: dedicated outgoing-bubble color (not
UIColor.systemBlue) — dark mode needs a darker blue for white-text contrast. Do NOT use tint. Agent bubble:UIViewwith.clearbackground — keep the view for layout, remove it from VoiceOver. Terminal peer: same mono font, systemBlue for username. - Android Chat: per-corner radii via
RoundedCornerShape. Terminal:AnnotatedStringwith colored spans for usernames. - Web Chat: flex column with
align-items: flex-endon outgoing messages — bubble and widgets both right-align. Terminal:.term-prompt-useris inline before path. - All Any user can send any content type — prose, media, widgets, code. The bubble/prompt wrapper changes, not the content inside.
- All Terminal line-height is 1.7 (not 1.6 from the type scale) — taller leading keeps dense monospace output scannable. This is an intentional terminal-specific override.
- All Capsule eligibility is a code decision. Requirements: (1) peer or user sender, (2) text-only content — no attachments inside the bubble, (3) text fits within max bubble width on a single line. If any condition fails, fall back to regular bubble (
chatBubbleRadius). A text-only message with a widget (outside the bubble) can use capsule. CSSwhite-space: nowrapprevents wrapping but cannot auto-switch shape — the rendering layer must branch. - All Media bubble modifier (
.chat-msg-bubble--media) covers two cases: media-only (0 padding) and media+caption (padding below image for text). On Web, CSS:has()selectors handle both. On native, explicitly branch based on whether a caption exists — this is not a single layout path. - All Media carousel gap: space4 for agent flat carousel (full-width, tighter fit), space8 for peer/user bubbled scroll (spaced for peek pattern). This is an intentional split — agent carousel bleeds to edges and needs compact spacing.
Rich Text
Here's a summary:
- Revenue is up 12% QoQ
- Retention improved from 68% to 74%
- API latency dropped to
42ms
The biggest driver was the onboarding redesign.
My notes on the spacing system:
- Related items →
space4–space8 - Unrelated →
space24–space48
Agreed! Also worth noting:
- The density budget concept is great
- Min card padding
space24
When a rounded rectangle is nested inside another, the inner radius must be smaller than the outer
Spacing Tokens
The spacing system uses a geometric scale based on multiples of 4px.
Scale
space4— Tight groupingspace8— Default gapspace16— Section paddingspace24— Group separation
Whitespace is content — generous padding signals confidence.
When a rounded rectangle is nested inside another, the inner radius must be smaller than the outer by the distance between them.
- .prose/Rendered markdown inside chat bubbles — lists, blockquotes, bold, inline code. Resets container padding/background to transparent.
- .term-prose/Mono prose in terminal — headings via weight + uppercase, body via secondaryLabel. Hierarchy through weight, not typeface.
- .is-streaming/Active generation — appends a tint block cursor via
::after. Remove class when stream completes. - Any sender can send rich text — agent, peer, user. Same prose styles in all contexts.
- iOS Chat:
AttributedString(markdown:)with Figtree. Terminal: IBM Plex Mono throughout, bold=600. - Android Chat:
AnnotatedStringwith markdown parser, Satoshi body font. Terminal: IBM Plex Mono throughout, bold=600. - Web Chat:
.proseresets inside bubble. Terminal:.term-proseinheritsvar(--fontMono). - All Chat = mixed fonts (Figtree + Plex code). Terminal = mono only. Hierarchy via weight, not typeface.
Code & Blocks
Here's the resolver:
function resolveToken(name) {
const v = getComputedStyle(root)
.getPropertyValue('--' + name);
return v || 'undefined';
}
What about this?
const tint = resolveToken('tint');
.term-kwsystemPink.term-strsystemGreen.term-fnsystemBlue.term-commenttertiaryLabel.term-diff-addsystemGreen + 6% fill.term-diff-delsystemRed + 6% fill.term-diff-hunksystemBlue · bold.term-kw#E0A0FF (muted pink).term-fn#7DD3FC (muted blue).term-str#86EFAC (muted green).term-param#FDE68A (muted yellow).term-oprgba(255,255,255,0.7).term-punct / .term-commentrgba(255,255,255,0.45)- .term-kw / .term-str / .term-fn/Syntax highlighting — reuses system colors (pink, green, blue). Same classes in chat
pre codeand terminal.term-line. - .term-block/Fenced container for diffs and multi-line code output. tertiarySystemBackground + border. Radius: radius14 in chat (all senders), radius10 in terminal.
- .term-diff-add / .term-diff-del / .term-diff-hunk/Diff lines — green add, red delete, blue hunk header. Semantic color + 6% fill background.
- .term-ansi-*/ANSI color classes — terminal only. Map standard 8 + 8 bright colors to system palette.
- Web Chat: syntax spans inside
.prose pre code. Terminal: syntax spans inside.term-line. Same.term-*classes in both. - iOS Syntax:
NSAttributedStringwith color spans. Diff: tintedUIColorbackgrounds at 6% opacity. - Android Syntax:
AnnotatedStringwithSpanStyle(color = ...). Diff:Modifier.background()with 6% alpha. - All Code is always Plex Mono regardless of context. Zero new tokens for syntax — all existing system colors. ANSI is terminal-only.
Status & Thinking
- .chat-typing/Peer typing — standard 3-dot bounce. Show when a human user is composing.
- .chat-thinking/Agent thinking — blinking block cursor (same as terminal). Replaced by output when response begins.
- .term-thinking/Terminal thinking — blinking block cursor. Same cursor component in both contexts.
- .term-line--stderr / --success / --warning / --info/Semantic status lines. Color = meaning: systemRed=error, success=success, warning=warning, systemBlue=info.
- .term-progress/Inline progress bar for long-running operations. Title + track + percentage.
- iOS Chat peer typing:
UIView.animatewith bounce. Agent thinking:CABasicAnimationfor cursor blink (same as terminal). - Android Chat peer:
InfiniteTransitionwith staggered delays. Agent thinking:animateFloatAsStatefor cursor opacity. - Web Chat peer:
@keyframes chat-typing. Agent + terminal thinking: shared.cursor--blockwith CSSsteps(2)blink, respectsprefers-reduced-motion. - All Two distinct indicators: peer dots (human composing) and block cursor (AI/terminal thinking). Cursor is shared between chat agent and terminal.
Tool Activity
Read styles.css done
Edit styles.css:95 done
Lint styles.css 2 warnings
Test 42 specs passed
Read styles.css done
Edit styles.css:95 done
Added --tintCoral to both themes.
- .term-fold/Collapsible block for tool calls, verbose output, logs. Collapsed by default — user reads response first, opens on demand.
- .term-fold-tool/Tool name (Read, Edit, Scaffold). SecondaryLabel to recede from the target path.
- .term-badge/Status badge right-aligned in fold summary. Success/error for quick scan without expanding.
- Any sender can share tool results — agent, peer, or user. Same fold structure in all contexts.
- iOS
DisclosureGroupwith custom label. Chevron: SF Symbolchevron.rightwith rotation animation. - Android
AnimatedVisibilitywith custom header row. Chevron:animateFloatAsStaterotation. - Web Native
<details>for free accessibility + keyboard support. Chevron via CSS::beforerotation. - All Default collapsed. Any sender can share tool results. Body uses mono font for code output regardless of chat/terminal context.
Steps
- .term-steps/Multi-step process with visible progress. Each step transitions pending → active → done (or error).
- .term-step--active/Currently executing. Tint icon + full-contrast text draw attention.
- .term-step--pending/Not yet started. Tertiary color recedes.
- Always show all steps — don't hide pending ones. Any sender can share step progress.
- iOS
VStack(alignment: .leading, spacing: 2). Icons: SF Symbolscircle,circle.inset.filled,checkmark,xmark. - Android
Column(verticalArrangement = Arrangement.spacedBy(2.dp)). Material icons for states. - Web Icons via CSS
::before— Unicode (○, ◉, ✓, ✕). No icon font needed. - All Steps update in-place as task progresses. Always show all steps — don't hide pending ones.
Options
- .term-options/Numbered choice list. Present all options at once — never paginate.
- .term-option--selected/User's selection — tint border. Only one selected at a time.
- .term-option-key/Shortcut key (1, 2, A, B). Left-aligned, secondary color.
- Any sender can present options — agent asks questions, peer proposes polls, user creates votes.
- iOS
VStackofButtonelements. Selected:.border(tint, width: 1). - Android
ColumnofSurfacewithonClick. Selected:BorderStroke(1.dp, tint). - Web Keyboard: number keys quick-select, arrow keys navigate, Enter confirms. Focus ring on
:focus-visible. - All After selection, picker collapses and chosen option appears as regular text. Any sender can present options — agent, peer, or user.
Link Preview
- .chat-link-preview/Rich link card below bubble. Agent, peer, or user can share links. One per message (widget slot).
- .chat-link-preview-icon/40px rounded square with systemFill background. Favicon or generic icon.
- Terminal: no card — links render inline as
<a>in prose.
- iOS
LPMetadataProviderfetches OpenGraph data. Card: customUIViewmatching spec radius and padding. - Android Custom OG-tag fetcher. Card:
Surfacecomposable withRoundedCornerShape(14.dp). - Web Server-side OG fetch (avoid CORS).
.chat-link-previewwith flex layout. Hover lifts withshadowSmall. - All Fallback: domain + title only when image/description unavailable. Max width 320px. One per message.
Changeset
- .term-changeset/Commit summary card. Shows icon, short hash, sequence number, message, and file list.
- .term-changeset-id/Short hash in tint — clickable to view full diff.
- .term-changeset-seq/Sequence number (e.g. #4) — tertiaryLabel, right-aligned. Tracks order within session.
- .term-changeset-icon/Commit type icon — use a full radial icon (gear/sun) for agent-generated commits, a simpler vertical icon for peer/user pushes. Icon is decorative; the semantic info is in the hash and message.
- Any sender can share changesets — agent commits, peer pushes, user references.
- iOS
VStackwith mono font throughout. Tap navigates to diff viewer. - Android
ColumninsideSurface(shape = RoundedCornerShape(12.dp)). Mono font. - Web
.term-changesetbase radius12;.chat-demo .term-changesetoverrides to radius14. Same component, context-aware radius. - All Terminal: mono font throughout. Chat: fontBody (caption1) for readability — only fold bodies retain mono. Radius14 in chat (concentric with bubble), radius12 in terminal.
Card
Dashboard: analytics.fork.site
Status: live · 3 widgets active
Terminal has no interactive preview card. Rich content renders as prose with links. For progress/status, use .term-activity (see Activity Card section).
- .chat-widget/Interactive preview card — dashboard, map, poll. One per message (widget slot).
- .chat-widget-preview/160px visual area with tertiarySystemBackground. Hosts live content or placeholder icon.
- .chat-widget-meta/Overline status — LIVE, TAP TO OPEN, count. Mono font, tertiaryLabel.
- Any sender can share cards — agent presents results, peer shares locations, user posts polls.
- iOS Card:
UIViewwithclipsToBoundsat radius14. Preview: embeddedWKWebVieworUIViewsnapshot. - Android Card:
Surface(shape = RoundedCornerShape(14.dp)). Preview:AndroidViewor composable snapshot. - Web
.chat-widgetwithoverflow: hidden+radius14. Hover:translateY(-1px)+shadowSmall. - All Tap opens full-screen view. Max width 320px. Chat-only component — terminal has no interactive preview card. Terminal renders rich content as prose with links; for progress/status use
.term-activity(separate component, see Activity Card).
Activity Card
- .term-activity/Progress card for long-running tasks — builds, deploys, installs. Shared component in chat and terminal.
- .term-activity-bar/Tint-colored progress fill. Width set via inline style (JS-driven). Animates with easeFluent.
- .term-badge/Status badge (live, error, etc.) replaces percentage label when task completes.
- Any sender can share activity — agent builds, peer deploys, user runs tests.
- iOS Card:
HStackwith icon + body + badge. Progress:ProgressViewwith custom tint style. - Android Card:
Rowcomposable. Progress:LinearProgressIndicatorwithanimateFloatAsState. - Web
.term-activityflex row. Bar width via inlinestyle, transitions witheaseFluent 0.4s. - All Terminal: mono font throughout. Chat: fontBody (caption1) for readability. Radius14 in chat (concentric with bubble), radius12 in terminal. Icon optional. Progress updates in-place — no re-render flicker.
Artifact
Large artifact pill for primary outputs — created apps, deployed projects. 56×56 icon, 72px pill.
- .attach-artifact--lg/Large artifact pill with 56×56 icon. Use when the agent creates an application, deploys a project, or generates a primary output artifact.
- Not for: regular file references (config.yaml, schema.sql) — use default
.attach-artifact. - Icon color: tint (default) for agent-created. Custom color via inline style for peer/external apps.
- iOS Data model:
message.widget = .artifact(name:, url:, badge:)— NOT inmessage.attachments. Icon shape:UISmoothCornerPath(continuous corner) at radius14 to match native app icon. AcceptsUIImageor SF Symbol at 24pt. - Android Data model:
message.widget = Widget.Artifact(...)— NOT inmessage.attachments. Icon shape:RoundedCornerShape(14.dp). AcceptsImageVectororPainterat 24dp. - Web Modifier
.attach-artifact--lgoverrides--pill-hand icon sizing via CSS custom properties. No JS needed. - All Artifact is a widget — it occupies the widget slot (below text), max one per message. Uses
.chat-attach-filesCSS markup for pill styling, but in the data model it iswidget: .artifact(...), NOT part ofattachments[]. An artifact and a changeset cannot coexist in one message (both are widgets — split into two messages). Tap opens the artifact. Badge optional (live, draft, error).
Audio Message
Voice message player — waveform visualization with play/pause and duration. Widget type: sits outside bubble in the widget slot (max 1 per message).
- .chat-audio/Voice message widget in chat. Sits in the widget slot — outside and below the bubble (or standalone when voice-only).
- .term-audio/Audio player in terminal context. Standalone block with tertiarySystemFill background.
- .is-played/Applied to each bar that has been played. Progress fills left-to-right as playback advances.
- States: not-played (play icon, all bars uncolored, total duration), playing (pause icon, bars fill left-to-right, elapsed time), played (play icon, all bars colored, total duration).
- With text: text goes in the bubble above, audio widget below — standard widget pattern (like link preview after text).
- Widget slot: audio occupies the widget slot. Cannot coexist with other widgets (link preview, changeset, artifact) in the same message.
- Not for: audio files (music, podcasts, sound effects) — use file pill with audio icon. Not for transcription — that's text content.
- iOS Playback:
AVAudioPlayer. Waveform:CAShapeLayerwith bar paths or customUIView.draw(_:). Play/pause:UIButtonwith spring animation on state change. Extend hit area to 44pt viapoint(inside:with:)override orcontentEdgeInsets. - Android Playback:
MediaPlayerorExoPlayer. Waveform:Canvas.drawRoundRect()per bar in customView, or ComposeCanvascomposable. Play button:IconButton(modifier = Modifier.size(44.dp))for touch target. - Web Playback:
HTMLAudioElementAPI. Waveform bars:<span>elements with--hcustom property. Toggle.is-playedper bar ontimeupdateevent. No audio analysis needed at render — waveform data comes from server. - All Waveform data = peak amplitudes sampled from audio file (typically 25–40 samples, normalized 0–100%). Play button effective tap target ≥ 44pt — 36px button + 12px widget padding = 48px touchable area. Data model:
message.widget = .audio(url, duration, waveform)— NOT inattachments[]. Occupies widget slot (max 1 per message).
Banner
- .chat-banner--error/Blocking errors requiring user action — token depletion, payment failure, account suspension. Destructive fill signals urgency.
- .chat-banner--warning/Non-blocking alerts — low token count, expiring subscription, approaching limits. Warning fill signals caution without blocking.
- .chat-banner--info/Informational CTAs — new features, promotions, tips. Link-blue fill signals neutral but actionable information.
- .chat-banner-cta/Optional right-aligned action label in tint color. Describes what tapping does ("Top up", "Try it"). Short — 1–2 words. Omit when the main text is self-explanatory.
- Tap target: entire banner is tappable. One action per banner, defined by the consumer. No embedded buttons — the banner IS the button.
- Persistence: banners stay in message history. Not dismissable — they document a system event with an action path.
- Not a system message: visually distinct from
.chat-msg--system(timestamps, joins). System messages are passive text. Banners are CTAs — the pill shape, colored fill, and chevron all signal "tap me."
- iOS Use a
UIButtonwith.plainstyle inside a full-width container, centered with auto layout. Background: semantic color at 10% viaUIColor.systemRed.withAlphaComponent(0.1). Corner radius:.capsule(continuous corners, equivalent toradiusFull). Min height 44pt is the default tap target — enforce via height constraint. UseUIButton.Configurationfor icon + title + chevron layout. - Android Compose:
Surface(onClick, shape = RoundedCornerShape(50%), color = destructive.copy(alpha = 0.1f))withRow(verticalAlignment = CenterVertically)inside. Min height44.dp. Center in parent viaModifier.align(CenterHorizontally). Ripple indication on tap. - Web Use
<a>or<button>for the banner element. Wrap in.chat-msg.chat-msg--systemfor centering. Hover/active states handled in CSS. Addrole="link"if using<div>. - All Banners live in the message stream at the system level — they have no sender. Position: between messages, centered in chat, full-width in terminal. The chevron is a visual affordance only — the entire surface is the tap target, not the chevron.
File Pills
- .chat-attach-media/Media carousel. Agent: full-width flat. Peer/user: inside bubble.
- .chat-attach-files/Vertical file pill list. Max 320px. Radius10 pills with fontBody.
- .chat-msg-bubble--media/Single media bubble. Image edge-to-edge, text below with padding.
- .chat-msg-bubble--media-scroll/Multi-media bubble. 70% thumb width, peek 30% of next.
- Agent flat: media + files not inside bubble, scroll full chat width.
- Terminal: horizontal waterfall in
.attach-row. Files are compact pills in 2-row columns.
- iOS Chat:
PHPickerViewControllerfor media. File pills: self-sizingUICollectionViewCell. Terminal:UICollectionViewhorizontal flow. - Android Chat:
ActivityResultContracts.GetContent(). Pills:AssistChip. Terminal:LazyRow. - Web Chat: hidden
<input type="file">.overflow-x: auto+ hidden scrollbar on carousel. - All Any sender can attach any file type. Unknown types = file pill. Thumb size differs: chat = 280px, terminal = 48px (
--pill-h).
Media Thumbnails
Check this screenshot.
Three screenshots from the review.
Check the layout on mobile.
var(--chat-media-w, 280px) for override flexibility, but 280px is the default on all platforms)- .chat-msg-bubble--media/Single media in peer/user bubble — image edge-to-edge, no padding. With text: image top, text below with padding.
- .chat-msg-bubble--media-scroll/Multiple media — horizontal scroll, 70% width per thumb (peek pattern). Inside peer/user bubble.
- .attach-thumb--generating/Shimmer placeholder while media is being generated. Opacity pulse animation, "Generating..." label.
- .attach-row--input/Terminal input attachments — compact wrapping layout. Media thumb + pill waterfall in 2-row columns.
- .attach-row--lg/Terminal output attachments — taller layout. 3-row column wrap for pills when media is present.
- iOS Media bubble:
AsyncImagewith.clipShape()matching bubble. Generating:.opacityanimation with shimmer gradient. - Android Media:
AsyncImagewithModifier.clip(). Scroll:LazyRowwith 70% width items. - Web Media bubble:
overflow: hiddenon bubble clips image to border-radius. Generating: CSS@keyframesopacity pulse. - All Aspect ratio is preserved — never crop or stretch media. If aspect ratio is unknown at load time, render a 4:3 placeholder; once dimensions resolve, animate to actual ratio (easeFluent 0.25s). Generating state must be visually distinct from loaded state.
Composer & Prompt
- .chat-compose/Chat input bar — capsule shape when single-line, softens to radius20 when content grows (attachments or multi-line).
- .chat-compose-attachments/Horizontal thumbnail strip above input. Scrollable, hidden scrollbar. Separator line between thumbs and text input.
- .chat-compose-send/Send action — chatBubbleUserBackground filled circle. Disabled (38% opacity) when input is empty.
- .chat-compose-attach/Attach action — systemFill circle, outside capsule. Opens file picker.
- .term-prompt/Terminal input — path + tint symbol + text. Prompt is inline, not a separate input area.
- .window-tabs/Tab bar in terminal titlebar for multiple sessions. Active tab: systemFill + label color.
- iOS Composer:
UITextViewwith auto-growing height. Send:UIButtonwith outgoing-bubble color fill (not systemBlue — see Chat Bubbles notes). Safe area: input sits above home indicator viasafeAreaLayoutGuide.bottomAnchor. - Android Composer:
BasicTextFieldwithModifier.heightIn(min = 44.dp). Send:IconButton. Bottom inset:WindowInsets.ime+WindowInsets.navigationBars. - Web Composer:
<textarea>with JS auto-resize. Terminal:keydownhandler on.term-surface[tabindex]. Bottom safe area:env(safe-area-inset-bottom). - All Input must stay above keyboard on mobile. Composer min tap target: 44pt. Terminal cursor blinks at 1s via
steps(2), pauses while typing.
Full Session
Comprehensive showcase — every component in context. Note: this demo intentionally exceeds the density budget to show all widget types in a single flow. In production, a single message should have at most 1 widget (anatomy rule).
I'll build it. Choose a style:
Scaffold weather-dash/ done
Write api.js done
Done! Here's your API key setup:
const KEY = 'your-key';Colors need adjusting — hot should be coral.
Read design-spec.md done
Scaffold weather-dash/ done
Done! API key: const KEY = 'your-key';
Edit styles.css:14 done
- Chat anatomy per message: attachments → folds → text → widget. At most 1 widget. Folds are inline metadata, not widgets. Text + changeset = OK. Text + widget = OK. Text + changeset + widget = split into 2 messages.
- Terminal flow: prompt → folds → steps → prose → changeset / activity → artifact. All in one continuous stream. No message boundary — output flows naturally.
- Peer in chat: media inside bubble (single = edge-to-edge). Peer in terminal:
@juliausername prefix. - Grouping: consecutive user or peer messages from the same sender (<60s) group (tight spacing, shared timestamp on last). Agent never groups.
- iOS Chat:
UICollectionViewwith self-sizing cells. Terminal:NSAttributedString-backed scroll view. - Android Chat:
LazyColumnwithreverseLayout = true. Terminal:LazyColumnwith auto-scroll. - Web Chat: flex column,
overflow-y: auto. Terminal:.term-surfacewithscrollIntoView. - All Virtualize long sessions (>500 items). Input stays above keyboard on mobile.