Tutorial: 2026-06-28

2026-06-28

Newest entry on top.

SPA internationalization with react-intl (2.15.0, branch feature/i18n-react-intl)

Internationalized the entire React SPA with react-intl + the full FormatJS tooling pipeline, on a feature/i18n-react-intl branch off dev. Owner chose: ship other languages only if quality is genuinely good (else English only), the full FormatJS pipeline, and a feature branch + commit.

Shipped-locale decision. The app is dense with domain jargon (DPL, prompt "salt", wrappers, expansions, block tokens, NSFW gating) that machine translation handles poorly and I can't verify — so per the owner's quality bar, English is the only real locale shipped. The pipeline makes a real language a one-file drop-in, and a generated en-XA pseudo-locale (accented/expanded English) ships as a coverage aid that exposes any missed string.

Core (gui/src/i18n/): config.js (locale registry + resolveLocale over navigator.languages), loadMessages.js (eager import.meta.glob over compiled/*.json; source en uses inline defaultMessage), I18nProvider.jsx (IntlProvider wrapper, <html lang/dir> sync, quiet MISSING_TRANSLATION fallback). App.jsx split into a thin root (settings + i18n boundary) + AppShell. A Display language select added to Settings.jsx, persisted in settings.locale (default "auto").

Extraction: every user-facing string across all ~28 components wrapped with defineMessages + intl.formatMessage / <FormattedMessage> — text, title/placeholder/aria-label, confirm/prompt dialogs, ICU plurals/number formatting. ~407 messages. Explicit namespaced IDs (app.*, home.*, manage.*, …). English renders byte-identically, so the Playwright visual/text baselines are untouched. ICU gotchas handled: literal braces ({#fx}'{#fx}') and an apostrophe-laden tooltip (reworded).

Tooling: babel-plugin-formatjs wired into @vitejs/plugin-react (auto IDs matching the extractor); @formatjs/cli scripts i18n:extract / i18n:pseudo (needs --ast) / i18n / i18n:check; a focused gui/eslint.config.js (formatjs-only, no-op react-hooks stub so existing disable directives resolve) runs enforce-default-message via npm run lint:i18n. Added react-intl, @formatjs/cli, babel-plugin-formatjs, eslint-plugin-formatjs, eslint to gui/package.json.

Tests: new gui/tests/testUtils.jsx exports a render wrapped in IntlProvider; Settings.test + TokenPicker.test switched to it (Field tests untouched — presentational).

Deliberately deferred (English-only): gui/src/lib/dpl/validateDpl.js (editor lint diagnostics) and gui/src/lib/dpl/dplInserts.js (DPL syntax teaching catalog) — isomorphic non-React modules shared with the core engine + a test suite keyed on English messages; threading i18n through them is a separate, riskier refactor noted for follow-up.

Verification (all green): repeated npm --prefix gui run build; npm test at engine-v3 root — lint (0 errors, 18 pre-existing warnings), smoke OK, test:unit 128 passed, test:web 60 passed; and npm run lint:i18n clean. VERSION + package.json2.15.0 (MINOR). Not yet released — awaiting the owner's go-ahead to merge to main.

DPL focus dial + global layer auto-merge + $ keyword sigil (2.13.0)

Implemented three linked DPL features (owner request), engine-first (content fluff-revival deferred to a follow-up pass):

  • Focus dial — a sibling of intensity in dpl.js + block.js: token arg {#name fNN%}, [f<NN%] line conditions, $focus / $focus% / $focus-word keyword (word ladder loose→pure), relative ±, threaded to generators as the 5th arg (ctx.focus; both loader bridges pass it to JS sidecars). Per the owner's clarification, focus is author-judged (no auto-scaling) — an AI/human tags each line's fluff threshold ([f<10%] a nearby city in a cave, etc.). Low focus fills the frame; high focus keeps only essentials and stacks cleanly.

  • Mandatory i/f prefixes + $ sigil. Conditions/token-args must prefix the dial ([i<10%], {#name i25% f80%}) since the two percents look identical; an unprefixed 25%/<10% is not dial syntax (owner was explicit: no unprefixed default). Keyword tokens moved off {intensity} (collides with {list}) to the $ sigil — and since the dial is a percent, $intensity renders 50% (no $var% form, per owner) and $intensity-word the word. Migrated all in-tree content losslessly via byte/EOL-preserving PowerShell passes: {intensity…}$intensity-word (28 files) and bare [<NN%][i<NN%] (18 files).

  • Global layer auto-merge (dedup). A generator renders once per prompt; a nested re-import of a singular generator drops out (user-typed duplicates always render). stacking: true (alias multi) front-matter exempts a generator — set on the chained decorators color/glow/neon/crystal so dedup doesn't regress them. State is per-block() call; keyed on the resolved generator key; pass-0 = user tokens, pass≥1 = imports.

  • Editor (CodeMirror DPL language, gui/src/lib/dpl/dplLanguage.js + styles.css). Added highlighting for the dials (i25%/f80% args, [i<..]/[f<..] conditions, $intensity/$focus), front matter (fences/keys/values), section heading names + special Start/Auto Begin/Auto End (via a ViewPlugin mark-decoration — a stream tokenizer can't see the next === line, so the heading-name line is decorated cross-line instead), and go to/insert/+ name targets. Added context-aware autocompletes: i/f dial popup (intensity-vs-focus descriptions) on a space inside {#name …}, front-matter keys, go to section names, and insert/+ generator+section names. Verified the completion logic with a throwaway node harness (9 cases) since there's no jsdom test for the language module. Also normalized a blank line after the front-matter fence across 89 .dpl files (owner preference).

  • Content pass — focus-tagging + fluff revival (started). Reworked 9 core place/nature scenes (cave, beach, city, landscape, mountains, ruins, house, park, castle) onto a consistent focus ladder: essentials unconditioned; [f<70%] atmosphere ({#nature}/{#weather}/…); [f<65..40%] extra depth; [f<35..15%] out-of-subject fluff revived from v1/v2 (distant settlements, a city beyond the cave, a dragon over the castle, …). Verified the gradient end-to-end with a node harness: at f95 a cave is just "cave, cave walls, cavern, …"; at f10 it fills with every layer + fluff. Remaining scenes (interiors, vehicles, space, micro-*) + subjects still to do.

  • 100-step word scales + size list. $intensity-word and $focus-word went from 6-bucket ladders to 100-word scales (one word per percent, owner-curated): INTENSITY_WORDS (size/amount/scale, 50≈normal) and FOCUS_WORDS (broad→pure, 50≈balanced) in src/core/dpl/dpl.js. Also expanded the look/size list (5→~128) with the deduped union of the owner's good size words (suffix-variants collapsed; the intentional ultra/extra/very-tiny sequence kept). Word-scale test assertions updated.

Tests: extended tests/unit/dpl.test.js (dial conditions both dials, $-keywords, ref normalization, legacy back-compat) and tests/integration/enginePipeline.test.js (focus dial, dedup, stacking, focus as 5th JS arg). Green: npm run smoke, test:unit (119), test:web (51), lint (0 errors), gui build. Notes: added reference/focus-design.md + reference/layering-design.md, updated reference/intensity-design.md to the $/i/f syntax and data/blocks/README.md. VERSION + package.json2.13.0 (MINOR). Not yet committed/merged at time of writing.

Manage tab — phase 6 + merge to dev + CI fix (2.12.0)

Added Node contract tests (tests/integration/manageFs.test.js, 11 — snapshot↔loader equivalence, ops round-trips, traversal guard, ghost detection); updated dependencies.md (@codemirror/lang-javascript) and status.md; bumped VERSION+package.json to 2.12.0 (MINOR). Full npm test green (Node 111 + SPA 51). Merged feature/manage-tabdev --no-ff, deleted the branch.

CI caught a real bug the local build hid: the merge run went red — SPA "Build gui" failed with UNRESOLVED_IMPORT ./InlineImageControls.jsx, and Playwright failed as a cascade. Cause: Home.jsx edits for the catalog subscription swept in pre-existing uncommitted WIP — an InlineImageControls import+usage whose file was untracked — plus several styles.css hunks (provider-redesign WIP). It built locally (file on disk) but CI checks out only committed files. Fix: committed the untracked InlineImageControls.jsx (completing the sweep rather than ripping the WIP back out — less destructive, and it's the owner's own work; builds + SPA tests pass). dev CI now green. Lesson: when editing a file that already shows uncommitted changes, check whether those changes are mine before staging the whole file (the working tree had genuine WIP mixed with CRLF noise). 2.12.0 is on dev, CI green — awaiting the owner's go-ahead to release to main. Follow-up: jsdom component + Playwright visual baselines for the Manage tab.

Manage tab — phase 5 (add/delete, drag-move, external watch)

Interactive structure ops: always-visible "+" on folders/roots → menu (New block/list → creates + opens for edit; New folder → empty folders now render); entry pill Delete (content+sidecars, confirmed)

  • folder editor "Delete folder" (recursive); drag entry pills onto folders/roots to move (content + sidecars, drop highlight). External-edit watch via SSE (/api/manage/watch, fs.watch both roots) → debounced client refresh; manual Refresh stays as fallback. Tweaked manageTree folder filter to keep empty folders (hide only nsfw-named when adult off). Build green. Reused the already-verified fsOp primitives (mkfile/move/delete/traversal). Next: phase 6 (tests, docs, dep/credits, version bump, merge to dev, ask before main).

Manage tab — phase 4b (ghost entries + published manifest)

Owner wanted deleted-locally-but-online files shown as restore-only "ghost" pills. First drafted it against the GitHub git-tree API; owner suggested a simpler published manifest instead — pivoted to scripts/build-data-manifest.mjsdata/manifest.json (npm run manifest, regenerate at release). remoteManifest fetches it from main and disk-caches in the OS temp dir for a day (hash-named file, checked on boot, ~once/day refresh, stale-cache offline fallback) per owner's caching request. computeGhosts/injectGhosts do the set difference + place ghosts (synthesizing ghost folders); Manage renders faded dashed restore-only pills. Verified ghost detection headlessly (exact), manifest gen (76/89), build green. Note: ghosts only light up once manifest.json is on main (after release); until then getRemoteManifest 404s → null → no ghosts (graceful). Next: phase 5 (add buttons, drag-drop, external watch).

Manage tab — phase 4 (list editor + restore-default)

ManageListEditor: entry mode (virtualized rows, search, add/inline-edit/delete — smooth at 27k lines, no size warnings) + raw mode (CodeEditor); fetch-once, in-memory line array, trailing-newline preserved; rename + description sidecar + Restore default. Added /api/manage/restore + manageFs.restoreFromRepo. Branch surprise: owner said restore from master, but master is a stale OLD-layout branch (root data/, no engine-v3/) — restore 404'd. Confirmed with owner to use main (has the engine-v3 layout, "Release 2.11.2"); verified main raw 200 + 404→delete path. Wired list/group routing in Manage. Build green. Next: phase 5 (add buttons, drag-drop, external watch).

Manage tab — phase 3 (block + folder editors, save + hot-apply)

Built the editors: ManageBlockEditor (DPL via DplEditor + JS sidecar with DPL⇄JS tabs / create-from- boilerplate / JS CodeMirror, name/description/NSFW-gated, save hot-applies, JS-runs-on-reload note), ManageFolderEditor (rename, sidecar priority/description/forceList, force-prefix + group-mode markers as plain controls), and a reusable CodeEditor. Added /api/manage/{sidecar,marker,fs} + manageFs helpers (mergeSidecar/setMarker/fsOp) + client wrappers; installed @codemirror/lang-javascript. Wired selection routing + handleChanged (hot-apply + reselect-on-rename) in Manage; exposed enableGroup/disableGroup on tree nodes. Verified the whole data round-trip headlessly (create/sidecar/ marker/rename+sidecar/delete + traversal guard + cleanup, 9/9); SPA build green. Next: phase 4 (list editor — entry + raw — and restore-defaults).

Manage tab — phase 2 (the tab + read-only folder tree)

Built the Manage tab UI (Manage.jsx + manageTree.js + styles + App wiring). Left pane is the real nested folder tree of both roots with categories vs subfolders color-coded, force-prefix/group folders badged (the _-markers abstracted away), per-folder gear, entry pills with a hover Edit action (no insert), search, and a Refresh button; right pane is a detail/preview (editors next). Tab is the 4th (after Single), gated on the local-mode capability probe (not the build flag) — locked online. Verified the model headlessly: Blocks 7 categories (prompt force-prefixed, expansion/* nested groups), Lists 11 categories with the expected force-prefix dirs, NSFW gating drops 4 adult lists when off. SPA build green. Next: phase 3 (block + folder editors with save/hot-apply).

Manage tab — plan + phase 1 (runtime loader + local-mode API)

Owner asked for a new Manage tab (4th, after Single): an in-app content manager for the catalog — edit generators/lists, the folder/category structure, sidecar options, with NSFW gating, big editor pane, real folder tree, pill hover Edit/Delete, drag-drop, restore-from-master. Wrote the full plan to notes/plans/manage-tab.md and iterated on it with the owner. Settled decisions: runtime/hot-apply like v1–v2 (clean only — no eval); edits the real data/ files; local mode vs online are runtime modes (not dev-vs-prod), Manage gated on a capability probe; restore fetches from the stable branch master; no JS hot-reload but full JS-sidecar editing (boilerplate + DPL⇄JS toggle, save-but-reload-to-run); seamless large lists (no size warnings); drag just nudges priority (ride on existing sort).

Built phase 1 (plumbing, no UI) on feature/manage-tab: gui/server/manageFs.js (snapshot/tree/ path-guard/atomic-write), /api/manage/{ping,snapshot,tree,file}, gui/src/lib/runtimeLoader.js (snapshot-backed loader, bundle fallback for .js/presets), manageApi.js, a rebuildable promptEngine.js with refreshCatalog() + catalog pub-sub, dpJsModule export on browserLoader, and bootstrap/subscribe wiring in App/Home. Verified a snapshot reproduces the Node loader's catalog exactly (87 lists / 89 generators, dpJs=0); SPA build + smoke green. Next: phase 2 (the tab

  • read-only folder tree).

fix stale web-app/gui/ refs in architecture.md

Cleaned up the three remaining current-tense web-app/ references in notes/decisions/architecture.md (the SPA folder is gui/): the npm --prefix web-app run build verify command and the JSDoc-SPA decision's web-app//web-app/src mentions. Left the tmp/webapp-docs build-artifact name alone (separate token, unverified). Notes-only, no version change.

postinstall hook: one npm install covers the gui deps (2.11.2)

Follow-up to the README clarity fix below. Owner approved making the gui install automatic so the docs become a backstop instead of a tripwire. Added "postinstall": "npm --prefix gui install" to engine-v3/package.json; now cd engine-v3 && npm install installs both packages, and the @vitejs/plugin-react fresh-clone failure can't happen from a single install. Kept web:install for reinstalling just the gui deps; simplified the README run-from-source block back to one install. CI unaffected (the gui job still installs+builds the SPA on its own). PATCH 2.11.2 (VERSION + package.json). Verified: npm install re-run fires postinstall and installs the gui deps; lint clean.

Tooling note: did all of this with PowerShell + the file tools — no bash sandbox (it had failed the previous commit with .git/objects permission errors and CRLF mangling). Hardened the no-bash-use-powershell memory accordingly.

README clarity for fresh clones (build & run from source)

A user cloning the repo hit Cannot find package '@vitejs/plugin-react' after npm install + npm run web: they'd installed only the engine-v3 deps, not the SPA's. The SPA in gui/ is a separate npm package (npm run web is just npm --prefix gui run dev), so its deps need their own install. Root cause in the docs: the README quick-start showed npm installnpm run web with no gui install, and still referred to the SPA as web-app/ (renamed to gui/).

Fix (README.md, docs only — no version bump): rewrote the engine-v3 Build & run from source block to list both installs (npm installnpm run web:installnpm run web) with a one-line "separate dependencies" note; split the production build (web:build) into its own block; moved testing (npm test / test:e2e) into a separate Development subsection pointing at notes/. First pass over-emphasized the step (callout quote + caps) — toned down to a plain step per owner feedback. Verified the README working-tree diff was CRLF-only before editing (git diff --ignore-all-space empty); prettier clean.

Still offered, not done: a postinstall hook on engine-v3 so a single npm install also installs the gui deps (makes the docs a backstop instead of a tripwire), and fixing the remaining current-tense web-app/ references in notes/decisions/architecture.md.

open generated images reliably in a new tab — online build (2.11.1)

Owner: clicking a generated image online "sometimes works, sometimes doesn't" and tries to open a website / pull online instead of the cached image. Cause: PromptResult renders each image as <a href={img} target="_blank">, and online there's no single view so onImageClick is undefined → the browser just navigates href. For data: URLs (OpenAI/Gemini/Grok/Stability return base64) browsers block top-level data: navigation (the intermittent failures); for remote URLs (fal/Leonardo/DALL·E-3) it opens the remote address, not the cached image.

Fix: openImageInNewTab() in lib/output.js — converts a data: URL to a blob: object URL synchronously (via atob, no await, so window.open stays inside the click gesture and dodges the popup blocker) and opens that; blob: / served / remote URLs open directly. PromptResult's image link now always preventDefault()s and routes to the single view (local) or openImageInNewTab (online). PATCH 2.11.1. lint clean, online build green, npm test green.