Substratal Apps Substratal Apps Hub specs
GitHub

Docs web SCREENS.md

Web (PWA) — Screen Specs

All 75 screens with the native Web (PWA) container and controls for each element. Behavior, data and copy are identical to shared/SCREEN-SPECS.md; construction rules for this platform are in PLATFORM-SPEC.md.

Container per archetype

Archetype Container
auth Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone
grid Main pane, CSS grid (auto-fill minmax 160px)
list List pane (resizable) + detail pane ≥ 768 px; single pane < 768 px
table Virtualized grid table ≥ 1024 px; card rows below
detail Detail pane with header, tablist, toolbar; full page < 768 px
tab tabpanel (route segment)
form Modal <dialog> ≥ 768 px; bottom sheet (drag handle, snap points) < 768 px
settings Grouped settings rows in main pane
confirm <dialog role=alertdialog>; bottom sheet < 768 px
secret Modal <dialog>, cancel prevented, no Esc
wizard Dialog/sheet with step indicator; each step a URL hash
popover Popover ≥ 1024 px; side panel 768–1023; bottom sheet < 768
handoff window.open (new tab) / same-tab redirect for PKCE
overlay Top-layer <dialog> / popover
hub Bottom-nav destination list < 768 px

D1 · Sign-in & Identity

AUTH-01 — Sign in

Domain: D1 Sign-in & Identity · Route: /sign-in · Archetype: auth · Phase: MVP
Who: Unauthenticated
Purpose: Exchange email + password for a session, or start an MFA challenge.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Entry points: App launch with no stored session; Session expired / revoked (preserves ?next= destination); Sign out

Element Kind Web (PWA) control Data / API Rules
Email email-field body.email required; RFC 5322; trim; autofill username
Password password-field + reveal body.password required; reveal toggle; autofill current-password
Sign in primary-button POST /auth/login disabled until both fields non-empty
Forgot password? link / → AUTH-05
Create an account link / → AUTH-03
Continue with SSO button POST /auth/sso/{provider}/callback hidden behind flag sso (API returns 501 today)
Sign in with a passkey button — hidden behind flag passkeys; reserved for when the API supports WebAuthn
Action Call Result handling
Sign in POST /auth/login 200 AuthSession → store tokens → go to next or APP-01. 200 MfaChallenge → AUTH-02 with mfa_token.
— 401 → inline form error "Email or password is incorrect." (never reveal which). 403 → account suspended/locked message. 429 → disable button, countdown from Retry-After.

States: Submitting: button spinner, fields read-only; Locked out (5 failures / 15 min): explain wait time

Notes: test_mode is never exposed in end-user UI; developer builds may show a hidden toggle (long-press logo / ⌥-click) for test-mode sign-in. The horizontal logo lockup (BrandLockup, brand/BRAND.md) sits above the form.

AUTH-02 — Two-factor challenge

Domain: D1 Sign-in & Identity · Route: /sign-in/mfa · Archetype: auth · Phase: MVP
Who: Holder of mfa_token
Purpose: Complete sign-in with a TOTP code or a recovery code.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Entry points: AUTH-01 returned MfaChallenge

Element Kind Web (PWA) control Data / API Rules
Code otp-field body.code 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill
Use a recovery code link / swaps to Recovery code field only if methods includes recovery_code
Recovery code text-field body.recovery_code format xxxx-xxxx; case-insensitive
Expiry countdown caption MfaChallenge.expires_in announce at 60 s and 10 s
Verify primary-button POST /auth/mfa/verify
Action Call Result handling
Verify POST /auth/mfa/verify 200 → same as AUTH-01 success. 401 → "That code didn't work." clear field. Expired → back to AUTH-01 with message. 429 → countdown.

States: Challenge expired

AUTH-03 — Create account

Domain: D1 Sign-in & Identity · Route: /sign-up · Archetype: auth · Phase: MVP
Who: Unauthenticated
Purpose: Self-service signup with email and password.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Entry points: AUTH-01 link; Marketing/app deep link with optional application_id

Element Kind Web (PWA) control Data / API Rules
Display name text-field body.display_name optional; ≤ 100
Email email-field body.email required
Password password-field + reveal body.password required; strength meter; autofill new-password; show policy hint
Create account primary-button POST /auth/signup
Terms / Privacy links external
Action Call Result handling
Create account POST /auth/signup 201 AuthSession → AUTH-04 (verify pending) then APP-01. 409 email_taken → "An account with this email may already exist. Sign in or reset your password." (with details.reason invitation_pending the API re-sends the invitation: say "Check your email for your invitation.") 422 → field errors.

Notes: Pass application_id through when signup started from an app's link; it is recorded as signup_application_id. Every new User starts on the Free plan and receives platform-managed free Entitlements to every free Application, including the Hub itself (app_hub). No plan choice at signup; upgrading is BIL-01.

AUTH-04 — Verify your email

Domain: D1 Sign-in & Identity · Route: /verify-email · Archetype: auth · Phase: MVP
Who: Signed in (unverified) or token holder
Purpose: Tell the user to check email; complete verification from the emailed link.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Entry points: After signup; Persistent banner on APP-01 while email_verified is false; Deep link /verify-email?token=…

Element Kind Web (PWA) control Data / API Rules
Instruction text

User.email "We sent a link to {email}."
Resend email button POST /auth/email/verify/resend 30 s cooldown after each send
Continue to Substratal button → APP-01 unverified users may continue
Change email link / → ACC-02
Action Call Result handling
Deep link opened POST /auth/email/verify {token} 204 → success state, refresh User. 400 → "This link has expired." + Resend.
Resend email POST /auth/email/verify/resend Always 202 → toast "If that address needs verifying, we sent a new link."

AUTH-05 — Forgot password

Domain: D1 Sign-in & Identity · Route: /forgot-password · Archetype: auth · Phase: MVP
Who: Unauthenticated
Purpose: Request a password-reset email.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Element Kind Web (PWA) control Data / API Rules
Email email-field body.email prefilled from AUTH-01
Send reset link primary-button POST /auth/password/forgot
Action Call Result handling
Send reset link POST /auth/password/forgot Always 202 → confirmation "If an account exists for {email}, we sent a reset link." 429 → countdown.

AUTH-06 — Set a new password

Domain: D1 Sign-in & Identity · Route: /reset-password · Archetype: auth · Phase: MVP
Who: Reset-token holder
Purpose: Choose a new password from the emailed reset link.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Entry points: Deep link /reset-password?token=…

Element Kind Web (PWA) control Data / API Rules
New password password-field + reveal body.new_password strength meter; autofill new-password
Confirm password password-field + reveal client only must match
Notice caption "You'll be signed out on all devices."
Save password primary-button POST /auth/password/reset
Action Call Result handling
Save password POST /auth/password/reset 204 → clear local session → AUTH-01 with success banner. 400 → expired link + link to AUTH-05. 422 → field errors.

AUTH-07 — Accept invitation

Domain: D1 Sign-in & Identity · Route: /invite · Archetype: auth · Phase: MVP
Who: Invitation-token holder
Purpose: Activate an account an admin created: set password and sign in.

Web (PWA) container: Standalone route outside the app shell, centered card ≥ 768 px, full-bleed on phone

Entry points: Deep link /invite?token=… from the invitation email

Element Kind Web (PWA) control Data / API Rules
Display name text-field body.display_name optional
Password password-field + reveal body.password strength meter
Accept and continue primary-button POST /auth/invitations/accept
Action Call Result handling
Accept and continue POST /auth/invitations/accept 200 AuthSession → APP-01. 400 → "This invitation is no longer valid. Ask your admin to resend it."

D2 · Account & Security

ACC-01 — Account & profile

Domain: D2 Account & Security · Route: /account · Archetype: settings · Phase: MVP
Who: Self
Purpose: View and edit the signed-in user's global profile.

Web (PWA) container: Grouped settings rows in main pane

Loads: GET /users/me · GET /users/me/profile

Element Kind Web (PWA) control Data / API Rules
Avatar image-picker + paste + drop Profile.avatar_url camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL*
Display name text-field Profile.display_name required; ≤ 100; save on blur/Return
Email row → ACC-02 row link User.email + email_verified + pending_email shows "Pending: {pending_email}" when set
User ID id-chip + copy button User.id copy
Contact email email-field Profile.contact_email optional
Contact phone phone-field Profile.contact_phone optional; E.164 formatting by locale
Plan row → BIL-01 row link User.plan free|standard + plan_sources "Free" or "Standard · via {org}" / "Standard · your subscription"
Member since / last sign-in caption User.created_at, last_login_at locale formatted
Sign out button POST /auth/logout always visible at the bottom of Account (mobile) / account menu (desktop)
Action Call Result handling
Edit field PATCH /users/me/profile (If-Match) Save per field; 409 version_conflict → reload prompt; 422 → field error.
Sign out POST /auth/logout best effort; always wipe local credentials and cache → AUTH-01

States: Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

Notes: *The API stores avatar_url only. Upload target is a client-side decision (spec gap — see OPEN-QUESTIONS).

ACC-02 — Change email

Domain: D2 Account & Security · Route: /account/email · Archetype: form · Phase: MVP
Who: Self
Purpose: Start an email change; the new address must be verified.

Web (PWA) container: Modal <dialog> ≥ 768 px; bottom sheet (drag handle, snap points) < 768 px

Element Kind Web (PWA) control Data / API Rules
New email email-field body.email required; differs from current
Save primary-button PATCH /users/me
Action Call Result handling
Save PATCH /users/me {email} 200 → User.pending_email set; show AUTH-04-style "check your new inbox". 409 not_unique → "That email is already in use."

ACC-03 — Change password

Domain: D2 Account & Security · Route: /account/security/password · Archetype: form · Phase: MVP
Who: Self
Purpose: Change password knowing the current one.

Web (PWA) container: Modal <dialog> ≥ 768 px; bottom sheet (drag handle, snap points) < 768 px

Element Kind Web (PWA) control Data / API Rules
Current password password-field + reveal body.current_password autofill current-password
New password password-field + reveal body.new_password strength meter; autofill new-password
Save primary-button POST /users/me/password
Action Call Result handling
Save POST /users/me/password 204 → toast "Password changed". 401 → "Current password is incorrect." 422 → policy errors.

ACC-04 — Security

Domain: D2 Account & Security · Route: /account/security · Archetype: settings · Phase: MVP
Who: Self
Purpose: Password, two-factor and active sessions in one place.

Web (PWA) container: Grouped settings rows in main pane

Loads: GET /users/me · GET /users/me/sessions

Element Kind Web (PWA) control Data / API Rules
Password row → ACC-03 row link "Change…"
Two-factor authentication toggle User.mfa_enabled on → ACC-05; off → ACC-06
Sessions list virtualized list Session[]: user_agent → device name/icon, ip_address → approximate location*, last_seen_at, current current session marked "This device" and not revocable here
Sign out row-button trailing button DELETE /users/me/sessions/{sessionId} per non-current row
Sign out of all other sessions destructive-button DELETE /users/me/sessions/{sessionId} for each non-current session confirm first; also stops push to those sessions' devices
Devices & notifications row → NTF-03 row link count of active devices
Action Call Result handling
Revoke one DELETE /users/me/sessions/{sessionId} Optimistic remove; 404 → already gone, refresh.
Sign out everywhere else DELETE each non-current session Confirm first (COPY). Uses per-session deletes so the current session survives; logout all_sessions signs this device out too. Revoking a session deactivates the push devices it registered.

States: Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES

Notes: *IP → city lookup is client-side/optional; never show the raw IP to non-staff unless the user expands details.

ACC-05 — Turn on two-factor

Domain: D2 Account & Security · Route: /account/security/2fa/enable · Archetype: wizard · Phase: MVP
Who: Self
Purpose: Enroll an authenticator app (TOTP) and save recovery codes.

Web (PWA) container: Dialog/sheet with step indicator; each step a URL hash

Element Kind Web (PWA) control Data / API Rules
Step 1 — Scan qr + secret / QR POST /users/me/mfa/totp → otpauth_uri, secret QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile)
Step 2 — Confirm otp-field body.code 6 digits
Step 3 — Recovery codes secret-list SecretReveal + download/print/share confirm response: 10 recovery codes Copy all · Save as file · Print · Share (mobile); "I've saved these" checkbox gates Done
Action Call Result handling
Start POST /users/me/mfa/totp 409 → already enabled; close.
Confirm POST /users/me/mfa/totp/confirm 200 → step 3. 401 → wrong code.

States: Cancel before confirm leaves MFA off (no server cleanup needed)

ACC-06 — Turn off two-factor

Domain: D2 Account & Security · Route: /account/security/2fa/disable · Archetype: confirm · Phase: MVP
Who: Self
Purpose: Disable TOTP with a current code or a recovery code.

Web (PWA) container: <dialog role=alertdialog>; bottom sheet < 768 px

Element Kind Web (PWA) control Data / API Rules
Code or recovery code otp-field / text-field body.code | body.recovery_code
Turn off destructive-button POST /users/me/mfa/totp/disable
Action Call Result handling
Turn off POST /users/me/mfa/totp/disable 204 → toggle off, toast. 401 → wrong code. 429 → countdown.

ACC-07 — Privacy

Domain: D2 Account & Security · Route: /account/privacy · Archetype: settings · Phase: MVP
Who: Self
Purpose: Export personal data and delete the account.

Web (PWA) container: Grouped settings rows in main pane

Element Kind Web (PWA) control Data / API Rules
Download my data button GET /users/me/export saves substratal-export-{date}.json via save dialog / share sheet / download
Delete account destructive-row → ACC-08 row button (destructive)
Action Call Result handling
Download GET /users/me/export 200 → save file. 429 → "You can export again in {n} minutes."

ACC-08 — Delete account

Domain: D2 Account & Security · Route: /account/privacy/delete · Archetype: confirm · Phase: MVP
Who: Self
Purpose: Request erasure: deactivate now, erase after 7 days.

Web (PWA) container: <dialog role=alertdialog>; bottom sheet < 768 px

Element Kind Web (PWA) control Data / API Rules
What happens text

list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days
Reason text-area