Skip to content

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 /settings page. 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 fieldsdisplay_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_locale lives 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:

  1. One page, all knobs. Stefan lands at /settings and 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.
  2. 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.
  3. 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_chf lands 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 a Stringer column.

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 via aria-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 beforeunload warning 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 /settings page 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 regular multipart/form-data POST; 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/password page 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/deactivate page 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 sm width.
  • "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+; on sm the 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_template ships 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-NNNN per 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:

  1. 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.
  2. 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.
  3. Default labor / order price gets a new Stringer.default_labor_chf column 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.
  4. 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").
  5. Email is read-only — contact admin to change, vs. a stringer-self-service email change. V2 punts the email-change verification flow.
  6. Password remove ("revert to magic-link only") is not in V2 — once set, password stays set until admin intervention. Low-value affordance.
  7. Notifications section uses Person.notification_prefs JSONB on the stringer's lazy-self-Person row — not new columns on Stringer. Same column the V3 client portal will use.
  8. Account closure is "deactivate" only — no hard-delete-self in V2. Admin-finalize after 90-day grace stays the only deletion path.
  9. Stringer.signoff_default ships 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).
  10. 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)

  1. 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.
  2. 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.
  3. 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).
  4. "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.
  5. 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.
  6. 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.
  7. 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.
  8. Password "Forgot your password?" link placement — inline in the Sign-in section, or only on the /login page? Proposed default: both. Stefan-on-his-phone in /settings who forgot his current password should not have to log out, navigate to /login, and click "forgot." Inline is cheap.

Cross-references