Skrzynka Desktop — interface design
Design contract for the Skrzynka macOS client. It states what the operator is looking at, why each surface exists, and which rules the implementation may not break. Executable behavior stays authoritative in Sources/SkrzynkaDesktop; this fi
Overview#
Design contract for the Skrzynka macOS client. It states what the operator is looking at, why each surface exists, and which rules the implementation may not break. Executable behavior stays authoritative in Sources/SkrzynkaDesktop; this file is authoritative for information architecture, hierarchy, density, and state presentation.
The shared rules this file specialises are in `wisent-components/docs/APP-SHELL.md`. Every measurement quoted from there was taken on a shipped Wisent client, and each one is the argument for the rule it justifies.
Baseline measured on main at 95fa3b7: 6 029 lines of Swift across seventeen files in Sources/SkrzynkaDesktop. Section 1 measures the superseded build at 01a870d, which was roughly 2 100 lines across nine files. Product surface transcribed from wisent-ai/skrzynka (Rust 0.2.0, schema 4), which is read-only input to this repository.
| package | version |
|---|---|
wisent-components (WisentDesignSystem) | 0.9.1 |
wisent-desktop-auth (WisentAuth) | 0.3.6 |
echo (WisentOnboarding) | 0.3.0 |
wisent-errors | 1.0.0 |
wisent-desktop-update | 0.2.0 |
wisent-desktop-auth 0.3.6 resolves wisent-components 0.9.1 itself, so the two move together. Every requirement is an exact version, and Package.resolved records versions rather than revisions; naming a different components version makes the graph unresolvable rather than merely inconsistent.
1. What was wrong, measured#
D1 — The navigation was a data list, not the operator's work. The sidebar (WorkspaceView.swift:82-146) held exactly two kinds of row: "All inboxes", and one row per mailbox. That is a query parameter — mailbox_id on GET /v1/messages — promoted to navigation. The whole application was three columns of one journey (read mail, reply) plus two sheets. Of sixteen operator journeys derivable from Skrzynka's own source, eleven had no surface at all: fleet sync triage, pause/resume, reply-outcome audit, interrupted-send recovery, credential-rotation recovery, the Skarbiec authority inventory, the service's own posture, schema and database path, the backup procedure, the boundary statement, and the Gmail flow's intermediate states.
D2 — The backend's error code was decoded and then discarded. SkrzynkaAPIClient.swift:191-195 decoded code, message and retryable off the envelope. SkrzynkaStore.present(_:) (SkrzynkaStore.swift:324-326) then kept only localizedDescription, which is message. Skrzynka emits 47 distinct codes; every one of them reached the operator as the same anonymous red line. GMAIL_OAUTH_ACCOUNT_MISMATCH — authorize the exact selected address — and SMTP_UNCERTAIN — a mail may already have been sent — were indistinguishable. No screen anywhere carried a reproducing command.
D3 — A read failure was swallowed. SkrzynkaStore.swift:288: replies = (try? await client.replies(messageID: messageID)) ?? replies. A dead socket and a message with no replies produced the same empty pane.
D4 — A failed refresh contradicted retained data. refreshAll() (SkrzynkaStore.swift:88-99) assigned status, mailboxes and messages in sequence inside one do; a throw part-way left the earlier assignments applied and the later ones stale, then set serviceAvailability = .unavailable. The toolbar pill therefore read "Core offline" (SkrzynkaTheme.swift:74-77) above a full inbox, with the reason discarded at the pill and surviving only in a dismissible banner.
D5 — Success was suppressed by an undismissed failure. WorkspaceView.swift:15-19 is if let error … else if let notice. One error the operator had not dismissed hid the outcome sentence of every subsequent mutation, including "SMTP accepted the reply".
D6 — The operator could not own the idempotency key. SkrzynkaStore.swift:275 minted UUID() per send. The key is the mechanism that makes a reply replayable, and IDEMPOTENCY_KEY_REUSED (db.rs:405-406) is only comprehensible to whoever chose the key. J14 — an uncertain reply, where the rule is inspect provider Sent mail, never auto-resend, retry needs a new key — had no surface; uncertain was a colour on a row (WorkspaceView.swift:392-399).
D7 — Onboarding could lock the operator out of a working service. SkrzynkaRootView.swift:30-34 gated the entire workspace on onboarding.status == .completed. A journey-transport failure, an invalid bundled contract, or a JourneyClient init throw (SkrzynkaOnboarding.swift:44-47) therefore hid a perfectly healthy Skrzynka behind a wizard. The neighbouring loading state (SkrzynkaRootView.swift:36) was ProgressView("Preparing organization workspace…") — it named nothing being read and had no failure path, so an organization that never bound spun forever.
D8 — The client duplicated the product's version number. Models.swift:190-192, AppVersion.current = "0.2.0". A compiled-in copy of a value GET /v1/status returns, guaranteed to drift.
D9 — A local design system, 153 lines. SkrzynkaTheme.swift:1-153: a hand-rolled palette (accent #1AA1A6, blue, warning, danger, panel, canvas, minimumSize), plus local ProductMark (:14-43), ServicePill (:45-77), MessageBanner (:79-105) and EmptyPanel (:107-135). Consumed at forty-odd call sites. Two copies of a shell are two sources of truth, and the second is always the one nobody updates.
2. Principles#
- Severity is the layout. A failure gets a full-width
WisentAlertPanelwith a severity edge, the backend's own sentence and the exact command that reproduces it. A healthy signal gets one line inWisentSignalStrip. The two are never the same size. - Zero mailboxes is not red.
mailbox_count == 0is a fresh install, not an outage; it renders.neutraland says "Not configured"..dangeris reserved for a backend that was asked and could not answer. A mailbox that has never synchronized is.info, not a failure: absence by sequence is not an incident. - Navigate by decision, not by noun. A destination exists because a human has something to decide or verify there. The mailbox in view is a facet, not a destination, because it is a query parameter.
- Quote the backend, then say what to do. Every failure carries the core's verbatim
message, its machinecode, and the documented recovery fromskrzynka/docs/ONBOARDING.md:38-52. No paraphrase, and no invented advice. - Every failure carries a reproducing command. A
curlwith the real endpoint and organization, or the real CLI line. The bearer is referenced as$WISENT_ACCESS_TOKEN, never printed, so the command is safe to paste into a bug report. - Data never disappears because a later read failed. Each surface keeps its own failure beside its own value.
- A provider-facing act is a decision, not a button. Sending mail and destroying local evidence both go through
WisentDecisionDialog, and the operator owns the idempotency key. - One boundary statement, not five. The secret boundary is in the sidebar footer once. The capability inventory lives on one destination.
3. Information architecture#
Scope — the Wisent organization and the loopback endpoint — is the sidebar header, not a destination: every screen reads it. It is a button, so clicking it opens Service where it can be changed.
| group | destination | exists because | backing surface |
|---|---|---|---|
| WORK | Posture | Triage: whether Skarbiec answers, which mailboxes are failing, and which sends stopped without terminal evidence. | /healthz, /v1/status, each mailbox's last_error_code/last_error_message |
| WORK | Mailboxes | The fleet: undeclare one mailbox in Skarbiec or declare it again, read its last error, and remove the local mail of an undeclared one under confirmation. | GET /v1/mailboxes, POST …/undeclare, POST /v1/mailboxes/declare, DELETE …?confirm=true, POST …/sync |
| WORK | Connect | Turn a Skarbiec authority into a mailbox — a generic password bundle, a Gmail app-specific-password item, a Google identity through OAuth, or Workspace domain-wide delegation. The connection report measures provider access; see Gmail connection for prerequisites and refusals. | GET /v1/skarbiec/items, GET /v1/gmail/profiles, GET /v1/gmail/connection, POST /v1/gmail/oauth/start, GET /v1/gmail/oauth/:flow_id, POST /v1/gmail/delegate, POST /v1/gmail/app-password, POST /v1/mailboxes |
| Inbox | Read normalized mail from every mailbox with the receiving identity preserved, and reply through that same identity. | GET /v1/messages, POST /v1/messages/:id/replies | |
| Compose | Originate mail: choose the mailbox to send as, name the recipients and subject no inbound message supplies, and hand one provider mutation to a dialog. | GET /v1/mailboxes, POST /v1/mailboxes/:id/outbound | |
| Sync | Run a fleet sweep and read the per-mailbox verdict: which mailboxes failed this cycle while the rest kept working. | POST /v1/sync, POST /v1/mailboxes/:id/sync | |
| EVIDENCE | Replies | Audit provider-facing sends, and resolve the ambiguous ones by inspecting provider Sent mail rather than resending. | GET /v1/messages/:id/replies |
| EVIDENCE | Outbound | Audit what this organization originated, and resolve an unclear send by inspecting provider Sent mail rather than replaying a key. | GET /v1/outbound |
| SYSTEM | Service | The service itself: endpoint, schema, database path, poll interval, and the dependency this product cannot work without. | /healthz, /v1/status, plus skrzynka serve and the backup procedure as commands |
| SYSTEM | Boundary — specified, not built | What Skrzynka will and will not do, so the operator stops looking for a capability that was never promised. This row is a requirement of this contract, not a description of the shipped window: no such destination exists at 95fa3b7. | skrzynka/README.md:19-31 and the integrations boundary statements, when it is built |
This contract further requires each destination's sentence to be compiled into the application as SkrzynkaDestination.rationale and rendered on Boundary, so that the information architecture is inspectable from inside the application and a destination that cannot state its journey is visibly wrong. That, like the destination itself, is specified and not built: at 95fa3b7 SkrzynkaDestination carries nine cases — Posture, Mailboxes, Connect, Inbox, Compose, Sync, Replies, Outbound, Service — and exactly three members, group, title and symbol. There is no boundary case and no rationale.
Sidebar rows carry a count only where the count changes behavior: Mailboxes shows a danger count while any mailbox holds a last_error_code, Replies shows a warning count while any attempt is uncertain or failed, and Outbound shows that same warning count over originated messages in those two states. Nothing else is decorated.
Absent by contract. No screen sends an inline secret, because no secret ever crosses the API. The client does not supervise the Skrzynka process, does not read or write a Skarbiec value (declaring asks the core to tag an item), does not resend an uncertain attempt, and does not delete provider-side mail. These are listed under "Never available", not rendered as disabled controls: a disabled button implies a permission that might arrive, an absent capability states a rule.
4. Layout grammar#
- Window minimum
WisentAppLayout.minimumWindowWidth × minimumWindowHeight(1000 × 700), default 1280 × 820. - Sidebar 236 pt on
canvasMuted, brand block 40 × 40 onbrandSoftat radiusmedium, scope button beneath it, boundary sentence in the footer. - Context bar, 44 pt, replacing the per-screen hero: destination name, scope chip, right-aligned freshness, at most two verbs. No screen renders
WisentTypography.display;WisentTypeScaledoes not expose it. - Data screens are three zones in one
HStack(spacing: 0)underWisentScreen(scrolls: false, constrainsWidth: false): facet rail 168 pt, table, inspector 320 pt. Mailboxes, Connect, Inbox, Replies and Outbound are three-zone tables; Compose keeps the rail and the inspector and puts the message form where the table would be. - Stacked-panel screens — Posture, Sync, Service, and Boundary once it is built — take the
WisentScreendefaults. - Column widths fit the middle zone, not the window. At the 1000 pt minimum, 1000 − 236 − 168 − 320 = 276 pt, so exactly one column flexes per table and every other is small and fixed: Mailboxes uses 150 / 82 / 76 / 62 with the name flexing; Inbox uses 110 / 150 / 80 and Outbound 74 / 110 / 150 / 84, both with the subject flexing.
- A table header is pinned above its own
ScrollView; nothing scrolls under the context bar.
5. Type and colour rules#
Strict subset of WisentDesignSystem; no new tokens, no local ones.
| role | token |
|---|---|
| destination name | WisentTypeScale.screenTitle() |
| section title | WisentTypeScale.section() |
| metric | WisentTypeScale.metric(), monospaced digits |
| table cell | WisentTypeScale.body() in 28 pt rows |
| identifier, key, digest, path, timestamp | WisentTypeScale.identifier() / identifierSmall(), selectable |
| column head, eyebrow | WisentTypeScale.columnHead() / eyebrow(), uppercase, tracking 0.7–0.8 |
| button label | WisentTypeScale.button() |
- Brand green is chrome and selection, never status. Status uses only
success,warning,danger,info,neutral. - A status pill appears only for the minority state. A healthy enabled mailbox gets no ink and its count lives in the facet rail;
Failing,PausedandNewget one pill each. Asentreply is plain mono text;Uncertain,Failed,PendingandSendingget pills. - Row selection is
brandSoftfill plus a 3 pt brand leading edge. - The only raw numbers permitted in a view are
spacing: 0for structure, SF Symbol glyph sizes, and the fixed column widths justified in §4.
6. Screen specifications#
6.1 Posture
Skrzynka has no `doctor` command — Command (main.rs:48-73) has six entries and none is a check — so this screen composes the picture from /healthz, /v1/status and each mailbox's stored error pair. Order is fixed and severity-driven:
- Failures, full width, one panel each. The service not answering outranks everything, because no other reading on the screen means anything while it holds. Then
skarbiec_available == falseas danger, not a warning: it is the credential authority, and without it every sync will fail. Then one panel per failing mailbox carrying that mailbox's own stored sentence — never a count, because each has a different remedy. A credential failure (IMAP_AUTHENTICATION_FAILED,GMAIL_AUTHORIZATION_EXPIRED,GMAIL_REFRESH_TOKEN_MISSING) offers Reconnect, not Retry: retrying a refused credential is not a remedy. - Signal strip: service, Skarbiec, schema, poller, mailboxes, messages.
mailbox_count == 0reads "Not configured" in neutral. - Needs a decision: no mailbox configured, mailboxes never synchronized, mailboxes paused, replies uncertain — each with a real timestamp and one verb. Empty state:
Nothing is waiting. - Aggregates: the counts the core reports, and the last sweep's totals.
/healthz reports exactly one status value, "ready" (api.rs:55-62). Showing it is honest; calling it a verdict would not be, so it is a signal and never a green check.
6.2 Mailboxes
Facets: All / Failing / Paused / Never synchronized / Gmail OAuth, with counts. Table: mailbox, address, authority, last sync, minority-state pill.
Inspector: the stored failure as an alert with what its code means and what to do beside it, then the address, the saved account it resolves, the incoming and outgoing servers with the outgoing TLS mode, and the last synchronization — then the three verbs. Remove mailbox is the one red button in this screen and it opens a dialog.
last_error_code is written on failure (db.rs:281-290) and cleared to NULL on the next success (db.rs:270-279). It is therefore live state, not history; the rail footer says so, because a "Failing" count that were history would mean the opposite of what it appears to mean.
6.3 Connect
The three authority journeys share one screen because the operator's question is the same: which authority, and what does Skrzynka still need from me.
Account-type facets switch among Google account, Gmail app password, and Saved account. Available facets carry Google-account, saved-account, and already-declared counts. Declaring an item that is already a mailbox reuses the mailbox and preserves retained messages by UID.
For Gmail the inspector states the scopes, what the core does with the refresh token, and the account-mismatch guard before the browser opens. While Google sign-in is being watched, Cancel Google sign-in stops the wait; the flow has no lifetime of its own. Every intermediate flow state the core can report — pending, processing, completed, failed — is named in the mutation bar as it happens; a state this build does not recognise is reported as the literal string the core sent, not silently treated as failure.
Declaring a saved account selects an exact Skarbiec item; it does not offer an account-definition form. The item must supply the address, server profile and optional poll_interval_seconds, and missing data is reported instead of replaced by local overrides. Nothing on this screen displays a secret: /v1/skarbiec/items returns metadata only.
6.4 Inbox
Mailbox attribution is a column, not a detail: preserving the receiving identity is the product's whole promise.
Selecting a mailbox facet re-reads GET /v1/messages?mailbox_id=… rather than filtering the loaded window. The window is the newest 300 across every mailbox; filtering it client-side would silently under-report a quiet mailbox, which is exactly the mailbox an operator checks on.
An empty result after a sync is an explicit answer, not a failure, and says so — unless the scoped mailbox holds a last_error_code, in which case that sentence is what the operator needs and the empty state is replaced by the alert.
The composer states the identity it will send as, the address the core will target (reply_to when present, else from — mail.rs:179-180), the byte budget against the real 256 KiB limit, and the idempotency key as an editable field. Sending opens a dialog listing recipient, thread, key and byte count, and stating that replaying the key returns the existing attempt while a different body under the same key is refused.
The message body pane is height-bounded. A body may be 2 MiB; an inspector that grows to its content is how a split view gets pushed off the top of the window.
6.5 Compose
Skrzynka can start a conversation and not only answer one, so origination is a destination rather than a mode of Inbox: no inbound message supplies the recipients, the subject or the identity to send as, and all three are decisions.
Every connected mailbox is offered in the Send as rail, a paused one included and toned as paused: enabled governs synchronization and the send route never consults it. Withholding that mailbox would invent a rule the core does not have.
Nothing is validated in Swift. The core splits recipients on commas, parses every address, bounds the subject and the 256 KiB body, and refuses before it claims a row (service.rs:775-847), so this screen renders the refusal — the machine code beside the core's own sentence, including the SMTP server's text that SMTP_REJECTED carries — instead of a paraphrase that would drift from it.
The draft lives on the model, not in the view: the sidebar rebuilds the destination, and a half-written message has to survive a click on Outbound. The idempotency key is minted with the draft and shown beside the send verb. A key that reached the core is spent — repeating it answers with the first attempt (service.rs:618-620) — so a refused send offers Start a new message, which mints a new key and keeps what was typed, and an accepted one leaves the composer holding its mailbox and a fresh key.
Readiness to review is a mailbox plus three non-blank fields. Legality is the core's answer, and a send in flight is never submitted twice.
6.6 Sync
The core reports each mailbox separately and one failure never aborts the fleet (service.rs:337-359), so the operator's question is always which ones failed while the rest kept working — a per-mailbox verdict table, never a single success or failure.
Two tables, and they answer different questions. Rotation is live mailbox state: interval, last sync, last UID, minority pill, ordered failing-first. Last sweep verdict is the sweep's own answer: received, skipped and code per mailbox. A sweep's per-mailbox answers are returned once and are not stored for later reading, so when no sweep has run this session the screen says exactly that rather than showing an empty table that implies zero.
Skipped messages are counted and never identified (mail.rs:132-134), so the aggregate is the only honest reading and the panel states that they can be found only on the provider.
6.7 Replies
Skrzynka exposes replies per message only; there is no fleet-wide reply endpoint. Evidence is therefore a bounded sweep the operator asks for — one request per message in the loaded window, six at a time — and the rail footer states the bound. A partial list is never presented as if it were everything, and a message whose read fails is reported rather than skipped.
Facets default to Needs a decision, because sent is not why anyone opens this screen. The standing uncertain rule sits above the table as a warning panel, not inside a row: it is one rule about a class of state.
The uncertain inspector is the emergency surface. It quotes the stored code and sentence, then names the concrete next act — open provider Sent mail for this address, look for this thread, and if it is absent compose again with a new key. Resending under the same key, cancelling an accepted send, and reading the provider's Sent folder are listed under "Never available".
6.8 Outbound
Outbound mail has a fleet-wide list where replies do not (GET /v1/outbound), so this evidence screen is a plain read under the same mailbox scope as Inbox: no bounded sweep, no request per message, and no partial list to caveat.
Facets default to Needs attention. sent is the majority in a healthy service and stays plain text; May be sent, Failed and Sending each get one pill, and the two states that need a human are also the sidebar count and a Posture panel.
The inspector is the audit record: refusal code and stored sentence, sending identity, recipients, Cc, provider message id, idempotency key, the created, updated and sent instants, and the stored body in a height-bounded pane — the message as it was actually sent.
The only verb an unresolved row offers composes a new message from it under a new key. Replaying a key is not a capability: the core would answer with the row that already exists, and a second copy at the provider is a decision the operator makes in the composer.
6.9 Service
Endpoint with inline validation mirroring the core's own rule: the core refuses a non-loopback bind, so the client refuses to address anything else. An endpoint this app would accept but the service could never serve is a misconfiguration disguised as an outage.
Identity states that the core re-verifies bearer and organization on every request with no local cache, so an identity outage fails closed — and that the skrzynka CLI is unauthenticated and reads the organization named by its --organization flag, which may differ from the one in the window. An operator comparing CLI output against this window otherwise concludes the database is wrong.
Core reports only what the service said about itself; no version is compiled in.
Operator procedures are the two journeys this application cannot perform — starting the service and backing up the database — rendered as the exact command, with the live database path interpolated. Not a disabled button: a command.
6.10 Boundary
The product's promise, its "What works now" list, and its stated exclusions as WisentCapabilityList(isAvailable: false) — absence as inventory. Then the client's own declined capabilities, the transport declaration, and the destination table with each rationale.
Specified, not built. SkrzynkaDestination carries nine cases at 95fa3b7 — Posture, Mailboxes, Connect, Inbox, Compose, Sync, Replies, Outbound, Service — with no boundary case and no rationale member, and BoundaryScreen.swift was deleted at 402035a. Nothing above describes a screen the operator can open; until the destination returns, the exclusion inventory lives only in the repository README, and the application states no capability boundary of its own.
7. State canon#
| state | presentation |
|---|---|
| loading | WisentLoadingPanel naming what is being read |
| empty, nothing | WisentEmptyPanel with the reason and one verb |
| empty, by filter | different copy plus "Clear filter" |
| not configured | neutral, the words "Not configured", never red |
| refresh failed | WisentErrorBanner above retained content |
| read failed, one item | that surface's own failure; siblings keep their values |
| mutation | WisentMutationBar, the backend's exact sentence, never silence |
| irreversible | WisentDecisionDialog, safe verb primary, destructive verb red and last |
A skeleton that cannot resolve is a bug, not a state. Every loading panel on every screen has a resolution path and a failure path.
Known limitation of the shared component: with macOS Full Keyboard Access off, a WisentDecisionDialog cannot be settled from its own window by keyboard. Return does nothing and leaves the sheet open without sending; Tab puts no focus ring on either action, so Space has no focused control to activate. A pointer click on the sheet's action does send. The shared component renders each WisentAction as a plain Button without default-action or cancel keyboard shortcuts, and WisentAction has no shortcut field; the remedy belongs to wisent-components, not Skrzynka Desktop.
8. Rules the implementation may not break#
- No local design system. No
Color,Font, radius or spacing literal outsideWisentDesign/WisentTypeScale/WisentAppLayout. The only raw numbers permitted arespacing: 0, SF Symbol glyph sizes, and the column widths in §4. - No
try?on a read whose failure the operator needs to know about, nonilstanding in for an error, no empty array standing in for a failure. - Every failure value carries
code, the backend's verbatimsentence, and a reproducing command. A screen may add the documented recovery; it may never rewrite the sentence. - A failed read never clears a value a previous read produced.
- Sidebar rows and table rows are
Buttons. ANavigationLinkin aListdid not change the destination when the row was clicked through the accessibility action, which made a shipped Wisent client keyboard-navigable only. - Concurrent reads use a task group, never a fan of
async letbindings: a task-local allocator is a stack, and awaiting children in declaration order aborts the window insideasyncLet_finish_after_task_completion. - No screen may render a mailbox password, an OAuth code, an access token or a refresh token. None of these crosses the API.
- No irreversible act without
WisentDecisionDialog. Sending mail and deleting local evidence both qualify. - The operator owns the idempotency key. The client may propose one; it may not hide it.
- Nothing may overflow the window.
WisentScreen'sGeometryReaderis never worked around, and every scrolling pane inside an inspector is height-bounded. - No value the core reports may be compiled in. Version, schema version, database path, poll interval and every count come from
/v1/status.
9. Traceability, journey by journey#
Journeys derived from Skrzynka's own dispatcher, router and docs.
| journey | before | after |
|---|---|---|
| J1 orientation, what this will not do | absent | Boundary, specified and not built (§6.10) |
| J2 prerequisite and posture check | absent | Posture, detail on Service |
| J3 start/stop the service | absent | Service, as the exact serve command |
| J4 browse credential authorities | inside a sheet | Connect, with kind, revisions and connected state |
| J5 connect a password mailbox | sheet, no refusal surface | Connect, with MAILBOX_PROFILE_INVALID and MAILBOX_IMPORT_PROFILE_CONFLICT stated |
| J6 connect a Gmail identity | sheet; flow states collapsed to a spinner | Connect, every flow state named, mismatch guard stated up front |
| J7 first-success verification | toolbar button | Sync to run, Inbox to verify, queued on Posture |
| J8 unified inbox reading | the whole app | Inbox, three zones, mailbox as a column |
| J9 reply as the receiving mailbox | hidden key, no confirmation | Inbox composer plus a decision dialog; operator owns the key |
| J10 reply-outcome audit | a colour on a row | Replies, faceted by outcome |
| J11 fleet sync triage | one toast | Sync, per-mailbox verdict table |
| J12 pause / resume a mailbox | context menu | Mailboxes, verb plus a Paused facet |
| J13 credential rotation recovery | absent | Mailboxes and Posture, routing to Connect |
| J14 interrupted-send recovery | absent | Replies, uncertain facet, standing rule, fresh-key retry |
| J15 destructive local teardown | alert | Mailboxes plus WisentDecisionDialog quoting the product's sentence |
| J16 backup / upgrade / rollback | absent | Service, with the live database path in the command |
| J17 originate mail from a mailbox | absent; the capability existed only as skrzynka message send | Compose, plus a decision dialog naming the sending address, the recipients as typed and the key |
| J18 outbound audit and unclear-send recovery | absent | Outbound, faceted by outcome, with the unresolved states counted in the sidebar and raised on Posture |
10. What was removed, and where it went#
The gate the Echo WisentOnboarding journey client stood in is gone. The client is not: SkrzynkaOnboarding.swift and Resources/skrzynka-desktop-first-use.json are in the build, and echo is a direct exact-version dependency again at 0.3.0. What was removed is the gating, for two reasons, in order:
- It gated the whole application (D7). A journey-transport failure hid a healthy Skrzynka behind a wizard. Progress telemetry may not stand between an operator and a working service.
- The journey it tracked — service ready, mailbox exists, synchronize — is not a thing an operator decides; it is a sequence. That sequence is now the Posture decision queue, driven by real evidence (
/v1/statusmailbox_count, each mailbox'slast_sync_at) rather than by self-reported clicks, and it never blocks navigation.
The journey therefore renders as a card at the bottom of the window, above a workspace that is navigable while it is on screen, and its progress is recorded per installation rather than per operator. SkrzynkaRootView presents it as an overlay and no destination is disabled by it.
Also removed: AppVersion.current (D8), the local theme (D9), and the per-mailbox sidebar rows, which became the Inbox mailbox facet.