Skip to content

v0.85.0 — Character Import, phases 3–5

An import is a guess, and phases 0–2 made the guess good enough to be dangerous. v0.69.0 taught the importer to read a sheet and produce a whole character in the current shape, matched against the module's own catalogue. What it never built is the screen that says which parts of that it is confident about — so a wrong match rewrites a character's rules silently, and a partial import looks exactly like a complete one.

Decision D2 kept the close tier deliberately conservative so that nothing below the confident tier is applied without being flagged. A flag nobody sees is not a flag. Phase 3 is that screen, and it is the phase that makes the other two worth having.

For 5e, "custom" is the normal outcome, not the error path. The 2014 SRD bundles one background and one feat, so a typical sheet imports mostly custom rows. D1 chose to report that honestly rather than soften it — which only works if something reports it.

Numbered v0.85.0 because v0.84.0 is taken. A parallel session claimed it for Test Health work, on a branch cut from v0.82.0 with its own Current Release section. The same thing happened at v0.79.0 with monstergen; the resolution then and now is that the planned item moves rather than the branch being renamed. ROADMAP.md and the frontmatter were renumbered together, because the build fails if they disagree.

All seven decisions were answered on 2026-08-25 and none of them reopens here — they were answered for the whole project, not for v0.69.0's slice. One of them has a consequence this release inherits: D3 confirmed matching runs client-side, and recorded an in-process module-content path as a prerequisite of 5.3 rather than of the release that answered it. That work is scheduled nowhere, so 5.3 left this release for its own gated entry; see phase 5.

The spec came back from git history again. v0.81.0's migration split this project into four phase pages and kept the prose of each, dropping all eleven tasks and the seven decisions. That is the third release running to start by recovering its own specification.

Before either — the specs come back

  • [x] S.0 — Restore the eleven tasks. §7, §8 and §9 of the original requirements.md return to the three phase pages, with D3's unbuilt prerequisite recorded on the task it blocks. One commit, because they were lost in one.

Riding along — a production bug

  • [x] Submenus were clipped by the menu that opened them. Reported against the background token bar, where Resize on Map and Move/Copy to Map are submenus and the part you needed was the part outside the box. Two correct things interacting: Mantine's MenuSub hardcodes withinPortal: false, so a submenu renders inside the parent dropdown's DOM, and v0.82.0's P.10 clamp gives every dropdown overflowY: 'auto', which makes that DOM a scroll container. The clamp stays — what was wrong is that a submenu ever lived inside it. Fixed in the theme rather than on the two menus that were reported, on both skins, because both carry the clamp and the report only happened to arrive against the retro one.

Riding along — a playtest report

  • [x] The D&D Beyond URL route crashed the modal. characterFromDndBeyond returns only the fields it can fill — which is right for a mapper and is not a ParsedCharacterData, a shape that declares skills, attacks and origins, all of which the preview reads. The as cast made the type-checker agree and the render throw on parsedData.skills.length. It goes through parseImportedCharacter now, the same zod schema the server runs, so there is one definition of what an extraction looks like.

  • [x] The console was mostly dropzone warnings. A Mantine accept array may hold only MIME types; bare extensions in it are rejected one warning at a time, on every render. Moved to the object form, where extensions are legal — and the whole list with it, since the two forms cannot be mixed.

  • [x] Chunking made a long sheet fail MORE often. A real six-page export splits into four requests, and the first version threw on any one unparseable reply — so splitting a sheet in order to read it better quadrupled the chance of reading none of it. A bad chunk is skipped and reported now; only an all-chunks-failed read is an error, and a single-chunk read still throws, because it has nothing to salvage.

  • [x] "Import Character" imported no character. Reported after testing a real D&D Beyond PDF: the result was a token, and its Handaxe rolled damage but not to-hit. One cause. The Add-to-Map chooser's Import Character opened the token modal's own sheet importer — a second, older importer that posts to /api/ai/parse-character-sheet and writes token metadata and ad-hoc actions. None of the import work since v0.69.0 was on that path: no catalogue matching, no review, no module validation, no form-field template, no D&D Beyond route. And the attack lost its to-hit because the token shape has nowhere to put one, while ImportedAttackSchema has carried attribute, proficient and hit all along. It now creates a character with a token linked to it — the same route "New Character" already took.

  • [x] The D&D Beyond paste box was nowhere to be found, for the same reason: it lives on the whole-character importer, and that path never opened it. The map now renders that modal, so the ⓘ, the review and both D&D Beyond routes are reachable from a map.

  • [x] 4.1's field names are verified. The report came with a real export — the official Wizards fillable sheet, 1,345 filled AcroForm fields — and the template fires on it, reading name, both classes, the total level, the race and all six ability scores. One guessed name was wrong: the class field is CLASS LEVEL, two spaces, not ClassLevel. It matches anyway because the matcher normalises case and whitespace, which is that tolerance earning its keep rather than a lucky guess. The real fields are a fixture now, so this is no longer an assumption.

Phase 3 — Review and confidence

Spec: character-import-review-and-confidence

  • [x] 3.1 — Turn the preview into a review. The modal's read-only accordion becomes a per-row list showing name, tier badge (matched / close / custom) and the matched record's source tag. The information already exists; it is collapsed behind a summary count.
  • [x] 3.2 — Let the user fix a row before it is written. Accept a close match, reject it to custom, or resolve an ambiguous one from the candidate list. Nothing is created until the user confirms — which is the difference between a preview and a review.
  • [x] 3.3 — Run the module's own validation on the preview. module.validate through the existing useCharacterValidation, shown as "this character will import with N issues". The system's own vocabulary, not the importer's: the module knows what a legal character looks like and the importer does not.
  • [x] 3.4 — Make overwrite explicit. CharacterDetail's import replaces every non-empty collection on an existing character with no diff and no confirmation. Show what will be replaced, and require a confirmation.

Phase 4 — Better extraction

Spec: character-import-better-extraction

Every row this gets right is a row phase 3 does not have to ask a human about.

  • [x] 4.1 — Map known sheet templates by form field. The official fillable 5e PDF and the D&D Beyond export use stable AcroForm field names, so a template map is exact, deterministic and free — and the model stops being asked a question it can get wrong. The best ratio in the phase.

  • [x] 4.2 — Vision fallback for flat sheets. A scanned or flattened PDF has no text layer at all, so today it reads as an empty document. Render pages to images and send them to a vision model.

    **The spec's premise was wrong.** There is no "provider ladder the VBL
    detector already drives" — the VBL detector is a computer-vision pipeline
    that calls no language model, and **nothing in this repository had ever
    sent an image to one**. `resolveLLMConfig` is real and is reused
    unchanged; the multimodal request shape is new, and the two dialects
    disagree about every part of it.
    
  • [x] 4.3 — Handle long sheets. Chunk by page or section and merge, so a high-level caster's spell list cannot silently truncate the reply — silently being the problem, since a truncated import looks like a complete one.

  • [x] 4.4 — Support more document types. .docx, .md, .txt and pasted text through the one reader v0.69.0 built, per the ask's "a PDF or other document".

Phase 5 — Beyond one sheet

Spec: character-import-beyond-one-sheet

  • [x] 5.1 — Native imports. A D&D Beyond JSON export is structured data. Matching it needs no model at all, which makes it both the most accurate path and the only free one.

    **One box takes a link OR an export**, detected after pasting, with an ⓘ
    explaining how to get either. The endpoint —
    `character-service.dndbeyond.com/character/v5/character/{id}` — **sends no
    CORS headers**, verified rather than assumed, so a link goes through a
    platform proxy; an export needs no network and works for a private
    character. Both converge on one mapper, so they cannot disagree. The
    endpoint is undocumented and unofficial, so every reader is tolerant: a
    changed shape produces a thinner character, never a throw.
    
  • [x] 5.2 — Multi-character documents. A party PDF produces several characters, reusing .cat's existing "archive holds several characters" flow rather than inventing a second one.

  • 5.3 — Expose the importer as a function the Library can call — moved out of this release into its own roadmap entry, Headless character import, drafted to five tasks. It could not ship here: D3 confirmed matching runs client-side because that is where modules load, so a caller with no browser needs the module content providers to have an in-process path that does not go over HTTP — a Module API change scheduled nowhere, whose only consumer, Library Overhaul phase 6, is itself gated. Building the function now would mean building it against an interface that does not exist, for a caller that cannot call it. The release ships with the other ten.