Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 & {
Expand Down Expand Up @@ -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 }) =>
Expand All @@ -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,
Expand Down Expand Up @@ -240,6 +246,17 @@ export function BaseHeader({
<MobileDropdown items={visibleOtherActions} />
</div>
)}
{onClose && (
<div className="w-full md:hidden [&>*]:w-full">
<F0Button
label={i18n.actions.close}
icon={Cross}
variant="outline"
size="lg"
onClick={onClose}
/>
</div>
)}
</div>

<div className="-m-1 hidden w-fit shrink-0 flex-wrap items-center gap-x-2 gap-y-2 p-1 md:flex md:overflow-x-auto">
Expand Down Expand Up @@ -308,6 +325,20 @@ export function BaseHeader({
/>
</div>
)}
{onClose && (
<>
<div className="mx-1 h-4 w-px bg-f1-background-secondary-hover" />
<div className="hidden md:block">
<F0Button
label={i18n.actions.close}
hideLabel
icon={Cross}
variant="outline"
onClick={onClose}
/>
</div>
</>
)}
</div>
</div>
{allMetadata.length > 0 && (
Expand Down
41 changes: 24 additions & 17 deletions packages/react/src/lib/storybook-utils/ai-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
`<p>Our quarterly review highlighted <b>three priorities</b> for the next cycle: improving the onboarding experience, reducing the time it takes to close payroll, and giving managers better visibility over team workloads.</p><p>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.</p>`,
Expand Down
157 changes: 157 additions & 0 deletions packages/react/src/patterns/Cocreation/__stories__/accessibility.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { Meta, Unstyled } from "@storybook/addon-docs/blocks"
import { DoDonts } from "@/lib/storybook-utils/do-donts"

<Meta title="Co-creation/Accessibility" />

# 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.

<Unstyled>
<table className="mb-8 w-full dark:text-f1-foreground-inverse/80">
<thead>
<tr>
<th className="text-left">Surface</th>
<th className="text-left">Keyboard behavior</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<strong>Clarifying questions</strong> (<code>F0ClarifyingPanel</code>)
</td>
<td>
Arrow keys move between options, <kbd>Enter</kbd> selects and
advances, and <kbd>Esc</kbd> cancels the whole flow. The panel
surfaces these hints inline ("navigate · Enter select · Esc cancel").
</td>
</tr>
<tr>
<td>
<strong>Proposal cards</strong> (accept / reject)
</td>
<td>
The accept (✓) and reject (✗) actions are real buttons with text
labels, focusable in reading order. Resolving a proposal must not
strand focus (see below).
</td>
</tr>
<tr>
<td>
<strong>Resource cards</strong> (Open / Close)
</td>
<td>
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.
</td>
</tr>
<tr>
<td>
<strong>Header trigger</strong> (<code>F0OneSwitch</code>)
</td>
<td>
The One switch is reachable in every phase, so the keyboard user can
always open, collapse, or return to the conversation.
</td>
</tr>
</tbody>
</table>
</Unstyled>

<DoDonts
do={{
description:
"Give accept / reject buttons real text labels for assistive tech.",
guidelines: [
'Label the actions ("Apply" / "Discard"), don\'t rely on the ✓ / ✗ glyph alone',
"Keep the whole proposal card decision reachable by keyboard, in reading order",
],
}}
dont={{
description: "Don't make a decision pointer-only or icon-only.",
guidelines: [
'Don\'t ship a ✓ / ✗ that a screen reader announces as just "button"',
"Don't trap focus inside the canvas with no keyboard way back to the chat",
],
}}
/>

## 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.

<DoDonts
do={{
description:
"Move focus to the surface the user is meant to act on next as the flow advances.",
}}
dont={{
description:
"Don't leave focus on a control that was removed, dimmed, or superseded by the change.",
}}
/>

## 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.

<DoDonts
do={{
description:
'Announce "the AI is working", "a change was applied", and "a decision is waiting" politely.',
}}
dont={{
description:
"Don't push every streamed token into the live region — it floods assistive tech and buries the outcome.",
}}
/>

## 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.

<DoDonts
do={{
description:
"Respect prefers-reduced-motion for the chat entrance and the processing overlay.",
}}
dont={{
description:
"Don't force the animated transition on users who have asked the system to reduce motion.",
}}
/>

For broader, component-level accessibility expectations, see the foundations docs; this page
covers only what is specific to co-creation.
Loading
Loading