# Release v1.49.0

Released: 2026-05-03

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

## Added

- **Video Gen — 2× Lanczos upscale on completed videos + "No music" prompt toggle + mid-clip thumbnail seek.** Three improvements lifted from the Phosphene LTX panel:
  1. **2× upscale action.** Every video in the gallery (Video Gen recent + Media History) now exposes a `Maximize2`-icon button that runs an ffmpeg Lanczos 2× upscale on the clip — H.264 yuv420p CRF 18, audio stream-copied, `+faststart` preserved, ~10-30s on a typical 5s LTX render. The result lands as a new history entry tagged with `upscaledFrom: <originalId>` and a "(2×)" prompt suffix; the source clip is left untouched so the user gets both versions side-by-side. A `2×` badge surfaces on upscaled tiles to keep them distinguishable. New helper `upscaleVideo2x()` in `server/lib/ffmpeg.js`, service entry `upscaleHistoryItem()` in `server/services/videoGen/local.js`, and `POST /api/video-gen/upscale/:id` route. The upscale button is hidden on already-upscaled clips so users don't chain 2×→4×→8× by accident.
  2. **"No music" prompt toggle.** New checkbox below the existing "Disable audio" toggle on `/media/video`. When on (and audio gen is itself active), submit-time prompt composition appends `\n\nno music, no soundtrack` so LTX-2 steers toward ambient/diegetic sound (footsteps, room tone) rather than generated background music — useful when the clip is destined for downstream scoring or VO. Idempotent: skipped if the user already typed "no music" themselves. Disabled when `disableAudio` is checked since there's no audio output to steer.
  3. **Mid-clip thumbnail seek.** Thumbnails were extracting frame 0 via `-vframes 1`, which on LTX-2 renders is the darkest moment of the clip (model fades IN from black, so 0-0.5s is near-zero brightness) and produced "broken black tile" complaints in the gallery. `generateThumbnail()` now ffprobes duration, seeks to `min(2.5s, duration/2)` (1s fallback when ffprobe is unavailable) so the cached JPG samples the visual peak instead of the fade-in. Affects every new render going forward — old thumbnails keep their existing frame until regenerated. Touched: `server/lib/ffmpeg.js`, `server/services/videoGen/local.js`, `server/routes/videoGen.{js,test.js}`, `client/src/components/media/{MediaCard,normalize}.{jsx,js}`, `client/src/pages/{VideoGen,MediaHistory}.jsx`, `client/src/services/apiImageVideo.js`.

- **Storyboard style presets — prompts expanded 2-4x with richer detail.** Every preset in `server/lib/writersRoomStylePresets.js` (all 40 across Cinema & Photo, Genre & Era, Animation, and Illustration & Comics) had its `prompt` rewritten from a 150-300 char stub into a 670-970 char production-grade descriptor. Each prompt now layers (1) named medium / film stock / brush technique, (2) lighting direction + quality + color temperature, (3) named color palette, (4) compositional + framing intent, (5) atmospheric / texture detail, and (6) explicit artist or studio influence (Roger Deakins, Frank Frazetta, Mucha, Hokusai, Mignola, McQuarrie, etc.). Negative prompts also enriched to better suppress unwanted hybrid styles. No schema changes — `id`, `label`, `category`, and `description` (UI text) all preserved, so existing Works that recorded a preset id still resolve to the new richer text on next render. Touched: `server/lib/writersRoomStylePresets.js`.

- **Style presets surfaced on Image / Video gen, expanded library, and Codex backend wired into Writers Room storyboard.** The world-style presets that previously only existed inside the Writers Room storyboard config are now available on the standalone **Image Gen** and **Video Gen** pages too — a category-grouped picker (`StylePresetPicker`) sits above each form's prompt textarea, prefixes the user's prompt with the preset's style at submit time, and appends the preset's negative-prompt to the user's negative. The library grew from 9 to **40 curated presets across four categories** — *Cinema & Photo* (cinematic, noir, photorealistic, vintage film, polaroid, golden hour, low-key dramatic), *Genre & Era* (cyberpunk neon, steampunk, dieselpunk, solarpunk, dark/high fantasy, space opera, cosmic horror, vaporwave, 80s sci-fi paperback, victorian, Wes Anderson), *Animation* (Studio Ghibli, Pixar 3D, Disney 2D classic, anime modern + 90s, claymation), and *Illustration & Comics* (graphic novel, comic book, manga B&W, watercolor storybook, oil painting, impressionist, art deco, art nouveau, ukiyo-e woodblock, concept art, pixel art, low-poly, isometric, technical blueprint, sumi-e ink wash) — every preset that nudges toward text/panel rendering aggressively negatives `text, words, letters, lettering, typography, title, logo, watermark, signature, caption, word balloon, speech bubble, sound effect` to suppress FLUX/Klein/Schnell's text-rendering gibberish. The Writers Room storyboard's Config tab gained a **Local | Codex** backend chip strip (only renders when more than one backend is configured); picking Codex routes scene renders through the `$imagegen` skill on the user's logged-in Codex session — model/steps/seed are hidden in that mode since `image_gen` picks them internally. Codex's sync response now also returns `status: 'running'` so SceneCard's UI stays in spinner state until the SSE `completed` event lands instead of flipping to `done` and 404'ing the placeholder `<img>`. New endpoint `GET /api/image-gen/style-presets` (canonical) replaces the old `/api/writers-room/style-presets`; client now caches the 40-item response with a module-level promise so navigating Image → Video → Writers Room doesn't refetch. New shared modules: `client/src/components/media/{StylePresetPicker,BackendChipStrip}.jsx`, `client/src/lib/{composeStyledPrompt,imageGenBackends}.js`. Touched: `server/lib/writersRoomStylePresets.js`, `server/routes/{imageGen,writersRoom}.js`, `server/services/imageGen/codex.js`, `client/src/pages/{ImageGen,VideoGen}.jsx`, `client/src/components/writers-room/StoryboardPanel.jsx`, `client/src/services/{apiSystem,apiWritersRoom}.js`.

- **Writers Room — tabbed sidebar (Characters | World | Scenes | Boards | Config), top progress strip, and "Stop all" cancel for in-flight scene renders.** The right column was a single-purpose Storyboard pane with the bibles hidden behind drawer overlays from the work overflow menu — clicking "Refresh Characters" closed the menu and hid the only spinner, so users couldn't tell whether anything was happening. Replaced with a 5-tab sub-nav: **Characters** and **World** render their bibles inline with a top "Refresh from prose" button (with running spinner per-button); **Scenes** is the prose-derived outline (clickable rows jump to that segment in the textarea); **Boards** keeps the existing storyboard scene cards; **Config** consolidates the gear-icon's Adapt LLM picker, World style, and image-gen settings. A status strip ("Refreshing characters…") sits below the tab nav whenever any analysis runs — visible from any tab even after the overflow menu closes. Each tab's icon shows a spinner when its kind is the running one. Adapt auto-jumps to the Boards tab when it starts. Long queues of auto-generated scene images can now be aborted: when one or more scenes are rendering, a banner appears above the cards ("Rendering N scenes…") with a red **Stop all** button — clicking it POSTs `/api/image-gen/cancel { all: true }` (queued cancel before running so the slot doesn't refill mid-loop), then iterates each running scene's imperative ref and calls `cancel()` to roll local state back to idle (idempotent — handles the race where the cancellation `image-gen:failed` socket event arrives first and flips the card to `error`). Per-card unmount cleanup in `onRunningChange` prevents stale entries inflating the count if a scene disappears mid-render. Touched: `client/src/components/writers-room/{StoryboardPanel,WorkEditor,SceneCard,CharactersBible,SettingsBible}.jsx`, `client/src/services/apiImageVideo.js`, `server/routes/imageGen.{js,test.js}`.

- **Writers Room — 3-step storyboard setup (Characters → Settings → Adapt) replaces the lone "Run Adapt" CTA.** A fresh story used to greet the user with a single "Run Adapt" button, but Adapt's quality depends on the character + setting bibles being populated first (otherwise the LLM re-improvises descriptions every scene, breaking visual continuity). The empty-state CTA now shows a guided 3-step panel: each step has a status pill (numbered → spinner → green check), its own `Run characters` / `Run settings` / `Run Adapt` button, and a prominent `Run all in order →` master action that fires the sequential pipeline (`runFullPipeline`) so the user can one-click the whole flow. Once Adapt has run, a separate `BiblesMissingNotice` banner shows above the scenes if either bible is empty, with inline "Extract characters" / "Extract settings" buttons — so users who ran Adapt before populating bibles get a clear path to fix the visual drift without leaving the panel. `runAnalysis` now returns success/failure so the pipeline can bail on first error rather than silently chaining through a broken state. Touched: `client/src/components/writers-room/{StoryboardPanel,WorkEditor}.jsx`.

- **Writers Room — setting / world bible (`Refresh settings`) + bibles fed back into Adapt for cross-scene continuity.** New analysis kind `settings` extracts a per-location bible from prose: each entry keyed by screenplay slugline (`INT. CURRY OCITY — NIGHT`) with `description`, `palette`, `era`, `weather`, `recurringDetails`, plus prose evidence + missing-fields hints. Same merge semantics as the character bible — re-runs preserve user edits, only fill blanks, and add new locations. New stage `writers-room-settings` (md prompt + stage-config seed); new service `server/services/writersRoom/settings.js`; new routes `GET/POST /api/writers-room/works/:id/settings`, `PATCH/DELETE /api/writers-room/works/:id/settings/:settingId`; new `SettingsBible.jsx` drawer in WorkEditor with create/edit/delete; new `Refresh settings` menu item. Sluglines normalize identically on client + server (uppercase, em-dash → space, punctuation stripped) so `INT. KITCHEN — NIGHT` and `INT KITCHEN - NIGHT` resolve to the same entry. The script (Adapt) prompt now also receives both bibles as variables (`existingCharactersJson`, `existingSettingsJson`) and is instructed to (a) cite canonical character `physicalDescription` rather than re-improvising and (b) reuse the bible's slugline string verbatim so the storyboard pipeline auto-matches. SceneCard's image-prompt composition now layers the matched setting between world-style and characters: `{worldStyle} . {workTitle} . {setting.description} {setting.palette} {setting.recurring} . Featuring — {chars} . {scene.visualPrompt}` (priority order: visualPrompt always survives, then setting baseline, then characters drop one-by-one to fit the 1900-char budget). End-to-end verified — gemini-2.5-pro extracted 5 locations from a 4,800-word draft in 66s, merged with the user-edited entry, and now feeds image gen for visual continuity across scenes set in the same place. New files: `server/services/writersRoom/settings.js`, `client/src/components/writers-room/SettingsBible.jsx`, `data{,sample}/prompts/stages/writers-room-settings.md`. Touched: `server/lib/{validation,writersRoomPresets}.js`, `server/services/writersRoom/evaluator.js`, `server/routes/writersRoom.js`, `data{,sample}/prompts/{stages/writers-room-script.md,stage-config.json}`, `client/src/{services/apiWritersRoom.js,components/writers-room/{WorkEditor,StoryboardPanel,SceneCard,sceneCardHelpers}.jsx}`.

- **Writers Room — Codex / Claude Code CLI support for AI passes, and tail-truncated CLI errors so failures are actually readable.** `callCliProvider` previously branched on `gemini-cli` and treated everything else as "spawn the binary, pipe prompt to stdin" — which Codex and Claude Code reject because they need a subcommand to run non-interactively. Picking Codex as the Adapt LLM exited code 1 with `Error: stdin is not a terminal` because `codex` defaulted to its REPL; Claude Code would have the same gap. Replaced the binary `isGeminiCli` switch with a `buildCliInvocation(provider, model)` dispatcher mirroring `runner.js` / `agentCliSpawning.js`: `codex` → `exec [--model <m>] -`, `claude-code` / `claude-code-bedrock` → `-p - [--model <m>]`, `gemini-cli` → unchanged (`--prompt` arg + `stdio[0] = 'ignore'`), default → spawn binary and pipe prompt to stdin. Also flipped CLI error truncation from `slice(0, 500)` → `slice(-2000)` since the actual failure (e.g. `"The 'gpt-5' model is not supported when using Codex with a ChatGPT account"`) lives at the end of stderr — the head-truncation was clipping the banner and hiding the real cause. Touched: `server/services/writersRoom/evaluator.js`.

- **Writers Room — per-stage provider/model picker in Storyboard settings, and surfaced Adapt failures.** The storyboard's gear-icon settings drawer now exposes an **Adapt LLM** picker with the same Tier-vs-Specific toggle the global Prompt Manager uses (`Tier: default/quick/coding/heavy` or `Specific: <provider> + <model>`). Picking a value PUTs `/api/prompts/writers-room-script` so the same selector works from `/prompts` or any panel that runs the stage. The evaluator's `callAI` now reads `stage.provider` + `stage.model` first (falling back to active provider only when no override is set) and resolves tier names through the provider's `defaultModel`/`lightModel`/`mediumModel`/`heavyModel`, fixing a case where every Writers Room AI pass silently routed through `getActiveProvider()` regardless of stage config. The Storyboard panel also now surfaces failed runs: instead of silently falling back to "No storyboard yet" when the latest Adapt snapshot is `failed`, it shows an error banner with the actual error message, an `Adjust LLM` shortcut into the settings drawer, and a `Re-run Adapt` button. (The 5-minute Gemini-CLI timeout that prompted this — `CLI AI call timed out after 300000ms` on a 4,800-word draft — was the original symptom: the call had been failing on disk for hours but the UI just looked empty.) New file: `client/src/components/writers-room/StagePromptModelPicker.jsx`. Touched: `server/services/writersRoom/evaluator.js`, `client/src/components/writers-room/StoryboardPanel.jsx`.

- **Writers Room — auto-queue every missing scene image when Adapt completes.** After **Run Adapt** finishes, the storyboard automatically dispatches a render for every scene that doesn't already have an image. Toasts `Queued N scene renders`. Scenes that already have an image (e.g. unchanged scene IDs from a prior pass) are skipped — re-Adapting only queues NEW scenes, not re-renders. The local FLUX image-gen queue serializes the requests server-side so the GPU isn't slammed; cards sit in queued state until the worker picks them up, and each card's existing socket subscription handles its own progress + completion + scene-image attachment. Implementation: `SceneCard` is now `forwardRef` + `useImperativeHandle` exposing `{ generate, canGenerate }`; `StoryboardPanel` collects refs and dispatches via `requestAnimationFrame` on the post-Adapt `latestScript` update. Touched: `client/src/components/writers-room/{SceneCard,StoryboardPanel}.jsx`.

- **Writers Room — Steps + Seed inputs in the storyboard image-gen settings.** The storyboard's settings popover now exposes per-Writers-Room Steps and Seed alongside Image Model + Resolution (organized as two 2-column grids — `[model | resolution]` and `[steps | seed]`). Seed has a Dice5 randomize button matching the Image Gen page. Empty steps = use model default; empty seed = random per render. Both persist in `settings.json` and flow through `generateImage`. Touched: `client/src/components/writers-room/{StoryboardPanel,SceneCard,sceneCardHelpers}.jsx`.

- **Writers Room — per-work world style for image gen.** Each Work now carries an `imageStyle = { presetId, prompt, negativePrompt }` field that gets prepended to every scene's image-gen prompt, keeping the storyboard visually consistent across scenes. Ships with 9 curated presets (cinematic, film noir, cyberpunk neon, Studio Ghibli, Pixar 3D, graphic novel, watercolor storybook, comic book, photorealistic) defined in `server/lib/writersRoomStylePresets.js` — code-shipped reference data, not gitignored, so every install gets the same starting set. Picking a preset fills editable prompt + negative-prompt textareas in the storyboard's existing image-gen settings popover; editing the text flips the discriminator to `'custom'` so the dropdown reflects that the prompt no longer matches the curated text. Style is the FIRST token in the composed prompt because diffusion models weight early tokens heaviest (`{worldStyle}. {workTitle}. Featuring — {chars}. {scene.visualPrompt}`). The `negativePrompt` flows through the existing `generateImage` route and is honored by all three runners (local FLUX, codex, external SDAPI). A `🎨 <preset>` chip appears next to the storyboard's scene-count when a style is active. New endpoint `GET /api/writers-room/style-presets`. Touched: `server/lib/{writersRoomStylePresets.js,validation.js}`, `server/services/writersRoom/local.js`, `server/routes/writersRoom.js`, `client/src/components/writers-room/{StoryboardPanel,SceneCard,sceneCardHelpers,WorkEditor}.jsx`, `client/src/services/apiWritersRoom.js`.

- **Writers Room — vertical Storyboard companion + UX cleanup.** The AI/Outline/Versions tabbed sidebar is replaced with an always-on `StoryboardPanel` that shows the latest Adapt analysis as a vertical column of scene cards (heading, slugline, image, summary, character chips, visual prompt). Each card has a per-scene image regenerate button and a "Debug" overflow menu that scopes follow-up tools (Why this image / Check characters / Editorial pass / Jump to prose) to that one scene. Stale banner appears when the active draft's `contentHash` differs from the analysis's `sourceContentHash`, with a one-click re-run. Click a card heading to jump the prose textarea to that scene — falls back to scene-summary substring or scene-index proportional scroll when the LLM heading doesn't match the user's prose verbatim. The header collapses to title + status + Save + Snapshot + a single `(•••)` overflow menu (AI section: Run Adapt / Refresh characters / Editorial pass / Format pass · Open section: Outline / Versions / Characters [badge count] / Analysis history · View section: reading-theme toggle / Write-for-10 toggle). Outline, Versions, Characters, and Analysis history each open as a slide-over `Drawer` instead of competing with the storyboard for sidebar real estate. Mobile (<lg): a Writing/Storyboard segmented toggle replaces the previous stacked layout, fixing the double-scroll problem. The page-level Write-for-10 button moves into the work overflow menu so the Writers Room shell header is now title-only. New files: `StoryboardPanel.jsx`, `SceneCard.jsx`, `CharactersBible.jsx`, `AnalysisHistory.jsx`, `sceneCardHelpers.js`. Removed: `AiPanel.jsx` (decomposed into the above). Touched: `client/src/components/writers-room/WorkEditor.jsx`, `client/src/pages/WritersRoom.jsx`.

- **Writers Room — character bible with image-gen prompt injection.** New `Characters` AI action extracts a per-work character roster (name, aliases, role, image-gen-ready physical description, personality, background) and merges it into an editable bible at `data/writers-room/works/<id>/characters.json`. Re-running the pass preserves any field the writer has already filled in (matched by canonical name OR alias, case-insensitive) and only fills blanks; prose-derived metadata (`firstAppearance`, `evidence`, `missingFromProse`) refreshes every run so the writer can see which fields the prose still doesn't support. Inline editor in the AI panel for create/edit/delete. The script-stage `SceneCard` now matches each scene's `characters` list against the bible and prepends `Featuring — <Name>: <physical description>` to the image-gen prompt before render, so the same character looks consistent across scenes. Matched character chips on the scene card show a port-accent border so the writer can see at a glance which characters in a scene have linked profiles. New routes: `GET/POST /api/writers-room/works/:id/characters`, `PATCH/DELETE /api/writers-room/works/:id/characters/:characterId`. New prompt stage `writers-room-characters` (sample + live). Touched: `server/services/writersRoom/{characters.js,evaluator.js}`, `server/routes/writersRoom.js`, `server/lib/{validation,writersRoomPresets}.js`, `client/src/components/writers-room/AiPanel.jsx`, `client/src/services/apiWritersRoom.js`, `data{,sample}/prompts/{stages/writers-room-characters.md,stage-config.json}`, `server/services/writersRoom/characters.test.js`.

- **Collapsed-sidebar section flyout.** Hovering or focusing any multi-child section icon (Brain, Calendar, Chief of Staff, Comms, Create, Dev Tools, Digital Twin, MeatSpace, POST, Settings, System, Wiki) while the sidebar is collapsed opens a fixed-position popover to its right listing the section's children — so siblings (e.g. **Writers Room** under **Create**, which defaults to Media Gen) stay reachable without expanding the whole sidebar. Clicking a child navigates and closes the flyout; a 180 ms grace period lets the user move from icon → popover without it snapping shut. Renders via `position: fixed` so it escapes the nav scroller's `overflow-x-hidden` clipping. ARIA: `role="menu"` + `aria-haspopup="menu"` + `aria-expanded` on the trigger; keyboard focus on the icon also opens the flyout.

## Changed

- **Apps page header tightened — "Add App" → "Add", icon detection moved per-app.** The Apps page header had a `+ Add App` button next to a `Detect Icons` button that scanned every registered app's repo on click. Both are noisier than they need to be: the heading already says "Apps" so the button reads `+ Add`, and bulk icon scanning is something you almost never want — you typically want it for the one app you just added. The `Detect Icons` header button is removed and replaced with a per-app `Detect Icon` action in each app's Overview tab Quick Actions row, backed by a new `POST /api/apps/:id/detect-icon` endpoint that scans only that app's repoPath via the existing `detectAppIcon()` service and toasts success/failure. The bulk `POST /api/apps/detect-icons` endpoint is kept for completeness (no UI consumer remains in PortOS itself, but it's a small server route that may still be useful for scripts). Touched: `server/routes/apps.js`, `client/src/{services/apiApps.js,pages/Apps.jsx,components/apps/tabs/OverviewTab.jsx}`.

- **Writers Room storyboard Config — inline "Backends" button opens the Image Gen settings drawer.** The Config tab's image gen section already had a Local/Codex backend chip strip, but the chip only appeared when more than one backend was configured — and to enable Codex `$imagegen` you had to leave the Writers Room and navigate to Settings → Image Gen. The Config tab now mirrors the pattern from `ImageGen.jsx`: a small **Backends** gear-button next to the "Image generation" header opens a slide-over `Drawer` hosting the same `<ImageGenTab />` settings UI (deep-linkable via `?settings=imagegen`). When the drawer closes, `getSettings()` is re-fetched so newly-enabled backends surface as chips immediately without a page reload. A warning row also renders when zero backends are configured, pointing the user at the Backends button. Touched: `client/src/components/writers-room/StoryboardPanel.jsx`.

- **Gemini-CLI provider model list updated to current Google releases.** Replaced the deprecated `gemini-2.5-flash-lite` with the current set: `gemini-3.1-pro` (heavy), `gemini-3-flash` (medium), `gemini-2.5-pro`, `gemini-2.5-flash` (light + default). Tier mappings remap: `defaultModel`/`lightModel` → `gemini-2.5-flash`, `mediumModel` → `gemini-3-flash`, `heavyModel` → `gemini-3.1-pro`. The hardcoded gemini-cli fallback in `brain.js` and `writersRoom/evaluator.js` (used when both `defaultModel` and `lightModel` are unset on the provider) now points to `gemini-2.5-flash` instead of `gemini-2.5-flash-lite`. Mirror change applied to the upstream `portos-ai-toolkit` `providers.sample.json` so fresh installs pick up the new defaults. Touched: `data{,sample}/providers.json`, `server/services/{brain.js,brain.test.js,writersRoom/evaluator.js}`, `../portos-ai-toolkit/src/defaults/providers.sample.json`.

- **Writers Room — Adapt prompt rewritten for paragraph-grain output.** The Adapt stage was producing 8 scenes for a 4,800-word piece because the prompt told the LLM "A scene = one continuous unit of action in one location" + "Don't pad. If the natural arc is 3 scenes, return 3." That's a screenplay rule; wrong for a storyboard companion where dense beat-level cards are the whole point. Reframed "scene" as "one storyboard panel," set an explicit count anchor ("a 4,000-word piece with ~80 paragraphs should yield ~50–80 scenes, not 8"), listed when to SPLIT (default — every substantive paragraph; new visual framing; back-and-forth dialogue) vs MERGE (rare — one-sentence paragraphs depicting the same instant; pure dialogue tags), removed the "don't pad" anti-pattern, and tightened action/visualPrompt budgets since each work now has many more cards. Sluglines may repeat across consecutive same-location scenes. Touched: `data{,sample}/prompts/stages/writers-room-script.md`.

- **Writers Room — analysis storage collapsed from history-of-runs to latest-per-kind.** The Adapt / Evaluate / Format / Characters analyses were each accumulating a fresh `wr-analysis-<uuid>.json` file per run, growing forever. The user only ever cares about the latest of each kind. Analysis id is now just the kind (`script`, `format`, `evaluate`, `characters`); files are `<kind>.json`; re-running a kind atomicWrite-overwrites its previous snapshot. Boot-time `recoverStuckAnalyses` gained a one-time migration that scans for legacy UUID-named files, groups by kind, keeps the latest per kind (by `completedAt|createdAt`), rewrites as `<kind>.json`, and deletes the legacy files. Idempotent. Touched: `server/services/writersRoom/evaluator.js`.

- **Writers Room — graphic novel + comic book presets suppress text/word-balloons.** Fast image models (FLUX.2 Klein, Schnell) render text as garbled gibberish. The `graphic-novel` and `comic-book` presets were inviting it via "sequential art panels" / generic comic-cover language, producing scenes with baked-in title-card text and word balloons. Both presets now drop the multi-panel language, add `single full-bleed image, no text` to the prompt, and aggressively negative-prompt `text, words, letters, lettering, typography, title, logo, word balloon, speech bubble, caption box, sound effect`, plus `onomatopoeia + comic book cover` for comic book. Existing works that already saved an old preset prompt need to re-pick the preset from the dropdown to refresh their stored text; custom-edited prompts are left alone. Touched: `server/lib/writersRoomStylePresets.js`.

## Fixed

- **App deploy modal — dismissable while a deploy is in progress.** The Deploy panel modal (App detail → Deploy) hid its X close button whenever `isDeploying` was true and had no Esc / backdrop-click handlers, so an iOS deploy (which can take many minutes during the TestFlight upload phase) trapped the user behind a full-screen modal with no escape. The X button now renders unconditionally; while a deploy is running it acts as a *minimize* (sets a local `dismissed` flag, leaves the server-side `bash deploy.sh` child process untouched, keeps the streaming Socket.IO listeners attached so output/result keep accumulating in state) and after the deploy finishes it falls back to the original `clearDeploy()` reset. Esc and clicking the dimmed backdrop both call the same `handleClose` so all three dismiss vectors behave identically. The Deploy split-button label now flips to **"View deploy…"** while a hidden run is in progress, and clicking it short-circuits — `setDismissed(false)` reopens the modal instead of calling `startDeploy` again (which the server's per-app `deployingApps` lock would have rejected anyway, surfacing a confusing "Deploy already in progress" error). Disabled state on the primary Deploy button removed since the click is now meaningful even mid-run; the chevron-down options trigger stays disabled because changing flags mid-run is nonsensical. Touched: `client/src/components/apps/DeployPanel.jsx`.

- **Mobile sidebar footer — version + icons no longer overflow the nav.** The expanded sidebar drawer footer rendered the version label and four 40×40 touch-target icons (Ambient, theme toggle, voice toggle, notifications) on a single `flex justify-between` row. On mobile the sidebar is `w-56` (224px) with `p-4` padding, leaving 192px of content width — but `~50px` for `v1.48.0` plus `4 × 40px + gaps ≈ 172px` for the icons totaled ~225px, so the rightmost icon (notification bell with its yellow badge) bled outside the nav background. Fixed by stacking the row vertically below the `sm:` breakpoint (`flex-col items-center gap-2 sm:flex-row sm:gap-0 sm:justify-between`), which puts the version above the centered icon row on mobile while preserving the original side-by-side layout on `sm+` and the existing `lg:flex-col` stack when the sidebar is collapsed. Touched: `client/src/components/Layout.jsx`.

- **CoS agent progress bar — active runs cap at 99%, not 100%.** The P80-based progress meter on `AgentCard` clamped at 100% via `Math.min(100, …)`, so any agent that reached its expected run time visually flipped to "100% complete" while still actively running — confusing the user into thinking the task had finished. Capped the calculation at 99% instead so only completion can land on 100%, and switched the overtime color trigger from `progress >= 100` (now unreachable) to the existing `remainingTime?.isOvertime` flag so the bar still flips to yellow once the duration exceeds the P80 estimate. The "+Xs over estimate" badge already handled the textual overtime indicator. Touched: `client/src/components/cos/tabs/AgentCard.jsx`.

- **Digital Twin — page now constrains to viewport instead of growing the whole document.** Every Digital Twin tab whose content exceeded one screen (Identity, Overview, Goals, Documents, Enrich, etc.) used to push the layout `<main>` past viewport height, which forced the vertical scrollbar onto the body and shaved ~8px off `<main>`'s width — visible as an "odd right margin" on mobile and as a body-level scrollbar on desktop. Root cause: `<main>` was `flex-1 overflow-hidden` but its `flex-1` couldn't actually constrain because the parent column had no `min-h-0` and Digital Twin's wrapper used `h-full` (which resolves to `auto` when its parent height is indefinite, so the wrapper grew with content and dragged `<main>` up the chain). Two-line fix: (1) `Layout.jsx` adds `min-h-0` to the main element and `relative` for full-width routes, (2) `DigitalTwin.jsx` switches its outer wrapper from `flex flex-col h-full` to `absolute inset-0 flex flex-col` so it always fills `<main>` exactly. The inner `flex-1 overflow-auto p-4` content container now scrolls internally as designed; body width matches viewport on every tab. Touched: `client/src/components/Layout.jsx`, `client/src/pages/DigitalTwin.jsx`.

- **Mobile header — duplicate notification bell removed.** The mobile header showed a bell + unread count that, when tapped, popped a dropdown overlay over page content. The same `NotificationDropdown` is already mounted in the sidebar drawer footer, so the header instance was redundant; removing it eliminates the unwanted overlay and reclaims header space when the nav is closed. Notifications remain accessible by opening the nav drawer. Touched: `client/src/components/Layout.jsx`.

- **Dashboard header trimmed — server status moved to a glowing dot, app count moved above the Apps grid.** The Dashboard header was a two-line block ("Dashboard" + "N apps registered (M archived)") plus a separate "Server: Online" pill in the action bar — three pieces of secondary status taking up vertical space above the actual widgets. Replaced the two-line title with a single-line `Dashboard ●` where the trailing dot is a 10px Tailwind-animated `port-success`/`port-error` indicator (with `animate-ping` halo + `shadow-[0_0_8px_*]` glow when online, solid red glow when the health check fails) and a `title=`/`aria-label=` tooltip exposing "Server online" / "Server offline" for hover + screen readers. The app-count subtitle moved into `AppsGridWidget` itself as a small `text-xs text-gray-500` line directly above the tile grid, so the count travels with the widget regardless of where the user drags it on the dashboard layout. Touched: `client/src/pages/Dashboard.jsx`, `client/src/components/dashboard/builtins/AppsGridWidget.jsx`.

- **App detail header — Launch + Dev UI grouped, and "Building..." active-state two-tone fixed.** The Launch (HTTPS/HTTP) and Dev UI buttons in the App detail header were rendered as three independent pills, visually unrelated despite being a single "open in browser" cluster. Wrapped them in the same `inline-flex rounded-lg overflow-hidden border` segmented-button container the Stop/Restart group uses (with `divide-x divide-port-border` between segments). The "Building..." button on mobile showed an odd two-tone yellow — the right portion appeared darker than the left — because `hover:bg-port-warning/30` continued to apply after iOS Safari's "sticky hover" combined with the `disabled:opacity-50` state, so the bg flipped between `/20 * 0.5` and `/30 * 0.5` mid-transition. Replaced every `hover:bg-X` on a `disabled`-able button with Tailwind's `enabled:hover:bg-X` modifier so hover only fires while the button is interactive (Stop/Restart/Start/Build in both `AppDetailView` and `Apps` list, plus the launch buttons for consistency). Stop and Restart buttons now also show "Stopping..." / "Restarting..." active text (parity with the existing "Starting..." / "Building..." patterns) so the user has a verbal signal to match the icon-spin. Touched: `client/src/components/apps/AppDetailView.jsx`, `client/src/pages/Apps.jsx`.

- **Dashboard System Health — "0/16 apps" was always zero, and counted Xcode/iOS projects that can't run.** The widget called `getAllApps()` and filtered by `overallStatus === 'online'`, but `overallStatus` is only computed inside the `/api/apps` route's PM2 enrichment — `getAllApps()` returns the raw records, so the filter never matched and the count was always `0`. The denominator also included native projects (`xcode`, `ios-native`, `macos-native`, `swift`) that have no detectable runtime state at all. Replaced with a new `getAppStatusSummary()` helper in `services/apps.js` that groups apps by `pm2Home`, queries each unique home once, and computes `{ total, online, stopped, notStarted, unmanaged }` — `total` covers PM2-runnable apps only, while `unmanaged` separately tracks native projects. The widget tile now reads "Services" instead of "Apps", shows real running counts (e.g. `1/9` plus `+7 native`), uses "All running" instead of the confusing "Ready" fallback, and falls back to an em-dash when no PM2 apps exist (with a "N native only" subtext). A tooltip explains what's counted. Touched: `server/services/apps.js`, `server/routes/systemHealth.js`, `client/src/components/SystemHealthWidget.jsx`.

- **Writers Room mobile editor squashed to ~150px (regression).** `WritersRoom`'s `<main>` element had `min-h-0 flex flex-col` with no `flex-1`, so on mobile (below `md:`, where the parent flips to `display: grid`) the main area sized to its natural content height — collapsing the textarea to whatever space was left after the library, header, and tab strip. Added `flex-1` so main grows to fill the remaining viewport on mobile; desktop is unaffected because `md:grid` takes over and the `flex-1` is dormant. Touched: `client/src/pages/WritersRoom.jsx`.

- **Collapsed sidebar footer overflow — theme toggle + bell wouldn't fit in the 64px rail.** The footer's two visible icons (ThemeModeToggle ~30px and NotificationDropdown bell ~36px plus a 2px focus-ring offset) summed to ~70–80px side-by-side, blowing out a `lg:w-16` collapsed sidebar that has only 32px of content width after `p-4`. Reduced collapsed-state footer padding to `p-2` (lg only) and stacked the buttons vertically (`lg:flex-col`) so each icon centers in the rail. Expanded layout is unchanged. Touched: `client/src/components/Layout.jsx`.

- **Writers Room mobile prose area squeezed by AI panel.** On mobile/tablet (below `lg`), the editor and AI sidebar stack vertically; the AI sidebar `<aside>` had no flex-grow, so it expanded to its (long) content height — leaving the prose textarea with whatever was left, often only ~150px. Gave the textarea wrapper `flex-[2]` and the AI aside `flex-1` on mobile (both reset at `lg:` where the desktop fixed-width split takes over) so the prose now gets ~2/3 of the available editor height and the AI panel scrolls internally within its 1/3. Touched: `client/src/components/writers-room/WorkEditor.jsx`.

- **Writers Room loaded empty in dev (Vite on `:5554`) while production worked.** All five `mountedRef` guards in the Writers Room and Ask pages used `useRef(true)` plus a cleanup-only `useEffect(() => () => { mountedRef.current = false; }, [])` to skip post-`await` `setState` after unmount. In React 18 `<StrictMode>` (dev only), the mount → cleanup → mount cycle flipped the ref to `false` permanently and never reset it, so every async fetch resolved with the guard tripped and folders/works/exercises/analyses/draft saves never reached state. Fixed by setting `mountedRef.current = true` at the top of the same effect — production builds (port `:5553`/`:5555`) were unaffected because StrictMode does not double-mount in production. Touched: `client/src/pages/WritersRoom.jsx`, `client/src/components/writers-room/{ExercisePanel,AiPanel,WorkEditor}.jsx`, `client/src/pages/Ask.jsx`.

- **Release hardening — Zod-validated history ids on the new `POST /api/video-gen/upscale/:id` and `POST /api/video-gen/stitch` endpoints, server-side guard against chained upscales, keyboard focus retained on collapsed-sidebar flyout.** Three release-review fixes: (1) the upscale and stitch routes now `safeParse` their inputs against a `^[a-f0-9-]{36}$` UUID schema with the project's standard `VALIDATION_ERROR` 400 response; the stitch endpoint additionally requires 2-20 valid ids per request. (2) `upscaleHistoryItem()` now refuses to operate on entries that already carry an `upscaledFrom` field, closing a hole where a direct API call could chain `2× → 4× → 8×` upscales (the UI button already gates this client-side via `MediaCard.jsx:101`). (3) The collapsed-sidebar section flyout (Brain, Calendar, etc.) gained `onFocus={cancelCloseFlyout}` + `onBlur={scheduleCloseFlyout}` on the popover container so keyboard users tabbing through child links don't see the flyout collapse mid-navigation — the `role="menu"` markup was advertising keyboard support that focus-loss-through-bubbling was breaking. Touched: `server/routes/videoGen.{js,test.js}`, `server/services/videoGen/local.js`, `client/src/components/Layout.jsx`.

## Removed

- **Dead code cleanup — three orphan service modules deleted (~860 LOC).** `server/services/cosEvolution.js` (402 LOC, base-model self-modification proposals), `server/services/selfImprovement.js` (233 LOC, alternate self-improvement task generator superseded by the inline `evaluateAppImprovementTasks` flow in `cos.js`), and `server/services/portosChangelog.js` (90 LOC, recent-feat/fix commit extractor for daily briefing) had zero call sites in the codebase — `cosEvolution` and `selfImprovement` were never imported by any production module, and `portosChangelog` was only imported by its own test file. Also dropped the unused `PATHS.cosEvolution` entry in `server/lib/fileUtils.js` and the corresponding row in `docs/features/cos-enhancement.md`. Test count went 3089 → 3079 (the 10 tests in the deleted `portosChangelog.test.js`); no other tests affected.
