-
-
Notifications
You must be signed in to change notification settings - Fork 2
Changelog
All notable changes to Formamorph. This fork's first line is 2.0.0 โ a full TypeScript rebuild of the upstream JavaScript app (FieryLionite's Formamorph, ~v1.2) โ with feature parity as the baseline plus new features on top.
โ 2.0.0 โ 2.4.1 are released (collapsed below). New work lands under ๐ง In Progress โ an unnumbered section, so changes accumulate without pinning a version. When a batch earns a release its section is marked Released and collapsed, and a fresh In Progress opens.
package.jsonreads 2.4.1 โ the latest released version.
Each release groups changes as Major / Minor, then Added / Removed / Fixed, and within those by audience: ๐ค user-facing ยท ๐ ๏ธ developer tooling ยท โ๏ธ backend / invisible.
Unreleased โ new work accumulates here until it earns a version bump. The next batch will pin its own version; package.json reads 2.4.1 (just released below).
โ 2.4.1 โ Released 2026-07-16 โ a fix-only follow-up to 2.4.0: sub-locations you can walk back out of and a narrator that knows what place you're inside, dropdowns that stay on screen, and desktop publishing that can update and delete again (click to expand)
-
๐ค User-facing
- The AI now knows what place your location is inside, and you can walk back out of it. The Reachable scope on the Location and Entities prompt chips now sends the location that contains your current one โ listed first โ alongside its neighbors, so the narrator knows what building the room is in and who's out there. Previously nothing you could put in a prompt could mention it at all. It's also somewhere you can now travel to: moving into a sub-location with no sub-locations of its own and no neighbors used to strand you there permanently, because the game worked out where you could go from a place without ever considering the one containing it. No prompt edits needed โ if you use the Reachable chip, this arrives on its own.
- Long dropdown lists no longer run off the top of the screen. In a world with a lot of locations, the Change Location list was tall enough that it opened upward and had its top cut off by the edge of the screen, putting the first locations out of reach. Dropdowns now size themselves to the space actually available and scroll inside it. Affects every dropdown in the app, not just Change Location โ most noticeable on shorter screens.
- Updating and deleting your published worlds works again in the desktop app. Publishing an update over an existing world, or deleting one you'd already posted, failed in the desktop app with a "Failed to fetch" error โ while both worked fine in the browser. The desktop app rewrites the community server's permissions so a native app can talk to it, and that rewrite was accidentally dropping the permission to update or delete. Publishing a brand-new world and downloading were never affected, and nothing about your worlds or the server needed changing.
โ 2.4.0 โ Released 2026-07-16 โ a safety net for your library: full Backup & Restore to one portable file, per-turn Autosave, and persistent-storage marking โ plus multi-file imports, a browsable Save menu, jump-to-page, data-backed reasoning-model recommendations, and built-in worlds that refresh themselves (click to expand)
-
๐ค User-facing
- Import many files at once. The Import buttons for worlds, characters, dictionaries, and saves now let you select multiple files in one go. Bad files are skipped and you get a summary ("Imported 5 worlds (1 skipped)"). When a world or character import includes oversized images, a single Optimize / Downscale / Keep prompt covers the whole batch. Lorebooks embedded in imported character cards are added to your dictionary library automatically.
- Jump to a page. In any paged list (the in-game transcript, the AI-context view, Community Creations), click the โฆ or the current page number to open a small โgo to pageโ box โ type a page and press Enter. Out-of-range numbers snap to the nearest valid page. On mobile, tap the current page number.
- Autosave. Your game now saves itself after every turn, starting once the opening scene finishes. Each world keeps a single Autosave slot (overwritten each turn, tagged Auto in Load) that's separate from your manual saves โ it never overwrites them, and you can't accidentally clobber it from the Save dialog. It also re-saves after a Regenerate, but not after an Undo. On by default; turn it off in Settings โ Accessibility. A downloaded autosave re-imports as an ordinary manual save.
- The Save menu now lists your existing saves. Saving a game shows the same browsable save list as Load โ your worlds' save folders, with download, delete, and drag-to-reorder. Click a save to drop its name into the box, so overwriting an existing save is one click (then Save confirms the overwrite as before). Typing a fresh name still makes a new save. The box also prefills with the name of the save you're currently in (the one you last loaded or saved this session), so re-saving over your slot is quick.
-
Back up and restore your library in one file. A new Backup & Restore option in the main menu exports your worlds, saves, characters, and dictionaries to a single portable
.json, and restores them on any device or after an update. You pick exactly which worlds, saves, characters, and dictionaries to include โ each is its own checkbox under a per-type group toggle. Restore works the same way; items that already exist are tagged so you can choose, per type, whether the backup overwrites them. Restoring worlds or characters can also optimize or downscale their images on the way in. Especially useful in the itch app, where an update can otherwise hide your local data โ back up first. - Optimize a character's portrait when you import it. Importing a character with an oversized image now offers the same Optimize / Downscale choice worlds already had, so a huge portrait doesn't bloat your library.
- The desktop model catalog now includes reasoning roleplay models, backed by data. The Recommended list (desktop) was refreshed with current community roleplay finetunes, and each now shows its release date and download count so you can weigh the recommendation yourself (counts refresh from Hugging Face and fall back to a snapshot when you're offline). The reasoning-capable ones are flagged with a Reasoning badge (a compact 4B and the Qwen3.6 mixture-of-experts finetune); reasoning is controlled per-prompt from Settings, so these pair with the thinking-budget control out of the box.
- Built-in worlds refresh themselves. When a built-in world's content changes in a new build, your copy updates to it automatically on launch โ as long as you haven't edited that world (edited copies are always left alone). Any worlds updated this way are named in a notification so you know what changed. It compares the actual content, so an update lands exactly once and a world can be improved without waiting for an app version bump.
- Old saves pick up newly added stats. If a world gains a new stat after you started playing it, loading an existing save now shows that stat (at its starting value) instead of leaving it out. Your existing stats and progress are untouched.
- Built-in worlds no longer warn about custom code. The "this world contains custom code, trust the source" warning is meant for worlds from elsewhere โ it no longer shows for the unmodified built-in example worlds, since they're ours. If you edit a built-in world, the warning returns.
-
๐ ๏ธ Developer tooling
-
A repeatable prompt-quality gate. The baseline harness gained a scripted gate (
GATE-PROBE.md) with a neutral prompt preset and a second reference world, so prompt changes can be measured the same way every time instead of judged by eye. -
Model leaderboard. Probe runs now record to a tracked leaderboard (
testing/baseline/leaderboard.md), making it easy to see how the reference models compare across prompts โ with the research writeup kept alongside it. -
Screenshot capture in the harness. The baseline runner can grab screens (
harness/screen.mjs) and its profile example covers the current model tiers, so a run's output can be eyeballed after the fact.
-
A repeatable prompt-quality gate. The baseline harness gained a scripted gate (
-
๐ค User-facing
- Long lists scroll again on mobile. On a phone, the Traits and Dictionary lists in the World Editor and the save list in Load/Save wouldn't scroll once they grew past a screenful โ the overflow was simply cut off and unreachable. All three now scroll normally.
- Your library is now marked as persistent storage. The app asks the browser to keep its saved data (worlds, saves, characters, dictionaries) exempt from automatic cleanup, so it's far less likely to be evicted under storage pressure โ a common cause of "everything disappeared" reports on the web and itch builds. Pairs with the new Backup & Restore for a belt-and-suspenders safeguard.
-
No more endpoint errors when you're not using reasoning. The app used to probe your endpoint's reasoning support (and tag requests with a reasoning setting) on every connect โ which made plain servers like LM Studio log a burst of
'messages' field is requirederrors and could wedge a just-loaded model. That reasoning machinery now runs only when you've actually turned reasoning on (a Thinking mode, a native effort level, or a per-prompt reasoning level). With reasoning off, requests look exactly like before โ no probe, no reasoning fields. - Popups no longer flash their empty state while closing. Many dialogs briefly reverted to a blank/initial state for a frame or two during their fade-out โ form dialogs (Save game, Backup & Restore, login/profile, preset sharing) and confirmation prompts alike (e.g. a delete confirm losing the item's name as it closed). Form dialogs now reset when opening rather than closing, and confirmations keep the content they were showing until they finish fading out.
- Saving a backup is reliable everywhere. An earlier approach used the system "save as" dialog, which errored when overwriting an existing file and, in the itch app, could pop the save dialog twice. Backups now download directly โ one step, and it works the same in every browser and in the itch app.
- Downloading community worlds quickly no longer loses one. Two worlds downloaded within the same instant could be assigned the same internal id, so the second silently overwrote the first in your library. Each download now gets a unique id.
- Clicking a character opens the right one. When two characters shared a name prefix (e.g. "Guard" and "Guard Captain"), clicking one could open the other's details. It now matches the exact character.
- Dragging a chip and dropping it outside a text box no longer misplaces one later. Cancelling a chip drag (dropping it on empty space or pressing Escape) could leave a stray reference that made your next unrelated drag move a chip you didn't touch. Cancelled drags now clear cleanly.
- Stat descriptors accept a 0% threshold. In the World Editor, adding a stat descriptor with a threshold of 0 did nothing; it now saves like any other value.
- Starving no longer wipes out that turn's other stat changes. On a turn where you were starving, the health penalty overwrote Health with a value read before the turn's other changes landed โ so any Health the AI awarded, and any Health recovery you'd earned, silently vanished. The penalty is now subtracted from your current Health, so it stacks with the rest of the turn instead of replacing it.
- Loading a save can't hang on a worker failure. If a background helper (save conversion/download) failed to start, the "Processing save fileโฆ" notice could spin forever; such failures now surface as an error and release the dialog.
- The oldest saves restore their traits and body changes. Very old saves (the deep-nested pre-2.0 format) loaded with their character traits stripped of description (so the AI saw only bare names) and their body stats no longer driving the 3D model. Loading one now brings those up to the current format, the same as newer saves already did.
- A clearer message when the web app can't reach your AI server. On the web/itch build, a connection failure used to show only a generic "Failed to process AI request." It now offers a Fix connection guide covering the three causes โ server not running, wrong Endpoint URL, or CORS turned off on the server โ with the exact toggle for LM Studio and Ollama. The same guide is reachable any time from Settings โ Endpoint โ "Trouble connecting?". (The desktop app already handles this automatically, so it's web-only.)
-
โ๏ธ Backend
- Built-in worlds seed with their full content. The first-run seeding path dropped a world's lorebook (dictionaries) and placeholders; they're now carried over intact.
- Built-in worlds stop re-saving themselves on every launch. The refresh check compared a world's shipped version against a field that actually records the app version that last touched it โ so a world could look permanently "newer", rewriting itself (and showing an "Updated" notice) every single launch, while a genuine content-only fix could never reach you at all. It now compares a hash of the shipped content, which settles after one update and can't loop.
- A background helper that fails to start no longer wedges the rest of the session. If a worker's script failed to load, the broken instance was kept and reused, so every later save load hung on a spinner instead of reporting the error. The failed worker is now discarded and the next attempt starts fresh.
โ 2.3.0 โ Released 2026-07-14 โ Formamorph goes mobile: the game view, World Editor, character customization, Settings, and Community Creations all adapt to mobile and portrait โ one-panel push layouts, bottom sheets, collapsing toolbars, and viewport-correct popups โ plus a version-aware "What's new" popup and a configurable Staged-thinking cast (click to expand)
-
๐ค User-facing
- The "What's new" popup highlights your version. The changelog popout is wider and laid out as a cleaner outline โ each version flush-left with its Added/Fixed groups and items stepped in beneath it. Your installed version is tinted blue and tagged (Current), and the newest available version is tinted green and tagged (New!), so you can tell at a glance what you have versus what's new.
-
The "What's new" popup surfaces the versions that matter to you. Instead of a flat "newest three," it now picks what to show based on the version you're on: the full current X.Y series, the couple of versions just before yours, and โ when you're several releases behind โ the newest few as well. A
...line marks any stretch that's been skipped, and an available update is always shown. You see more of what's relevant around your version without scrolling the whole history. - The library's top bar collapses to one menu sooner. The Community Creations / New / Import buttons now fold into a single hamburger on narrower windows (below 1000px), not just in portrait. When they collapse, the separate right-side Load Game / Settings menu folds into that same hamburger too โ so there's one menu instead of two, and the grid/detailed view toggle sits flush to the right.
- Skip Customize is clearer when entering a world. The button gained a skip-forward icon, and in portrait it collapses to just the icon so it fits alongside Enter World without crowding.
- Choose how many characters the director stages (Staged thinking). Staged mode used to hard-cap the cast at three, so editing the Director prompt to allow more had no effect. Settings โ System Prompts now has a Limit Active Characters control (under Thinking, shown for Staged): turn it off for as many characters as the scene calls for, or set your own number (default 5). A new Active Character Guidance chip in the Director prompt keeps its wording in sync with your choice.
- Toggle the character's animation while customizing. A new Animate character checkbox (above Initial Body Features) freezes the model in place when unchecked โ handy for lining up body and color changes on a still pose โ and resumes the idle motion when checked. The scene stays live either way, so you can still orbit and see your edits.
- Character customization works in portrait now. On mobile or a narrow window, the customization controls no longer get squeezed into a sliver that cuts off their labels. They move into a bottom sheet that slides up over the character โ tap Customize to open it, adjust anything, then close it to see the full model. The sheet opens at a shorter height that keeps the character in view, with an expand button in its header to grow it when you want more controls at once; it scrolls its full contents at either size. Desktop is unchanged.
-
๐ค User-facing
- Manual stat edits survive a re-generate. Nudging a stat with the in-game slider, then playing on and re-generating a later turn, no longer reverts that stat to its pre-edit value. The edit now becomes the turn's baseline, matching how edited narration and notes already carry forward.
- Chip pop-outs stay open while you adjust options. Picking a mode on a placeholder or prompt chip (e.g. a Wildcard's World/Unique, or a Location chip's scope/content/format) no longer closes the pop-out, so you can change several settings in a row. Click elsewhere or press Escape to close it as before.
- The placeholder Edit/Preview toggle only shows when a world has placeholders. It used to appear on every placeholder-capable field even when no placeholders were defined; now it shows up only once you've added at least one.
- Popups fit the visible screen on mobile. Dialogs (Settings, world details, entity/dictionary editors, load game, and more) were sized to the full device height including the browser's address-bar area, so on mobile a centered popup's title and close button could sit above the visible screen. Every popup now sizes to the actually-visible area, so its header and controls stay on-screen.
- Turn pager is tidier on mobile. The Previous / Next buttons show just their arrows on mobile (the labels return on wider screens), the numbered strip collapses to only the three pages closest to your current one (no ellipsis), and in the story the pager left-aligns on mobile instead of centering โ so the row reads cleanly next to Re-generate.
- Re-generate is icon-only on mobile. The Re-generate button under the story drops its text label on mobile and shows just the refresh icon, saving room in the tight mobile toolbar. Its keyboard/screen-reader label is preserved.
- The in-game header is tidier on mobile. The floating buttons over the story crowded the top of the screen, so on mobile the menu (โฏ) now sits on the Character/Game/Status tab row, with Edit World and AI Context folded inside it. The background-music toggle and the Hide-UI button are dropped on mobile (not needed), and the three tabs shrink to make room. Desktop keeps all the header buttons as before.
- The World Editor works on mobile. It used to warn "not optimized for mobile, use a desktop" and hand you the cramped desktop split. It now adapts: on mobile each tab shows one panel at a time โ the list of stats/entities/locations/traits/dictionary entries/placeholders, and tapping one slides in its editor with a back button โ while desktop keeps the resizable two-column view. The tab strip scrolls sideways, the toolbar wraps, and the mobile warning is gone.
- The placeholder editor works on mobile. Its two-column list-and-editor layout was unusably cramped on mobile; it now shows one panel at a time โ the list, and tapping a placeholder slides in its editor with a back button. Desktop keeps the side-by-side view.
- The character model fills its panel on mobile. In the Model tab, the 3D character was boxed to a fixed aspect and left dead space; it now uses the panel's full height and width. Landscape/desktop keep the aspect-boxed viewer above the panel.
- The game view uses the full screen on mobile. The play screen no longer has an outer margin on mobile, so the panels run edge-to-edge and reclaim that space. Desktop is unchanged.
- The main-menu footer is less crowded on mobile. On mobile the footer keeps your profile/login button and the copyright, and tucks the version/what's-new control, the Patreon and GitHub links, and the "Based onโฆ" credit into a single โฏ menu. Desktop shows them all inline as before.
- Community Creations filters tuck away on mobile. On mobile, the sort, author/tag filters, hidden list, and "updates first" toggle now collapse behind a Filters button (with a count of what's active) so the search bar and results have room; tap to expand them in a panel. Refresh now sits beside the search box everywhere. Desktop keeps all the controls inline. Their label-and-control rows used a fixed two-column layout that stayed side-by-side on mobile (unlike Presentation and Generation, which already stacked), squeezing the controls. Those rows now drop to a single stacked column below a small breakpoint, matching the other tabs.
- Settings tab widgets fit narrow screens. The six-tab row along the top of Settings, plus the Thinking and Native Reasoning mode pickers, were too wide for mobile; below a small breakpoint each now collapses into a dropdown of the current choice, and stays a full tab row on wider screens.
- Character customization's Back button becomes Abort on the first step. When avatar customization is the first (or only) step of entering a world, its Back button was disabled โ a dead end. It now shows Abort and cancels the enter-world flow, matching the other steps.
- The enter-world steps dim the menu behind them again. The trait / location / character / dictionary selection popups render as plain cards without a dialog backdrop, so the main menu showed through undimmed. A black overlay now sits behind them like every other popup.
- Location Change is skipped in single-location worlds. With Location Change on, a world that has only one location no longer fires the (pointless) move request each turn โ there's nowhere to go โ so you don't have to toggle the setting off per world.
-
Community world thumbnails zoom again. Clicking a world's image in the Community Creations details view opens the pan/zoom viewer as before; it had stopped loading because the zoom used the raw server image URL (which the server blocks from cross-origin
<img>loads), while the card thumbnail already loaded through the local image cache. The zoom now uses that same cached copy. - The world details popup no longer scrolls sideways on small screens. On a narrow portrait viewport, the panel that opens when you pick a world could be cut off on the right with a stray horizontal scroll; its content now stays within the screen.
- The character model renders cleanly in its viewer. In character customization the 3D model could draw outside the rounded edges of the viewer card, and it could stay cut off at the wrong size after rotating the device or resizing between landscape and portrait. It's now clipped to the panel and re-fits itself whenever the panel changes size, in both the desktop and portrait layouts.
-
The app no longer shows a blank screen over plain HTTP. Opening Formamorph from a non-secure origin โ for example loading the dev build on your phone at
http://<your-pc-ip>over the local network, or self-hosting without HTTPS โ used to render a white screen, because the browser withholds its built-in ID generator outside a secure context. It now falls back to a compatible generator, so it loads and runs the same everywhere. - Scrolling the main-menu card grids on touch no longer drags cards around. On mobile, swiping to scroll the worlds, entities, or dictionaries could accidentally pick up and reorder a card. Reordering on touch now needs a short press-and-hold first, so a plain swipe scrolls as expected. Mouse drag is unchanged.
- The World Editor's + button always adds an item. Adding a stat, entity, location, or stat update used to do nothing unless you'd first typed a name in the search box. It now creates a new item with a default name (e.g. "New Stat") and selects it for editing โ matching how traits, dictionaries, and placeholders already worked. Typing a name first still uses that name.
- The World Editor runs edge-to-edge on mobile. The mobile layout had an outer margin and extra card padding that ate into the little screen space there is; those are gone, so the list and editor use the full width. Desktop is unchanged.
- Popup buttons don't crowd together when they stack on mobile. When a dialog's action buttons wrap from a row into a vertical stack on a narrow screen, they had no space between them and nearly touched. They now keep standard spacing whether stacked or side-by-side.
-
๐ ๏ธ Developer tooling
-
Reusable
ListDetailmaster-detail layout. Newcomponents/ui/list-detail.tsxrenders a list + detail side-by-side on desktop and a single-panel push (detail slides in over the list with a back header, parallaxed, reduced-motion aware) on mobile โ branching onuseIsMobile. The caller owns selection and passesshowDetail+onBack.PlaceholderEditoris the first adopter (drops its hand-rolled two-column split), so the entity/dictionary modals and the World Editor placeholders tab all get the mobile push for free. Unit-tested (desktop-both-panels vs mobile-back-bar). The list's dnd is unaffected โ at rest the list panel's transform isnone, so no containing-block interference. Adopted byPlaceholderEditor,DictionaryEditorModal(dictionary tree โ book/entry editor), and theWorldEditor(its per-tab list + detail extracted to sharedlistContent/detailContentfragments, branched onuseIsMobile: desktop resizable split vs mobile push; the< 1024px"not optimized for mobile" warning was removed). -
Configurable staged cast cap + guidance chip.
matchCastToEntities's hardcoded3is nowrunStagedPlanning'sactiveCharacterCap(Infinity = unbounded), fed from two new settings โlimitActiveCharacters(bool) +activeCharacterLimit(int, default 5). A new<ACTIVE CHARACTER GUIDANCE>prompt variable (registered inpromptVariables, offered on the Director prompt, resolved viaactiveCharacterGuidance()inbuildContextValues/preview) replaces the Director prompt's cast-size sentence so the prompt tracks the setting. Settings UI row is gated to Staged. Unit tests cover the guidance text and the pinned Director chip list. Default staged cast size shifts 3 โ 5. -
Modal/overlay heights use
dvh, notvh. Every arbitrary[Nvh]height on a dialog, card modal, bottom drawer, or scroll region (plus the two full-screen dialogs'h-screen) was converted to the dynamic-viewport unitdvh, so centered overlays no longer overflow the mobile visible viewport and clip their own chrome. Page-rooth-screenscaffolds were intentionally left as-is. Verified no[Nvh]heights remain. -
Changelog popout formatting.
UpdateService.foldReleasenow emits each version as its own### <tag>heading (no more versionยทcategory merge);ChangelogBodytags the current / newest-uninstalled version headings (cl-current/cl-update) so.changelog-bodyCSS can tint them (info / success viacolor-mix, kept muted) and append the (Current) / (New!) labels; indentation is CSS too. Both changelog popouts widened tomax-w-3xl. Tests updated. -
Version-aware changelog selection.
buildRecentChangelognow takes the running version and picks releases by rule instead of slicing the newest three: always the newest release (rule 0), the full current minor including newer patches (rule 1), the newestRECENT_CHANGELOG_COUNTwhen behind by more than one release (rule 2), and the two releases just below the running version (rule 3). Renders...gap markers wherever hidden releases sit between shown ones (or below the last).currentVersionthreaded throughparseReleases; unit-tested across the up-to-date, behind-by-one-into-new-minor, and behind-by-many cases. -
Character customization responsive layout + bottom drawer.
CharacterCustomizationextracts the viewer and the controls into shared fragments and branches onuseIsMobile: desktop keeps the two-card split; portrait renders the viewer full-height with the controls in avaulbottom drawer (new dep) toggled by a floating Customize button. Newcomponents/ui/drawer.tsxfollows the shadcn recipe but keeps the overlay separate fromDrawerContent. The drawer is modal with a faint overlay so its gestures don't contend with the model's OrbitControls (this was the first-interact lag); it toggles between two fixed, fully-scrollable heights (DRAWER_HEIGHTS, short/tall) via a header expand button rather than vaulsnapPointsโ a partial snap can't scroll its inner content, so snap points were dropped. The viewerCardgainedoverflow-hiddento clip the WebGL canvas, andVRMViewernow re-fits via aResizeObserveron its mount (not justwindow.resize, which measured stale dimensions mid-layout-swap) so orientation/landscapeโportrait changes no longer leave the model cut off. Single mount of the controls (no duplicate DOM ids). Reachable for verification via the dev-router (#dev?modal=avatar, added toDEV_MODALS). No export-shape or behavior change. -
VRMViewercancels its render loop and animation timers on unmount. TherequestAnimationFrameloop and the self-schedulingplayNextAnimationsetTimeoutchain were never cancelled in the effect cleanup (only THREE resources were disposed), so every remount (model swap, layout change) leaked another loop + timer driving a disposed scene. Cleanup now tracks and cancels both. Also: the Animate-character toggle feedsdt=0to freeze in place, andplayNextAnimationskips the clip swap while paused so the pose no longer jumps between keyframes. -
Touch-friendly DnD sensors on the library grids.
MainMenuswapped the sharedworldSensorsfrom a singlePointerSensor(distance: 8, which fired on any touch swipe) to aMouseSensor(distance: 8) +TouchSensor(delay: 200, tolerance: 5) pair โ a touch swipe scrolls, a press-and-hold reorders; mouse behavior is unchanged. Fixes worlds/entities/dictionaries in one place. Paired with this,SortableWorldCard's whole-card draggables changedtouch-noneโtouch-pan-yso a vertical swipe pans the grid's ScrollArea natively (withtouch-nonethe browser couldn't start a scroll on a card, so the grid was unscrollable on touch); the delay sensor still takes over both axes once a drag activates. Small grip-handle draggables keeptouch-none(their card bodies already scroll). -
randomUUID()helper with a non-secure-context fallback.crypto.randomUUIDis only defined in secure contexts (HTTPS/localhost), so any startup path that mints an id โ e.g.migrateWorldon load โ threw a blank screen when the app was served over plain-HTTP LAN (phone testing the dev server) or self-hosted without TLS. Newlib/uuid.tsuses the native API when present and otherwise builds an RFC-4122 v4 fromcrypto.getRandomValues(which isn't secure-context-gated); all 34crypto.randomUUID()call sites now route through it. Unit-tested (native path, fallback path, uniqueness). Still v4 UUIDs โ no export-shape or version impact.
-
Reusable
โ 2.2.2 โ Released โ author-defined **Placeholders**: reusable Variables / random Wildcards dropped into world text as chips, with a live Edit/Preview toggle, portability when you share characters and dictionaries, and one shared editor everywhere โ plus downloaded worlds now keep their own thumbnail and work fully offline (click to expand)
-
๐ค User-facing
- Placeholders โ reusable values and randomness in your worlds. A new Placeholders tab in the World Editor lets you define named values and drop them into your world's text. Add one value and it's a Variable โ reuse it everywhere and change it in one place. Add two or more and it's a Wildcard โ each playthrough picks a random one, so worlds feel varied without you writing every case. Insert them as chips into any entity, location, world, or dictionary description (both the player-facing and AI-facing text), and each Wildcard chip chooses World (the same value everywhere this playthrough) or Unique (its own value at that spot). The player and the AI always see the same rolled value, frozen for the save.
- Preview placeholders while editing. Any field that accepts placeholder chips now has an Edit / Preview toggle (like the prompt fields). Preview swaps each chip for a value โ a Variable shows its value, a Wildcard shows a random one of its values (World chips share a value, Unique chips roll separately) โ highlighted in the chip's own color. Re-rolls each time you open Preview, so you can see the range.
- Edit a shared item's placeholders in the library. The library character and dictionary editors get a Character/Dictionary โ Placeholders tab switch at the top; the Placeholders tab is the same list-and-editor as the World Editor's, so you can add, edit, and reorder the placeholders a standalone item carries. Its chips now display and resolve correctly in the library editor too.
- Shared entities and dictionaries keep their placeholders. When you export a character card or a dictionary that uses placeholders, the placeholder definitions it needs travel with it. Importing it into another world adds them to that world (reusing an exact same-name/values match if one already exists, so you don't get duplicates), and its chips keep working. Held in the library, an imported item carries its placeholders until it lands in a world.
-
๐ ๏ธ Developer tooling
-
Placeholders โ scoped store, shared split UI. The placeholder editor now follows the same pattern as the dictionary editor: a scoped
PlaceholderStorecontext (contexts/PlaceholderStoreContext.tsx, with aplaceholderStore(value, setValue)adapter).GameDataContextbinds it to the world's placeholders; the library editors bind an isolated adapter over the item's ownplaceholdersfield. The split UI is now genuinely the same components everywhere โPlaceholderList(left) +PlaceholderManager(right), reading/writing through the store โ instead of the library reimplementing the layout.PlaceholderEditoris the context-bound combined wrapper the modals use; the World Editor's Placeholders tab renders the same pieces (dropped from the generic tab machinery, like the Dictionary tab already was). No behavior change; verified in the World Editor. -
Placeholders โ export/import portability (slice 6).
Entity+Dictionarygain an optionalplaceholders?(one representation for the export file and the in-app library item). Export (buildEntityCardData/buildDictionaryFile) bundles just the used defs viacollectUsedPlaceholders; import parses them onto the object; adding a card/book to a world runsabsorbPlaceholders(perfect name+values match โ reuse id, else fresh-id add) +remapPlaceholderIdsto rewrite the item's tokens, then clears the carried section. All pure + unit-tested; entity round-trip test added. Additive export-shape change to the entity card + dictionary.json. -
Placeholders โ field rollout (slice 5).
PlaceholderField(the placeholder chip editor) replaced the plain textareas on the accepting fields: entity player/AI descriptions + summary (EntityFields), location player/AI descriptions + summary (LocationManager), world System Prompt (WorldDetailsManager), and dictionary entry value (DictionaryManager). Shared editors (EntityFields,DictionaryManager) takeplaceholdersas a prop so the world-editor callers pass the world's list while the isolated library editors pass none. ExtractedKeywordChipsreused for values. Completes the feature (author โ insert chip โ resolve at runtime), verified end-to-end. -
Placeholders โ resolution wiring (slice 4). Placeholder chips now resolve to their frozen values at runtime. Rolls are eagerly primed when a save activates (
primeRollsscans the world's authored text once, rolls every Wildcard placement, keeps a loaded save's existing rolls), so resolution is a pure lookup everywhere โ no render-time side effects.SaveObject.placeholderRollspersists them (GameplayContext).usePlaceholderResolver+ GameViewer'sresolvePHapply resolution at the AI-context boundaries (buildContextValues, scene entities, dictionary) and at player-display (entity modal, location panel). The same roll feeds both, so the player and the AI always see the same value. Reverses the earlier "lazy" plan for the render-safety win. -
Placeholders โ chip editor (slice 3). The Lexical chip editor was generalized behind a
ChipVocabularyabstraction (lib/chipVocabulary.ts) so one editor serves both token families: the static prompt variables (<โฆ>) and dynamic per-world placeholders ({{phโฆ}}).PromptField/VariableNodenow read their vocabulary from context (prompt path unchanged โ verified), and a newPlaceholderFieldwraps the same editor with the placeholder vocabulary: the toolbar inserts the world's placeholders (fresh placement id per insert), and a Wildcard chip's pop-out offers World | Unique once it has 2+ values. Vocabulary logic unit-tested; not wired into world fields yet (slice 5). -
Placeholders โ foundation (slices 1โ2). New authored-world concept: a named value dropped into world text, its behavior inferred from value count (1 = a fixed Variable, 2+ = a random Wildcard). Slice 1:
Placeholdertype +World.placeholders, save-sideSaveObject.placeholderRolls, and the pureresolvePlaceholderscore (lib/placeholders.ts) with the token codec, World-shared/Unique-per-spot rolls, lazy roll+freeze, and missing/empty โ "" (unit-tested). Slice 2: a per-world Placeholders editor tab (list dnd/duplicate/delete +PlaceholderManagername/values panel), reusing the extracted sharedKeywordChipsinput. Additive world + save export-shape change (nothing consumes it at runtime yet โ the chip editor + resolution wiring are later slices).
-
Placeholders โ scoped store, shared split UI. The placeholder editor now follows the same pattern as the dictionary editor: a scoped
-
๐ค User-facing
- Downloaded worlds now show their thumbnail in the library โ and work fully offline. A world downloaded from Community Creations was storing a link to the server's thumbnail image, which the browser refuses to display on the library card (the server marks those images as same-origin only) and which wouldn't work offline anyway. Downloads now save the world's own cover image directly into the local copy, so the card shows it and the world is fully self-contained. Worlds you already downloaded are healed automatically โ no need to re-download.
-
๐ ๏ธ Developer tooling
-
World download stores the embedded thumbnail, not the catalog URL.
useDownloadCoordinator.fetchWorldContentnow prefersmigrated.worldOverview.thumbnail(base64, already in the downloaded content) over the${API_URL}/thumbnails/...URL โ the server'sCross-Origin-Resource-Policy: same-origin(Helmet default) blocks the raw URL from<img>embedding cross-origin, and a URL can't render offline.getWorldMetadataalso falls back to the embedded thumbnail when a stored one is anhttp(s)URL, healing pre-fix downloads at read time (it already loadsdatafor tags, so no extra cost). Unit-tested. Separately, the FormamorphServer thumbnails route now setsCross-Origin-Resource-Policy: cross-origin(overriding Helmet'ssame-origindefault) so those public images are embeddable cross-origin โ not needed by the app anymore, but correct for any other embedder (needs a server deploy).
-
World download stores the embedded thumbnail, not the catalog URL.
โ 2.2.1 โ Released โ turns run their follow-up requests (choices, stats, summaries, diaries) concurrently for faster, steadier pacing, and the bundled desktop engine gains parallel requests, Flash Attention on by default, big-model / multi-GPU offload, and a VRAM-share readout (click to expand)
-
๐ค User-facing
- Turns finish faster with concurrent requests. After the story text, the app asks the model for your choices, stat changes, and any location change โ previously one after another, now all at once by default. On endpoints that handle parallel requests (e.g. LM Studio's Parallel setting) this cuts the wait between the narration and your choices by roughly a third, and makes turn times steadier. When memory summaries (and, in Staged mode, character diaries and new-character notes) are on, those run in the same batch too, so they finish with the turn instead of trickling in afterward. In Staged mode the per-character motivation passes also run at the same time instead of one by one, which noticeably speeds up busy scenes. A new Concurrent Requests toggle under Settings โ Generation lets you turn it off if a memory-tight local model slows down under the extra load; endpoints that don't run requests in parallel are unaffected either way.
- The GPU memory widget now shows Formamorph's own share (Desktop). The VRAM bars in Settings โ Endpoint get a highlighted segment marking how much the bundled model is using, with a "Formamorph: โฆ GB" readout. It uses the exact per-process figure when your GPU reports one, and otherwise falls back to the engine's own estimate (shown with a ~) โ so it works even on the many Windows GeForce cards that don't expose per-process VRAM.
- Big models and multi-GPU rigs are now supported (Desktop). The GPU offload control used to cap at 64 layers, so a large downloaded model (or a multi-GPU setup with room to spare) couldn't be fully offloaded. It now has Auto (fits as many layers as your VRAM allows โ the new default, never runs out of memory), Max (offloads the whole model, splitting across all your GPUs), and Custom (a fixed layer count) under Settings โ Endpoint (Advanced). The Context Size slider now also tops out at whatever the loaded model was trained for, instead of a fixed limit.
- Flash Attention is now on by default (Desktop). The bundled local engine's Flash Attention โ which lowers the model's memory use and is often faster โ now defaults on instead of off. It's widely supported on modern GPUs, and turning it on by default leaves more headroom for the parallel requests above. If an older GPU won't run it, you can still turn it off under Settings โ Endpoint (Advanced).
- The bundled desktop engine now handles parallel requests. The built-in local model (Desktop app) used to answer one request at a time and reject the rest, which meant the concurrent-requests speedup above didn't apply to it. It now runs up to a configurable number of requests at once. A new Parallel Requests slider under Settings โ Endpoint (Advanced) sets how many โ the default is 2, and it shows the per-slot context each setting leaves you. Higher is faster but splits the context window between slots and uses more VRAM, so raise it only if your GPU has room.
-
๐ ๏ธ Developer tooling
-
Local engine request pooling (
electron/llmEngine.cjs). The single-sequence +busy/429 design was replaced with a pool of N node-llama-cpp context sequences (createContext({ sequences: N })) plus an acquire/release wait-queue: concurrent requests each take a free sequence and decode in parallel (llama.cpp batching), and overflow requests queue instead of erroring. Sequences areclearHistory()-reset between uses; a client disconnect aborts the in-flight generation (via anAbortControlleron the responseclose) so a canceled request frees its slot. Slot count threads throughengineOptions(llm-set-options) and a newlocalParallelRequestssetting; changing it reloads the model.
-
Local engine request pooling (
โ 2.2.0 โ Released โ the desktop app self-updates, paging back shows a whole past turn (stats, scene, and choices travel back with the story text), entity groups, stacked choices, and a batch of drag-and-drop, scrollbar, stat-bar, and theming polish (click to expand)
-
๐ค User-facing
-
The desktop app can update itself โ The version number in the menu footer now checks for new releases (on launch, every few hours, and whenever you click it) and shows a " โ Update Available!" tag when there's a newer version. Click it for a dialog with the new version's release notes and a Download button; the download runs in the background with a progress bar under the version number, then turns into an Update & Restart button. A release channel picker in that dialog lets you choose Stable (default) or Pre-release builds. On Windows and Linux the update installs itself; on macOS the Download button opens the new
.dmgfor you to install. (The welcome-animation replay moved off the version number onto the copyright text to make room.)
-
The desktop app can update itself โ The version number in the menu footer now checks for new releases (on launch, every few hours, and whenever you click it) and shows a " โ Update Available!" tag when there's a newer version. Click it for a dialog with the new version's release notes and a Download button; the download runs in the background with a progress bar under the version number, then turns into an Update & Restart button. A release channel picker in that dialog lets you choose Stable (default) or Pre-release builds. On Windows and Linux the update installs itself; on macOS the Download button opens the new
-
๐ ๏ธ Developer tooling
-
Auto-update architecture (desktop). Detection is renderer-side and uniform across platforms:
UpdateServicereads the GitHub Releases API through the existingnet-fetchbridge, filters by theupdateChannelsetting (GitHub'sprereleaseflag), semver-compares againstAPP_VERSION, and caches the last result for an offline fallback. State is a pure reducer (src/lib/updates/updateState.ts) driven byuseUpdateChecker; the footer UI (UpdateVersionControl+UpdateDialog) is mounted only underisDesktop()so the web build never checks. Download/apply are per-platform in the main process (electron/updater.cjs): Linux useselectron-updaterAppImage self-update (addedelectron-updaterdep;publish: githubin electron-builder.yml; the Linux CI step now--publish alwayssolatest-linux.ymlreaches the release, which thepublishjob finalizes); macOS is a notifier (shell.openExternalthe.dmg); Windows uses a new Discord-style launcher. Pure logic is unit-tested (semver, reducer, channel filtering, cache fallback); a render test covers the footer tag + dialog. -
Windows launcher +
/appswap. A tiny Go stub (launcher/) is the stable root exe (the only binary SmartScreen judges); the real Electron app lives in<root>/appand is swapped in place on update, withuserdata/+models/beside the root untouched (FORMAMORPH_ROOTnow takes precedence inportableProfile.cjs/main.cjs).electron/winUpdate.cjsdownloads the/apppayload zip, verifies its sha512, and recordspending.json; the launcher waits for the app to exit, extracts + swaps (appโapp.backup,app.newโapp), relaunches with--just-updated, and rolls back if the new build crashes before writing alaunch-okmarker. The Windows target changed fromportabletodir; CI builds the Go stub and assembles the full-root zip + theapp-<ver>-win.zippayload + its.sha512. -
No world/save export-shape change. The only new persisted state is the
FORMAMORPH_updateChannelsetting; save/world.jsonshapes are untouched.
-
Auto-update architecture (desktop). Detection is renderer-side and uniform across platforms:
-
๐ค User-facing
- Multi-select dropdowns now match the theme like the regular dropdowns. The closed multi-select control used a transparent/off-theme surface and its selected-item chips used hardcoded colors that washed out in light mode. The control now uses the same background, border, and focus ring as a normal dropdown, and the chips and their remove buttons follow the theme.
- Stat-change bars animate consistently everywhere, and respect "reduce motion." The green/red change band and the accent fill now use one shared animation whether the change came from the AI or from paging back through turns, so the two no longer disagree. Turning on your OS "reduce motion" setting makes the bars snap to their final state instead of sliding.
- Ctrl+click (or long-press) a choice to add it to your action instead of replacing it. Clicking a suggested choice still replaces whatever's in the input box, but Ctrl/Cmd+click โ or a touch long-press โ appends the choice as a new sentence, so you can stack a couple of choices into one compound action (e.g. "Draw your sword. Attack the goblin."). Every choice whose text is currently staged in the box stays highlighted, and when you page back to a past turn, every choice you stacked that turn is highlighted (not just one).
-
Entities can be organized into groups (folders). The Entities tab now works like the Traits tab: add a group (a named folder), then drag entities into it, and nest groups inside groups to any depth. Groups are purely for keeping the editor tidy โ they are never sent to the AI and change nothing about gameplay or how entities read in a scene. Imported character cards land ungrouped. (Adds
entityGroups+ an entitygroupId/orderto world exports.) - Paging back to an earlier turn now shows that whole turn, not just its text. Flipping to a past turn used to change only the story text while the stats, location, scene, and character stayed on the latest turn. Now the entire view travels back with you: that turn's stats, traits, location (background and ambient sound included), who was in the scene, its choices, and the character's appearance all reflect how things were on that turn. A "Viewing turn N of M" banner marks that you're browsing history, and the controls that only make sense on the current turn (editing stats, changing location, picking a choice) are disabled while you look back โ narration and Player Notes stay editable on any turn. Return to the latest turn and everything is live again. On a past turn, the choice you took is highlighted โ inferred by matching your next action against that turn's options, so it still lights up even if you reworded the choice before sending it (nothing about your pick is stored). The stat changes for that turn show too โ the green/red +/โ text and the growing bar segment โ and they animate in as you scroll to the turn rather than snapping into place.
-
๐ ๏ธ Developer tooling
-
Dragged list/card items no longer resize while dragging. When rows differed in height (e.g. a save folder with a two-line name), the item being dragged snapped to the size of the row it was over. Cause: every sortable rendered its drag offset with
CSS.Transform.toString, which bakes in dnd-kit's slot-fit scale. Switched all of them (CSS.Translate.toString, translation only) so a dragged row/card/chip keeps its own size โ acrossSortableTree,LoadGameDialog,LocalModelModal,DictionaryTree,DictionarySelectionModal,WorldEditor,SortableWorldCard,Chip/EditableChip. -
Dragging an item to the bottom of a World Editor list no longer jitters/runs away. Inside the editor's Radix
<ScrollArea>, dragging a location/entity/trait to the bottom made the list judder and the view scroll into empty space. Two causes: at the bottom edgepointerWithinreturned no target, so the drop gap flipped on/off every frame (jitter); and the in-flow dragged row extended the scroll height, so auto-scroll chased it downward forever (runaway). Fixed inSortableTreewith aclosestCenterfallback for collisions (never empty) and a Y-only bounding modifier (restrictYToScrollAncestor) that clamps the drag's vertical position to the viewport but leaves X free โ so auto-scroll stays bounded while horizontal depth-nesting still works. Audited every otherDndContext: none need it (they're vertical-only/grids bounded byrestrictToFirstScrollableAncestor, or have auto-scroll off). -
Scrollbars: converting native
overflow-*containers to Radix<ScrollArea>to drop the OS arrow buttons. On Windows, Chromium renders up/down stepper arrows on native scrollbars that CSS cannot remove (::-webkit-scrollbar-button { display: none }andscrollbar-widthare both ignored for the buttons). The only button-less scrollbar is Radix<ScrollArea>, which hides the native bar and draws its own thumb. Converted so far: the Community Creations results list, the changelog body (ChangelogUi), the local-model recommended list + settings panel (LocalModelModal/LocalModelPanel), all sixSettingsModaltab/section scrollers (presentation, generation, endpoint, image, prompt-options, accessibility), and the Character Customization card. Also converted (harder cases): the drag-and-drop lists inLoadGameDialogand the installed-models list (LocalModelModal) โ the sortable rows now live in the ScrollArea viewport, whichrestrictToFirstScrollableAncestorstill finds;EntityModal(its short-content vertical centering degrades to top-aligned inside the overlay); and bothThemePreviewDialogcolumns. Still deferred:<textarea>s / editable-div editors (MarkdownField,PromptField, the prompt boxes โ need auto-grow + wrapper, an app-wide UX call), the responsive dual-columnWorldDetails(splitColumnClassesscrolls one column on mobile, two on desktop โ needs a per-breakpoint restructure), horizontal-scroll code blocks (the shared<ScrollArea>is vertical-only by design), the 3D-model viewer tab (canvas owns the wheel), and the popover-hosted lists (command/multi-select/SuggestionListโ scroll-lock interactions). No behavior change beyond the scrollbar visual. E2E coverage for these (which jsdom can't provide) is a tracked follow-up. -
Stat bar animation unified onto one tested geometry (
statBar.ts). The live and history paths had diverged โ different delta sources (held/drainvsviewStatChanges) and different fill mechanisms (plain width transition vs keyedstat-fill-slide) โ which is why the bars kept breaking with each change. Both now feed the same purestatBarFrame(prevValue, curValue, min, max)โ{ curPct, prevPct, bandLeftPct, bandWidthPct, hasBand, gain }plusbandOrigin(gain, draining), and the accent always slides prevโcur viastat-fill-slide(drain leaves the accent put and collapses the band toward the current value).StatBarnow takes(value, delta, draining, animKey)instead of(delta, drainDelta, animKey). Reduced-motion snaps to final (no slide/grow, no drain band) viausePrefersReducedMotion. Geometry is unit-tested (statBar.test.ts: gain/loss/no-change/clamping/sub-pixel/origins). No save- or world-shape change. -
Entity groups (editor-only folders; the simplest version). Replaced the earlier entity-nesting model with distinct
EntityGroupfolders, mirroringTraitGroup/traitTreeexactly.EntityGroup { id, name, parentId, order }(nestable folders);Entityswaps its oldparentIdfor a singlegroupId+order. A newentityGroupTree.ts(a 1:1 port oftraitTree.tsminus the AI-context builder โ groups never feed the AI) backs a rewrittenEntityTreeon the sharedSortableTree; the World Editor's Entities tab gains an Add-Group/Add-Entity popover.GameDataContextgainsentityGroups+addEntityGroup/updateEntityGroup/removeEntityGroup(deleting a group reparents its children). Selecting a group opens a name-onlyEntityGroupManager(mirrors the traitGroupManager).buildEntityContextreverted to the flat pre-grouping output (groupId/orderdestructured out) โ the entity context is byte-for-byte what it was before grouping. Card imports resetgroupIdto null. Deleted the oldparentIdTree.tsand the nesting tests; addedentityGroupTree.test.ts. World-export-shape change (entityGroupsadded,Entity.parentIdโgroupId+order); no save-shape change. Since 2.2.0 is unreleased, no migration is shipped โ old entity-nesting silently drops. -
Per-page "viewed" state in
GameplayContext(isViewingPast+view*selectors). Instead of mutating live state on page change (which would re-introduce the edit-clobbering bug), the context derives read-onlyviewStats/viewTraits/viewCharacterData/viewVisibleEntities/viewGameTime/viewLocationId/viewChoices/viewStatChangesfrom the paged snapshot โ each aliases the live value on the latest page, so normal play is provably unchanged and only past pages read the snapshot. Panels consume them via destructure-aliases (minimal churn) and gate their mechanical controls onisViewingPast. The body-morph effect now derives fromviewStats(avatar re-morphs to the paged turn); background reads the resolved viewed location; a new ambient effect keyed onviewLocationIdfollows the viewed location and fixes a pre-existing gap whereloadGameStateset the location without its ambient sound. No save-shape change. Verified in the preview via thewhiteRoom3-turn fixture: cue/narration/choices/edit-disabling all track the paged turn; the latest page stays live. -
Inferred choice highlight on past turns (
matchChoiceToAction,choices.ts). A past page marks which choice the player took by fuzzy-matching the next turn's action against that turn'sviewChoicesโ word-set SรธrensenโDice similarity with a 0.5 confidence threshold (returns-1for a custom action), tolerant of reworded/reordered choices, glue words dropped, markdown/punctuation stripped. Derived, never stored. Exposed asviewSelectedChoice; the choice render uses it forisSelectedon past pages (the live page keeps itschoice === playerInputselection). Pure + unit-tested (choices.test.ts), and preview-confirmed on the fixture (the highlighted choice matched the actual action taken). -
Animated stat deltas on past pages. Reviewing a past turn feeds that turn's derived change (
viewStatChanges, computed vs the previous page's stats) into the sameStatBargrow band and delta text as a live turn, so the green/red bar ++/โtext render for the reviewed turn. The delta is computed by a purepageStatDeltas(stats, prevStats)(statChanges.ts): each stat's value minus its previous-turn value, or โ on the opening turn, which has no predecessor โ itsstartingvalue (elsemin), so turn 1 shows its deltas too instead of nothing. Newstat-delta-text-inCSS class (fade/slide-in and hold, unlike the live 10s fade-out);StatBargained ananimKey(the page number) so the band re-animates on each scroll-back even when a stat's value/delta coincide between turns; the delta text span re-keys oncurrentPagefor the same reason. While reviewing history the base fill also slides via a keyedstat-fill-slideCSS-var animation (--fill-from/--fill-to= the turn's previous โ current value) instead of a plainwidthtransition โ so it always starts from the correct origin and stays in step with the grow band, rather than sliding from whatever page you scrolled from (which left a gap on backward navigation). Live keeps the plain transition (you always arrive from the previous turn there). The DEVwhiteRoomfixture now carries two stats with per-turn changes so the stats view + deltas are actually exercised (preview-verified:+2green andโ10red on turn 2, both with animate-in text and matching grow bands; the latest page stays on live deltas). โ Starting Formamorph briefly popped up a black terminal window before the app appeared; it no longer does, and the launcher now shows the Formamorph icon instead of a generic one - Updates no longer leave a full-size backup copy behind (Windows) โ After a successful update the previous version was kept on disk, roughly doubling the install size; it's now removed once the new version is confirmed healthy
- Re-generate and Rollback no longer undo your edits to the previous turn โ Editing a turn's narration or your Player Notes and then re-generating (or rolling back to that turn) reverted it to the original, unedited text. Both now keep your edits โ they restore the turn's game state without throwing away what you changed.
-
Stat change bars show the real change and match between the current turn and history โ The current turn's green/red delta came from the amount the AI asked to change a stat, while a past turn showed what actually changed; when a change was capped (stat already at max/min) or otherwise limited these disagreed โ a maxed stat could still flash a "+N" bar. The live bar/text now use the actual applied movement (
appliedStatDeltas, computed from the clamped result), so they line up with the history view everywhere. Unit-tested incl. the capped-stat case. - Editing a turn's narration now refreshes what the AI remembers of it โ A turn's memory summary and character diary entries are built from its narration; after you edited that narration they were left describing the old text. Editing now clears them so they quietly rebuild from what you wrote.
- Page footers' Previous/Next buttons no longer jump around as you flip, and the current page is clearly highlighted. As you paged, the strip of page numbers changed how many were shown (more in the middle, fewer near the ends), shifting the Previous and Next buttons left and right. Every pager โ the story turns, the AI-context viewer, and the Community Creations browser โ now reserves its widest layout at all times, so the buttons stay put. The low page numbers hug the left, the high ones anchor to the right, and a single "โฆ" sits centered between them. The page you're on now shows as a filled button so it's obvious at a glance.
- You can recover after a local model fails to load (desktop) โ If the built-in engine reported a context size too large for VRAM, the panel got stuck: lowering Context Size (or GPU Layers) did nothing because the Save & Reload button only worked while a model was already loaded. Now, after a failed load, the button re-enables so you can lower the setting and retry, and a hint points you at what to change. (The GPU Layers slider under Advanced already lets you partially offload a model that doesn't fully fit VRAM.)
-
Dragged list/card items no longer resize while dragging. When rows differed in height (e.g. a save folder with a two-line name), the item being dragged snapped to the size of the row it was over. Cause: every sortable rendered its drag offset with
-
๐ ๏ธ Developer tooling
-
Local-engine reload isn't gated on
status === 'ready'anymore.LocalModelPanel's Save & Reload was disabled unless the engine wasready, so astatus: 'error'load (e.g. node-llama-cpp's memory-safety check rejecting a too-large context) trapped the user โ the lowered Context Size never reachedengineOptionsinmain.cjs. The enable check is now "settings differ on a ready model, or the last load errored"; the error branch also relabels the button "Retry with these settings" and shows an out-of-VRAM hint. Renderer-only; the underlying node-llama-cpp VRAM estimate is unchanged (its rejection was legitimate). -
Rollback/Re-generate restore mechanical state only; narration + notes come from the live history. Each
gameStates[i]snapshot froze a full copy offullMessageHistory+playerNotesat turn-end, andloadGameStatere-injected those stale copies on restore โ clobbering any edit the player made afterward.loadGameStategained akeepLiveHistoryoption;handleRollback/handleRegeneratenow restore the snapshot's mechanical fields but rewind narration by slicing the live flat history (sliceHistoryToPage,turnHistory.ts) and leave notes live. Restore logic only โ no save-file shape change. Unit-tested (turnHistory.test.ts), incl. a regression that a kept turn's edit survives the slice. -
Narration edits invalidate the turn's derived memory (
clearTurnDerived).EditTextModal onSavedrops the edited turn'ssummary+diaries(turnDigest.ts, keyed byturnId) so the digest/diary drainers re-select and rebuild the turn. Scoped to narration edits only โ Notes edits and the choices/stat partial-regens don't feed the digest or diaries, so they don't force a rebuild. Unit-tested (turnDigest.test.ts). - A past page with no saved snapshot no longer claims to show history. If a save's per-turn snapshots were short of its page count (e.g. a converted deep-nested legacy save), paging back flagged history mode โ banner, disabled controls โ while the stats/location/notes silently fell back to the current turn. A page now counts as history only when it actually has a snapshot; otherwise the panels stay live (matching the pre-page-view behavior) instead of contradicting themselves. Verified: removing a page's snapshot flips it out of history mode with the view state equal to live.
- Code-derived stats now animate their change live, like every other stat. A stat computed by code (e.g. a level derived from XP) updated its value but showed no green/red delta band on the turn it changed โ while paging back to that turn did show it. The live feedback now folds in the code-recompute delta, so live and history agree. (Only affects worlds using stat code.)
- Editing an unparseable past turn's notes no longer wipes its narration. If a turn's stored JSON couldn't be parsed, saving a notes edit on that turn wrote a blank stub โ destroying its narration, choices, and turn id. The notes write now bails when the turn doesn't parse (leaving it untouched), matching the digest/diary paths. Verified: a corrupt turn's content is left intact on a notes edit, while a normal turn still takes the note with narration/choices preserved.
-
A fresh world (or an aborted opening turn) no longer falsely shows "viewing history". With an empty message history the page counter landed on 0 while the total was floored to 1, so
currentPage < totalPagesflagged history mode โ showing a "Viewing turn 0 of 1" banner, disabling choices/location/edit, and dropping Player Notes edits (the write targeted message index โ1) before the game even started. The current-page follow now floors at 1. Verified: empty history โ page 1, not viewing past, no banner. -
Stat bars no longer animate away twice when you submit an action. On submit the green/red band drained once, then instantly re-grew and drained again. Cause:
currentPagewas synced to the latest turn by a post-render effect, so appending your action grew the page count a frame beforecurrentPagecaught up โ flipping "viewing history" true for one render and remounting the stat band.currentPageis now derived (GameplayContext): it follows the latest turn automatically unless you've paged back, so it can't lag and the flip is gone at the source โ fixing every panel, not just the bars. Verified by counting band mounts across a real turn:grow โ drain(one), previouslygrow โ grow โ drain. Side effect (intended): paging back during generation is now sticky โ a new turn no longer yanks you to the latest until you navigate or take an action. - Loading a save shows the real stats, not the world's starting values. After loading a mid-game save, the live stats panel could briefly (or persistently) show the world's default values instead of the saved ones, because a reactive effect re-seeded stats from the world whenever its definition loaded โ racing with the save restore. Seeding now happens once, only when starting a fresh game (the load path is separate), so a loaded save's stats are never overwritten. Verified: loading a save shows its saved stat values, with each stat's game-start baseline intact.
- Code-driven stats no longer wipe a turn's regen. After a turn, applying stat code re-set the whole stats array from a snapshot taken before the code ran โ so if regen (or a re-generate) landed in between, it was overwritten. The code result now updates only the stats the code actually moved, layered onto the latest values, so regen and concurrent changes survive.
-
Turn/page internals cleanup. The paged turn's assistant message was JSON-parsed twice per render (once for choices, once for notes) โ now parsed once. The pageโmessage index math (
pageยท2โ1for the turn's narration,pageยท2for the next action) is centralized in testedpageAssistantIndex/pageNextActionIndexhelpers instead of being re-encoded across the view selectors, rollback, and re-generate. No behavior change. -
Stat bars don't double-grow / lose regen from a redundant recompute. Separately, after a turn
processStatCodealways handed back a fresh array even when no stat had code, soapplyStatChangesre-set the stats a second time โ from a pre-regen baseline โ replaying the grow and wiping that turn's regen.processStatCodenow returns the original array when nothing changed, so the redundant update is skipped. Unit-tested (same reference on no-op, new reference on a real code change). -
The opening turn's stat changes read from the world's starting values, not zero. Page 1 always showed green "+full value" bars because it had no earlier turn to compare against and fell back to 0. Each stat's game-start value is now recorded (
starting, populated at game init from the world default), so the first turn's green/red deltas reflect what actually changed from the world's starting stats. Additive save-shape (startingon saved stats); old saves without it fall back to the previous behavior on their opening turn. Preview-verified: turn 1 shows +2/+5 from the starting values instead of +5/+30 from zero. -
Rollback/Re-generate now carry the target turn's own notes, not the latest turn's. With per-turn notes,
handleRollback/handleRegeneraterestore withkeepLiveHistory(which skips the snapshot's notes), so the live scratchpad still held the pre-rollback latest turn's notes โ a re-generate then froze those onto the fresh turn. Both handlers now seedplayerNotesfrom the target turn's messagenotes(parseTurnContent(...).notes ?? '') after the restore. Preview-verified: rolling back to a turn seeds the scratchpad with that turn's notes. -
Per-turn Player Notes (
AITurnResult.notes, additive save-shape). Notes moved from the single globalplayerNotesscratchpad to a per-turn field frozen onto each turn's assistant-message JSON when the turn commits (omitted when empty).GameplayContextgainedviewNotes(current page โ live scratchpad; past page โ that turn's messagenotes, falling back to the snapshot's globalplayerNotesfor old saves) and a page-awaresetViewNotes(edits on the current page write the live scratchpad; on a past page they patch that turn's message only). The Notes tab consumes both; notes stay editable on any page. The live scratchpad is still what the AI receives each turn, so send behavior is unchanged. No migration needed โ the snapshot-playerNotesfallback covers pre-existing saves. Preview-verified on thewhiteRoomfixture: a note typed on the current page doesn't bleed to past pages, a note typed on a past page persists to that turn only (confirmed on the message JSON) and not its neighbors, and the live page keeps its own note. -
Paging back mid-generation no longer drags the streaming narration onto the history page. While a turn was still generating, flipping to an earlier turn kept printing the new narration onto the page you were viewing. Generation now continues in the background while you browse history โ the past page shows that turn's finished text โ and the live narration reappears when you return to the current turn. Fix was render-only (
MiddlePanel): the live reveal (narration + reasoning) is now gated on being on the current page (!isViewingPast), not just on a page-local "last message" flag. - Player Notes are now per-turn โ each turn keeps its own notes as you page back. Notes used to be one shared box for the whole game; paging to an earlier turn still showed (and edited) that single global note. Now every turn carries the notes it had, so flipping back shows that turn's notes and you can edit them there without touching any other turn. On the current turn the box works exactly as before (a running scratchpad the AI sees), and Re-generate carries your notes onto the fresh turn. Old saves still load โ a turn with no saved per-turn note falls back to the save's previous global note.
-
Save files no longer store the chat history N times over (storage cleanup + migration). Each per-turn snapshot (
currentState+ everystateHistoryentry) embedded its own copy of the whole flat message history โ O(Nยฒ) growth on disk. The envelope now carries one canonical top-levelmessageHistory; snapshots are stripped of theirfullMessageHistorycopies (stripSnapshotHistory,version.ts), andsaveGamewrites the new shape. Loading reconstitutes the live current state from the canonical history, so narration/rollback are unchanged. Save-file shape change with a presence-based migration in the sharedmigrateSavepath (import + load): any old save โ legacy numericversion: 2or a string-version save written before this change โ has its history hoisted and copies stripped on load, idempotently; old saves still load. The import boundary now runsmigrateSavefor every envelope (not just numeric-legacy). Unit-tested (saveCompat.test.ts: hoist + strip on legacy and string-version saves, idempotency, canonical-history correctness); preview-verified thewhiteRoomfixture (an old-shape save) still loads all 8 turns through the migration with the live history intact. -
Shared, non-reflowing page footer (
Pager). Three pagers โ game turns, the AI-context debug viewer, and the community browser โ each carried their own copy of the same item loop (first, last, ยฑ1 window, ellipsis gaps), so the centered strip's width, and thus Previous/Next, shifted while flipping. Extracted onePagercomponent (components/ui/pagination.tsx) over a purepaginationSlots(currentPage, totalPages)(pagination.ts) that returns a constant number of cells (min(totalPages, 7)), padding short/near-edge layouts with invisible spacers; every cell is normalized to the same width (linksw-10, spacersh-10 w-10, ellipsis overridden tow-10). Constant count ร equal width โ invariant strip โ the buttons never reflow. All three call sites now render<Pager page pageCount onPageChange />โ the three duplicate loops (~60 lines) are gone. For near-edge layouts the spacers are distributed, not tail-padded: low pages left, high pages right-anchored.Pagercoalesces each contiguous spacer+ellipsis run into one flex cell (width = the cells it replaces, incl. inter-cell gaps, so the strip width is unchanged) with the ellipsis centered โ so a lone ellipsis sits dead-center of the gap even when the groups are lopsided (1 2 3 4 โฆ 8), not jammed against a cluster. The active page uses the filleddefaultbutton variant (was the near-invisibleoutline). Pure logic unit-tested (pagination.test.ts); the DEVwhiteRoomfixture extended to 8 turns to exercise it โ preview-measured: ellipsis 0px off gap-center, Previous/Next pixel-identical across pages, active pagebg-primary.
-
Local-engine reload isn't gated on
โ 2.1.0 โ Released โ reasoning-model support (Native mode, endpoint-aware effort, per-prompt reasoning, desktop thought budget, live reasoning block), shareable prompt presets, a first-run welcome animation, and a halved Vulkan-only desktop build (click to expand)
-
๐ค User-facing
-
Share prompt presets โ Settings โ Prompts now has an Export button and an Import Presetโฆ entry in the preset dropdown. Export gives you a copy-paste share code (for Discord/chat) or a
.jsonfile; the whole preset travels โ all prompt text, its section style, and its per-prompt tuning (samplers, reasoning, verbatim turns). You can export any preset, including the built-in Default/Simple as starting points. Import lets you pick a file or paste a code, previews the name and any "made for a different version" note, and lets you choose prompt text only vs text + tuning; if the name already exists you can overwrite it or keep both. Presets made in another version import as-is โ anything the app doesn't recognize is skipped. -
A first-run welcome animation โ The first time you ever launch Formamorph, a "Welcome Toโฆ" title types in and a field of liquid "goo" pops up, gathers together, and merges into the Formamorph title before handing off to the menu. It plays once (never again automatically) and respects your reduced-motion setting (shows a still title instead). Click the tiny version number in the footer to replay it any time (snappier the second time around).
-
Native Reasoning control for reasoning models โ The Thinking mode formerly called Off is now Native โ it adds nothing to the prompt, so a reasoning model thinks as it normally would and everything else answers immediately. When Native is selected, a Native Reasoning control appears with Default (send no hint โ the endpoint decides), None (stop a reasoning model from thinking so it answers directly), and effort levels. The levels shown are detected from your endpoint โ the app quietly asks it which it accepts on connect, so you only see options that actually work (e.g. some endpoints offer Min, others Max). While it's still probing (or your endpoint is offline) it shows the universally-safe set. All of it is ignored by models without native reasoning.
Reasoning is no longer an all-or-nothing switch: it's per-prompt. The Narration and Choices prompts each get their own Native Reasoning control in their Options tab (Settings โ Prompts โ pick prompt โ Options) โ narration follows the endpoint-wide level (Global) by default, choices is None. Every other prompt (stat updates, location, summaries, images, the planning stages) never reasons, so a reasoning model won't waste time thinking on bookkeeping. And the Inline / Planning / Staged thinking modes now turn a reasoning model's own thinking off entirely, so it doesn't fight Formamorph's planning step.
-
Reasoning Budget on the built-in desktop engine โ When you run Formamorph's own bundled model (desktop app, no custom endpoint), the reasoning control in Narration/Choices Options becomes a Reasoning Budget slider โ the share of Max Output Tokens the model may spend thinking before it writes (0% = no reasoning). It's an exact, graceful cap: the model wraps up its thought and gets on with the scene, so you trade a little planning for a lot less waiting. Narration defaults to 40%, Choices to 0%. (This precise budget isn't available over most external endpoints โ those keep the coarse effort control instead.)
-
See a reasoning model's thinking โ When a reasoning model (or the Inline thinking mode) thinks before it writes, that scratchpad now shows as a collapsible "Thinkingโฆ" note above the turn's narration โ it streams live while the model thinks, then folds up to "Thought for Ns" once the story begins. Click to re-open it any time; it's saved with the turn, so it's there on old turns and in scrollback too. A new Show Reasoning toggle (Settings โ Generation, on by default) hides it if you'd rather not see it โ the reasoning is still captured, so turning it back on reveals it on past turns.
-
-
๐ ๏ธ Developer tooling
-
Inline thinking reliably opens the
<think>block now (placement fix). The Inline-mode reasoning directive was buried at the end of the narration system prompt, where small models mostly ignored it โ so the new "See a reasoning model's thinking" block often stayed empty in Inline mode. RewroteINLINE_THINKING_DIRECTIVEto a tight 3โ4-bullet continuity structure and moved it onto the final player-action turn (adjacent to where the model writes, same trick the Planning plan uses). A new A/B probe (testing/baseline/harness/inline-probe.mjs, 9 gold scenarios ร 2 runs on the reference tiers) isolated the cause: the reworded directive still in the system prompt emitted<think>on only Silver-Siren 2/18 ยท MeroMero 5/18, but the same text on the user turn hit 18/18 on both, every one followed by clean narration with no bullet-leak into the prose. Recency โ not wording โ was the lever. Prompt/plumbing only; no version/save/export impact. -
Desktop build roughly halved โ Vulkan-only GPU backend (CUDA dropped). electron-builder now excludes node-llama-cpp's CUDA backends (
@node-llama-cpp/*cuda*, ~537MB), the unused Windows arm64 backend, and the from-sourcegitRelease.bundle. Reason (investigated, not assumed): the CUDA prebuilts need CUDA redistributables that don't ship with node-llama-cpp and aren't on normal systems, and even with the exact-matching redist the prebuilt initializes 0 devices on a reference RTX 4090 + CUDA-13.3 driver โ a liability that only balloons the download while showing a "CUDA not compatible, falling back" warning every launch. Vulkan runs on every GPU including NVIDIA (it's what the reference machine used all along), so it's the shipped GPU backend with CPU fallback. A clean Windows--dirbuild measured 548MB, down from 1134MB (โ52%), and boots. A dedicated CUDA build (bundling the redistributables) can come later. Config only โ no code/version/save impact. -
Desktop package ~350MB lighter (renderer deps no longer double-shipped). Every renderer library (react, three, kokoro, onnxruntime, Radix, fontsโฆ) was listed in
dependencies, so electron-builder auto-packed its rawnode_modulescopy into the app โ dead weight, since Vite already bakes them intodist/. All renderer-only packages moved todevDependencies;dependenciesnow holds justnode-llama-cpp(the one package the Electron main process loads at runtime โ verified by grepping everyrequire/importinelectron/*.cjs). Verified on a real--dirbuild:app.asaris 52MB (โ dist + app code), its 92 packages are node-llama-cpp + transitive deps only, zero renderer packages remain, and the built exe boots. Faster launch for the portable exe (that much less to extract + virus-scan every start) and a smaller download. -
reasoningEffortsetting + Native rename.ThinkingMode'soffvalue is relabeled Native in the UI (the stored value stays'off', so no settings migration); addedReasoningEffort = 'none' | 'auto' | 'low' | 'medium' | 'high'(SettingsContext,FORMAMORPH_reasoningEffort, defaultauto;nonewill sendreasoning_effort: "none"to suppress native thinking). The Settings โ Generation โ Thinking block gains a Native Reasoning tabs widget rendered only under Native (thinkingMode === 'off'), mirroring the Thinking widget's stacked-help layout. All of the reasoning-effort logic is pure + unit-tested insrc/lib/reasoningEffort.ts:-
Endpoint capability detection.
detectSupportedReasoningEfforts(url, token, model)probes each candidate (none/minimal/low/medium/high/xhigh/max) with a 1-token request and keeps the 200s (400 = rejected; any network/auth error โnull= inconclusive, keep fallback).SettingsContextruns it debounced on connect and remembers eachendpoint|model's result in a bounded map (FORMAMORPH_reasoningSupport, cap 30, oldest evicted), so flipping between endpoints or swapping the model on one reads from cache instead of re-probing; it exposessupportedReasoningEffortsfor the active pair.reasoningTabs(supported)builds the tabs โ always Default (omit) first, then the supported levels in canonical order โ falling back to the universalnone/low/medium/highwhen the set is unknown. Verified live: cloud accepts all seven; Ollama acceptsnone/low/medium/high/max(nominimal); an offline endpoint falls back to the safe set. -
Request wiring.
reasoningEffortBody(mode, effort, supported)is spread intomakeAIRequest's body: under Native it sendsreasoning_effortverbatim and omits it forauto(send nothing โ endpoint default; the literalautoisn't a valid value anywhere โ every backend 400s on it); under the guided modes (Inline/Planning/Staged) it forcesnoneso a native model's own thinking doesn't fight the guided step. In every case a value the detected set doesn't include is omitted, sononecan't 400 a non-reasoning endpoint and a stale selection can't 400 a turn after switching endpoints. -
Per-prompt override.
resolvePromptReasoning(kind, prefs, globalEffort)picks each request's effort under Native mode:REASONING_CONTROL_KINDS(narration, choices) read their stored choice (now preset-scoped viaactiveReasoning, defaults narration=global, choices=none), every other prompt is forced tonone;globalfolds in the Generation-levelreasoningEffort(which no longer applies to requests on its own). The control renders in the prompt's Options tab (PromptReasoningField) only under Native mode for the two controlled kinds, disabled under the built-in Default preset.makeAIRequestfeeds the resolved value throughreasoningEffortBody. -
Reasoning block (capture + persist + UI, additive save-shape). The narration stream now captures reasoning from both sources โ a native model's separate
reasoning/reasoning_contentdelta field and inline<think>blocks in content (extractReasoning/extractReasoningLive, unit-tested) โ with think-duration timing (first token โ first narration token). It's persisted into the per-turn assistant-message JSON asreasoning: { text, ms }(AITurnResult) โ an additive save-shape change (absent when the model didn't reason / on pre-2.1.0 saves; presence-based, old saves load fine). Live streaming rides an external store (reasoningStreamStore, mirroringgameplayTextStoreso per-token updates don't re-render gameplay).ReasoningBlockrenders it above the narration (MiddlePanel): expanded + "Thinkingโฆ" whileactive, auto-collapsing to "Thought for Ns" when narration begins, full markdown in a de-emphasized container. Gated by theshowReasoningsetting (default on; captured regardless, so toggling on reveals past turns). Verified: model emits<think>/reasoning (node-confirmed), helpers parse it (unit tests), the app correctly captures nothing when the model emits nothing, and the block renders/collapses/styles correctly (preview, injected data). -
Reasoning Budget (local engine โ thought-token cap). The reasoning control is engine-split in the Options tab: external endpoints show the effort level; the bundled desktop engine (
localModelActive) shows a Reasoning Budget slider (0โ100% of max output).reasoningBudgetBody(mode, kind, budgets, maxTokens)โ{ thinking_budget_tokens: round(pct% ร maxTokens) }, spread intomakeAIRequest's body only whenlocalModelActive; guided modes and uncontrolled prompts resolve to0.electron/llmEngine.cjsmapsbody.thinking_budget_tokens โ session.prompt({ budgets: { thoughtTokens } })(node-llama-cpp; verified exact โ capping to 200/100/40/0 landed on the cap, narration stayed coherent, latency scaled). This also fixes a gap: the local engine ignoresreasoning_effort, so guided-mode/None suppression is now enforced there viathinking_budget_tokens: 0. Per-prompt %, preset-scoped (reasoningBudgetonPromptPreset, defaults narration 40% / choices 0%), travels with preset export/import. Preset-share shape change (additive): the shared preset.json/code gains an optionalreasoningBudgetmap (older imports drop it;FORMAT_VERSIONunchanged). Pure logic unit-tested; UI (engine swap + defaults) verified in preview by forcing the local branch. Desktop end-to-end confirmed by driving the realllmEngine.cjsserver standalone:thinking_budget_tokensof omit/200/40/0 capped the<think>block to 1785/873/161/0 chars, narration stayed coherent at every budget (incl. 0).
-
Endpoint capability detection.
-
Intro sequence (
src/components/IntroSequence.tsx) โ a self-contained Canvas 2D + SVG-metaball-filter animation. Two layers (themed background + a blob layer behind afeGaussianBlur+feColorMatrixgoo threshold), with the real letterforms drawn onto the filtered layer so they read crisply while keeping liquid edges; the word's alpha ramps through the threshold across the whole hold so it appears to set out of the goo rather than fade in. Colors are pulled live from the app's CSS theme tokens (--primary/--card/--background), so it adapts to the active skin. The puffy title face is a ~2KB Baloo 2 subset embedded as base64 (src/lib/introFont.ts), loaded viaFontFacebefore canvas sampling. First-run gating is alocalStorageflag inApp.tsx(cinematic first, snappy on replay); the overlay mounts over MainMenu and fades its backdrop out on hand-off to reveal the menu underneath. Reachable for verification via the dev-router (#dev?modal=intro).
-
Inline thinking reliably opens the
-
๐ ๏ธ Developer tooling
-
Cross-version chip compat locked (import safety). Confirmed + regression-tested that the prompt tokenizer already treats any chip it doesn't recognize as literal text โ so a preset imported from a different app version can't blank, drop text, or crash the editor/preview/runtime; a chip added in a later version just shows as its raw
<โฆ>token until that version is installed.parsePromptTemplate's registry-onlyTOKEN_REis the single chokepoint (editor, preview,renderPromptTemplateall route through it); new tests inpromptTemplate.test.tscover a foreign base and a foreign variant across parse/serialize/render. No behavior change. -
Prompt-preset export/import UI.
PresetShareDialogs.tsx(ExportPresetDialog+ImportPresetDialog) wired into Settings โ Prompts: an Export button (materializes the selected preset โ built-ins included โ viaexportActivePreset(APP_VERSION)) and anImport PresetโฆSelect sentinel. Import parses a file or pasted code (parseSharedAny), previews name + non-blocking warnings, offers texts-only vs +tuning and overwrite-vs-keep-both on a name clash, thenimportPreset(imported, opts)adds (addFullPreset) or overwrites (replacePreset) and selects it. Export/import round-trip verified live (code decodes to the full stamped preset with tuning; import created a distinct copy, showed the version warning, handled the collision). Not an export-shape change to worlds/saves โ a new shareable format (like character cards). -
Prompt-preset share codec (
src/lib/promptPresetShare.ts). Serializes a preset โ 15 texts + section style + preset-scoped tuning (samplers/reasoning/verbatim) โ to a pretty.jsonfile and a copy-paste share code (UTF-8-safe base64 behind anFMPRESET1:prefix). Stampskind+formatVersion(1) + the sourceappVersion. Parsing sanitizes: keeps only recognized text keys and well-typed tuning, silently drops the rest (with a warning), and a source-version/newer-format mismatch warns but still imports as-is (the agreed "import as-is + warn" contract). Groundwork for the import UI; not wired to any surface yet. Unit-tested (round-trip, unicode, junk rejection, unknown-key drop, version/format warnings). Part of the shareable-preset feature (seedocs-internal/prompt-preset-sharing.md). -
Per-prompt tuning is now preset-scoped (settings-store change). Samplers, per-prompt reasoning, and verbatim-turns moved from three global stores (
FORMAMORPH_promptSamplers/_promptReasoning/ the six_*VerbatimTurnskeys) onto eachPromptPreset(samplers/reasoning/verbatimfields insrc/lib/promptPresets.ts) โ groundwork for shareable "prompt packs". Built-in presets carry no tuning (resolve to shipped defaults; their tuning setters no-op like their text).SettingsContextderives the three viaactiveSamplers/activeReasoning/activeVerbatim(presetStore)and routes setters throughupdateSamplers/updateReasoning/updateVerbatim(no-op under a built-in) โ the public context field/setter names are unchanged, so GameViewer/SettingsModal consumers are untouched. A one-timemigratePromptTuning()folds the old global tuning onto every user preset (viafoldTuningIntoUserPresets, never overwriting an existing per-preset value, only carrying non-default verbatim), then retires the old keys behind a_promptTuningMigratedmarker. Behavior change: switching presets now swaps tuning too (previously text-only); a user on a built-in with custom tuning reverts to defaults there (by design). Pure logic unit-tested; migration verified live. Settings-store only โ not world/save export shape.
-
Cross-version chip compat locked (import safety). Confirmed + regression-tested that the prompt tokenizer already treats any chip it doesn't recognize as literal text โ so a preset imported from a different app version can't blank, drop text, or crash the editor/preview/runtime; a chip added in a later version just shows as its raw
-
๐ค User-facing
- The welcome animation now matches your theme โ On a dark theme the first-run intro rendered on a white background with the "Welcome Toโฆ" text invisible (white on white). It read the theme's colors a moment too early โ before your theme was applied โ so it always grabbed the light-theme palette. It now tracks the active theme, so the intro shows on the correct background with readable text in light or dark.
- The desktop app now works with local AI servers that don't send CORS headers โ On the desktop build, pointing at a custom endpoint whose server has CORS turned off (e.g. some LM Studio setups) failed every request with a generic "Failed to process AI request" โ the browser layer blocked the call before it left the app, so no error detail ever surfaced. The desktop app now handles this itself, so a custom endpoint works regardless of the server's CORS setting. (The browser build still needs the server's CORS enabled โ that's a browser rule the web version can't bypass.)
-
๐ ๏ธ Developer tooling
-
Intro sequence samples theme tokens inside the rAF loop, not once at mount.
IntroSequenceread--background/--card/--primaryviagetComputedStylein its mount effect โ which fires before the parentThemeProvider's effect adds the.dark/.lightclass, so it always caught the classless:root(light) defaults, while the kicker's livetext-foregroundflipped to the applied theme โ white-on-white on a dark theme.readColors()now runs at the top of eachframe()(andrenderStatic(), whose reduced-motion call is deferred one rAF), so the canvas tracks whatever theme CSS currently resolves โ in lockstep with the CSS-driven kicker, and self-healing if the OS theme flips mid-intro. Verified deterministically (rAF is throttled in a hidden preview tab, so live capture is unreliable): reproducing the token math showed the classless read yields--background: hsl(0 0% 100%)(white) vshsl(220 12% 10%)(dark) with.darkapplied. -
Desktop CORS shim. The renderer's
app://localorigin made every external fetch (custom LLM endpoint, community server, Hugging Face) browser-CORS-gated; a server with CORS off failed the preflight (noAccess-Control-Allow-Origin) and surfaced only as a network-levelTypeError: Failed to fetchโ the generic toast.electron/main.cjsnow rewrites external http(s) responses (including theOPTIONSpreflight) viasession.defaultSession.webRequest.onHeadersReceivedto carry permissive CORS headers โ keeping the normal streaming fetch and leavingwebSecurityon (notwebSecurity: false, which would drop cross-origin protections app-wide given the app loads community-authored worlds). The pure header transform lives inelectron/corsShim.cjs(Authorizationis listed explicitly becauseAccess-Control-Allow-Headers: *excludes it per spec); Node-verified. The web build is unaffected โ browsers enforce CORS, so hosted users still need the server to send the headers.
-
Intro sequence samples theme tokens inside the rAF loop, not once at mount.
โ 2.0.3 โ Released โ per-world save folders, per-prompt sampling & rebuilt Planning mode, portable-build data folder, smoother narration reveal, and a batch of editor/menu UX fixes (click to expand)
-
๐ค User-facing
-
Saves are now organized into per-world folders โ Opening Load Game drops you straight into the current world's folder, listing only its saves โ the common case is now one step. A Back button (under Import) and a
Saves / <world>breadcrumb take you to the root, where every world with saves has a folder (the current world is always first, even when empty), each showing its save count and when it was last played. Saves and folders can be dragged into any order by a handle on the left, and new ones always appear on top. On a save row, Download and a โ delete (which now asks before deleting) sit on the right. Loading a save from a different world now actually switches to that world (after a confirmation) instead of loading in place โ or, if that world isn't installed, warns you it'll load into your current world and lets you decide. Importing a save file drops it into its own world's folder and takes you there. Saving a name that already exists in the current world lets you overwrite it or keep both. You can also load a save straight from the main menu โ its cog is now a hamburger with Load Game and Settings; picking a save loads its world and drops you into the game (a save whose world you don't have installed explains that and offers download/delete). - Per-prompt sampling controls โ each prompt's editor (Settings โ System Prompts) gains an Options tab with Custom Temperature and Custom Repetition Penalty controls: tick either to override that one prompt's sampling on a slider, or leave it off to use the smart default. The behind-the-scenes helper prompts default to tuned values so their bookkeeping and scene-planning stay consistent โ Stat Updates, Location Change, and Summary run low/near-deterministic, while the Planning step runs at a moderate temperature with the repetition penalty relaxed (which it needs to keep every character in a scene). Creative prompts (narration, choices, โฆ) follow your endpoint's own settings. Like the custom-endpoint fields, a value you dial in is kept (just unused) when you switch the override back off. The editor's Verbatim turns field moved onto this Options tab too.
- Steadier scenes in Planning mode โ the optional pre-turn Planning step (Settings โ Generation โ Thinking โ Planning) was rebuilt as a lightweight scene-continuity planner. It now carries the previous scene forward โ who was present, what they were holding, where they stood โ so the narration doesn't quietly drop a character mid-conversation or swap an established object for a new one, and it names the cast up front. A single call that does the job of the heavier Staged mode's first stages, tuned and verified on both a 12B and a 24B local model.
- Planning mode fills the scene list โ the cast the Planning step names now drives the Entities panel (who's present this turn), the same as Staged mode already did. And a character whose name the player hasn't heard yet is kept out of the narrator's hidden plan โ the planner refers to them by how you currently know them ("the silver-haired woman") until the name is actually spoken in the story, so the plan can't accidentally spoil a reveal.
- The Entities tab is now a live scene list โ in Planning and Staged modes it shows exactly who is physically present this turn (sourced from the planner's cast, so someone merely mentioned โ "my father, King Luke" โ no longer wrongly appears), with their portrait. A character you haven't been introduced to yet shows by how you currently know them ("the hooded woman") and their picture, and their real name appears the instant the narration first speaks it. Off/Inline modes keep the previous behavior (the tab is filled from names found in the narration). Opening a not-yet-named character's detail card is held back until the name is revealed, so the list itself can't spoil it.
-
Portable desktop builds now keep everything in their own folder โ The Windows portable
.exeand the Linux AppImage store all your data โ saves, settings, worlds, and your character library โ in auserdata/folder right beside the app, instead of tucked away in your OS profile. Copy that folder (or the whole portable directory to a USB stick) and your entire setup moves with it. The first time you run the new build, existing data is migrated across automatically. The macOS build installs normally and is unchanged. The version in the corner also now shows the build type โ portable, installed, or dev โ so you can tell at a glance which build you're on (the browser build stays unlabeled).
-
Saves are now organized into per-world folders โ Opening Load Game drops you straight into the current world's folder, listing only its saves โ the common case is now one step. A Back button (under Import) and a
-
๐ ๏ธ Developer tooling
-
Save store re-keyed to a stable id + per-world folders (save-shape change). The IndexedDB
savesstore (keyed byname) is replaced bysaveRecords(keyed by a uuidid), so identical names can coexist across and within worlds; a one-time upgrade migrates existing rows, minting anidand best-effort stampingworldIdby matching each save'sworldNameto an installed world (unmatched saves stay name-only and group by name). New saves carrySaveObject.worldId(fromWorldOverview.id's owningWorld) โ an additive save-file shape change (the exported.jsongainsworldId; the device-localidis stripped on export, re-minted on import). This additive shape change is what 2.0.3 carries โ the version bump lands with it. Grouping/ordering logic is pure + unit-tested inlib/saveOrdering.ts(groupSaves/folderRefFor/mergeOrder); ordering is device-local (a separatesaveOrderstore keyed by__folders__/ folder key), so it never touches exported saves.saveGame/loadGamenow key by id;MenuModalrebuilt around folder/root views with dnd-kit reorder (mirrors the DictionarySelectionModal pattern). Cross-world load is a real world switch:onLoad(id, worldId)โGameViewercallsgetWorldData(worldId)โloadWorldDataโloadGameagainst the target world's locations (previously the save loaded into the current world's shell); an uninstalled world can't be switched to, so it warns and loads in place (current world's rules) โ harmless (the location lookup is guarded; foreign stats/entities degrade to no-ops), so it's left to the player. The load UI is extracted to a sharedLoadGameDialogused by both the in-game menu and the main menu;groupSavestakes an optionalcurrent(omitted in the main menu โ no pinned folder). The main menu's cog became a[Load Game, Settings]hamburger; a cold-load there resolves the save's world (getWorldDataโloadWorldData) and hands the save id toApp(initialSaveId), whichGameViewerrestores on mount instead of starting fresh โ an orphaned save is blocked there (no world to run it in). -
Precall planning wired into participation โ the Planning step now parses its
Scene/Cast/Beatsoutput through the sharedclassifyCast(extracted fromrunStagedPlanningso both paths bucket identically) into defined-entity (loose-match) + ad-hoc (strict-match) candidates that the narration confirms, exactly like the staged director. AndsanitizePlanForRevealrewrites any cast name absent from past narration to its parenthetical alias โ or a neutral fallback when the model gave none โ across the whole plan before it's injected, a code backstop for the prompt's name-spoiler rule that larger models honor unevenly.lib/stagedPlanning.ts. -
Scene list re-sourced from the planner cast (
SceneEntity, save-shape change) โGameState.visibleEntitiesgrows fromstring[]toSceneEntity[]({ name, alias?, revealed }). A newbuildSceneList(lib/stagedPlanning.ts) maps the turn's planner cast to the tab: canonical entity name for the portrait tie, the alias carried through, andrevealedcomputed against the narration (prior turns + this turn so far, so it flips mid-stream); with no planner it falls back tofindEntityNames.runStagedPlanningnow returns its NPCcast;GameViewerdrives the list at stream-start, per-sentence, and finalize (via a ref, since the narration stream runs at component scope). Old saves load fine โ a barestring[]normalizes to{ name, revealed: true }on load (normalizeVisibleEntities), non-destructive, no migration of stored files. New saves write the richer shape. -
Per-prompt sampler resolution (
lib/promptSamplers.ts) โ resolves each request's temperature and repetition penalty: a pinned or custom value goes to every endpoint; an unpinned prompt sends the global slider value on the built-in engine but omits the field on a custom endpoint, so LM Studio / Ollama apply the model's own configured value (see the verified endpoint-temperature behavior). Every pin was set from measured probe sweeps against a 12B + 24B local model (testing/baseline/harness/*-probe.mjs), not guessed:statUpdates0.2,locationChange0.15,summarygreedy 0 (small models echo dialogue verbatim above it), and โ the odd one out โthinking(Planning) at temp 0.4 with repetition penalty 1, because that prompt inverts the usual rule: its greedy default is to drop cast members the action doesn't engage (so it needs entropy to keep the scene), and a repetition penalty on the list-shaped cast roster suppresses completeness and drives invented characters. Overrides persist as onepromptSamplerslocalStorage record. -
Portable profile + build-type indicator. Electron redirects
userDatato<portableRoot>/userdatafor relocatable builds (runtimePORTABLE_EXECUTABLE_DIR/APPIMAGE), moving the whole Chromium profile beside the exe; on first run it copies the persistent stores (IndexedDB, Local/Session Storage โ caches skipped) out of the old default location once. Pure, electron-free logic lives inelectron/portableProfile.cjs(unit-testable). The build type is baked via a Vite__BUILD_TARGET__define fed byFORMAMORPH_BUILD(set per target in the release workflow:portablefor the Windows portable + Linux AppImage,installedfor the mac dmg,webfor the browser build), surfaced asBUILD_TAGinlib/buildInfo.tsand shown in the footer (empty โdev). The portable artifact is renamedFormamorph-${version}-portable.exeso a future NSIS installer (-setup.exe) can't collide. Release workflow's official actions bumped to current majors (checkout 7, setup-node 6, upload-artifact 7, download-artifact 8).
-
Save store re-keyed to a stable id + per-world folders (save-shape change). The IndexedDB
-
๐ค User-facing
- Load Game menu no longer clips or overflows โ Save rows were fixed-height buttons, so a long save name or the "different world" warning spilled out of the row instead of growing it, and a long list of saves ran off the bottom of the dialog with no way to scroll. Rows now expand to fit their contents, the list scrolls when it's taller than the dialog, and the per-save Delete (left) and Download (right) controls sit on opposite ends for less mis-clicking.
- Long names in dictionary and picker lists now clip with "โฆ" instead of overflowing โ A long entry/dictionary name (or entity/theme label) stretched its row past the panel's edge, pushing the duplicate and delete buttons out of reach. Those rows now truncate the name with an ellipsis and keep their controls in view.
- Opening the World Editor from the menu now animates and no longer reloads the whole menu โ It opened as a hard screen swap and, on close, rebuilt the entire main menu (a visible flash plus a redundant re-fetch and profile re-check). It now opens with the same full-screen zoom-from-center animation as Community Creations, and closing it (via its back arrow) just refreshes the world grid, keeping the rest of the menu untouched. The distracting "New world ready" toast on creating a world was also removed.
- New worlds no longer start with a broken thumbnail โ Creating a world seeded a placeholder image from an external host that no longer resolves, so the new world showed a broken image. New worlds now start with no thumbnail (the same clean fallback used everywhere else) until you set one.
- Every World Editor tab now shows a hint when it's empty โ Only Locations did before โ Stats, Entities, Traits, and Dictionaries were just blank, leaving you unsure whether to add something or whether it failed to load. They now all show the same "No โฆ yet โ use the + button to add one." hint (and a "no matches" note when a search comes up empty).
- Prompt editor's "Thinking" tab renamed to "Planning" โ to match the Thinking-mode option it edits (Off / Inline / Planning / Staged), which was already labeled Planning.
- Re-generate no longer flashes the old narration โ Pressing Re-generate briefly animated the entire previous narration (every paragraph at once) before clearing and streaming the new text. The reveal now only animates while the new narration is actually streaming โ during the setup/thinking phase it shows the settled text โ so the flash is gone.
-
Smoother narration reveal โ a cluster of long-standing glitches in the animated text reveal traced to several compounding causes, now all fixed. The core one: the sentence detector didn't understand markdown, so a sentence ending inside emphasis (
*screech.*), a paragraph ending on an em-dash, or a bare**Power: 88%**line never counted as a boundary โ whole paragraphs fused into one giant "sentence" that starved the reveal (stutter), then animated across the paragraph break in one burst. Sentence boundaries now recognize closing emphasis markers, and a blank line always ends a sentence (this also fixes streamed text-to-speech chunking at the same spots). On top of that: the reveal now lets each paragraph finish landing before the next begins, no longer has a speed floor faster than slow models can generate (the old cap forced stuttering on any model under ~11 words/s), and uses a tighter per-word fade so a sentence finishes fading within its own span instead of being cut off by the next one. Most importantly, the reveal no longer guesses at a tokens-per-second rate (which a server's initial burst of text could throw off for the whole turn) โ it now measures the real time between sentences as they arrive and paces to that, keeping a small buffer so it never runs dry (stutter) or lags far behind (text still revealing long after the choices appear). It also remembers the measured speed between turns, so after the first turn it already knows roughly how fast your model is and starts at the right pace instead of ramping up from a cautious default.
-
๐ ๏ธ Developer tooling
-
World Editor is now an in-place modal, not a top-level view. It moved from an
AppcurrentViewbranch into a MainMenu-hosted full-screen<Dialog>(reusing Community Creations' classes) so the menu stays mounted and animates; a newrefreshWorldscallback (mirroringrefreshDictionaries/refreshEntities) refreshes just the world grid on close instead of remounting the menu. WorldEditor gained abackButtonprop that decouples the header back arrow fromembedded(GameViewer's embedded popup keeps its X; the menu modal shows the back arrow with no X, Esc/overlay blocked so the guarded back arrow is the sole exit). In the DEV dev-router,worldEditormoved fromDEV_VIEWStoDEV_MODALS(reached via#dev?modal=worldEditor), and the drift-guard test was updated to match. -
List rows clip instead of overflowing (root cause + shared empty-state). Two causes: a
flex-grow/flex-1truncating child needsmin-w-0to shrink, and Radix's ScrollArea wraps its viewport content in an inlinedisplay:tablediv that shrink-wraps to content width โ overridden with[&>div]:!blockon the shadcnScrollAreaviewport (safe: no horizontal ScrollArea exists anywhere). Dictionary row controls gotshrink-0so the name (not the buttons) absorbs the truncation. The per-tab "nothing here yet" hint is unified into one sharedEmptyListHintcomponent reused by every list/tree tab.
-
World Editor is now an in-place modal, not a top-level view. It moved from an
-
โ๏ธ Backend / invisible
-
Imported multi-turn saves from v1.2 no longer corrupt their page history. Converting an old (v1.2) save duplicated the opening page and dropped the current page from the turn history, so on a 3-turn save Re-generate and Rollback landed one turn too early (a page effectively vanished). Old saves stored their history as the prior pages only, with the current turn kept separately; the converter now appends the current turn as the final page (instead of a stale re-alignment that assumed the opposite) and stamps the newer
discoveredEntitiesfield onto every converted snapshot. Single-turn saves were unaffected and stay so. Already-imported broken saves aren't auto-repaired โ re-import the original v1.2 file to fix them. -
Phantom "empty scene" cast bullets no longer become participants.
isEmptyCastNamenow also recognizes multi-word declarations like "no other characters present" / "no one else" (not only single-word sentinels such as "none"), so a planner or director that writes the empty scene as a cast bullet doesn't spawn a bogus participant. Affects both Planning and Staged. -
Cast names with a role/alias parenthetical now classify correctly. Small models habitually gloss a cast line as
Bram (ferryman)orMaela (the hooded woman); the cast parser kept the whole"Bram (ferryman"as the name, so it no longer matched the "Bram" entity and misfiled as an invented character (losing the entity tie).parseDirectorCastnow splits the name from the parenthetical (shared with the reveal sanitizer viasplitNameAlias) and captures the alias on the cast member. Confirmed on a live 12B via a new real-parser probe (harness/planning-parser-probe.test.ts): misclassification dropped from 5/9 cases to 0. Affects Planning and Staged participation. - Silent background requests no longer overlap. With Memory Summaries and Character Diaries both on, a turn-summary request could fire while a diary or character-discovery request was already running, doubling up on a single-GPU local endpoint; the three background jobs now strictly take turns.
- Rollback snapshots no longer change after the fact. Repeating log entries incremented a counter on an entry object shared with already-saved game-state snapshots, retroactively editing them; the increment now produces a fresh entry.
-
Desktop IPC surface hardened โ the desktop app's internal fetch bridge now accepts only
http/httpsURLs (it could previously be asked to readfile:paths), and an unused bridge that could load a model from an arbitrary absolute path was removed (models load by filename from the models folder only). - Duplication pass across the codebase โ extracted shared helpers for the five copy-pasted blob-download flows, the three location-list serializers, the image providers' HTTP plumbing, the main menu's three drag-reorder handlers, the editor managers' draft-editing pattern, the settings-form row layouts, and the Location/Trait drag-tree scaffold (~480 duplicated lines removed, no behavior change).
-
Imported multi-turn saves from v1.2 no longer corrupt their page history. Converting an old (v1.2) save duplicated the opening page and dropped the current page from the turn history, so on a 3-turn save Re-generate and Rollback landed one turn too early (a page effectively vanished). Old saves stored their history as the prior pages only, with the current turn kept separately; the converter now appends the current turn as the final page (instead of a stale re-alignment that assumed the opposite) and stamps the newer
โ 2.0.2 โ Released โ old-save rollback/import fixes + summary-prompt hardening (click to expand)
-
๐ค User-facing
- The opening turn of an old (1.2) save can now be rolled back and re-generated โ The 2.0.1 import fix handled later turns but left the very first turn dead โ rolling back to it did nothing and its Re-generate button was inert. The opening turn now restores and re-rolls. (Its exact pre-turn snapshot was never stored, so a rolled-back opening lands one turn later, and re-generating it rebuilds a fresh start.) This also fixed re-generating the opening on any loaded save, not just 1.2 ones.
-
Importing an old (1.2) save now upgrades it properly โ Bringing in a 1.2 save file used to stamp it as current without actually converting it โ so its traits reached the AI as bare names (descriptions dropped) and its body-shape stats (belly / weight / bust) stopped driving the character model. Old saves are now migrated the same way whether you import a file or load one already in your library, and the two paths run identical conversion so they can't drift.
โ ๏ธ If you imported a 1.2 save before this fix, re-import the original file to upgrade it โ a save that was already brought in stays in its old state. - More consistent turn summaries โ the memory-digest "summary" prompt (which condenses each turn for long-game memory) was hardened so its retellings come out uniformly short (one or two sentences, single line), lead with what you did, and never quote dialogue verbatim or invent a character name the narration didn't give. Verified on both a small (12B) and large (24B) local model.
-
๐ ๏ธ Developer tooling
- The Sedge Landing baseline harness gained a
summaryprofile (exercises the digest prompt) and per-profilesettleMs, so a profile with async drainers can wait longer without slowing the others.
- The Sedge Landing baseline harness gained a
โ 2.0.1 โ Released โ local LLM engine, AI image generation, dictionaries & character cards, themes, and more (click to expand)
-
๐ค User-facing
- Run a model locally โ no endpoint needed (desktop) โ the desktop app can now run an LLM entirely on your own machine, so no LM Studio / Ollama / cloud API is required. A bundled engine (node-llama-cpp) loads a GGUF model and serves it on a local OpenAI-compatible endpoint the app targets by default; flip Use My Own Endpoint on to point at a custom/cloud API instead. A model manager (Settings โ Endpoint โ Manage modelsโฆ) has two tabs: Recommended โ a curated catalog of community roleplay models grouped by the VRAM tier they fit (โค4 GB ยท โค8 GB ยท โค16 GB ยท No Limit, auto-selected from your detected GPU), each downloadable straight from Hugging Face with a live progress bar and pause / resume (a paused download survives closing the app); and Installed โ every GGUF in your models folder, reorderable, with one-click Load / Unload and a confirm-first Delete. Runtime controls sit in the Endpoint tab behind a Simple / Advanced toggle: Context Size, GPU (a simple on/off, or an exact layer count in Advanced), Flash Attention, and sampling โ Temperature, Max Output Tokens, Top-p, Top-K, Min-P, Repetition Penalty โ beside a live GPU memory readout and engine status. Models are saved next to the app (portable โ not buried in AppData), and it's cross-platform (Windows / macOS / Linux).
-
AI image generation โ generate images for entities, locations, and world thumbnails right in the World Editor. A Generate with AI button drafts an SD-style prompt from the subject's description (via your text model), lets you tweak it, and renders through your configured image provider. Providers: a local A1111 / Forge server, and โ in the desktop app โ an OpenAI-compatible cloud image API (proxied through Electron to sidestep browser CORS). Prompts are LLM-refined into stripped danbooru / booru tags via an editable Tag Prompt (with a
<SUBJECT>chip, Settings โ Image Gen), plus a positive-prompt prefix for quality/style tags. Characters render at portrait dimensions, locations & thumbnails at landscape. Settings live in named, editable endpoint presets (a "Default" plus your own), switchable in Settings and from the generate popup. For A1111: an ADetailer face/hand-fix toggle and a live progress bar + in-progress preview while it renders.
-
๐ ๏ธ Developer tooling
-
Local LLM engine โ
electron/llmEngine.cjsdynamic-imports node-llama-cpp (ESM) from the CommonJS main process, loads a GGUF, and serves a minimal OpenAI-compatible chat-completions endpoint (streaming + non-streaming) on127.0.0.1. It reconstructs the model's raw output โ re-wrapping reasoning<think>segments viaonResponseChunkโ so structured aux requests (choices / stats / location) don't come back empty on models that segment their scratchpad. The renderer drives it over an IPC bridge (window.formamorphDesktop.llm): start/stop/load, status pushes, installed-model + partial-download listing, engine options (context / GPU layers / flash attention) with reload-on-change, and a Hugging-Face-only downloader (electron/modelDownload.cjs) using.parttemp files, HTTP Range resume, and cancel. The catalog + VRAM tiers live insrc/lib/localModels.ts(filenames/sizes verified against the HF API; reasoning models deliberately excluded for now). All of it is gated onisDesktop()and tree-shaken from the web build. -
Linux & macOS desktop builds โ electron-builder now targets AppImage (Linux) and dmg (macOS) beside the Windows portable exe (
desktop:build:linux/desktop:build:mac). A release workflow builds all three from onev*tag (a Windows / Linux / macOS matrix) and attaches them to the GitHub Release. -
Hosted browser build on GitHub Pages โ deploys on every push to
main, with the community-server features (Discover, login, publish) disabled in that build (VITE_ENABLE_COMMUNITY=false) so nothing unmoderated is surfaced by default. Play it at jakejamesdev.github.io/formamorph. -
Web build release artifact โ tagging a
v*release also produces aformamorph-web-<tag>.zip(a full, community-enabled browser build) attached to the GitHub Release, ready to drop onto itch.io or your own host.
-
Local LLM engine โ
-
๐ ๏ธ Developer tooling
- Tagged desktop builds no longer fail on the Linux/macOS runners โ electron-builder no longer tries to self-publish (missing
GH_TOKEN); the workflow's own publish step handles the upload.
- Tagged desktop builds no longer fail on the Linux/macOS runners โ electron-builder no longer tries to self-publish (missing
-
๐ค User-facing
- Import tags from an image's metadata โ upload a PNG that embeds its generation prompt (A1111/Forge, ComfyUI, InvokeAI, NovelAI, and other JSON-metadata tools) to an entity or location, and Formamorph offers to use that positive prompt as the Image Tags โ no retyping. Declined images and plain photos are untouched.
- Authored image tags โ entities and locations now have an Image Tags field (under the image, above Generate with AI) that persists your booru tags. A shared โจ toolbar with Undo / Redo writes tags from the description (and now also generates the AI-Facing Summary the same way). The Generate with AI popup edits those same tags two-way โ tweak, generate, adjust, regenerate โ and no longer auto-overwrites what you authored.
- Danbooru tag autocomplete โ the Image Tags fields, the Generate with AI prompt, and the world Tags editor suggest real Danbooru tags as you type, ranked by how commonly they're used, so you complete a tag with a keystroke instead of guessing spellings. Start typing to filter (an empty field shows nothing); the list loads on demand and works offline. The world Tags editor previously suggested tags scraped from the community browser (with a Refresh button) โ it now uses this standardized list, so that button is gone.
- Editable tag chips โ double-click a chip in the world Tags editor or a dictionary entry's Keywords to fix it in place (Enter saves, Esc cancels, clearing it removes the chip) instead of deleting and retyping. Editing a world tag still shows the Danbooru autocomplete; chips keep their position, and drag-to-reorder is unchanged.
-
ComfyUI image provider โ generate images through a local ComfyUI server alongside the existing A1111/Forge and cloud options. Ships a working SDXL txt2img workflow that's fully editable per preset: paste your own Save (API Format) graph and mark inject points with tokens (
%prompt%,%negative%,%ckpt%,%width%,%height%,%steps%,%cfg%,%seed%,%sampler%). Live progress bar + preview frames stream over ComfyUI's WebSocket, matching the A1111 experience. Launch ComfyUI with--enable-cors-headerso the browser can reach it (default port 8188). - World README โ authors can add a markdown Readme (in the World Editor, below System Prompt Addition, using the same editor as the World Description) that greets the player in a popup when they enter the world. Dismiss it with the X, click-outside, or Esc. A per-world Show Readme toggle lives in the main-menu world popup and is mirrored by a Don't Show This Again checkbox in the popup itself โ the two stay in sync (one setting, on by default).
- Consent-based image optimization โ before an oversized image is re-encoded (on import, on generate "Use image", or via the editor's Optimize Images), a prompt offers Optimize (convert to lossless WebP at the same resolution โ no visible quality loss) or Downscale (shrink to fit), with a clear Keep original to do nothing. Already-WebP images skip the redundant Optimize option.
- The world-browser Hidden popover now uses editable tag / author autocomplete boxes, matching the search filters.
-
Dictionaries are books โ the World Editor's Dictionary tab now groups lorebook entries into named dictionaries ("books") you add, reorder, collapse, and toggle on/off as a whole, instead of one flat pile. Each book keeps its own Background and Foreground lists, and entries drag between zones or across books; book order sets injection order, and disabling a book mutes all its entries at once. New worlds start with a Default book. A single book exports to its own
.json(Download <name> on a selected dictionary) and imports back as a new book (Import Dictionary) โ handy for sharing lore between worlds; importing a full world or save file is rejected with a clear message. -
Dictionary library + lorebook import โ the main menu's top-left switcher gains a Dictionaries tab: a library of standalone dictionaries you Import and manage as cards (persisted locally), separate from any world. Import isn't limited to Formamorph files โ it also reads and converts open lorebooks: SillyTavern World Info exports and Character Card V2/V3
character_bookdata (keywords, secondary keywords, content, enabled/constant, case-sensitivity, regex, placement, scan depth). Cards with no image (and worlds without a thumbnail) now show a neutral grey placeholder instead of a broken image. Click a dictionary card to open it in a full editor โ the same Background/Foreground tree, entry editor, and drag-to-reorder as the World Editor โ rename it, edit entries, Download it, and Save back to the library (with an unsaved-changes prompt). Dictionaries also gain a Description (author note, not sent to the AI), and entries gain Whole words matching (keyword boundaries, so "art" no longer fires on "cart") and richer secondary-keyword logic โ require any or all secondaries, and an Exclude mode so an entry fires only when certain words are absent. In the World Editor, Add Dictionary pulls copies of one or more library dictionaries into the current world (multi-select), each as an independent copy. - Theme + theme color โ Settings โ Presentation has a Theme control (Light / Dark / System, as segmented tab buttons; System is the new default and follows your OS) and a Theme Color dropdown below it that recolors the whole app. Eight presets: Graphite (the default โ a soft monochrome), Purple, Blue, Rose, Bubble Gum, Forest, Monochrome (crisp greyscale with a true-black dark mode), and High Contrast (an accessibility theme โ maximal text contrast, everything outlined, yellow accents in dark). Each is a full hand-tuned palette for both light and dark, so switching restyles every surface โ backgrounds, cards, borders, buttons, focus rings โ not just a button tint, while keeping text legible. Status colors (error / success / warning) stay constant across all themes. On a fresh install, if your OS requests higher contrast and you're on System mode, it starts on High Contrast automatically โ as the initial default only; whatever you pick afterward always sticks.
- Location background controls โ Settings โ Presentation adds a Location Background toggle (on by default) that shows or hides the current location's image behind the game; turn it off for a clean, blank themed background. When it's on, a Background Fade slider (0โ100%) fades the location image toward the background color for readability โ slide it up to calm a busy scene toward the plain background. Pressing the hide-UI (eye) button drops the fade too, so you always see the untouched image underneath.
- AI Language is now a combobox โ the language setting (Settings โ Presentation, and the in-game side panel โ renamed AI Language) suggests common languages the moment you focus it (no typing needed), while still accepting any free text. A short note explains what it does: it sets the language the AI writes narration and choices in โ and you can type your own value, or even a style like "formal English" or "pirate speak", instead of picking from the list.
- Back button in the enter-world flow โ every step of the start-a-world flow (Traits โ Location โ Characters โ Dictionaries โ Avatar) now has a Back button, always just to the left of the next/confirm button, that returns to the previous step. On the very first step it's shown but faded/disabled. On the Traits step it also pages back through trait sections before it reaches the flow's start.
- Bring characters into a world when you start it โ a new step in the enter-world flow (after location, before dictionaries) lists your library characters with their portraits; check the ones you want and they're placed into your starting location for that playthrough. Only appears when you have saved characters. The chosen characters are copied in with fresh ids and live entirely in the save โ they're never written into the authored world.
- Choose dictionaries when you start a world โ a new step in the enter-world flow (after location, before avatar customization) lists the world's own dictionaries on top and your downloaded library below, each with a drag handle, an enable/disable checkbox, its description, and an enabled/total entry count. Reorder and toggle any of them โ world-authored included โ to set exactly which lore applies and in what order for this playthrough; enabling a library dictionary pulls in a fresh copy. Your choice is saved with the game, so reloading keeps it. The step is skipped when there's nothing to choose (a single-book world with an empty library).
-
Character cards โ share a character as an image โ a single character now exports to a shareable WebP whose picture is the character's portrait and whose text (name, descriptions, summary, type, image tags) rides along invisibly inside the image, the way other games do character cards. In the World Editor, Download <name> on a selected entity saves the card; drop that
.webpback in via the new main-menu Entities tab and the character returns with its portrait and every field intact (the image itself becomes the portrait โ no duplicate stored). The Entities tab is a local library of characters you New, Import, edit in a full editor, reorder, and delete โ separate from any world; Add Character in the World Editor pulls copies of one or more of them into the world (multi-select), each an independent copy. A character with no picture still exports, using a generated placeholder. Importing a world, save, dictionary, or a plain non-card image shows a clear message. -
Import SillyTavern characters โ the Entities Import also reads SillyTavern / Character-Card PNGs (the
ccv3/charaembedded-card format). It brings across the picture and the name plus description + personality + scenario (folded into the AI-facing description, with{{char}}โ the character's name and{{user}}โ "the player"); the chat-only fields (greeting, example dialogue, jailbreak prompts) are skipped as they have no place in a world character. If the card carries its own lorebook, you're asked whether to add it to your dictionary library too. - Sign in & publish from the desktop app โ the desktop build no longer blocks logging into the community server or publishing a world; that guard is gone now that v2 is the standard.
- Selectable app font โ Settings โ Presentation gains a Font picker that restyles the whole app's typeface.
- Live theme preview โ a Preview popup (Settings โ Presentation) shows every theme color token with a label and color picker, laid out across the real widgets the app uses โ checkboxes, tabs, draggable tags, a multiselect, a reorderable list, circle buttons, a sample card โ so you can eyeball a palette on actual UI before committing. It's a viewer; nothing is saved.
- Cards stand out โ each theme now gives the card surface its own shade distinct from the page background, so world / entity / dictionary cards read as raised panels instead of blending in. Community & main-menu tags also recolor with the theme (they were stuck blue).
- Animated stat changes โ in-game stat bars now show a change instead of only printing a number: a gain grows a green band and a loss a red band spanning the previous โ current value, the ยฑdelta text fades in and out, and the colors drain away at the start of the next turn.
- "Move Automatically" โ the auto-move setting (Settings โ Generation) is renamed from Location Moves to Move Automatically, and now resolves the move up front so the narration is written already in the new place rather than describing a move that hasn't happened yet.
- Narration reveal animation โ the streamed-text reveal is a configurable animation (fade / scale / blur / slide, its pace tracking how fast the model generates), tuned in Settings โ Presentation with a live demo.
-
๐ ๏ธ Developer tooling
-
Env-seeded image defaults โ the whole Image Gen setup (provider, endpoint, dimensions, ADetailer, and multiple named presets via
VITE_DEFAULT_IMAGE_PRESETS) can be seeded from.env.local. -
Unified app icon โ one source icon (
public/icon.png) is now both the browser-tab favicon and the desktop app icon (electron-builder converts it per-platform), replacing the default Vite / Electron icons. -
VRAM readout built into the desktop app โ the Hardware tab's live GPU readout no longer needs a separately-run
npm run vram-helperin the packaged desktop build: the Electron main process runsnvidia-smiitself and feeds the renderer over an IPC bridge (window.formamorphDesktop.vramStats). The nvidia-smi logic was factored into a sharedelectron/vramCollect.cjsused by both the desktop main process and the standalone dev helper.useVramStatsprefers IPC when in the desktop build, else polls the helper URL. The Hardware tab is now hidden in the production web build (where no VRAM source exists) and shown only innpm run devor the desktop app; on desktop it drops the "run the helper" instructions for a built-in note. - The main-menu GitHub button and the README / wiki now link to this fork's repo and the live web build.
-
API doc comments + tooling โ the high-value TypeScript surface (domain types, contexts, services, and the core
libmodules) now carries prose TSDoc comments, so VS Code shows a plain-English summary on hover next to the types. The lint gate validates comment syntax (eslint-plugin-tsdoc), andnpm run docsgenerates a browsable TypeDoc API reference into the gitignoreddocs-api/. -
World shape:
dictionaryโdictionariesโ the flatdictionary: DictionaryEntry[]becomes an ordereddictionaries: Dictionary[](books of entries). An idempotent load-time migration folds any legacy world (including the bundled defaults) into one Default book, positions preserved โ no world is re-persisted until the user saves. Injection stays entry-based via aflattenEnabledBookEntriesbridge, so a single-book world's prompt output is unchanged. New standalone dictionary file format carries aformamorphKind: 'dictionary'discriminator; world and save exports now also carry an optionalformamorphKind: 'world'/'save'label (additive โ old files without it still import). Foreign lorebook parsing (lorebookImport.ts) is grounded in the MIT Character Card V3 spec (credited inTHIRD-PARTY-NOTICES.md). The dictionary editing widgets were decoupled from the world store into a scopedDictionaryStorecontext (useDictionaryStoreState), so the World Editor binds them to the current world's books while the library editor binds them to an isolated single book โ the same widgets, two hosts, no shared-state leakage.Dictionarygains an optionaldescription;DictionaryEntrygains optionalmatchWholeWords,secondaryAll, andsecondaryExclude(all additive โ absent = prior behavior). The importer now maps STmatchWholeWords,selectiveLogic(AND-ANY/ALL, NOT-ANY/ALL), and lorebookdescription. -
Save shape: optional per-playthrough
dictionariesโ the save envelope (SaveObject) gains an optionaldictionaries: Dictionary[]written by the new world-entry dictionary step; older saves lack it and fall back to the world's current books (never clobbered). The per-run set is owned byGameplayContext(runtimeDictionaries+ the flattenedruntimeDictionaryfed to injection);saveGame/loadGamestamp and restore it internally, soGameDataContextholds only the authored books. Pure selection helpers live inlib/dictionarySelection.ts(shouldShowDictionaryStep,buildInitialSelection,finalizeSelection). Additive โ old saves import unchanged. -
New file format: character cards (
.webp) โ a single entity slices out to a shareable WebP with its text fields embedded in the image, mirroring the dictionary slice.lib/entityCard.tsis a pure RIFF/WebP metadata codec (embedEntityCard/readEntityCard): it promotes a simple VP8/VP8L file to extended VP8X and writes the JSON into anXMPchunk (idempotent โ re-embedding replaces, never appends), modeled on the existing PNG-chunk reader insdMetadata.ts.lib/entityFile.tsadds theformamorphKind: 'entity'discriminator,buildEntityCardData(dropsimage/model/sound),parseEntityCardData(fresh id, rejects world/save/dictionary payloads), and theexportEntityCard/importEntityCardorchestrators โ export omits the portrait from the JSON and import re-sources it from the card's own pixels, so the image is never stored twice. A local library mirrors the dictionary one:EntityStorageService(IndexedDBentitiesDB), a main-menu Entities grid (reusingSortableWorldCard),EntityEditorModal, andAddEntityModal.EntityManager's fields were extracted into a sharedEntityFieldsbody (locations picker optional) so the World Editor and the library editor reuse it. NewEntityMetadatatype. This is a new shareable artifact type; it does not change the world or save.jsonshape โ no version bump. -
Entry-step character placement (runtime-only) โ the new enter-world character step is gated by
shouldShowCharacterStep(lib/characterSelection.ts) and picked inCharacterSelectionModal. The chosen library characters thread MainMenu โAppโGameVieweras aninitialCharactersprop (mirroringinitialDictionaries), and the init effect seeds them intoGameplayContext.discoveredEntitiesanchored to the resolved starting location โ reusing the existing runtime-character path (allEntities+mergeDiscoveredIntoLocation). So they show as present, persist in the save, and never touchGameData(the authored-world-immutable invariant). Fresh ids on selection;loadGameoverrides on load. -
SillyTavern character PNG import โ
lib/tavernCard.tsreads a Character-Card PNG (base64 JSON in theccv3/charatext chunk, V1 flat / V2-V3dataenvelope), reusing the now-exportedreadPngTextChunksfromsdMetadata.ts. It maps only name + description/personality/scenario โaiDescription(macro substitution{{char}}/{{user}}), drops the chat-runtime fields, and hands anycharacter_bookto the existingconvertLorebook.entityFile.importCharacterFile(file)is the unified import entry โ WebP card or ST PNG โ returning{ entity, book }; the main menu offers the book via a confirm dialog. The PNG's pixels become the portrait (re-encoded to WebP). -
Authored world is immutable during play โ the running session no longer writes runtime state back into the authored world (
GameDataContext). The stat-change handler updates onlyplayerStats(dropping the oldupdateStatwrite-back), and the per-run dictionary set lives inGameplayContext, threaded in from world entry as aGameViewerprop (mirroringinitialTraits/initialLocationId). The mid-game Edit World tool edits the authored world directly; it persists only on Save World, and no longer opens pre-marked dirty. The now-unused flatteneddictionaryderivation was removed fromGameDataContext.
-
Env-seeded image defaults โ the whole Image Gen setup (provider, endpoint, dimensions, ADetailer, and multiple named presets via
-
โ๏ธ Backend / invisible
-
Design-token color pass โ the app's colors were migrated off hardcoded Tailwind palette classes (grays, plus inline reds / greens / ambers / blues for status) and stray hex onto CSS design tokens in
index.css, so every surface now flips correctly between light/dark and swaps wholesale with the selected theme color with no per-component overrides. Preset themes ride on this: adata-themeattribute on<html>swaps a full token set, so a theme is authored purely as.light[data-theme="โฆ"]/.dark[data-theme="โฆ"]blocks inindex.cssโ no per-component work. Eight ship (Blue, Purple, Rose, Bubble Gum, Forest, Graphite, Monochrome, High Contrast), each built by one recipe: pick a hue, preserve each token's lightness (so contrast holds), scale saturation by role, and leave the semantic status/chart tokens constant. The bare base and the first-run default are decoupled โbluelives in:rootand applies with no attribute, whilegraphiteis what a fresh install starts on. Monochrome and High Contrast communicate depth by an elevation-by-lightness surface ramp (true-black canvas โ progressively lighter panels) rather than shadows, which don't read on black; High Contrast targets WCAG AAA (near-maximal text contrast, borders on every element, a yellow accent in dark / monochrome in light) and auto-selects on first run when the OS setsprefers-contrast: more(System mode only, and default-only โ it never overrides a chosen theme). To make that outline-heavy theme complete, the button variants now reserve a token-colored border (border border-transparentin the base,border-borderon secondary/ghost) โ invisible-to-faint in the color themes, maximal in High Contrast, with no layout shift. This whole system replaces the earlier single-color accent picker (itslib/color.tshexโHSL helper is removed). Added semantic--success/--warning/--infotokens (styled to match the existing--destructive), an--overlayscrim token for image and modal backdrops, and a fill-vs-ink split for destructive red:--destructiveis tuned bright for text/icons/indicator bars (readable as its own color on a dark surface) while--destructive-fillstays deep for solid button/badge fills (where white text sits on top) โ one token per role so red reads right in both. react-toastify's per-type accent variables now derive from these tokens too. Duplicated widgets were factored into a sharedWorldCardShell(main-menu card frame) andDropzone(dashed upload frame) so a shared color changes in one place. Decorative gradient buttons, scrim contrast text, and runtime/data-driven colors (entity chips, VRM appearance defaults, the categorical highlight palette) are intentionally left literal.
-
Design-token color pass โ the app's colors were migrated off hardcoded Tailwind palette classes (grays, plus inline reds / greens / ambers / blues for status) and stray hex onto CSS design tokens in
-
๐ค User-facing
- New World / Entity / Dictionary no longer leave a blank behind โ Clicking New used to immediately save an empty entry, so backing out of the editor without saving left a stray "New World"/"New Character"/"New Dictionary" in your library. Now New opens the editor on an in-memory draft and nothing is stored until you Save โ cancel and it's gone.
- Gameplay no longer edits your world โ Playing a world used to quietly drift its authored stat values (and, briefly, its dictionary order/toggles) into the saved world โ so opening Edit World mid-run showed changed values and a "dirty" state you hadn't touched, and saving there baked playthrough state into the world. Now the world changes only when you edit it in the World Editor and Save; per-run state stays with the save file.
- Re-generate & rollback work on imported v1.2 saves โ Old (1.2) saves stored their per-turn history one slot short, so after loading one, rolling back or re-generating landed on the wrong turn. Loading a 1.2 save now realigns that history. (The very first turn of a 1.2 save still can't be rolled back โ its snapshot was never stored โ but every later turn works.)
- The world-browser autocomplete dropdowns no longer close when you click their scrollbar, and the Hidden popover's dropdown now scrolls with the mouse wheel.
- Image Gen polish: the booru tag prompt no longer emits PascalCase / underscored tags the model can't use; the Settings โ System Prompts tab is no longer pushed out of place by the Image Gen tab; and generated / imported images are no longer silently optimized without asking first.
- Toasts follow the theme โ pop-up notifications were hardcoded to the dark theme, so they stayed dark-on-light in the new Light / System modes; they now track the resolved light/dark theme, and their success / error / warning accents come from the app's color tokens.
- Streaming narration reveal fixes โ the character-by-character reveal no longer re-animates every paragraph at the end of a response, no longer stalls at the start, and its pacing now tracks the true generation rate (bugs that surfaced with reasoning models and the local engine). A stray "0" could also flash where a stat change of exactly zero was rendered โ zero-changes now show nothing at all.
โ 2.0.0 โ Released โ full TypeScript rebuild + feature parity (click to expand)
Everything below is relative to the point this fork diverged from upstream.
-
๐ค User-facing
-
Standalone Windows app โ run Formamorph as a desktop
.exe(Electron) with no browser needed. It bundles its own engine, so WebGPU TTS, 3D rendering, and saved-world storage all work the same as in-browser. -
Custom 3D avatars โ bring your own
.vrm, or set a per-world custom player model. - In-app media player โ a fully themed audio widget for TTS narration, with auto-generated TTS when the model is loaded and a one-click unload of the TTS model to free VRAM.
- Redesigned world browser ("Discover") โ card-grid layout, tag & author chip search/filtering, and world-preview image zoom.
- AI output controls โ automatic paragraph limits and length-based truncation, an optional "thinking / planning" step before the reply, and a smoothed character-by-character reveal of streamed text.
- Re-generate & rollback โ page back through the turn history to re-generate the latest AI response, or roll back to an earlier turn (discarding the turns after it). The per-turn AI-context viewer flags the regenerated and rolled-back turns.
- Markdown narration โ the AI can format story text with bold/italic, bullet & numbered lists, tables (e.g. inventory), headings, blockquotes, and code; it renders live as the text streams in.
- Token-based Context Window, auto-detected from your endpoint (LM Studio, OpenRouter, โฆ) with a Detect button and manual override โ conversation history now fills the model's real context instead of a fixed character cap, and an over-limit warning flags a value set above the detected maximum.
- Portrait / mobile layout โ a single-panel responsive mode alongside the three-panel desktop view.
- Lore Dictionary โ a keyword-triggered world-info editor; matching keywords inject lore into the AI prompt.
- Full per-turn AI context viewer โ see exactly what's sent to the AI on each turn, plus the raw response before sanitation โ useful both for debugging and for authoring/tuning worlds. Each request is its own collapsible section with the prompt and raw output nested inside, and Collapse/Expand all folds every level at once.
- Stat-driven body sliders โ in the world editor, bind any stat to one or more of the model's body morph sliders; the stat's value (minโmax) drives the morph live in-game. Legacy weight/breasts/stomach stats are auto-bound on import, so existing worlds keep working.
-
Prompt variables are now chips โ the AI prompt editors (Settings โ System Prompts) no longer use raw
<โฆ>placeholder text. A toolbar inserts each variable as a color-coded chip you can drag to reposition, remove with ร, or use more than once. Clicking a chip opens its options: the Location chip switches between Full / Summary / List (the list of all location names, usable in any prompt โ no separate chip needed), and Entities switches between Full / Summary. During a game, an Edit / Preview toggle shows each chip swapped for its live value โ color-matched to its chip โ so you can see the real assembled prompt. - Entities are their own prompt section โ the characters and things at a location are no longer dumped inside the location block (which made the AI treat the whole cast as all-present). They're now a separate Entities chip โ a roster of who or what could appear โ so the model is less likely to crowd every NPC into the scene.
-
Standalone Windows app โ run Formamorph as a desktop
-
๐ ๏ธ Developer tooling
-
Automated test suite built from scratch (Vitest + Testing Library) covering the new libraries, services, and components, plus a coverage script (
npm run coverage). - GitHub Actions CI โ typecheck, lint, and tests run on every push and pull request.
-
Optional VRAM monitor โ a local helper (
npm run vram-helper) and in-app readout that warns before loading TTS would exhaust GPU memory. -
Desktop packaging โ a thin Electron shell (
electron/) plus electron-builder config;npm run desktop:buildproduces a portable Windows exe from the same web build. - Project scaffolding:
.env.example,.nvmrc, and a rewritten README. - This wiki, published automatically from
docs/by a GitHub Action.
-
Automated test suite built from scratch (Vitest + Testing Library) covering the new libraries, services, and components, plus a coverage script (
-
โ๏ธ Backend / invisible
-
Full JavaScript โ TypeScript conversion of the entire codebase (10 phases), finishing on strict mode with shared domain types under
src/types. -
World & save versioning โ an
APP_VERSIONderived frompackage.json, idempotent import migration (migrateWorld) that upgrades legacy worlds at every import boundary, presence-based save-envelope detection, and version stamps on saves, exports, and the bundled worlds. (See the World Format versioning notes.) - Heavy save conversion and serialization moved to web workers so the UI never freezes on large files.
- Reusable utilities extracted: world import/catalog, output length, AI-response parsing, tag/dictionary/highlight helpers, and a thumbnail cache.
-
Full JavaScript โ TypeScript conversion of the entire codebase (10 phases), finishing on strict mode with shared domain types under
-
๐ค User-facing
- The fullscreen menu popup, replaced by the redesigned world browser.
- The previous bundled example worlds (
slime,sugarscape,veilwood), swapped for the current set.
-
๐ ๏ธ Developer tooling
- In-repo planning notes and server stubs under
src/docs/(publish-feature plan, server README, tags-implementation notes, server-worlds stub).
- In-repo planning notes and server stubs under
-
โ๏ธ Backend / invisible
- All legacy
.jsx/.jssources, superseded by.tsx/.tsequivalents (views, contexts, workers, prompts, db & utils, UI primitives). -
jsconfig.json(replaced bytsconfig.json) and a strayIdle.fbxanimation file.
- All legacy
-
๐ค User-facing
- Custom VRM models exported from v1.2 were silently stripped on import โ now migrated and preserved.
- Body-morph sliders now resize clothing meshes instead of clipping through them.
- VRM outline color rendering issue.
- AI response parsing errors that could break choices and stat updates.
- The Settings panel no longer jumps vertically when its contents change.
- Unsaved world edits are detected on exit, with a confirmation prompt to prevent data loss.
-
โ๏ธ Backend / invisible
- Server-Sent Events streaming now handles chunk boundaries correctly โ no dropped or garbled tokens mid-stream.
- Removed side effects from the
setPlayerStatsstate updater. - The recent-stat-change highlight timer is cleared properly, and streaming guards against a missing
response.body.
-
๐ค User-facing
- Markdown formatting โ toggle (Settings โ Presentation, default on) โ when on, the narration prompt asks the AI to format with bold/italics, lists, and tables (e.g. inventory) using a floor-based rule (one bold + at least one italic per turn) that small models follow reliably; off keeps plain prose. (Reset Prompts to pick it up if you've customized the system prompt.)
- Hide stat numbers from the narrator (Settings โ Generation, default on) โ the narration, planning, and choices requests receive stat descriptors (e.g. "severely injured") instead of raw values, so the model writes to how a stat feels rather than fixating on the number. Stat-updates still get the numbers; falls back to the number when a stat has no descriptor.
-
Slash commands โ in the action box (input starting with
/) that bypass the AI; the first,/markdown test, types a rich sample through the real narration renderer to preview markdown formatting. - Use Custom Endpoint โ toggle in Settings โ Endpoint โ off uses the built-in default endpoint (fields read-only); on lets you enter and edit your own. Custom values are preserved when toggling off and back on.
- Hide-UI toggle โ an eye button in the bottom-left of the game view hides all panels to reveal the background image; it fades out over the background until hovered.
- Drag-to-reorder for worlds in the main menu.
- Additional avatar color options.
- Unified, consistently styled scroll areas, checkboxes, radios, and chips across the app.
- Tag truncation and a centered download control in the world browser.
- LLM request status surfaced next to the status bar.
- Update-aware world browser โ downloaded worlds show their status (Downloaded / Update available / out of date) with one-click re-download to pull the latest version.
- World provenance on cards โ local world cards show how each world arrived (Created / Downloaded / Imported) with timestamps.
- Main-menu layout toggle โ switch the world grid between Grid and Detailed layouts.
- Markdown in world descriptions โ world descriptions render markdown, matching the in-game narration.
- In-game entity viewer โ the avatar panel gained Player / Entities tabs; the Entities view shows the first detected entity's image, clicking an entity in the list swaps to it, and clicking the image opens a full zoom view (works even for worlds without a player model). The tab fills from the characters that actually took part in the turn (once the narration finishes, so there's no mid-stream flicker); a Staged-mode character the planner invented shows as a plain, non-clickable entry.
- Entity popup โ viewing an entity now supports the same pan/zoom image view as world thumbnails, sizes the image to most of the popup with its description below, and shows a muted "No description provided." when there's no text.
- AI-context "current context only" filter โ the per-turn context viewer can hide re-generated, rolled-back, and aborted turns so you see only what the AI currently sees (on by default).
- System Prompts editor โ Settings โ System Prompts has a tab per prompt type (Narration, Thinking, Choices, Stat Updates, Location Change, Summary, plus the Staged director/character/storyboard), and Reset affects only the selected prompt. Each helper request (Choices, Stat Updates, Location Change, Summary) has a System | User toggle, so you can edit the user message it sends โ the framing and the "just output X, no story" cue โ not just the system prompt, with Player Action and Narration chips to drop the turn's values into your template. A checkbox group in Settings โ Generation turns the optional prompts (Choices, Stat Updates, Location Change) on or off โ replacing the old "type DISABLED into the prompt" trick โ and a disabled prompt hides its editor tab (worlds that used the DISABLED trick migrate to off automatically). A Verbatim turns field in the editor footer sets how many recent turns each prompt sends in full before older ones collapse into the summary recap.
- Organized Settings โ gameplay settings are grouped into Presentation (language, music, output length, auto-scroll, markdown, narration audio) and Generation (hide stat numbers, thinking, memory summaries, silent requests), so related settings sit together.
- Staged (director-led) thinking โ a new Thinking mode beside Off / Planning / Inline that plans a turn in stages for higher-quality, more consistent scenes: a director stages the scene (a brief description of where you are and what's visible) and casts who's present with each one's placement โ where they stand and what they're physically doing right now, always leading with your own position as "Player Character". The director casts only living, acting beings (people, creatures, threats โ places and objects stay in the scene description), and gives any character it invents a concrete, reusable name so they stay the same character turn to turn. Each chosen character (up to three) plans its own motivation in a separate pass, speaking in the first person ("I wantโฆ / I intend toโฆ"); and a storyboarder combines those into the plan (reacting to what you did, never scripting your next move). When no other characters are present, the motivation and storyboard passes are skipped โ the plan is just the scene and your placement โ so a solo moment stays fast and doesn't invent filler. The director's scene and cast placements are handed to the narrator alongside the storyboard beats, so the story is grounded in concrete staging and physical interactions stay consistent. The trade-off is several extra requests per turn, so it's best with a fast endpoint. Each stage is visible in the AI-context viewer and never leaks into the story.
- Full-length, progressive TTS narration โ narration is split into sentence chunks so audio is no longer cut off at ~26 s (Kokoro's per-call token cap). Playback is progressive: speech starts after the first sentence and plays gaplessly (Web Audio) as the rest generate, with a determinate progress bar (sentence X of N); the seek bar is driven by the same engine, so the clip is scrubbable/replayable with no hand-off.
- Stream Narration Audio (in the Text-to-Speech popup, default off) โ when on, text-to-speech begins synthesizing each sentence the moment it finishes streaming from the model, instead of waiting for the whole story; much lower audio latency. Off by default since TTS then runs alongside the model and can compete for the GPU on a single-machine setup.
- Narration speed โ a persisted Speed slider (0.5รโ2.0ร) in the Text-to-Speech popup controls how fast the voice reads. Applies to newly generated audio, so change it and hit the regenerate (โป) button to re-speak the current text at the new pace.
- Karaoke sentence highlighting โ while narration audio plays, the sentence currently being spoken is highlighted in place with a soft accent tint, and the highlight follows the playhead when you scrub the seek bar โ so you always know where you are in the text. Works across bold/italic/linked words within a sentence and while narration is still streaming in. On by default (toggle "Highlight while speaking" in the Text-to-Speech popup); silently does nothing on browsers without highlight support.
- Frozen audio bar โ once TTS audio exists, the play/scrub seek bar is pinned to the top of the story panel (instead of scrolling away with the text) and carries the audio-specific buttons (play, regenerate) on its row; the export and edit buttons stay in the top-right corner.
-
Export the story โ a download button beside the narration's edit pencil saves the whole playthrough's narration to a file, one turn per block, with a choice of plain text (
.txt) or Markdown (.md). - Audio settings live with the audio โ the TTS controls (voice, speed, Stream Narration Audio, Highlight while speaking) all sit together in the Text-to-Speech popup rather than the main Settings menu, since they only matter once a TTS model is loaded โ which only happens from that popup, in-game.
- Non-streaming narration generates before the rest of the turn โ with a TTS model loaded and Stream Narration Audio off, the narration's audio is now generated right after the story text and the turn's remaining requests (choices, stat updates, location change) wait for it to finish, rather than racing alongside it โ avoiding GPU contention when the model and TTS share one machine.
- Memory Summaries (Settings โ Generation, default off) โ long stories stay coherent without bloating each request. Older turns are summarized into short fact lines as they age out of recent history and fed back to the model: recent turns stay word-for-word, older turns collapse into a compact "story so far" recap, and any past turn your action references is pulled back to full detail. Recall works two ways: by keyword, and by participant โ when your action mentions a character, an older turn that character actually took part in is resurfaced even if its summary shares no words with what you typed (so returning to "talk to Mira" after many turns finds the right scene). The memory meter breaks out how much of the window the recap and rehydrated turns use. Runs an extra request per turn, so it can compete for the GPU on a single-machine setup.
- Link entities to locations from either side โ in the World Editor a location's entities are now chosen from a searchable multiselect dropdown (replacing the long checkbox list), and each entity gained a matching Locations dropdown. The two stay in sync, so the relationship can be edited from whichever side you're on.
- Steadier World Editor tabs โ the add/search row now sits below the editor tabs instead of above them, so the tab strip no longer shifts when you switch to or from the Overview tab.
-
Audience-labeled description fields โ an entity's and location's descriptions are now split by audience: Player-Facing Description (shown in-game) and AI-Facing Description (full text sent to the model), plus a new AI-Facing Summary โ a short version for use where the full description is too long, which a โจ button generates from the AI-Facing Description using your connected LLM (with undo). Existing v1.2 worlds are upgraded automatically (their old
inGameDescription/detailedDescriptionfields are renamed on import). - Planning step uses AI-Facing Summaries โ the optional pre-reply planning request now feeds the model each location's and entity's short AI-Facing Summary (falling back to the full description where none is authored), keeping the planning pass lightweight. Narration still gets the full descriptions. The plan also lists each present character with their placement โ where they are and what they're physically doing right now โ giving the narration concrete staging to work from.
- Smarter next-action choices โ the choices request sees the player's most recent action (not just the resulting narration), so suggestions track what you were actually trying to do, and it uses the short AI-Facing Summaries instead of full descriptions. It's scene-aware: it only sees the characters actually present (named in the recent narration, within a short rolling window), not the whole location roster, so it won't suggest actions for people who haven't appeared yet or already left. And the story no longer duplicates the menu โ the narrator prompt is a positive contract that writes only prose while the separate Choices step presents the options, so smaller models stop tacking a "What do you do next?" list onto the end of the narration.
- AI-context "Hydrations" view โ the AI Context viewer (the per-turn debug popup) gained a Dictionary / Hydrations toggle. "Hydrations" shows the exact words and characters used to decide what older context gets recalled for that turn, as color-coded, clickable chips that highlight their matches โ and only within the game-text request, so the choices/stats requests stay clean.
- Duplicate button in the World Editor โ every list item (stats, entities, locations, traits, groups, dictionary entries) gains a duplicate button beside delete that makes a deep copy placed right below the original. Duplicating a trait keeps it in the same group; duplicating a group copies the whole nested subtree (subgroups and traits) with fresh ids in the exact same nesting. The copy's name gets a " (Copy)" suffix.
- Markdown toolbar for the world description โ the Edit tab gained a formatting toolbar (bold, italic, headings, lists, link, quote, inline code) plus undo/redo (buttons and Ctrl+Z / Ctrl+Y); the Preview still renders through the same engine as in-game narration.
- Trait groups (folders) โ the World Editor's Traits tab is now a foldering tree: the + opens a popout to Add Group or Add Trait, and traits/groups drag to reorder and nest โ drag a row right to tuck it under the group above, left to pull it back out, with the row indenting live as you drag. New worlds start with World and Player groups. Traits and groups gain Player-Facing and AI-Facing descriptions, and traits gain an Enabled by Default flag. In play, the trait-selection popup is now tabbed by group (an implicit General tab holds ungrouped traits, nested groups add a tab row); default traits start checked, and the buttons are Next (walk the groups), Skip (start with the current selection), and Abort (cancel loading the world). A group's AI description is sent to the model as a header above its selected traits.
-
โ๏ธ Backend / invisible
- Shared hooks and helpers: a
createWorkerClientworker factory, a single IndexedDB open/promisify helper (idb),usePersistentState,useIsMobile, and a cached-thumbnail hook. - The Community Creations browser was split out of
MainMenuinto its ownCommunityCreationsBrowserview, with shared world-card/detail presentation helpers (WorldDetails). -
Trait group data model โ the world format gains a
traitGroupsarray ({ id, name, playerDescription?, aiDescription?, parentId, order }); traits move from a singledescriptiontoplayerDescription/aiDescriptionand gaingroupId,isDefault, andorder. The tree, reordering, and AI trait-context builder live in pure, unit-tested helpers (lib/traitTree.ts); legacy v1.2 worlds rename their traitdescriptionโplayerDescriptionon import. -
Character diaries โ each character present in a turn quietly records a short first-person diary entry about it, from their own point of view, generated as turns age out (opt-in via Settings โ Generation โ Character Diaries; prompt editable under System Prompts โ Diary; covers ad-hoc walk-ons too). With "Show Silent Requests" on, each entry is inspectable per turn in the AI-context viewer. In staged planning, a character's own recent diary is now fed back into its motivation pass as private memory, so it acts with continuity and only knows what it recorded โ the start of real information asymmetry between characters. Entries are stored per-turn (
AITurnResult.diaries), an additive save-shape change that rolls back with the turn; old saves read as absent.
- Shared hooks and helpers: a
-
โ๏ธ Backend / invisible
- Dead code and noisy logging, including a leftover
bufferToSentencehelper and an auth-token console log.
- Dead code and noisy logging, including a leftover
-
๐ค User-facing
- Mislabeled UI text and icons corrected.
- The memory meter is now an accurate token gauge of the model's context window โ prompt + (actually-sent) history + reserved output as a % of the window โ fixing the old bar that mixed characters and tokens and counted the full untrimmed log.
-
โ๏ธ Backend / invisible
- Entity IDs now use
crypto.randomUUID()instead of timestamp-based IDs, avoiding collisions. -
Cleaner AI prompts โ blank or unset author fields (an empty entity
type, a missing description, etc.) are no longer padded into the location/entity/trait data sent to the model, and the editor-only starting-location flag is dropped from it. Empty fields previously leaked as blank lines or the literal textundefined, which could confuse smaller models. -
Stat code runs in a real sandbox โ world-authored stat formulas (which ride inside downloaded worlds) now execute in an isolated QuickJS WebAssembly VM instead of
new Function, so they can no longer reach the page (fetch,localStorage, the DOM) โ closing an untrusted-code hole. A runtime interrupt enforces the 1-second timeout, so awhile(true)loop is actually killed instead of hanging the tab (the old after-the-fact check never fired), with memory and stack caps on top. Only whitelisted stat data crosses into the VM; the numeric result is read back and clamped exactly as before. The engine is lazily loaded on first use, so it doesn't affect page load. - Lint cleanups across the converted codebase.
- Entity IDs now use
Playing
Reference
Project