# Release v1.48.0

Released: 2026-05-02

## Added

- **Day/night theme toggle + 4 daytime theme variants.** Each existing theme now ships with a paired light-scheme counterpart: `classic-midnight ↔ classic-noon`, `lumen-glass ↔ lumen-glass-day` (cream/cyan glass on a drifting peach mesh — the daytime glassmorphic delivered from a Claude Design handoff), `black-ice-terminal ↔ black-ice-terminal-day` (Phosphor Paper — green ink on cream paper, pixel grid, line-printer report aesthetic), `blueprint-ops ↔ blueprint-ops-day` (Drafting Paper — pale cyan grid on white, navy headings, amber annotations). The picker (`Settings → General`) now groups themes by family and shows a Sun/Moon switch on each card; a global Sun/Moon button in the sidebar footer (and mobile header) toggles the active theme to its pair without leaving the page. Pairing is derived at runtime from `family + opposite-mode` — no per-theme `pair` field to maintain. Day mode surfaces as `data-port-theme-mode="day"` on `<html>`, letting one CSS layer remap hardcoded Tailwind dark utilities (`text-white`, `bg-slate-900/X`, `from-slate-900/X` gradient stops, `border-indigo-500/X`, `text-indigo-400`, etc.) onto theme tokens (`--port-card`, `--port-border`, `--port-accent`) so existing dark-only components — including the CoS avatar pane and `StateLabel` "Thinking" pill — render correctly under day themes without touching JSX. Inter + IBM Plex Mono load globally via Google Fonts. `LEGACY_THEME_ALIASES` redirects any pre-migration `pastel-*` ids onto their nearest day-mode equivalent so persisted settings don't break.

- **Writers Room — manual AI passes (Evaluate / Format / Adapt to Screenplay).** Three new prompt stages in `data.sample/prompts/stages/writers-room-{evaluate,format,script}.md` (registered in `stage-config.json`, seeded into `data/prompts/` by `npm run setup:data`) drive a new evaluator service (`server/services/writersRoom/evaluator.js`). Triggered by user click from a new AI tab in the WorkEditor right sidebar — never auto-runs. **Evaluate** returns a structured editorial critique (logline, summary, themes, strengths, severity-tagged issues with verbatim excerpts, target-anchored suggestions). **Format** returns cleaned prose with paragraphing, dialogue formatting, whitespace, punctuation, and consistent chapter/scene markdown headings; an "Apply to draft" button replaces the editor buffer (no autosave — user still has to hit Save). **Adapt to Screenplay** returns a scene-by-scene breakdown with sluglines, action, dialogue, and a self-contained `visualPrompt` per scene that the per-scene "Generate image" button feeds into the existing `/api/image-gen/generate` queue — making Writers Room the first surface that hands prose-driven prompts straight into the FLUX.2 image pipeline. Snapshots are immutable and pinned to the source draft's `contentHash`; the analyses list shows a stale-warning badge when the draft has moved on since the analysis ran. Storage: `data/writers-room/works/<id>/analysis/<analysisId>.json`. Routes: `POST /api/writers-room/works/:id/analysis` (kind required), `GET /api/writers-room/works/:id/analysis`, `GET /api/writers-room/works/:id/analysis/:analysisId`. Boot recovery (`recoverStuckAnalyses` in `server/index.js`) marks any `running` snapshot as `failed` after a server restart so the UI never spins on a phantom row. Single source of truth for analysis kinds (`ANALYSIS_KINDS` in `writersRoomPresets.js`) shared by Zod and the evaluator. Reuses `stripCodeFences` from `server/lib/aiProvider.js` instead of carrying its own fence regex.
- **Writers Room — drag-and-drop works between folders.** Each work row in the Library pane now has a grip handle (left edge) — drag from there to drop the work onto any folder header to file it, or onto the Unfiled zone to remove it from a folder. Built on `@dnd-kit/core` (already a dependency, same pattern as `KanbanBoard.jsx` / `goals/GoalsListView.jsx`). PointerSensor uses a 6px activation distance so a normal click still selects the work. Dropping into a closed folder auto-expands it so the moved work is immediately visible. While a drag is in flight the Unfiled zone always renders (even when empty) with a "Drop here to unfile" hint so a user can unfile the only work in a folder. A `<DragOverlay>` chip follows the cursor while the source row dims to 30% opacity. Toast confirms the move (`Moved "Bad Curry" to Dream Machine` / `Moved "Bad Curry" to Unfiled`). Backend was already wired — the existing `PATCH /api/writers-room/works/:id` accepts `{ folderId }`.
- **Writers Room (`/writers-room`) — Phase 1 authoring core.** New top-level `Create` sidebar group bundles **Media Gen** (default landing) with **Writers Room**, the new file-backed workspace for prose/short-story/screenplay/novel/essay drafts. Library pane manages folders + works; Markdown editor with live word count, segment-index outline (auto-derived from `#` chapters and `##`/`###` scenes), version snapshots, and Cmd/Ctrl+S save; "Write for 10" sprint timer with 5/10/15/25-min presets, prompt field, and per-session word-delta logging. Storage lives under `data/writers-room/` (`folders.json`, `exercises.json`, `works/<id>/manifest.json` + `drafts/*.md`) — driven by a single writer (`server/services/writersRoom/local.js`) following the Creative Director pattern. New routes under `/api/writers-room/*` cover folder/work CRUD, draft body I/O, version snapshot/switch, and exercise lifecycle. Backend tests cover word counting, content-hash determinism, segment-index splitting, folder/work CRUD with cross-validation (deleting a non-empty folder, missing-parent rejection), draft round-trip + version pointer-switching across multiple snapshots, exercise duration clamping + word-delta math + settled-state rejection, and the strict Zod schemas at the route boundary. Mobile/touch-friendly: library pane caps height on mobile, delete buttons stay reachable without hover. Phases 2-5 (manual AI analysis, Creative Director handoff, synced prose/script/media review, realtime CD feedback) tracked in `docs/features/writers-room.md`.
- **Queue additional renders while one is in flight.** Image Gen form fields stay editable during a render and the Generate button becomes a "Queue" button — submissions land in `mediaJobQueue` (server FIFO) without taking over the active SSE/preview, so the user can stack multiple prompts without waiting. A "+N queued" badge surfaces queue depth, and a 4 s `/api/media-jobs` poll syncs it against the server's truth and refreshes the gallery the moment a queued render completes (only on busy-count drop — no per-tick refresh churn). The poll's effect deps key on a `queueActive` boolean so the interval clock doesn't tear down on every count change. A separate "Cancel current" button lets the user kill the active render without losing the form's content. Mirrors the form-editable-during-render policy already in place for video gen.
- **STAGE pipeline for FLUX.2 model loading + downloads.** `flux2_macos.py` now emits `STAGE:download-tokenizer:<repo>` / `STAGE:download-pipeline:<repo>` / `STAGE:move-to-device` / `STAGE:inference` markers; `server/services/imageGen/local.js#handleLine` parses them and broadcasts a typed `{type:'stage', stage, detail}` SSE event. The Image Gen page maps each marker to a friendly label via a `STAGE_LABELS` lookup ("Downloading model weights (~8 GB on first run)…", "Moving model to GPU…", etc.) so the multi-minute first-run HF weight download no longer looks like a frozen "step 0/8". Inference progress events also carry the active phase so the step counter only ticks during real diffusion, not during download `tqdm` bars (which count safetensors files, not steps).
- **Friendly typed errors for HuggingFace failures.** Top-level exception handler in `flux2_macos.py` walks the exception cause chain (diffusers wraps HF errors in `OSError`) to detect `GatedRepoError` / `HfHubHTTPError(401)` / `RepositoryNotFoundError`, then emits a structured `USER_ERROR:<kind>:<repo>` line plus a friendly `❌ <prose>` line — including a `_repo_from_hf_error()` URL parser that reconstructs `owner/name` from the failing request URL because `huggingface_hub` doesn't always populate `error.repo_id`. `local.js` parses the `USER_ERROR:` line out of the stderr buffer (single `findIndex` pass — no nested `indexOf` O(n²)) and propagates `kind` + `repo` through both the per-job SSE error event and the `imageGenEvents.emit('failed', ...)` payload, so the queue's downstream broadcast inherits the friendly message instead of "Exit code 1". The Image Gen page renders a typed error card with an `ERROR_HEADINGS` lookup ("Model access required" / "HuggingFace token rejected" / "Model repo not found") and a one-click "Request access to <repo> ↗" button that deep-links to the HF page — replacing the previous behavior where a 9B gated-access failure surfaced as bare "Exit code 1" with a wall of Python tracebacks in `pm2 logs`.
- **Python child stderr/stdout echoed to pm2 logs.** `local.js` now logs `🐍 [<jobId>] <line>` / `🐍-out [<jobId>] <line>` for every non-progress, non-noise line from `flux2_macos.py` (and any future image runner). Lines that drove a `progress` or `stage` SSE event are intentionally excluded so the log stream isn't drowned by tqdm bars. Mirrors the existing video-gen logging pattern — without it, model-load lifecycle (`🔧 sdnq: pipeline ← …`) was only visible in the SSE channel the browser saw and never landed in server logs, making "stuck on step 0/8 for 8 minutes" indistinguishable from a hang.
- **In-app HuggingFace token entry.** The "FLUX.2-klein is gated, export HF_TOKEN before running PortOS" banner now has a paste-and-save form: enter your `hf_…` token, click Save, and PortOS persists it to `settings.imageGen.hfToken`. The local-image worker (`server/services/imageGen/local.js`) injects it into the spawn env (`HF_TOKEN`, `HUGGINGFACE_HUB_TOKEN`, `HUGGINGFACEHUB_API_TOKEN` — `huggingface_hub` reads any of the three) so `flux2_macos.py` can pull gated repos like `black-forest-labs/FLUX.2-klein-4B` without the user having to drop to a shell. New helper `server/lib/hfToken.js` (`getHfToken()` / `hfTokenEnv()` / `isPlausibleHfToken()`) — stored token wins over env-var fallback so the user can update without restarting PortOS. New route `POST /api/image-gen/setup/flux2-token` validates the `hf_…` prefix before persisting. Same security trade-off as other PortOS settings: plaintext in `data/settings.json`, justified by the single-user Tailscale deployment model documented in CLAUDE.md.
- **In-app FLUX.2 venv installer.** Selecting a FLUX.2 model when the venv isn't bootstrapped now surfaces an "Install FLUX.2" button on the Image Gen page instead of telling the user to drop to a shell and run `INSTALL_FLUX2=1 bash scripts/setup-image-video.sh`. Clicking it opens a modal that streams real-time progress over a new SSE endpoint (`GET /api/image-gen/setup/flux2-install`) — a 5-stage pipeline (detect Python → create venv → upgrade pip → install packages → verify `Flux2KleinPipeline` import) animates with check-marks/spinners while pip output scrolls in a live log pane below. Closing the modal mid-install SIGTERMs pip so a multi-GB torch download doesn't keep going invisibly. Server-side logic lives in `installFlux2Venv()` in `server/lib/pythonSetup.js`, mirroring the bash script's package list (`FLUX2_PIP_SPECS`) so both paths stay in sync. The status cache (`resolveFlux2Python`) busts on completion so the banner clears without a server restart.
- **FLUX.2-klein image generation on macOS (Apple Silicon).** Three quantized variants ship: 4B SDNQ-4bit (~8 GB @ 512px, recommended), 9B SDNQ-4bit (~12 GB, needs 32+ GB RAM), and 4B Int8 (~16 GB). Runs through PyTorch + diffusers + MPS in a separate `~/.portos/venv-flux2` to avoid colliding with mflux's older torch. `INSTALL_FLUX2=1 bash scripts/setup-image-video.sh` bootstraps the venv (idempotent — skips re-install when `Flux2KleinPipeline` already imports). The runner (`scripts/flux2_macos.py`) mirrors `mflux-generate`'s CLI surface so the existing local-image dispatcher just swaps the binary path; live preview frames, metadata sidecar, gallery, and Remix all work unchanged. Stepwise preview decodes latents on a `@torch.no_grad()` callback and writes capped 512-px PNGs so fs.watch + base64 stays cheap. Models are gated on HuggingFace — accept the license at https://huggingface.co/black-forest-labs/FLUX.2-klein-4B and export `HF_TOKEN`; the Image Gen page surfaces a yellow banner when the venv is missing or the token isn't set. The legacy `flux2-klein-{4b,9b}` registry entries (which had `broken: 'macos'`) auto-upgrade in place — user customizations like a custom name or steps are preserved.
- **Creative Director — autonomous long-form video projects** (`/media/creative-director`). Configure a project with a fixed aspect ratio, quality, model, target duration (up to 10 min), style spec, and optional starting image or user-supplied story. A CoS-driven agent writes a scene-by-scene treatment, kicks off 1–10s segment renders one at a time (continuing from each prior segment's last frame or starting fresh from a new image), evaluates output thumbnails against the style spec via its own multimodal review, and self-perpetuates until the episode is complete. On completion the server auto-creates a video-timeline project and renders the final concat into a single mp4. All segments + the final cut are grouped in an auto-created media collection. New routes (`/api/creative-director/*`), data file (`data/creative-director-projects.json`), preset tables (`server/lib/creativeDirectorPresets.js`), agent prompts (`server/lib/creativeDirectorPrompts.js`), and UI tabs (Overview / Treatment / Segments / Runs).
- **Unified media job queue** (`server/services/mediaJobQueue/`) for image and video gen. Replaces the per-provider 409 BUSY error with a FIFO that accepts every submission immediately and returns `{ jobId, status: 'queued', position }`. A single worker drains serially (image and video share Metal/MLX runtime, so parallelism would OOM anyway). Persists to `data/media-jobs.json`; on boot any job stuck in `running` is reclassified `failed (interrupted by restart)`. SSE on `/api/{video,image}-gen/:jobId/events` covers the full `queued → started → progress → complete/failed` lifecycle so existing UIs keep working without changes. New `/api/media-jobs` lets the UI inspect and cancel queued/running renders.
- **Media model registry at `data/media-models.json`** — single source of truth for video and image model definitions, the LTX text encoder, and per-platform defaults. Replaces hardcoded model catalogs in `videoGen/local.js`, `imageGen/local.js`, and `imageVideoModels.js`. Seeded automatically on first run; edit + restart the server to swap models. New `gemma-bf16` (now default, best output quality), `gemma-qat-4bit` (auto-detected if LM Studio installed it), and `gemma-4bit` text-encoder options. Loader resolves `~/...` and prefers a local `localPath` when it exists.
- **`+faststart` mp4 post-processing** — every video render is remuxed (no re-encoding, ~hundreds of ms) to move the moov atom to the front of the file. Gallery tiles with `preload="metadata"` now render their poster frame on page load instead of flashing black until clicked.
- **`caffeinate -i -w <pid>`** spawned alongside the LTX Python child on macOS so a 90s+ render isn't aborted by display/system sleep on a laptop. `-w` makes it self-exit when our pid does — no manual lifecycle management.
- **Raw stderr/stdout logging from the LTX Python child** with `🐍`/`🐍-out` prefixes per job. Previously the python child's diagnostic output was filtered for `STATUS:`/`STAGE:` markers and the rest silently dropped, hiding upstream errors like "VAE encoder failed to load" until you ran the CLI manually.
- **`imageStrength` parameter** on the video gen API + service. Plumbed through validation, route, service, and CLI args. mlx_video's actual semantics are inverted from its help text — `1.0` preserves the source latent (PortOS default if unspecified), `0.0` fully denoises. Documented inline.
- **1:1 square resolutions** (512×512, 768×768) added to the Video Gen resolution dropdown.
- **Image upload preview + larger reference-image previews** in Video Gen — uploads create object URLs so users see what they uploaded before submit; source / FFLF last-frame / extend-mode previews now render at `max-h-48 object-contain` instead of 48×48 thumbnails.

- **Morse code trainer** in POST (`/post/morse`) — Koch-method Copy mode (listen → type, 90% accuracy unlocks the next letter) and a Send mode that decodes spacebar/touch keying into text. Native Web Audio (no new deps), Farnsworth timing, configurable WPM/tone. Reachable from the POST launcher header, the sidebar, ⌘K, and voice (`ui_navigate "morse"`).
- **Morse trainer side widget** with a binary tree visualization that highlights the live keying path (DAH-left, DIT-right), three reference views (Tree / Length / List), a tap-anywhere practice key, and a real-time decoded log. Spacebar keying is intercepted in capture phase so it doesn't trigger the voice FAB push-to-talk hotkey while on the morse page.
- **Video Gen mode switch + batch queue** (`/media/video`) — segmented control selects Text / Image / FFLF (first-frame + last-frame) / Extend (continue from a prior render). Mode-specific input panels (start image, end image, prior-video picker) replace the always-on source image upload. New "Add to queue" button enqueues the current configuration; a batch-queue panel runs jobs serially with per-item status (pending/running/complete/error), inline error messages, and a clear-finished action that drops both successful and errored items. Backend route accepts a `lastImageFile` (gallery-pick) and `mode` field; `--last-image` is plumbed to the LTX Python child (currently advisory until a multi-keyframe wrapper lands).

## Changed

- **Creative Director audio defaults to OFF.** The current macOS LTX/MLX video pipeline produces inconsistent audio across renders (gaps, garbled passes, off-mood beds), so new CD projects now create with `disableAudio: true`. The new-project form has a "Generate audio for each scene" checkbox (off by default; opt-in if you want the audio pipeline to run anyway), and the project Overview tab gained an Audio toggle (using the existing `ToggleSwitch` component to match the Apps/CoS settings-toggle pattern) that PATCHes `disableAudio` post-creation — applies to future scene renders only, since already-rendered scenes keep their original audio. `creativeDirectorProjectUpdateSchema` accepts the new field. Existing projects retain their original setting. (Standalone Video Gen still defaults audio ON — only CD changed.)

- **Writers Room — per-scene generated images persist across navigation + auto-collect.** Scene images used to vanish from the UI the moment you navigated away, even though the rendered PNG was sitting on disk. Now: when `image-gen:completed` fires for a scene, the client POSTs `/api/writers-room/works/:id/analysis/:analysisId/scene-image` which (a) writes the scene→image link to the analysis snapshot under `sceneImages[sceneId]`, and (b) appends the image to a per-work auto-created MediaGen Collection (`Writers Room: <title>`, lazily created on first render and cached on the work manifest under `mediaCollectionId`). Re-opening the Adapt result re-seeds each `SceneCard` with its persisted image, so it shows the final render immediately instead of an empty button. Stale collection ids (e.g. user deleted the collection out-of-band) fall through to a fresh create.
- **Writers Room — per-work reading theme switcher.** The WorkEditor header now has a Sun/Moon toggle (next to Save/Snapshot) that flips the prose textarea + AI sidebar's Adapt scene cards between dark mode (default — matches the rest of PortOS) and a sepia/light reading mode (`#f5f1e8` background, dark text) for users who don't want to read white-on-black for hours. Persists per-user via `localStorage` (`wr.readingTheme`); page chrome and library stay app-themed so the contrast cue stays consistent. Scene-card body text, character chips, dialogue, and the "X words / unsaved" status pill all flip with the toggle.
- **Writers Room — per-scene image gen has its own model + resolution settings.** The Adapt result now exposes a small inline settings row (gear button under the logline) where you can pick the model (`flux2-klein-4b` is the default — fastest FLUX.2 variant on Apple Silicon — with `flux2-klein-9b`, `flux2-klein-4b-int8`, and the existing `dev`/`schnell` mflux models also selectable) and a resolution preset (default 768×512 / 3:2; presets cover 1:1, 2:3, 16:9, and 1:1 1024). Choices persist to `settings.json` under `writersRoom.imageGen` so they survive reloads and apply across all works in the Writers Room. The per-scene preview area now uses the configured aspect ratio instead of always being square, so 3:2 / 16:9 renders aren't letterboxed inside an `aspect-square` slot.
- **Writers Room — AI sidebar is wider and drag-to-resize.** The right-hand AI/Outline/Versions panel default jumps from 240px → 480px so the Adapt scene cards, Evaluate critique, and Format diff render as real page content instead of being squeezed into a margin. A new vertical splitter between the editor and the sidebar lets the user drag to resize (range 280px → 70% of the work area); the chosen width persists per-user via `localStorage` (`wr.sidebarWidth`) so it sticks across reloads. Double-clicking the splitter resets to 480px. Mobile/tablet (< `lg`) still stacks editor + sidebar vertically and ignores the saved width.
- **Writers Room — per-scene image gen now shows a real preview area.** Clicking the per-scene "Image" button used to silently fire a render with no in-card UI feedback — for FLUX.2 jobs that take 30-90s, this looked like nothing happened until the final PNG appeared. Each `SceneCard` now subscribes to `image-gen:started` / `progress` / `completed` / `failed` socket events filtered by its own job id and renders an `aspect-square` preview slot (matching the Image Gen page) that shows: a spinner with "Step N/M" + ETA while queued, the live base64 diffusion frame as the model decodes intermediate latents, a thin progress bar overlaid at the bottom, and the final PNG once `image-gen:completed` fires. The button label flips between "Image" / "Rendering…" / "Re-render" so the state is unambiguous from the row header alone. Two scenes generating concurrently can't fight over progress because each card filters by its own jobId rather than sharing a single hook.

- **Writers Room — Library pane is collapsible.** A new chevron button in the header hides the 260px library to give the editor full width; a thin 32px rail stays visible with an expand button so the user can bring it back. State persists in `localStorage` under `wr.libraryCollapsed` and survives reloads. Right sidebar in the WorkEditor was also reorganized into three tabs — **AI** (default), **Outline**, **Versions** — replacing the previous always-visible Outline + Versions stack so the AI panel has room to render evaluation results, format diffs, and scene cards inline.
- **Edge-to-edge layout for Media Gen, Writers Room, and Settings.** Pages that own a tab strip now follow the canonical full-width shell (header → edge-to-edge tab bar → padded content area) used by Calendar/Brain/etc., so the tab strip pins to the page edges instead of floating inside a centered `max-w-7xl` box. `Layout.jsx` also drops the `max-w-7xl mx-auto` cap on the remaining boxed pages, so on wide monitors content fills the available width (outer `p-4 md:p-6` is preserved, so non-tabbed pages still have breathing room). `MediaGen.jsx` and `Settings.jsx` were rewritten to the canonical shell; `WritersRoom.jsx` swapped its `h-[calc(100vh-3rem)]` viewport hack for `h-full` so the desktop layout no longer leaves a 3rem dead gap at the bottom.
- **Sidebar reorg: AI group folded into Settings.** The top-level `AI` sidebar group is gone — its two children (`Prompts`, `Providers`) now live alphabetically under `Settings`. `server/lib/navManifest.js` re-keys `nav.prompts` → `nav.settings.prompts` and `nav.providers` → `nav.settings.providers` (with `section: 'Settings'`) so ⌘K and the voice agent's `ui_navigate` resolver group them with the rest of Settings. Routes (`/prompts`, `/ai`) are unchanged — only the navigational grouping moved, so existing bookmarks keep working.
- **Image Gen and Video Gen layouts tightened for above-the-fold density.** Form and preview now render side-by-side on `lg+` (`grid-cols-[3fr_2fr]`) instead of stacking; previews are capped (image `max-w-[360px]`, video `max-w-[420px]`) so they no longer dominate the viewport. Prompt + Negative Prompt sit in two columns on `md+`, parameter grids force three columns on `sm+`, the LTX mode switch and mode-specific image pickers are more compact, and section headers adopt a consistent `text-xs uppercase` style. Status text is hoisted into the Generate button row, video Download moved into the preview header for parity with image. Note: the codebase requires underscore (not comma) separators in Tailwind arbitrary grid template values (e.g. `lg:grid-cols-[3fr_2fr]`); the prior `[1fr,1.2fr]` was silently falling back to a single column.
- **Worktree policy** clarified in `CLAUDE.md`: TUI sessions edit the main repo directly; worktrees are reserved for unattended CoS sub-agents.

## Fixed

- **Inline code in CoS task summaries readable on day themes.** `MarkdownOutput.jsx` rendered inline code (`` `8a440037` ``, `` `<input>` ``, `` `flex-wrap` ``, etc.) and fenced code blocks with a hardcoded `text-cyan-400` foreground on a `bg-port-bg` background. In daytime themes — most visibly Lumen Glass Day — `bg-port-bg` is cream/white, leaving bright cyan-on-cream that was unreadable in agent task-summary cards. Both `<code>` styles now use `text-port-accent`, which is sized per theme for legible contrast (`#0891b2` on cream in Lumen Glass Day, `#1d4ed8` on white in Drafting Paper, `#0a7a4a` on cream in Phosphor Paper) and continues to be the bright cyan/blue/green accent in dark themes.

- **Settings tabs reflow cleanly on mobile.** The Timezone row in `Settings → General` (input + Save + "Use detected: <long-tz>") used to overflow a 375 px viewport because everything was forced into a single `flex` row with a `max-w-xs` input that filled the screen and pushed the buttons off the right edge. The row now stacks vertically below `sm` and the detected-tz button truncates instead of breaking layout. Same family of fix applied across the rest of the Settings panes — `Voice`, `MortalLoom`, `Telegram`, `Backup`, `Database` — where action-button rows now wrap (`flex-wrap`) and inherit a 40 px `min-h` so buttons stay tappable when they reflow onto a second line. Card padding uniformly drops from `p-6` to `p-4 sm:p-6` so 16 px is reclaimed on each side at mobile widths. VoiceTab's checkbox-with-helper rows (Enable voice mode, Show floating widget, Use personality, Enable tools, CoreML encoder) used to squish the gray hint inline next to the main label and produced a wall of vertically-misaligned text on a phone — they now stack the hint below the main label on mobile and inline it on `sm+`. The MortalLoom Enabled toggle's hint similarly drops to a second row on phone widths instead of pushing the toggle off-screen. The Telegram bot-token reveal/hide button gains a 40 px tap target, and the BackupTab "+" exclude-path button matches.

- **CoS Schedule tab — agent-option toggles now respond to taps on iPhone.** The per-task "Agent Options" rows in `/cos/schedule` rendered a `<ToggleSwitch>` (which is a `<button>`) inside another `<button>`, producing invalid nested-button HTML. iOS Safari refuses to dispatch tap events reliably for nested interactive controls, so tapping the toggle (or the row) sometimes did nothing. `ToggleSwitch` now supports a `decorative` prop that renders as a `<span>` (with `aria-hidden`) so it can be used purely visually inside another button without breaking the click target. The Schedule tab's Agent Options use it; the outer button's existing `aria-pressed` continues to communicate state to assistive tech.

- **Day/night theme toggles now have real mobile tap targets.** The per-card Sun/Moon switch in `Settings → General` and the global Sun/Moon button in the sidebar/mobile-header rendered ~26–30px buttons (`p-1.5` around a 14–18px icon), which sat below the 40px minimum touch target rule and were especially error-prone in the per-card pill where the two adjacent buttons share a 2px-padded seam. Both now expand to a `min-w-[40px] min-h-[40px]` touch target on mobile and collapse back to the original compact pill at `sm+` so the desktop chrome stays tight.

- **Creative Director projects no longer get stuck in `planning` with zero runs.** Clicking Start on a fresh `draft` project flipped status to `planning` in the route, then fired `startCreativeDirectorProject` → `advanceAfterSceneSettled`, which re-read the project, saw `status === 'planning'`, and bailed via a too-aggressive dedup early-return that was meant to catch "treatment task already in flight." The treatment task was never enqueued, no run was recorded, and the same flaw silently bricked `failed`/`paused` projects without a treatment plus the boot-time recovery path. Replaced the brittle status check with a runs-array probe (`runs.some(r => r.kind === 'treatment' && r.status === 'running')`) that actually reflects "a treatment task is mid-flight." Boot recovery now correctly resumes stuck projects on server restart. Two new regression tests in `orchestrator.test.js` cover the fresh-Start path and the genuine in-flight dedup path.

- **Creative Director scene segments play inline.** The Segments tab used to render each scene's mp4 as an `<a target="_blank">`-wrapped thumbnail — clicking opened the file in a new tab, which is awkward for previewing 6–20 short clips in sequence. Each tile is now an inline `<video controls preload="none" playsInline poster=…>` element using the existing per-job thumbnail JPEG as the poster frame, so the grid still renders instantly without paying for 20 metadata round-trips on tab open. Fetching only happens on play. `onError` hides any tile whose mp4 is missing, matching the prior `<img>` fallback behavior.

- **Browser downloads are now retrievable from the UI.** PortOS is accessed remotely over Tailscale, so any file Chrome saved into `data/browser-downloads/` was visible in the listing but unreachable — the row only rendered name/size/mtime, with no link to fetch the bytes. Added `GET /api/browser/downloads/:name` (path-traversal guarded via `basename` + `resolve` + `startsWith` on the configured download dir, MIME-typed for previewable files, `Content-Disposition: attachment` forced for risky types like `.html`/`.svg`/`.js`/`.xml` to keep same-origin XSS off the table) and a sibling `DELETE` route. The Downloads pane on `/browser` now renders each row as a clickable link (opens in a new tab for inline preview), plus an inline Download icon (`?attachment=1` + HTML5 `download` attr to force save-to-device) and a Trash icon that DELETEs and removes the row from local state.
- **Brain → memory bridge no longer fans out across every record on a single write.** Adding one inbox item used to trigger ~12 memory updates and 12+ embedding rebuilds for unrelated projects (visible as a block of `🧠🔗 Updated brain→memory` lines after a single classification). Root cause: `saveJsonStore` emits `${type}:changed` with the whole `{ records }` blob on every write, and `brainMemoryBridge.handleEntityChanged` iterated every record on each event. The journals path was already migrated to per-entry events for exactly this reason; the sibling entity types (people, projects, ideas, admin, memories) were never updated. `server/services/brainStorage.js` now also emits per-record `${type}:upserted` (with `{ id, record }`) on `create`/`update` and `${type}:deleted` on `remove`; `server/services/brainMemoryBridge.js` listens to those instead of `:changed`, so a single classify-and-file now produces a single memory write + embedding. The store-wide `:changed` event still fires for any UI listeners that genuinely want the full set.
- **Writers Room — "Add folder" form no longer clips its Cancel button.** The new-folder inline form previously laid out as `[input flex-1] [Add] [Cancel]` inside the 260px library aside, which left no room for the Cancel button — it overflowed off the right edge and was clipped by the panel's overflow boundary. Restructured to stack: the input takes the full width, then `[Add (flex-1)] [Cancel]` sits on a second row inside a `bg-port-card` boxed group matching the new-work form's existing layout.
- **FLUX.2 stepwise previews now actually render.** The callback's `vae.decode(latents)` was silently failing every step with `IndexError: tuple index out of range` because `Flux2KleinPipeline` hands the callback latents in transformer-packed shape `(B, num_patches, C·P²)` (e.g. `(1, 2304, 128)` for 1024×576) but the VAE expects unpacked `(B, C, H, W)`. New `_unpack_flux2_latents()` does the patch unpack (2×2 patches × 8× VAE downsample) before the decode call. `make_stepwise_callback()` now takes `height`/`width` and short-circuits the unpack if a future pipeline version hands us an already-4-D tensor.
- **FLUX.2 install detection now checks functional health, not just file existence.** Previously `resolveFlux2Python()` returned the venv binary path as long as the file existed — but a venv whose install was killed mid-pip leaves the python binary behind with an empty site-packages, so the status endpoint reported `venvInstalled: true`, the install banner cleared, and rendering crashed with `Exit code 1` in 100ms when `flux2_macos.py` failed to `import diffusers`. New helper `isFlux2VenvHealthy()` (cached) probes `from diffusers import Flux2KleinPipeline` once. `/setup/flux2-status` and `/setup/flux2-install`'s early-return both use it, so a half-broken venv now reports as not-installed and the user can retry the install from the UI without manual cleanup.
- **PM2 watcher debounce reduced from 5 min → 1 min** and `ignore_watch` hardened with `data/`, `logs/`, `.cache/`, and `portos-stepwise-*` patterns. `watch: ['server']` already scopes the watcher correctly, but the long debounce meant a server-file edit + start-render-now sequence could cleanly kick off a render and then have PM2 restart 5 minutes in, killing the Python child with SIGINT halfway through. Tighter debounce + explicit defense-in-depth ignore patterns make the failure mode less catastrophic and easier to predict during dev.
- **FLUX.2 stepwise preview diagnostic.** Added a one-time `🖼️  stepwise: callback live, latents.shape=…` log on the first callback fire, and a post-run `🖼️  stepwise summary: callback fired N times, saved M previews` so when previews don't show up the cause (callback never attached, latents missing, VAE decode failing) shows up in `pm2 logs`. The existing per-step `⚠️ stepwise preview failed` line now also includes the exception type for triage.

- **Creative Director smoke-test fixture is ~2× faster.** Drops scene `durationSeconds` from 3s → 2s and switches `aspectRatio` from `1:1` (512×512) to a new hidden `1:1-small` preset (384×384, ~44% fewer pixels) — total compute budget goes from `3 × 3s × 512²` to `3 × 2s × 384²`, roughly a 63% pixel-frame reduction. The smaller preset is registered in `ASPECT_PRESETS` only (not `ASPECT_RATIOS`), so the user-facing dropdown and route validation surface stay unchanged; the smoke route bypasses validation by constructing the project internally. `targetDurationSeconds` updated to 6 to match (3 × 2s). Lets the post-pipeline-change health check finish in render time only without waiting on full-resolution clips.
- **Creative Director agent prompts moved into the Prompts Manager.** `buildTreatmentPrompt` and `buildEvaluatePrompt` (in `server/lib/creativeDirectorPrompts.js`) now compute a flat view object and delegate to `buildPrompt('cd-treatment', view)` / `buildPrompt('cd-evaluate', view)`. The actual prompt text lives in `data/prompts/stages/cd-treatment.md` and `cd-evaluate.md` — editable from the Prompts Manager UI without code changes. The view object precomputes everything the template engine can't (aspect dims, render params, multi-frame list with timeline-position labels, scene strategy line, retry budget delta), so authors can rearrange wording and sections freely. `agentBridge.js` updated to await the now-async builders. The two new stages plus their templates ship as starters in `data.sample/prompts/stages/cd-treatment.md`, `cd-evaluate.md`, and the `cd-*` entries in `data.sample/prompts/stage-config.json` — `npm run setup` / `npm run setup:data` copies them into `data/prompts/` on fresh installs (and fills in any missing files on existing installs without overwriting user customizations).
- **Prompt template engine upgraded to support dot notation.** `server/lib/promptTemplate.js` ships an enhanced Mustache-flavored renderer that handles `{{a.b.c}}` lookups, object section scopes (`{{#user}}{{first}}{{/user}}`), array iteration with parent-scope fall-through (so `{{project.id}}` still resolves inside `{{#frames}}`), `{{.}}` for primitive iteration items, inverted sections (`{{^x}}`), and triple-mustache (`{{{x}}}`). Routed through `server/services/promptService.js#buildPrompt` so every PortOS template (including legacy ones like `cos-evaluate.md` that were silently leaving `{{metadata.context}}` literals in the output because the upstream toolkit's `\w+`-only regex doesn't match dots) now renders correctly. Storage stays in the toolkit (`stage-config.json`, `variables.json`, `.md` files); only the templating step is local. 17 engine tests + 12 CD prompt-rendering tests cover the surface.
- **Creative Director multi-frame evaluator sampling.** The cognitive evaluator previously only Read the rendered video's frame-0 thumbnail, so 5s i2v scenes whose intent develops mid- or late-clip (archway appearing, light bloom, particles converging) were getting rejected even when the render delivered. The server now samples 5 evenly-spaced frames at render-completion time via a new `extractEvaluationFrames()` helper in `server/lib/ffmpeg.js` (probes total frames with `ffprobe`, then uses `ffmpeg -vf "select='eq(n,0)+eq(n,N/4)+eq(n,N/2)+eq(n,3*N/4)+eq(n,N-1)'" -vsync vfr`), writes them as `<jobId>-f1.jpg ... -f5.jpg` in `data/video-thumbnails/`, persists the basenames to `scene.evaluationFrames`, and `buildEvaluatePrompt` now lists every frame with timeline position tags ("start (0%)", "~50% through", "end (~100%)") and instructs the agent to Read each one. Includes explicit "intent that arrives late still counts as delivered" guidance to fix the false-reject pattern observed on the first real E2E run. Falls back to single-thumbnail Read when extraction fails (ffmpeg/ffprobe missing, probe returns 0 frames).
- **Creative Director stuck projects now self-recover** — `completionHook` always re-evaluates project state after any CD task completes (regardless of task kind), so legacy `scene`-kind tasks from before the treatment/evaluate split no longer leave a project frozen. PATCHing a scene to a terminal status (`accepted` or `failed`) also nudges the orchestrator to start the next scene immediately.
- **Creative Director projects auto-resume on server restart.** New `recoverInFlightProjects()` runs at boot (after `initMediaJobQueue` reclassifies running jobs as `failed (interrupted by restart)`): for every project in `planning`/`rendering`/`stitching`, it resets any scenes stuck in `rendering` or `evaluating` (whose in-memory listeners died with the previous process) back to `pending` and fires `advanceAfterSceneSettled` so the orchestrator picks up where it left off. Without this, a pm2 reload mid-render left the project frozen — scene status pointed at a dead job, no listener to advance.
- **Creative Director smoke-test fixture** — `POST /api/creative-director/smoke-test` (or the "Run smoke test" button on the CD list page) creates a deterministic 3-scene colored-ball project: scene 1 generates a red ball bouncing on white (text-to-video), scene 2 continues from scene 1's last frame with a "blue ball" prompt to verify the i2v honors a prompt-driven color change while preserving motion, scene 3 continues from scene 2 with the same prompt to verify pure i2v continuity. The fixture sets `disableAudio: true` (skips the mlx_video audio pass to roughly halve wall-clock per scene) and `autoAcceptScenes: true` (sceneRunner short-circuits the cognitive evaluator with a synthetic `accepted` evaluation, so a smoke run completes in render time only — no Claude in the loop). Adds `disableAudio` and `autoAcceptScenes` as CD project fields and plumbs them through to the queue params.
- **Creative Director scene-retry path unblocked.** `creativeDirectorSceneUpdateSchema` previously stripped `pending` from the agent-callable status enum, but the evaluator-agent prompt template (`creativeDirectorPrompts.js`) and `advanceAfterSceneSettled` (`completionHook.js`) both depend on the agent flipping a scene back to `pending` (with a refined prompt and bumped `retryCount`) to request a re-render. The validation rejected every retry PATCH with `Invalid enum value` and stranded scenes in `evaluating` forever. Schema now accepts the full `SCENE_STATUSES` set and the misleading "agents shouldn't be allowed to flip back to pending" comment is corrected.

- **Image-to-video on macOS now actually preserves the source image as the first frame.** Root cause: PortOS was on `mlx-video-with-audio` 0.1.33, which had a known bug where the VAE encoder failed to load for split-format unified models (LTX-2.3 distilled-Q4) and silently produced gray-fog conditioned frames. Pinned `>=0.1.35` in `scripts/setup-image-video.sh`. The setup script also installed the wrong PyPI package: `mlx_video` (a different unrelated "video loading utilities" package) instead of `mlx-video-with-audio` (the LTX generation backend). Both provide an `import mlx_video` module so the wrong install was silent. Verified end-to-end through the API: pixel-perfect first-frame preservation with coherent motion across all frames.
- **FFLF last-frame conditioning silently produced no output.** PortOS was passing `numFrames - 1` (= 24 for 25 frames) as `--image-frame-idx`, but mlx_video expects a *latent* frame index — for LTX that's `floor((videoFrames - 1) / 8)`, max 3 for 25 frames. ffmpeg-conditioning shape check failed and the python child exited 0 with no output mp4. Now computes `lastLatentIdx` correctly.
- **"Continue" / extract-last-frame produced phantom-success logs but no file**, leaving a broken image preview in Video Gen. Two compounded bugs: (1) `-sseof -0.1` was too narrow a seek window — for videos with audio (B-frames + AV mux), the last keyframe sits earlier than 100ms before EOF, so ffmpeg returned zero frames while sometimes still exiting 0. Widened to `-sseof -1.0` and added `-update 1`. (2) The success path resolved on exit-code without verifying the output file existed. Now requires `existsSync(framePath)` too.
- **Last-frame extraction now caches** to `data/images/lastframe-<videoId>.png` — repeated "Continue" clicks reuse the cached PNG instead of re-spawning ffmpeg. Cuts ~1–2s per click.
- **Delete operations now log** for both image (`🗑️ Deleted image: <filename>`) and video (`🗑️ Deleted video: <filename>`) gallery actions. Previously delete was silent, making it impossible to confirm from the server logs that the click hit the backend.
- **Media model registry tolerates partial user edits.** `loadMediaModels()` now merges parsed `data/media-models.json` over the built-in defaults so a registry missing top-level keys (e.g. `{}` or no `video`) doesn't crash server boot at module import. The loader also catches read errors (permissions, broken symlink) — not just JSON parse — falling back to defaults so a registry I/O problem can't take down startup. Wrong-type fields are coerced back to defaults too (e.g. `image: {}` or `video.macos: "ltx"` → built-in array), so consumers like `getImageModels()` / `buildAppModels()` can't throw at import-time on parseable-but-wrong-shape JSON. `getDefaultVideoModelId()` validates the configured default against the platform's available list — typos or `broken`-flagged ids fall back to the first available model with a warning instead of surfacing as "Unknown video model" in the UI. `getTextEncoderRepo()` validates that the resolved entry has a non-empty repo string before passing it to mlx_video. macOS video gen now also validates `model.repo` exists before spawning, surfacing a 500 with a clear error rather than passing `undefined` into spawn args.
- **Last-frame cache validates non-zero size.** A prior ffmpeg crash could leave a 0-byte placeholder at `data/images/lastframe-<videoId>.png`, and the cache hit (`existsSync(framePath)`) would serve it forever as a broken image. `extractLastFrame` now `statSync`s the cached file and falls through to re-extract when it's missing or zero bytes; failed re-extractions also unlink the partial file so the next call retries instead of inheriting the broken cache.
- **Faststart remux works on Windows without risking the rendered video.** `optimizeForStreaming()` did an in-place `fs.rename(tmpPath, videoPath)` which fails on Windows when the destination already exists. The Windows replace strategy now moves the original aside to a `.bak.<uuid>` first, installs the optimized file, and restores the backup on any failure — so a transient rename failure (file lock, AV scan, permissions) skips faststart instead of destroying the rendered video. POSIX still uses the single-syscall atomic replace.
- **`caffeinate` sleep-prevention is now strictly macOS-only.** Previous gate (`!IS_WIN`) would also fire on Linux and emit a pointless ENOENT every render. Tightened to `process.platform === 'darwin'`.
- **`scripts/setup-image-video.sh` removes the wrong package before install.** The PyPI package `mlx_video` (unrelated video-loading utility) shadows the LTX backend's `import mlx_video` if previously installed. The script now `pip uninstall --yes mlx_video` before installing `mlx-video-with-audio>=0.1.35`, so upgraders aren't left in an ambiguous state.
- **Form fields stay editable during a video render.** Previously every input was `disabled={generating}`, blocking the user from queueing a different config while a render was in flight. The queue worker captures payloads at enqueue time, so live editing is safe.
- **"Add to collection" popover on media cards no longer gets clipped at the card edge.** `MediaCard`'s outer wrapper had `overflow-hidden` (to clip the image into the card's `rounded-xl`), which was creating a clipping context that swallowed the `AddToCollectionMenu` popover regardless of its `z-index`. Moved the corner-clipping onto the image button itself (`rounded-t-xl overflow-hidden`) so the outer card no longer clips its absolutely-positioned children.

- **Morse Send drill** no longer reveals the answer or competes with itself for spacebar input: the morse pattern under each prompt is gone (the reference tabs on the right are still available for hints), the global spacebar keying listener only attaches in Send mode (so it doesn't fire on the trainer landing page or during Copy drills), the side widget's "Practice Key" panel is hidden outside Send mode and no longer duplicates the live decoded letter, and the "Your sending" box starts blank with a "waiting for your first key…" hint instead of a misleading "—" placeholder.
- **CoS agents now reliably open PRs and request Copilot reviews when configured.** Previously, when both `openPR` and `reviewLoop` were enabled on a task, the worktree-cleanup logic's gate (`taskOpenPR && !taskReviewLoop`) skipped PR creation — falling through to auto-merge the agent's branch into main with no PR, no review, and contradictory wording in the agent prompt about whether it should open the PR itself. The cleanup now always opens the PR when `openPR` is set; when `reviewLoop` is also set, it requests a Copilot review on the new PR via the new `git.requestCopilotReview()` helper. The agent prompt is updated to consistently tell the agent the system handles PR creation and review requests.
- **Brain inbox delete silently did nothing on filed entries.** The DELETE `/api/brain/inbox/:id` endpoint returns 204 No Content; `apiCore.request()` maps 204 → `null`, so the `if (!result) return` guard in `handleDelete` fired immediately — skipping the toast, state update, and refetch. Server still deleted the entry, making the bug invisible until a page reload. Fixed by switching to a `failed` flag (same pattern as `MemoryTab.jsx`) and replacing the `fetchInbox()` refetch with an optimistic `setEntries(prev => prev.filter(...))` for instant UI feedback.
- **`update.ps1` no longer hangs at "Running setup…" when Docker is unavailable.** `setup-db.js` checks `!process.stdout.isTTY` (stdout is redirected to the log file by `Invoke-Logged`) in addition to stdin, so the interactive storage-mode prompt is skipped — it logs a warning to stderr and exits 0 instead of hanging for input or silently rewriting `PGMODE=file` in `.env`.
- **`setup.ps1` no longer fails with `EISDIR` on Windows.** Replaced the `npm run setup` call (which uses `npm install --prefix client/server/autofixer`) with individual `Push-Location`/`Pop-Location` installs. `npm install --prefix <subdir>` from the root causes npm to symlink the root `portos` package into each sub-package's `node_modules` as a directory symlink, which requires Developer Mode or admin rights on Windows. Also adds the previously missing steps: git submodule update, autofixer install, esbuild rebuild, and node-pty rebuild.

## Full Changelog

**Full Diff**: https://github.com/atomantic/PortOS/compare/v1.47.0...v1.48.0
