# CLAUDE.md

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

## Commands

```sh
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`](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`](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`](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`](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:

```sh
# 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:

```sh
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`](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.
