# Release v1.52.0

Released: 2026-05-10

**Full Diff**: https://github.com/atomantic/PortOS/compare/v1.51.0...v1.52.0


## Added

- **Lightbox prev/next navigation across Image Gen, Video Gen, and Media History.** The MediaLightbox now exposes left/right chevron buttons (visible on hover, top of the modal at `top-1/2 -translate-y-1/2`) and binds `ArrowLeft` / `ArrowRight` keyboard shortcuts so you can flip through the active gallery without closing/reopening. Each page passes its own filtered+sorted list as the navigation source — Image Gen uses visible+hidden gallery (respecting the "show hidden" toggle), Video Gen uses visible+hidden history (same toggle), and Media History uses the search/filter-scoped merged timeline so you only navigate within what's currently visible. New helpers: `getAdjacentMedia(items, item)` returns `{ previous, next, hasPrevious, hasNext }` keyed by `item.key`, and `getMediaNavProps(items, item, onSelect)` returns the four ready-to-spread Lightbox props so call sites stay one line.
- **Auto-refresh Image Gen, Video Gen, and Media History on completion.** The three media pages now subscribe to `image-gen:completed` / `video-gen:completed` socket events via a new `useMediaCompletionRefresh` hook and silently re-fetch their galleries (250 ms debounce to coalesce burst completions). MediaHistory's `refresh()` gains a `silent: true` option so the auto-refresh doesn't flash the loading state on top of the existing list. No need to reload the page after a render finishes — the new image/video appears in place.
- **World Builder: world-pitch-poster composite board kind.**  The `compositeSheets` list now distinguishes between two complementary board kinds: `reference_sheet` (existing — costume guides, material swatches, fastener icons) and `world_pitch_poster` (new — single-image cinematic concept-pitch posters with a hero panorama, inset environments, palette/materials strips, theme icons, and section headers like "The World", "The Feel", "Cultures"). The expansion prompt asks the LLM to emit 1-3 pitch posters in addition to any reference sheets when the starter idea spans a broad world/setting, and warns it to keep "text" out of the negativePrompt for those (so titles/section labels can render). Each sheet's `kind` is editable in-place via a select in the Composite Boards editor; when rendered, pitch posters are tagged with `category: 'world_pitch_posters'` instead of `composite_sheets` so they're separable downstream. Server `COMPOSITE_SHEET_KINDS` is the canonical enum (frozen, mirrored client-side); unknown kinds coerce to `reference_sheet` for forward-compat. UI strings flipped from "composite sheets" to "composite boards" to reflect the broader scope.

- **World Builder: custom categories + composite reference sheets.** The world template is no longer locked to the original five buckets (landscapes/environments/characters/structures/vehicles). The expansion prompt now asks the LLM for **5–12 categories of its choosing** in snake_case (e.g. `colonies`, `factions`, `species`, `clothing_styles`, `material_palettes`, `raider_clans`) and the editor exposes an **Add category** input plus a per-category remove button — so a "Gas-Giant Drifters costume systems" world can carry the buckets it actually needs instead of being shoehorned into "characters". A new top-level **`compositeSheets`** array stores complete reference-board prompts (up to 4000 chars each, up to 50 per world) that combine multiple buckets into one image — costume sheets, material swatches, color palette strips, fastener/accessory icon boards, etc. — and the LLM is prompted to emit 3–8 of them when the starter idea involves clothing/colonies/factions/cultures/species/vehicles. The Batch render panel gains a **Prompt set** picker (Composite sheets / Atomic variations / Everything), and successful expansion that produced any sheets auto-selects the sheets mode so the next click renders the boards. Server-side: `WORLD_CATEGORIES` is now a starter list, not a contract; `categories` zod schema is `z.record` with a 30-bucket cap and a 64-char key cap; `normalizeCategoryKey` snake-cases incoming keys (`Clothing Styles` → `clothing_styles`) on both client and server so saved worlds stay portable; `compilePrompts` accepts `promptMode` + `sheetSelection` and tags rendered sheets with `category: 'composite_sheets'`. The `extractJson` shape-detector accepts responses that have only `compositeSheets` (no variations), and the expansion logger now reports per-bucket counts plus sheet count for fast debugging. Tests cover key normalization, custom-bucket round-trip, sheet sanitization, the new prompt-mode branches in `compilePrompts`, and the route schemas for arbitrary category names.
- **Search box on Media History.** `/media/history` gains a search input (top-left of the toolbar) that filters the merged image+video timeline as you type. Whitespace tokens are AND-combined (e.g. `sunset flux2 1024` matches items whose searchable haystack contains all three substrings, in any order), and the haystack covers prompt, negative prompt, model id, filename, kind, seed, dimensions, LoRA names, plus virtual tokens for `extracted frame` / `stitched` / `upscaled 2x` so the existing corner badges are reachable through the search bar. Item counts in the Images / Videos / All filter chips update against the search-scoped totals, and the empty state offers a one-click "Clear search" when nothing matches. Haystacks are precomputed once per items list (memo keyed on `items`) so each keystroke only re-runs `String.includes()`, not array-build + join + lowercase per item — keeps the UI responsive on 500+ item galleries.
- **Extracted video frames carry their video's prompt.** When you click "Continue" on a video card, PortOS extracts the last frame as a PNG into the gallery — that PNG now also gets a sidecar metadata.json copying the source video's `prompt`, `negativePrompt`, `modelId`, `width`/`height`, and `seed`, plus provenance fields (`extractedFromVideoId`, `extractedFromVideoFilename`, `kind: 'extracted-frame'`). The Media History card surfaces a yellow `frame` corner badge so extracted frames are visually distinct from raw renders, and the new fields flow through the search haystack so a query like `extracted frame` (or any prompt term from the parent video) filters the gallery to those frames. Sidecar write uses `writeFile({ flag: 'wx' })` so cache-hit re-clicks are race-free and cheap; pre-existing extracted frames get backfilled on the next continue-click without re-running ffmpeg.

- **ERNIE-Image runner.** Adds `runner: 'ernie'` so PortOS can drive Baidu's ERNIE-Image (8B DiT, Apache 2.0, no HF login) — both the SFT model (`baidu/ERNIE-Image`, 50 steps, guidance 4.0) and the Turbo distilled variant (`baidu/ERNIE-Image-Turbo`, 8 steps, CFG-disabled). Reuses the FLUX.2 venv (same diffusers + torch stack, no extra setup step). The runner script gains a generic `--pipeline-class` arg (used here for `ErnieImagePipeline` since it isn't yet in `AutoPipelineForText2Image`'s registry) and a `--use-pe` flag for ERNIE's built-in Prompt Enhancer module — both passed only when the loaded pipe accepts them, so existing Z-Image renders are unaffected. LoRA picker, suggestion search, and Civitai's `baseModel` mapping all gain the `'ernie'` family.
- **Multi-version curated cards** — a curated suggestion (e.g. `hyperdetailed-colored-pencil`) typically has separate Civitai versions per base model (Flux.1, Flux 2 Klein 4B, Klein 9B, Z-Image-Turbo, ERNIE…). The card now renders one **Install for X** button per available family, each installing the family-specific version via that family's `?modelVersionId=...`. Buttons flip to ✓ Installed independently, so you can install the Flux 2 version today and the Z-Image one later. Server-side `buildCuratedCard()` walks every `modelVersions[]` entry and builds an `installs: { mflux, flux2, 'z-image', ernie }` map (first match per family wins; versions without a `.safetensors` file are skipped).
- **One-click LoRA trigger-word insertion.** Each selected LoRA in the Image Gen picker now exposes a small `+ trigger` button next to its scale input — click it to append the LoRA's trigger words to the prompt, comma-separated, deduped (case-insensitive substring match against the existing prompt so re-clicking is a no-op). The "Test" deep-link from the LoRAs manager page (`/media/image?lora=<filename>`) also auto-inserts the trigger words on preselect — when a user clicks "Test this LoRA," they want to actually see the LoRA fire, which most LoRAs only do once the trigger is in the prompt. Helper `appendTriggerWords()` lives in `ImageGen.jsx` for now; would extract to `genUtils.js` if reused elsewhere.
- **Suggested LoRAs panel on the manager page.** `/media/loras` now leads with a "Suggested LoRAs" section that surfaces (1) a hand-curated list of picks that work well across the family — initially `hyperdetailed-colored-pencil`, `anime-art`, `dark-ghibli-fairytales`, `ars-midjourney-watercolor` — and (2) per-runner-family top-N from Civitai's "Most Downloaded" endpoint (Flux 1 / Flux 2 / Z-Image). Each card shows the preview thumbnail, runner-compat badges (derived from the model's actual `modelVersions[].baseModel`, so a multi-base-model upload shows all the badges it deserves), creator, downloads count, and an expandable "Sample prompt" section pulled from `images[0].meta.prompt`. **Quick install** routes through the same path as URL-paste, so the auth-prompt modal still fires for gated LoRAs and the same lockfile-collision protection applies. Cards already installed render as a green "Installed ✓" pill instead of the install button. Server: new `server/services/civitaiSuggestions.js` (in-memory 1-hour TTL cache per family, independent caches so a Z-Image search returning zero doesn't poison Flux), new `searchCivitaiLoras()` + `extractSamplePrompt()` helpers in `civitai.js`, new `GET /api/loras/suggestions` route (with `?force=1` to bust cache). 5 new tests cover the curated/runners shape, partial-failure tolerance, cache hit/miss, force-refresh, and runner-family derivation from multi-version models.
- **In-UI Civitai API key prompt.** When a Civitai install fails because the LoRA is gated (HTTP 401/403 from either the metadata or download endpoint), PortOS now opens an inline modal asking for an API key — paste it, click "Save key & retry install", and the same install fires again with the freshly-saved key in scope. The Manager page also surfaces a green "Key saved" / blue "Key (env)" / grey "Add API key" pill in the install form so users see at a glance whether a key is configured, and the modal includes a "Clear" button to remove a settings-level key (with a hint that the env-var key, if set, takes over after clearing). New endpoints: `GET /api/loras/auth/civitai` (returns `{ hasKey, source }` — the key itself never round-trips), `POST /api/loras/auth/civitai`, `DELETE /api/loras/auth/civitai`. Client API: `getCivitaiAuth`, `setCivitaiAuth`, `clearCivitaiAuth`. The install client now also takes `silent: true` so the auth-error path bypasses the auto-toast and reaches the modal cleanly.
- **Civitai LoRA manager.** A new `Media Gen → LoRAs` tab (`/media/loras`) lets you paste any Civitai model URL — `https://civitai.com/models/...`, `https://civitai.red/...`, a numeric model id, or `civitai:<id>` shortcut — and PortOS fetches the metadata, picks the primary `.safetensors`, streams the download into `data/loras/`, and writes a sidecar JSON next to it with trigger words, recommended scale, base model, creator, preview image URL, and the Civitai version id. Each card shows the preview thumbnail, a runner-family badge (Flux 1 / Flux 2 / Z-Image / Unsupported), trigger words, recommended scale, file size, and three actions: **Test** (deep-links into `/media/image?lora=<filename>` which preselects the LoRA and seeds its scale from the recommended value), **Open on Civitai**, and **Delete**. The Image Gen LoRA picker now reads the same registry, filters down to LoRAs whose `runnerFamily` matches the selected model's runner (or has no family — legacy/unknown), shows trigger words inline, and applies the recommended scale by default when you tick a LoRA. Restricted/adult LoRAs that need a logged-in download work via either the `CIVITAI_API_KEY` env var or `civitai.apiKey` in `data/settings.json`. New endpoints: `GET /api/loras`, `POST /api/loras/install`, `GET /api/loras/:filename`, `PATCH /api/loras/:filename`, `DELETE /api/loras/:filename`. Server: `server/lib/civitai.js` (URL parsing, version/file picking, sidecar shape) + `server/services/loras.js` (install/list/patch/delete with stream-to-disk + .partial finalizer). The runner side gets a best-effort `apply_loras()` helper added to `scripts/flux2_macos.py` and `scripts/z_image_turbo.py` (calls diffusers' `pipe.load_lora_weights()` + `pipe.set_adapters()` and logs+continues on shape mismatch rather than aborting the render); `mflux` continues to receive LoRAs through its existing `--lora-paths` / `--lora-scales` CLI surface. The `IMAGE_GEN_FLUX2_LORA_UNSUPPORTED` guard is removed — flux2 and z-image runners now plumb LoRAs through too. 45 new tests cover the Civitai URL-shape parser, version/file/preview picking, the auth header / download token plumbing, sidecar shape, and the install/list/delete service path (using a fake fetchImpl + temp loras dir).
- **Z-Image-Turbo support in image gen.** A new `runner: 'z-image'` lets PortOS render through Tongyi/Alibaba's Z-Image-Turbo (~6B distilled DiT, ~8 steps, Apache 2.0 — no HF token / license-acceptance required, unlike FLUX.2). Two registry entries seed by default in `data/media-models.json`: `z-image-turbo-bf16` (Tongyi-MAI/Z-Image-Turbo, ~13 GB on a 32 GB+ Mac or 16 GB+ CUDA card) and a hidden `z-image-turbo-quant` template (set the `repo` and clear `broken: true` to point at a community quantized port). The runner reuses the FLUX.2 venv at `~/.portos/venv-flux2` (already has `diffusers` from git HEAD + `torch` + MPS support), so no extra setup step. Wires through both txt2img and i2i (init image + strength) on day one, with stepwise live-preview frames and the same SSE / cancel / metadata-sidecar plumbing the FLUX.2 runner already uses. New script: `scripts/z_image_turbo.py`. Registry now also tracks `_shippedDefaults.image` so future built-in models land on existing installs without overwriting user customisations or undoing user deletions. 9 new tests cover the dispatch branch (`server/services/imageGen/local.test.js`) and the image upgrade path (`server/lib/mediaModels.test.js`).
- **World Builder for Media Gen.** A new `Media Gen → World Builder` tab (`/media/world-builder`) takes a one-line starter prompt — e.g. "moebius and scavengers reign meets Prophet inspired sci fi universe" — and asks the LLM of your choice to expand it into a structured prompt set: a positive `stylePrompt` prefix, a `negativePrompt`, and 6–10 variations across five canonical buckets (landscapes, environments, characters, structures, vehicles). You can edit any field, save the world, then kick off a batch render that compiles `stylePrompt + variation.prompt` for every selected bucket and queues them through the existing image-gen pipeline (local mflux or Codex; external SD-API not supported for batches). Each batch lands in an auto-named Media Collection ("World: <name> — <timestamp>") so the renders stay grouped with the template that produced them. Worlds support full CRUD, the LLM provider/model is per-template (falls back to active provider), and runs are logged with deep-links into the resulting collection. Backed by `server/services/worldBuilder.js` (storage + prompt compilation), `server/services/worldBuilderExpand.js` (LLM call), `server/routes/worldBuilder.js` (REST surface), and `server/services/worldBuilderCollectionHook.js` (hooks media-job completion → collection assignment). State persists in `data/world-builder.json`. 20 new tests cover sanitizers, compile-prompts logic, and the route surface.
- **Workflow tab in Chief of Staff with canonical project-maintenance ordering.** A new `/cos/workflow` page (and a `Workflow` tab in the CoS panel) renders the full set of scheduled tasks and autonomous jobs as a left-to-right pipeline grouped into seven canonical stages — Hygiene → Review → Plan → Audit → Build → Report → Ambient — so it's obvious which work happens before which. Each card surfaces its own schedule, last-run time, run count, enabled state, and live "due / waiting / disabled / waiting-on-dependencies" badge, plus inline `runAfter` arrows showing hard prerequisites. Backed by a new `GET /api/cos/workflow` endpoint and `server/services/workflow.js` with stage definitions, gate state for jobs, and dependency edges. Two default `runAfter` wirings ship with this: `do-replan` runs after `pr-reviewer` and `branch-cleanup` (so the plan reflects merged reality), and `feature-ideas` runs after `do-replan` (so new feature work lands on a fresh plan). Both can be overridden per task or per app from the existing Schedule tab.
- **Clean button on Image Gen lightbox.** Generated images now expose a Clean control with `Light` and `Aggressive` options that runs the dev-tool Image Cleaner (median denoise + C2PA strip + sharp re-encode) over the file in place and writes the result back to the gallery as `<base>_clean-<level>.png` with the source's prompt/seed/model carried forward in a sidecar. New endpoint `POST /api/image-gen/:filename/clean` (refactors the cleaning core out of `imageClean.js` so both the base64 and file paths share `cleanImageBuffer()`) and a `cleanGalleryImage(filename, level)` client API. Idempotent at a level (re-running Light overwrites the previous Light copy) and the new image is spliced to the top of the gallery without a refetch. 10 new tests cover happy path, level differences, idempotency, traversal/PNG-only/missing-source rejections, and sidecar lineage.
- **Render queue embedded on Image Gen and Video Gen pages; standalone `/media/queue` page removed.** Both `/media/image` and `/media/video` now render a compact, kind-filtered live queue inline beneath the form — live job rows (with cancel) plus a collapsible "recent" toggle — backed by the existing `/api/media-jobs?kind=image|video` endpoint (no new server surface). The dedicated `/media/queue` page (`pages/RenderQueue.jsx`), its `Render Queue` tab in the Media Gen tab strip, the App.jsx route, and the `nav.media.queue` ⌘K / voice-nav entry are all gone — the new inline component (`client/src/components/media/MediaJobsQueue.jsx`) replaces it everywhere.
- **Dismissible AI Recommendations on the Learning tab.** Each recommendation in the Chief of Staff Learning Analytics panel now has an X to dismiss it. Dismissals persist to `data/cos/dismissed-recommendations.json` and are filtered out of future loads. For count-based alerts (e.g., "unknown errors occurred 74 times"), the dismissal records the count snapshot and only re-surfaces if the count grows materially worse (≥1.5× and at least +20). Rate-based recommendations stay dismissed until restored. A "Show dismissed" expander lets you restore individual entries or clear them all.

## Fixed

- **Image Gen backend switcher no longer feels frozen mid-check.** Clicking Local / Codex / External in the backend chip strip kicks off a status probe (e.g. SD API reachability for External, codex CLI version for Codex) — the buttons were already correctly `disabled` during the probe, but there was no visible feedback so the UI just felt unresponsive while the request was in flight. The clicked chip now swaps its icon for a spinning `Loader2`, the tooltip flips to "Checking <label>…", and the status badge to the left shows "🔄 Checking <mode>…" instead of stale state from the previous backend. `BackendChipStrip` gains an optional `loadingId` prop (default `null`) — existing consumers (WorldBuilder, Storyboard) keep their current behavior.
- **mflux corrupted-install detection.** When mflux's entry-point shim references `mflux.models.flux.cli.flux_generate` but the importable `mflux/` package on disk is stuck at an older layout (file tree out of sync with the dist-info — easy to hit when `~/.local/lib/python*/site-packages/mflux/` and a conda-env mflux both exist at version 0.12.1 but were installed at different repackaging stages), the runner crashed with bare "Exit code 1" and a Python `ModuleNotFoundError`. PortOS now detects that specific traceback in the runner's stderr and surfaces an actionable message explaining the cause and the exact `pip uninstall -y mflux && pip install --user --force-reinstall --no-cache-dir --no-deps mflux` repair command. `scripts/setup-image-video.sh` also now passes `--force-reinstall --no-deps` on the mflux install so subsequent setup runs flush the package's file tree without re-resolving torch/mlx and friends — heading off the divergence at the source.
- **Guidance input hidden for distilled image models; runner stops emitting "Guidance scale X is ignored" warnings.** Step-wise distilled image models (Flux 1 Schnell, FLUX.2 Klein 4B/9B/Int8, Z-Image-Turbo) bake classifier-free guidance internally — any guidance scale > 1.0 is silently dropped by diffusers, which logs a noisy `"Guidance scale 7.0 is ignored for step-wise distilled models."` line on every render. The registry now flags those entries with `cfgDisabled: true` (backfilled on existing installs by id, in `server/lib/mediaModels.js → backfillCfgDisabled`), the Image Gen / World Builder Guidance input is hidden when the selected model has the flag set, and `server/services/imageGen/local.js` pins the resolved guidance to `1.0` for cfgDisabled models so the runner's diffusers call stays quiet and the saved metadata sidecar reflects the value the model actually used. Any user override of `cfgDisabled` (true OR false) on a custom registry entry wins.
- **World Builder expansion no longer 0-variations on Codex CLI responses.** The expand-starter step had three failure modes against Codex's CLI provider that all manifested as "Expanded into 0 variations": (1) Codex echoes the user prompt back to stdout before the model's reply, and the prompt itself contains a JSON-shaped *schema example* (`{ "label": string (max 80 chars), "prompt": … }`) — its braces balance but its contents are not valid JSON, so the previous first-balanced-block extractor crashed on it; (2) Codex occasionally emits an orphan `}` between a variation's close-brace and the array's `]` (`…blister"}}]}}}`), making `JSON.parse` throw on otherwise-recoverable output; (3) some prompt variants embed small literal `{ "label": …, "prompt": … }` example variations that parse cleanly but are not the response, so the extractor would happily return the wrong object. `extractJson` now (a) walks every top-level brace-balanced block and tries each in order, (b) prefers a block that has any of `stylePrompt` / `negativePrompt` / `categories` at the top level over an example-shaped one, and (c) attempts cheap repairs for trailing commas and the orphan-brace `}}]` → `}]` corruption before giving up. The expand path also logs the toolkit `runId` plus per-category variation counts so a 0-variation outcome is debuggable from `data/runs/<id>/output.txt`. Four new tests cover the Codex prompt-echo, orphan-brace, trailing-comma, and shape-preference cases.
- **CoS review-loop tasks now actually loop until clean and merge the PR.** Previously, scheduled or user tasks running with worktree + openPR + reviewLoop only fired one Copilot review request and stopped — the worktree got cleaned up immediately, the PR sat with whatever feedback Copilot returned, and nothing addressed comments or merged. The fix adds a follow-up internal task that the original cleanup hook spawns once the PR is open and the initial Copilot review has been requested. The follow-up agent attaches a fresh worktree to the PR branch (via a new `existingBranch` option on `createWorktree`), runs the full `/do:rpr`-style poll/fix/push/re-request loop until Copilot returns zero unresolved comments (or hits a 10-iteration guardrail), then merges the PR with `gh pr merge --squash --auto --delete-branch`. The follow-up's own cleanup pass passes `skipMerge: true` so the worktree branch isn't re-merged locally on top of the squash-merge gh just executed. GitLab MRs and other non-GitHub forges still skip the loop entirely (Copilot reviewer is GitHub-only). See `server/services/agentLifecycle.js` (`spawnReviewLoopFollowUp`) and `server/services/agentPromptBuilder.js` (`reviewLoopFollowUpSection`).
- **Dashboard form input no longer wipes when rotating between portrait and landscape.** `DashboardGrid` was returning two structurally different render trees on either side of the 640px breakpoint — a flat `<div className="space-y-4">` on mobile and a relative-positioned absolute-grid wrapper on desktop. Crossing the breakpoint (which happens any time you rotate an iPhone, since portrait ~390px is below it and landscape ~844px is above) put the widget at a different DOM depth, so React unmounted every widget and remounted a new one — taking any in-progress `useState` value with it. The Quick Capture text field reset to empty mid-typing. The grid now uses a single render tree across both modes, toggling only `className`/`style` between flow and absolute layouts. Widget components keep their state across rotation.
- **Toast notifications and the eye-exam form no longer overflow on mobile.** The agent-feedback toast had a hard `w-[480px]` when expanded that overflowed past the right edge of a 375px viewport, and the global toast container's `max-w-[520px]` could similarly extend past the screen on narrow viewports. Both now cap at `calc(100vw - 2rem)` on mobile and switch to their full width at the `sm:` breakpoint (640px+). The eye-exam editor on MeatSpace → Body was also unusable on mobile — its `grid-cols-7` layout squeezed seven number inputs into ~42px each at 375px wide; it now reflows to two columns on mobile (with the date field full-width), four on tablet, seven on desktop.
- **Image preview lightbox readable in glass-style day themes.** The MediaLightbox panel (used by Image Gen and Video Gen) inherited `--port-card-alpha: 0.6` from glass themes like Lumen Glass Day, rendering as muddy translucent grey over the modal's `bg-black/90` overlay. Combined with the existing tinted action buttons (`bg-port-accent/20 text-port-accent`), the Remix and Send to Video buttons became nearly invisible. The panel now pins `--port-card-alpha` and `--port-border-alpha` to 1 within the lightbox scope so it renders solid in any theme, and the action buttons switched from translucent-tint to solid-fill (`bg-port-accent text-white`, `bg-port-success text-white`) for high contrast across all day and night themes.
- **Action buttons no longer look disabled in day themes.** Tinted-fill action buttons across the app (e.g. World Builder's "Expand starter → variations", Voice Widget's mode toggles, OpenClaw's status banners) use the dark-mode-friendly `bg-{color}-600/30 text-{color}-200` pattern. In day themes (Lumen Glass Day, Black ICE Day, Blueprint Day) the existing CSS layer remapped `text-{color}-300/400/500` to dark variants for contrast on cream/white surfaces but skipped the lighter `text-{color}-100/200` tints — so a "Light text on a light tinted background" combination ended up looking faded and disabled even when enabled. The day-mode text-color remap in `client/src/index.css` now also covers the 100/200 tones for every hue family (purple, violet, cyan, sky, blue, indigo, pink, rose, red, orange, amber, yellow, lime, green, emerald, teal, fuchsia), so the only remaining visual difference between an enabled and disabled action button is the explicit `disabled:opacity-50` (or similar) — making the standard "tinted bg + saturated dark text in day mode, faded out when disabled" consistent across all day-mode themes.

## Changed

- **World Builder batch-render form gains the full Image Gen knob set.** The `/media/world-builder` Batch render panel previously exposed only Width / Height / Steps / CFG / Per-variation, and a backend dropdown limited to "Local (mflux)" / "Codex". It now reuses the same controls as the standalone Image Gen page via a new shared `client/src/components/imageGen/ImageGenControls` component: Model picker (auto-populated from `listImageModels`), resolution presets (extracted into `client/src/lib/imageGenResolutions.js` so both pages stay in lockstep), Steps, Guidance, CFG Scale (external mode), Quantize (mflux), plus a `BackendChipStrip` that mirrors Image Gen's chip-style selector and hides external SD-API (which the batch path can't drive). The `/api/world-builder/:id/render` route accepts the new `quantize` field. Successful expansion now auto-saves the world if it already has a name, so clicking "Render" right after "Expand starter → variations" no longer trips "No prompts to render" on a stale on-disk copy.
- **Browser downloads default to `~/Downloads`.** The CDP browser previously sent downloads to `data/browser-downloads/` inside the project; it now writes to the OS-native user Downloads directory (`~/Downloads` on macOS/Linux, `C:\Users\<u>\Downloads` on Windows) via `os.homedir()`. Both the keep-alive `Browser.setDownloadBehavior` call in `browser/server.js` and `PATHS.browserDownloads` in `server/lib/fileUtils.js` were updated so the Browser page UI's listing/delete operations stay in sync with where Chrome actually writes. Existing installs without an explicit `downloadDir` in `data/browser-config.json` pick up the new location automatically; users who want the old behavior can pin it by setting `downloadDir` in that config file.
