# Substratal Hub — Windows app UI specification

> Substratal Hub for Windows. Fluent 2 — Windows 11. Stack: WinUI 3 / Windows App SDK, C#. Built against the Substratal Apps Platform API v1.4.0 (https://compositecode.github.io/substratalapps.com/).

This file is the AI-readable version of https://compositecode.github.io/substratalapps-applications-specs/platforms/windows.html. Behavior, data and copy are identical on all six platforms and defined in the shared spec; this platform's documents define containers, native controls, integrations and theming.

## At a glance

- App: Windows
- Stack: WinUI 3 / Windows App SDK, C#
- Notifications: Polling + local notifications
- Screens specified: 75 (see SCREENS.md below); reference renders: 17, each in light (Moss Paper) and dark (Forest Floor)
- Themes: 10 (Teal Ledger, Indigo Root, Moss Paper, Coral Clay, Spore Pink; Night Soil, Deep Teal, Graphite Spore, Forest Floor, Ember Clay); defaults moss-paper / forest-floor
- Mockups accept `?mode=dark&light=<theme-id>&dark=<theme-id>`
- Source folder: https://github.com/CompositeCode/substratalapps-applications-specs/tree/main/substratal-application-specs/windows

## Windows documents

- [Team handoff](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/README.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/README.md
- [Platform spec](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/PLATFORM-SPEC.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/PLATFORM-SPEC.md
- [Screens](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/SCREENS.html) — Markdown: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/SCREENS.md

## Shared spec (reading order)

- [What changed (v1.4.0)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/CHANGES.md): What changed in this edition
- [Product spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/PRODUCT-SPEC.md): Scope, personas, domains, phases, acceptance criteria
- [Screen specs](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.md): Every screen: route, data, elements, actions, states
- [Client architecture](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/CLIENT-ARCHITECTURE.md): Sessions, requests, Capabilities, push and inbox, billing
- [UX flows](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/UX-FLOWS.md): The 16 end-to-end flows
- [Information architecture](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/INFORMATION-ARCHITECTURE.md): Navigation tree, routes, visibility rules, deep links
- [Themes](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/THEMES.md): Theme system: roles, palettes, contrast, persistence
- [Logo standard](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/brand/BRAND.md): Logo standard and app icons
- [Components](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/COMPONENTS.md): Component behavior contracts and native mapping
- [Copy & states](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/COPY-AND-STATES.md): Labels, confirmations, empty states, errors
- [Accessibility](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/ACCESSIBILITY.md): Accessibility requirements
- [Non-functional](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/NON-FUNCTIONAL.md): Performance, security, reliability
- [Open questions](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/OPEN-QUESTIONS.md): Unresolved decisions
- [API → screen map (CSV)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/API-SCREEN-MAP.csv): all 118 API operations → screens
- [Theme tokens (JSON)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/themes/themes.json) · [Brand tokens (JSON)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/brand/brand-tokens.json) · [Status semantics (JSON)](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/status-semantics.json)

## Reference screens

| Screen | Title | Light | Dark | Live mockup | Spec |
|---|---|---|---|---|---|
| ACC-04 | Security | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/ACC-04-security.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/ACC-04-security.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/ACC-04-security.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#acc-04--security) |
| ACX-01/03 | User — Access | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/ACX-01-03-access-turn-off.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/ACX-01-03-access-turn-off.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/ACX-01-03-access-turn-off.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#acx-01--user--access) |
| APL-03 | Application — Overview | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/APL-03-application.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/APL-03-application.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/APL-03-application.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#apl-03--application--overview) |
| APL-07 | Push credentials | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/APL-07-push-credentials.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/APL-07-push-credentials.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/APL-07-push-credentials.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#apl-07--push-credentials) |
| APP-01 | Home — My Apps | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/APP-01-home.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/APP-01-home.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/APP-01-home.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#app-01--home--my-apps) |
| AUD-01/02 | Audit log | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/AUD-01-02-audit.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/AUD-01-02-audit.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/AUD-01-02-audit.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#aud-01--audit-log) |
| AUTH-01/02 | Sign in | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/AUTH-01-02-sign-in.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/AUTH-01-02-sign-in.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/AUTH-01-02-sign-in.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#auth-01--sign-in) |
| BIL-01 | My plan | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/BIL-01-my-plan.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/BIL-01-my-plan.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/BIL-01-my-plan.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#bil-01--my-plan) |
| KEY-01/03 | API keys | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/KEY-01-03-api-keys-secret.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/KEY-01-03-api-keys-secret.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/KEY-01-03-api-keys-secret.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#key-01--api-keys) |
| NTF-01/02 | Inbox | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/NTF-01-02-inbox.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/NTF-01-02-inbox.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/NTF-01-02-inbox.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#ntf-01--inbox) |
| NTF-03 | Devices & notifications | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/NTF-03-devices.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/NTF-03-devices.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/NTF-03-devices.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#ntf-03--devices--notifications) |
| ORG-06 | Organization — Apps & seats | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/ORG-06-org-apps-seats.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/ORG-06-org-apps-seats.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/ORG-06-org-apps-seats.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#org-06--organization--apps--seats) |
| ROL-02 | Role detail | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/ROL-02-role.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/ROL-02-role.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/ROL-02-role.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#rol-02--role-detail) |
| SET-01 | Settings — Global | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/SET-01-appearance-themes.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/SET-01-appearance-themes.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/SET-01-appearance-themes.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#set-01--settings--global) |
| SET-02 | Settings — Per app | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/SET-02-app-settings.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/SET-02-app-settings.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/SET-02-app-settings.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#set-02--settings--per-app) |
| USR-01 | Users | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/USR-01-users.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/USR-01-users.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/USR-01-users.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#usr-01--users) |
| WHK-03 | Webhook — Deliveries | [light](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/light/WHK-03-deliveries.png) | [dark](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/screens/dark/WHK-03-deliveries.png) | [html](https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/WHK-03-deliveries.html) | [spec](https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#whk-03--webhook--deliveries) |

Mockup index: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/mockups/index.html

## Theme implementation files

- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/SubstratalThemes.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/ThemeService.cs
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Dark/DeepTeal.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Dark/EmberClay.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Dark/ForestFloor.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Dark/GraphiteSpore.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Dark/NightSoil.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Light/CoralClay.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Light/IndigoRoot.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Light/MossPaper.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Light/SporePink.xaml
- https://compositecode.github.io/substratalapps-applications-specs/spec/windows/theme/Themes/Light/TealLedger.xaml

Brand assets: https://github.com/CompositeCode/substratalapps-applications-specs/tree/main/substratal-application-specs/windows/brand-assets

---

## Full text: windows/README.md

Source: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/README.md

**Standard:** Fluent 2 — Windows 11 · **Stack:** WinUI 3 / Windows App SDK, C#
**API:** Substratal Apps Platform API v1.4.0 (openapi 1.0.0-draft.3) — https://compositecode.github.io/substratalapps.com/

### Reading order

1. `shared/CHANGES.md` — what's new in this edition (push notifications, Free/Standard billing)
2. `shared/PRODUCT-SPEC.md` — scope, personas, domains, phases, acceptance criteria
3. `PLATFORM-SPEC.md` — how this app is built on Windows
4. `SCREENS.md` — all 75 screens with native containers and controls (the build checklist)
5. `shared/CLIENT-ARCHITECTURE.md` — sessions, request conventions, Capabilities, push and inbox (§6), billing hand-off (§9)
6. `shared/UX-FLOWS.md` (+ `shared/flows/*.svg`) — the 16 end-to-end flows
7. `mockups/index.html` / `screens/` — reference renders in the default light (Moss Paper) and dark (Forest Floor) themes; `?mode=dark&light=<id>&dark=<id>` previews any theme
8. While building: `shared/COMPONENTS.md`, `shared/COPY-AND-STATES.md`, `shared/ACCESSIBILITY.md`, `shared/NON-FUNCTIONAL.md`, `shared/OPEN-QUESTIONS.md`, `shared/API-SCREEN-MAP.csv`

**Themes:** `theme/` has this platform's implementation of the 5 light and 5 dark themes. Rules are in `shared/THEMES.md`; wiring is in `PLATFORM-SPEC.md` (Theming section).

**Logo & app icon:** `brand-assets/` has every icon and logo file this app needs. Wiring is in `PLATFORM-SPEC.md` (Brand & app icon section) and rules in `shared/brand/BRAND.md`.

### Definition of done (per screen)

- [ ] Elements, data bindings and API calls match `SCREENS.md`
- [ ] Reachable by its route / deep link; restores state from the route
- [ ] Gated by Capabilities per `shared/INFORMATION-ARCHITECTURE.md` §2
- [ ] Loading, empty, error (401/403/404/409/422/429/503) and offline states
- [ ] Idempotency-Key on creates, If-Match on edits, conflict handling
- [ ] Every gesture/shortcut has a visible equivalent; screen reader and largest text size pass
- [ ] Copy matches `shared/COPY-AND-STATES.md`
- [ ] Correct in both default themes and in every other theme (colours only from theme roles, no literals); high-contrast modes fall back to system colours
- [ ] Logo and icons come only from `brand-assets/` (no redraws), with accessible name "Substratal Apps"
- [ ] Flag-gated features (notifications, push, billing) hide cleanly on `501`

### Reference screens

- `ACC-04-security`: [light](screens/light/ACC-04-security.png) · [dark](screens/dark/ACC-04-security.png)
- `ACX-01-03-access-turn-off`: [light](screens/light/ACX-01-03-access-turn-off.png) · [dark](screens/dark/ACX-01-03-access-turn-off.png)
- `APL-03-application`: [light](screens/light/APL-03-application.png) · [dark](screens/dark/APL-03-application.png)
- `APL-07-push-credentials`: [light](screens/light/APL-07-push-credentials.png) · [dark](screens/dark/APL-07-push-credentials.png)
- `APP-01-home`: [light](screens/light/APP-01-home.png) · [dark](screens/dark/APP-01-home.png)
- `AUD-01-02-audit`: [light](screens/light/AUD-01-02-audit.png) · [dark](screens/dark/AUD-01-02-audit.png)
- `AUTH-01-02-sign-in`: [light](screens/light/AUTH-01-02-sign-in.png) · [dark](screens/dark/AUTH-01-02-sign-in.png)
- `BIL-01-my-plan`: [light](screens/light/BIL-01-my-plan.png) · [dark](screens/dark/BIL-01-my-plan.png)
- `KEY-01-03-api-keys-secret`: [light](screens/light/KEY-01-03-api-keys-secret.png) · [dark](screens/dark/KEY-01-03-api-keys-secret.png)
- `NTF-01-02-inbox`: [light](screens/light/NTF-01-02-inbox.png) · [dark](screens/dark/NTF-01-02-inbox.png)
- `NTF-03-devices`: [light](screens/light/NTF-03-devices.png) · [dark](screens/dark/NTF-03-devices.png)
- `ORG-06-org-apps-seats`: [light](screens/light/ORG-06-org-apps-seats.png) · [dark](screens/dark/ORG-06-org-apps-seats.png)
- `ROL-02-role`: [light](screens/light/ROL-02-role.png) · [dark](screens/dark/ROL-02-role.png)
- `SET-01-appearance-themes`: [light](screens/light/SET-01-appearance-themes.png) · [dark](screens/dark/SET-01-appearance-themes.png)
- `SET-02-app-settings`: [light](screens/light/SET-02-app-settings.png) · [dark](screens/dark/SET-02-app-settings.png)
- `USR-01-users`: [light](screens/light/USR-01-users.png) · [dark](screens/dark/USR-01-users.png)
- `WHK-03-deliveries`: [light](screens/light/WHK-03-deliveries.png) · [dark](screens/dark/WHK-03-deliveries.png)

---

## Full text: windows/PLATFORM-SPEC.md

Source: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/PLATFORM-SPEC.md

**Standard:** Fluent 2 Design System and Windows 11 app design guidance.
**Read with:** `SCREENS.md` (all 75 screens with WinUI controls), `shared/`.

### 1. Technology

| Concern | Choice |
|---|---|
| UI | WinUI 3 (Windows App SDK 1.7+), C# / .NET 8+ |
| Pattern | MVVM with CommunityToolkit.Mvvm; DI via Microsoft.Extensions.Hosting |
| Controls | WinUI + CommunityToolkit (SettingsCard/Expander, Segmented, TokenizingTextBox, DataTable/TableView, GridSplitter) |
| API client | Kiota (or NSwag) from `openapi.yaml`; `HttpClient` with `SocketsHttpHandler`, HTTP/3 enabled |
| Secure storage | Credential Locker (`PasswordVault`) or DPAPI-protected file (`ProtectedData`, CurrentUser) |
| Cache | SQLite (Microsoft.Data.Sqlite) in `LocalCacheFolder`, encrypted with DPAPI-protected key |
| Minimum | Windows 10 22H2 (WinAppSDK); design target Windows 11 |
| Distribution | MSIX via Microsoft Store + App Installer (sideload, auto-update); single-instance (`AppInstance.FindOrRegisterForKey`) |

### 2. Window & navigation

- **Title bar:** custom (`ExtendsContentIntoTitleBar`), tall (48 epx): app icon + "Substratal"; centered search `AutoSuggestBox` (Ctrl+K / Ctrl+E) that is the command palette; caption buttons. Backdrop Mica.
- **NavigationView** `PaneDisplayMode=Left`: group headers Personal (Home, Inbox, Organizations), Administration (Users, Access, Roles, Audit Log, Send notification), Developer (Applications, API Keys, Webhooks, Tenant); **FooterMenuItems: Account, Plan & Billing, Settings** (Fluent convention). `InfoBadge` with the unread count on Inbox and pending invitations on Organizations.
- Adaptive: Left ≥ 1008 epx; LeftCompact 641–1007; LeftMinimal ≤ 640. List-detail pages collapse to single pane < 900 epx with drill-in + back.
- **Pages:** `Frame` navigation, page header = TitleLarge + `BreadcrumbBar` for drill-ins (Access › Dana Okafor) + right-aligned `CommandBar` (primary action uses AccentButtonStyle).
- **Sign-in** is a full-window page before the shell is created (no NavigationView).
- Multiple windows: "Open in new window" from context menus creates a secondary `Window` with a single page.

### 3. Interaction

| Interaction | Behavior |
|---|---|
| Context menus | `ContextFlyout` (`MenuFlyout` / `CommandBarFlyout`) on tiles, rows, IDs; opens on right-click, Shift+F10, Menu key, and **press-and-hold** on touch |
| Multi-select | `ListView SelectionMode=Extended` (click, Shift, Ctrl, Ctrl+A); touch: "Select" toggles checkbox mode; bulk actions in CommandBar |
| Drag & drop | users → org in NavigationView (invite); reorder pinned tiles; drop CSV/TXT/images/JSON from Explorer (`AllowDrop`, `DataPackageView.GetStorageItemsAsync`); drag export/audit event out as `StorageFile` |
| Hover preview | `ToolTip` with rich content (person/app card) after 500 ms on names/IDs; keyboard: Space |
| Touch | press-and-hold menus, swipe on list items via `SwipeControl` (leading Accept/Turn on, trailing Turn off/Remove → confirm), pull-to-refresh via `RefreshContainer` |
| Pen | precise pointer; inking not used |
| Undo | access toggle InfoBar with Undo (10 s) + Ctrl+Z |
| Secret reveal | ContentDialog; window `SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE)` while visible |

**Keyboard** (declared with `KeyboardAccelerator`, shown in tooltips; Alt access keys on CommandBar):

| Shortcut | Action |
|---|---|
| Ctrl+K / Ctrl+E | Command palette / search |
| Ctrl+1 … Ctrl+6 | Home, Inbox, Organizations, Account, Plan & Billing, Settings |
| Ctrl+Shift+U | Mark all as read (Inbox) |
| Ctrl+Shift+1 … 4 | Users, Access, Roles, Audit Log |
| Ctrl+Alt+1 … 4 | Applications, API Keys, Webhooks, Tenant |
| Ctrl+N | Contextual New |
| Ctrl+R / F5 | Refresh |
| Ctrl+Z | Undo access toggle |
| Ctrl+C | Copy ID |
| Delete | Destructive action on selection (confirms) |
| Alt+Left / Backspace (not editing) / mouse back | Back |
| F6 | Cycle focus: nav pane → list → detail |
| Shift+F10 / Menu | Context menu |
| Ctrl+Enter | Submit dialog |

### 4. Platform integration

| Concern | Implementation |
|---|---|
| Secure storage | Credential Locker; Windows Hello (`UserConsentVerifier`) to unlock on launch (opt-in) |
| Password / passkeys | Windows Hello passkeys via WebAuthn APIs when flag `passkeys` is on |
| Deep links | protocol `substratal:` in manifest; App URI Handler for `https://dashboard.substratalapps.com`; activation redirected to the running instance |
| App launch / billing | `Launcher.LaunchUriAsync` (default browser) for app launch and Stripe Checkout/Portal; the App URI Handler brings `/billing/return` back to the app, which polls until `plan = standard`. PKCE via browser + protocol return. Microsoft Store builds: confirm Store policy for third-party payment of a digital subscription before enabling Upgrade. |
| Notifications | **No Windows push provider in the API** (APNs/FCM/Web Push only) — see §4a; taskbar badge (`BadgeUpdateManager`) = `unread-count` |
| Share | `DataTransferManager` share sheet for export files and links |
| Files | `FileSavePicker` / `FileOpenPicker` (with window handle interop) |
| Clipboard | `ClipboardContentOptions.IsAllowedInHistory=false`, `IsRoamable=false` for secrets |
| Startup | `StartupTask` for "Open at sign-in" (opt-in) |
| Jump list | Taskbar jump list: Home, Users, API Keys, Audit Log, recent users |

### 4a. Notifications without push (polling + local app notifications)

| Step | Implementation |
|---|---|
| Source | `GET /users/me/notifications/unread-count` every 60 s while running (window open or minimized to tray), and `GET /users/me/notifications?created_after={lastSeen}` when the count rises |
| Show | `AppNotificationManager` local app notifications for each new notification since `lastSeen`, title/body as sent, buttons per category (Accept / Review sessions / Open) |
| Background | Optional "Keep checking for notifications in the background" (SET-01) → `StartupTask` + tray icon; off by default |
| Activation | Notification arguments → mark read → route by `action_url` |
| Badge | `BadgeUpdateManager` numeric badge = `unread_count`; `InfoBadge` on Inbox |
| Devices | NTF-03 lists other devices; "This device" explains Windows notifications come from the app while it's running. NTF-04 is replaced by this explanation |
| Future | If the API adds a `wns` provider (OPEN-QUESTIONS #2), register the WNS channel URI as a Device and drop polling |

### 4b. Brand & app icon

Assets: `brand-assets/` (see `shared/brand/BRAND.md`).

| Item | File | Wiring |
|---|---|---|
| Window / taskbar icon | `Substratal.ico` (16–256; 16–24 px use the small mark). Use `Substratal-dark.ico` on dark title bars. | `AppWindow.SetIcon(...)`. Also set `ApplicationIcon` in the .csproj. |
| MSIX visual assets | `Assets/`: Square44x44Logo (scale-100…400, targetsize-16…256 with `altform-unplated` for dark taskbar and `altform-lightunplated` for light), Square150x150Logo, Wide310x150Logo, SmallTile, LargeTile, StoreLogo, SplashScreen, BadgeLogo | Copy into the packaging project's `Assets/` and merge `Package.appxmanifest.snippet.xml`. |
| Title bar | 16 px small mark + "Substratal" (custom title bar, `ExtendsContentIntoTitleBar`) | Use the matching `svg/mark-small*.svg` with an `ImageIcon`. |
| Sign-in / About | `svg/lockup-inline*.svg` | Render via `SvgImageSource`. Accessible name "Substratal Apps". |
| Toasts | App logo comes from the package. `appLogoOverride` is only used for per-app icons in the inbox. | |

Plated tiles use the tint surface (`#E8E8F7`) so they read on both light and dark Start. Unplated taskbar icons switch palette by theme.


### 4c. Theming

`theme/SubstratalThemes.xaml` (merge first in App.xaml), `theme/Themes/{Light,Dark}/*.xaml` and `theme/ThemeService.cs`.

- Theme dictionaries override `SystemAccentColor*`, `TextFillColor*`, `SolidBackgroundFillColorBase`, `LayerFillColorDefault`, `CardBackgroundFillColorDefault` and `DividerStrokeColorDefault`, so stock WinUI controls pick up the theme. Custom UI uses `Substratal*Brush` via `{ThemeResource}`.
- `ThemeService.Apply` swaps the dictionaries and sets `RequestedTheme`. Tint Mica with `background` (`ThemeService.TintBackdrop`).
- High Contrast: the empty `HighContrast` dictionary keeps system colours.
- SET-01 uses a SettingsExpander containing a GridView of swatches.

### 5. Layout metrics

- Page padding 36 epx (24 compact); SettingsCard min height 68, 4 epx gap; 24 epx between groups.
- Control corner radius 4, overlays 8 (theme resources, not hard-coded).
- Type ramp: TitleLarge 28 (page titles), Subtitle 20 (detail/dialog titles), BodyStrong 14 (group headers), Body 14, Caption 12; IDs in Cascadia Mono.
- Icons: Segoe Fluent Icons / Fluent System Icons per INFORMATION-ARCHITECTURE §5.

### 6. Accessibility

Narrator: `AutomationProperties.Name` on badges/switches ("Access to Timetrack, On"); `LiveSetting=Polite` on InfoBars/toasts; grid patterns on tables. Text scaling to 225 %; High Contrast themes (no hard-coded colors — use theme/system resources). Every SwipeControl/press-and-hold action is also in the context menu and CommandBar.

### 7. Testing checklist

- [ ] Polling notifications: local app notifications for new items, actions, taskbar badge, background option; no duplicate toasts after restart.
- [ ] Stripe Checkout/Portal round trip via default browser and App URI Handler return.
- [ ] All screens via routes and NavigationView; persona matrix.
- [ ] 100 %, 150 %, 200 % scaling; touch laptop (touch + pen + trackpad simultaneously); tablet posture.
- [ ] Keyboard-only and Narrator passes of the 11 flows; Accessibility Insights FastPass clean.
- [ ] Single-instance deep-link activation while running/closed.
- [ ] MSIX install/upgrade via Store and App Installer.

---

## Full text: windows/SCREENS.md

Source: https://compositecode.github.io/substratalapps-applications-specs/spec/windows/SCREENS.md

All 75 screens with the native Windows 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 | Full-window page before the shell (no NavigationView) |
| grid | Frame page, `GridView` |
| list | Page with list pane (`ListView`) + detail (list-detail) |
| table | Page with table (`ListView` + header grid / DataTable) |
| detail | Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions |
| tab | `SelectorBar` item content |
| form | `ContentDialog` (≤ 5 fields) or drill-in page |
| settings | Page of `SettingsCard`s |
| confirm | `ContentDialog`, DefaultButton = Close |
| secret | `ContentDialog`, primary disabled until acknowledged |
| wizard | `ContentDialog` steps or page with `BreadcrumbBar` |
| popover | `TeachingTip` / right pane |
| handoff | `Launcher.LaunchUriAsync` |
| overlay | Title-bar flyout / pane |
| hub | — (NavigationView replaces hubs) |

### 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.

**Windows container:** Full-window page before the shell (no NavigationView)

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Email | email-field | TextBox InputScope=EmailSmtpAddress | body.email | required; RFC 5322; trim; autofill username |
| Password | password-field | PasswordBox (reveal) | body.password | required; reveal toggle; autofill current-password |
| Sign in | primary-button | Button Style=AccentButtonStyle | POST /auth/login | disabled until both fields non-empty |
| Forgot password? | link | HyperlinkButton | → AUTH-05 |  |
| Create an account | link | HyperlinkButton | → AUTH-03 |  |
| Continue with SSO | button | Button | POST /auth/sso/{provider}/callback | hidden behind flag `sso` (API returns 501 today) |
| Sign in with a passkey | button | 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.

**Windows container:** Full-window page before the shell (no NavigationView)

**Entry points:** AUTH-01 returned MfaChallenge

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Code | otp-field | TextBox InputScope=Number MaxLength=6 | body.code | 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill |
| Use a recovery code | link | HyperlinkButton | swaps to Recovery code field | only if `methods` includes recovery_code |
| Recovery code | text-field | TextBox | body.recovery_code | format xxxx-xxxx; case-insensitive |
| Expiry countdown | caption | TextBlock Caption | MfaChallenge.expires_in | announce at 60 s and 10 s |
| Verify | primary-button | Button Style=AccentButtonStyle | 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.

**Windows container:** Full-window page before the shell (no NavigationView)

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Display name | text-field | TextBox | body.display_name | optional; ≤ 100 |
| Email | email-field | TextBox InputScope=EmailSmtpAddress | body.email | required |
| Password | password-field | PasswordBox (reveal) | body.password | required; strength meter; autofill new-password; show policy hint |
| Create account | primary-button | Button Style=AccentButtonStyle | POST /auth/signup |  |
| Terms / Privacy | links | HyperlinkButton | 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.

**Windows container:** Full-window page before the shell (no NavigationView)

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Instruction | text | TextBlock | User.email | "We sent a link to {email}." |
| Resend email | button | Button | POST /auth/email/verify/resend | 30 s cooldown after each send |
| Continue to Substratal | button | Button | → APP-01 | unverified users may continue |
| Change email | link | HyperlinkButton | → 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.

**Windows container:** Full-window page before the shell (no NavigationView)

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Email | email-field | TextBox InputScope=EmailSmtpAddress | body.email | prefilled from AUTH-01 |
| Send reset link | primary-button | Button Style=AccentButtonStyle | 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.

**Windows container:** Full-window page before the shell (no NavigationView)

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| New password | password-field | PasswordBox (reveal) | body.new_password | strength meter; autofill new-password |
| Confirm password | password-field | PasswordBox (reveal) | client only | must match |
| Notice | caption | TextBlock Caption |  | "You'll be signed out on all devices." |
| Save password | primary-button | Button Style=AccentButtonStyle | 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.

**Windows container:** Full-window page before the shell (no NavigationView)

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Display name | text-field | TextBox | body.display_name | optional |
| Password | password-field | PasswordBox (reveal) | body.password | strength meter |
| Accept and continue | primary-button | Button Style=AccentButtonStyle | 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.

**Windows container:** Page of `SettingsCard`s

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Avatar | image-picker | FileOpenPicker + camera + paste/drop | Profile.avatar_url | camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL* |
| Display name | text-field | TextBox | Profile.display_name | required; ≤ 100; save on blur/Return |
| Email | row → ACC-02 | SettingsCard IsClickEnabled | User.email + email_verified + pending_email | shows "Pending: {pending_email}" when set |
| User ID | id-chip | TextBlock Cascadia + copy Button | User.id | copy |
| Contact email | email-field | TextBox InputScope=EmailSmtpAddress | Profile.contact_email | optional |
| Contact phone | phone-field | TextBox InputScope=TelephoneNumber | Profile.contact_phone | optional; E.164 formatting by locale |
| Plan | row → BIL-01 | SettingsCard IsClickEnabled | User.plan free\|standard + plan_sources | "Free" or "Standard · via {org}" / "Standard · your subscription" |
| Member since / last sign-in | caption | TextBlock Caption | User.created_at, last_login_at | locale formatted |
| Sign out | button | 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.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| New email | email-field | TextBox InputScope=EmailSmtpAddress | body.email | required; differs from current |
| Save | primary-button | Button Style=AccentButtonStyle | 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.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Current password | password-field | PasswordBox (reveal) | body.current_password | autofill current-password |
| New password | password-field | PasswordBox (reveal) | body.new_password | strength meter; autofill new-password |
| Save | primary-button | Button Style=AccentButtonStyle | 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.

**Windows container:** Page of `SettingsCard`s

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

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Password | row → ACC-03 | SettingsCard IsClickEnabled |  | "Change…" |
| Two-factor authentication | toggle | ToggleSwitch | User.mfa_enabled | on → ACC-05; off → ACC-06 |
| Sessions | list | ListView | 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 | Button (critical style) | DELETE /users/me/sessions/{sessionId} for each non-current session | confirm first; also stops push to those sessions' devices |
| Devices & notifications | row → NTF-03 | SettingsCard IsClickEnabled | 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.

**Windows container:** `ContentDialog` steps or page with `BreadcrumbBar`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Step 1 — Scan | qr + secret | Image (QR bitmap) | POST /users/me/mfa/totp → otpauth_uri, secret | QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile) |
| Step 2 — Confirm | otp-field | TextBox InputScope=Number MaxLength=6 | body.code | 6 digits |
| Step 3 — Recovery codes | secret-list | SecretReveal + Save (FileSavePicker)/Print | 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.

**Windows container:** `ContentDialog`, DefaultButton = Close

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Code or recovery code | otp-field / text-field | TextBox InputScope=Number MaxLength=6 | body.code \| body.recovery_code |  |
| Turn off | destructive-button | Button (critical style) | 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.

**Windows container:** Page of `SettingsCard`s

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Download my data | button | Button | GET /users/me/export | saves `substratal-export-{date}.json` via save dialog / share sheet / download |
| Delete account | destructive-row → ACC-08 | SettingsCard (critical text) |  |  |

| 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.

**Windows container:** `ContentDialog`, DefaultButton = Close

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| What happens | text | TextBlock |  | list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days |
| Reason | text-area | TextBox AcceptsReturn | body.reason | optional |
| Type DELETE | text-field | TextBox | client only | must equal localized confirm word |
| Delete account | destructive-button | Button (critical style) | POST /users/me/erasure-requests |  |

| Action | Call | Result handling |
|---|---|---|
| Delete account | `POST /users/me/erasure-requests` | 202 ErasureRequest → clear local data → AUTH-01 with "Your account is scheduled for deletion on {scheduled_for}." |

**Notes:** If the User has an individual Standard subscription, warn first and link to "Manage billing" (BIL-03) — erasure doesn't cancel Stripe billing by itself (see OPEN-QUESTIONS).


### D3 · My Apps

#### APP-01 — Home — My Apps
**Domain:** D3 My Apps · **Route:** `/` · **Archetype:** grid · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** See every app you can use, its access status, and launch it.

**Windows container:** Frame page, `GridView`

**Loads:** `GET /applications (catalog the caller can see; each has access_tier free|standard)` · `GET /users/me/entitlements?include_inactive=true` · `GET /users/me (plan, plan_sources)` · `GET /organizations?status=active (for pending invites)` · `GET /users/me/notifications/unread-count (bell badge)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Search | search | AutoSuggestBox | client filter on name/description |  |
| Grid / List | segmented | Segmented (Toolkit) / RadioButtons | client preference | persisted locally |
| Invitation banner | banner | InfoBar | Organization.my_membership_status = pending | "{org} invited you…" → ORG-09 |
| Verify-email banner | banner | InfoBar | User.email_verified = false | → AUTH-04 |
| Your apps | tile-grid | GridView / ItemsView | join Application × resolved entitlement per app | tile = icon, name, StatusBadge, helper text (COPY); order: recently launched, then A–Z; pin to top (local) |
| Included with Standard | tile-grid | GridView / ItemsView | standard apps with status none while User.plan = free | badge "Standard"; tap → upsell sheet → BIL-01 ("Get every app for $6/month") |
| Available | tile-grid | GridView / ItemsView | other public apps with status none | "Get access" → app support/launch page |
| Inbox bell | toolbar button + count | CommandBar | NotificationUnreadCount.unread_count | → NTF-01 |
| Past apps | row → APP-05 | SettingsCard IsClickEnabled | count of revoked/expired |  |

| Action | Call | Result handling |
|---|---|---|
| Open tile (active) | `→ APP-03` | Launch |
| Open tile (other status) | `→ APP-02` | Explain status |
| Context menu / long-press |  | Open · App settings (SET-02) · App profile (APP-04) · Pin · Copy app ID |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: "No apps yet" (COPY); Offline: show cached grid read-only, launch disabled

**Notes:** Resolved status per app: if any path active → Active; else precedence scheduled > disabled > expired > revoked > none (matches the API's resolved_entitlement_status). Use `GET /users/me/apps/{appId}/effective-permissions` for the authoritative answer when the user opens APP-02. Tile helper text by source of the active path: `free` → "Free"; `subscription` → "Standard" (or "Standard · via {org}"); developer grants as before. The Hub itself (`app_hub`) is hidden from the grid.

#### APP-02 — App info
**Domain:** D3 My Apps · **Route:** `/apps/:appId` · **Archetype:** popover · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** Explain an app's access status and how the user has access.

**Windows container:** `TeachingTip` / right pane

**Loads:** `GET /applications/{appId}` · `GET /users/me/apps/{appId}/effective-permissions`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header | icon + name + StatusBadge | Image + TextBlock + StatusBadge | Application, EffectivePermissions.entitlement_status |  |
| Description | text | TextBlock | Application.description |  |
| Access paths | list | ListView | EffectivePermissions.access_paths[]: source, status, organization_id, member_decision | "Free", "Standard · your subscription", "Standard · via Northwind", "Personal · Granted by admin", "Via Northwind (seat)"; member_decision excluded explained |
| Tier | badge | StatusBadge (Border + FontIcon + TextBlock) | Application.access_tier | "Free" or "Standard" |
| Your roles | chips | ToggleButton group / SelectorBar | EffectivePermissions.roles | Phase 2 |
| Primary action | button | Button |  | Active → Open; none + standard tier + plan free → Upgrade to Standard (BIL-01); none+public → Get access; disabled/expired/revoked → Contact support (support_url) |
| Notification settings | row → SET-02 | SettingsCard IsClickEnabled | resolved notifications.push for this app | "Push notifications: On/Off" |
| App settings / App profile | rows | SettingsCard rows | → SET-02, APP-04 | only when active |

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

#### APP-03 — Launch app
**Domain:** D3 My Apps · **Route:** `/apps/:appId/launch` · **Archetype:** handoff · **Phase:** MVP  
**Who:** Active entitlement  
**Purpose:** Mint an app token (or authorization code) and open the app.

**Windows container:** `Launcher.LaunchUriAsync`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Progress | inline spinner on tile | ProgressRing on tile |  | ≤ 1 s target |
| Organization picker | menu | DropDownButton / MenuFlyout | body.organization_id | only when the user has access via >1 org and the app needs org context |

| Action | Call | Result handling |
|---|---|---|
| Embedded launch | `POST /auth/app-tokens {application_id, organization_id?}` | 200 AppToken (5-min JWT) → open launch_url with token per app contract; never cache the token. 403 → refresh status, show APP-02. 409 → choose org. |
| Hosted launch (Phase 3) | `POST /auth/oauth/authorization-codes (PKCE S256)` | 201 → open redirect_uri?code=…&state=… in system browser / auth session |

**Notes:** Record launch time locally for "recently launched" ordering.

#### APP-04 — App profile
**Domain:** D3 My Apps · **Route:** `/apps/:appId/profile` · **Archetype:** form · **Phase:** Phase 2  
**Who:** Self, active entitlement  
**Purpose:** Per-app display handle and custom profile fields.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

**Loads:** `GET /users/me/apps/{appId}/profile`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Display handle | text-field | TextBox | AppProfile.display_handle | optional; shows effective_display_name as placeholder |
| Custom fields | key/value editor | ItemsRepeater of TextBox pairs + Add | AppProfile.custom | only keys the app documents; null removes a key |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /users/me/apps/{appId}/profile (If-Match)` |  |

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

#### APP-05 — Past apps
**Domain:** D3 My Apps · **Route:** `/apps/past` · **Archetype:** list · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** Apps whose access was revoked or expired.

**Windows container:** Page with list pane (`ListView`) + detail (list-detail)

**Loads:** `GET /users/me/entitlements?include_inactive=true&status=revoked,expired`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | ListView | Application name/icon, status, ended/revoked date, source | tap → APP-02 |

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


### D4 · Settings

#### SET-01 — Settings — Global
**Domain:** D4 Settings · **Route:** `/settings` · **Archetype:** settings · **Phase:** MVP  
**Who:** Self  
**Purpose:** Preferences that apply to every app.

**Windows container:** Page of `SettingsCard`s

**Loads:** `GET /users/me/settings` · `GET /users/me/apps/app_hub/settings` (theme choices)

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Language | select | ComboBox | Settings.locale | BCP 47; default = OS language; changing re-localizes the UI live |
| Time zone | searchable select | AutoSuggestBox | Settings.timezone | IANA; default = OS zone; "Use device time zone" option |
| Appearance | segmented | Segmented (Toolkit) / RadioButtons | Settings.theme light/dark/system | Light · Dark · Match system (default). Applies instantly; Match system follows OS changes live. See THEMES.md |
| Light theme | swatch picker | GridView (single selection) of swatches inside a SettingsExpander | `app_hub` setting `appearance.light_theme` | 5 themes: Teal Ledger, Indigo Root, **Moss Paper (default)**, Coral Clay, Spore Pink. Each swatch previews background, sidebar, accent and text; selected swatch shows a check and accent ring; accessible name "{name} light theme, selected" |
| Dark theme | swatch picker | GridView (single selection) of swatches inside a SettingsExpander | `app_hub` setting `appearance.dark_theme` | 5 themes: Night Soil, Deep Teal, Graphite Spore, **Forest Floor (default)**, Ember Clay. Same behaviour as Light theme |
| Push notifications | toggle | ToggleSwitch | Settings.notifications.push (missing = on) | global master switch for push from every app; inbox still fills (NTF-01). Shows OS permission state beneath: "Allowed in system settings" / "Blocked — Open system settings" |
| Email notifications | toggle | ToggleSwitch | Settings.notifications.email |  |
| SMS notifications | toggle | ToggleSwitch | Settings.notifications.sms | disabled until Profile.contact_phone set |
| Share usage analytics | toggle | ToggleSwitch | client preference | default per platform policy; see CLIENT-ARCHITECTURE §10 |
| Scope list | list | ListView | Global + each app with active access | → SET-02 (desktop: middle column) |

| Action | Call | Result handling |
|---|---|---|
| Change any row | `PATCH /users/me/settings (If-Match)` | Optimistic; revert + toast on error. |
| Choose a light or dark theme | `PATCH /users/me/apps/app_hub/settings (If-Match)` with `appearance.light_theme` / `appearance.dark_theme` | Apply instantly, cache locally, then sync; 409 → refetch and re-apply; 422 → revert + toast "Couldn't save your theme. Try again." |

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

#### SET-02 — Settings — Per app
**Domain:** D4 Settings · **Route:** `/settings/apps/:appId` · **Archetype:** settings · **Phase:** Phase 2  
**Who:** Self, active entitlement  
**Purpose:** Override global settings and set app-specific preferences from the app's settings_schema.

**Windows container:** Page of `SettingsCard`s

**Loads:** `GET /applications/{appId} (settings_schema)` · `GET /users/me/apps/{appId}/settings`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Schema form | generated rows | SchemaForm | settings_schema properties × AppSettingsResolved.resolved | string+enum → select; string → text; string date-time → date-time picker; boolean → toggle; integer/number with min/max → stepper/number; object/array → read-only JSON preview |
| Source caption | caption per row | TextBlock Caption | AppSettingsResolved.sources[key] | app_override → "Changed for this app · Reset"; global → "Using your global setting"; app_default → "App default" |
| Reset | link per overridden row | SettingsCard IsClickEnabled | PATCH overrides {key:null} |  |
| Push notifications for this app | toggle | ToggleSwitch | resolved notifications.push; override via overrides.notifications.push | source caption as other rows; Reset clears the override |
| Stale overrides | banner | InfoBar | AppSettingsResolved.stale_overrides | "Some saved settings no longer apply" → Clear |

| Action | Call | Result handling |
|---|---|---|
| Change row | `PATCH /users/me/apps/{appId}/settings {overrides:{key:value}} (If-Match)` | 422 → field error from details (enum_mismatch, out_of_range, undeclared_key). |

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


### D5 · Organizations

#### ORG-01 — Organizations
**Domain:** D5 Organizations · **Route:** `/orgs` · **Archetype:** list · **Phase:** MVP  
**Who:** Signed in (all orgs with organizations.manage)  
**Purpose:** Organizations you belong to, plus pending invitations.

**Windows container:** Page with list pane (`ListView`) + detail (list-detail)

**Loads:** `GET /organizations`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Pending invitations | section | group header + cards | my_membership_status = pending | Accept / Decline inline → ORG-09 |
| Rows | list | ListView | Organization: name, member_count, my_role, status | suspended orgs show badge |
| Search | search | AutoSuggestBox | q | server-side when > 50 |
| New organization | primary-button | Button Style=AccentButtonStyle | → ORG-02 |  |

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

#### ORG-02 — New organization
**Domain:** D5 Organizations · **Route:** `/orgs/new` · **Archetype:** form · **Phase:** MVP  
**Who:** Any active user  
**Purpose:** Create an organization; creator becomes org admin.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextBox | body.name | required; ≤ 100 |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /organizations (Idempotency-Key)` | 201 → ORG-03. 429 → rate limited. |

#### ORG-03 — Organization — Members
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members` · **Archetype:** detail · **Phase:** MVP  
**Who:** Member (read), org_admin / organizations.manage (write)  
**Purpose:** See and manage members and pending invitations.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /organizations/{id}` · `GET /organizations/{id}/members`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header | avatar + name + id-chip + status | TextBlock Cascadia + copy Button | Organization | my_role shown as caption |
| Tabs | tabs | SelectorBar | Members · Apps & seats · Subscription · Activity · Settings | Subscription tab = BIL-02 (org_admin / billing.manage only) |
| Filter | chips | ToggleButton group / SelectorBar | role, membership_status |  |
| Rows | list | ListView | OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at | pending rows show Invited badge + invited date |
| Invite | primary-button | Button Style=AccentButtonStyle | → ORG-04 | org_admin only |

| Action | Call | Result handling |
|---|---|---|
| Row menu | `→ ORG-05` | Change role · Remove · Withdraw invitation |

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

#### ORG-04 — Invite member
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members/invite` · **Archetype:** form · **Phase:** MVP  
**Who:** org_admin; organizations.manage may add by user_id  
**Purpose:** Invite people by email (or add an existing user, staff only).

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Emails | token-field | TokenizingTextBox | body.email (one request per address) | paste a list separated by commas/newlines |
| Role | radio-group | RadioButtons | body.role org_admin \| member | default member |
| Add existing user | user-picker | AutoSuggestBox / TokenizingTextBox | body.user_id | organizations.manage only |

| Action | Call | Result handling |
|---|---|---|
| Send invitations | `POST /organizations/{id}/members × n` | Per-address results summary; 409 → "already a member" per address. Responses look identical whether or not the address has an account (by design). |

**Notes:** Existing Users also receive an `account.organization_invitation` platform alert (NTF-01, and a push where the Hub has a device).

#### ORG-05 — Member actions
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members/:userId` · **Archetype:** confirm · **Phase:** MVP  
**Who:** org_admin / organizations.manage  
**Purpose:** Change a member's role or remove them.

**Windows container:** `ContentDialog`, DefaultButton = Close

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Role | radio-group | RadioButtons | body.role |  |
| Remove from organization | destructive-button | Button (critical style) | DELETE …/members/{userId} | COPY confirm |

| Action | Call | Result handling |
|---|---|---|
| Change role | `PATCH /organizations/{id}/members/{userId}` | 409 → last admin can't be demoted (explain). |
| Remove | `DELETE /organizations/{id}/members/{userId}` | 409 → last admin can't leave/remove. |

#### ORG-06 — Organization — Apps & seats
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/apps` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Member (read), org_admin (write)  
**Purpose:** Org-wide app grants and who they include.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /organizations/{id}/entitlements`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | ListView | Entitlement (org): app, status, source (org_seat \| subscription), member_scope, included_member_count, starts_at/ends_at | AccessSwitch for status (org_admin with rights); `subscription` rows read "Included with the organization's Standard subscription" and their scope is edited in BIL-02 |
| Grant app | button | Button | → ORG-07 |  |

| Action | Call | Result handling |
|---|---|---|
| Toggle | `PATCH /entitlements/{id} {status}` | Off requires confirm (COPY). On platform-managed rows only active ⇄ disabled is allowed; anything else 409 entitlement_platform_managed. |
| Edit scope | `→ ORG-07 (org_seat) / BIL-02 (subscription)` |  |

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

#### ORG-07 — Grant app to organization / member scope
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/apps/grant` · **Archetype:** form · **Phase:** Phase 2  
**Who:** entitlements.manage or app owner; scope edits by org_admin  
**Purpose:** Create an org-wide grant or change who it includes.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| App | select | ComboBox | body.application_id | create only |
| Who gets access | radio-group | RadioButtons | member_scope all_members \| allowlist \| denylist | COPY labels |
| Members | member-picker | AutoSuggestBox / TokenizingTextBox | member_overrides | visible for allowlist/denylist |
| Starts / Ends | date-fields | CalendarDatePicker / TimePicker | starts_at, ends_at | optional |
| Order ID | text-field | TextBox | order_id | optional; staff/app owner |

| Action | Call | Result handling |
|---|---|---|
| Grant | `POST /organizations/{id}/entitlements (Idempotency-Key)` | 409 entitlement_already_exists → already granted (view it). 422 → field errors. |
| Save scope | `PATCH /entitlements/{id} {member_scope, member_overrides}` |  |

#### ORG-08 — Organization — Settings
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/settings` · **Archetype:** settings · **Phase:** MVP  
**Who:** org_admin (rename), organizations.manage (suspend), any member (leave)  
**Purpose:** Rename, leave, or (staff) suspend the organization.

**Windows container:** Page of `SettingsCard`s

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextBox | Organization.name |  |
| Leave organization | destructive-row | SettingsCard (critical text) | DELETE …/members/me |  |
| Suspend / Reactivate | destructive-row | SettingsCard (critical text) | PATCH {status, reason} | staff only; reason required |

| Action | Call | Result handling |
|---|---|---|
| Rename | `PATCH /organizations/{id} {name}` |  |
| Leave | `DELETE /organizations/{id}/members/me` | 409 last admin |

#### ORG-09 — Invitation
**Domain:** D5 Organizations · **Route:** `/orgs/:orgId/invitation` · **Archetype:** confirm · **Phase:** MVP  
**Who:** Invited user  
**Purpose:** Accept or decline an organization invitation.

**Windows container:** `ContentDialog`, DefaultButton = Close

**Entry points:** Invitation banner on APP-01; ORG-01 pending section; `account.organization_invitation` notification (NTF-01 / push action "Accept")

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Org name + inviter | text | TextBlock | Organization |  |
| Accept | primary-button | Button Style=AccentButtonStyle |  |  |
| Decline | button | Button |  |  |

| Action | Call | Result handling |
|---|---|---|
| Accept | `POST /organizations/{id}/members/me/accept` | 200 → ORG-03; apps granted via org (and the org's Standard subscription, if it covers the member) appear on APP-01. Mark the related notification read. |
| Decline | `DELETE /organizations/{id}/members/me` |  |


### D6 · Users

#### USR-01 — Users
**Domain:** D6 Users · **Route:** `/admin/users` · **Archetype:** table · **Phase:** MVP  
**Who:** users.list (write actions need users.manage)  
**Purpose:** Find and manage user accounts.

**Windows container:** Page with table (`ListView` + header grid / DataTable)

**Loads:** `GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Search | search | AutoSuggestBox | q (name, email, ID prefix usr_) | debounced 300 ms; exact ID jumps to USR-03 |
| Status filter | chips | ToggleButton group / SelectorBar | status active \| invited \| suspended \| deleted | deleted visible to users.manage only |
| More filters | menu | DropDownButton / MenuFlyout | application_id, organization_id, created range |  |
| Columns | table | ListView + header grid (sortable) | display name + email, status, id, last_login_at, created_at | sortable client-side within loaded page; server order = created_at desc |
| Multi-select | selection | ComboBox |  | bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows) |
| Invite user | primary-button | Button Style=AccentButtonStyle | → USR-02 | users.manage |

| Action | Call | Result handling |
|---|---|---|
| Open row | `→ USR-03` |  |
| Row menu |  | Suspend · Reactivate · Resend invitation · Copy ID · Copy email |

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

#### USR-02 — Invite user
**Domain:** D6 Users · **Route:** `/admin/users/new` · **Archetype:** form · **Phase:** MVP  
**Who:** users.manage  
**Purpose:** Create a user and send an invitation (or create active).

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Email | email-field | TextBox InputScope=EmailSmtpAddress | body.email | required |
| Display name | text-field | TextBox | body.display_name | optional |
| Send invitation email | toggle | ToggleSwitch | body.send_invitation | default on |
| Create as active | toggle | ToggleSwitch | body.status active\|invited | default off (invited) |

| Action | Call | Result handling |
|---|---|---|
| Invite | `POST /users (Idempotency-Key)` | 201 → USR-03. 409 → "A user with that email exists" + link. |

#### USR-03 — User — Overview
**Domain:** D6 Users · **Route:** `/admin/users/:userId` · **Archetype:** detail · **Phase:** MVP  
**Who:** users.list (read), users.manage (write)  
**Purpose:** One user's account state and administrative actions.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /users/{id}` · `GET /users/{id}/profile` · `GET /users/{id}/subscription (billing.manage only)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header | avatar, display name, email, id-chip, StatusBadge | TextBlock Cascadia + copy Button | User + Profile |  |
| Tabs | tabs | SelectorBar | Overview · Access · Roles · Sessions · Notifications · Activity | Activity = AUD-01 filtered target_user_id; Notifications = NTF-05 |
| Facts | key-value list | SettingsCard (read-only) | email_verified, pending_email, mfa_enabled, plan, plan_sources, created_at, last_login_at, signup_application_id, test_mode | plan_sources link to the org or to BIL-01 subscription view (billing.manage) |
| Actions | toolbar/menu | DropDownButton / MenuFlyout |  | Suspend (USR-05) · Reactivate · Resend invitation · Reset two-factor · Export data · Delete · Restore · Cancel scheduled erasure |

| Action | Call | Result handling |
|---|---|---|
| Reactivate / Restore | `PATCH /users/{id} {status:active} (If-Match)` |  |
| Resend invitation | `POST /users/{id}/invitation` | 202 toast; 409 → not in invited state |
| Reset two-factor | `DELETE /users/{id}/mfa/totp` | confirm |
| Export | `GET /users/{id}/export` | save file |
| Delete | `DELETE /users/{id}` | confirm |
| Cancel erasure | `DELETE /users/{id}/erasure-requests/current` | visible during 7-day window |

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

#### USR-04 — User — Sessions
**Domain:** D6 Users · **Route:** `/admin/users/:userId/sessions` · **Archetype:** tab · **Phase:** MVP  
**Who:** users.manage  
**Purpose:** See and revoke a user's sessions.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /users/{id}/sessions`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | ListView | Session: user_agent, ip_address, amr, created_at, last_seen_at, expires_at | staff see full IP |

| Action | Call | Result handling |
|---|---|---|
| Revoke | `DELETE /users/{id}/sessions/{sessionId}` |  |

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

#### USR-05 — Suspend user
**Domain:** D6 Users · **Route:** `/admin/users/:userId/suspend` · **Archetype:** confirm · **Phase:** MVP  
**Who:** users.manage  
**Purpose:** Suspend an account (signs them out everywhere).

**Windows container:** `ContentDialog`, DefaultButton = Close

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Reason | text-area | TextBox AcceptsReturn | body.reason | optional, written to audit |

| Action | Call | Result handling |
|---|---|---|
| Suspend | `POST /users/{id}/suspend` | 200 → badge Suspended. 409 → already suspended. |

**Notes:** Suspension revokes sessions and deactivates every push device the user holds; say so in the confirm body.


### D7 · Access

#### ACX-01 — User — Access
**Domain:** D7 Access · **Route:** `/admin/users/:userId/access` · **Archetype:** tab · **Phase:** MVP  
**Who:** users.list (read), entitlements.manage or app owner (write)  
**Purpose:** Turn apps on or off for one person; see every path that grants access.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /users/{id}/entitlements?include_inactive=true` · `GET /applications`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows (one per app) | list | ListView | resolved status per app + paths (personal Entitlement, org grants) | StatusBadge + AccessSwitch on the personal path |
| Paths disclosure | expandable | SettingsExpander | Entitlement.granted_via, source, subscription_id, organization_id, member_decision, starts_at/ends_at, disabled_reason | platform-managed paths (`free`, `subscription`) show a "Managed by Substratal" caption; developer grants and platform rows can coexist |
| Org-only access | caption + link | TextBlock Caption |  | switch replaced by "Via {org}" → ORG-06 |
| Grant access | primary-button | Button Style=AccentButtonStyle | → ACX-02 |  |
| Why? | button | Button | → ACX-04 |  |

| Action | Call | Result handling |
|---|---|---|
| Switch off | `→ ACX-03 confirm → PATCH /entitlements/{id} {status:disabled, disabled_reason}` | Optimistic with Undo (PATCH status:active) for 10 s |
| Switch on | `PATCH /entitlements/{id} {status:active}` | No confirm |
| Remove grant made in error | `DELETE /entitlements/{id}` | Only developer grants with no order_id and < 24 h old; never on `free`/`subscription` rows (409 entitlement_platform_managed); 409 otherwise |

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

**Notes:** The switch on a platform-managed row only disables / re-enables it (the platform never re-enables a row an admin disabled). Term, source and scope edits are hidden for those rows.

#### ACX-02 — Grant access
**Domain:** D7 Access · **Route:** `/admin/users/:userId/access/grant` · **Archetype:** form · **Phase:** MVP  
**Who:** entitlements.manage or app owner (own app)  
**Purpose:** Give a person access to an app.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| App | select | ComboBox | body.application_id | app owners see only their apps |
| Type | radio-group | RadioButtons | body.source purchase \| trial \| admin_grant | `free` and `subscription` are never offered — the platform grants them |
| Order ID | text-field | TextBox | body.order_id | required feel for purchase |
| Starts | date-time | CalendarDatePicker / TimePicker | body.starts_at | default now |
| Ends | date-time | CalendarDatePicker / TimePicker | body.ends_at | optional; required for trial |

| Action | Call | Result handling |
|---|---|---|
| Grant | `POST /users/{id}/entitlements (Idempotency-Key, required)` | 201 → row Active. 409 entitlement_already_exists → "Already has access — view it". A platform-managed row doesn't block a grant (they coexist). |

#### ACX-03 — Turn off / edit access
**Domain:** D7 Access · **Route:** `/admin/entitlements/:entId` · **Archetype:** confirm · **Phase:** MVP  
**Who:** entitlements.manage or app owner  
**Purpose:** Confirm turning off access, or edit term/source.

**Windows container:** `ContentDialog`, DefaultButton = Close

**Loads:** `GET /entitlements/{id}`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Title | text | TextBlock |  | "Turn off {app} for {name}?" |
| Reason | text-field | TextBox | body.disabled_reason | optional; audit |
| Edit term (advanced) | disclosure | Expander | ends_at, source trial→purchase, order_id | hidden for platform-managed rows |

| Action | Call | Result handling |
|---|---|---|
| Turn off | `PATCH /entitlements/{id} {status:disabled, disabled_reason} (If-Match)` | 409 version_conflict → reload and re-apply |
| Save changes | `PATCH /entitlements/{id}` |  |

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

#### ACX-04 — Access check (Why?)
**Domain:** D7 Access · **Route:** `/admin/users/:userId/apps/:appId/why` · **Archetype:** popover · **Phase:** MVP  
**Who:** users.list or app owner  
**Purpose:** Explain exactly why a person can or can't use an app.

**Windows container:** `TeachingTip` / right pane

**Loads:** `GET /users/{id}/apps/{appId}/effective-permissions`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Verdict | banner | InfoBar | allowed | "Dana can use Timetrack" / "can't" + first failing reason (user_status, entitlement_status) |
| Checks | checklist | ItemsRepeater with icons | user_status, entitlement_status, access_paths[], roles, effective_permissions | each with pass/fail icon |
| Computed at | caption | TextBlock Caption | computed_at |  |

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

#### ACX-05 — Access (all)
**Domain:** D7 Access · **Route:** `/admin/access` · **Archetype:** table · **Phase:** MVP  
**Who:** entitlements.manage or app owner (confined to own apps)  
**Purpose:** Every grant across users: audit expirations, trials, revocations.

**Windows container:** Page with table (`ListView` + header grid / DataTable)

**Loads:** `GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Filters | chips/menus | ToggleButton group / SelectorBar | app, status, source, ends before (Expiring in 7/30 days presets) |  |
| Columns | table | ListView + header grid (sortable) | user, app, status, source (incl. free / subscription), subscription_id, starts_at, ends_at, order_id, granted_via | source filter defaults to developer grants; "Show platform-managed" chip adds free/subscription rows |
| Multi-select | selection | ComboBox |  | bulk Turn off (confirm, one PATCH per row, progress + per-row result) |

| Action | Call | Result handling |
|---|---|---|
| Open row | `→ ACX-01 for that user` |  |

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


### D8 · Roles & Permissions

#### ROL-01 — Roles
**Domain:** D8 Roles & Permissions · **Route:** `/admin/roles` · **Archetype:** list · **Phase:** MVP  
**Who:** users.list (read), roles.manage (write)  
**Purpose:** Platform and app roles.

**Windows container:** Page with list pane (`ListView`) + detail (list-detail)

**Loads:** `GET /roles?scope` · `GET /permissions (includes notifications.send)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Scope | segmented | Segmented (Toolkit) / RadioButtons | scope platform \| {application_id} | app picker when App |
| Rows | list | ListView | Role: name, scope, seed, assignment_count, description | seed → lock icon |
| New role | primary-button | Button Style=AccentButtonStyle | → ROL-03 | roles.manage |

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

#### ROL-02 — Role detail
**Domain:** D8 Roles & Permissions · **Route:** `/admin/roles/:roleId` · **Archetype:** detail · **Phase:** MVP  
**Who:** users.list (read), roles.manage (write)  
**Purpose:** View or edit a role's permissions.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /roles/{id}` · `GET /permissions?scope`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header | name, id-chip, scope, assignment_count | TextBlock Cascadia + copy Button |  |  |
| Description | text-field | TextBox | Role.description | read-only when seed |
| Permissions | checkbox-list grouped by prefix | ItemsRepeater of CheckBox, grouped | Role.permissions ⊂ Permission[].key | read-only when seed; show Permission.description |
| Holders | link | HyperlinkButton | → USR-01 filtered | Phase 2 if API adds filter; else hidden |
| Delete role | destructive-button | Button (critical style) | DELETE /roles/{id} | only non-seed with assignment_count 0 |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /roles/{id} {description, permissions} (If-Match)` |  |
| Delete | `DELETE /roles/{id}` | 409 → still assigned |

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

#### ROL-03 — New role
**Domain:** D8 Roles & Permissions · **Route:** `/admin/roles/new` · **Archetype:** form · **Phase:** MVP  
**Who:** roles.manage  
**Purpose:** Define a custom role.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextBox | body.name | ^[a-z][a-z0-9_]{1,40}$; live validation |
| Scope | select | ComboBox | body.scope | platform or an application |
| Description | text-area | TextBox AcceptsReturn | body.description |  |
| Permissions | checkbox-list | ItemsRepeater of CheckBox, grouped | body.permissions | ≥ 1 |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /roles` | 409 → name taken in scope |

#### ROL-04 — User — Roles
**Domain:** D8 Roles & Permissions · **Route:** `/admin/users/:userId/roles` · **Archetype:** tab · **Phase:** MVP  
**Who:** users.list (read), roles.manage (write)  
**Purpose:** Assign and remove roles for a user.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /users/{id}/roles`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | ListView | UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit | implicit → "Granted with app access", not removable |
| Assign role | button → role picker | AutoSuggestBox / TokenizingTextBox |  |  |

| Action | Call | Result handling |
|---|---|---|
| Assign | `POST /users/{id}/roles/{roleId}` | idempotent; 200 already held |
| Remove | `DELETE /users/{id}/roles/{roleId}` | 409 → implicit role |

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


### D9 · Applications

#### APL-01 — Applications
**Domain:** D9 Applications · **Route:** `/dev/apps` · **Archetype:** list · **Phase:** MVP  
**Who:** Any (catalog), owner/applications.manage (manage)  
**Purpose:** Applications you own or administer.

**Windows container:** Page with list pane (`ListView`) + detail (list-detail)

**Loads:** `GET /applications?owned=true (staff: all, filter review_status)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | ListView | ApplicationSummary: icon, name, slug, visibility, review_status |  |
| Register app | primary-button | Button Style=AccentButtonStyle | → APL-02 | applications.manage (Phase 3: self-service) |

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

#### APL-02 — Register application
**Domain:** D9 Applications · **Route:** `/dev/apps/new` · **Archetype:** wizard · **Phase:** MVP  
**Who:** applications.manage  
**Purpose:** Register a new application.

**Windows container:** `ContentDialog` steps or page with `BreadcrumbBar`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextBox | name |  |
| Slug | text-field | TextBox | slug | ^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn |
| Launch URL | url-field | TextBox InputScope=Url | launch_url | https |
| Redirect URIs | url-list | ItemsRepeater of TextBox + Add | redirect_uris |  |
| Visibility | radio-group | RadioButtons | visibility public \| invite_only \| internal |  |
| Access tier | radio-group | RadioButtons | access_tier free \| standard (default standard) | applications.manage only; explains who gets access |
| Owner | select | ComboBox | owner_user_id \| owner_organization_id |  |
| Icon | image-picker | FileOpenPicker + camera + paste/drop | icon_url |  |
| Support URL | url-field | TextBox InputScope=Url | support_url |  |
| Email from name | text-field | TextBox | email_from_name |  |

| Action | Call | Result handling |
|---|---|---|
| Register | `POST /applications (Idempotency-Key)` | 409 slug taken; 422 field errors. Registration grants the platform-managed Entitlements its access_tier calls for. |

#### APL-03 — Application — Overview
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId` · **Archetype:** detail · **Phase:** MVP  
**Who:** Owner (configure), applications.manage (anything)  
**Purpose:** Configure an application.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /applications/{id}`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header | icon, name, id-chip, review StatusBadge | TextBlock Cascadia + copy Button |  |  |
| Tabs | tabs | SelectorBar | Overview · Settings schema · Permissions & roles · Push · Keys · Webhooks · Access · Activity | Push = APL-07; Keys/Webhooks/Access = KEY-01/WHK-01/ACX-05 filtered to app |
| Fields | settings rows | SettingsCards | name, description, launch_url, redirect_uris, support_url, email_from_name, icon_url (owner); visibility, access_tier (applications.manage) | slug read-only with lock |
| Platform alerts | toggle | ToggleSwitch | receives_platform_notifications | owner-writable; explain that security/account alerts will push under this app's name and icon |
| Review notes | banner | InfoBar | review_status rejected/suspended + review_notes |  |

| Action | Call | Result handling |
|---|---|---|
| Save field | `PATCH /applications/{id} (If-Match)` | 422 → field errors |

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

#### APL-04 — Settings schema
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/schema` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Owner / applications.manage  
**Purpose:** Declare per-user settings the app stores in Substratal.

**Windows container:** `SelectorBar` item content

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Properties | list | ListView | settings_schema.properties: key, type, enum, default, min/max, maxLength | add/edit via form |
| JSON editor | code-editor | TextBox Cascadia AcceptsReturn | settings_schema | JSON validation; diff before save |
| Preview | rendered SET-02 form | SchemaForm preview |  | shows exactly what users will see |
| Stats | caption | TextBlock Caption | settings_schema_stats |  |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /applications/{id} {settings_schema}` | 422 → schema rule errors |

#### APL-05 — Permissions & roles
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/roles` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Owner / applications.manage  
**Purpose:** Declare app permissions, available app roles and the default role.

**Windows container:** `SelectorBar` item content

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Permissions | editable list | ListView + header grid (sortable) | permissions[] {key, description} | key app.<slug>.* |
| Available app roles | token list | TokenizingTextBox | available_app_roles (≤ 20) |  |
| Default app role | select | ComboBox | default_app_role | held implicitly by everyone with access |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /applications/{id}` |  |

#### APL-06 — Review
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/review` · **Archetype:** form · **Phase:** Phase 3  
**Who:** applications.manage  
**Purpose:** Staff moderation of an application.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Review status | radio-group | RadioButtons | review_status |  |
| Notes | text-area | TextBox AcceptsReturn | review_notes | visible to owner |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /applications/{id} {review_status, review_notes}` |  |

#### APL-07 — Push credentials
**Domain:** D9 Applications · **Route:** `/dev/apps/:appId/push` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** Owner or applications.manage with a User token (write); app key / owner (read)  
**Purpose:** Configure APNs, FCM and Web Push for an Application so it can send push notifications.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /applications/{id}/push-credentials`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Providers | list | ListView | PushCredential per provider apns \| fcm \| web_push: status active\|failed, failure_reason, failed_at, last_used_at; apns team_id/key_id/topic; fcm project_id/client_email; web_push subject + vapid_public_key | one row per provider with Add / Edit / Remove; failed → warning banner with reason and Fix |
| Add APNs | form | ContentDialog form | POST body: provider apns, team_id (10), key_id (10), topic (bundle id), private_key (.p8) | file picker / drop / paste for the .p8; key never shown again |
| Add FCM | form | ContentDialog form | POST body: provider fcm, service_account (JSON file) | drop/pick the downloaded JSON; validate shape client-side |
| Add Web Push | form | ContentDialog form | POST body: provider web_push, subject (mailto:/https:) | platform generates VAPID keys; show vapid_public_key with Copy for the web client |
| Rotate / fix | form | ContentDialog form | PATCH /applications/{id}/push-credentials/{provider} {key material \| status: active} | web_push can't rotate in place — explain delete + recreate |
| Remove | destructive-button | Button (critical style) | DELETE /applications/{id}/push-credentials/{provider} | confirm; web_push removal deactivates every browser subscription |
| Platform alerts | toggle | ToggleSwitch | Application.receives_platform_notifications | mirror of APL-03 toggle |

| Action | Call | Result handling |
|---|---|---|
| Add | `POST /applications/{id}/push-credentials` | 201; 409 push_credential_exists → Edit; 422 invalid_push_credential → field error; 400 user_token_required never happens in the Hub (User token) |
| Fetch one | `GET /applications/{id}/push-credentials/{provider}` | on Edit |
| Save | `PATCH /applications/{id}/push-credentials/{provider}` | 409 invalid_status_transition |
| Remove | `DELETE /applications/{id}/push-credentials/{provider}` | 204 |

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

**Notes:** To prove a credential end to end, the owner registers a device of their own app and uses its device test (NTF-03 lists only devices the owner holds).


### D10 · Developer Credentials

#### KEY-01 — API keys
**Domain:** D10 Developer Credentials · **Route:** `/dev/keys` · **Archetype:** table · **Phase:** MVP  
**Who:** api_keys.manage or app owner  
**Purpose:** Service and agent credentials.

**Windows container:** Page with table (`ListView` + header grid / DataTable)

**Loads:** `GET /api-keys?scope&mode&intended_use&include_inactive`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Filters | chips | ToggleButton group / SelectorBar | mode live\|test, intended_use, include revoked | TEST pill on test keys |
| Columns | table | ListView + header grid (sortable) | name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at) |  |
| Create key | primary-button | Button Style=AccentButtonStyle | → KEY-02 |  |

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

#### KEY-02 — Create API key
**Domain:** D10 Developer Credentials · **Route:** `/dev/keys/new` · **Archetype:** form · **Phase:** MVP  
**Who:** api_keys.manage or app owner (app-scoped)  
**Purpose:** Create a key with least-privilege permissions.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Name | text-field | TextBox | name |  |
| Scope | select | ComboBox | scope platform \| application | owners: own apps only |
| Mode | segmented | Segmented (Toolkit) / RadioButtons | mode live \| test | fixed after creation — say so |
| Used by | radio-group | RadioButtons | intended_use service \| agent | agent defaults restrict_destructive on |
| Block destructive operations | toggle | ToggleSwitch | restrict_destructive |  |
| Permissions | checkbox-list | ItemsRepeater of CheckBox, grouped | permissions | filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send |
| Expires | date-field | CalendarDatePicker / TimePicker | expires_at | optional; presets 30/90/365 days |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /api-keys` | 201 ApiKeyWithSecret → KEY-03 |

#### KEY-03 — Secret reveal
**Domain:** D10 Developer Credentials · **Route:** `(modal)` · **Archetype:** secret · **Phase:** MVP  
**Who:** Creator  
**Purpose:** Show a new API key secret, webhook signing secret, or recovery codes exactly once.

**Windows container:** `ContentDialog`, primary disabled until acknowledged

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Secret | monospace selectable text | TextBox IsReadOnly Cascadia Mono | secret | never logged, never persisted, excluded from screenshots where the OS allows |
| Copy | button | Button | clipboard (sensitive) | auto-clear after 60 s where supported |
| Acknowledge | checkbox | CheckBox | client | gates Done; modal not dismissible until checked |

#### KEY-04 — API key detail
**Domain:** D10 Developer Credentials · **Route:** `/dev/keys/:keyId` · **Archetype:** detail · **Phase:** MVP  
**Who:** api_keys.manage or app owner  
**Purpose:** Edit, rotate or revoke a key.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /api-keys/{id}` · `GET /audit-events?actor_id={id}`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Fields | settings rows | SettingsCards | name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only) |  |
| Rotate | button | Button | POST /api-keys/{id}/rotate | confirm: no overlap |
| Revoke | destructive-button | Button (critical style) | DELETE /api-keys/{id} | confirm |
| Recent activity | list | ListView | audit events by this key |  |

| Action | Call | Result handling |
|---|---|---|
| Save | `PATCH /api-keys/{id}` |  |
| Rotate | `POST …/rotate` | 200 → KEY-03 |
| Revoke | `DELETE /api-keys/{id}` | 204 → row shows Revoked |

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

#### WHK-01 — Webhooks
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks` · **Archetype:** list · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** Endpoints that receive access-change events.

**Windows container:** Page with list pane (`ListView`) + detail (list-detail)

**Loads:** `GET /webhooks?application_id`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Rows | list | ListView | Webhook: url, events count, status healthy\|unhealthy\|disabled, consecutive_failures, last_delivery_at, scope |  |
| Add webhook | primary-button | Button Style=AccentButtonStyle | → WHK-02 |  |

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

#### WHK-02 — Add / edit webhook
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/new` · **Archetype:** form · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** Subscribe a URL to events.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| URL | url-field | TextBox InputScope=Url | url | https required |
| Events | checkbox-list grouped | ItemsRepeater of CheckBox, grouped | events (WebhookEvent enum: access.*, entitlement.*, role.*, user.*, organization.*, application.*, device.*, notification.push_failed, push_credential.failed, webhook.test) | presets: Access changes (access.granted, access.revoked) · Push health (notification.push_failed, push_credential.failed, device.deactivated) |
| Description | text-field | TextBox | description |  |
| Scope | select | ComboBox | scope |  |
| API version | select | ComboBox | api_version | edit only |
| Enabled | toggle | ToggleSwitch | status disabled ↔ healthy | edit only (re-enable) |

| Action | Call | Result handling |
|---|---|---|
| Create | `POST /webhooks` | 201 WebhookWithSecret → KEY-03 |
| Save | `PATCH /webhooks/{id}` |  |

#### WHK-03 — Webhook — Deliveries
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/:hookId` · **Archetype:** detail · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** Delivery history, testing and secret rotation.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /webhooks/{id}` · `GET /webhooks/{id}/deliveries?status&since&event_id`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header actions | toolbar | CommandBar |  | Send test · Rotate secret · Edit · Delete |
| Filter | chips | ToggleButton group / SelectorBar | status pending\|succeeded\|failed |  |
| Rows | table | ListView + header grid (sortable) | WebhookDelivery: status, response_status, event_type, event_id, attempt, duration_ms, created_at, next_retry_at, error | failed → Redeliver |

| Action | Call | Result handling |
|---|---|---|
| Send test | `POST /webhooks/{id}/test` | 202 → new pending row appears on refresh (poll 2 s × 5) |
| Rotate secret | `POST /webhooks/{id}/rotate-secret` | confirm (24 h overlap) → KEY-03 |
| Redeliver | `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver` | 202 toast; same event_id |
| Delete | `DELETE /webhooks/{id}` | confirm |

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

#### WHK-04 — Delivery detail
**Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/:hookId/deliveries/:deliveryId` · **Archetype:** popover · **Phase:** Phase 2  
**Who:** webhooks.manage or app owner  
**Purpose:** One delivery attempt in detail.

**Windows container:** `TeachingTip` / right pane

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Facts | key-value | SettingsCard (read-only) | all WebhookDelivery fields |  |
| Redeliver | button | Button |  |  |


### D11 · Plan & Billing

#### BIL-01 — My plan
**Domain:** D11 Plan & Billing · **Route:** `/billing` · **Archetype:** settings · **Phase:** Phase 2  
**Who:** Self; billing.manage can view any User's (read-only)  
**Purpose:** Free or Standard: what covers you, your own subscription, upgrade and manage billing.

**Windows container:** Page of `SettingsCard`s

**Loads:** `GET /users/me/subscription`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Plan card | card | Border card (OverlayCornerRadius) | UserSubscription.plan free\|standard | Free: "Free apps and services"; Standard: "Every app and service" |
| Covered by | list | ListView | plan_sources[]: {type user\|organization, id} | "Your subscription" / "Northwind Survey Co." (→ ORG-03); both listed when doubly covered |
| Double coverage tip | banner | InfoBar | plan_sources has both user and organization | "Your organization already covers you. You can cancel your own subscription in Manage billing." (no automatic refund) |
| Your subscription | key-value | SettingsCard (read-only) | Subscription.status, current_period_end, cancel_at_period_end, unit_amount, currency | status badge via status-semantics; past_due → warning banner (Stripe retries ~3 weeks) |
| Upgrade to Standard | primary-button | Button Style=AccentButtonStyle | → BIL-03 checkout | when no covering subscription, or own subscription is incomplete/incomplete_expired/canceled/unpaid; price "$6 per month" from unit_amount when known, else from pricing config; store flags on iOS/Android |
| Manage billing | button | Button | → BIL-03 portal | when the user has completed a checkout (own subscription exists and isn't incomplete) |
| What Standard includes | link | HyperlinkButton | standard apps from GET /applications?access_tier=standard | count + list |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; After return from checkout: poll GET /users/me/subscription every 2 s (max 30 s) until plan = standard, showing "Finishing up…"; Downgraded: "Your Standard access ended on {date}. Your data is kept."

**Notes:** Business/Enterprise aren't offered (422 plan_requires_sales); show "Contact sales" link only. Annual plans and a Standard trial are undecided in the API (DECISIONS #31) — no UI for them.

#### BIL-02 — Organization subscription
**Domain:** D11 Plan & Billing · **Route:** `/orgs/:orgId/subscription` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** org_admin (manage); billing.manage (read + portal)  
**Purpose:** An organization's Standard subscription and which members it covers.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /organizations/{id}/subscription` · `GET /organizations/{id}/members (for the member picker)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Plan card | card | Border card (OverlayCornerRadius) | OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end |  |
| Billed users | key-value | SettingsCard (read-only) | quantity.current (live), quantity.last_synced + last_synced_at, unit_amount | "31 covered · 30 billed on next invoice (synced daily)" |
| Who's covered | radio-group | RadioButtons | member_scope all_members \| allowlist \| denylist | COPY labels |
| Members | member-picker | AutoSuggestBox / TokenizingTextBox | member_overrides (active members only) | visible for allowlist/denylist |
| Subscribe the organization | primary-button | Button Style=AccentButtonStyle | → BIL-03 org checkout (member_scope optional) | org_admin; when no subscription or not in force |
| Manage billing | button | Button | → BIL-03 org portal | org_admin or billing.manage |

| Action | Call | Result handling |
|---|---|---|
| Save coverage | `PATCH /organizations/{id}/subscription {member_scope, member_overrides} (If-Match)` | effective immediately (access.granted/revoked); billed from next daily sync. 422 member_override_not_a_member → mark rows. 404 subscription_not_found → show Subscribe. |

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

**Notes:** Members gained or lost take effect at once; say so before saving.

#### BIL-03 — Checkout / billing portal
**Domain:** D11 Plan & Billing · **Route:** `/billing/return` · **Archetype:** handoff · **Phase:** Phase 2  
**Who:** Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal)  
**Purpose:** Hand off to Stripe in the browser and confirm the result on return.

**Windows container:** `Launcher.LaunchUriAsync`

| Action | Call | Result handling |
|---|---|---|
| Upgrade (individual) | `POST /users/me/billing/checkout-sessions {plan:standard, success_url, cancel_url} (Idempotency-Key)` | 201 {url, expires_at} → open url externally. 409 subscription_exists → open portal instead. 409 test_mode_billing_unavailable / 503 billing_unavailable → explain. |
| Manage billing (individual) | `POST /users/me/billing/portal-sessions {return_url}` | 201 → open url externally. 409 no_billing_account → hide button. |
| Subscribe organization | `POST /organizations/{id}/billing/checkout-sessions {plan:standard, success_url, cancel_url, member_scope?, member_overrides?} (Idempotency-Key)` | as individual |
| Manage organization billing | `POST /organizations/{id}/billing/portal-sessions {return_url}` | as individual |
| Return | `deep link /billing/return?checkout=success\|cancelled (or /orgs/:orgId/subscription?…)` | success: poll the matching GET …/subscription until plan = standard (coverage starts when Stripe's webhook lands, usually seconds); cancelled: toast "Checkout cancelled" |

**Notes:** success_url / cancel_url / return_url must be https: use https://dashboard.substratalapps.com/billing/return… (Universal/App Links open the native app).

#### BIL-04 — Tenant & infrastructure
**Domain:** D11 Plan & Billing · **Route:** `/dev/tenant` · **Archetype:** detail · **Phase:** Phase 2  
**Who:** Tenant owner (read), tenants.manage (write)  
**Purpose:** Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed.

**Windows container:** Detail pane / drill-in page; `SelectorBar` tabs; CommandBar actions

**Loads:** `GET /tenants` · `GET /tenants/{id}` · `GET /tenants/{id}/tier-change-requests`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Facts | key-value | SettingsCard (read-only) | Tenant: tier, region, status, owner, application_count | status migrating → banner, writes may 503 |
| Tier change requests | list | ListView | TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes |  |
| Request change | button | Button | → form: requested_tier, requested_region, reason | tenants.manage only; owners see "isolated and dedicated-region tiers come with business pricing — contact sales" |

| Action | Call | Result handling |
|---|---|---|
| Open request | `POST /tenants/{id}/tier-change-requests` | 422/409 errors |
| Open request detail | `GET /tenants/{id}/tier-change-requests/{requestId}` | polls every 30 s while visible |
| Advance request | `PATCH /tenants/{id}/tier-change-requests/{requestId} {status, scheduled_for, notes} (If-Match)` | staff only; status scheduled → in_progress → completed, or cancelled |

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


### D12 · Audit Log

#### AUD-01 — Audit log
**Domain:** D12 Audit Log · **Route:** `/admin/audit` · **Archetype:** table · **Phase:** MVP  
**Who:** audit.view  
**Purpose:** Who changed what, when.

**Windows container:** Page with table (`ListView` + header grid / DataTable)

**Loads:** `GET /audit-events?since&until&actor_type&actor_id&action&target_type&target_id&target_user_id&application_id&organization_id&tenant_id`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Filters | search + menus | AutoSuggestBox | time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource | filters encoded in URL/state for sharing |
| Rows | table | ListView + header grid (sortable) | timestamp, actor (type icon + name/id), action, target (type + link), app/org | relative time with absolute on hover/long-press |
| Inspector | side panel | right pane + GridSplitter | → AUD-02 | desktop: trailing pane; compact: push |

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

**Notes:** Hot window is one year for every Tenant; read the `Audit-Hot-Window-Start` response header and show it in an info caption. Export is Phase 3. New actions to group in the filter: subscription.*, push_credential.*, device.deleted.

#### AUD-02 — Audit event
**Domain:** D12 Audit Log · **Route:** `/admin/audit/:eventId` · **Archetype:** popover · **Phase:** MVP  
**Who:** audit.view  
**Purpose:** One event with before/after diff.

**Windows container:** `TeachingTip` / right pane

**Loads:** `GET /audit-events/{id}`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Facts | key-value | SettingsCard (read-only) | action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp | each id links to its screen |
| Diff | diff view | RichTextBlock with diff lines | before / after | JSON diff, changed keys highlighted |

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


### D13 · Notifications

#### NTF-01 — Inbox
**Domain:** D13 Notifications · **Route:** `/inbox` · **Archetype:** list · **Phase:** Phase 2  
**Who:** Self  
**Purpose:** Every notification the person has received — from every app and from Substratal — with read and archived state.

**Windows container:** Page with list pane (`ListView`) + detail (list-detail)

**Entry points:** Bell in the toolbar / Inbox in the sidebar (desktop) / Inbox tab (compact); Tapping a push; Badges (app icon, Dock/taskbar, PWA)

**Loads:** `GET /users/me/notifications?state&application_id&source&category&priority&created_after&created_before&include_inactive` · `GET /users/me/notifications/unread-count` · `GET /applications (names, icons)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Filter | segmented | Segmented (Toolkit) / RadioButtons | Unread · All · Archived (state) | default All (unarchived) |
| App filter | menu | DropDownButton / MenuFlyout | application_id, source=platform ("Substratal") | per-app unread counts from unread-count.applications[] |
| Rows | list | ListView | Notification: app icon (or Substratal mark for source=platform), title, body (2 lines), category, priority high → "Important" marker, created_at relative, unread dot | grouped Today / Yesterday / Earlier; high priority security.* rows show a shield icon |
| Mark all as read | button | Button | POST /users/me/notifications/mark-read {before: newest loaded created_at, application_id?} | respects current app filter; uses `before` so items arriving now aren't swept |
| Multi-select | selection | ComboBox |  | bulk Mark read / Mark unread / Archive (one PATCH per row) |
| Row actions | row-button | trailing Button | PATCH /users/me/notifications/{notificationId} {state} | swipe/hover: Read ⇄ Unread, Archive |

| Action | Call | Result handling |
|---|---|---|
| Open row | `→ NTF-02 and PATCH {state:read}` | optimistic; badge decrements |
| Archive | `PATCH /users/me/notifications/{notificationId} {state:archived}` | toast with Undo (PATCH state:read) |
| Mark read / unread | `PATCH /users/me/notifications/{notificationId} {state}` |  |

**States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: "You're all caught up"; Feature off (501 not_implemented): hide Inbox entry points; badges fall back to none; Retention: items older than 90 days disappear (caption in empty Archived view)

**Notes:** The Inbox shows notifications from every Application plus platform alerts, but push banners for another app's notifications arrive through that app's own install — the Hub only pushes its own (`app_hub`) notifications and platform alerts.

#### NTF-02 — Notification
**Domain:** D13 Notifications · **Route:** `/inbox/:notificationId` · **Archetype:** popover · **Phase:** Phase 2  
**Who:** Self (users.list via NTF-05)  
**Purpose:** One notification in full, with where it leads and how its push went.

**Windows container:** `TeachingTip` / right pane

**Loads:** `GET /users/me/notifications/{notificationId}`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Header | icon + app name + time | Image + TextBlock + StatusBadge | application_id / source, created_at |  |
| Title & body | text | TextBlock | title, body | full text |
| Open | primary-button | Button Style=AccentButtonStyle | action_url | Hub route → navigate in-app; https → open in browser / installed app (Universal/App Link); private scheme → hand to OS; hidden when null |
| Contextual actions | buttons | Button | by category | security.new_sign_in → "Review sessions" (ACC-04) · security.password_changed / email_changed / mfa_disabled / session_reuse_detected → "Secure my account" (ACC-04) · account.organization_invitation → Accept / Decline (ORG-09) · account.access_granted → Open app (APP-03) |
| Delivery | disclosure | Expander | push.status, devices_targeted/sent/failed, skip_reason, deliveries[] (30 days) | plain-language: "Pushed to 2 devices", "Not pushed — notifications are off for this app" |
| Mark unread / Archive | toolbar | CommandBar | PATCH {state} |  |

| Action | Call | Result handling |
|---|---|---|
| Opened | `PATCH /users/me/notifications/{notificationId} {state:read}` | on open if unread |

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

#### NTF-03 — Devices & notifications
**Domain:** D13 Notifications · **Route:** `/account/devices` · **Archetype:** settings · **Phase:** Phase 2  
**Who:** Self  
**Purpose:** Where push notifications reach this person, and controls for this device.

**Windows container:** Page of `SettingsCard`s

**Loads:** `GET /users/me/devices?include_inactive=true` · `GET /users/me/settings (notifications.push)`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| This device | card | Border card (OverlayCornerRadius) | Hub Device for this install (matched by stored device id) | status, "Notifications are on" / off / blocked by OS; button Turn on (→ NTF-04) or Turn off |
| Push notifications (all apps) | toggle | ToggleSwitch | Settings.notifications.push | same control as SET-01 |
| Devices | list | ListView | Device: name, application_id (app icon), provider, environment, status, inactive_reason, last_registered_at, last_delivery_at, token_hint | grouped by app; inactive rows dimmed with reason ("Signed out", "Not opened in 9 months"…) |
| Rename | row-button | trailing Button | PATCH /users/me/devices/{deviceId} {name} |  |
| Send test notification | row-button | trailing Button | POST /users/me/devices/{deviceId}/test | Hub devices only; shows provider result inline (sent / failed + error) within 10 s |
| Remove | row-button | trailing Button | DELETE /users/me/devices/{deviceId} | confirm; that install stops receiving pushes until it registers again |

| Action | Call | Result handling |
|---|---|---|
| Turn off on this device | `DELETE /users/me/devices/{deviceId}` | and stop re-registering until the user turns it back on |
| Test | `POST /users/me/devices/{deviceId}/test` | 429 → "Try again later (10 tests per hour)"; 409 push_not_configured → "Push isn't set up for this app yet" |

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

**Notes:** Device tokens are never displayed (only token_hint).

#### NTF-04 — Turn on notifications
**Domain:** D13 Notifications · **Route:** `/account/devices/enable` · **Archetype:** wizard · **Phase:** Phase 2  
**Who:** Self  
**Purpose:** Explain, request the OS permission, and register this install for push.

**Windows container:** `ContentDialog` steps or page with `BreadcrumbBar`

**Entry points:** First time a notification-worthy moment happens (org invite, first launch after sign-in on mobile, enabling in NTF-03/SET-01) — never on cold start before sign-in

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Primer | text + illustration | TextBlock |  | what you'll get: security alerts, invitations, access changes; "You can change this anytime" |
| Turn on | primary-button | Button Style=AccentButtonStyle | OS permission prompt | platform API per PLATFORM-SPEC |
| Not now | button | Button |  | snooze 14 days |

| Action | Call | Result handling |
|---|---|---|
| Permission granted | `POST /users/me/devices {application_id: app_hub, provider, token \| subscription, environment (apns), name, app_version}` | 201 new / 200 refresh → store device id; 403 entitlement_required → Hub access disabled for this user: show inbox-only mode; 422 invalid_device_token → log + retry next launch |
| Token rotated by OS | `PATCH /users/me/devices/{deviceId} {token \| subscription}` |  |
| Permission denied | `—` | Settings shows "Blocked — Open system settings" deep link |

**Notes:** Re-register on launch at most once a day (keeps the device from going stale after 270 days). Windows and Linux have no supported push provider in the API: this screen is replaced by an in-app explanation and local notifications from polling (see their PLATFORM-SPEC).

#### NTF-05 — User — Notifications & devices
**Domain:** D13 Notifications · **Route:** `/admin/users/:userId/notifications` · **Archetype:** tab · **Phase:** Phase 2  
**Who:** users.list (read), users.manage (delete devices)  
**Purpose:** Support view of a person's inbox and push devices for investigations.

**Windows container:** `SelectorBar` item content

**Loads:** `GET /users/{id}/notifications?include_inactive=true` · `GET /users/{id}/notifications/{notificationId}` · `GET /users/{id}/devices?include_inactive=true` · `GET /users/{id}/devices/{deviceId}`

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Inbox | table | ListView + header grid (sortable) | Notification: created_at, application/source, category, priority, title, state, push.status, skip_reason | read-only; never mark the user's items read |
| Delivery detail | side panel | right pane + GridSplitter | deliveries[]: provider, status, attempts, error, sent_at |  |
| Devices | table | ListView + header grid (sortable) | Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at |  |
| Delete device | destructive-button | Button (critical style) | DELETE /users/{id}/devices/{deviceId} | users.manage; audited (device.deleted) |

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

#### NTF-06 — Send notification
**Domain:** D13 Notifications · **Route:** `/admin/notifications/new` · **Archetype:** form · **Phase:** Phase 2  
**Who:** platform notifications.send  
**Purpose:** Staff tool: send one notification to one person on behalf of an Application.

**Windows container:** `ContentDialog` (≤ 5 fields) or drill-in page

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Recipient | picker | AutoSuggestBox / TokenizingTextBox | user (GET /users?q) | must have active access to the chosen app |
| Application | select | ComboBox | body.application_id | required for platform senders |
| Category | text-field | TextBox | body.category | ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$; security./account./billing. reserved (422 reserved_category) |
| Priority | segmented | Segmented (Toolkit) / RadioButtons | body.priority normal \| high | high explains provider throttling |
| Title | text-field | TextBox | body.title | 1–120 |
| Body | text-area | TextBox AcceptsReturn | body.body | ≤ 1,000 |
| Data | key/value editor | ItemsRepeater of TextBox pairs + Add | body.data | ≤ 20 string pairs, 2 KB; key `substratal` reserved |
| Open link | url-field | TextBox InputScope=Url | body.action_url | https or private scheme ≤ 2,048 |
| Advanced | disclosure | Expander | push (bool), badge, sound default\|silent, collapse_key, ttl_seconds |  |
| Payload size | meters | ProgressBar + labels | client estimate of the 4 KB push payload | 422 notification_too_large → details.bytes/max |
| Preview | card | Border card (OverlayCornerRadius) | lock-screen style preview per platform | warns: lock screens show title/body — keep sensitive content out |

| Action | Call | Result handling |
|---|---|---|
| Send | `POST /users/{id}/notifications (Idempotency-Key)` | 201 → result panel with push.status / skip_reason; 403 entitlement_required → recipient has no access; 429 scope sender\|recipient → countdown |


### G · Global / Shell

#### GLB-01 — Command palette
**Domain:** G Global / Shell · **Route:** `(overlay)` · **Archetype:** overlay · **Phase:** MVP  
**Who:** Signed in  
**Purpose:** Jump anywhere and run any action by typing.

**Windows container:** Title-bar flyout / pane

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Query | search | AutoSuggestBox |  | Cmd/Ctrl+K (desktop), search button (touch) |
| Results | grouped list | ListView grouped | Navigation, Apps (launch), Users (GET /users?q), Orgs, Inbox (recent notifications), Actions (Invite user, Create key, Upgrade to Standard, Mark all read…) | permission-filtered; recent items first; ID prefixes resolve directly |

#### GLB-02 — Manage hub
**Domain:** G Global / Shell · **Route:** `/manage` · **Archetype:** hub · **Phase:** MVP  
**Who:** Any admin or developer permission  
**Purpose:** Compact-layout landing list for the Administration and Developer sections.

**Windows container:** — (NavigationView replaces hubs)

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Administration | list | ListView | Users, Access, Roles, Audit Log, Send notification — those the caller may open, each with a live count | section hidden when no rights |
| Developer | list | ListView | Applications, API Keys, Webhooks, Tenant — with counts and health (unhealthy webhooks, failed push credentials) | section hidden when no rights |

#### GLB-03 — System states
**Domain:** G Global / Shell · **Route:** `(global)` · **Archetype:** overlay · **Phase:** MVP  
**Who:** —  
**Purpose:** App-wide banners and blocking states.

**Windows container:** Title-bar flyout / pane

| Element | Kind | Windows control | Data / API | Rules |
|---|---|---|---|---|
| Offline | banner | InfoBar | network status | reads from cache, writes disabled with explanation |
| Maintenance / tenant migrating | banner | InfoBar | 503 service_unavailable + Retry-After | countdown, auto-retry reads |
| Session ended | modal → AUTH-01 | ContentDialog → AUTH-01 | 401 after failed refresh | preserve destination |
| Forbidden | inline state | ProgressRing on tile | 403 | COPY |
| Not built yet | inline state | ProgressRing on tile | 501 not_implemented (Notifications today) | hide the feature behind its flag; never show an error to end users |
| Update required | blocking screen | full-window page | API version deprecation header (if present) |  |

| Action | Call | Result handling |
|---|---|---|
| Silent token refresh | `POST /auth/token/refresh` | single-flight; rotate refresh token atomically (CLIENT-ARCHITECTURE §2) |
| Connectivity probe | `GET /health` | used after offline/503 to decide when to clear the banner |
| Forced sign-out | `POST /auth/logout` | on session_revoked or user request; the server deactivates this session's push devices |
| Badge refresh | `GET /users/me/notifications/unread-count` | on launch, foreground, after a push arrives, and every 60 s while visible (Windows/Linux: also in background) → app icon/Dock/taskbar/PWA badge |

**Notes:** `POST /auth/oauth/token` is called by the launched Application (PKCE exchange), never by the Hub.
