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¶
- stringer-lifecycle § Onboarding — recap of M3 + M21, profile fields collected at first sign-in, single-page form, lazy self-Person creation.
- stringer-lifecycle § Onboarding acceptance criteria — A1–A9 (required fields, locale default, partial-save preservation, redirect-on-save).
- stringer-lifecycle § Hidden requirements items 1–2: single-page form, locale toggle.
- v2-scope M3 + M21 — magic-link registration; password set later.
- ADR-0006 — JWT lands in
HttpOnlycookie; the onboarding flow does not see the token.
Goal¶
Three commitments, in tension-resolution order:
- 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.
- 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.
- 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.
Stage A — Magic-link landing¶
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-Languageif EN or DE; else EN (A2). Selecting the other language re-renders the form labels (HTMXhx-get="/onboarding/profile?locale=de" hx-target="body") but preserves entered field values via server round-trip. - Required field validation.
display_nameanddefault_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-700text-small. Server-side validation only (no JS); HTMX swaps the form region on submit-failure. - Save behaviour.
- On submit: POST
/onboarding/profile. Server:- Persists the
Stringerrow updates. - Triggers Stage C (password modal) per Stage C.
- Persists the
- 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 withis_active = FALSEuntil the explicit "Save & continue" click flipsis_active = TRUE. (A8 is satisfied without a separate "draft" table; we rely on the existingStringerrow.) - 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 = TRUEand 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 thepassword-fieldsregion. - 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/passwordwhich calls gotrue's/auth/v1/userPATCH 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>carriesaria-required="true"; the asterisk is decorative. - Field hints are linked via
aria-describedbyfrom 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>postinglocaleas 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¶
- Source requirements: stringer-lifecycle (Iris); v2-scope M3 + M21; use-cases UC-5.
- Architecture: auth-and-tenancy; ADR-0006 (JWT-in-cookie session); ADR-0009 (RBO never sees password).
- Linked design surfaces: stringer-dashboard (the success-state landing); add-stringjob (the welcome-toast tap target); design-tokens.
- Future surfaces (out of Round 3 scope):
- Sign-in page (M21 password + magic-link login). Queued.
- Account-settings page — where the optional fields go to be filled later, where the password can be set / changed, where the logo is uploaded. Queued in index § Future rounds.
- Re-activation magic-link landing page for self-deactivated stringers per stringer-lifecycle § offboarding hidden requirement 4. Queued.
- i18n strategy: i18n architecture.
- Issue tracking: racket-book#109.
Open questions for Stefan (with proposed defaults)¶
-
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.
-
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.
-
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.
-
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.
-
Display-name length cap (Iris specifies 80). Confirmed default: 80 chars. From Iris's spec. Flagged here for visibility; nothing to flip.
-
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.
-
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.
-
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.
-
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. -
"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.
-
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.