Feature Waves
Wave 2a — Rich Feed Blocks
Rich content as an opt-in artifact Mallory verbally points to — the spoken channel stays primary and clean.
Situation
The feed rendered assistant replies as plain text, and Mallory's persona is voice-first — every reply is written to be read aloud, which is why he's told "plain prose only, no markdown/lists/URLs." Naively rendering all replies as markdown would fight that identity and make TTS read syntax aloud.
The resolving principle: the spoken channel stays primary and clean; rich content is an opt-in, rendered attachment Mallory verbally points to. This wave is the first slice of a layered sequence — link previews (2c) and diagrams/data widgets (2d) are deferred, slotting into the same artifact frame later.
Key decisions
Rich content = artifacts he points to
Non-speakable detail (table/image) renders as a collapsed card; Mallory says he's included it ("I've put a table together for you") rather than reading it. No custom artifact fence — standard markdown table/image, auto-collapsed by policy.
Choice chips
A model-emitted trailing ``chips`` fence, parsed client-side into tappable suggested user utterances (tap = send). No action/deep-link chip type.
Artifact display
Inline collapsed preview + Open/Expand, reusing the existing reply-level clamp (12em + fade + "Show more"), chosen over chip-gated display.
Flatten-for-speech is required
A pure toSpeech() strips the chips fence, tables, images, markdown emphasis/headings/code, and bare URLs before anything reaches TTS.
Safe links
Internal routes navigate in-app via isInternalHref; external/obsidian/tel/mailto open appropriately; DOMPurify's ALLOWED_URI_REGEXP extended for obsidian:, javascript: stays blocked.
Architecture
Two new pure modules (markdown, replyBlocks) shared between the feed and ReportViewer, plus a persona nudge permitting the new affordances.
renderMarkdown(md, opts) — DOMPurify-sanitized marked output; feed uses breaks:true to preserve plain-prose line breaks, ReportViewer keeps breaks:false.parseChips (splits trailing chips fence from body), isInternalHref, toSpeech (strips all rich syntax for TTS) — unit-tested..dispatch de-roles from a button to a plain div so links/chips are valid; link-click interception routes internal hrefs via goto.toSpeech(text) instead of raw text.Risks & guardrails
TTS reads syntax aloud
Mitigated by toSpeech at every speak call site — verified by unit test, since real TTS itself needs the device.
Streaming jank from partial markdown
While streaming, the reply renders as plain text (current behaviour); markdown/chip parsing only applies once the reply settles.
Misrouted chips fence
A ``chips`` fence not at the very end of the reply is left in the body, not stripped — avoids accidentally eating mid-reply content that merely resembles the fence.
Delivery
Task 1
Pure reply-block logic (replyBlocks.ts).
Task 2
Shared markdown renderer (markdown.ts) + ReportViewer refactor onto it.
Task 3
Flatten TTS text via toSpeech (session.svelte.ts).
Task 4
Persona nudge (src/persona.ts).
Task 5
Feed rendering — markdown, chips, safe links, artifacts (+page.svelte).
Out of scope
docs/superpowers/specs/2026-06-13-wave-2a-rich-feed-blocks-design.mdplan ·
docs/superpowers/plans/2026-06-13-wave-2a-rich-feed-blocks.md