Settings (V2) — /settings¶
The single stringer-scoped settings surface. One page, sectioned, mobile-first. Owned by Mira. Cross-cuts: V2 scope M3 + M19 + M21, stringer-lifecycle § profile fields, receipt-content § stringer business-identity fields, stringer-onboarding (the form that defers everything optional to here), share-management § notifications, client-management-v2 (the partner spec — client-specific overrides live there, not here).
Source requirement¶
- V2 Settings epic — racket-book#140 — single
/settingspage. Stefan flagged three known examples (change password, UI language, default order price); the epic is explicit that those are NOT exhaustive and Mira must sweep comprehensively. - stringer-onboarding § Stage B — every optional onboarding field carries the line "you can fill these later in account settings." This page is the "later."
- receipt-content § stringer business-identity fields —
display_name,business_name,phone,business_address,logo,receipt_thankyou_text,default_locale. Logo is account-settings-only per OQ-R-5 default; receipt-thankyou is per-stringer customizable per OQ-R-3. - stringer-lifecycle § Offboarding — self-deactivate is a stringer-initiated affordance; this page hosts it.
- V2 scope M21 — magic-link first; password thereafter. The post-onboarding "set / change password" surface lands here.
- v2-scope M19 + i18n architecture — saved-pref-wins for locale; the toggle that flips
Stringer.default_localelives here. - v2-scope M20 — per-stringer self-service export (XLSX + JSON); the "download my data" trigger lands here.
- Round 6 auth surfaces — keystone#127 password policy — 12-char min + lower/upper/digit/symbol classes (already enforced at the password endpoint; the UI surface adopts the same gate).
Goal¶
Three commitments, in tension-resolution order:
- One page, all knobs. Stefan lands at
/settingsand finds every stringer-scoped configuration in one scroll. No nested settings menus, no "advanced" mystery panel. The seven sections below are the only sections; new V2 settings go into one of them, not into a fresh page. - Required vs optional is visually obvious. The two required fields (display name + locale) are at the top with no skip affordance; everything else has clear "optional / change anytime" framing. This mirrors stringer-onboarding's Required-vs-Business-identity split and continues the same posture into the post-onboarding surface.
- Client-specific overrides do NOT live here. Per epic #140's explicit non-goal: per-client price, per-client notification prefs, per-client sign-off override (V3) all belong on the client-detail page (client-management-v2). This page is stringer-default settings only.
Note on the 24 kg default tension — per Stefan's 2026-05-09 lock, default tension is not a stringer setting. The 24 kg prefill that add-stringjob uses when creating an order for a brand-new client (no prior order to copy from) lives in the form's prefill logic as a code-level constant. For returning clients, the form copies the tension from the most recent order, as it does today. There is no Stringer.default_tension column.
Comprehensive setting sweep¶
The full audit of stringer-scoped configuration in V2. Source columns from app/db/models/identity.py (the Stringer model) plus the schema-hook and design-spec sweep flagged in epic #140. Each row maps to a section below.
| # | Setting | Schema | Default | Source | V2 UI? |
|---|---|---|---|---|---|
| Identity | |||||
| 1 | Email (read-only) | Stringer.email |
n/a | identity.py L126 + receipt-content F-4 | Yes (read-only) |
| 2 | Display name | Stringer.display_name (≤80) |
required at onboarding | identity.py L149 + receipt-content TL-2 | Yes (editable) |
| 3 | UI / receipt language | Stringer.default_locale enum (en|de) |
en (or browser-lang at onboarding) |
identity.py L155 + i18n architecture | Yes (toggle) |
| Business identity (receipt) | |||||
| 4 | Business name | Stringer.business_name (≤120) |
NULL | identity.py L167 + receipt-content TL-3 | Yes |
| 5 | Phone | Stringer.phone (≤40) |
NULL | identity.py L173 + receipt-content F-3 | Yes |
| 6 | Business address | Stringer.business_address (text) |
NULL | identity.py L179 + receipt-content F-2 | Yes |
| 7 | Logo | Stringer.logo (storage handle) |
NULL | identity.py L186 + receipt-content TL-1 + OQ-R-5 default | Yes (upload) |
| 8 | Receipt thank-you text | Stringer.receipt_thankyou_text (text) |
NULL → platform i18n fallback | identity.py L194 + receipt-content BR-10 + OQ-R-3 | Yes |
| Stringjob defaults | |||||
| 9 | Default labor (CHF) | NEW column Stringer.default_labor_chf numeric(8,2) |
25.00 | add-stringjob.md OQ #2 + V1 baseline | Yes |
| 10 | Default order price hint (CHF) | (covered by #9 — labor IS the default order price) | 25.00 | epic #140 example #3 | Same as #9 |
| Auth | |||||
| 12 | Password — set / change | gotrue (no RBO column) | unset (magic-link-only until set) | M21 + Round 6 (#127, #131-#136) + keystone#125 policy | Yes |
| 13 | Sign-in methods overview | derived (gotrue user has password? has magic-link?) | both available after Stage C | M21 + onboarding Stage C | Yes (read-only summary) |
| Notifications (sender side) | |||||
| 14 | Receive email when a Rule #1 share is granted to me | Person.notification_prefs JSONB key share_granted_to_me |
false (V2 default per share-management.md L464) |
share-management.md § Notifications | Yes |
| 15 | Receive email when a grantee accesses a job I shared | Person.notification_prefs JSONB key share_accessed_by_grantee |
false (noisy; opt-in) |
share-management.md L445 | Yes |
| Data export & account closure | |||||
| 16 | Download all my data (XLSX + JSON) | M20 endpoint | n/a | v2-scope M20 | Yes (action button) |
| 17 | Deactivate my account | Stringer.deactivated_at |
NULL | stringer-lifecycle § self-deactivation B1 | Yes (destructive action) |
| V3 hooks (column ships, NO V2 UI) | |||||
| H1 | Stringer.notification_template |
text, NULL | NULL | issue #42 — "Do it empty." Stefan, 2026-05-01 | No — V3 surface |
| H2 | Stringer.signoff_default |
enum require|skip | require |
v3-vision § sign-off + issue #51 | No — V3 surface |
Why some columns ship without UI: issues #42 (notification_template) and the V3 sign-off fields land in V2 migrations specifically so V3 is data-only, not schema-change (per v3-vision § V2 hooks to consider landing now). They are listed in the table for completeness; the UI sections below do not render them.
Why one new column is proposed (row #9): the add-stringjob spec flags "default labor 25 CHF" as a Stefan-personal default, configurable per stringer in a future round (OQ #2). Epic #140 is that round. Without this column, every newly-onboarded stringer inherits Stefan's 25 CHF baseline forever — fine for a single-stringer V1, wrong for the multi-tenant V2.
Locked decision (Stefan, 2026-05-09):
Stringer.default_labor_chflands in V2. Default tension is NOT a stringer setting — Stefan: "default tension does not make any sense, this is not a stringer default. Drop completely." The 24 kg prefill that add-stringjob uses for brand-new clients (no prior order to copy from) becomes a code-level constant, not aStringercolumn.
Page shape¶
Single /settings page; seven sections in a fixed vertical order. Mobile-first one-column scroll on sm (375 px); two-column lg-fallback for desktop stringers. No tab bars, no left-nav settings menu — Stefan asked for one page.
The order is deliberate: identity at the top (most-edited), then business identity (receipt-relevant), then defaults (stringjob-relevant), then auth (occasional), then notifications (rarely flipped), then data + danger zone (almost-never).
1. Account (email read-only, display_name, language)
2. Business identity (business name, phone, address, logo, thank-you text)
3. Stringjob defaults (default labor / order price)
4. Sign-in (password — set / change; magic-link always available)
5. Notifications (share-related opt-ins)
6. Your data (download XLSX + JSON)
7. Account closure (self-deactivate)
Viewport sm 375 px (mobile-first)¶
┌───────────────────────────────────────┐
│ ← Settings │ Header: back to dashboard
├───────────────────────────────────────┤
│ │
│ ─── Account ─── │ Group label
│ │
│ Email │
│ ┌───────────────────────────────────┐ │
│ │ stefan.wagen@gmail.com (locked) │ │ Read-only field
│ └───────────────────────────────────┘ │
│ Identifies your account. Contact │ Hint, slate-500
│ admin to change. │
│ │
│ Display name * │
│ ┌───────────────────────────────────┐ │
│ │ Stefan Wagen │ │
│ └───────────────────────────────────┘ │
│ Shown on every receipt. │ Hint
│ │
│ Language * │
│ ┌───────────────────────────────────┐ │
│ │ ◉ English ◯ Deutsch │ │ Two-pill toggle
│ └───────────────────────────────────┘ │
│ Used for the app and your receipts. │
│ │
│ [ Save account changes ] │ Per-section save
│ │
│ ─── Business identity ─── │ Group label
│ Optional. Shown on your receipts. │ Group hint
│ │
│ Business name │
│ ┌───────────────────────────────────┐ │
│ │ RacketLab │ │
│ └───────────────────────────────────┘ │
│ │
│ Phone │
│ ┌───────────────────────────────────┐ │
│ │ +41 79 ... │ │
│ └───────────────────────────────────┘ │
│ │
│ Business address │
│ ┌───────────────────────────────────┐ │
│ │ Bahnhofstrasse 1 │ │ Multi-line textarea
│ │ 8001 Zürich │ │
│ └───────────────────────────────────┘ │
│ │
│ Logo │
│ ┌───────────────────────────────────┐ │
│ │ [logo preview thumbnail] │ │
│ │ Upload new ▸ · Remove │ │
│ └───────────────────────────────────┘ │
│ PNG or JPG, max 1 MB. Square crop │
│ recommended (renders in a 28×28 mm │
│ receipt slot). │
│ │
│ Receipt thank-you text │
│ ┌───────────────────────────────────┐ │
│ │ Vielen Dank — see you on court! │ │ Free-text textarea
│ └───────────────────────────────────┘ │
│ Optional. Replaces the default │
│ "Thank you for your business." line │
│ on every receipt. │
│ │
│ [ Save business identity ] │
│ │
│ ─── Stringjob defaults ─── │
│ Used to prefill new orders. │
│ │
│ Default labor / order price (CHF) │
│ ┌───────────────────────────────────┐ │
│ │ CHF 25.00 │ │
│ └───────────────────────────────────┘ │
│ │
│ [ Save defaults ] │
│ │
│ ─── Sign-in ─── │
│ │
│ Magic-link Always on ✓ │ Status row
│ Sign in via emailed link any time. │
│ │
│ Password Set ▸ │ Status row + CTA
│ Add a password to sign in without │
│ email. │
│ │
│ ─── Notifications ─── │
│ Optional emails about share activity.│
│ │
│ ☐ Tell me when another stringer │
│ shares a job with me. │
│ │
│ ☐ Tell me when someone I shared a │
│ job with views it. │
│ │
│ [ Save notifications ] │
│ │
│ ─── Your data ─── │
│ │
│ Download all your data │
│ ┌───────────────────────────────────┐ │
│ │ Download XLSX + JSON ▾ │ │
│ └───────────────────────────────────┘ │
│ Includes every order, client, racket │
│ and receipt you own. Generated on │
│ request; large workspaces may take a │
│ minute. │
│ │
│ ─── Account closure ─── │
│ │
│ Deactivate my account │
│ ┌───────────────────────────────────┐ │
│ │ Deactivate… │ │ Destructive action
│ └───────────────────────────────────┘ │
│ Locks your account immediately. Your │
│ data is retained for 90 days; admin │
│ may permanently remove it after that.│
│ │
└───────────────────────────────────────┘
The page is naturally tall (~7 sections × ~140 px on sm ≈ 1000 px). That's fine — settings is a low-frequency surface; tap-then-scroll is acceptable. Per-section "Save" buttons mean a stringer changing one field doesn't accidentally commit others.
Viewport md 768 px¶
Same single-column layout at 720 px max width, centered. Sections gain space-y-10 between them (vs space-y-8 on sm). Otherwise identical to sm.
Viewport lg 1280 px¶
Two-column with a sticky left-rail anchor list (jump-to-section). Right pane is the same scroll content as sm / md, max-width 720 px.
┌───────────────────────────────────────────────────────────────────┐
│ ← Settings │
├──────────────────────┬────────────────────────────────────────────┤
│ │ │
│ Settings │ ─── Account ─── │
│ │ │
│ ▸ Account │ Email │
│ Business identity │ [stefan.wagen@gmail.com (locked)] │
│ Stringjob default │ Identifies your account. Contact admin │
│ Sign-in │ to change. │
│ Notifications │ │
│ Your data │ Display name * │
│ Account closure │ [Stefan Wagen ] │
│ │ Shown on every receipt. │
│ │ │
│ │ Language * │
│ │ [◉ English ◯ Deutsch] │
│ │ │
│ │ [ Save account changes ] │
│ │ │
│ │ ─── Business identity ─── │
│ │ ⋯ │
│ │ │
└──────────────────────┴────────────────────────────────────────────┘
The left-rail anchor list highlights the current section as the user scrolls (IntersectionObserver, progressive enhancement; <a href="#account"> falls back without JS).
Section-by-section component breakdown¶
1. Account¶
| Component | Notes |
|---|---|
| Email (read-only) | Plain <input disabled> styled like the locked-onboarding-email pattern; bg-slate-50 border-slate-200 text-slate-700. Hint: "Identifies your account. Contact admin to change." |
| Display name | <input type="text" maxlength="80" required> — same shape as the onboarding field. Validation: non-empty, ≤80 chars (identity.py L150). |
| Language toggle | Two-pill switch EN / DE, identical to the onboarding-form locale toggle. Selecting flips Stringer.default_locale on save. Page strings re-render to the new locale on save (full page reload); the user sees the chosen language take effect immediately. |
| Save button | bg-indigo-700 text-white text-body font-medium rounded-lg py-3 px-6 — full-width on sm, inline on md+. Saves only the Account section's fields. |
Why is email read-only? Per Stringer.email UNIQUE + the gotrue identity tie-in (gotrue_user_id), changing email is an admin operation: it touches both Stringer.email and the gotrue user record, and re-verification flow is non-trivial. V2 punts: contact admin. The hint says so.
2. Business identity¶
All fields optional. The group hint reads: "Optional. Shown on your receipts."
| Component | Notes |
|---|---|
| Business name | <input type="text" maxlength="120">. Hint: "Trading name when different from your display name." Renders below display_name on the receipt (TL-3). |
| Phone | <input type="tel" maxlength="40">. Lenient validation per onboarding (only flag <4 digits as warning; doesn't block save). E.164 normalisation deferred per identity.py L177. |
| Business address | <textarea rows="3" maxlength="500">. Multi-line. Hint: "Your customers will see this on every receipt." (verbatim from onboarding A4.) |
| Logo | Composite component: thumbnail preview (slate-50 box, 96×96 px) + "Upload new" button + "Remove" link. Upload affordance: <input type="file" accept="image/png,image/jpeg"> triggered by the button. Max 1 MB (UI floor; server enforces canonical limit). Hint: "PNG or JPG, max 1 MB. Square crop recommended (renders in a 28×28 mm receipt slot)." Per receipt-pdf § no-logo case, removing the logo gracefully falls back to the wordmark on the receipt. |
| Receipt thank-you text | <textarea rows="2" maxlength="200">. Hint: "Optional. Replaces the default 'Thank you for your business.' line on every receipt." Per receipt-content BR-10, locale-neutral free text; falls back to receipt.thankyou.default i18n string when NULL. |
| Save button | Same shape as Account. |
Receipt re-emit posture: editing any business-identity field changes every receipt. Per receipt-content § re-emit triggers, V2 does NOT auto-bulk-re-emit on these edits — only on per-order edits. A "regenerate affected receipts" button is a V2.x polish; not in this spec. The save toast notes: "Saved. New receipts will use the updated business identity; existing receipts are unchanged."
3. Stringjob defaults¶
The single new column (#9 in the sweep table). Used to prefill the labor / order-price field on every new order via the add-stringjob form.
| Component | Notes |
|---|---|
| Default labor / order price | <input inputmode="decimal"> with CHF prefix. Default 25.00. Hint: "Used as both the labor line and the new-client order-total starting point. You can always edit per-order." |
| Save button | Same shape. |
Why "default labor / order price" is one field, not two: Stefan's V1 pattern is labor IS the order's headline price for client jobs (the strings_subtotal is added on top per BL-4 / BR-4 / BR-7, but labor is the customer-facing CHF X.XX hint). The third explicit example from epic #140 ("default order price") maps to this single setting; rolling it into labor avoids a confused two-field UX where the values would always be the same.
Locked decision (Stefan, 2026-05-09): the merged "default labor / order price" framing is confirmed — single field, single column (
Stringer.default_labor_chf). Stefan: "Agree with default labor."
Why default tension is NOT here: Stefan locked on 2026-05-09 — "default tension does not make any sense, this is not a stringer default. Drop completely." The add-stringjob form already copies the tension from the client's previous order; for brand-new clients (no previous order) the form falls back to a 24 kg code-level constant, not a stringer setting.
4. Sign-in¶
A two-row status panel showing the two methods configured for the current stringer. Magic-link is always on (M21 baseline); password is opt-in via Round-3 onboarding Stage C or this page.
| Component | Notes |
|---|---|
| Magic-link row | Always on ✓ (slate-700 + green-600 check). Hint: "Sign in via emailed link any time." No CTA — it's not toggle-able in V2. |
| Password row — when unset | Set ▸ text-link CTA (indigo-700). Hint: "Add a password to sign in without email." Tap → modal: same shape as stringer-onboarding § Stage C password-set form (12-char min + 4 character classes per keystone#127). |
| Password row — when set | Change ▸ text-link CTA. Tap → modal asking for current password + new password + confirm. |
| Forgot-password CTA | Inline link below the password row when password is set: "Forgot your password? Email yourself a reset link." Triggers gotrue's recovery email flow (M22, already shipped per #132/#133). |
Validation — password modal: the same gates as keystone#127 — MIN_PASSWORD_LENGTH = 12, plus lower / upper / digit / symbol class requirements (app/auth/password_policy.py). The modal renders the four rule-checks live (the same PasswordRuleStatus shape the policy module exports), turning green as each criterion is met.
No surface for "remove password / revert to magic-link only" in V2. Once a password is set, magic-link still works (per M21); removing the password is a low-value affordance. Flagged as a possible follow-up.
5. Notifications¶
Two checkboxes backed by Person.notification_prefs JSONB keys, per share-management § notifications. Both default false per Stefan's "avoid surprise emails as the platform onboards" stance (share-management.md L464).
| Component | Notes |
|---|---|
share_granted_to_me checkbox |
Label: "Tell me when another stringer shares a job with me." Default unchecked. Stores to Person.notification_prefs.share_granted_to_me = true. The Person row used is the stringer's own platform-level Person (the one that backs their gotrue identity); see stringer-lifecycle § Self-Profile slot. |
share_accessed_by_grantee checkbox |
Label: "Tell me when someone I shared a job with views it." Default unchecked (potentially noisy). Stores to Person.notification_prefs.share_accessed_by_grantee = true. |
| Save button | Same shape. |
Why these aren't on Stringer directly: the channel-keyed prefs blob lives on Person per identity.py L345 (notification_prefs: JSONB). A stringer's own Person row carries it (the lazy-self-Person from stringer-lifecycle § Self-Profile slot). Storing on Person — not on Stringer — keeps the V3 client-portal-side notification preferences and the V2 stringer-side ones using the same column, no schema fork.
Note on V3: the V3 signoff_default toggle (epic D5 / issue #51) is NOT rendered here in V2. The column exists (V3 hook); the UI lights up in V3.
6. Your data¶
A single action: download. Wraps M20 (per-stringer self-service export — XLSX + JSON).
| Component | Notes |
|---|---|
| Download button | Dropdown: "Download XLSX" / "Download JSON" / "Download both (zip)". Tap triggers GET /settings/export?format=...; server streams the file with Content-Disposition: attachment. |
| Hint | "Includes every order, client, racket, and receipt you own. Generated on request; large workspaces may take a minute." |
| In-flight state | Button shows spinner + "Generating…"; user can navigate away (server completes async; an email lands when done IF the workspace is large enough that streaming would time out). |
Implementation note for Pax: for V1-Stefan-scale workspaces (~750 orders), synchronous streaming is fine. The async-email path is a nice-to-have if Stefan wants it; otherwise stays sync.
7. Account closure¶
Self-deactivation per stringer-lifecycle § self-deactivation B1. Destructive action, isolated at the bottom of the page.
| Component | Notes |
|---|---|
| Deactivate button | bg-red-50 text-red-700 border border-red-300 — destructive styling without being aggressive (Stefan flagged elsewhere: a calm-red palette for destructive actions). Hit target 48 px. |
| Hint | "Locks your account immediately. Your data is retained for 90 days; admin may permanently remove it after that." |
| Confirmation dialog (on tap) | Modal: "Deactivate your account?" + free-text "Reason (optional)" textarea + "Confirm deactivation" red button + "Cancel" secondary. Per stringer-lifecycle B1: "one-click affordance + confirmation dialog with optional free-text reason." |
| Post-confirm | Server sets Stringer.deactivated_at = NOW(), logs the user out, redirects to a "Account deactivated" landing page (out of this spec — queued in index § Future rounds as the re-activation magic-link landing surface). |
No "delete my account" affordance in V2 — only deactivate. Hard-delete is admin-only after the 90-day grace per stringer-lifecycle. Documented in the hint.
Save semantics¶
Per-section save, not whole-page save. Each section's "Save" button POSTs only the fields in that section.
Rationale: a stringer who wants to flip the language from EN to DE shouldn't accidentally commit a half-edited business-name field. Per-section saves also localise validation errors — a phone-format warning in section 2 doesn't block a language flip in section 1.
HTMX seam: each section is its own <form hx-post="/settings/{section}" hx-target="#{section}" hx-swap="outerHTML">. On success, the section re-renders with a transient "Saved." toast (4-second auto-dismiss) inline at the section header. Without JS: regular form POST → server returns the full /settings page with a flash banner at top.
Dirty-state warning: if the user navigates away with unsaved fields, the browser's native beforeunload warning fires. This is JS-only; without JS, no warning (acceptable degradation).
Interaction states¶
| State | What renders |
|---|---|
| Initial render | Server-rendered HTML; all sections present, fields hydrated from the current Stringer row + Person row (for notification prefs). |
| Field validation error | Server re-renders the affected section with inline red-700 text-small error message under the offending field. Other sections untouched. |
| Save success | Section re-renders with a bg-green-50 border-green-200 text-green-800 toast at the section header reading "Saved." Auto-dismisses in 4 s. |
| Logo upload — in flight | Thumbnail shows a bg-slate-100 animate-pulse skeleton; "Upload new" button shows spinner. |
| Logo upload — server reject (>1 MB / wrong format) | Inline error under the upload row; thumbnail reverts to previous state. |
| Password modal — open | Modal as in stringer-onboarding § Stage C, with the live policy-rule-status checks. |
| Password modal — wrong current password | Inline red-700 "Current password incorrect." Modal stays open, fields preserved. |
| Export download — in flight | Button shows Generating… spinner; download starts in browser when the server response is ready. |
| Deactivate — confirmation modal | Modal as described in section 7. |
| Deactivate — confirmed | Server logs the user out and redirects; the user sees the deactivated-landing page (out of spec). |
Validation rules (UI surface; canonical server-side)¶
| Rule | Inline message |
|---|---|
display_name empty |
"Enter your display name." |
display_name > 80 chars |
"Display name is too long (max 80)." |
business_name > 120 chars |
"Business name is too long (max 120)." |
phone < 4 digits AND not empty |
"That phone number looks short. Save it anyway, or fix and re-save." (Permissive; doesn't block save.) |
business_address > 500 chars |
"Address is too long (max 500)." |
receipt_thankyou_text > 200 chars |
"Thank-you text is too long (max 200)." |
default_labor_chf negative |
"Labor can't be negative." |
| Logo > 1 MB | "Logo must be 1 MB or smaller." |
| Logo wrong format | "Logo must be a PNG or JPG image." |
| Password < 12 chars | "Password must be at least 12 characters." |
| Password missing class | "Password must include lowercase, uppercase, digit, and symbol." (Per keystone#127.) |
| New password ≠ confirm | "The two passwords don't match." |
| Current password wrong (change-password modal) | "Current password incorrect." |
Server-side validation is canonical; the UI strings above are the user-facing message.
Accessibility¶
- Heading order:
<h1>"Settings" (visually hidden; the back-arrow header carries the page title visually) →<h2>per section ("Account", "Business identity", "Stringjob defaults", "Sign-in", "Notifications", "Your data", "Account closure"). The lg-fallback left-rail uses<nav aria-label="Settings sections">with<a href="#account">style anchors. - Form-control labels: every input has a programmatic
<label for=...>. The required-vs-optional state is announced viaaria-required="true"on required inputs; the asterisk is decorative. - Locale toggle:
<fieldset>with two<input type="radio" role="radio">styled as pills, per the stringer-onboarding pattern. - Hit targets: every interactive element ≥ 44 × 44 px; the section "Save" buttons + the deactivate button are 48 px tall.
- Color contrast: indigo-700 on white = 8.59:1; red-700 on white = 5.94:1; green-800 on green-50 = 7.4:1. All WCAG 2.1 AA.
- Destructive action protection: the deactivate button requires a confirmation dialog (no double-tap-to-deactivate). The dialog is
role="dialog" aria-modal="true"with focus trapped. - Live-policy password rule checks:
aria-live="polite"on the rule-status list so screen-reader users hear each rule flip from ✗ to ✓ as they type. prefers-reduced-motion: disables the section-toast cross-fade and the spinner rotation for upload-in-flight (replaced with a static "Saving…" label).- Without JS: every section save is a regular form POST → page reload with a top-of-page flash banner. The dirty-state
beforeunloadwarning is JS-only — degraded but not broken.
HTMX / progressive-enhancement seams¶
- Per-section save: each
<form hx-post="/settings/{section}" hx-target="closest section" hx-swap="outerHTML">. Without JS: regular POST → full/settingspage reload with a top flash. - Logo upload:
<form hx-post="/settings/logo" hx-encoding="multipart/form-data" hx-target="#logo-row">. Without JS: same form as a regularmultipart/form-dataPOST; server re-renders the page on success. - Password modal — opening:
hx-get="/settings/password" hx-target="#modal-portal" hx-swap="innerHTML". Without JS: a separate/settings/passwordpage renders the modal-as-page; on save, redirect back to/settings. - Password modal — live rule checks:
<input hx-post="/settings/password/_check" hx-trigger="input changed delay:200ms" hx-target="#password-rules" hx-swap="innerHTML" hx-include="closest input">. Without JS: rules are evaluated server-side on submit only. - Export download: standard
<a href="/settings/export?format=xlsx" download>. No HTMX; the browser handles streaming download. - Deactivate confirmation:
hx-get="/settings/deactivate" hx-target="#modal-portal". Without JS: a separate/settings/deactivatepage hosts the confirmation form. - lg left-rail anchors:
<a href="#account">etc. The "currently in view" highlight uses an IntersectionObserver — JS-only; without JS the rail still navigates, just without the active-section pulse.
i18n affordance¶
| String | Type | Catalogue key |
|---|---|---|
| "Settings" (page title) | {% trans %} |
settings.title |
| Section H2s ("Account", "Business identity", "Stringjob defaults", "Sign-in", "Notifications", "Your data", "Account closure") | {% trans %} |
settings.section.{account,business_identity,defaults,signin,notifications,data,closure} |
| Field labels ("Email", "Display name", "Language", "Business name", "Phone", "Business address", "Logo", "Receipt thank-you text", "Default labor / order price") | {% trans %} |
settings.field.{email,display_name,language,business_name,phone,business_address,logo,thankyou_text,default_labor}.label |
| Field hints | {% trans %} |
settings.field.{...}.hint |
| "Save" buttons (per section) | {% trans %} |
settings.save.{account,business_identity,defaults,notifications} |
| Section save toast "Saved." | {% trans %} |
settings.save.toast |
| Magic-link / Password row labels + status | {% trans %} |
settings.signin.{magic_link,password,always_on,set,change} |
| Password rule labels | {% trans %} |
settings.password.rule.{length,lower,upper,digit,symbol} (re-uses keystone#127 keys where they exist) |
Notification labels (share_granted_to_me, share_accessed_by_grantee) |
{% trans %} |
settings.notifications.{share_granted_to_me,share_accessed_by_grantee} |
| "Download XLSX" / "Download JSON" / "Download both (zip)" | {% trans %} |
settings.data.download.{xlsx,json,both} |
| "Deactivate…" + confirmation dialog body + "Confirm deactivation" + "Cancel" | {% trans %} |
settings.closure.{deactivate,dialog_title,dialog_body,confirm,cancel} |
| Email value (Stefan's actual address) | Data | n/a |
| Logo file (binary) | Data | n/a |
| Currency "CHF 25.00" | Format (Babel format_currency) |
n/a |
DE strings are Iris's later pass.
DE width budget (designer note)¶
DE labels here are typically 20–30% longer than EN. Specific watch-outs:
- "Settings" → "Einstellungen" (~1.4×) — the back-arrow header reserves width.
- "Business identity" → "Geschäftsidentität" (~1.3×) — fits the section header at all viewports.
- "Stringjob defaults" → "Bespannungs-Vorgaben" (~1.4×) — fits
smwidth. - "Display name" → "Anzeigename" (similar).
- "Receipt thank-you text" → "Quittungs-Dankestext" (~1.4×) — fits.
- "Tell me when another stringer shares a job with me." → "Benachrichtige mich, wenn ein anderer Bespanner einen Auftrag mit mir teilt." (~1.5×) — wraps to two lines on
sm; the wireframe reserves the vertical room. - "Save business identity" → "Geschäftsidentität speichern" (~1.4×) — fits inline at
md+; onsmthe button is full-width. - "Deactivate…" → "Deaktivieren…" (~1.2×) — fits.
- "Locks your account immediately." → "Sperrt Ihr Konto sofort." (~1.0×).
The single-column scroll naturally handles wrapping; no DE-specific layout tweaks needed.
Explicit non-goals¶
Per epic #140's non-goals plus Mira's sweep:
- Client-specific overrides — per-client price, per-client notification template overrides, per-client locale. These belong on the client-detail page per client-management-v2.
- V3 notification opt-out toggles (#51 / D5) —
Stringer.signoff_default,Player.stringer_signoff_override,Player.signoff_pref. The columns ship in V2 (per v3-vision § V2 hooks) but no V2 UI surfaces them — they light up in V3 alongside the client portal (C1). - Notification template editor (#42) —
Stringer.notification_templateships empty per Stefan's "Do it empty." 2026-05-01 directive; the editor lands in V3. - Email-address change — admin-only in V2; the UI shows it as locked with a "contact admin" hint.
- Person-merge / cross-stringer admin — admin-only surface, not stringer-scoped.
- Hard-delete-my-account — V2 only offers self-deactivation; admin finalizes after 90-day grace per stringer-lifecycle.
- Receipt-numbering format override (#43) — receipt-numbering is platform-fixed at
YYYY-NNNNper receipt-content § receipt-number-format; per-stringer customisation is out of V2 scope. - Receipt-email failure surface (#44) — Stefan locked silent + log; no UI banner. Settings does not expose a "show me failed sends" log; that would be a V2.x admin polish.
Decisions Mira made on Stefan's behalf¶
Listed for the MR description so Stefan can reverse any of them on review:
- One-page settings with seven fixed sections, per-section save — vs. tabbed / multi-page settings. Rationale: epic explicitly asks for one page; per-section save lets the user commit one change without others.
- Logo upload in the Business-identity section, not in onboarding — confirms the OQ-R-5 default ("account-settings only, V2 first cut"). The onboarding form remains a "you can add a logo later in account settings" notice.
- Default labor / order price gets a new
Stringer.default_labor_chfcolumn in V2 (row #9 in the sweep table). Without it, epic #140's third explicit example ("default order price") has no schema. Default tension is NOT a stringer setting per Stefan's 2026-05-09 lock — the 24 kg fallback for brand-new clients lives as a code-level constant inside the add-stringjob form's prefill logic. - Default labor and default order price are merged into one field (row #9 covers both). Stefan locked the merged framing on 2026-05-09 ("Agree with default labor").
- Email is read-only — contact admin to change, vs. a stringer-self-service email change. V2 punts the email-change verification flow.
- Password remove ("revert to magic-link only") is not in V2 — once set, password stays set until admin intervention. Low-value affordance.
- Notifications section uses
Person.notification_prefsJSONB on the stringer's lazy-self-Person row — not new columns onStringer. Same column the V3 client portal will use. - Account closure is "deactivate" only — no hard-delete-self in V2. Admin-finalize after 90-day grace stays the only deletion path.
Stringer.signoff_defaultships with no V2 UI, even though the column lands in V2 per v3-vision. The toggle is V3-portal-bound (the OR-logic is meaningless without a client-side opt-out, which doesn't exist in V2).- Receipt-thank-you-text max length 200 chars — opinionated cap; long enough for a sentence-or-two, short enough to fit the receipt's BR-10 slot without breaking the layout. Stefan to flag if he wants a longer cap.
Open questions for Stefan (with proposed defaults)¶
- Should a stringer be able to upload a custom favicon / brand color for the receipt PDF in V2? Proposed default: no — out of V2 scope. Logo + display_name + business_name + thank-you-text is the receipt-customisation surface; brand color is a Round-2 receipt-pdf concern, not a settings concern, and no requirement asks for it.
- Should the locale toggle re-render the page immediately (full reload) or defer until the next page load? Proposed default: full reload immediately on save. The user expects the language to change "now"; deferring to the next navigation feels broken. Cost: one extra round-trip on a low-frequency action.
- Receipt-thank-you-text — should it be locale-aware (separate EN + DE strings) or locale-neutral (one free-text in whichever language the stringer chooses)? Proposed default: locale-neutral, per receipt-content BR-10 ("the stringer chooses which language they write it in"). Alternative: separate EN + DE textareas; rejected as too much UI for a marginal benefit. If Stefan wants both, he keeps the field NULL and uses the platform i18n fallback (which IS locale-routed).
- "Save business identity" — should pricing edits to existing receipts auto-bulk-re-emit? Proposed default: no. Per receipt-content § re-emit triggers, bulk re-emit is V2.x polish. The save-toast notes that new receipts use the updated business identity; existing receipts are unchanged.
- Notifications section — should there be a "Test email" button that sends a sample to verify the address works? Proposed default: no — out of V2 scope. Email-address change is admin-only; a test-email is a V2.x polish. Stefan to flag if he wants it.
- Export — should the download include shared-with-me jobs (read-only) or only own jobs? Proposed default: only own jobs. Per M20 ("per-stringer self-service export"); shared-with-me data is the granter's data, not the grantee's. Stefan to confirm.
- Deactivate — should the confirmation dialog require typing the stringer's email to confirm, or just a single "Confirm deactivation" click? Proposed default: single click + free-text reason field (optional). Per stringer-lifecycle B1 — "one-click affordance + confirmation dialog with optional free-text reason." A typed-email gate would be admin-finalize-style friction, overkill for self-deactivate.
- Password "Forgot your password?" link placement — inline in the Sign-in section, or only on the
/loginpage? Proposed default: both. Stefan-on-his-phone in/settingswho forgot his current password should not have to log out, navigate to/login, and click "forgot." Inline is cheap.
Cross-references¶
- Source requirement: V2 Settings epic — racket-book#140, V2 scope M3, M19, M20, M21, stringer-lifecycle.
- Schema:
app/db/models/identity.py§Stringer, data-model § Stringer. - Receipt: receipt-content § stringer business-identity fields, receipt-pdf § no-logo case.
- Schema hooks not surfaced in V2: #42 notification_template, #43 receipt numbering, #44 receipt-email failure, #51 / D5 V3 sign-off.
- Linked design surfaces: stringer-onboarding (defers optional fields here), client-management-v2 (client-specific overrides), add-stringjob (consumes the stringjob defaults), share-management (notifications opt-ins land here).
- Auth + password policy: ADR-0006 JWT-in-cookie session,
app/auth/password_policy.py(keystone#127 — 12-char floor + 4 character classes). - i18n strategy: i18n architecture.
- Issue tracking: racket-book#140 (closes on merge).