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.