Functional Fixes — Cycle 10

Date: 2026-04-17 Working directory: C:\Users\keen4\WxManBran\tools\tropical-update-publisher\build_v2\v1\tools\tropical-update-publisher Baseline: refinement-functional-cycle-10.md (83 PASS / 28 MISSING / 100.0% health; 3/3 full-suite green; zero FAIL/BLOCKED/PARTIAL/REGRESSION). Scope: Cycle-10 report surfaced zero failing features but still flagged 28 MISSING features as debt. Per prompt rule “MISSING features are NOT acceptable as a final state,” and cycle-10 Watch Flag 2 (“Session B — F030-F033 — is the smallest coherent next slice”), this fix pass implements Session B: F030 + F031 + F032 + F033 — the Publish UX slice (queue rows, commit+upload, global/per-file YouTube, live status log with gitStreamSubscribe).

Summary Table

Metric Before (cycle 10) After (this pass) Δ
Total features 111 111 0
PASS 83 87 +4
MISSING 28 24 -4
FAIL / BLOCKED / PARTIAL / REGRESSION 0 0 0
Health Score 100.0% 100.0% 0
Test files 58 66 +8
Tests 597 659 +62

Health Score formula: PASS / (PASS + FAIL + BLOCKED + PARTIAL) — stays at 100.0% because MISSING is excluded from the denominator; the improvement surfaces in PASS count and MISSING burn-down instead.


Fix 1 — F030 File Queue Rows, Parsed Metadata, And Row Actions

  • Feature ID: F030
  • Priority: HIGH (blocks the entire Publish workflow — commit/upload/YouTube/status-log all consume the queue)
  • Spec Reference: §6.6 (queue row layout: title, original+converted basename, formatted date/time, per-row YouTube, remove, converted badge), §12 (dedup by original AND converted basename), §5.1/§5.2 (filename parse + legacy conversion), §19 R2 (per-file YouTube override).
  • Root cause (cycle 10 spot-check): PublishPlaceholder kept drag/browse results as a plain string[] in local state and rendered them as a preview <ul>. There was no filename parsing, no dedup, no per-file metadata, no YouTube input, no remove/clear actions. The main-side FileValidator (NEW+OLD patterns, conversion, display strings) and YouTubeUrl validator both already existed as pure shared modules but had no renderer surface consuming them.
  • Files changed:
    • src/renderer/features/publish/queueRowModel.ts (new) — pure model: createQueueRow(path){id, originalBasename, effectiveBasename, converted, valid, errors, display, youtubeUrl, youtubeValid, youtubeId}; addPaths(existing, paths) returns {rows, added, rejected} with dedup by original AND effective basename (§12); removeRowById, clearAllRows, applyYouTubeUrl, applyGlobalYouTubeUrl, countValidRows, hasBlockingYouTubeError.
    • src/renderer/stores/usePublishQueueStore.ts (new) — Zustand store owning rows, commitMessage, commitMessageTouched, globalYouTubeUrl, publishing, statusLog plus the action surface (enqueuePaths, removeRow, clearAll, setCommitMessage, setGlobalYouTubeUrl, setRowYouTubeUrl, setPublishing, appendGitLogEvent, appendLocalLogEntry, clearStatusLog). Auto-applies defaultCommitMessageFor until the user edits (touched flag), re-applies the current global YouTube URL to every row on enqueue, and exposes resetPublishQueueStoreForTests for deterministic test setup.
    • src/renderer/features/publish/FileList.tsx (new) — valid-row chrome (Check icon, parsed title, original basename + converted badge, dateFormatted · timeFormatted, per-row YouTube Input with aria-invalid + extracted-id preview, Trash2 remove) vs invalid-row chrome (TriangleAlert, error list). Section header shows “Queued files (N)” + Clear All button; renders null when queue is empty so the drop zone remains the primary affordance.
    • src/renderer/routes/Placeholders.tsx — removed the local useState<string[]> preview; added usePublishQueueStore import; onEnqueueDocxPaths calls enqueuePaths(paths) and toasts dedup collisions with “{basename} already in queue” (§12 copy); wires <FileList />, <YouTubeSection />, <CommitMessage />, <StatusLog /> in spec-ordered sequence before the existing button-variant/dialog smoke block + PublishFooter.
    • tests/renderer/queueRowModel.test.ts (new) — 17 tests: NEW+OLD happy paths, converted-basename collision, dedup-original, insertion order, unique id, remove/clear idempotency, YouTube per-row + global, countValidRows, hasBlockingYouTubeError.
    • tests/renderer/usePublishQueueStore.test.ts (new) — 8 tests: dedup routing, auto commit-msg + touched flag, global YouTube re-apply, new-row inherits global URL, clearAll resets derived state, appendGitLogEvent + clearStatusLog, setPublishing toggle.
    • tests/renderer/fileList.test.tsx (new) — 6 tests: empty-state null render, valid+invalid row mix, converted badge, Clear All wiring, per-row remove, per-row YouTube aria-invalid.
    • tests/renderer/publishPlaceholder.test.tsx — rewritten to assert the new FileList region (data-testid file-list-region) after Browse, retains the git-status Refresh query test, pulls resetPublishQueueStoreForTests into beforeEach so runs don’t leak.
  • What was done: The pure model is split from the store so dedup + conversion logic can be tested without RTL. The store owns the touched flag so auto-populating the commit message stops the instant the user types (prevents spec §6.7 copy from stomping a hand-crafted message). enqueuePaths re-applies the current globalYouTubeUrl to incoming rows so late-enqueued files inherit the existing URL (§11). Remove/clear are route-local store actions, no IPC. FileList suppresses itself when empty so the DropZone hero copy stays uncluttered.
  • Verification: npm run typecheck + npm run lint + npm run build + npm test all green. tests/renderer/queueRowModel.test.ts 17/17 PASS, tests/renderer/usePublishQueueStore.test.ts 8/8 PASS, tests/renderer/fileList.test.tsx 6/6 PASS, tests/renderer/publishPlaceholder.test.tsx 2/2 PASS.
  • Risk: Low-medium. Existing publishPlaceholder.test.tsx had to be updated because the scaffold-era preview <section> was removed — the replacement test covers the spec §6.6 region instead and verifies the browse-to-queue flow still lands paths. Zustand was already a dependency (useSidebarStore.ts precedent). Legacy-to-converted collision (§12) is exercised in queueRowModel.test.ts with a legacy name whose converted form matches an existing NEW row.

Fix 2 — F031 Commit Message Input And Upload Button State Machine

  • Feature ID: F031
  • Priority: HIGH (blocks the upload action — this is the only way a user triggers gitPublish from the UI)
  • Spec Reference: §6.7 (auto-populated default “Add tropical update(s)” keyed to count; Upload button labels “Upload 1 File” / “Upload N Files” / “Publishing…”; disable gates: no files, empty commit message, publishing, YouTube error), §8 (git publish workflow entrypoint).
  • Root cause (cycle 10 spot-check): No renderer commit-message form existed. useGitPublish was already implemented and tested but had no on-screen trigger; the mutation was only reachable via PublishUnknownStateBanner’s retry path.
  • Files changed:
    • src/renderer/features/publish/commitMessageModel.ts (new) — pure helpers: defaultCommitMessageFor(count) returns “Add tropical update” (1) / “Add tropical updates” (0 or ≥2); uploadButtonLabel(count, publishing) returns “Publishing…” / “Upload 1 File” / “Upload N Files” / “Upload Files”; isUploadEnabled({validCount, commitMessage, publishing, hasYouTubeError}) enforces the four disable gates.
    • src/renderer/features/publish/CommitMessage.tsx (new) — shadcn Input for the commit message + shadcn Button Upload. Reads rows / commitMessage / publishing from the store; calls mutation.mutate(payload, { onSettled }) from useGitPublish and pushes start + settled status-log entries (local-<iso> id prefix) into the store so the §6.8 log shows publish progress even before main’s git:log stream fires.
    • tests/renderer/commitMessageModel.test.ts (new) — 12 tests: singular/plural/zero copy, all four button label variants, all five enable/disable boundaries.
    • tests/renderer/commitMessage.test.tsx (new) — 4 tests: empty-queue disable, label + enable toggle on commit-msg edit, valid-path gitPublish invocation (captures the PublishFileDescriptor carrying youtubeId), YouTube-error disable.
  • What was done: The enable/disable predicate is a pure function so every gate is unit-covered without rendering. rowsToDescriptors filters out invalid rows so the main-side publish handler never sees a broken filename. Settled log lines include the short commit sha when main returns success (Publish complete (abc1234)), or the structured userMessage/error/fallback on failure. The onMutate side of useGitPublish already clears unknownAfterTimeout, so the existing banner integration continues to work.
  • Verification: tests/renderer/commitMessageModel.test.ts 12/12 PASS, tests/renderer/commitMessage.test.tsx 4/4 PASS.
  • Risk: Low. The Upload button is gated on isUploadEnabled before mutation.mutate runs, so a race where the queue mutates between render and click still produces a valid descriptor list (empty-descriptor short-circuit in onUpload). The mutation already has a 60s timeout + unknown-state banner via useGitPublish, so no new failure mode is introduced.

Fix 3 — F032 YouTube URL UI

  • Feature ID: F032
  • Priority: MEDIUM-HIGH (sidecar youtube_id is the primary consumer of this input per §11 — without it every publish sidecar ships with a null youtube_id)
  • Spec Reference: §6.5 (global YouTube URL section with live ✓/✗ + extracted 11-char video ID preview), §11 (same global YouTube ID applied to all files in a single publish batch), §19 R2 (per-file override available when needed).
  • Root cause (cycle 10 spot-check): Backend already supports per-file youtubeId in PublishFileDescriptor, but no renderer UI collected the URL. Shared validateYouTubeUrl pure validator existed with full coverage and was unused outside unit tests.
  • Files changed:
    • src/renderer/features/publish/YouTubeSection.tsx (new) — <label> + shadcn Input wired to globalYouTubeUrl/setGlobalYouTubeUrl store methods. Runs validateYouTubeUrl on every keystroke; shows a Check icon + “Video ID: dQw4w9WgXcQ” when valid, an X icon + “Enter a YouTube URL like https://youtu.be/dQw4w9WgXcQ” when invalid, nothing when the input is empty. aria-invalid reflects the live state and role="status" + aria-live="polite" announce changes to screen readers.
    • FileList.tsx (already in Fix 1) — per-row override input feeds setRowYouTubeUrl(id, value)applyYouTubeUrl. Per-row errors surface aria-invalid but do not disable the row itself (the disable gate lives in the Upload button via hasBlockingYouTubeError).
    • tests/renderer/youTubeSection.test.tsx (new) — 4 tests: empty initial state, valid URL → success chip + extracted id, invalid URL → aria-invalid + error chip, store-controlled value mirrors into the input.
  • What was done: The global and per-row inputs coexist — the global URL fills every row (§11), and typing into a per-row input overrides just that row (§19 R2). Because applyGlobalYouTubeUrl re-stamps rows on every setGlobalYouTubeUrl call, per-row customization is only stable while the user keeps the global input unchanged — matching spec §11 “single publish batch” semantics.
  • Verification: tests/renderer/youTubeSection.test.tsx 4/4 PASS. Per-row override covered in Fix 1’s tests/renderer/fileList.test.tsx.
  • Risk: Low. All validation is pure; no IPC. The Upload button’s hasBlockingYouTubeError gate prevents a malformed URL from reaching gitPublish — main-side already tolerates a null youtubeId, so even an empty global URL is a valid publish.

Fix 4 — F033 Status Log UI And Live Git Log Stream

  • Feature ID: F033
  • Priority: HIGH (operator trust — without the status log the user cannot see commit/push progress and would not know what gitPublish actually did)
  • Spec Reference: §6.8 (status log surface below the Publish form), §7.4 (publish emits log events), §13 (50-entry FIFO trim; icons • ✓ ⚠ ✗; en-US 12-hour timestamp with seconds, e.g. 10:42:15 PM).
  • Root cause (cycle 10 spot-check): The preload gitStreamSubscribe was implemented as a stub that threw NOT_IMPLEMENTED on invocation, and no renderer component consumed git:log events. Main already emitted webContents.send('git:log', payload) inside src/main/ipc/handlers/gitPublishHandlers.ts for every stage of the publish pipeline — the pipe was just broken at the preload boundary.
  • Files changed:
    • src/preload/index.ts — replaced the NOT_IMPLEMENTED stub with a real ipcRenderer.on('git:log', listener) subscription that returns an off function (removeListener). Added GitLogEventPayload type import; introduced GIT_LOG_EVENT_CHANNEL = 'git:log' constant; dropped the now-unused createNotImplementedError helper.
    • src/renderer/features/publish/statusLogModel.ts (new) — pure helpers: STATUS_LOG_MAX_ENTRIES = 50, iconForLevel maps to • ✓ ⚠ ✗, entryFromPayload preserves line/level/ts and assigns a unique id, appendLogEntry FIFO-trims to 50 (slice(-50) after concat), formatEntryTimestamp uses toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }) and returns '' on unparsable input.
    • src/renderer/hooks/useGitLogStream.ts (new) — useEffect installs api.gitStreamSubscribe((p) => appendGitLogEvent(p)) on mount and cleans up via the returned unsubscribe. Try/catch swallows a missing-preload race with a console.warn (recoverable — status log stays empty until preload is ready).
    • src/renderer/features/publish/StatusLog.tsx (new) — calls useGitLogStream() to start the subscription. Renders an ordered list with icons + formatted timestamps + level-colored text (success/warning/error/info); auto-scrolls to bottom on entries change via a useEffect + scrollRef. Shows an empty-state hint (“No publish activity yet. Logs appear here as the git workflow runs.”) and a Clear button in the header when entries exist.
    • tests/renderer/statusLogModel.test.ts (new) — 7 tests: icon mapping, entryFromPayload field preservation, append-below-cap, 50-entry FIFO overflow trim, empty-list append, 12h timestamp shape, unparsable timestamp returns empty.
    • tests/renderer/statusLog.test.tsx (new) — 4 tests: empty-state copy, store entries render with level-keyed data-testid, live gitStreamSubscribe callback forwarding from mock to rendered DOM, Clear button empties the store.
    • tests/renderer/windowApiMock.ts — added gitStreamSubscribe to WindowApiMockOptions and the handler map so tests can inject a custom subscribe stub (default vi.fn(() => vi.fn())).
  • What was done: The preload fix is the load-bearing change — everything else is presentation on top of a working stream. The pure model makes the 50-entry FIFO and en-US timestamp format unit-testable without rendering. StatusLog uses useEffect([entries]) + scrollRef.scrollTop = scrollRef.scrollHeight to keep the latest entry visible; auto-scroll triggers regardless of whether the entry arrived via IPC stream or via the local log (CommitMessage pushes its own start/settled entries). The level→color mapping (text-success / text-warning / text-destructive / text-muted-foreground) matches the §14 color ladder used by StatusBar and the new TopBar git indicator.
  • Verification: tests/renderer/statusLogModel.test.ts 7/7 PASS, tests/renderer/statusLog.test.tsx 4/4 PASS. Preload fix verified indirectly by the “installs the gitStreamSubscribe listener on mount and forwards events” test — it passes a custom gitStreamSubscribe stub through window.api and asserts payloads reach the rendered DOM via the store.
  • Risk: Medium. Two items to watch:
    1. Subscription lifecycleuseGitLogStream installs the listener per StatusLog mount. Because PublishPlaceholder is the only consumer, and that route is always mounted when the user is on the Publish page, duplicate subscriptions won’t happen. If a future route also mounts StatusLog, the subscribe/unsubscribe pair will still behave correctly because each call allocates a fresh listener pair.
    2. Timestamp locale drifttoLocaleTimeString('en-US', …) respects the OS timezone. The spec fixes the format (12-hour with seconds) but not the zone; the app’s other timestamp surfaces (StatusBar, PublishFooter) follow the same OS-local convention, so this matches existing behavior.

Mechanical Gates

All three green against the post-fix source tree:

Command Result
npm run typecheck exit 0 (main + preload + renderer)
npm run lint exit 0 (zero warnings)
npm run build exit 0 — renderer 1,030.80 kB / 54.24 kB CSS. The single pre-existing Tailwind informational warning on duration-[var(--motion-duration-medium)] remains (unchanged since cycle 2).
npm test 66/66 test files PASS, 659/659 tests PASS, 65.43s. +8 test files (queueRowModel.test.ts, commitMessageModel.test.ts, statusLogModel.test.ts, usePublishQueueStore.test.ts, fileList.test.tsx, commitMessage.test.tsx, youTubeSection.test.tsx, statusLog.test.tsx) and +62 tests versus cycle 10’s 58 files / 597 tests.

One neighbor-test touch-up: tests/renderer/smoke.test.tsx updated one assertion from /Renderer ready/ (Session 1B scaffold copy) to /Drop Word documents below to queue them for publish/ (the live Publish hero copy after Session B lands) — the ARIA landmark (role="status" + aria-label="Renderer scaffold active") kept intact so accessibility coverage is unchanged.

Master-List Update

  • F030: MISSINGPASS. Tag removed from heading; Code path now cites queueRowModel.ts + usePublishQueueStore.ts + FileList.tsx; Evidence field cites the three test files.
  • F031: MISSINGPASS. Tag removed; Code path cites commitMessageModel.ts + CommitMessage.tsx + touched flag; Evidence cites model + component tests.
  • F032: MISSINGPASS. Tag removed; Code path cites YouTubeSection.tsx + per-row override in FileList.tsx + store reapply; Evidence cites section + fileList + store tests.
  • F033: MISSINGPASS. Tag removed; Code path cites preload fix + useGitLogStream.ts + statusLogModel.ts + StatusLog.tsx; Evidence cites model + component tests and explicitly names the preload regression coverage.

Status totals now: 87 PASS + 24 MISSING + 0 FAIL + 0 BLOCKED + 0 PARTIAL + 0 UNTESTED = 111 ✓.

Spec Citation Per Fix

Per cycle-9/cycle-10 fix-prompt rule “Every fix must cite which spec section justifies the change”:

  • Fix 1 (F030) justified by §6.6 (queue row layout + converted badge), §12 (dedup by both basenames), §5.1/§5.2 (filename parse + legacy conversion), §19 R2 (per-file YouTube). Spec-literal: row chrome, dedup keys, validation messages, Clear All/per-row Remove controls.
  • Fix 2 (F031) justified by §6.7 (auto-populated commit message, button labels, disable gates), §8 (publish workflow entrypoint). Spec-literal: singular/plural copy, “Upload N Files” / “Publishing…” strings, all four disable gates enumerated.
  • Fix 3 (F032) justified by §6.5 (global YouTube with live ✓/✗ + 11-char video ID preview), §11 (“same global YouTube ID applied to all files in a single publish batch”), §19 R2 (per-file override). Spec-literal: live-validation chip + extracted id preview + reapply-on-enqueue behavior.
  • Fix 4 (F033) justified by §6.8 (status log surface), §7.4 (publish emits log events), §13 (50-entry FIFO + • ✓ ⚠ ✗ icons + en-US 12-hour timestamp). Spec-literal: FIFO cap, icon mapping, timestamp format, level-colored text.

Rules Compliance

  • Fix REAL issues not symptoms: preload gitStreamSubscribe now actually subscribes (root cause of F033 — not a bandage around NOT_IMPLEMENTED). Queue model, commit state machine, YouTube UI, and status log all ship with real logic end-to-end.
  • No stubs/TODOs: every new function has real logic; no throw new Error('not implemented'); no // TODO.
  • IPC registered BOTH sides: git:log main emitter already existed in gitPublishHandlers.ts; this pass wires the preload listener that was previously stubbed. No new IPC channels were added.
  • DB ops: none this pass.
  • No @ts-ignore, any, or eslint-disable: confirmed — lint exits 0 with zero warnings.
  • No silent error swallow: useGitLogStream wraps the initial subscribe in try/catch and logs a console.warn (recoverable — the stream re-installs on the next mount); publish failures surface in the status log via CommitMessage onSettled and reach the user as a red log entry. No catch blocks drop errors without either a user-visible log entry or a console.warn.
  • No deleted features/tests to pass build: publishPlaceholder.test.tsx was rewritten to cover the spec §6.6 region (was testing the scaffold preview); smoke.test.tsx hero-copy assertion retargeted from Session 1B scaffold text to the live Publish copy. Both changes track the spec — no behavioral coverage was lost.

Watch Flags Carried Forward To Verify 10

  1. Session B closed — F030 + F031 + F032 + F033 all land as PASS with 62 new tests.
  2. Sessions C–E still open — F039 (Document Creator renderer), F045 (Quick Browse renderer), F087-F089 (command palette + shortcuts + context menus), plus the remaining 20 MISSING spread across tropical monitor, drafts, and settings surfaces. 24 MISSING features remain.
  3. shadcn-smoke keyboard-focus flake (verify-8 flag, cycle-9/10 did-not-recur) — still stable under cycle-10 load; isolated-invocation rotation continues to pass. One timeout-flake observation during this pass: the very first full-suite run showed tests/main/configService.test.ts, tests/main/firstRunGate.test.ts, and tests/package-scripts.test.ts hitting 5s/22s timeouts under heavy parallel load (8 renderer vitest contexts + 8 main contexts + 8 shared contexts active simultaneously). A second, otherwise-identical full-suite run cleared all three — they pass in isolation in <100ms combined. Reclassified as CPU-contention flake, not a regression — MONITOR in verify-10.
  4. publicDir: 'assets' — unchanged; dist/renderer/WMB_Logo.png / icon.ico / README.md still copy at build time.
  5. Main-side git:log emission pathgitPublishHandlers.ts has always emitted webContents.send('git:log', payload) but until this pass nothing consumed it. With the preload fix landing, verify-10 should confirm that a real publish produces a real stream of entries in the status log (not just the synthetic local-<iso> entries from CommitMessage).

IMPLEMENTATION_COMPLETE