Architecture decisions — Tropical Update Publisher (v2 build)
Architecture decisions — Tropical Update Publisher (v2 build)
This file records cross-session decisions that are not obvious from code alone. Session 3C introduced the baseline Content Security Policy module; Phase 14D will extend the policy per project-spec.md §28.2.
Phase 14 CSP delta hosts (Session 3C — checklist for Session 14D)
Baseline production CSP (§1 rule 6 — implemented as BASELINE_CSP_PROD in src/main/security/csp.ts):
default-src 'self'script-src 'self'style-src 'self' 'unsafe-inline'
Phase 2 additions (exact text from project-spec.md §28.2 — not yet merged into the runtime prod string):
img-src: addhttps://www.nhc.noaa.govanddata:(in addition to'self'already implied by the Phase 2 spec block).connect-src: addhttps://www.nhc.noaa.govandhttps://api.anthropic.com(in addition to'self').font-src: add'self'anddata:as an explicit directive (baseline relies ondefault-srcfor fonts until Phase 2).
No other origins are permitted without a specification amendment.
Dev-only CSP relaxations (P11)
Documented in src/main/security/csp.ts (module-level comment). Never ship unsafe-eval in production; dev uses Vite’s eval-based HMR client.
Config unknown keys (Session 4A — ADR)
Context: Operators may hand-edit config.json or merge files from backups; newer app versions may add keys before older builds read the file.
Options:
- Strip — remove keys not in the schema (Zod object default behavior).
- Strict — fail validation when unknown keys exist (
.strict()on the root object and on every nested object in the schema). - Passthrough — retain unknown keys on the parsed object (rejected: secrets risk and unclear merge semantics).
Choice: Strip by default in parseAppConfig (unknownKeyPolicy defaults to 'strip'). Callers that require a closed world (e.g. settings import audit) pass { unknownKeyPolicy: 'reject' }, which uses AppConfigSchemaStrict (root plus nested .strict()).
Exports: AppConfigSchema (strip) and AppConfigSchemaStrict (reject unknowns at any schema-defined nesting level). Documented so Session 4B ConfigService can choose policy per call site.
Verification checklist item 9: Extra properties → strip mode removes them and parse succeeds; reject mode surfaces VALIDATION_ERROR with Zod “unrecognized key” issues (including nested objects).
Secrets excluded from AppConfig / JSON (Session 4A)
Forbidden key names (non-exhaustive list aligned with spec §28): smtpPassword, senderPassword, password, oauthToken, apiKey, anthropicApiKey. A bounded-depth tree walk (currently 12 object levels from the root) rejects these at any scanned object nesting level (not only the root) with FORBIDDEN_SECRET_KEY, even in strip mode, so mis-pasted secrets fail fast instead of being silently dropped. If a plain object sits deeper than that limit, parsing fails with FORBIDDEN_SCAN_INCOMPLETE so configs with extreme nesting cannot bypass the scan (shallower forbidden keys are still reported as FORBIDDEN_SECRET_KEY first when both apply).
Not modeled: SMTP/IMAP passwords, OAuth tokens, GitHub PAT, Anthropic API keys, Discord webhook secret — stored via keytar service WxManBran-TropicalPublisher per spec §28.1 / master plan §26 vault table.
File copy batch semantics + recovery (Session 5B)
Context: Publishing copies queued .docx files into incoming/posts and may emit per-file {stem}.meta.json when a validated youtubeId is present (R2 / domain 0B).
Partial batch failure policy
- Options: (A) continue after a failure and report all per-row errors; (B) abort the remainder after the first hard failure; (C) transactional all-or-nothing with rollback.
- Choice: (B) — process rows sequentially in input order (deterministic; matches queue insertion order). On the first row that fails validation,
copyFile,stat, or metawriteFile, record that row’s structurederrorCode, setbatchAbortedAfterto its 0-based index, and appendFILE_COPY_BATCH_ABORTEDresults for all remaining rows without copying them. Already-copied files and successful meta writes remain on disk (no automatic rollback). - Why: Matches master plan Session 5B / 5D risk note: operator may need completed files for diagnosis; remainder is explicitly skipped rather than left ambiguous.
Idempotent second publish
- Options: (A) refuse overwrite; (B) overwrite
.docxand rewrite.meta.jsononly when the new copy + meta write succeed; (C) delete incoming folder between runs. - Choice: (B) —
copyFileoverwrites an existing destination file;overwritten: truewhen a file already existed. A new successful run rewrites.meta.jsonwhenyoutubeIdis present. - Why: Aligns with “second publish with the same queue must be idempotent” in the master plan.
Optional publish-batch.json journal
- Options: implement a journal under
userDatafor crash recovery vs defer. - Choice: Deferred — not implemented in 5B. If the process dies mid-batch, the operator removes or reconciles stray
incoming/postsfiles manually; Session 5D orchestrator may revisit journaling if product owners require it. - Why: Session scope is
FileCopyService+ IPC wiring; journaling is called out as optional in the master plan narrative.
HashRouter for renderer navigation (Session 7A — ADR)
Context: Electron loads the renderer via loadFile / file:// (or http://localhost in Vite dev). BrowserHistory paths like /publish mutate the path portion of the URL, which is unreliable or opaque under file://. Session 4D already uses window.location.hash for the first-run surface (#first-run / #/first-run), establishing hash as a viable routing channel before the full shell existed.
Options:
BrowserRouter— clean URLs in dev; breaks or confuses deep links under packagedfile://without extra main-process URL plumbing.MemoryRouter— safe everywhere; loses addressable deep links and does not persist navigation across reloads in a way operators can share or bookmark.HashRouter— path lives inlocation.hash(#/publish), whichfile://tolerates; aligns with existing hash-based first-run gate; reload preserves the route within the hash fragment.
Choice: HashRouter wrapping the post-first-run AppShell tree (main.tsx still short-circuits to FirstRunModal when the hash matches first-run before App mounts).
Tradeoffs: Hash is visible to users who inspect the URL bar; MemoryRouter-style isolation tests use the real HashRouter in integration tests and manipulate window.location.hash where needed.
Verification checklist item 8: This section satisfies “HashRouter/MemoryRouter choice documented in ADR.”
Follow-up (optional): If product later requires path-style URLs in dev and hash in prod, split on import.meta.env — deferred until a session explicitly needs it.
TanStack Query + renderer IPC timeouts (Session 7B — ADR)
Context: The renderer cannot block forever on ipcRenderer.invoke calls. The master plan Window 4 policy mandates ≥8000 ms for read-style queries and ≥60000 ms for the git:publish family, with a distinct IPC_RENDERER_TIMEOUT path, operator toasts (§23.11 tone), no automatic second submit for publish, and one automatic retry only for idempotent reads.
Options:
- Timeout only inside main-process handlers — centralizes deadlines but leaves the renderer blind when main is wedged; violates the session brief to wrap preload/effective invoke from the renderer.
- Ad-hoc
Promise.raceper hook — minimal deps; duplicates deadline + error mapping across features. - Shared
invokeWithTimeout+ TanStack Query defaults — single race implementation, typed query keys,QueryCache/MutationCacheglobal toast wiring, explicit idempotent channel registry for retry policy.
Choice: (3) — src/renderer/api/ipcClient.ts owns invokeWithTimeout, IpcInvokeError, IPC_RENDERER_TIMEOUT, toast copy, createRendererQueryClient, and the exported IDEMPOTENT_QUERY_IPC_CHANNELS list (read-only channels safe for a single retry after timeout / GIT_STATUS_FAILED). Mutations default to retry: false; publish timeouts flip useGitPublish’s unknownAfterTimeout flag so the banner path can appear without implying success.
Toast transport: Sonner (sonner package) hosts global toasts with shadcn-aligned class tokens (src/renderer/components/ui/sonner.tsx) plus project-spec.md §23.11 “Copy details” action (clipboard).
Devtools: @tanstack/react-query-devtools loads only when import.meta.env.DEV via dynamic import() inside AppProviders so production bundles avoid pulling the panel.
Tradeoffs: Duplicate timeout guards (renderer + future main deadlines) are acceptable until a later consolidation session; the idempotent channel list must stay in sync with ipc-inventory when new read-only handlers appear.
Theme tokens + typography (Session 7C — ADR)
Context: project-spec.md §23.2 defines brand colors, storm severity coding, Inter + JetBrains Mono, and a px type scale. Session 7C must extend Tailwind + CSS variables without breaking shadcn/Radix token names (background, primary, destructive, …).
Options:
- Replace shadcn palette entirely with ad-hoc hex in Tailwind
theme.extendonly — risks Radix components drifting from app surfaces. - Keep shadcn HSL variables and only add parallel hex utilities — duplicates source of truth.
- Map §23.2 into
:root/.darkHSL custom properties consumed by existing shadcn keys, plus named extras (--success,--storm, …) and component utility classes for storm borders/surfaces.
Choice: (3) — globals.css owns tokens; tailwind.config.ts adds fontFamily, fontSize scale keys (type-12 … type-36), and semantic success / warning / info / storm colors pointing at new variables.
Typography loading: Google Fonts with display=swap in CSS @import (not self-hosted in-repo) to avoid bundling binary font files without a download step. Phase 14 / DECISIONS already list a font-src delta; Session 14D must add https://fonts.gstatic.com (and typically https://fonts.googleapis.com for any link-based stylesheet if CSP tightens) to production CSP when webfont loading is enabled in packaged builds.
Theme persistence: localStorage key tup:ui-theme-v1 until Settings IPC owns preferences. Invalid values fall back to dark (product default). bootstrapRendererChrome runs before createRoot so first paint matches stored preference.
Reduced motion: CSS variables (--motion-duration-*) collapse under prefers-reduced-motion: reduce; data-preferred-motion mirrors media query for Session 7D Framer wrappers.
Tradeoffs: Google Fonts require network at first run unless cached; operators on air-gapped machines fall back to system stacks defined in tailwind.config.ts.
Framer Motion presets + route transitions (Session 7D — ADR)
Context: Master plan §23.4 / session 7D require 200ms route-level transitions and 50ms list stagger, with reduced-motion parity. Session 7C already defined CSS custom properties (--motion-duration-*) that collapse under prefers-reduced-motion: reduce and mirrored data-preferred-motion on <html>.
Options:
- CSS-only transitions — no Framer; lighter bundle but awkward
react-routerexit animations and no shared stagger API. - Ad-hoc durations per view — fastest to sketch; drifts from spec and duplicates reduced-motion handling.
- Central
presets.ts+ thin layout wrapper — single source for ms constants,Variantsfactories (pure + unit-tested),RouteTransitionLayoutwithAnimatePresence mode="wait"for serialized route swaps.
Choice: (3) — src/renderer/motion/presets.ts exports ROUTE_TRANSITION_DURATION_MS (200), LIST_STAGGER_DELAY_MS (50), createRouteTransitionVariants, createListStaggerContainerVariants, createListStaggerItemVariants, and coerceReducedMotionFlag (Framer’s useReducedMotion() can be null before read). RouteTransitionLayout wraps Outlet and keys on location.pathname. Settings placeholder demonstrates list stagger.
Reduced motion: Factories accept prefersReducedMotion: boolean derived from useReducedMotion(); when true, opacities stay at 1 and transitions use duration: 0. Sidebar width continues to use duration-[var(--motion-duration-medium)] so CSS variables enforce the same OS preference for non-Framer motion.
Bundle tradeoff: framer-motion adds roughly ~55–75 KiB gzip to the renderer chunk (library + helpers; exact figure depends on tree-shaking and Vite split). Documented for operator cost awareness; acceptable for spec-mandated motion polish.
Follow-up: When real queues/lists land (publish dashboard), import the same stagger factories instead of inventing new timings.
Global status bar + top bar (Session 7E — ADR)
Context: project-spec.md §23.3 defines a persistent footer (monitor dot, AI backend, last push, storm count, git branch, network) and a top row (identity, ⌘K search, theme toggle). Session 7B already exposes useGitStatus for branch text.
Options:
- Inline JSX in
AppShellonly — fastest, but untestable copy and ordering drift risk. - Single
Chrome.tsxmega-component — one import site, harder to review and reuse. TopBar+StatusBar+ small pure formatters — matches prior sessions’ pattern (thin layout components, tested strings), integrates with TanStack Query likePublishPlaceholder.
Choice: (3) — TopBar.tsx / StatusBar.tsx plus statusBarModel.ts and topBarModel.ts. Git errors use a dedicated role="alert" banner above the footer (verification: not toast-only / silent). Polite aria-live carries a full sentence summary when healthy; on error the live text points operators to the banner to avoid duplicate assertive chatter.
Tradeoffs: Placeholder segments remain until monitor/network/history sessions land; horizontal space is managed with flex-wrap, truncate, and title tooltips for long branch names instead of a bespoke overflow DropdownMenu (not yet in the shadcn set for this build).
Publish DropZone + drag-drop bridge (Session 8A — ADR)
Context: Master plan Session 8A mandates a dashed .docx drop target, BEM state classes, Framer polish (§23.5), Browse via dialog:openDocx, synchronous path extraction for HTML5 drops, and strict a11y (role="region", timed aria-invalid, assertive errors without raw errno).
Options:
- Renderer-only paths — rely on deprecated
File.pathin Chromium/Electron without preload helpers; breaks under stricter sandbox builds and is hard to mock in Vitest. - Async IPC for every drop — invoke main to map
Filehandles; conflicts with ipc-inventory row 021 synchronous drop-handler expectation and adds latency. - Preload
webUtils.getPathForFile+ typedgetPathsForFiles, plus validatinggetFilePaths— matches Electron 33 guidance, keeps mapping synchronous, and mirrorsDND_NO_PATHSsemantics for empty handle lists.
Choice: (3) — src/preload/index.ts implements dragDrop.getPathsForFiles and a non-stub getFilePaths that trims handles and throws structured DND_NO_PATHS when empty. Renderer DropZone pairs lengths via zipFilesToPathsOrIssue in dropZoneModel.ts.
Browse alignment: createDialogOpenDocxHandler now serves mode: 'docx' (default) with multiSelections + Word filter so operators can enqueue up to five paths without drag metadata.
Tradeoff: Adding getPathsForFiles expands the TropicalDragDropApi surface beyond the historical single-method stub; downstream sessions must keep the two-call sequence (resolve → validate) stable for tests and production parity.