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.

packageversion
wisent-components (WisentDesignSystem)0.9.1
wisent-desktop-auth (WisentAuth)0.3.6
echo (WisentOnboarding)0.3.0
wisent-errors1.0.0
wisent-desktop-update0.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#

  1. Severity is the layout. A failure gets a full-width WisentAlertPanel with a severity edge, the backend's own sentence and the exact command that reproduces it. A healthy signal gets one line in WisentSignalStrip. The two are never the same size.
  2. Zero mailboxes is not red. mailbox_count == 0 is a fresh install, not an outage; it renders .neutral and says "Not configured". .danger is 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.
  3. 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.
  4. Quote the backend, then say what to do. Every failure carries the core's verbatim message, its machine code, and the documented recovery from skrzynka/docs/ONBOARDING.md:38-52. No paraphrase, and no invented advice.
  5. Every failure carries a reproducing command. A curl with 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.
  6. Data never disappears because a later read failed. Each surface keeps its own failure beside its own value.
  7. 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.
  8. 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.

groupdestinationexists becausebacking surface
WORKPostureTriage: 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
WORKMailboxesThe 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
WORKConnectTurn 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
MAILInboxRead normalized mail from every mailbox with the receiving identity preserved, and reply through that same identity.GET /v1/messages, POST /v1/messages/:id/replies
MAILComposeOriginate 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
MAILSyncRun 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
EVIDENCERepliesAudit provider-facing sends, and resolve the ambiguous ones by inspecting provider Sent mail rather than resending.GET /v1/messages/:id/replies
EVIDENCEOutboundAudit what this organization originated, and resolve an unclear send by inspecting provider Sent mail rather than replaying a key.GET /v1/outbound
SYSTEMServiceThe 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
SYSTEMBoundary — specified, not builtWhat 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 on brandSoft at radius medium, 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; WisentTypeScale does not expose it.
  • Data screens are three zones in one HStack(spacing: 0) under WisentScreen(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 WisentScreen defaults.
  • 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.

roletoken
destination nameWisentTypeScale.screenTitle()
section titleWisentTypeScale.section()
metricWisentTypeScale.metric(), monospaced digits
table cellWisentTypeScale.body() in 28 pt rows
identifier, key, digest, path, timestampWisentTypeScale.identifier() / identifierSmall(), selectable
column head, eyebrowWisentTypeScale.columnHead() / eyebrow(), uppercase, tracking 0.7–0.8
button labelWisentTypeScale.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, Paused and New get one pill each. A sent reply is plain mono text; Uncertain, Failed, Pending and Sending get pills.
  • Row selection is brandSoft fill plus a 3 pt brand leading edge.
  • The only raw numbers permitted in a view are spacing: 0 for 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:

  1. 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 == false as 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.
  2. Signal strip: service, Skarbiec, schema, poller, mailboxes, messages. mailbox_count == 0 reads "Not configured" in neutral.
  3. 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.
  4. 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#

statepresentation
loadingWisentLoadingPanel naming what is being read
empty, nothingWisentEmptyPanel with the reason and one verb
empty, by filterdifferent copy plus "Clear filter"
not configuredneutral, the words "Not configured", never red
refresh failedWisentErrorBanner above retained content
read failed, one itemthat surface's own failure; siblings keep their values
mutationWisentMutationBar, the backend's exact sentence, never silence
irreversibleWisentDecisionDialog, 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#

  1. No local design system. No Color, Font, radius or spacing literal outside WisentDesign / WisentTypeScale / WisentAppLayout. The only raw numbers permitted are spacing: 0, SF Symbol glyph sizes, and the column widths in §4.
  2. No try? on a read whose failure the operator needs to know about, no nil standing in for an error, no empty array standing in for a failure.
  3. Every failure value carries code, the backend's verbatim sentence, and a reproducing command. A screen may add the documented recovery; it may never rewrite the sentence.
  4. A failed read never clears a value a previous read produced.
  5. Sidebar rows and table rows are Buttons. A NavigationLink in a List did not change the destination when the row was clicked through the accessibility action, which made a shipped Wisent client keyboard-navigable only.
  6. Concurrent reads use a task group, never a fan of async let bindings: a task-local allocator is a stack, and awaiting children in declaration order aborts the window inside asyncLet_finish_after_task_completion.
  7. No screen may render a mailbox password, an OAuth code, an access token or a refresh token. None of these crosses the API.
  8. No irreversible act without WisentDecisionDialog. Sending mail and deleting local evidence both qualify.
  9. The operator owns the idempotency key. The client may propose one; it may not hide it.
  10. Nothing may overflow the window. WisentScreen's GeometryReader is never worked around, and every scrolling pane inside an inspector is height-bounded.
  11. 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.

journeybeforeafter
J1 orientation, what this will not doabsentBoundary, specified and not built (§6.10)
J2 prerequisite and posture checkabsentPosture, detail on Service
J3 start/stop the serviceabsentService, as the exact serve command
J4 browse credential authoritiesinside a sheetConnect, with kind, revisions and connected state
J5 connect a password mailboxsheet, no refusal surfaceConnect, with MAILBOX_PROFILE_INVALID and MAILBOX_IMPORT_PROFILE_CONFLICT stated
J6 connect a Gmail identitysheet; flow states collapsed to a spinnerConnect, every flow state named, mismatch guard stated up front
J7 first-success verificationtoolbar buttonSync to run, Inbox to verify, queued on Posture
J8 unified inbox readingthe whole appInbox, three zones, mailbox as a column
J9 reply as the receiving mailboxhidden key, no confirmationInbox composer plus a decision dialog; operator owns the key
J10 reply-outcome audita colour on a rowReplies, faceted by outcome
J11 fleet sync triageone toastSync, per-mailbox verdict table
J12 pause / resume a mailboxcontext menuMailboxes, verb plus a Paused facet
J13 credential rotation recoveryabsentMailboxes and Posture, routing to Connect
J14 interrupted-send recoveryabsentReplies, uncertain facet, standing rule, fresh-key retry
J15 destructive local teardownalertMailboxes plus WisentDecisionDialog quoting the product's sentence
J16 backup / upgrade / rollbackabsentService, with the live database path in the command
J17 originate mail from a mailboxabsent; the capability existed only as skrzynka message sendCompose, plus a decision dialog naming the sending address, the recipients as typed and the key
J18 outbound audit and unclear-send recoveryabsentOutbound, 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:

  1. 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.
  2. 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/status mailbox_count, each mailbox's last_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.