diff --git a/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx b/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx
index cd96c768a7..6d183dffcb 100644
--- a/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx
+++ b/packages/react/src/experimental/Information/Headers/BaseHeader/index.tsx
@@ -2,6 +2,7 @@ import { Fragment } from "react"
import { AvatarVariant, F0Avatar } from "@/components/avatars/F0Avatar"
import { F0Button } from "@/components/F0Button"
+import { Cross } from "@/icons/app"
import { F0ButtonDropdown } from "@/components/F0ButtonDropdown"
import { StatusVariant } from "@/components/tags/F0TagStatus"
import { Description } from "@/experimental/Information/Headers/BaseHeader/Description"
@@ -21,6 +22,7 @@ import {
DropdownItem,
MobileDropdown,
} from "@/experimental/Navigation/Dropdown"
+import { useI18n } from "@/lib/providers/i18n"
import { cn } from "@/lib/utils"
export type HeaderSecondaryButtonAction = SecondaryAction & {
@@ -60,6 +62,8 @@ interface BaseHeaderProps {
metadataRowGap?: MetadataProps["rowGap"]
/** Renders a 1px bottom border at the very bottom of the header. */
showBottomBorder?: boolean
+ /** When set, renders a close button in the header actions that calls this on click. */
+ onClose?: () => void
}
const isVisible = (action: { isVisible?: boolean }) =>
@@ -77,7 +81,9 @@ export function BaseHeader({
metadata = [],
metadataRowGap = "none",
showBottomBorder = false,
+ onClose,
}: BaseHeaderProps) {
+ const i18n = useI18n()
const allMetadata: BaseHeaderProps["metadata"] = [
status && {
label: status.label,
@@ -240,6 +246,17 @@ export function BaseHeader({
)}
+ {onClose && (
+
+
+
+ )}
@@ -308,6 +325,20 @@ export function BaseHeader({
/>
)}
+ {onClose && (
+ <>
+
+
+
+
+ >
+ )}
{allMetadata.length > 0 && (
diff --git a/packages/react/src/lib/storybook-utils/ai-mocks.ts b/packages/react/src/lib/storybook-utils/ai-mocks.ts
index eb4e00318d..e87837c103 100644
--- a/packages/react/src/lib/storybook-utils/ai-mocks.ts
+++ b/packages/react/src/lib/storybook-utils/ai-mocks.ts
@@ -13,25 +13,32 @@ const MOCK_TRANSCRIPTS = [
] as const
/**
- * Simulates a streaming STT endpoint: picks a random long transcript and emits
- * it word by word so the surface fills live (Wispr Flow feel) without any
- * backend.
+ * Builds a streaming STT mock from a pool of transcripts: picks a random one and
+ * emits it word by word so the surface fills live (Wispr Flow feel) without any
+ * backend. Use this to make voice dictation contextual to a given flow (e.g.
+ * survey-refinement phrasing in the co-creation story) instead of the generic
+ * pool below.
*/
-export const mockTranscribe: TranscribeFn = async (
- _audio,
- { onPartial, signal }
-) => {
- const transcript = pickRandom(MOCK_TRANSCRIPTS)
- const words = transcript.split(" ")
- let acc = ""
- for (const word of words) {
- if (signal?.aborted) break
- await new Promise((r) => setTimeout(r, 60 + Math.random() * 100))
- acc = acc ? `${acc} ${word}` : word
- onPartial(acc)
+export const makeMockTranscribe =
+ (transcripts: readonly string[]): TranscribeFn =>
+ async (_audio, { onPartial, signal }) => {
+ const transcript = pickRandom(transcripts)
+ const words = transcript.split(" ")
+ let acc = ""
+ for (const word of words) {
+ if (signal?.aborted) break
+ await new Promise((r) => setTimeout(r, 60 + Math.random() * 100))
+ acc = acc ? `${acc} ${word}` : word
+ onPartial(acc)
+ }
+ return transcript
}
- return transcript
-}
+
+/**
+ * Default streaming STT mock over the generic transcript pool above. Backs the
+ * `WithVoiceDictation` stories that aren't tied to a specific flow.
+ */
+export const mockTranscribe: TranscribeFn = makeMockTranscribe(MOCK_TRANSCRIPTS)
const MOCK_ENHANCED_TEXTS = [
`
Our quarterly review highlighted three priorities for the next cycle: improving the onboarding experience, reducing the time it takes to close payroll, and giving managers better visibility over team workloads.
Each priority now has a clear owner and a measurable target, so we can track progress week by week instead of waiting for the end of the quarter to find out where we stand.
`,
diff --git a/packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx b/packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx
new file mode 100644
index 0000000000..b846642855
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx
@@ -0,0 +1,157 @@
+import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
+import { DoDonts } from "@/lib/storybook-utils/do-donts"
+
+
+
+# Accessibility
+
+Co-creation puts a conversation, a set of accept/reject decisions, and a live resource canvas
+on screen at once, and changes them as the AI works. That makes a few things load-bearing:
+the user must be able to **drive every decision from the keyboard**, **hear what the AI just
+did** without watching the canvas, and **keep their place** when panels open, close, and
+swap. The building blocks already implement most of this; this page states the contract the
+pattern relies on so a new co-creation flow inherits it instead of re-deriving it.
+
+## Keyboard
+
+Everything the user can do with a pointer must be reachable from the keyboard, in a sensible
+order, with a visible focus ring.
+
+
+
+
+
+
Surface
+
Keyboard behavior
+
+
+
+
+
+ Clarifying questions (F0ClarifyingPanel)
+
+
+ Arrow keys move between options, Enter selects and
+ advances, and Esc cancels the whole flow. The panel
+ surfaces these hints inline ("navigate · Enter select · Esc cancel").
+
+
+
+
+ Proposal cards (accept / reject)
+
+
+ The accept (✓) and reject (✗) actions are real buttons with text
+ labels, focusable in reading order. Resolving a proposal must not
+ strand focus (see below).
+
+
+
+
+ Resource cards (Open / Close)
+
+
+ Opening and closing the canvas is a focusable control on the card —
+ never a pointer-only target. Superseded cards drop their action and
+ leave the tab order.
+
+
+
+
+ Header trigger (F0OneSwitch)
+
+
+ The One switch is reachable in every phase, so the keyboard user can
+ always open, collapse, or return to the conversation.
+
+
+
+
+
+
+
+
+## Focus
+
+The flow rearranges the screen as it advances — the chat animates in, the canvas opens, a
+proposal resolves and its card changes. Move focus deliberately at each of these moments so a
+keyboard or screen-reader user is never left on a control that just disappeared.
+
+- **Entering Chat** — move focus to the composer so the user can start typing immediately.
+- **Opening the canvas (Split)** — move focus into the canvas (or its heading), not back to
+ the top of the page.
+- **Resolving a proposal** — when an accepted change posts a new live card and supersedes the
+ old one, land focus on the new card (or the assistant's follow-up), not on the now-inert
+ superseded card.
+- **Closing the canvas** — return focus to the card that opened it.
+
+
+
+## Announcements
+
+Much of what co-creation does is visual and asynchronous — the AI streams a reply, then an
+overlay applies a change to the canvas. A screen-reader user needs those moments narrated.
+
+- **Applying a change** — `F0AiProcessingOverlay` renders with `role="status"` and
+ `aria-live="polite"`, so its "Applying changes" label is announced when the overlay appears
+ and the canvas is busy.
+- **Streaming replies** — announce that the assistant is responding, and that it has
+ finished, via a polite live region. Announce the arrival of a proposal or a new resource
+ card so the decision isn't missed.
+
+
+
+## Motion
+
+The chat animating in and the processing overlay are both motion. Honor
+`prefers-reduced-motion`: keep the state changes (so the user still knows what happened) but
+drop or shorten the animation. `F0AiProcessingOverlay` already respects the reduced-motion
+preference.
+
+
+
+For broader, component-level accessibility expectations, see the foundations docs; this page
+covers only what is specific to co-creation.
diff --git a/packages/react/src/patterns/Cocreation/__stories__/adoption.mdx b/packages/react/src/patterns/Cocreation/__stories__/adoption.mdx
new file mode 100644
index 0000000000..82fda2e3e2
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/adoption.mdx
@@ -0,0 +1,150 @@
+import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
+import { DoDonts } from "@/lib/storybook-utils/do-donts"
+
+
+
+# Adoption
+
+How to apply co-creation to a **new** resource. Co-creation is not a component you import —
+it is an orchestration of existing AI blocks around a small state machine. Surveys is the
+first instance; a job description, a policy draft, or a goal plan follows the same recipe.
+This page is the recipe: the state to own, the blocks to wire, the contracts to honor, and
+the few things that are actually specific to your resource.
+
+## 1. Own a single `phase`
+
+A co-creation flow runs on one chat-enabled
+[`ApplicationFrame`](?path=/docs/patterns-applicationframe--documentation) and is driven by a
+single `phase` value — `"collection" | "chat" | "split"`. Everything else (whether the chat
+is open, and whether it shows full width or as a side panel) is **derived** from `phase`,
+never set independently, so the header trigger and the in-flow buttons can't disagree. See
+[Phases](?path=/docs/patterns-co-creation-phases--documentation) for what each phase is and
+how the transitions fire.
+
+
+
+## 2. Wire the blocks (don't rebuild them)
+
+Reach for the existing AI blocks for every conversational part of the flow. You are
+assembling, not authoring — when a block improves, your flow inherits it. Each is documented
+on its own page; the catalog and responsibilities live in
+[Building blocks](?path=/docs/patterns-co-creation-building-blocks--documentation).
+
+
+
+
+
+
Responsibility
+
Block
+
+
+
+
+
Open the AI from any phase
+
+ F0OneSwitch in the frame header
+
+
+
+
The conversation surface, composer, and welcome screen
+
+ F0AiChat (plus its welcomeScreenCards /{" "}
+ welcomeScreenSuggestions)
+
+
+
+
Narrow a vague intent before drafting
+
+ F0ClarifyingPanel
+
+
+
+
The in-chat resource card and the accept/reject proposal card
+
+ F0CardHorizontal
+
+
+
+
"Applying changes" over the canvas
+
+ F0AiProcessingOverlay
+
+
+
+
+
+
+## 3. Honor the contracts
+
+These are the invariants that make every co-creation flow feel like the same pattern. They
+are defined in full on the
+[Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page:
+
+- **Canvas in chat** — the resource is built and edited in a canvas that opens inside the
+ chat, never on a separate page you route the user to.
+- **Approve every change** — no AI proposal reaches the resource without an explicit accept;
+ surface each one as a confirm/reject card.
+- **Leaving warns** — navigation that would abandon an in-progress creation confirms first;
+ merely collapsing the chat does not.
+- **Card lifecycle** — keep only the latest card for a resource interactive (active / live),
+ and leave superseded cards in the chat as history.
+
+Co-creation is also held to the pattern's
+[Accessibility](?path=/docs/patterns-co-creation-accessibility--documentation) contract —
+keyboard, focus, announcements, and reduced motion.
+
+## 4. Bring only your resource surfaces
+
+The conversational machinery is shared; what's specific to your resource is small:
+
+- **The canvas surfaces** — a read-only **preview** and an editable **builder/editor** for
+ your resource (Surveys supplies `SurveyAnsweringForm` and `SurveyFormBuilder`).
+- **Draft + apply logic** — how an intent becomes a first draft, and how an accepted proposal
+ is written into the resource.
+- **Save model** — when the resource is persisted. A resource with a **Draft** state is created
+ on first creation (it exists in its domain from then on; later changes are saved explicitly);
+ a resource **without** a draft state creates nothing until the user explicitly saves. This
+ drives the "leaving warns" contract — a draft survives navigation, unsaved non-draft work is
+ lost.
+- **Clarifying questions** — the handful that materially improve _your_ first draft (Surveys
+ asks type → audience → length).
+- **Welcome cards** — the starting points worth offering on the welcome screen (blank, browse
+ templates, seed a known template).
+
+
+
+## Checklist
+
+When standing up a new co-creation flow:
+
+1. Mount it on one chat-enabled `ApplicationFrame` with `F0OneSwitch` in the header.
+2. Model a single `phase`; derive the chat's open / visualization state from it.
+3. Define the welcome cards and the clarifying questions for your resource.
+4. Build the canvas preview and editor surfaces; open them **inside** the chat.
+5. Post each AI change as a confirm/reject `F0CardHorizontal`; apply only on accept, behind
+ `F0AiProcessingOverlay`.
+6. Manage the resource/proposal card lifecycle (active, live, superseded, resolved).
+7. Warn before navigation that abandons an in-progress creation.
+8. Verify the [Accessibility](?path=/docs/patterns-co-creation-accessibility--documentation)
+ contract — keyboard, focus, announcements, reduced motion.
+
+For the first end-to-end instance to read alongside this checklist, see
+[Creation with AI](?path=/docs/patterns-co-creation-creation-with-ai--documentation).
diff --git a/packages/react/src/patterns/Cocreation/__stories__/assets/chat-fullscreen.png b/packages/react/src/patterns/Cocreation/__stories__/assets/chat-fullscreen.png
new file mode 100644
index 0000000000..c21160ccd5
Binary files /dev/null and b/packages/react/src/patterns/Cocreation/__stories__/assets/chat-fullscreen.png differ
diff --git a/packages/react/src/patterns/Cocreation/__stories__/assets/split-empty.png b/packages/react/src/patterns/Cocreation/__stories__/assets/split-empty.png
new file mode 100644
index 0000000000..87e65d465b
Binary files /dev/null and b/packages/react/src/patterns/Cocreation/__stories__/assets/split-empty.png differ
diff --git a/packages/react/src/patterns/Cocreation/__stories__/building-blocks.mdx b/packages/react/src/patterns/Cocreation/__stories__/building-blocks.mdx
new file mode 100644
index 0000000000..8a064563fd
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/building-blocks.mdx
@@ -0,0 +1,154 @@
+import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
+
+
+
+# Building blocks
+
+Co-creation is not a single component — it is an **orchestration of existing SDS AI
+components**. Each block owns one responsibility in the flow; the pattern wires them together
+so every co-creation experience behaves consistently. Use this page to find the right block
+and its dedicated documentation.
+
+## Catalog
+
+
+
+
+
+
Block
+
Role in co-creation
+
Phase
+
Docs
+
+
+
+
+
+ F0OneSwitch
+
+
+ The One switch in the application header. Entry point that opens the
+ AI from any phase.
+
+ The single card primitive used for both the in-chat resource card
+ (opening it moves the flow into Split and shows the resource in the
+ canvas) and the human-in-the-loop proposal card (a proposed change
+ surfaced as an explicit accept / reject before it is applied). Its
+ active, inactive, and confirm/reject variants drive the card
+ lifecycle.
+
+
+
+## Behavior
+
+- The blocks are **independent and documented on their own pages** — co-creation does not
+ re-implement chat, clarifying questions, or confirmations. When a block changes, the
+ pattern inherits the change.
+- The welcome screen has two independent, optional entry points, configured separately on
+ `F0AiChat`. They differ by **what a click does — a card fires a host action, a suggestion
+ sends a prompt**:
+ - **Welcome cards** (`welcomeScreenCards`) — a grid of cards **below** the composer
+ (full width only). Each card carries its own `onClick`, and the **host decides
+ the behavior** — open a canvas, seed a template, or send the card's optional `message`.
+ Capped at **four** (a 2×2 grid); extras are dropped.
+ ([With Welcome Cards](?path=/story/sds-ai-f0aichattextarea--with-welcome-cards))
+ - **Welcome suggestions** (`welcomeScreenSuggestions`) — grouped outline buttons **above** the
+ composer, on both the side-panel and full-width welcome screens. Each group (icon + label)
+ opens a popover of starter prompts, and clicking one **sends its `prompt` straight to the
+ AI**. **No cap on the number of groups** yet; each group's popover shows up to five items.
+ ([With Welcome Suggestions](?path=/story/sds-ai-f0aichattextarea--with-welcome-suggestions))
+
+ Provide either, both, or neither — their counts are unrelated.
+
+- Human-in-the-loop confirmation — the `F0CardHorizontal` confirm/reject card — is the contract
+ that keeps the user in control: no AI-proposed change reaches the resource without an explicit
+ accept.
+- The resource and proposal cards change state as the AI iterates — active, live, superseded,
+ pending, resolved. The
+ [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page documents
+ this card lifecycle in full.
+- `F0AiProcessingOverlay` announces itself politely while it works — it renders with
+ `role="status"` and `aria-live="polite"`, so assistive tech hears "Applying changes" without
+ the user losing their place. See
+ [Accessibility](?path=/docs/patterns-co-creation-accessibility--documentation).
diff --git a/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.mdx b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.mdx
new file mode 100644
index 0000000000..37e4a22a17
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.mdx
@@ -0,0 +1,174 @@
+import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
+
+import chatFullscreen from "./assets/chat-fullscreen.png"
+import splitEmpty from "./assets/split-empty.png"
+
+
+
+# Creation with AI
+
+**Creation with AI** is the first concrete instance of the
+[Co-creation](?path=/docs/patterns-co-creation--documentation) pattern — the abstract flow
+grounded in one real resource. The resource here is a survey, but the structure is the
+pattern's, not the survey's: the user starts on a collection (Collection), describes what they
+want in a chat (Chat), then refines the resulting draft on a canvas while the conversation
+continues (Split). The walkthrough below maps each phase to what the survey example shows; the
+mock itself runs full screen, so open it in its own canvas:
+
+
+
+ Open the interactive flow ↗
+
+
+
+## Flow
+
+### Collection
+
+The flow starts on the collection the user is already working in — a Surveys data collection in
+this example. The user opens the AI from the header One switch (side panel) or the **Create**
+primary button (full width).
+
+### Chat
+
+
+
+
+
+ The chat opens full width, with an action-driven header. The user types
+ what they want (or dictates it with the mic), or picks a starting-point
+ card.
+
+
+
+
+The chat opens with welcome cards offering a few starting points. The cards are
+data-driven (`welcomeScreenCards`) and rendered below the composer on the full-width welcome
+screen; they're optional, and you can show up to four. Which starting points to offer is up to
+each flow — the survey example fills all four:
+
+
+
+
+
+
Starting point
+
What it does
+
+
+
+
+
+ Empty survey
+
+
+ Creates a blank survey and walks the user through it with clarifying
+ questions.
+
+
+
+
+ Templates
+
+
+ Opens the templates canvas to browse and preview pre-made surveys.
+
+
+
+
+ Employee NPS
+
+
+ Seeds a ready-made eNPS survey; the eNPS question is locked so scores
+ stay comparable, the rest is editable.
+
+
+
+
+ Employee Engagement
+
+
Seeds a ready-made employee-engagement survey to edit.
+
+
+
+
+
+Clarifying questions narrow the intent before anything is drafted — here, the survey's type,
+audience, and length. The draft is then surfaced as an in-chat resource card; opening it moves
+the flow into Split.
+
+### Split
+
+
+
+
+
+ After choosing Empty survey: the resource canvas fills the center, the
+ chat docks on the right with the resource card and clarifying questions.
+
+
+
+
+The resource canvas fills the center while the chat docks on the right. Every co-created
+resource needs a way to read the draft and a way to edit it; here the survey canvas offers those
+as two modes:
+
+
+
+
+
+
Mode
+
What the user sees
+
+
+
+
+
+ Preview
+
+
+ The survey rendered as it will be answered (the answering form), for
+ reviewing a template or draft.
+
+
+
+
+ Edit
+
+
+ The survey builder, with a Questions tab for the survey content and a
+ Settings tab for metadata (title, description, participants, schedule,
+ visibility, editors).
+
+
+
+
+
+
+The canvas header carries the resource actions — here **Save** and a primary **Publish**
+— so the user can keep the draft or ship it without leaving the flow.
+
+As the conversation continues, the AI proposes changes (for example, adding an open-ended
+comment question). Each proposal appears as a human-in-the-loop confirmation card; the user
+accepts or rejects it, and an overlay covers the canvas while an accepted change is applied.
+
+## Composed building blocks
+
+This flow assembles the blocks documented in
+[Building blocks](?path=/docs/patterns-co-creation-building-blocks--documentation):
+`F0OneSwitch`, `F0AiChat` (including its data-driven welcome cards and voice dictation),
+`F0ClarifyingPanel`, `F0CardHorizontal` (the in-chat resource and proposal cards), and
+`F0AiProcessingOverlay`. Only the surfaces specific to this example — the survey's answering
+form and builder — come from the
+[Surveys](?path=/docs/sds-surveys-surveyformbuilder--documentation) SDS.
diff --git a/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.stories.tsx b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.stories.tsx
new file mode 100644
index 0000000000..6ab8580622
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/creation-with-ai.stories.tsx
@@ -0,0 +1,2100 @@
+import type { Meta, StoryObj } from "@storybook/react-vite"
+
+import { z } from "zod"
+
+import {
+ ComponentProps,
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from "react"
+import { StandardLayout } from "@/layouts/StandardLayout"
+import { PageHeader } from "@/experimental/Navigation/Header/PageHeader"
+import { F0CardHorizontal } from "@/experimental/F0CardHorizontal"
+import {
+ Add,
+ ArrowLeft,
+ Check,
+ Cross,
+ Delete,
+ ExternalLink,
+ File,
+ Files,
+ LayersFront,
+ Marketplace,
+ Pencil,
+ Settings,
+ SolidPlay,
+} from "@/icons/app"
+import { F0Alert } from "@/components/F0Alert"
+import { ButtonInternal } from "@/components/F0Button/internal"
+import { F0Heading } from "@/components/F0Heading"
+import { dialogs } from "@/lib/providers/dialogs-alike"
+import { ButtonGroup } from "@/ui/ButtonGroup"
+import { ApplicationFrame } from "@/patterns/ApplicationFrame"
+import { Page as NavigationPage } from "@/patterns/Navigation/Page"
+import { Tabs } from "@/patterns/Navigation/Tabs"
+import { Sidebar } from "@/patterns/Navigation/Sidebar/Sidebar"
+import * as SidebarStories from "@/patterns/Navigation/Sidebar/index.stories"
+import { OneDataCollection } from "@/patterns/OneDataCollection"
+import { useDataCollectionSource } from "@/patterns/OneDataCollection/hooks/useDataCollectionSource"
+import { ResourceHeader } from "@/patterns/ResourceHeader"
+import { useAiChat } from "@/sds/ai/F0AiChat"
+import type { ClarifyingOption } from "@/sds/ai/F0ClarifyingPanel"
+import {
+ type CanvasContent,
+ type CanvasContentBase,
+ type CanvasEntityDefinition,
+} from "@/sds/ai/canvas"
+import { F0AiProcessingOverlay } from "@/sds/ai/F0AiProcessingOverlay"
+import {
+ type ClarifyingStep,
+ MockAiChatRuntimeProvider,
+ MockConnectedChatHeader,
+ MockConnectedChatInput,
+ MockConnectedMessagesContainer,
+ useMockAiChatRuntime,
+} from "@/sds/ai/F0AiChat/__stories__/_mock"
+
+import { f0FormField, F0Form } from "@/patterns/F0Form"
+import type { F0SectionConfig } from "@/patterns/F0Form"
+import { useF0FormDefinition } from "@/patterns/F0WizardForm"
+import { SurveyAnsweringForm } from "@/sds/surveys/SurveyAnsweringForm"
+import { SurveyFormBuilder } from "@/sds/surveys/SurveyFormBuilder/Form"
+import type { SurveyFormBuilderElement } from "@/sds/surveys/SurveyFormBuilder/types"
+import { mockDatasets } from "@/sds/surveys/__stories__/mocks"
+
+import {
+ cardVisualization,
+ filledDataAdapter,
+ resourceFilters,
+ resourceSortings,
+ tableVisualization,
+ templateFilters,
+ templatesDataAdapter,
+ templateSortings,
+} from "./mockData"
+import type { Template } from "./mockData"
+// WIP: temporary toast mock — replace with "@/hooks/toast" once
+// https://github.com/factorialco/f0/pull/3493 merges, then remove this import.
+import { toasts } from "@/hooks/toast"
+import { useI18n } from "@/lib/providers/i18n"
+import {
+ makeInitialSurveyElements,
+ mockSurveyTranscribe,
+ NPS_SURVEY_ELEMENTS,
+ SURVEY_DEFAULT_VALUES,
+ SURVEY_ELEMENTS,
+} from "./survey-mocks"
+import { TAB_CONFIGS } from "./tab-configs"
+import type { TabConfig } from "./tab-configs"
+import type { F0AiChatWelcomeCard } from "@/sds/ai/F0AiChat"
+
+/**
+ * Co-creation patterns — "Creation with AI".
+ *
+ * Interactive mockup of the AI-creation flow, built on a single chat-enabled
+ * ApplicationFrame so the "One" switch (the F0AiChat trigger) is always in the
+ * header. There are three phases:
+ * 1. Collection — data collection. Open the chat via the header "One" switch
+ * (side panel) OR the "Create" primary button (full width).
+ * 2. Chat — F0AiChat animates in so the user can describe what they
+ * want (full width via the "Create" button, side panel via the One switch).
+ * 3. Split — the chat docks as the right side panel and the resource
+ * (a document/preview canvas) fills the center.
+ *
+ * `phase` is the single source of truth; the chat's open/visualization state is
+ * derived from it (and kept in sync when the user toggles the One switch).
+ * Self-contained: this file owns its own mock world.
+ *
+ * The scripted conversation choreography — canned replies, the clarifying-question
+ * flow, the chat → split transition, and the appended resource/proposal cards — is
+ * authored below, one block per entry point (Empty survey, typed "Create", and the
+ * template cards).
+ */
+const meta = {
+ title: "Co-creation/Creation with AI",
+ // Manual MDX docs live in creation-with-ai.mdx; opt out of the globally
+ // enabled autodocs so the section shows a single Documentation page.
+ tags: ["!autodocs"],
+ parameters: {
+ layout: "fullscreen",
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+type Phase = "collection" | "chat" | "split"
+
+const COCREATION_MODULE = {
+ id: "ats" as const,
+ name: "Co-creation",
+ href: "/cocreation",
+}
+
+// `AiChatStateProvider` persists the chat's open/visualization-mode state to
+// localStorage. We reset those keys once on mount so the chat always starts
+// CLOSED in the collection view, regardless of a previous session.
+const AI_CHAT_STORAGE_KEYS = [
+ "ONE-ai-chat-open",
+ "ONE-ai-chat-visualization-mode",
+]
+const resetAiChatPersistence = () => {
+ try {
+ AI_CHAT_STORAGE_KEYS.forEach((key) => window.localStorage.removeItem(key))
+ } catch {
+ // localStorage may be unavailable (SSR / privacy mode) — ignore.
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tab-config context — exposes the live active tab (and its config) to
+// `FlowContent`, which reads it to drive the collection tab strip, the chat's
+// opening intent message, and the resource canvas shown in the split view.
+// ---------------------------------------------------------------------------
+
+type TabConfigContextValue = {
+ activeTabId: string
+ setActiveTabId: (id: string) => void
+ tabConfig: TabConfig
+}
+
+const TabConfigContext = createContext(null)
+
+function useTabConfig(): TabConfigContextValue {
+ const ctx = useContext(TabConfigContext)
+ if (!ctx) {
+ throw new Error("useTabConfig must be used inside ")
+ }
+ return ctx
+}
+
+function TabConfigProvider({
+ initialTabId = "surveys",
+ children,
+}: {
+ initialTabId?: string
+ children: ReactNode
+}) {
+ const [activeTabId, setActiveTabId] = useState(initialTabId)
+ const tabConfig = TAB_CONFIGS[activeTabId] ?? TAB_CONFIGS.surveys
+
+ return (
+
+ {children}
+
+ )
+}
+
+function SurveySettingsForm() {
+ const formSchema = z.object({
+ title: f0FormField.text({
+ label: "Title",
+ section: "basic",
+ placeholder: "Enter survey title",
+ }),
+ description: f0FormField.textarea({
+ label: "Description",
+ section: "basic",
+ optional: true,
+ rows: 3,
+ }),
+ participants: f0FormField.select({
+ label: "Select participants",
+ section: "participants",
+ options: [
+ { value: "all", label: "All employees" },
+ { value: "department", label: "By department" },
+ { value: "custom", label: "Custom selection" },
+ ],
+ placeholder: "Select participants",
+ }),
+ publishOn: f0FormField.date({
+ label: "Publish on",
+ section: "schedule",
+ row: "schedule-dates",
+ optional: true,
+ }),
+ endsAt: f0FormField.date({
+ label: "Ends at",
+ section: "schedule",
+ row: "schedule-dates",
+ optional: true,
+ }),
+ recurrence: f0FormField.select({
+ label: "Recurrence",
+ section: "schedule",
+ options: [
+ { value: "none", label: "Does not repeat" },
+ { value: "weekly", label: "Weekly" },
+ { value: "monthly", label: "Monthly" },
+ { value: "quarterly", label: "Quarterly" },
+ ],
+ }),
+ managerVisibility: f0FormField.boolean({
+ label: "Add visibility permissions to managers and team leads",
+ helpText:
+ "Grant access to managers and team leads so they can view results for their own teams once responses are available.",
+ section: "visibility",
+ optional: true,
+ }),
+ anonymousAnswers: f0FormField.boolean({
+ label: "Anonymous answers",
+ section: "visibility",
+ optional: true,
+ }),
+ editors: f0FormField.select({
+ label: "Select editors",
+ section: "editors",
+ options: [
+ { value: "none", label: "None" },
+ { value: "admins", label: "Administrators only" },
+ { value: "custom", label: "Custom selection" },
+ ],
+ placeholder: "Select editors",
+ }),
+ })
+
+ const sections: Record = {
+ basic: { title: "Basic Information" },
+ participants: {
+ title: "Participants",
+ description: "Choose who will receive this survey",
+ action: { label: "Manage groups", icon: ExternalLink, href: "#groups" },
+ },
+ schedule: { title: "Schedule" },
+ visibility: {
+ title: "Visibility & Privacy",
+ description:
+ "Configure the visibility and privacy settings for this survey",
+ action: {
+ label: "Privacy settings",
+ icon: Settings,
+ onClick: () => {},
+ },
+ },
+ editors: { title: "Editors" },
+ }
+
+ const formDefinition = useF0FormDefinition({
+ name: "survey-settings",
+ schema: formSchema,
+ sections,
+ defaultValues: {
+ title: "Employee engagement survey",
+ description:
+ "A 10-question pulse check covering motivation, clarity, and team dynamics.",
+ participants: undefined,
+ publishOn: undefined,
+ endsAt: undefined,
+ recurrence: "none",
+ managerVisibility: false,
+ anonymousAnswers: true,
+ editors: "admins",
+ },
+ onSubmit: async () => ({ success: true }),
+ // The header Save persists the whole resource (questions + settings), so the
+ // settings form carries no save affordance of its own.
+ submitConfig: { hideActionBar: true, hideSubmitButton: true },
+ })
+
+ return (
+
+ )
+}
+
+/**
+ * The survey-templates browse view as a card-view data collection. Shared by
+ * BOTH the collection "Templates" tab and the AI Canvas (see
+ * `templatesCanvasEntity`) so the cards + their metadata stay identical in both
+ * places as the data collection / card config evolves.
+ */
+function TemplatesCollection({
+ onSelect,
+}: {
+ onSelect?: (item: Template) => void
+} = {}) {
+ const source = useDataCollectionSource({
+ dataAdapter: templatesDataAdapter,
+ filters: templateFilters,
+ sortings: templateSortings,
+ search: { enabled: true, sync: true },
+ // No card CTAs — actions live on the preview surface. The card-body click
+ // is omitted entirely when no handler is supplied so the browse-tab cards
+ // stay inert (no pointer, no action); in the AI Canvas it opens the preview.
+ itemOnClick: onSelect ? (item) => () => onSelect(item) : undefined,
+ })
+ return (
+
+ )
+}
+
+// Canvas content + entity for the survey templates. `CanvasContent` is a closed
+// SDS union (dashboard | form | dataDownload); "templates" is story-specific, so
+// we keep a local content type and cast at the `openCanvas` call site — the
+// canvas registry/panel match on the `type` string at runtime.
+type TemplatesCanvasContent = CanvasContentBase & { type: "templates" }
+const TEMPLATES_CANVAS_CONTENT: TemplatesCanvasContent = {
+ type: "templates",
+ title: "Templates",
+}
+
+const templatesCanvasEntity: CanvasEntityDefinition = {
+ type: "templates",
+ // Standard canvas header: title (from content) on the left, close on the
+ // right — mirrors the design system's panel-header structure (no
+ // ResourceHeader chrome). Rendered as a component so closing can return to the
+ // first step of cocreation rather than just dismissing the canvas (see
+ // `TemplatesCanvasHeader`); the framework `onClose` is intentionally dropped.
+ renderHeader: ({ content }) => ,
+ renderContent: () => (
+
+
+
+ ),
+ overflowHidden: true,
+}
+
+// Story-local survey canvas. Like `templatesCanvasEntity`, the "survey" type is
+// story-specific (not part of the closed SDS `CanvasContent` union), so we keep
+// a local content type and cast at the `openCanvas` call site. `mode` selects
+// the surface: "preview" is the read-only `SurveyAnsweringForm` (template
+// browsing), while "edit" is the editable resource view — a `ResourceHeader` +
+// Questions/Settings tabs over the interactive `SurveyFormBuilder` (see
+// `SurveyEditorCanvasHeader` / `SurveyEditorCanvasBody`).
+type SurveyCanvasContent = CanvasContentBase & {
+ type: "survey"
+ mode: "edit" | "preview"
+ templateName: string
+ /**
+ * Identifies which survey in the multi-survey store this canvas renders. The
+ * editable "edit" mode always carries one (minted by `createSurvey`); the
+ * read-only template "preview" omits it (it renders static sample questions).
+ */
+ surveyId?: string
+ /** Carried from the template card into the edit view's resource header. */
+ description?: string
+ /** Blank "start from scratch" survey — the editable builder, seeded empty. */
+ empty?: boolean
+}
+
+// Bridge the story-local canvas types to the SDS API. `CanvasContent` /
+// `CanvasEntityDefinition` are a *closed* SDS union that doesn't include the
+// custom "survey"/"templates" entities this story demos, so we widen at the
+// provider boundary (the registry/panel match on the `type` string at runtime).
+// Centralized here so the unavoidable cast — and the reason for it — live in one
+// place instead of being repeated at every call site.
+type StoryCanvasContent = SurveyCanvasContent | TemplatesCanvasContent
+
+const toCanvasContent = (content: StoryCanvasContent): CanvasContent =>
+ content as unknown as CanvasContent
+
+const toCanvasEntity = (
+ entity:
+ | CanvasEntityDefinition
+ | CanvasEntityDefinition
+): CanvasEntityDefinition => entity as unknown as CanvasEntityDefinition
+
+// Inverse of `toCanvasContent`: read the story content back out of the SDS union.
+const asSurveyCanvasContent = (
+ content: CanvasContent | null
+): SurveyCanvasContent | null =>
+ content as unknown as SurveyCanvasContent | null
+
+// Default name for a blank "start from scratch" survey, shown until the guided
+// flow resolves a real name.
+const UNTITLED_SURVEY_NAME = "Untitled survey"
+
+// Name of the survey seeded by the "Employee NPS" welcome card.
+const EMPLOYEE_NPS_SURVEY_NAME = "Employee NPS"
+
+// Build the canvas content for a blank survey opened by the "Empty survey"
+// welcome card (or the typed "Create" flow). Reuses the survey canvas entity
+// (mode "edit") but with no sample questions: it mirrors a real Survey resource
+// view — a `ResourceHeader` + Questions/Settings tab strip — with the
+// interactive `SurveyFormBuilder` (seeded empty) living under the "Questions"
+// tab (see `SurveyEditorCanvasHeader` / `SurveyEditorCanvasBody`). The
+// `surveyId` ties it to its entry in the multi-survey store.
+const makeEmptySurveyContent = (
+ surveyId: string,
+ name: string = UNTITLED_SURVEY_NAME
+): SurveyCanvasContent => ({
+ type: "survey",
+ title: name,
+ mode: "edit",
+ templateName: name,
+ empty: true,
+ surveyId,
+})
+
+// In-chat survey card, built on `F0CardHorizontal`. Its Open/Close button drives
+// the single shared canvas. The card derives `isActive` by matching the open
+// `canvasContent` to its own `surveyId`, so many survey cards coexist in one chat
+// and only one survey shows at a time. A survey can post a series of cards as it
+// evolves (created → updated) — only the latest (`live`) card stays interactive;
+// superseded cards render faded + inert (no Open/Close), keeping the chat history
+// visible. Posted via the mock runtime's `appendCard` (generativeUI slot), which
+// re-invokes the render each pass so `isActive` / `live` stay reactive.
+function SurveyCard({
+ surveyId,
+ cardId,
+ title,
+ description,
+}: {
+ surveyId: string
+ cardId: string
+ title: string
+ description: string
+}) {
+ const { canvasContent, openCanvas, setVisualizationMode } = useAiChat()
+ const { isLiveCard } = useSurveyStore()
+ const live = isLiveCard(surveyId, cardId)
+ // `canvasContent` is the closed SDS union (dashboard | form | dataDownload);
+ // the story-local "survey" type is matched at runtime, so widen to read it.
+ const surveyContent = asSurveyCanvasContent(canvasContent)
+ const isActive =
+ surveyContent?.type === "survey" && surveyContent.surveyId === surveyId
+
+ const handleOpen = () =>
+ openCanvas(
+ toCanvasContent({
+ type: "survey",
+ title,
+ mode: "edit",
+ templateName: title,
+ surveyId,
+ description,
+ })
+ )
+
+ // Closing from the card collapses the canvas but keeps the chat docked as a
+ // side panel (split). The mode before the canvas opened is always either
+ // "sidepanel" or "fullscreen"; we preserve the side-panel case and, when it was
+ // fullscreen, deliberately fall to side panel rather than back to fullscreen —
+ // both resolve to "sidepanel". Switching modes here also clears the canvas
+ // content (the provider drops it on any canvas → non-canvas transition), so we
+ // don't need the generic `closeCanvas` (which would restore fullscreen).
+ const handleClose = () => setVisualizationMode("sidepanel")
+
+ // Superseded cards (no longer live) are inert: faded, non-interactive, no
+ // Open/Close button. The live card shows the toggle — "Close" while its survey
+ // is open in the canvas, "Open" otherwise.
+ return (
+
+
+
+ )
+}
+
+// Subtitle shown on a survey's "created" card (blank or from a template).
+const SURVEY_CREATED_DESCRIPTION = "Created in Engagement / Surveys"
+// Subtitle shown on the "updated" card posted once the AI drafts the questions.
+const SURVEY_UPDATED_DESCRIPTION = "Survey updated with your choices."
+
+// Toast titles reused across the survey resource-header actions and autosave.
+const SURVEY_SAVED_TOAST = "Survey saved in Engagement / Surveys"
+const SURVEY_PUBLISHED_TOAST = "Survey published"
+const SURVEY_DUPLICATED_TOAST = "Survey duplicated"
+const SURVEY_DELETED_TOAST = "Survey deleted"
+
+// The single open-ended question the AI "proposes" adding once a template has
+// been applied and the user describes a change. Appended to the survey (kept
+// alongside the template's questions) when the user accepts the proposal.
+const PROPOSED_QUESTION_ELEMENT = {
+ type: "question",
+ question: {
+ id: "q-proposed-comment",
+ title: "What would most improve your experience?",
+ type: "longText" as const,
+ },
+} satisfies SurveyFormBuilderElement
+
+// Label + subtitle shared by the proposal card across its pending/resolved
+// states, so the resolved card reads as the same proposal, now decided.
+const PROPOSED_CHANGE_TEXT = "Add an open-ended comment question"
+const PROPOSED_CHANGE_DESCRIPTION =
+ "A new question at the end: “What would most improve your experience?”"
+
+/**
+ * Human-in-the-loop proposal card posted whenever the user describes a change
+ * to an existing survey. Built on `F0CardHorizontal`'s confirm/reject variant:
+ * it owns its own pending → resolved state. Accepting
+ * runs the "applying changes" overlay, appends the proposed question, and posts
+ * a fresh live `SurveyCard` — which supersedes the prior card for the survey
+ * (its Open/Close button drops and it fades to opacity-50). Either way the card
+ * itself flips to its resolved `status` (the ✓/✗ buttons give way to an outcome
+ * icon) and the flow is re-armed, so a further typed message proposes again.
+ * Rendered via `appendCard` (generativeUI slot) inside the provider tree, so it
+ * can read the chat runtime + survey store via hooks.
+ */
+function ProposalConfirmationCard({
+ surveyId,
+ surveyTitle,
+}: {
+ surveyId: string
+ surveyTitle: string
+}) {
+ const [resolution, setResolution] = useState<
+ "pending" | "accepted" | "rejected"
+ >("pending")
+ const { appendMessages, appendCard } = useMockAiChatRuntime()
+ const { applyChange, getSurvey, nextCardId, registerLiveCard } =
+ useSurveyStore()
+ // Re-arm after the user decides, so the NEXT typed message proposes again —
+ // the propose → confirm → update loop repeats for as long as they keep typing.
+ const { armProposal } = useProposalFlow()
+
+ // No avatar on the update card — the proposed change isn't attributed to the
+ // Engagement module (unlike the resource cards), so the row leads with its text.
+ if (resolution === "accepted") {
+ return (
+
+ )
+ }
+ if (resolution === "rejected") {
+ return (
+
+ )
+ }
+
+ return (
+ {
+ setResolution("accepted")
+ // Mint a unique question id so repeated accepts never collide.
+ const proposed: SurveyFormBuilderElement = {
+ type: "question",
+ question: {
+ ...PROPOSED_QUESTION_ELEMENT.question,
+ id: `q-proposed-${nextCardId()}`,
+ },
+ }
+ applyChange(
+ surveyId,
+ (els) => [...els, proposed],
+ () => {
+ // A fresh live card for the SAME survey: the prior card linking to
+ // it drops its Open/Close button and fades to opacity-50.
+ const cardId = nextCardId()
+ registerLiveCard(surveyId, cardId)
+ appendCard(() => (
+
+ ))
+ appendMessages([
+ {
+ role: "assistant",
+ content: "Done — I've added the question to your survey.",
+ },
+ ])
+ armProposal(surveyId, surveyTitle)
+ }
+ )
+ },
+ }}
+ rejectAction={{
+ label: "Discard",
+ onClick: () => {
+ setResolution("rejected")
+ appendMessages([
+ {
+ role: "assistant",
+ content: "No problem — I've left the survey unchanged.",
+ },
+ ])
+ armProposal(surveyId, surveyTitle)
+ },
+ }}
+ />
+ )
+}
+
+/**
+ * Arms the next typed message to propose a change to an existing survey: the
+ * assistant posts a proposal line and a human-in-the-loop
+ * `ProposalConfirmationCard`. Shared by every entry point (Empty survey, typed
+ * "Create", and Templates) once the survey has been created, and re-armed by the
+ * card itself after each decision — so further typing keeps re-triggering the
+ * propose → confirm → update loop, each accepted change superseding the previous
+ * card pointing to the survey.
+ */
+function useProposalFlow() {
+ const { appendMessages, appendCard, setUserMessageInterceptor } =
+ useMockAiChatRuntime()
+ const armProposal = useCallback(
+ (surveyId: string, surveyTitle: string) => {
+ setUserMessageInterceptor(() => {
+ appendMessages([
+ {
+ role: "assistant",
+ content:
+ "Good idea. Here's a change I'd suggest — review it below.",
+ },
+ ])
+ appendCard(() => (
+
+ ))
+ })
+ },
+ [appendMessages, appendCard, setUserMessageInterceptor]
+ )
+ return { armProposal }
+}
+
+// Survey-type options offered as a clarifying question right after a blank
+// survey is created (mirrors the examples the assistant used to list inline).
+const SURVEY_TYPE_OPTIONS: ClarifyingOption[] = [
+ { id: "engagement", label: "Employee engagement" },
+ { id: "onboarding", label: "Onboarding feedback" },
+ { id: "pulse", label: "Customer pulse check" },
+ { id: "enps", label: "eNPS" },
+]
+
+// Follow-up clarifying questions walked after the survey type (audience, then
+// length) before the AI "drafts" the questions onto the canvas.
+const SURVEY_AUDIENCE_OPTIONS: ClarifyingOption[] = [
+ { id: "all", label: "All employees" },
+ { id: "department", label: "A specific department" },
+ { id: "custom", label: "A custom group" },
+]
+
+const SURVEY_LENGTH_OPTIONS: ClarifyingOption[] = [
+ { id: "short", label: "Short (3–5 questions)" },
+ { id: "standard", label: "Standard (6–10 questions)" },
+ { id: "deep", label: "In-depth (10+ questions)" },
+]
+
+// The three clarifying questions — type → audience → length — walked as a
+// single consecutive flow inside one panel: picking an answer advances to the
+// next question (the header shows a "X of Y" counter and a back arrow) and the
+// final question submits. Shared by both the "Empty survey" card and the typed
+// "Create" flow, which differ only in when the canvas opens.
+const SURVEY_CLARIFYING_STEPS: ClarifyingStep[] = [
+ {
+ question: "What kind of survey are you working on?",
+ options: SURVEY_TYPE_OPTIONS,
+ selectionMode: "single",
+ },
+ {
+ question: "Who should receive this survey?",
+ options: SURVEY_AUDIENCE_OPTIONS,
+ selectionMode: "single",
+ },
+ {
+ question: "How long should the survey be?",
+ options: SURVEY_LENGTH_OPTIONS,
+ selectionMode: "single",
+ },
+]
+
+// Once the consecutive flow resolves, echo the user's picks back into the
+// transcript as a SINGLE user message — each question in bold with the chosen
+// answer on the line beneath it (mirrors the real product, which consolidates
+// the clarifying answers into one reply instead of one bubble per step).
+const surveyAnswerMessages = (
+ answersByStep: string[][]
+): { role: "user"; content: string }[] => {
+ // answersByStep is index-aligned with SURVEY_CLARIFYING_STEPS (buildAnswers in
+ // MockAiChatRuntime maps over every step, in order).
+ const blocks = SURVEY_CLARIFYING_STEPS.map((step, i) => {
+ const answer = (answersByStep[i] ?? []).join(", ").trim()
+ if (!answer) return null
+ // Trailing backslash = CommonMark hard line break, so the answer renders
+ // directly UNDER the bold question (a plain "\n" is only a soft break/space).
+ return `**${step.question}**\\\n${answer}`
+ }).filter((block): block is string => block !== null)
+
+ if (blocks.length === 0) return []
+ // Blank line between pairs → separate
blocks, spaced by the bubble's gap.
+ return [{ role: "user" as const, content: blocks.join("\n\n") }]
+}
+
+// Survey name derived from the chosen type once the guided flow completes — the
+// canvas header switches from "Untitled survey" to this.
+const surveyNameForType = (typeLabel: string): string => `${typeLabel} survey`
+
+// Confirmation dialogs use the imperative Alert/Confirm API from Library/Dialogs
+// (`dialogs.confirmation`, rendered by the `DialogsAlikeLayoutProvider` that `F0Provider`
+// mounts). No open-state context needed — any trigger just calls these and awaits
+// the result (the picked action's value: `true` for confirm, `false` for cancel).
+
+// Shared "leave creation" confirmation — opened by the preview header's "Edit
+// Template" action and the in-canvas alert's "Templates" link.
+const confirmLeaveCreation = () =>
+ dialogs.confirmation({
+ title: "Leave creation?",
+ msg: "Editing this template opens it in Surveys / Templates. Are you sure you want to leave the current creation flow?",
+ confirm: { label: "Go to Edit Template" },
+ cancel: { label: "Cancel" },
+ })
+
+// "Unsaved changes" confirmation shown when "Publish" is pressed — offers to save
+// before publishing. Resolves `true` when the user confirms.
+const confirmPublish = () =>
+ dialogs.confirmation({
+ title: "Unsaved Changes",
+ msg: "You have unsaved changes, would you like to Save the changes before publishing?",
+ confirm: { label: "Save Changes and Publish" },
+ cancel: { label: "Cancel" },
+ })
+
+// "Unsaved changes" confirmation shown when the canvas Close (X) is pressed.
+// Closing leaves the in-progress draft, so the user gets the three ways out:
+// save the changes then close, close without saving, or cancel and stay.
+// Resolves the picked action's value ("save" | "discard" | "cancel"); dismissing
+// the dialog (backdrop / Esc) resolves `undefined`, also treated as "stay".
+const confirmCloseUnsaved = () =>
+ dialogs.notification({
+ type: "warning",
+ title: "Unsaved changes",
+ msg: "You have unsaved changes. Save them to Surveys before closing the canvas?",
+ actions: {
+ primary: { label: "Save and close", value: "save" },
+ secondary: [
+ { label: "Close without saving", value: "discard" },
+ { label: "Cancel", value: "cancel" },
+ ],
+ },
+ })
+
+/**
+ * The non-dismissable info banner shown atop a template preview. The "Templates"
+ * link doesn't navigate (this is a mock); it opens the same leave-creation
+ * confirmation as the header's "Edit Template" action. F0Alert's link is
+ * href-only, so the click is intercepted on capture.
+ */
+function TemplatePreviewAlert() {
+ return (
+
+ )
+}
+
+/**
+ * Header for the template-PREVIEW survey canvas (mode "preview"). Two exits:
+ * "Back to templates" (left) re-opens the templates list, and "Close" (right)
+ * returns to the starting point of the flow — the fullscreen welcome screen —
+ * the same behaviour as the templates-list canvas' close. The framework
+ * `onClose` is intentionally ignored in favour of these. "Use this template"
+ * swaps in the editable resource view (mode "edit"), carrying the template's
+ * name and description so its `ResourceHeader` is populated. Defined as a
+ * component so it can read `openCanvas` / `setVisualizationMode` from context.
+ */
+function SurveyCanvasHeader({ content }: { content: SurveyCanvasContent }) {
+ const { openCanvas, setVisualizationMode } = useAiChat()
+ const { appendCard, appendMessages } = useMockAiChatRuntime()
+ const { createSurvey, nextCardId, registerLiveCard } = useSurveyStore()
+ const { armProposal } = useProposalFlow()
+
+ const useThisTemplate = () => {
+ // A template copy is created already populated, so seed the sample questions
+ // (empty: false). No AI drafting follows, so this single card stays live.
+ const surveyId = createSurvey(content.templateName, { empty: false })
+ openCanvas(
+ toCanvasContent({
+ type: "survey",
+ title: content.templateName,
+ mode: "edit",
+ templateName: content.templateName,
+ surveyId,
+ description: content.description,
+ })
+ )
+ // Acknowledge the survey created from this template — an openable canvas card
+ // (template name + subtitle) followed by a guided follow-up question.
+ const cardId = nextCardId()
+ registerLiveCard(surveyId, cardId)
+ appendCard(() => (
+
+ ))
+ appendMessages([
+ {
+ role: "assistant",
+ content:
+ "I have created the survey using the template. What would you like to customize or change?",
+ },
+ ])
+ // Arm the next typed message: the user describes a change, the assistant
+ // proposes one, and a human-in-the-loop confirmation card lets them accept
+ // (apply + supersede the card) or reject it — then re-arms for the next.
+ armProposal(surveyId, content.templateName)
+ }
+
+ return (
+
+ {/* ButtonGroup owns the responsive collapse: when the row is wide both
+ actions show; when the title grows or the canvas narrows, the
+ secondary "Edit Template" sheds into the "⋯" menu while the primary
+ "Use this template" stays pinned. */}
+ void confirmLeaveCreation(),
+ },
+ ]}
+ />
+ {/* Close returns to the starting point of the flow (fullscreen welcome),
+ mirroring the templates-list canvas' close. */}
+ setVisualizationMode("fullscreen")}
+ />
+
+ )
+}
+
+// How long the canvas stays blurred under the "applying changes" overlay before
+// the drafted questions land.
+const SURVEY_DRAFTING_MS = 2200
+
+/**
+ * One survey's draft state held in the multi-survey store: the form-builder
+ * `elements`, the survey `name`, and a `processing` flag for the AI "applying
+ * changes" overlay.
+ */
+type SurveyEntry = {
+ elements: SurveyFormBuilderElement[]
+ name: string
+ processing: boolean
+}
+
+/**
+ * The multi-survey store, shared across the whole flow. Keyed by `surveyId` so
+ * MANY distinct surveys can coexist in one chat (each in-chat `SurveyCard` opens
+ * its own survey into the single shared canvas). Lives ABOVE the chat + canvas:
+ * the clarifying chain in the chat footer drafts into a survey, the canvas
+ * (rendered separately in the AI panel) shows it, and the chat cards read it —
+ * so no single one of them can own it alone. Also tracks which card is the
+ * `live` (interactive) one per survey, so a superseded card can render disabled.
+ */
+type SurveyStoreValue = {
+ /**
+ * Register + seed a new survey, returning its stable id. Pass `elements` to
+ * seed an explicit question set (e.g. the "Employee NPS" welcome card). Else
+ * `empty` starts blank (the AI drafts questions in later); otherwise it seeds
+ * the sample questions (a "Use this template" copy).
+ */
+ createSurvey: (
+ name: string,
+ opts?: { empty?: boolean; elements?: SurveyFormBuilderElement[] }
+ ) => string
+ /** Read a survey entry (undefined before creation, e.g. a template preview). */
+ getSurvey: (surveyId?: string) => SurveyEntry | undefined
+ /** Replace a survey's questions (builder edits), scoped to one survey. */
+ setElements: (surveyId: string, elements: SurveyFormBuilderElement[]) => void
+ /**
+ * Rename the survey, blur its canvas for a beat, then drop in a full set of
+ * mock questions — the AI "drafting" the survey at the end of the flow.
+ * `onComplete` fires once the questions land (the form is updated).
+ */
+ draftQuestions: (
+ surveyId: string,
+ name: string,
+ onComplete?: () => void
+ ) => void
+ /**
+ * Apply an AI-proposed edit to a survey: blur its canvas for a beat (the
+ * "applying changes" overlay), then transform its questions with `mutate`
+ * (e.g. appending a new question). Unlike `draftQuestions` it keeps the
+ * existing questions, so the change reads as a diff. `onComplete` fires once
+ * the new questions land.
+ */
+ applyChange: (
+ surveyId: string,
+ mutate: (els: SurveyFormBuilderElement[]) => SurveyFormBuilderElement[],
+ onComplete?: () => void
+ ) => void
+ /** Mint a unique id for a freshly-posted chat card. */
+ nextCardId: () => string
+ /** Mark `cardId` as the live (interactive) card for `surveyId`. */
+ registerLiveCard: (surveyId: string, cardId: string) => void
+ /** Whether `cardId` is the current live card for `surveyId`. */
+ isLiveCard: (surveyId: string, cardId: string) => boolean
+ /** True once any survey has had its questions drafted (elements.length > 0). */
+ hasDraftedSurvey: boolean
+}
+
+const SurveyStoreContext = createContext(null)
+
+function useSurveyStore(): SurveyStoreValue {
+ const ctx = useContext(SurveyStoreContext)
+ if (!ctx) {
+ throw new Error("useSurveyStore must be used inside ")
+ }
+ return ctx
+}
+
+function SurveyStoreProvider({ children }: { children: ReactNode }) {
+ const [surveys, setSurveys] = useState>({})
+ const [liveCardBySurvey, setLiveCardBySurvey] = useState<
+ Record
+ >({})
+ // Monotonic counter minting both survey and card ids — unique within a session
+ // (avoids Date.now/random; deterministic for the mock).
+ const idRef = useRef(0)
+ const timersRef = useRef>>({})
+
+ useEffect(
+ () => () => {
+ Object.values(timersRef.current).forEach(clearTimeout)
+ },
+ []
+ )
+
+ const createSurvey = useCallback(
+ (name, opts) => {
+ const surveyId = `survey-${(idRef.current += 1)}`
+ setSurveys((prev) => ({
+ ...prev,
+ [surveyId]: {
+ // A blank "empty" survey is seeded with its first section/question
+ // (the same starting point the form builder would auto-add) so that
+ // state is durable in the store instead of appearing only as a
+ // render-time side effect.
+ elements:
+ opts?.elements ??
+ (opts?.empty ? makeInitialSurveyElements() : SURVEY_ELEMENTS),
+ name,
+ processing: false,
+ },
+ }))
+ return surveyId
+ },
+ []
+ )
+
+ const getSurvey = useCallback(
+ (surveyId) => (surveyId ? surveys[surveyId] : undefined),
+ [surveys]
+ )
+
+ const setElements = useCallback(
+ (surveyId, elements) => {
+ setSurveys((prev) =>
+ prev[surveyId]
+ ? { ...prev, [surveyId]: { ...prev[surveyId], elements } }
+ : prev
+ )
+ },
+ []
+ )
+
+ const draftQuestions = useCallback(
+ (surveyId, name, onComplete) => {
+ setSurveys((prev) =>
+ prev[surveyId]
+ ? {
+ ...prev,
+ [surveyId]: { ...prev[surveyId], name, processing: true },
+ }
+ : prev
+ )
+ timersRef.current[surveyId] = setTimeout(() => {
+ setSurveys((prev) =>
+ prev[surveyId]
+ ? {
+ ...prev,
+ [surveyId]: {
+ ...prev[surveyId],
+ elements: SURVEY_ELEMENTS,
+ processing: false,
+ },
+ }
+ : prev
+ )
+ onComplete?.()
+ }, SURVEY_DRAFTING_MS)
+ },
+ []
+ )
+
+ const applyChange = useCallback(
+ (surveyId, mutate, onComplete) => {
+ setSurveys((prev) =>
+ prev[surveyId]
+ ? { ...prev, [surveyId]: { ...prev[surveyId], processing: true } }
+ : prev
+ )
+ timersRef.current[surveyId] = setTimeout(() => {
+ setSurveys((prev) =>
+ prev[surveyId]
+ ? {
+ ...prev,
+ [surveyId]: {
+ ...prev[surveyId],
+ elements: mutate(prev[surveyId].elements),
+ processing: false,
+ },
+ }
+ : prev
+ )
+ onComplete?.()
+ }, SURVEY_DRAFTING_MS)
+ },
+ []
+ )
+
+ const nextCardId = useCallback(
+ () => `card-${(idRef.current += 1)}`,
+ []
+ )
+
+ const registerLiveCard = useCallback(
+ (surveyId, cardId) => {
+ setLiveCardBySurvey((prev) => ({ ...prev, [surveyId]: cardId }))
+ },
+ []
+ )
+
+ const isLiveCard = useCallback(
+ (surveyId, cardId) => liveCardBySurvey[surveyId] === cardId,
+ [liveCardBySurvey]
+ )
+
+ const hasDraftedSurvey = Object.values(surveys).some(
+ (s) => s.elements.length > 0
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+/**
+ * Shared state for the editable survey canvas (mode "edit" — both the blank
+ * "Empty survey" and a "Use this template" copy). The canvas header and body
+ * are emitted by separate render functions (`renderHeader` / `renderContent`),
+ * so the active tab (Questions / Settings) can't live in either alone — it's
+ * held here and provided via the entity `wrapper`, which brackets both. The
+ * `elements` (and the `processing` overlay flag) come from the multi-survey
+ * store (keyed by `surveyId`) so the chat's clarifying chain can draft into them.
+ */
+type SurveyEditorCanvasState = {
+ tabId: string
+ setTabId: (id: string) => void
+ elements: SurveyFormBuilderElement[]
+ setElements: (elements: SurveyFormBuilderElement[]) => void
+ name: string
+ processing: boolean
+ /** Id of the survey this canvas edits — keys the one-shot first-creation save. */
+ surveyId: string | undefined
+}
+
+// Survey ids whose first-creation save toast has already fired. Module-scoped so
+// it survives the canvas body unmounting on close and remounting on reopen, which
+// would otherwise reset a per-mount ref and re-fire the toast every time.
+const createdSurveyIds = new Set()
+
+const SurveyEditorCanvasContext = createContext(
+ null
+)
+
+function useSurveyEditorCanvas(): SurveyEditorCanvasState {
+ const ctx = useContext(SurveyEditorCanvasContext)
+ if (!ctx) {
+ throw new Error(
+ "useSurveyEditorCanvas must be used inside the survey canvas wrapper"
+ )
+ }
+ return ctx
+}
+
+function SurveyCanvasStateProvider({
+ content,
+ children,
+}: {
+ content: SurveyCanvasContent
+ children: ReactNode
+}) {
+ // Default to the "editor" (Questions) tab — the survey-creation surface is
+ // the point of the edit flow.
+ const [tabId, setTabId] = useState("editor")
+ // Read this survey's draft from the store by id. The survey is seeded once at
+ // creation time (`createSurvey`), so reopening its canvas never resets it —
+ // each survey keeps its own questions as the user switches between cards.
+ const store = useSurveyStore()
+ const survey = store.getSurvey(content.surveyId)
+ const elements = survey?.elements ?? []
+ const name = survey?.name ?? content.templateName
+ const processing = survey?.processing ?? false
+ const surveyId = content.surveyId
+ const setElements = useCallback(
+ (next: SurveyFormBuilderElement[]) => {
+ if (surveyId) store.setElements(surveyId, next)
+ },
+ [store, surveyId]
+ )
+ return (
+
+ {children}
+
+ )
+}
+
+/** Total questions across the survey, counting those nested in sections. */
+function countQuestions(elements: SurveyFormBuilderElement[]): number {
+ return elements.reduce(
+ (n, el) =>
+ el.type === "section" ? n + (el.section.questions?.length ?? 0) : n + 1,
+ 0
+ )
+}
+
+/**
+ * Header for the editable survey canvas. Mirrors a real Survey resource view: a
+ * `ResourceHeader` (title, optional description, Draft status, Publish action,
+ * "⋯" menu, metadata) stacked over the Questions / Settings tab strip — the
+ * same chrome the split phase renders for a created survey. The "Questions"
+ * metadata reflects the live question count from the shared canvas state.
+ */
+function SurveyEditorCanvasHeader({
+ content,
+ onClose,
+}: {
+ content: SurveyCanvasContent
+ onClose: () => void
+}) {
+ const { tabId, setTabId, elements, name } = useSurveyEditorCanvas()
+ const i18n = useI18n()
+ return (
+ <>
+
+ void confirmPublish().then((ok) => {
+ if (ok)
+ toasts.open({
+ title: SURVEY_PUBLISHED_TOAST,
+ variant: "success",
+ })
+ }),
+ }}
+ onClose={() =>
+ void confirmCloseUnsaved().then((action) => {
+ // Cancel or dismissed → stay on the canvas.
+ if (action === "cancel" || action === undefined) return
+ if (action === "save")
+ toasts.open({
+ title: SURVEY_SAVED_TOAST,
+ variant: "success",
+ })
+ onClose()
+ })
+ }
+ secondaryActions={[
+ {
+ label: i18n.actions.save,
+ onClick: () =>
+ toasts.open({
+ title: SURVEY_SAVED_TOAST,
+ variant: "success",
+ }),
+ },
+ ]}
+ otherActions={[
+ {
+ label: "Duplicate",
+ icon: LayersFront,
+ onClick: () => toasts.open({ title: SURVEY_DUPLICATED_TOAST }),
+ },
+ { type: "separator" },
+ {
+ label: "Delete",
+ icon: Delete,
+ critical: true,
+ onClick: () =>
+ toasts.open({ title: SURVEY_DELETED_TOAST, variant: "error" }),
+ },
+ ]}
+ metadata={[
+ {
+ label: "Recurrence",
+ value: { type: "text", content: "Does not repeat" },
+ },
+ {
+ label: "Finishes on",
+ value: { type: "text", content: "Never ends" },
+ },
+ {
+ label: "Questions",
+ value: { type: "text", content: String(countQuestions(elements)) },
+ },
+ ]}
+ />
+
+ >
+ )
+}
+
+/**
+ * Body for the editable survey canvas. The survey-creation surface — the
+ * interactive `SurveyFormBuilder` (seeded blank for the empty survey, or with
+ * the template's questions for a "Use this template" copy) — lives under the
+ * "Questions" tab; "Settings" reuses the resource view's `SurveySettingsForm`.
+ */
+function SurveyEditorCanvasBody() {
+ const { tabId, elements, setElements, processing, surveyId } =
+ useSurveyEditorCanvas()
+ // This flow's resource carries a Draft status, so creating it persists a draft
+ // to its domain the moment its canvas first opens — the resource's first
+ // creation (not autosave), fired once per survey across every creation path
+ // (empty survey, "Use this template" copy, predefined template). Keyed in a
+ // module-scoped set so closing and reopening the canvas doesn't re-fire it.
+ // From here saves are explicit (the header's "Save"); resources
+ // without a draft state create nothing until the user explicitly saves.
+ useEffect(() => {
+ if (!surveyId || createdSurveyIds.has(surveyId)) return
+ const t = setTimeout(() => {
+ createdSurveyIds.add(surveyId)
+ toasts.open({
+ title: SURVEY_SAVED_TOAST,
+ variant: "success",
+ })
+ }, 600)
+ return () => clearTimeout(t)
+ }, [surveyId])
+ return (
+ // While the AI is "drafting" questions, blur + lock the builder behind the
+ // "applying changes" overlay.
+
+
+ {tabId === "settings" ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+const surveyCanvasEntity: CanvasEntityDefinition = {
+ type: "survey",
+ // Shared tab/elements state for the editable (mode "edit") canvas, bracketing
+ // both the header (tab strip) and the body (tab content). Harmless for the
+ // read-only "preview" mode, which doesn't read it.
+ wrapper: ({ content, children }) => (
+
+ {children}
+
+ ),
+ // "preview" is the read-only template browse view (its own thin header +
+ // answering form); "edit" (incl. the empty survey) is the editable resource
+ // view — ResourceHeader + Questions/Settings tabs over the form builder.
+ renderHeader: ({ content, onClose }) =>
+ content.mode === "preview" ? (
+
+ ) : (
+
+ ),
+ renderContent: ({ content }) =>
+ content.mode === "preview" ? (
+
+
+
+
+ ) : (
+
+ ),
+}
+
+/**
+ * Templates browse view as rendered inside the AI Canvas. Unlike the collection
+ * "Templates" tab (inert), the card click here is wired: it opens the template
+ * in preview framing (replacing the template list in-place via `openCanvas`).
+ * The actual "Use this template" / "Edit Template" actions live on the preview
+ * header, not the card.
+ */
+function TemplatesCanvasBody() {
+ const { openCanvas } = useAiChat()
+ const openPreview = (item: Template) =>
+ openCanvas(
+ toCanvasContent({
+ type: "survey",
+ title: item.name,
+ mode: "preview",
+ templateName: item.name,
+ description: item.description,
+ })
+ )
+ return
+}
+
+/**
+ * Header for the templates browse canvas. Defined as a component (like
+ * `SurveyCanvasHeader`) so it can read context and run a custom close instead of
+ * the framework `onClose`: closing the templates list returns to the FIRST step
+ * of cocreation — the fullscreen welcome screen (suggestions + welcome cards).
+ *
+ * Switching to "fullscreen" both closes the canvas (the provider drops canvas
+ * content on any canvas → non-canvas transition) and reopens the chat full
+ * width. We force fullscreen rather than letting `closeCanvas` restore the
+ * pre-canvas mode, since templates may be opened from a side-panel welcome
+ * screen and the first step is always the fullscreen chat.
+ */
+function TemplatesCanvasHeader({
+ content,
+}: {
+ content: TemplatesCanvasContent
+}) {
+ const { setVisualizationMode } = useAiChat()
+
+ const handleClose = () => {
+ setVisualizationMode("fullscreen")
+ }
+
+ return (
+
+
+
+
+ )
+}
+
+/**
+ * Registers the survey entry-point cards shown below the composer on the
+ * fullscreen welcome screen, via the chat's data-driven `welcomeScreenCards`
+ * prop. Each card opens the AI Canvas (docks beside the chat): "Empty survey"
+ * opens a blank survey + kicks off a scripted guided conversation; "Templates"
+ * opens the templates card collection. Renders nothing — it only feeds card
+ * data into the provider so `F0AiChatTextArea` owns the layout.
+ */
+function SurveyWelcomeCardsRegistrar() {
+ const { appendCard, appendMessages, startClarifying } = useMockAiChatRuntime()
+ const { openCanvas, setWelcomeScreenCards, setPlaceholders } = useAiChat()
+ const {
+ createSurvey,
+ draftQuestions,
+ nextCardId,
+ registerLiveCard,
+ hasDraftedSurvey,
+ } = useSurveyStore()
+ const { armProposal } = useProposalFlow()
+
+ // Switch the input placeholder based on whether a survey draft exists.
+ useEffect(() => {
+ setPlaceholders(
+ hasDraftedSurvey
+ ? ["Improve the Survey by..."]
+ : ["Describe the type of survey you want to create"]
+ )
+ }, [hasDraftedSurvey, setPlaceholders])
+
+ // The blank-survey conversation walks three clarifying questions — type →
+ // audience → length — as a single consecutive panel, then "drafts" the
+ // questions onto the canvas: echo the picks back into the transcript, post a
+ // drafting line, and (after a brief processing beat) fill the form with mock
+ // questions plus an "updated" card that supersedes the "created" one.
+ const askSurveyDetails = (surveyId: string) =>
+ startClarifying({
+ steps: SURVEY_CLARIFYING_STEPS,
+ onConfirm: (answersByStep) => {
+ appendMessages(surveyAnswerMessages(answersByStep))
+ appendMessages([
+ {
+ role: "assistant",
+ content:
+ "Great — I'll draft a first set of questions on the canvas for you to review.",
+ },
+ ])
+ const surveyName = surveyNameForType(
+ answersByStep[0]?.[0] ?? "Untitled"
+ )
+ // Once the form is drafted (questions land), post a new openable card
+ // and mark it live — which disables Open/Close on the "created" card —
+ // then arm the proposal flow so further typing keeps refining the
+ // survey (each accepted change supersedes the prior card in turn).
+ draftQuestions(surveyId, surveyName, () => {
+ const cardId = nextCardId()
+ registerLiveCard(surveyId, cardId)
+ appendCard(() => (
+
+ ))
+ armProposal(surveyId, surveyName)
+ })
+ },
+ })
+
+ const cards: F0AiChatWelcomeCard[] = [
+ {
+ id: "empty-survey",
+ icon: File,
+ title: "Empty survey",
+ description: "Start from scratch",
+ onClick: () => handleCardSelectRef.current("empty-survey"),
+ },
+ {
+ id: "templates",
+ icon: Marketplace,
+ title: "Templates",
+ description: "Browse pre-made surveys",
+ onClick: () => handleCardSelectRef.current("templates"),
+ },
+ {
+ id: "employee-nps",
+ icon: Files,
+ title: "Employee NPS",
+ description: "Template",
+ onClick: () => handleCardSelectRef.current("employee-nps"),
+ },
+ // Visual-only template card — its `onClick` hits no branch below, so
+ // clicking is inert (mock placeholder, no behavior wired yet).
+ {
+ id: "employee-engagement",
+ icon: Files,
+ title: "Employee Engagement",
+ description: "Template",
+ onClick: () => handleCardSelectRef.current("employee-engagement"),
+ },
+ ]
+ // Welcome-card behavior, keyed by card `id`. Each card's `onClick` (above)
+ // routes here through `handleCardSelectRef`; each branch opens the AI Canvas
+ // and seeds the matching guided flow.
+ const handleCardSelect = (id: string) => {
+ switch (id) {
+ case "empty-survey": {
+ // Blank-survey flow: create + seed the survey, open its canvas, then
+ // post the guided sequence — an intro line, the "created" canvas card
+ // (live/openable), and the first clarifying question (the chain walks
+ // the rest).
+ const surveyId = createSurvey(UNTITLED_SURVEY_NAME, { empty: true })
+ openCanvas(toCanvasContent(makeEmptySurveyContent(surveyId)))
+ appendMessages([
+ { role: "assistant", content: "Let's start with a blank survey." },
+ ])
+ const cardId = nextCardId()
+ registerLiveCard(surveyId, cardId)
+ appendCard(() => (
+
+ ))
+ askSurveyDetails(surveyId)
+ break
+ }
+ case "templates": {
+ // Browse-only: open the templates list in the canvas WITHOUT posting a
+ // chat message, so the chat stays on the welcome screen. Closing the
+ // list without choosing a template (see `TemplatesCanvasHeader`) then
+ // returns to the fullscreen welcome screen with the welcome cards intact.
+ openCanvas(toCanvasContent(TEMPLATES_CANVAS_CONTENT))
+ break
+ }
+ case "employee-nps": {
+ // Predefined-template flow: open a ready-made NPS survey on the canvas
+ // (mirrors "Use this template" — seeded with questions, no AI
+ // drafting). Its first question is a blocked eNPS question (locked +
+ // in-card warning); the rest stay editable.
+ const surveyId = createSurvey(EMPLOYEE_NPS_SURVEY_NAME, {
+ elements: NPS_SURVEY_ELEMENTS,
+ })
+ openCanvas(
+ toCanvasContent({
+ type: "survey",
+ title: EMPLOYEE_NPS_SURVEY_NAME,
+ mode: "edit",
+ templateName: EMPLOYEE_NPS_SURVEY_NAME,
+ surveyId,
+ description: SURVEY_CREATED_DESCRIPTION,
+ })
+ )
+ const cardId = nextCardId()
+ registerLiveCard(surveyId, cardId)
+ appendCard(() => (
+
+ ))
+ appendMessages([
+ {
+ role: "assistant",
+ content:
+ "I've set up an Employee NPS survey. The eNPS question at the top is fixed so your scores stay comparable — the rest is yours to edit. What would you like to change?",
+ },
+ ])
+ armProposal(surveyId, EMPLOYEE_NPS_SURVEY_NAME)
+ break
+ }
+ // "employee-engagement" is a visual-only placeholder — no behavior yet.
+ }
+ }
+
+ // Each card's `onClick` closes over runtime hooks via `handleCardSelectRef`,
+ // so the registered cards always call the latest handler. Keep the cards in a
+ // ref too, so a single mount effect registers them without re-running
+ // (`setWelcomeScreenCards` is stable). Clear them on unmount.
+ const cardsRef = useRef(cards)
+ cardsRef.current = cards
+ const handleCardSelectRef = useRef(handleCardSelect)
+ handleCardSelectRef.current = handleCardSelect
+ useEffect(() => {
+ setWelcomeScreenCards(cardsRef.current)
+ return () => {
+ setWelcomeScreenCards([])
+ }
+ }, [setWelcomeScreenCards])
+
+ return null
+}
+
+/**
+ * `Tabs` driven by `id` (controlled) render each tab as a `` rather than
+ * an ``, so the browser shows no pointer cursor — in product the tabs
+ * carry real hrefs and behave as links. This thin wrapper restores the pointer
+ * cursor on the story's controlled tab strips. `display: contents` keeps the
+ * wrapper layout-transparent.
+ */
+function ClickableTabs(props: ComponentProps) {
+ return (
+
+
+
+{/* TODO(creator): confirm the exact anti-patterns and where co-creation appears / what triggers it in Factorial. */}
+
+## Principles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+The [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page defines
+these rules in full, including the resource-card lifecycle.
diff --git a/packages/react/src/patterns/Cocreation/__stories__/index.mdx b/packages/react/src/patterns/Cocreation/__stories__/index.mdx
new file mode 100644
index 0000000000..92ece27c14
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/index.mdx
@@ -0,0 +1,142 @@
+import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
+import { FeatureGrid } from "~/docs/components/FeatureGrid"
+import {
+ LayersFront,
+ BookOpen,
+ Marketplace,
+ Code,
+ CheckCircle,
+ Person,
+ Pencil,
+} from "@/icons/app"
+
+
+
+# Co-creation
+
+Co-creation is the pattern for **building a resource together with AI, in context**.
+Instead of filling a form alone, the user describes what they want, the system proposes
+and drafts it, and the two refine it side by side until the resource is ready — with the
+user approving every change. {/* TODO(creator): confirm the canonical one-line definition. */}
+
+Want to see it first? **[Try the interactive demo ↗](?path=/story/patterns-co-creation-creation-with-ai--surveys)** — co-creating a survey from a typed intent to a ready-to-publish draft.
+
+For open-ended assistance that does not produce a resource, use the chat surface directly
+([`F0AiChat`](?path=/docs/sds-ai-f0aichat--documentation)). For creating and editing
+records through deterministic forms and dialogs, use
+[Data collection / CRUD patterns](?path=/docs/patterns-data-collection-crud-patterns-overview--documentation).
+Co-creation sits between the two: conversational input, a real resource as output.
+
+## Introduction
+
+### Definition
+
+Co-creation turns creation into a conversation. A chat-enabled surface lets the user state
+an intent in natural language; the system asks clarifying questions, drafts the resource,
+and surfaces each proposed change as a card the user can accept or reject. The resource
+stays visible and editable throughout, so the user is always co-author rather than reviewer.
+{/* TODO(creator): confirm definition wording and scope. */}
+
+The pattern holds a few invariants, defined in full on the
+[Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page: the
+resource is built in a **canvas inside the chat** — never on a separate page the user is routed
+to; the user **approves every change** before it is applied; and **leaving an in-progress
+creation warns** before the draft is lost.
+
+### Purpose
+
+- **Lower the cost of a blank start:** an intent in plain language is enough to produce a
+ first usable draft, instead of an empty form.
+- **Keep the human in control:** AI proposes, the user disposes — changes are explicit,
+ reviewable, and reversible before they are applied.
+- **Stay in context:** creation happens over the surface the user came from (a data
+ collection, a page), not in a detached tool.
+- **Compose, don't reinvent:** the pattern orchestrates existing AI building blocks so each
+ co-creation flow behaves consistently across the product.
+
+## Features
+
+
+
+
+
+## Anatomy
+
+A co-creation flow runs on a single chat-enabled
+[`ApplicationFrame`](?path=/docs/patterns-applicationframe--documentation): the AI trigger
+(the One switch) lives in the header and is available from any phase. The flow advances
+through three phases — **Collection → Chat → Split** — described in
+[Phases](?path=/docs/patterns-co-creation-phases--documentation), and follows the contracts on
+the [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) page (the
+resource lives in a canvas inside the chat; the user approves every change; leaving warns).
+
+## Try the flow
+
+The pattern is demonstrated by an interactive mock — the **Creation with AI** example, which
+co-creates a survey from a typed intent to a ready-to-publish draft. It runs full screen, so it
+opens in its own canvas rather than cramped into this docs page:
+
+
+
+ Open the interactive flow ↗
+
+
+
+For a step-by-step walkthrough of what happens at each phase, see
+[Creation with AI](?path=/docs/patterns-co-creation-creation-with-ai--documentation).
diff --git a/packages/react/src/patterns/Cocreation/__stories__/mockData.tsx b/packages/react/src/patterns/Cocreation/__stories__/mockData.tsx
new file mode 100644
index 0000000000..c1c6fd0258
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/mockData.tsx
@@ -0,0 +1,195 @@
+// Mock data for the "Creation with AI" co-creation story — the collection
+// records, filters, sortings, data adapter, and visualization backing the
+// Surveys tab. Pure data; consumed by FlowContent's OneDataCollection source.
+
+import { File, Marketplace } from "@/icons/app"
+
+export type ResourceStatus = "Draft" | "Complete" | "Needs details"
+
+export type Resource = {
+ id: string
+ name: string
+ owner: string
+ status: ResourceStatus
+}
+
+export const resourceFilters = {
+ status: {
+ type: "in" as const,
+ label: "Status",
+ options: {
+ options: [
+ { value: "Draft", label: "Draft" },
+ { value: "Needs details", label: "Needs details" },
+ { value: "Complete", label: "Complete" },
+ ],
+ },
+ },
+ owner: {
+ type: "in" as const,
+ label: "Owner",
+ options: {
+ options: [
+ { value: "Alicia Keys", label: "Alicia Keys" },
+ { value: "Dani Moreno", label: "Dani Moreno" },
+ { value: "Marta Soler", label: "Marta Soler" },
+ { value: "Nora Park", label: "Nora Park" },
+ ],
+ },
+ },
+}
+
+export const resourceSortings = {
+ name: { label: "Name" },
+ owner: { label: "Owner" },
+ status: { label: "Status" },
+} as const
+
+export const MOCK_RESOURCES: Resource[] = [
+ {
+ id: "1",
+ name: "Engineering onboarding plan",
+ owner: "Alicia Keys",
+ status: "Draft",
+ },
+ {
+ id: "2",
+ name: "Q3 performance review cycle",
+ owner: "Dani Moreno",
+ status: "Complete",
+ },
+ {
+ id: "3",
+ name: "Product roadmap 2026",
+ owner: "Marta Soler",
+ status: "Needs details",
+ },
+ {
+ id: "4",
+ name: "Offboarding checklist",
+ owner: "Nora Park",
+ status: "Draft",
+ },
+]
+
+export const filledDataAdapter = {
+ fetchData: () => Promise.resolve({ records: MOCK_RESOURCES }),
+}
+
+export const tableVisualization = {
+ type: "table" as const,
+ options: {
+ columns: [
+ { label: "Name", render: (item: Resource) => item.name },
+ { label: "Owner", render: (item: Resource) => item.owner },
+ { label: "Status", render: (item: Resource) => item.status },
+ ],
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Templates tab — a browse view backed by a card visualization. Pure mock
+// data: survey templates a user can start from, surfaced as metadata cards.
+// ---------------------------------------------------------------------------
+
+export type Template = {
+ id: string
+ name: string
+ category: string
+ description: string
+ questions: number
+}
+
+export const MOCK_TEMPLATES: Template[] = [
+ {
+ id: "t1",
+ name: "Employee engagement survey",
+ category: "Engagement",
+ description:
+ "A 10-question pulse check covering motivation, clarity, and team dynamics.",
+ questions: 10,
+ },
+ {
+ id: "t2",
+ name: "Employee Net Promoter Score (eNPS)",
+ category: "Engagement",
+ description:
+ "Measure loyalty and advocacy with a single-question score plus follow-ups.",
+ questions: 3,
+ },
+ {
+ id: "t3",
+ name: "Post-onboarding feedback",
+ category: "Lifecycle",
+ description: "Capture new hire impressions after their first 30 days.",
+ questions: 8,
+ },
+ {
+ id: "t4",
+ name: "Exit interview",
+ category: "Lifecycle",
+ description:
+ "Understand why people leave and surface trends across departures.",
+ questions: 12,
+ },
+ {
+ id: "t5",
+ name: "Manager effectiveness",
+ category: "Management",
+ description:
+ "Gather upward feedback on coaching, communication, and support.",
+ questions: 9,
+ },
+ {
+ id: "t6",
+ name: "Wellbeing pulse",
+ category: "Wellbeing",
+ description:
+ "Short check-in on workload, balance, and overall wellbeing at work.",
+ questions: 6,
+ },
+]
+
+export const templatesDataAdapter = {
+ fetchData: () => Promise.resolve({ records: MOCK_TEMPLATES }),
+}
+
+export const templateFilters = {
+ category: {
+ type: "in" as const,
+ label: "Category",
+ options: {
+ options: [
+ { value: "Engagement", label: "Engagement" },
+ { value: "Lifecycle", label: "Lifecycle" },
+ { value: "Management", label: "Management" },
+ { value: "Wellbeing", label: "Wellbeing" },
+ ],
+ },
+ },
+}
+
+export const templateSortings = {
+ name: { label: "Name" },
+ questions: { label: "Questions" },
+} as const
+
+export const cardVisualization = {
+ type: "card" as const,
+ options: {
+ title: (item: Template) => item.name,
+ description: (item: Template) => item.description,
+ cardProperties: [
+ {
+ label: "Category",
+ icon: Marketplace,
+ render: (item: Template) => item.category,
+ },
+ {
+ label: "Questions",
+ icon: File,
+ render: (item: Template) => `${item.questions} questions`,
+ },
+ ],
+ },
+}
diff --git a/packages/react/src/patterns/Cocreation/__stories__/phases.mdx b/packages/react/src/patterns/Cocreation/__stories__/phases.mdx
new file mode 100644
index 0000000000..d0c9bf0ca1
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/phases.mdx
@@ -0,0 +1,120 @@
+import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
+import { DoDonts } from "@/lib/storybook-utils/do-donts"
+
+
+
+# Phases
+
+Every co-creation flow moves through three phases — **Collection → Chat → Split**. They run
+on one chat-enabled [`ApplicationFrame`](?path=/docs/patterns-applicationframe--documentation),
+so the AI trigger in the header is available throughout. The whole flow is coordinated by a
+single `phase` value: the chat's open state and visualization follow from it, staying in sync
+as the user toggles the header trigger.
+
+## The three phases
+
+
+
+
+
+
Phase
+
What happens
+
Entry / exit
+
+
+
+
+
+ Collection
+
+
+ The starting surface — a data collection, page, or list. The user
+ works as usual; co-creation is one trigger away.
+
+
+ Enters from the surface itself. Exits to Chat when the user opens the
+ AI via the header One switch (side panel) or the{" "}
+ Create primary button (full width).
+
+
+
+
+ Chat
+
+
+ The chat animates in so the user can describe what they want.
+ Clarifying questions narrow the intent before anything is drafted.
+
+
+ Enters from Collection. Opens full width via the Create button, or as
+ a side panel via the One switch. Exits to Split once there is a
+ resource to show.
+
+
+
+
+ Split
+
+
+ The chat docks as the right side panel and the resource canvas fills
+ the center. The user reviews, edits, and refines the draft while the
+ conversation continues alongside it.
+
+
+ Enters from Chat when a resource is created or opened. The
+ conversation and the canvas stay visible together until the resource
+ is done.
+
+
+
+
+
+
+The interactive mock begins in **Collection**; opening the AI from the header moves it through
+Chat and Split. It runs full screen, so open it in its own canvas:
+
+
+
+ Open the interactive flow ↗
+
+
+
+## Behavior
+
+- `phase` (`"collection" | "chat" | "split"`) is the single source of truth. The chat's
+ open state and visualization (side panel vs. full width) are derived from it — never set
+ independently — so the header trigger and the in-flow buttons can never disagree.
+- The chat trigger lives in the `ApplicationFrame` header and is present in every phase, so
+ the user can open, collapse, or return to the conversation at any point.
+- In Split, the resource lives in a canvas **inside the chat** — the flow never navigates to a
+ separate resource page. See the
+ [Standard flow](?path=/docs/patterns-co-creation-standard-flow--documentation) for this and
+ the other rules that hold across the phases.
+
+## Guidelines
+
+
+
+
diff --git a/packages/react/src/patterns/Cocreation/__stories__/standard-flow.mdx b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.mdx
new file mode 100644
index 0000000000..f2440fdbc2
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.mdx
@@ -0,0 +1,165 @@
+import { Canvas, Meta, Unstyled } from "@storybook/addon-docs/blocks"
+import { DoDonts } from "@/lib/storybook-utils/do-donts"
+
+import * as Stories from "./standard-flow.stories"
+
+
+
+# Standard flow
+
+The rules every co-creation flow follows. They define how a resource is created, how the user
+moves in and out of the flow, and how the in-chat cards behave as the AI iterates. The
+[Phases](?path=/docs/patterns-co-creation-phases--documentation) page describes the stages;
+this page describes the contracts that hold across them.
+
+## Resources are created in the canvas, inside the chat
+
+A co-created resource is built and edited in a **canvas that opens inside the chat** — never on
+a separate page the user is routed to. Once there is a draft, the chat docks to the side and the
+canvas fills the center (the [Split](?path=/docs/patterns-co-creation-phases--documentation)
+phase), so the conversation and the resource stay together while the user refines it.
+
+The user opens and closes the canvas from the resource's in-chat card; they are never navigated
+away to reach it. Keeping creation in the canvas is what lets the AI and the user stay in one
+shared context from intent to finished resource.
+
+See it in the interactive mock (runs full screen, opens in its own canvas):
+
+
+
+ Open the interactive flow ↗
+
+
+
+## Leaving an in-progress creation warns the user
+
+If the user does something that would **leave an in-progress creation** — following a link out,
+switching to an unrelated area — confirm before letting them go, so they don't lose the draft or
+the conversation. The flow does this with a "Leave creation?" confirmation.
+
+This is a **general rule**, not specific to co-creation: any flow holding unsaved, in-progress
+work should warn before discarding it. {/* TODO(creator): promote this to a general DS principle and link it here. */}
+
+Closing or collapsing the chat panel is **not** leaving — the flow is preserved and reopens
+where it was, so that action does not warn. Reserve the confirmation for navigation that would
+actually abandon the work.
+
+
+
+## Resource cards across AI iterations
+
+Each resource the AI creates posts an **in-chat card** — built on
+[`F0CardHorizontal`](?path=/docs/experimental-cardhorizontal--documentation) — that is the user's handle to
+open and close that resource in the canvas. As the AI iterates, the cards change state so the
+chat stays an accurate, navigable history.
+
+- **Active vs. inactive** — a card is _active_ when its resource is the one currently shown in
+ the canvas (it offers Close and reads as selected). Only one card is active at a time; opening
+ another resource deactivates the previous one.
+- **Live vs. superseded** — when the AI applies an accepted change, it posts a **fresh card** for
+ the same resource. The new card is _live_ (interactive); the previous one is _superseded_ — it
+ drops its Open/Close action and dims, but stays in the chat so the timeline is preserved.
+- **Proposals** — a proposed change appears as a pending
+ [`F0CardHorizontal`](?path=/docs/experimental-cardhorizontal--documentation) confirm/reject card
+ with accept (✓) and reject (✗) actions. Deciding it resolves the card to an outcome state.
+ Accepting runs the
+ [processing overlay](?path=/docs/patterns-co-creation-building-blocks--documentation), applies
+ the change, and posts the new live resource card that supersedes the prior one.
+
+The resource cards below show the lifecycle: the live card carries a module avatar and an
+Open/Close toggle, while the superseded card — replaced by a newer one for the same resource —
+drops the toggle and fades to 50%, staying in the chat as history:
+
+
+
+Proposals resolve the same way. In the conversation below, the two earlier proposal cards are
+resolved — accepted keeps its outcome icon, rejected is struck through and dimmed — while the
+last card is still active, awaiting the user's accept (✓) or reject (✗):
+
+
+
+
+
+
+
+
Card
+
State
+
Appearance
+
Interactive
+
+
+
+
+
Resource card
+
+ Active
+
+
+ Its resource is open in the canvas — offers Close, highlighted border.
+
+
Yes — one card active at a time
+
+
+
Resource card
+
+ Live (not open)
+
+
+ Latest card for the resource, not currently shown — offers Open.
+
+
Yes
+
+
+
Resource card
+
+ Superseded
+
+
+ Replaced by a newer card — Open/Close removed, dimmed, kept in
+ history.
+
+
No
+
+
+
Proposal card
+
+ Pending
+
+
Accept (✓) / reject (✗) actions on the trailing edge.
+
Yes
+
+
+
Proposal card
+
+ Resolved
+
+
Outcome icon (Accepted / Rejected) in place of the actions.
+
No
+
+
+
+
+
+
diff --git a/packages/react/src/patterns/Cocreation/__stories__/standard-flow.stories.tsx b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.stories.tsx
new file mode 100644
index 0000000000..188c8d9690
--- /dev/null
+++ b/packages/react/src/patterns/Cocreation/__stories__/standard-flow.stories.tsx
@@ -0,0 +1,129 @@
+import type { Meta, StoryObj } from "@storybook/react-vite"
+
+import { type ReactNode } from "react"
+
+import { F0CardHorizontal } from "@/experimental/F0CardHorizontal"
+import { Check, Cross } from "@/icons/app"
+
+// Illustrative-only stories for the Co-creation "Standard flow" docs page. They
+// render compact mocks (not the full-screen flow) so they can be embedded inline
+// via