Files
luxick 96bcc975a7 Adjust Image rendering in header
there are now layers in the card header so that the image does not
overlap any readable text
2026-08-03 17:15:03 +02:00

7.8 KiB

CLAUDE.md

Guidance for agents that work in this repository. README.md describes what the app does for its users. This file describes how it is built.

Commands

npm install
npm run dev          # http://localhost:5173
npm test             # the parity suite, see "Tests"
npm run build        # static files in dist/
npm run fixtures     # regenerate test fixtures, see "Tests"

npm run lint reports findings that come from upstream, mostly a11y/useButtonType. These findings are old, so lint is not part of CI. Before you treat a finding as new, compare it against git show upstream/main:<file>.

Where the transforms fit

upload/.rosz  ->  unzip  ->  DOMParser  ->  applyTransforms  ->  Create40kRoster*  ->  render
                                                ^
                                       src/transforms/index.js

src/App.jsx runs the transforms between the parse of the XML and the build of the roster. A toggle therefore rebuilds the cards from the original XML, and the user uploads nothing a second time. For the same reason, a saved roster holds the raw roster XML and not the parsed object.

src/transforms/config.js lists the abilities that the transforms strip and split. When you find more of them, add them to abilitiesToStrip and abilitiesToConvert.

Lore text

src/helpers/lore.js matches a roster name against public/Lore.csv in three steps:

  1. The normalized name. Case, accents, curly quotes and punctuation all differ between the roster and the export.
  2. The name with its last word in the singular form. Example: "Myphitic Blight-haulers" against "Myphitic Blight-hauler".
  3. The longest known name that the roster name ends with. Example: "Thousand Sons Chaos Spawn" against "Chaos Spawn".

This search resolves every unit in the bundled 10th-edition examples.

parseLore finds columns by header name. If someone exports the file again with a faction column, parseLore can match that column against the catalogue of the force. Nothing else has to change. Today the export has no such column, so the longest entry wins for a name with lore in more than one faction.

Two properties of the card header are less obvious than they look:

  • The italic text needs a fourth font file. ConduitITCStd shipped as three upright faces, and :root in src/index.css sets font-synthesis: none. A request for italic therefore printed upright text and gave no warning. public/fonts/ConduitITCStd Italic.woff2 and its @font-face rule correct this. The element also sets font-synthesis: style. As a result, a face that fails to load degrades to a slanted upright face and not to no italic at all.
  • The lore panel has absolute position, so it cannot make the header taller. Without help, a long legend is clipped: the longest entry in the export overruns a 15rem header at each width below approximately 1300px. A ResizeObserver on the text feeds the min-height of the header instead. The observer is necessary because the text wraps differently at each card width.

Only the 10th-edition and 11th-edition renderers show lore. Leave the 9th-edition renderer in src/9th/ alone. It lays out its header differently.

The header has five layers

The card header stacks the coloured accent bar (1), the model image (2), the name and the stat line (3) and the two lore elements (4, 5). The order comes from the official cards, where a wide image passes behind the text. Every layer therefore carries an explicit z-index, and the header sets isolation: isolate so those five numbers never meet the rest of the card. A new absolutely positioned element in the header needs a number from this scale; without one it lands under the image.

The name and the stat line are wide, mostly empty boxes, and they now lie over the image. pointer-events: none on the box with auto on the text inside keeps the drag and the wheel that place the image (ImgEditor) reaching it. A new element on layer 3 needs the same treatment, or it takes the pointer away from the image behind it.

The image box also carries a mask-image (imageFade in src/10th/Roster.jsx). A picture wider than its box would otherwise end at a hard vertical edge in the middle of the stat line.

Tests

The transforms began as three standalone Python scripts (merge_duplicate_units, remove_leader_abilities, convert_choice_abilities) that rewrote the .ros file before the upload to FancyScribe. The scripts are gone, but the tests measure against their output.

src/transforms/__fixtures__/ holds four 11th-edition rosters. For each roster it also holds the output of those scripts: <name>.{merge,strip,convert,all}.ros. The suite runs each transform over the input and asserts that the result is the same roster. This is worth more than a snapshot of the current code, because the expected output comes from a different implementation. A person verified that implementation with printed cards.

CAUTION: Do not regenerate an existing fixture from the JavaScript code. The test then compares the code against itself and passes whatever the code does. scripts/update-fixtures.mjs therefore refuses to overwrite a fixture without --force. Use the script when you add a new example roster, and read the diff:

# Put a new roster in src/transforms/__fixtures__/, then:
npm run fixtures

The comparison is structural, not textual. The Python scripts edited the file as text and kept its format byte for byte. These transforms build DOM nodes, so attribute order and whitespace differ legitimately. The suite compares generated id and typeId values as tokens. The requirement is that ids are shared and distinct in the same pattern, not that both implementations hash alike.

integration.test.js pushes the transformed document through the real roster parser and checks what lands on the card:

  • one Skitarii card that carries both weapons
  • the data-tether ability on the model that brings it
  • no Support ability
  • one row for each Canticle

The tests run under jsdom, which does not implement scoped selectors like a browser. force.querySelectorAll("force>selections>…") finds nothing under jsdom. A browser matches the selector against the whole tree and then keeps the descendants of force, so the parser found no units at all. The two call sites that depend on this behavior now use :scope>…, which works in both.

Merges from upstream

FancyScribe is under active development, and 11th-edition support landed recently. This fork touches little of it. The upstream remote is configured:

git fetch upstream
git merge upstream/main

Expect conflicts only in src/App.jsx, index.html, vite.config.js and package.json. src/transforms/ is completely new and never conflicts.

The fork also hides three things that upstream shows, because a generic datasheet cannot use them: the roster overview card with its charts, the unit composition, and the points cost of each unit. These decisions live in src/fork.js, which upstream does not have. src/10th/Roster.jsx uses them on as few lines as possible:

  • It imports ShortSummaryTable from ../fork, not from ./ShortSummaryTable. This one-line change leaves the render site untouched. The upstream component stays in the tree, unused, so its future diffs continue to apply.
  • hideModelCount is pinned to HIDE_UNIT_COMPOSITION and is no longer a piece of checkbox state.

Only the two checkboxes and the pts span are deleted. A merge that touches them therefore reports a conflict instead of quietly bringing them back.

vite.config.js sets base: "/", because the app is served at the root of a domain. Upstream sets /fancyscribe for GitHub Pages. If you serve the app from a subpath, change this setting.