Skip to content

Stringer Onboarding

The flow a newly-invited stringer goes through after admin (Stefan) issues them a magic-link invite, ending at a working session in the empty workspace. Owned by Mira. Cross-cuts: stringer-lifecycle (Iris's source requirement, M3 + M21), auth-and-tenancy, ADR-0006 (JWT-in-cookie session contract), design-tokens, stringer-dashboard (the success-state landing).

Source requirement

Goal

Three commitments, in tension-resolution order:

  1. The first-login form must feel one-screen, not a wizard tunnel. Iris's spec is explicit: single-page form. This page commits a single-page layout with progressive disclosure for the password-setup affordance, not a multi-step wizard.
  2. The form must work on a phone in transit (Stefan's reality: "I just got an invite at the club, let me set this up while my last racket cools"). Mobile-first; sticky-bottom CTA; the locale toggle is the first interactive element.
  3. The defer-able fields must visibly defer. Optional fields are clearly optional, with the "you can fill these later in account settings" affordance always visible — not a hidden footnote. This is what makes the form skippable per A3.

Flow overview

Email inbox                    Magic-link landing               First-login profile             Empty workspace
─────────────────────►─────────────────────────────►───────────────────────────►───────────────────────────►
[invite email]   click link     [verify token spinner]   →    [profile-fill form]  →   [dashboard]
                                       ↓ on fail                       ↓ on save                ↑ A6
                              [link-expired error]            [optional password modal]
                                                                       ↓ skip / set
                                                                  [success toast]

Three distinct surfaces:

  • Stage A — Magic-link landing. A short verification-spinner page. No user input. Either succeeds (→ Stage B) or fails (→ link-expired error).
  • Stage B — Profile fill. The single-page form per Iris's A1–A9.
  • Stage C — Optional password setup. A non-blocking modal after profile save, offering "I want to set a password now" per M21. Skippable.

The user lands on the dashboard (stringer-dashboard) once Stage B (and optionally Stage C) is complete.

Entry: the stringer clicks the link in the invite email. The URL carries the gotrue magic-link token as a query parameter (?token=...&type=invite); the RBO landing route hands it to gotrue for verification.

sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│                                       │
│            [racket-book]              │  Brand mark, top
│                                       │
│                                       │
│                                       │
│         Verifying your invite…        │  H2 — copy
│                                       │
│            [spinner 32 px]            │
│                                       │
│   This usually takes a moment.        │  text-small, slate-600
│                                       │
│                                       │
│                                       │
└───────────────────────────────────────┘

Behaviour

  • The page does not request user input. It POSTs the token to gotrue server-side (RBO middleware) on initial render; the spinner is a perceived-latency cushion, not a blocker.
  • On verify success: server sets the JWT cookie (per ADR-0006) and 303-redirects to the profile-fill route at /onboarding/profile. From the user's perspective, the spinner page resolves into Stage B in one navigation.
  • On verify failure — token expired / token already used / token malformed — the page renders the link-expired state inline (no redirect; same URL):
┌───────────────────────────────────────┐
│                                       │
│            [racket-book]              │
│                                       │
│      ⚠  This invite link expired      │  H1
│                                       │
│   Magic-links are one-time and        │  text-body
│   expire after 24 hours. Ask your     │
│   admin to send a fresh invite.       │
│                                       │
│   ┌─────────────────────────────────┐ │
│   │       Back to sign-in           │ │  Secondary CTA
│   └─────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

The "Back to sign-in" CTA navigates to /sign-in (the M21 sign-in page; out of round-3 scope).

Component breakdown

Component Notes
Brand mark text-h2 indigo-700; centered horizontally; ~96 px from top on sm. Re-used across all auth surfaces for consistency.
Verification spinner 32 × 32 px, indigo-700, infinite rotate. aria-busy="true" on the page region.
Status copy "Verifying your invite…" — text-h2 slate-900 text-center.
Reassurance line "This usually takes a moment." — text-small slate-600.
Error icon (failure state) lucide:circle-alert 24 px amber-700.
Error H1 "This invite link expired" — text-h1.
Error body text-body slate-600. Two sentences max.
Secondary CTA "Back to sign-in" — full-width on sm; bg-white border border-slate-300 text-slate-900.

Stage B — Profile fill

Per stringer-lifecycle § profile fields: single page; required fields first; optional fields below; defer-affordance visible.

sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│  [racket-book]              EN ▾  DE  │  Header — locale toggle (top right)
├───────────────────────────────────────┤
│                                       │
│  Welcome to racket-book.              │  H1
│  Tell us who you are.                 │  Subhead, slate-600
│                                       │
│  ─── Required ───                     │  Group label, text-small slate-500
│                                       │
│  Your display name *                  │  text-small slate-700
│ ┌───────────────────────────────────┐ │
│ │ Stefan Wagen                      │ │  Input
│ └───────────────────────────────────┘ │
│  Shown on every receipt.              │  text-tiny slate-500
│                                       │
│  Your language *                      │
│ ┌───────────────────────────────────┐ │
│ │ English                       ▾   │ │  Select (EN | DE)
│ └───────────────────────────────────┘ │
│  Used for the app and your receipts.  │  text-tiny slate-500
│                                       │
│  ─── Business identity (optional) ─── │  Group label
│  You can fill these later in          │  text-small slate-500
│  account settings.                    │
│                                       │
│  Business name                        │
│ ┌───────────────────────────────────┐ │
│ │ e.g. RacketLab                    │ │
│ └───────────────────────────────────┘ │
│  Shown on receipts under your name.   │  text-tiny slate-500
│                                       │
│  Phone                                │
│ ┌───────────────────────────────────┐ │
│ │ +41 …                             │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Business address                     │
│ ┌───────────────────────────────────┐ │
│ │ Bahnhofstrasse 1                  │ │  Multi-line textarea
│ │ 8001 Zürich                       │ │
│ │                                   │ │
│ └───────────────────────────────────┘ │
│  Your customers will see this on      │  text-tiny slate-500 — A4
│  every receipt.                       │
│                                       │
│  Logo                                 │
│ ┌───────────────────────────────────┐ │
│ │ You can add a logo later in       │ │  Static notice (no upload here) — A5
│ │ account settings.                 │ │
│ └───────────────────────────────────┘ │
│                                       │
├───────────────────────────────────────┤
│   ┌─────────────────────────────────┐ │
│   │           Save & continue       │ │  Sticky-bottom primary CTA
│   └─────────────────────────────────┘ │
│   Skipping optional fields is fine.   │  text-tiny slate-500 centered — A3
└───────────────────────────────────────┘

md 768 px

┌─────────────────────────────────────────────────────────────────┐
│  [racket-book]                                  EN ▾   DE       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│   Welcome to racket-book.                                       │
│   Tell us who you are.                                          │
│                                                                 │
│   ┌─────────────────────────────┐  ┌──────────────────────────┐ │
│   │  Required                   │  │  Business identity       │ │
│   │                             │  │  (optional)              │ │
│   │  Display name *             │  │                          │ │
│   │  [_______________________]  │  │  Business name           │ │
│   │  Shown on every receipt.    │  │  [____________________]  │ │
│   │                             │  │                          │ │
│   │  Language *                 │  │  Phone                   │ │
│   │  [English          ▾]       │  │  [____________________]  │ │
│   │  Used for app and receipts. │  │                          │ │
│   │                             │  │  Business address        │ │
│   │                             │  │  [____________________]  │ │
│   │                             │  │  [____________________]  │ │
│   │                             │  │  Customers will see this │ │
│   │                             │  │  on every receipt.       │ │
│   │                             │  │                          │ │
│   │                             │  │  Logo                    │ │
│   │                             │  │  Add later in settings.  │ │
│   └─────────────────────────────┘  └──────────────────────────┘ │
│                                                                 │
│                              ┌─────────────────────────────────┐│
│                              │       Save & continue           ││
│                              └─────────────────────────────────┘│
│                              Skipping optional fields is fine.  │
└─────────────────────────────────────────────────────────────────┘

Two columns at md+: required on the left, optional on the right. The columns make the optionality visible at a glance.

Behaviour

  • Locale toggle (top right). Per stringer-lifecycle § hidden requirement 2: the locale toggle lets the stringer switch the form itself between EN and DE before completing it. Default is browser Accept-Language if EN or DE; else EN (A2). Selecting the other language re-renders the form labels (HTMX hx-get="/onboarding/profile?locale=de" hx-target="body") but preserves entered field values via server round-trip.
  • Required field validation. display_name and default_locale (A1). Validation:
  • display_name: non-empty; ≤ 80 chars (per Iris's spec).
  • default_locale: enum (EN | DE). Pre-selected per browser; the user can change it.
  • Inline error display: per-field error messages render below the input in red-700 text-small. Server-side validation only (no JS); HTMX swaps the form region on submit-failure.
  • Save behaviour.
  • On submit: POST /onboarding/profile. Server:
    1. Persists the Stringer row updates.
    2. Triggers Stage C (password modal) per Stage C.
  • Partial-save preservation (A8). If the stringer refreshes mid-fill, the server does persist the entered fields on every input blur via HTMX hx-patch="/onboarding/profile/draft" hx-trigger="blur changed". Re-rendering the form re-hydrates from the persisted draft. This is a soft commit: the row is created with is_active = FALSE until the explicit "Save & continue" click flips is_active = TRUE. (A8 is satisfied without a separate "draft" table; we rely on the existing Stringer row.)
  • Skip-optional flow. A user who fills only display_name + locale and clicks "Save & continue" passes validation. The hint "Skipping optional fields is fine" reassures.
  • Server response on success: 303-redirect to /onboarding/password-setup (Stage C). The stringer never sees a "saved" toast on this page; the success surface is Stage C's modal.

Component breakdown

Component Notes
Locale toggle Two-pill switch, EN / DE, in slate-100 background; selected pill has bg-indigo-700 text-white. Toggling is a full GET (HTMX swap of <body>) — preserves entered values.
Group label text-small slate-500 uppercase tracking-wide; horizontal rules on either side render as border-t border-slate-200 flexbox-aligned with the label centered.
Field label text-small slate-700 above input. Asterisk for required.
Input text-body, bg-white border border-slate-300 rounded-lg px-3 py-2. Focus ring ring-2 ring-indigo-700. Min height 44 px.
Textarea Same as input; min-h-24 (96 px) for business_address.
Hint text text-tiny slate-500 below the input.
Logo notice (A5) bg-slate-50 border border-slate-200 rounded-lg p-3 text-small slate-600. No upload affordance.
Sticky-bottom CTA Full-width primary; bg-indigo-700 text-white text-body font-medium rounded-lg py-3. Sticky on sm (fixed bottom-0 inset-x-0). At md+ it sits in the form footer.
Skip-OK reassurance text-tiny slate-500 text-center mt-2.

Validation rules (UI surface; canonical server-side per A1)

Rule Inline message
display_name empty "Enter your display name."
display_name > 80 chars "Display name is too long (max 80)."
default_locale empty (impossible from the select; defensive) "Pick a language."
Phone invalid format (V2: lenient; only flag if non-empty AND obviously bogus, e.g. fewer than 4 digits) "That phone number looks short. You can add it later in settings." (Permissive; doesn't block save.)
Business address > 500 chars "Address is too long (max 500)."

Permissive validation is intentional: the form is the gate to the workspace; we do not want the user stuck on a phone-number regex.

Stage C — Optional password setup

Per M21: magic-link first; password thereafter. The onboarding flow surfaces password-setup as a non-blocking step after the profile saves — the user is technically already signed-in via the JWT cookie and could close the tab right now and return via magic-link forever. The modal is a one-time prompt to opt into the password path.

sm 375 px

┌───────────────────────────────────────┐
│  [dashboard preview, dimmed bg]       │  Background — actual dashboard rendered
│                                       │  behind the modal
│   ┌───────────────────────────────┐   │
│   │  ✓ Profile saved              │   │  Toast (5s, dismissible)
│   └───────────────────────────────┘   │
│                                       │
│   ╔═══════════════════════════════╗   │  Modal
│   ║                               ║   │
│   ║  One last thing               ║   │  H2
│   ║                               ║   │
│   ║  You can sign in with magic-  ║   │  text-body
│   ║  link emails any time. Want   ║   │
│   ║  to set a password too?       ║   │
│   ║                               ║   │
│   ║  ☐ I want to use a password   ║   │  Checkbox — default UNCHECKED
│   ║                               ║   │
│   ║  [password fields appear      ║   │  Conditional reveal on check
│   ║   below when checked]         ║   │
│   ║                               ║   │
│   ║  ┌───────────────────────────┐║   │
│   ║  │   Continue to dashboard   │║   │  Primary CTA
│   ║  └───────────────────────────┘║   │
│   ║                               ║   │
│   ║  Skip — set a password later  ║   │  Secondary text-link
│   ║                               ║   │
│   ╚═══════════════════════════════╝   │
└───────────────────────────────────────┘

Conditional-expanded state (checkbox checked)

   ╔═══════════════════════════════╗
   ║  One last thing               ║
   ║                               ║
   ║  ☑ I want to use a password   ║
   ║                               ║
   ║  Password                     ║
   ║  [______________________]     ║
   ║  At least 12 characters.      ║
   ║                               ║
   ║  Confirm password             ║
   ║  [______________________]     ║
   ║                               ║
   ║  ┌───────────────────────────┐║
   ║  │  Save password & continue │║
   ║  └───────────────────────────┘║
   ║                               ║
   ║  Skip — set a password later  ║
   ║                               ║
   ╚═══════════════════════════════╝

Behaviour

  • Default state: checkbox unchecked. The user can dismiss the modal with one click ("Continue to dashboard" or "Skip — set a password later"); both paths land on the dashboard with Stringer.is_active = TRUE and no password set.
  • Explicit checkbox required (per Round-3 scope: "explicit 'I want to use a password' checkbox"). This is the M21 dual-method opt-in moment.
  • Password fields appear conditionally when the checkbox is checked. HTMX hx-trigger="change" swaps the password-fields region.
  • Validation: server-side per gotrue's policy (auth-and-tenancy — gotrue owns the password-rule space). UI floors to 12-character minimum and matched-confirmation; gotrue may add complexity rules. UI displays gotrue's error response inline if the password fails server-side policy.
  • Save behaviour: when checkbox is checked + password valid, "Save password & continue" POSTs /onboarding/password which calls gotrue's /auth/v1/user PATCH endpoint with the new password. On success, modal closes, dashboard renders, toast: "Password saved. You can sign in with email + password from now on."
  • "Continue to dashboard" (unchecked-state primary): closes the modal, redirects to dashboard.
  • "Skip — set a password later" (text-link, both states): closes the modal, redirects to dashboard. (Functionally identical to "Continue to dashboard" in the unchecked state; we offer both labels because the unchecked-state primary reads as "continue without a password" while the link reads as "I'll do this later" — different mental models, same behaviour. The link is also visible after the user has checked the box and changed their mind.)
  • One-time prompt: the modal does NOT re-appear on subsequent sign-ins. After the dashboard first renders, "set a password" is a button on the account-settings page (out of Round-3 scope; queued for the account-settings spec).

Component breakdown

Component Notes
Toast (success) bg-green-50 border border-green-200 text-green-800 rounded-lg px-4 py-3; dismisses after 5 seconds. Lives in the page-toast region per shared design tokens.
Modal backdrop bg-slate-900/40 semi-transparent; clicking outside the modal does NOT dismiss (the user MUST explicitly choose continue/skip).
Modal panel bg-white rounded-xl shadow-xl p-6 max-w-md mx-auto. On sm the panel is full-width minus 16 px gutter.
Modal H2 "One last thing" — text-h2.
Body copy text-body slate-700.
Checkbox 20 × 20 px; label "I want to use a password" inline. Hit target 44 × 44 px.
Password fields (conditional) text-body; inputs type="password". Confirm-password field validates equality on blur.
Hint "At least 12 characters" text-tiny slate-500.
Primary CTA "Continue to dashboard" (unchecked) or "Save password & continue" (checked). bg-indigo-700.
Secondary text-link "Skip — set a password later" — text-small text-indigo-700 underline. Always visible.

Success state — landing on the dashboard

After Stage C completes (with or without password), the user lands on the dashboard (stringer-dashboard). The dashboard renders its empty state (no orders yet — A6 / UC-5).

A welcome toast fires once: "Welcome, {display_name}. Add your first stringjob to get started." with a tap-affordance jumping to the Add-Stringjob flow. The toast is dismissable; it does NOT re-fire on subsequent dashboard loads.

Interaction states

State What renders
Stage A — verify in flight Spinner + status copy. aria-busy=true.
Stage A — verify success 303 → Stage B; user perceives one navigation.
Stage A — verify failure Inline error panel + "Back to sign-in" CTA.
Stage B — initial render Form pre-filled with browser-locale default + the email gotrue verified (read-only, displayed as "Signed in as: stefan@…" at the top of the form for orientation).
Stage B — locale toggle HTMX hx-get swaps body; entered field values preserved server-side.
Stage B — partial-save (autosave on blur) Each blur triggers hx-patch /onboarding/profile/draft; no visible feedback (silent autosave per A8).
Stage B — submit-validation error Inline text-small red-700 per affected field; the form retains all entered values.
Stage B — submit success 303 → Stage C with success-toast banner.
Stage C — modal initial Checkbox unchecked; password fields not rendered.
Stage C — checkbox checked HTMX swap reveals password fields; primary CTA label changes to "Save password & continue".
Stage C — password validation error Inline red-700; the modal does not close.
Stage C — password save success Modal closes; dashboard renders; toast confirms.
Stage C — skip Modal closes; dashboard renders; no password-related toast.

Accessibility

  • Stage A spinner has role="status" aria-live="polite" and reads "Verifying your invite…" to screen readers.
  • Stage A error uses role="alert" so the failure announces.
  • Stage B locale toggle is a <fieldset> with two radio inputs styled as pills; screen readers announce "Language, English / German" and the change is announced on selection.
  • Required-field markers ("*"): <label> carries aria-required="true"; the asterisk is decorative.
  • Field hints are linked via aria-describedby from the input to the hint text.
  • Stage C modal has role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to the H2 on open; ESC dismisses (equivalent to "Skip"); focus trap inside the modal until close.
  • Hit targets ≥ 44 × 44 px on every interactive element (locale toggle pills, checkbox, buttons, inputs).
  • Color contrast: indigo-700 on white = 8.59:1; slate-900 on white = 16.1:1; red-700 on white (errors) = 5.94:1. All AA-compliant per design-tokens.
  • Form-on-refresh preservation (A8) is also an accessibility win: a screen-reader user who lost focus does not lose context.
  • Without JS: the locale toggle works as a regular <form method="get"> (full-page reload re-renders with the new locale). The autosave-on-blur is HTMX-only; without JS, the form values persist only on the explicit submit — the user simply can't refresh mid-fill, but A8 degrades gracefully because re-rendering after a non-validation refresh is rare. The Stage C modal renders as a full page (/onboarding/password-setup) without JS; the conditional password-fields reveal becomes a server-side branch on the checkbox value.

HTMX / progressive-enhancement seams

  • Stage A is fully server-side; no HTMX. The token verification is a server-side concern; the spinner is a perceived-latency illusion.
  • Stage B locale toggle: hx-get="/onboarding/profile?locale=de" hx-target="body" hx-push-url="true". Without JS: regular <a href="/onboarding/profile?locale=de">DE</a> link with the <form> posting locale as a hidden field on save.
  • Stage B autosave on blur: hx-patch="/onboarding/profile/draft" hx-trigger="blur changed" hx-include="closest input". Without JS: no autosave; the user must complete the form in one session (degraded UX, not a blocker).
  • Stage B form submit: regular <form method="post" action="/onboarding/profile">. HTMX optionally swaps the form region on validation error: hx-post="/onboarding/profile" hx-target="#profile-form" hx-swap="outerHTML". The 303-redirect on success works identically with or without HTMX.
  • Stage C checkbox reveal: hx-get="/onboarding/password-setup?want_password=true" hx-target="#password-fields" hx-swap="innerHTML". Without JS: a "Set a password" submit button on the modal posts the checkbox state and re-renders the modal with the password fields rendered; one extra round-trip but functionally identical.
  • Stage C save: regular <form method="post" action="/onboarding/password">. The gotrue call happens server-side.

The whole flow's fundamental contract is "regular form POSTs". HTMX swaps are optimisations.

i18n affordance

String Type Catalogue key
"Verifying your invite…" {% trans %} onboarding.verify.title
"This usually takes a moment." {% trans %} onboarding.verify.body
"This invite link expired" {% trans %} onboarding.verify.error.title
"Magic-links are one-time and expire after 24 hours. Ask your admin to send a fresh invite." {% trans %} onboarding.verify.error.body
"Back to sign-in" {% trans %} onboarding.verify.error.cta
"Welcome to racket-book." / "Tell us who you are." {% trans %} onboarding.profile.title / onboarding.profile.subhead
"Required" / "Business identity (optional)" {% trans %} onboarding.profile.group.{required,optional}
"You can fill these later in account settings." {% trans %} onboarding.profile.group.optional.hint
"Your display name" / "Shown on every receipt." {% trans %} onboarding.profile.field.display_name.{label,hint}
"Your language" / "Used for the app and your receipts." {% trans %} onboarding.profile.field.locale.{label,hint}
"Business name" / "Shown on receipts under your name." {% trans %} onboarding.profile.field.business_name.{label,hint}
"Phone" {% trans %} onboarding.profile.field.phone.label
"Business address" / "Your customers will see this on every receipt." {% trans %} onboarding.profile.field.business_address.{label,hint} — hint text is load-bearing per A4; verbatim.
"Logo" / "You can add a logo later in account settings." {% trans %} onboarding.profile.field.logo.{label,notice} — verbatim per A5.
"Save & continue" {% trans %} onboarding.profile.cta.save
"Skipping optional fields is fine." {% trans %} onboarding.profile.skip_ok
Validation messages ("Enter your display name." etc.) {% trans %} onboarding.profile.validation.{display_name_empty,display_name_long,locale_empty,phone_short,address_long}
"One last thing" {% trans %} onboarding.password.title
"You can sign in with magic-link emails any time. Want to set a password too?" {% trans %} onboarding.password.body
"I want to use a password" {% trans %} onboarding.password.opt_in
"Password" / "Confirm password" / "At least 12 characters." {% trans %} onboarding.password.field.{password,confirm,hint}
"Continue to dashboard" / "Save password & continue" / "Skip — set a password later" {% trans %} onboarding.password.cta.{continue,save,skip}
"Welcome, {display_name}. Add your first stringjob to get started." {% trans %} (Babel) onboarding.success.toast
Email address (Stefan's actual address shown in "Signed in as:") Data n/a

Iris's DE pass follows after merge; Mira commits the catalogue keys + EN values.

DE width budget (designer note)

DE labels here are typically 20–35% longer than EN. Specific watch-outs:

  • "Welcome to racket-book." → "Willkommen bei racket-book." (similar).
  • "Tell us who you are." → "Erzähl uns wer du bist." (~1.1×).
  • "Your display name" → "Dein Anzeigename" (~1.1×).
  • "Business identity (optional)" → "Geschäftsidentität (optional)" (~1.3×) — fits the group-label width.
  • "Shown on every receipt." → "Wird auf jeder Quittung angezeigt." (~1.3×) — fits one line on sm.
  • "You can add a logo later in account settings." → "Du kannst dein Logo später in den Kontoeinstellungen hinzufügen." (~1.4×) — wraps to two lines on sm; reserve the height.
  • "Save & continue" → "Speichern & fortfahren" (~1.4×) — sticky CTA fits within full-width minus 16 px padding.
  • "Continue to dashboard" → "Weiter zum Dashboard" (~1.0×) — fits.
  • "Save password & continue" → "Passwort speichern & fortfahren" (~1.5×) — within the modal max-width.
  • "Skip — set a password later" → "Überspringen — Passwort später setzen" (~1.4×) — fits as a text-link.

Cross-references

Open questions for Stefan (with proposed defaults)

  1. Should the welcome-back toast on dashboard land fire only the first time, or every time the dashboard loads after onboarding within the first 24h? Proposed default: fire once, then never. Aligned with first-run-experience norms; a recurring "welcome" feels noisy. Alternative: fire on every dashboard load until the stringer has created their first order ("Add your first stringjob to get started" disappears once they've done it). Stefan to confirm the once-only stance.

  2. Locale toggle placement — top-right vs. inline above the form. Proposed default: top-right of the page header. Persistent affordance; matches the in-app locale switcher we'll likely build for account-settings. Alternative: a single-row pill above the H1 (more discoverable but eats vertical space). Stefan to confirm the header placement.

  3. Magic-link landing — show the email address gotrue verified, or stay anonymous "Verifying your invite"? Proposed default: anonymous on the verify page; show "Signed in as: {email}" on the profile-fill page. The verify page is in flight before we trust the token; the profile-fill page is post-trust. Alternative: show the email on the verify page too (slight surveillance vibe; reveals nothing the user doesn't already know but feels presumptuous). Mira leans anonymous-then-explicit.

  4. Phone field validation strictness. Proposed default: lenient — only flag obviously bogus values (< 4 digits) AND don't block save. Stefan flagged elsewhere: a phone regex on a phone form is a way to enrage users. Alternative: stricter (E.164 normalisation client-side; reject malformed). Lenient matches the "skipping is fine" stance. Stefan to confirm.

  5. Display-name length cap (Iris specifies 80). Confirmed default: 80 chars. From Iris's spec. Flagged here for visibility; nothing to flip.

  6. Should the autosave-on-blur trigger render any visible affordance ("Saved 09:42")? Proposed default: no — silent autosave. The user has not yet committed the form (the explicit "Save & continue" is the commitment). Showing "Saved" before the user clicked save is misleading. Alternative: a small subtle "draft saved" pill in the form footer. Mira leans silent.

  7. Stage C modal — close-on-backdrop-click behaviour. Proposed default: backdrop click does NOT dismiss the modal. The user must explicitly choose Continue / Save / Skip — the password decision is meaningful enough to warrant explicit input. Alternative: backdrop dismisses (treated as Skip). Mira leans explicit-choice.

  8. Password minimum length (UI floor, server may impose more). Proposed default: 12 characters. Strong enough to be respectable; not so long as to scare the user. gotrue's policy is the canonical floor; 12 is the UI's pre-validation. Alternative: 8 (industry-min) or 16 (security-leaning). Stefan to confirm; defer to gotrue's actual default if it's enforced upstream.

  9. Welcome-toast tap-affordance to Add Stringjob — does Stefan want the toast to be tappable, or is it informational only? Proposed default: tappable — tapping the toast deep-links to /orders/new. Reduces the "I clicked through onboarding, now what?" gap. Alternative: informational toast only; user discovers the "+ Add" CTA on the dashboard (which is already prominent per stringer-dashboard). Mira leans tappable; Stefan to confirm whether the dashboard's existing CTA is enough.

  10. "Signed in as: {email}" header on the profile-fill page — does Stefan want a "wrong account?" / sign-out link there? Proposed default: no sign-out link in V2. The stringer just clicked through a magic-link; if it's the wrong account, they'd close the tab and ask Stefan to re-invite. Adding a sign-out link here is friction for the 99% case. Alternative: include a small "Not you? Sign out →" link. Stefan to confirm whether the simplification is worth the rare wrong-account case.

  11. Empty workspace message on first dashboard render — Mira leans on the existing dashboard's empty state. No new design surface here; flagged for cross-link.