diff --git a/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md b/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md
index 06140e84a..009b1c6e9 100644
--- a/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md
+++ b/docs/superpowers/specs/2026-08-25-homepage-medium-switcher-design.md
@@ -1,7 +1,7 @@
# Homepage medium switcher — design
**Date:** 2026-08-25
-**Status:** approved, not yet implemented
+**Status:** Phase 1 shipped (PR #831). Phase 2 revised 2026-08-26 — see `?featured=`.
## Problem
@@ -39,7 +39,7 @@ they came for — and so the homepage gains a code surface where it matters.
interface SectionMedia {
video?: DemoClip; // from lib/demo-media.ts
code?: SolutionCode[]; // reuse the solutions type
- live?: { prompt: string; mode?: 'embed' | 'popup' | 'sidebar' };
+ live?: { featured: string; mode?: 'embed' | 'popup' | 'sidebar' };
}
```
@@ -73,7 +73,7 @@ Four sections × three mediums = twelve panes.
| Section | Video | Code | Live prompt |
| --- | --- | --- | --- |
-| Stream | `langgraph-demo` (exists) | streaming snippet | stream a long answer |
+| Stream | `langgraph-demo` (exists) | streaming snippet | a markdown-streaming suggestion |
| Render | **needs recording** — generative UI | `views()` + `` | chart request |
| Ship | **needs recording** — reload restores the thread | `error()` / `status()` / `reload()` | any prompt, then reload |
| Approve | `hitl-demo` (exists) | `interrupt()` / `submit({ resume })` | the backups approval scenario |
@@ -87,23 +87,38 @@ Every code pane must be a working snippet against the published API, sourced
from the docs rather than written from memory. The same rule
`solutions-data.ts` already enforces applies here.
-## `?prompt=` in examples/chat
+## `?featured=` in examples/chat
-The demo app supports `/embed/:threadId`, `/popup/:threadId`,
-`/sidebar/:threadId` and an `?appmode=` flag, but has no way to open on a given
-scenario. Without one, every section's live tab is the same empty demo under a
-different heading — the find-and-replace pattern `solutions-data.ts` exists to
-prevent, and the weakest tab in every section.
+**Revised 2026-08-26.** The original design called for a free-text `?prompt=`
+that prefills the composer. Researching it before implementation showed two
+problems:
-Add `?prompt=` to `examples/chat`, which **prefills the composer and never
-auto-sends**. Auto-executing text from a URL would let any link run something on
-a visitor's behalf; prefilling keeps a human in the loop, which is the framework's
-own argument.
+1. `ChatComponent` has no draft input, so prefilling the composer means adding a
+ public input to `@threadplane/chat` — a commercially licensed published
+ package. That is a semver-relevant API addition requiring an api-docs
+ regeneration, for the sake of a marketing page.
+2. Free text in a URL means any link can display arbitrary attacker-chosen text
+ inside the Threadplane demo UI. The original design worried about
+ auto-execution but not about defacement.
-Rejected alternative: deep-linking pre-seeded threads via `/embed/:threadId`.
-It needs no app change, but the seeded threads must survive in production
-storage, and a checkpoint wipe would silently empty every live tab on the
-homepage with no failing test anywhere.
+The demo already has the mechanism needed. `welcome-suggestions` renders a
+featured chip plus a "More prompts" dropdown, and `suggestionsForAppMode()`
+decides which suggestion is featured. A **keyed** `?featured=` param
+selecting from that curated list gets the same outcome:
+
+- No library change — the work is contained in `examples/chat`.
+- Never auto-sends: the chip still requires a click.
+- A link cannot inject text, because unknown ids fall back to the default
+ featured suggestion rather than rendering what the URL says.
+- Reuses UI that already exists and is already tested.
+
+The cost is that a live tab can only open on a curated scenario. For a marketing
+surface that is a feature, not a limitation.
+
+Each `SectionMedia.live` therefore carries a suggestion id, and the live tab
+frames `https://demo.threadplane.ai/embed?featured=`. The demo sets no
+`X-Frame-Options` or frame-ancestors CSP, and `DemoModal` already frames it, so
+embedding works.
## Accessibility
@@ -123,20 +138,22 @@ readers actually reach for is measurable rather than assumed.
- `MediumSwitcher`: tab roles and `aria-selected`; arrow-key movement; a single
medium renders bare with no tablist; **only the active pane is in the DOM**;
the live iframe is absent until its tab is selected.
-- Data: every section declares at least one medium; every live prompt is
- non-empty; every video URL resolves through `DEMO_CDN`.
-- `?prompt=`: the composer is prefilled and **no run starts** — the important
- assertion, since the failure mode is a URL that executes.
+- Data: every section declares at least one medium; every live entry names a
+ suggestion id that actually exists; every video URL resolves through `DEMO_CDN`.
+- `?featured=`: a known id features that suggestion; an UNKNOWN id falls back to
+ the default rather than rendering the URL's text — the important assertion,
+ since the failure mode being designed out is a link that controls the page.
+ And selecting a suggestion still requires a click: **no run starts on load**.
## Delivery
-Two PRs, so neither is unreviewable and half ships without waiting on
-recordings.
+Three PRs, so none is unreviewable and nothing waits on a recording session.
-1. `MediumSwitcher` + Stream and Approve (their videos already exist), with code
- and video tabs. No live tab yet.
-2. `?prompt=` in `examples/chat`, the Render and Ship recordings, and the live
- tab across all four sections.
+1. ✅ SHIPPED (PR #831). `MediumSwitcher` + Stream and Approve, video and code
+ tabs. No live tab yet.
+2. `?featured=` in `examples/chat`, then the live tab and the Render/Ship
+ switchers with code tabs.
+3. The Render and Ship recordings, added as video tabs once produced.
## Open questions
diff --git a/examples/chat/angular/src/app/modes/embed-mode.component.ts b/examples/chat/angular/src/app/modes/embed-mode.component.ts
index 6c43525c0..06ceec118 100644
--- a/examples/chat/angular/src/app/modes/embed-mode.component.ts
+++ b/examples/chat/angular/src/app/modes/embed-mode.component.ts
@@ -19,7 +19,8 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
[selectedModel]="shell.model()"
(selectedModelChange)="shell.onModelChange($event)"
>
-
+
`,
styles: [`
diff --git a/examples/chat/angular/src/app/modes/popup-mode.component.ts b/examples/chat/angular/src/app/modes/popup-mode.component.ts
index 4eeb8e1e7..986714c01 100644
--- a/examples/chat/angular/src/app/modes/popup-mode.component.ts
+++ b/examples/chat/angular/src/app/modes/popup-mode.component.ts
@@ -33,7 +33,8 @@ import { AppModePromoComponent } from './app-mode-promo.component';
[showModelPicker]="false"
(selectedModelChange)="shell.onModelChange($event)"
>
-
+
`,
styles: [`
diff --git a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts
index 233ba1ae7..eedab8718 100644
--- a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts
+++ b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts
@@ -39,7 +39,8 @@ import { WelcomeSuggestionsComponent } from './welcome-suggestions.component';
/>
}
-
+
`,
styles: [`
diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts
index d5b508786..b7424da77 100644
--- a/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts
+++ b/examples/chat/angular/src/app/modes/welcome-suggestions.component.ts
@@ -122,7 +122,15 @@ export class WelcomeSuggestionsComponent {
*/
readonly appModeOn = input(false);
- private readonly suggestions = computed(() => suggestionsForAppMode(this.appModeOn()));
+ /**
+ * Optional `?featured=` id. A KEY into the curated list, never free text —
+ * an unrecognised id falls back to the default rather than rendering it.
+ */
+ readonly featuredId = input(undefined);
+
+ private readonly suggestions = computed(() =>
+ suggestionsForAppMode(this.appModeOn(), this.featuredId()),
+ );
protected readonly featuredOne = computed(() => this.suggestions().featured);
protected readonly moreOptions = computed(() =>
this.suggestions().more.map((s) => ({ value: s.value, label: s.label, description: s.description })),
diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.spec.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.spec.ts
new file mode 100644
index 000000000..58a138398
--- /dev/null
+++ b/examples/chat/angular/src/app/modes/welcome-suggestions.spec.ts
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+import { describe, expect, it } from 'vitest';
+import {
+ FEATURED_SUGGESTIONS,
+ ITINERARY_SUGGESTIONS,
+ MORE_SUGGESTIONS,
+ suggestionsForAppMode,
+} from './welcome-suggestions';
+
+/**
+ * `?featured=` lets a link open the demo on a specific curated scenario — the
+ * homepage's live tabs use it so each section frames its OWN story rather than
+ * the same empty demo under four headings.
+ *
+ * The id is a KEY into this curated list, never free text. That is deliberate:
+ * a free-text param would let any link render arbitrary chosen words inside the
+ * Threadplane demo UI.
+ */
+describe('suggestionsForAppMode with a featured id', () => {
+ const all = [...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS, ...ITINERARY_SUGGESTIONS];
+
+ it('gives every suggestion a unique id', () => {
+ const ids = all.map((s) => s.id);
+ expect(ids.every((id) => typeof id === 'string' && id.length > 0)).toBe(true);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it('features the suggestion whose id is requested', () => {
+ const target = MORE_SUGGESTIONS[0];
+
+ const { featured, more } = suggestionsForAppMode(false, target.id);
+
+ expect(featured.id).toBe(target.id);
+ expect(more.some((s) => s.id === target.id)).toBe(false);
+ });
+
+ it('keeps every other suggestion available in the dropdown', () => {
+ const target = MORE_SUGGESTIONS[0];
+
+ const { featured, more } = suggestionsForAppMode(false, target.id);
+
+ expect(more).toHaveLength(all.filter((s) => !ITINERARY_SUGGESTIONS.includes(s)).length - 1);
+ expect([featured, ...more].map((s) => s.id).sort()).toEqual(
+ [...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS].map((s) => s.id).sort(),
+ );
+ });
+
+ it('falls back to the default when the id is unknown', () => {
+ // The security-relevant case: an unknown id must NOT render what the URL
+ // says. It must behave exactly as if no id were supplied.
+ const { featured } = suggestionsForAppMode(false, 'no-such-suggestion');
+
+ expect(featured.id).toBe(FEATURED_SUGGESTIONS[0].id);
+ });
+
+ it('ignores an id that is not a plain string', () => {
+ const { featured } = suggestionsForAppMode(false, undefined);
+
+ expect(featured.id).toBe(FEATURED_SUGGESTIONS[0].id);
+ });
+
+ it('honours a requested id even in app mode', () => {
+ const target = FEATURED_SUGGESTIONS[1];
+
+ const { featured } = suggestionsForAppMode(true, target.id);
+
+ expect(featured.id).toBe(target.id);
+ });
+
+ it('still leads with the itinerary starter in app mode with no id', () => {
+ const { featured } = suggestionsForAppMode(true);
+
+ expect(featured.id).toBe(ITINERARY_SUGGESTIONS[0].id);
+ });
+});
diff --git a/examples/chat/angular/src/app/modes/welcome-suggestions.ts b/examples/chat/angular/src/app/modes/welcome-suggestions.ts
index e4bfccd2d..a5b337e98 100644
--- a/examples/chat/angular/src/app/modes/welcome-suggestions.ts
+++ b/examples/chat/angular/src/app/modes/welcome-suggestions.ts
@@ -18,6 +18,12 @@
* imports it (none in-tree today; preserved for back-compat).
*/
export interface WelcomeSuggestion {
+ /**
+ * Stable key for `?featured=`. Deliberately NOT derived from the label at
+ * runtime: the homepage links to these ids, so renaming a label must not
+ * silently break a link. Change an id only if you also update its consumers.
+ */
+ readonly id: string;
readonly label: string;
readonly value: string;
readonly description: string;
@@ -26,6 +32,7 @@ export interface WelcomeSuggestion {
export const FEATURED_SUGGESTIONS: readonly WelcomeSuggestion[] = [
// 1. GenUI surface render — the canonical demo's most differentiating capability
{
+ id: 'generative-ui-contact-form',
label: 'Generative UI: contact form',
value:
'Show me a contact form with fields for name, email address, subject, and a multi-line message, plus a Send button.',
@@ -34,6 +41,7 @@ export const FEATURED_SUGGESTIONS: readonly WelcomeSuggestion[] = [
// 2. Markdown / streaming showcase
{
+ id: 'tell-me-about-coral',
label: 'Tell me about coral reefs',
value: 'Tell me about coral reefs',
description: 'Streams a rich markdown response — headings, bullets, and emphasis.',
@@ -41,6 +49,7 @@ export const FEATURED_SUGGESTIONS: readonly WelcomeSuggestion[] = [
// 3. Tool use + citations
{
+ id: 'angular-signals-search-and',
label: 'Angular signals — search and cite',
value:
'Use the search tool to find authoritative information about Angular signals, then explain what they are and when to use them. Cite each source inline as [^doc-id] using the document `id` field returned by the tool.',
@@ -50,81 +59,95 @@ export const FEATURED_SUGGESTIONS: readonly WelcomeSuggestion[] = [
export const MORE_SUGGESTIONS: readonly WelcomeSuggestion[] = [
{
+ id: 'write-a-haiku-about',
label: 'Write a haiku about Angular',
value: 'Write a haiku about Angular',
description: 'Quick streaming output — good for checking latency.',
},
{
+ id: 'list-5-productivity-tips',
label: 'List 5 productivity tips',
value: 'List 5 productivity tips, in markdown bullets.',
description: 'Renders a markdown bullet list in the streaming bubble.',
},
{
+ id: 'compare-signals-rxjs-and',
label: 'Compare signals, RxJS, and zone.js',
value:
'Show me a table comparing Angular signals, RxJS, and zone.js — three columns: name, mental model, when to use.',
description: 'Renders a markdown comparison table across three columns.',
},
{
+ id: 'explain-promises-with-code',
label: 'Explain promises with code',
value: 'Explain JavaScript promises with a fenced code block in TypeScript.',
description: 'Shows the syntax-highlighted code block renderer.',
},
{
+ id: 'solve-a-multi-step',
label: 'Solve a multi-step puzzle',
value:
'Three friends start with 14 apples. They share them so each gets a different prime number of apples and one gets exactly twice as many as another. How many does each get? Walk through your reasoning step by step.',
description: 'Try Effort = high — shows step-by-step chain-of-thought output.',
},
{
+ id: 'approve-before-a-destructive',
label: 'Approve before a destructive action',
value:
'I want to clean up old database backups older than 90 days. Walk me through what you would delete, and call request_approval before doing anything destructive so I can review your plan.',
description: 'Pauses mid-run for your approval before proceeding.',
},
{
+ id: 'dispatch-a-research-subagent',
label: 'Dispatch a research subagent',
value:
'Use the research subagent to investigate the history and motivation behind Angular standalone components, then report back with a concise summary.',
description: 'Parent delegates work to a subagent and streams back the result.',
},
{
+ id: 'generative-ui-feedback-form',
label: 'Generative UI: feedback form',
value:
'Build me an interactive feedback form with a name field, a 1–5 rating picker, and a Submit button.',
description: 'Renders an interactive form with a rating input and Submit button.',
},
{
+ id: 'generative-ui-settings-card',
label: 'Generative UI: settings card',
value:
'Render a settings card with a toggle for dark mode, a language dropdown (English / Spanish / French), and a Save button.',
description: 'Composes toggle, dropdown, and button components into a card.',
},
{
+ id: 'generative-ui-poll',
label: 'Generative UI: poll',
value:
'Create a quick poll asking "Which front-end framework do you prefer?" with options Angular, React, Vue, and Svelte, plus a Vote button.',
description: 'Renders a live-vote poll component you can interact with.',
},
{
+ id: 'generative-ui-media-product',
label: 'Generative UI: media product card',
value:
'Render a product card with: a header image at the top, a tab strip with two tabs ("Overview" and "Specs"). Under Overview show a Row containing an icon and a short description Text. Under Specs show a List of feature bullets each prefixed with a small icon. Below the tabs add a primary "Add to cart" Button.',
description: 'Combines image, tabs, icons, and list into a rich product card.',
},
{
+ id: 'generative-ui-booking-surface',
label: 'Generative UI: booking surface with modal',
value:
'Render a booking surface: a heading "Book your trip", a DateTimeInput for travel date, a horizontal divider, then a Row containing two Cards (one for departure city, one for return city) each with a TextField. Below the Row add a primary "Continue" Button whose action opens a Modal containing a confirmation Column with a summary Text and Confirm / Cancel Buttons.',
description: 'Multi-component layout ending in a confirmation modal.',
},
{
+ id: 'smoke-media-layout-kitchen',
label: 'Smoke: media + layout kitchen sink',
value:
'Render a Card containing a Tabs component with two tabs labeled "Media" and "Layout". Under the Media tab show a Column containing: a header Image (use https://placehold.co/600x300/4f8df5/ffffff.png as the URL), an Icon (any icon name from the canonical set, e.g. star), a short Text caption, an AudioPlayer (use https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3 as the URL), and a Video (use https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 as the URL). Under the Layout tab show: a Row containing two Text components separated by a vertical Divider, then a horizontal Divider, then a List of three Text bullet items, then a Column containing two Text components.',
description: 'Exercises image, audio, video, divider, row, and list primitives.',
},
{
+ id: 'smoke-interactive-form-kitchen',
label: 'Smoke: interactive form kitchen sink',
value:
'Render a Card titled "Profile setup" containing a Column with: a TextField for display name, a Slider for "experience years" (range 0-30), a CheckBox for "subscribe to newsletter", a DateTimeInput for birthday (date only), a ChoicePicker for "favorite frameworks" with options Angular, React, Vue, Svelte using the multipleSelection variant, a horizontal Divider, a Row containing a primary "Save" Button and a secondary "Open details" Button whose action opens a Modal with a Column containing a Text summary and a Close Button.',
@@ -150,16 +173,19 @@ export const WELCOME_SUGGESTIONS: readonly WelcomeSuggestion[] = [
*/
export const ITINERARY_SUGGESTIONS: readonly WelcomeSuggestion[] = [
{
+ id: 'plan-a-long-weekend',
label: 'Plan a long weekend in Paris',
value: 'Plan a long weekend in Paris',
description: 'Agent builds a day-by-day itinerary you watch appear on the map.',
},
{
+ id: '3-days-in-tokyo',
label: '3 days in Tokyo with great food',
value: '3 days in Tokyo with great food',
description: 'Streams a food-forward trip and plots each stop live.',
},
{
+ id: 'a-week-on-the',
label: 'A week on the California coast',
value: 'A week on the California coast',
description: 'Lays out a multi-day route the agent edits on the cockpit map.',
@@ -174,19 +200,23 @@ export const ITINERARY_SUGGESTIONS: readonly WelcomeSuggestion[] = [
* - App mode OFF (plain chat): keep this demo's EXISTING capability surface
* (GenUI / streaming / tool-use + citations, then the "More prompts" set).
*/
-export function suggestionsForAppMode(appModeOn: boolean): {
+export function suggestionsForAppMode(
+ appModeOn: boolean,
+ featuredId?: string,
+): {
readonly featured: WelcomeSuggestion;
readonly more: readonly WelcomeSuggestion[];
} {
- if (appModeOn) {
- const [featured, ...restItinerary] = ITINERARY_SUGGESTIONS;
- return {
- featured,
- more: [...restItinerary, ...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS],
- };
- }
- return {
- featured: FEATURED_SUGGESTIONS[0],
- more: [...FEATURED_SUGGESTIONS.slice(1), ...MORE_SUGGESTIONS],
- };
+ const pool = appModeOn
+ ? [...ITINERARY_SUGGESTIONS, ...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS]
+ : [...FEATURED_SUGGESTIONS, ...MORE_SUGGESTIONS];
+
+ // `featuredId` arrives from a URL, so it is untrusted input. Treat it as a
+ // KEY, never as content: an id we do not recognise falls back to the default
+ // rather than rendering anything the link supplied. That is what stops a
+ // crafted link from putting arbitrary words inside the demo UI.
+ const requested = featuredId ? pool.find((s) => s.id === featuredId) : undefined;
+ const featured = requested ?? pool[0];
+
+ return { featured, more: pool.filter((s) => s.id !== featured.id) };
}
diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts
index 30d2e8db7..4ed8a3c9e 100644
--- a/examples/chat/angular/src/app/shell/demo-shell.component.ts
+++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts
@@ -384,6 +384,26 @@ export class DemoShell {
return this.locationMode() === 'embed' ? 'off' : 'on';
}
+ /**
+ * The `?featured=` id, if the URL carries one.
+ *
+ * Read once at construction like `appmode`, not tracked reactively: this
+ * selects which welcome suggestion leads on a cold open (the website's live
+ * demo tabs link here per section), and re-reading it mid-session would let a
+ * later navigation reshuffle the chips under the user.
+ *
+ * Passed through untouched. `suggestionsForAppMode` treats it as a key and
+ * ignores anything it does not recognise, so no validation is needed here —
+ * and none should be added, since sanitising it here would imply it is ever
+ * rendered as content.
+ */
+ readonly featuredSuggestionId: string | undefined = this.initialFeaturedId();
+
+ private initialFeaturedId(): string | undefined {
+ const search = this.document.defaultView?.location.search ?? '';
+ return new URLSearchParams(search).get('featured') ?? undefined;
+ }
+
/**
* Source of truth for the model picker. The shell owns it; the
* patched submit injects it into state on every send.