Connects is the catalog, then the connect flow, then health.
Apps your assistants run on — browse what's connectable, connect it (OAuth or a pasted key), see it healthy, and know the moment it needs you.
The widths drawn here are samples of a fluid
continuum, not the only states that exist — content (spacing, type, gaps, card/column widths)
scales continuously between them, each clamp reading a named container. Structure (rail,
row↔stack, grid columns) steps only at sm 640 · md 768 ·
lg 1024; radius, hairlines, and pills never scale. Kit: design-canon.md U8.
Reading order in this document: § Desktop
journey first — every desktop (and 768 tablet) frame, full flow, journey order — then
§ Mobile journey — 375 below it — every 375 frame, same order, laid out side
by side as a wrapping grid (never a tall single-file column of phone screens). Two sections,
never interleaved — a review-document convention only (this repo's design-canon.md
§ Mockup review reading order). Mobile 375 is still designed and scored FIRST in the /design
ritual (kit canon U5/U8) — the two rules never conflate.
Connect an app once; every assistant that needs it can use it. Amber = needs your attention.
ConnectsPage.tsx
ConnectionStatusButton control every real app row uses — one control species everywhere. Their menus open the same as any connected row's; Disconnect (and, for AI, Switch account) render GREYED with a signed reason instead of being omitted or drawn as a dead chevron-less chip — the earlier "no status pill, no Reconnect/Disconnect" note above is retired.assistant-lifecycle.html#sec-5b) draws the Storage menu; this C7 note is the Connects-page source for all three lines.badge-label--info pill is retired, see the Google row above for the reference instance).Connect an app once; every assistant that needs it can use it.
supportedMethods in config/connections.ts — OAuth, API key, or WhatsApp's own embedded-signup + manual-fallback pair. Each shape draws ONE real exemplar (the actual shipped copy, not placeholder text) and lists every app that shares it — active and coming-soon — instead of redrawing near-identical forms per app.ApiKeyForm · PROVIDER_GUIDES.whatsapp
contentWidth="wide"; mobile collapses the how-to steps behind a tap (prereq list stays open — that visibility is the point of #3030).contentWidth="wide" (sheet-slide-panel.md #2193/#2194 — sanctioned for "split form+preview") drops the 1120px reading-column cap so the sheet body runs to ~920px. Body splits reference LEFT 38% / active-work RIGHT, matching the locked split axis (2026-07-26: LEFT = reference/page pane, RIGHT = active work). This is NOT the AssistantModalShell page-slide mechanic — no separate real page renders, it's a plain two-column body inside one `ModalWrapper` sheet.<details> disclosure (closed by default) — matches 3 existing call sites (MetaSetupChecklistStep, DataPrivacySection, AssistantSummaryCard.post). The "Before you start" prereq list stays always-open — that visibility is the #3030 audit's specific fix, not something to re-hide.lg (mobile), the split collapses to a single stacked column — no room for two columns (2026-06-14 lock, applies to every 60/40 usage in this codebase) — same collapsed-how-to treatment carries over.ErrorDisplayWithAI card.ErrorDisplayWithAI · PR #3103 / issue #3113
errorFieldId === 'phone_number_id' in ApiKeyForm.tsx) — number, token, and WABA ID keep their normal borders. Before #3103 every field read the shared error string and went red together; that all-red state was the bug — never draw or build it.ErrorDisplayWithAI with the form's friendlyOverride: title "Number already connected", the same one-line message, one "How to fix" step, and — because supportContact: true — the "Message us" link (opens WhatsApp and auto-sends structured error context to the team) with its explainer line. "Try Again" renders because the form passes onRetry; it clears the error and re-submits.ErrorDisplayWithAI card, directly under the fields — identical to desktop, not just annotated.OAuthForm.tsx · AUTO_METHOD_LABELS
supportedMethods.apiKey: false → hasManual false). The Auto tab is always the default.MystFlo will be able to read and edit only the Sheets and Docs you create or pick — never your whole Drive.
EmbeddedSignupForm.tsx
ConnectionModal.tsx always renders the tab strip (lit-underline idiom, Auto default with a quiet "· Recommended"), one self-describing subtitle per tab. The form's own "Prefer manual setup?" link is suppressed in this host (hideManualFallback), so the tab is the only way across. Corrected against shipped code at merge time — the pre-merge draft of this frame claimed the tabs had been replaced by a text link, which was never true of the shipped modal.
Connects → "Connect" on the WhatsApp card
│
sheet opens — AUTO tab is default
│
┌────────── AUTO (3a) ────┴────────── MANUAL (3b) ─────────┐
▼ ▼
has a Facebook account? 7-item prerequisite gate
no → "Create one free" (before any fields — incl.
│ yes System User token / Never)
▼ │
[Connect with Meta] ──► Meta popup ▼
Business Portfolio, WABA, and paste 4 credentials → Save
number can be CREATED in-popup │
(coexistence offered if number live Graph validation
is on the WhatsApp Business app) (EF whatsapp?action=connect)
│ ┌────────────┼─────────────┐
▼ ▼ ▼ ▼
auth code → server-side exchange 409 number 400 invalid success
(same EF, action=connect) already credentials │
│ connected │ │
▼ inline field error state + ▼
Connected ✓ error retry Connected ✓
Meta's Embedded Signup handles everything — no manual setup needed
Requires a Facebook account. Don't have one? Create one free
EmbeddedSignupForm.tsx)
Follow the steps in the popup to connect your WhatsApp Business account
loading-sdk/exchanging — same spinner shell, only the headline text changes ("Preparing Meta login…" / "Connecting your WhatsApp account…").Something went wrong. Please try again.
Or enter credentials manually
Or use Meta's onboarding page ↗
hideManualFallback, which only hides the idle-state link) — both land on 3b.Meta requires your WhatsApp Business App number to have at least 7 days of real conversations before it can connect to platforms like MystFlo. This is a Meta security requirement — not a MystFlo limitation.
Already have API credentials? Enter them manually
ErrorDisplayWithAI card below the fields, identical stack to desktop f3b (#3113). The healthy state is the same frame without .fld.err/.err-card; see the desktop frame below.MystFlo will be able to read and edit only the Sheets and Docs you create or pick — never your whole Drive.
PROVIDER_GUIDES.stripe
PROVIDER_GUIDES entry — only the URL, steps, warning, and key format differ).You'll need a Stripe account. Sign up or log in first.
Connect an app once; every assistant that needs it can use it. Amber = needs your attention.
ConnectionStatusButton + T336's trigger-color invariant — corrected 2026-07-23, was previously drawn monochrome here); dropdown holds only Disconnect.getGoogleScopeLabels()) — no colored badge.assistant-flows/assistant-lifecycle.html sec-5b/sec-9d) lists the SAME underlying connections by ROLE instead (Communication / Payment / Storage / AI Model), scoped to what that one assistant uses — its badge is the picker for role-level choices (which provider, which resource), while R35's split-doorway sends any "fix this app" action (Reconnect, or — post-activation — "use a different account") HERE, to this exact row. The two surfaces never duplicate each other's job: role vs. resource choice lives on the assistant; account-level health, scopes, and disconnect live here.Connect an app once; every assistant that needs it can use it. Amber = needs your attention.
DrillOver shell Billing already ships (760px centered content column) — matching this frame's own "mirrors mobile exactly" note and the canon "slide-over as the real page" rule; the earlier drawn 560px panel cap was a drawing artifact, not a spec.catalog.label_short; the row name is catalog.native_label (Bahasa Indonesia · Español), English prose elsewhere (confirm body, Pulse remind, the wallet ledger line “Follow-up language: Spanish”) uses catalog.label. The tag is never written into a template name — Meta registers the SAME name once per language code, and WhatsApp Manager lists one name with a language column (architecture §3.1 / §7). · NEW (PRD debt) — an added language renders exactly one of two states: Approved (emerald dot) or Pending Meta approval (amber dot). No Reconnect, no message counts, no per-template rows, no founder button on a row — a rejection or a wording revision is resubmitted by MystFlo and the row simply reads Pending again. · Backend reconcile (#5166) — data is the account-wide read GET whatsapp?action=template-languages (no assistant_id): languages[] from the catalog where available = true, in sort_order. Mapping: included → the Included row · state = approved → Approved · state = pending → Pending Meta approval · state = available → not in this list, picker only. Internal rejected / failed template rows never reach the founder — the API already folds them into pending (auto re-translate + resubmit, cap 3, then an ops alert; the row stays Pending). Edge the builder must know: approved is “every live assistant's tracked template has an approved sibling” — a language bought before any chat/booking assistant is live reads Approved with nothing yet submitted, and flips to Pending at the first activation; no third state is drawn for it (two states only, Peyton 2026-09-20). Loading = the list hairline + one ghost row at list opacity, no spinner (motion is earned, U13). · NEW (PRD debt) — translation is ours: the founder never reviews or validates a translation; copy says so (“We translate the wording and send it to Meta for approval. Nothing for you to check.”). · NEW (PRD debt) — the only actionable control is + Add language ▾; the copy line above the list is verbatim: “Your assistant already replies in any language. This is only for the follow-ups Meta makes us pre-approve.” · NEW (PRD debt) — the charge is $10 one-time per added language PER ACCOUNT (not per number, not per assistant), debited from the prepaid wallet at confirm, never again; the figure is DB-sourced. · NEW (PRD debt) — no restart: languages are not assistant config — they live on the connection and are read at send time. Adding one after activation needs no pause, no stop, no re-activation and never routes through Start fresh; the quiet line under the control says so (“Applies to every assistant on this account, including ones already running.”).1.4cqi clamp); radius, hairline and the pill never scale (U8). · Money on the surface: a one-time purchase shows its amount (+$10, $10) — ledger amendment 2026-09-20, runs-only rule otherwise unchanged. · The two-verb rule holds where a verb exists — this section has none; Add is the entry control, not a status verb.presentation="sheet" with the standard band (title · subtitle · 44×44 ✕ at the gutter). · NEW (PRD debt) — a search field appears only when the authored list passes ~8 languages (not wave 1). · Backend reconcile (#5166) — options are the same languages[] read: rows with included = false; purchased = true → Added (dimmed, not tappable), else +{price_usd} formatted from the API's price_usd (10.00 renders as +$10), never a literal. Wave 2 rows (available = false) are not returned, so nothing is hidden client-side.min(360px, 80cqi)); 44px rows; the underlying page here shows English + Bahasa only, so Español is still purchasable (the state the picker is for). Choice, stated: the original brief said “searchable list” — with two authored languages a search box would be furniture, so it is deferred by rule above rather than drawn empty.presentation="dialog" with the one-time charge + walletpresentation="dialog": eyebrow · “Add Español (ES) for follow-ups?” · two sentences (whole account incl. running assistants · we translate and submit, nothing to check) · one money line (One-time · $10 · wallet $41.20 → $31.20) · Cancel + Add Español · $10. · NEW (PRD debt) — the debit happens at this confirm only; the row then reads Pending Meta approval immediately; no assistant is paused or restarted. · Backend reconcile (#5166) — primary fires POST whatsapp?action=template-languages/purchase with { language: "es" } (catalog code). 200 → { purchased, already_purchased, language, state: "pending" }: render the Pending row from the response, re-read the list after. already_purchased = true (double-tap / retry — the RPC's second idempotency wall, no second debit) is a quiet success, not an error: close, show the row. The wallet figures on the money line are the page's own wallet balance (useWallet) and price_usd; the ledger line Billing later shows reads “Follow-up language: Spanish” (catalog.label). Translation + Meta submission run in the background after the response (EdgeRuntime.waitUntil) — the founder never waits on them.price_usd; when balance < price the confirm opens straight in this state and no request is sent. (2) Server-authoritative: POST …/purchase answers 402 { code: "topup_required", balance_micros, needed_micros } — render the same frame from those two figures (the pre-flight path sends them; the rarer in-RPC insufficient_balance path sends the code only — fall back to the wallet the page holds). Either way nothing is written: no purchase row, no ledger row. Top up first → opens the existing top-up slide-over; on return the founder re-taps Add — the purchase is not queued.{ code: "wallet_refund_pending" } (a wallet frozen mid-refund, T1212 — the balance may be sufficient, so this is NEVER folded into the top-up frame) → sentence “Your wallet is settling a refund — try again in a minute. Nothing was charged.”; 500 (generic Failed to purchase language) → sentence in the playbook error register (“Something broke on our end. Try again in a minute. Nothing was charged.” — final string Chief Writer). Primary Try again re-sends the same POST; the RPC's idempotency walls make a retry safe even if the first call half-landed (already_purchased → quiet success). Cancel stays. Money line unchanged (nothing moved). 400 invalid_language cannot be reached from this picker (only purchasable rows are listed) and 429 (5 purchases / hour / user) cannot be reached in wave 1 (two purchasable languages) — neither is drawn.RECOVERY_MESSAGES · OAUTH_ERROR_MESSAGES · Continue-Activation block, ConnectsPage.tsx
.banner pattern (pulse.html).?focus=whatsapp&reason=credential_expired) or failed-run redirect; page auto-scrolls to the named row and rings it ~10s (amber inset + tint, not a boxed card).DriveFileScopeBanner): pre-T340 Google connections hold legacy Drive scopes but not drive.file — every picker pick would silently 403. Same .banner.warn strip as the others; NO Dismiss at any width (functional gate, PRD §2.4). Detail in 7b below.sm 640.DriveFileScopeBanner.tsx (shipped, restyle target)
ConnectionStatusButton; the banner never replaces or duplicates it. · NEW (PRD debt) — banner renders iff a status=connected Google row exists AND its stored scopes lack drive.file (googleScopes.ts:66 googleNeedsDriveFileReconnect). A legacy Drive scope is NOT required: hasDriveFileScope returns false for null/empty/absent scopes, so an unpopulated row also shows the banner — fail-loud by design, since we cannot confirm coverage. Post-T340 connections never see it. · NEW (PRD debt) — non-dismissible: no dismiss affordance at any tier; unmounts only when useGoogleScopeStatus re-checks after a successful reconnect. · NEW (PRD debt) — copy is context-keyed to exactly three strings (connects / wizard / knowledge), verbatim from the component; no fourth context without a PRD row..banner .cta treatment (amber tint, verb Reconnect Google) — NOT an embedded ConnectionStatusButton. REQ-C2-01 scopes itself to connection ROWS (Connections row, Wizard Step 1, detail-modal Connect tab); §2.4's T347 carve-out sanctions the banner as an alert surface with "a single 'Reconnect Google' CTA". Embedding the primitive would put two status controls for one connection on one page (the row below already carries it) — the "three signals for one thing" bug §2.3.1 exists to kill — and would drag in the primitive's dropdown/label contract (REQ-C2-03/04), both wrong inside a banner. The two-verb ledger still binds the CTA label: Reconnect + provider name, exactly §2.4's own copy..banner.warn strip renders on Brain (brain.html f26, context knowledge: "Reconnect Google to link existing documents") and in the wizard Personalize step (context wizard: "Reconnect Google to continue activation"). The wizard's artifact (assistant-flows) does not yet depict this state — flagged as mockup debt, not silently skipped. The old boxed card (rounded-xl amber glass + 40px AlertTriangle tile + outlined button) is RETIRED — this frame is the contract.IncrementalScopeModal (state #9 flow), requesting DRIVE_FILE_SCOPE. Correction (this pass, #2889): PRD §2.3.7 / REQ-C2 table say this modal's button keeps the verb "Grant access" — the shipped component (IncrementalScopeModal.tsx:190) actually renders "Allow access". Genuine spec/code drift, not a typo here: the mockup below draws the shipped text; the PRD row is stale and should be corrected to match code, not the reverse. Full sheet + hand-off sequence: 7c below.IncrementalScopeModal.tsx)We need one more permission from Google to enable this feature.
Permission needed
This permission will be added to your existing Google connection. You can revoke access anytime from your Connects page.
We need one more permission from Google to enable this feature.
Permission needed
This permission will be added to your existing Google connection. You can revoke access anytime from your Connects page.
IncrementalScopeModal.tsx (shipped, not previously drawn anywhere)
ConnectionStatusButton throughout this whole flow, unaffected by the modal or the redirect. · NEW (PRD debt) — sheet presentation, title Permission needed for {featureName}, one row per requested scope (this call site always exactly one: DRIVE_FILE_SCOPE). · NEW (PRD debt) — confirming state disables both actions and swaps the primary to a spinner + "Connecting…"; no PRD row specifies this UI, only the underlying OAuth mechanics (§7). · NEW (PRD debt) — "Allow access" is a full top-level redirect, never a popup; §7 step 2/3 confirm the mechanism, no AC covers the UI presentation of the hand-off. · NEW (PRD debt) — return path is a page reload via oauth-callback → /connects?status=success, not the component's own onGranted prop (never invoked inside IncrementalScopeModal's own success path — it exists on the interface but this call site's redirect bypasses it)..flow-card.sheet-card/.flow-head/.flow-body chrome already established in frame 3's Connect-flow sheet — never a new sheet shape for this frame.DriveFileScopeBanner.tsx's own string tables. Wizard context artifact remains mockup debt per 7b.sm 640, below it the picker is a sheet; the dialog stays centered to sm 640.1.4cqi clamp of the panel; nothing snaps between 375 and 1180 except the menu → sheet swap below sm 640.#4K2): gated on OPEN #2590 — the build ships the plain status chip until that discriminator is decided; the drawn chip is the target state, not current drift.presentation="dialog" content rendered as a bottom sheet — mobile is always a sheet (T1086). Buttons stack full-width; primary carries the amount (one-time purchase), or reads Top up first → (402) / Try again (409 · 500). Acceptance shared with the desktop notes — Backend reconcile (#5166) paragraphs there are the contract.We need one more permission from Google to enable this feature.
Permission needed
This permission will be added to your existing Google connection. You can revoke access anytime from your Connects page.
flex-col sm:flex-row). Close ✕ sits top-right of the title row (the shipped ModalWrapper band, simplified here to the title row — no separate sticky band drawn at this scale).requiredScopes=[DRIVE_FILE_SCOPE]) — the list is variable-length in the component (this is why it's a sheet, not a dialog) but every Drive-reconnect call site requests the single drive.file scope.We need one more permission from Google to enable this feature.
Permission needed
This permission will be added to your existing Google connection. You can revoke access anytime from your Connects page.
window.location.href), not a MystFlo
surface. Out of scope for this artifact.
handleRequestPermission calls setIsRequesting(true), then window.location.href = authorizationUrl in the same handler; no popup is ever attempted, no server round-trip is shown. Both buttons disable together; the primary swaps to the Shield glyph (drawn here with the shared .step-ic spinner idiom) + "Connecting…". Holds identically at every tier drawn in 7c — only the button layout (stacked vs row) differs across breakpoints, not this state.oauth-callback. That screen is Google's product surface, not MystFlo's; it isn't drawn here on purpose.oauth-callback redirects to /connects?status=success; the page remounts, useGoogleScopeStatus re-checks, finds drive.file present, and the banner unmounts. The Google row does not change — it was already emerald Connected before the reconnect (the missing scope only broke file-picker calls, never the row's own health signal, frame 7).#ff1 links do not 404.#f1: MystFlo AI is a built-in Connected row. Switch account is greyed: “MystFlo AI is built in — there's nothing to switch.”ai_mode / user_ai_providers. Not a frontend reopen.