Compare commits

...

12 Commits

Author SHA1 Message Date
luxick 836a53e95e Rename Invulnerable Save for 11th edition 2026-08-03 17:36:53 +02:00
luxick 0623ba903b Update AdMech background 2026-08-03 17:32:52 +02:00
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
luxick e7ecbcabc0 Add lore texts 2026-08-03 11:20:31 +02:00
luxick 1846df6ac2 Revert "Comma serparate keywords on sheet"
This reverts commit 41edd40cd5.
2026-07-30 20:39:51 +02:00
luxick 163ee34d8d Use ConduitITCStd font 2026-07-25 20:21:19 +02:00
luxick 783ff0df1e Remove unused ci workflow 2026-07-25 19:45:44 +02:00
luxick 41edd40cd5 Comma serparate keywords on sheet
CI / check (push) Has been cancelled
2026-07-25 19:44:06 +02:00
luxick cf67918bc0 Update README.md
CI / check (push) Has been cancelled
2026-07-25 19:37:32 +02:00
luxick 60850f026c Update nginx-scribe.luxick.de.conf
CI / check (push) Has been cancelled
2026-07-25 19:36:41 +02:00
luxick 444d26fd47 Improve Keyword rendering
CI / check (push) Has been cancelled
2026-07-25 16:08:21 +02:00
luxick b35c88b451 Remove unneded upstream features
CI / check (push) Has been cancelled
2026-07-25 15:38:41 +02:00
18 changed files with 2606 additions and 251 deletions
-32
View File
@@ -1,32 +0,0 @@
# Lint, test and build on every push.
#
# This needs a Gitea Actions runner registered against the instance. If there
# isn't one, delete this file - nothing else depends on it, and `npm test &&
# npm run build` locally covers the same ground.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
# Asserts the transforms still produce the rosters the Python scripts they
# were ported from produced; see README.md.
- run: npm test
- run: npm run build
+171
View File
@@ -0,0 +1,171 @@
# 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.
+104 -118
View File
@@ -1,152 +1,138 @@
# BrevyScribe # BrevyScribe
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
**generic datasheets** rather than a record of one particular army list. It prints **generic datasheets** instead of a record of one army list.
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
datacards, showing exactly the wargear you picked. That is the right thing for a datacards. The cards show only the wargear that you selected. That is correct
list you are about to play, but the wrong thing for a reference card you want to for a list that you play today. It is not correct for a reference card that you
keep: the official cards show *every* option a unit could take. BrevyScribe keep, because the official cards show *every* option of a unit. BrevyScribe
rewrites the roster on the way in so the printed cards read like the official rewrites the roster before it renders the cards. As a result, the printed cards
ones. read like the official ones.
Three transforms do the work, all of them toggleable in the UI: Three transforms do this work. You can switch each one on or off in the user
interface.
| Transform | What it does | | Transform | What it does |
| --- | --- | | --- | --- |
| **Merge duplicates** | Mutually exclusive wargear forces you to take a datasheet twice - one Skatros with a radium jezzail, another with a transuranic arquebus. Copies of the same unit are folded into one card carrying every option. Points are not summed; the card keeps the highest cost of the copies it absorbed. | | **Merge duplicates** | Mutually exclusive wargear makes you take one datasheet two times. One Skatros has a radium jezzail, another has a transuranic arquebus. This transform folds the copies of a unit into one card that carries all options. |
| **Drop Leader/Support** | Removes the attachment rules. Once the army is built they say nothing you need mid-game, and they are long enough to push the rules you *do* need off the card. | | **Drop Leader/Support** | This transform removes the attachment rules. After the army is built, these rules tell you nothing that you need during a game. They are also long, and they push the necessary rules off the card. |
| **Split choice abilities** | An ability like *Canticles of the Omnissiah* arrives as one blob of text. This splits it into the intro rule plus one titled row per option, which is how the datasheets print it. | | **Split choice abilities** | An ability such as *Canticles of the Omnissiah* arrives as one block of text. This transform divides it into the intro rule and one titled row for each option. The official datasheets print it in this form. |
Everything still runs in the browser. There is no server component, no upload, The app does all of its work in the browser. There is no server component, no
no account, and no analytics; rosters are held in `localStorage` and never leave upload, no account and no analytics. Rosters stay in `localStorage` and never
the machine. leave your machine.
## Running it ## Lore text
Official datasheets print a paragraph of flavor text to the right of the model
image. The **Show Lore Text** toggle prints this text. The card then gives the
right third of its image to the text, behind a gradient that fades the image
into the dark. Cards without an entry keep the full-width image.
[`public/Lore.csv`](public/Lore.csv) supplies the text. The file is a
pipe-delimited export in this form:
```
name|legend
Custodian Guard|These warriors form the backbone of the shield companies, ...
```
The app fetches this file only when a roster is on screen. A missing or bad
file means that the cards print without lore text.
The names in a roster do not match the export exactly, so the app searches for
the closest entry. This search is a heuristic, and you can correct it:
- To change the text of a card, edit it in place. The app keeps your edit in
`localStorage` under `lore_<unit name>`. If you clear the edit, the text of
the export comes back.
- If the export does not cover a unit, the card shows an **Add lore** button.
About 40 names carry lore for more than one faction. Chaos Daemons and Death
Guard both field Plaguebearers, and three armies field a Ministorum Priest. The
export has no column that separates them, so the longest entry wins.
Only the 10th-edition and 11th-edition cards show lore text. The 9th-edition
renderer lays out its header differently.
## Run it on your machine
```sh ```sh
npm install npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
npm test # the parity suite, see below npm test
npm run build # static files into dist/ npm run build # static files in dist/
``` ```
`npm run lint` reports pre-existing findings inherited from upstream (mostly ## Deploy to scribe.luxick.de
`a11y/useButtonType`), so it is deliberately not part of CI. Compare against
`git show upstream/main:<file>` before treating any of them as new.
## Deploying to scribe.luxick.de The build output is **static files**. There is no application server and no
socket, so nginx serves the files directly. The app does all of its work in the
browser. For the same reason, upstream FancyScribe can live on GitHub Pages.
The build output is **static files** - no application server, no socket, nothing The server needs no Node. Do the setup one time, in two stages. The two stages
for nginx to proxy to. The app does all its work in the browser, which is why are necessary because the real configuration names a certificate, and nginx
upstream can live on GitHub Pages. refuses to load a configuration whose certificate does not exist. Therefore
nginx first comes up on port 80 only. That is far enough for certbot to answer
One-time setup on the server: the challenge.
```sh ```sh
sudo mkdir -p /var/www/brevyscribe sudo mkdir -p /var/www/brevyscribe /var/www/certbot
sudo chown "$USER" /var/www/brevyscribe sudo chown "$USER" /var/www/brevyscribe
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
# Stage 1: HTTP only, so that nginx starts without a certificate.
sudo tee /etc/nginx/sites-available/scribe.luxick.de >/dev/null <<'EOF'
server {
listen 80;
listen [::]:80;
server_name scribe.luxick.de;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
}
EOF
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
sudo certbot --nginx -d scribe.luxick.de sudo nginx -t && sudo systemctl reload nginx
sudo certbot certonly --webroot -w /var/www/certbot -d scribe.luxick.de
# Stage 2: the real configuration, now that the certificate is on disk.
sudo cp deploy/nginx-scribe.luxick.de.conf /etc/nginx/sites-available/scribe.luxick.de
sudo nginx -t && sudo systemctl reload nginx sudo nginx -t && sudo systemctl reload nginx
``` ```
Then every deploy is one command from a checkout on your own machine - it runs Use `certonly --webroot`, not `--nginx`. The configuration in the repository
the tests, builds, and rsyncs `dist/` over. The server needs no Node. carries its own redirect and TLS block. The nginx plugin rewrites the installed
file, and the installed file then drifts away from the one in the repository.
The port 80 block keeps its `acme-challenge` location for this reason, so
renewals continue to work without your attention.
CAUTION: The timer of certbot does not reload nginx. Put a one-line hook that
runs `systemctl reload nginx` in `/etc/letsencrypt/renewal-hooks/deploy/`.
Without this hook, the server serves a renewed certificate only after the next
restart.
Each deploy is then one command from a checkout on your own machine. The
command runs the tests, builds the app and copies `dist/` to the server with
rsync.
```sh ```sh
./deploy/deploy.sh ./deploy/deploy.sh
``` ```
`--delete` is deliberate: asset filenames are content-hashed, so without it old The script uses `--delete` on purpose. The asset filenames contain a hash of
bundles would pile up forever. There is no state on the server and nothing to the content, so old bundles collect on the server without this flag. There is
back up; every roster lives in the browser's `localStorage`. no state on the server and nothing to back up. Every roster stays in the
`localStorage` of the browser.
`vite.config.js` sets `base: "/"`, because this is served at a domain root - `index.html` loads Noto Sans from Google Fonts, so a page load contacts
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a `fonts.googleapis.com`. If you do not want this request, delete the `<link>`
subpath, that is the setting to change. tags. Then host the font next to the fonts in `public/fonts/`.
Note that `index.html` loads Noto Sans from Google Fonts, so a page load reaches
out to `fonts.googleapis.com`. If you would rather it did not, drop the
`<link>` tags and self-host the font next to the ones already in `public/fonts/`.
## How the transforms fit in
```
upload/.rosz -> unzip -> DOMParser -> applyTransforms -> Create40kRoster* -> render
^
src/transforms/index.js
```
`src/App.jsx` runs them between parsing the XML and building the roster, so
flipping a toggle rebuilds from the original XML with no re-upload. That is also
why saved rosters hold the raw roster XML rather than the parsed object.
Which abilities get stripped and split is configured in
[`src/transforms/config.js`](src/transforms/config.js) - add to
`abilitiesToStrip` and `abilitiesToConvert` as you meet more of them.
## The test suite
The transforms began as three standalone Python scripts (`merge_duplicate_units`,
`remove_leader_abilities`, `convert_choice_abilities`) that rewrote the `.ros`
file before it was uploaded to FancyScribe. They are gone now, but they are what
the tests measure against.
`src/transforms/__fixtures__/` holds four 11th-edition rosters plus, for each,
the output those scripts produced: `<name>.{merge,strip,convert,all}.ros`. The
suite runs each transform over the input and asserts it produces the same roster.
That is worth more than a snapshot of the current code, because the expected
output came from a **different implementation** that was verified by actually
printing the cards.
That property is easy to destroy and hard to notice: regenerate a fixture from
the JS and the test compares the code against itself, passing no matter what it
does. So `scripts/update-fixtures.mjs` will not overwrite an existing fixture
without `--force`. Use it when you add a new example roster, and read the diff:
```sh
# drop a new roster in src/transforms/__fixtures__/, then
npm run fixtures
```
Comparison is structural rather than textual. The scripts edited the file as text
to keep its formatting byte for byte; these transforms build DOM nodes, so
attribute order and whitespace legitimately differ. Generated `id`/`typeId`
values are compared as *tokens*, so what has to match is that ids are shared and
distinct in the same pattern, not that both implementations hash alike.
`integration.test.js` goes further and pushes the transformed document through
the real roster parser, checking what lands on the card: one Skitarii card
carrying both weapons, the data-tether ability travelling with the model that
brings it, no Support ability left, and each Canticle its own row.
One wrinkle: the tests run under jsdom, which does not implement scoped selectors
the way browsers do. `force.querySelectorAll("force>selections>…")` finds nothing
there, where a browser matches the selector against the whole tree and then keeps
the descendants of `force` - so under jsdom the parser found no units at all. The
two call sites that relied on it now say `:scope>…`, which means the same thing in
a browser and works in both.
## Staying current with upstream
FancyScribe is actively developed - 11th edition support landed recently - and
this fork touches little of it. `upstream` is wired up:
```sh
git fetch upstream
git merge upstream/main
```
Conflicts should be confined to `src/App.jsx`, `index.html`, `vite.config.js` and
`package.json`. `src/transforms/` is entirely new and will never conflict.
## Credit ## Credit
All the hard parts - the parsing, the card layout, the print CSS - are The difficult parts are the work of
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic [Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
descended in turn from layout and the print CSS. The parsing logic comes in turn from
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no [PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
licence file, so treat this fork as a private, personal deployment rather than license file. Treat this fork as a private, personal deployment, not as
something to redistribute. something that you redistribute.
+14 -3
View File
@@ -20,14 +20,25 @@ server {
} }
server { server {
listen 443 ssl; # The `http2 on;` directive only exists from nginx 1.25.1; on older builds
listen [::]:443 ssl; # (Debian bookworm ships 1.22, Ubuntu 22.04 ships 1.18) it is an unknown
http2 on; # directive and the config will not load. This form works everywhere, at the
# price of a deprecation warning on 1.25.1+.
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name scribe.luxick.de; server_name scribe.luxick.de;
ssl_certificate /etc/letsencrypt/live/scribe.luxick.de/fullchain.pem; ssl_certificate /etc/letsencrypt/live/scribe.luxick.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/scribe.luxick.de/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/scribe.luxick.de/privkey.pem;
# `certbot certonly` issues the certificate and stops there - unlike the nginx
# plugin it never writes an `options-ssl-nginx.conf` include, so the protocol
# and session settings have to live here.
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
root /var/www/brevyscribe; root /var/www/brevyscribe;
index index.html; index index.html;
+1713
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+175 -34
View File
@@ -19,8 +19,18 @@ import { Arrow, wavyLine } from "../assets/icons";
import { Weapons, hasDifferentProfiles } from "./Weapons"; import { Weapons, hasDifferentProfiles } from "./Weapons";
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
import { ImgEditor } from "./ImgEditor"; import { ImgEditor } from "./ImgEditor";
import { trySettingLocalStorage } from "../helpers/useLocalStorage"; import {
import { ShortSummaryTable } from "./ShortSummaryTable"; trySettingLocalStorage,
useLocalStorage,
} from "../helpers/useLocalStorage";
import { useLore } from "../helpers/useLore";
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
// Soft left edge for the model image in the card header. Opaque over the right
// two thirds of the image box, so only the part that reaches across the stat
// line is faded.
const imageFade =
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 16%, rgba(0,0,0,1) 36%)";
const getShortSummarySubtitle = (force) => { const getShortSummarySubtitle = (force) => {
const details = []; const details = [];
@@ -46,6 +56,7 @@ export const Roster = ({
onePerPage, onePerPage,
colorUserChoice, colorUserChoice,
primaryColor, primaryColor,
showLore,
}) => { }) => {
if (!roster) { if (!roster) {
return null; return null;
@@ -70,6 +81,7 @@ export const Roster = ({
force={force} force={force}
onePerPage={onePerPage} onePerPage={onePerPage}
colorUserChoice={colorUserChoice} colorUserChoice={colorUserChoice}
showLore={showLore}
/> />
</React.Fragment> </React.Fragment>
))} ))}
@@ -77,7 +89,7 @@ export const Roster = ({
); );
}; };
const Force = ({ force, onePerPage, colorUserChoice }) => { const Force = ({ force, onePerPage, colorUserChoice, showLore }) => {
const { units, factionRules, rules, catalog } = force; const { units, factionRules, rules, catalog } = force;
const mergedRules = new Map([...factionRules, ...rules]); const mergedRules = new Map([...factionRules, ...rules]);
@@ -173,6 +185,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
onePerPage={onePerPage} onePerPage={onePerPage}
forceRules={rules} forceRules={rules}
colorUserChoice={colorUserChoice} colorUserChoice={colorUserChoice}
showLore={showLore}
/> />
</div> </div>
))} ))}
@@ -181,9 +194,16 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
); );
}; };
const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => { const Unit = ({
unit,
catalog,
onePerPage,
forceRules,
colorUserChoice,
showLore,
}) => {
const [hide, setHide] = useState(false); const [hide, setHide] = useState(false);
const [hideModelCount, setHideModelCount] = useState(false); const hideModelCount = HIDE_UNIT_COMPOSITION;
const uploadRef = useRef(); const uploadRef = useRef();
let { let {
name, name,
@@ -201,6 +221,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
const hasImage = image && image !== "undefined"; const hasImage = image && image !== "undefined";
const [bgRemoved, setBgRemoved] = useState(false); const [bgRemoved, setBgRemoved] = useState(false);
// Flavour text, as the official cards print it beside the model image. The
// export is matched on the unit name, and whatever it comes back with can be
// edited in place - the name match is a heuristic, and a few names carry
// lore for more than one faction. An empty edit falls back to the export.
const lore = useLore();
const [loreOverride, setLoreOverride] = useLocalStorage(`lore_${name}`);
const loreText =
loreOverride && loreOverride !== "undefined"
? loreOverride
: lore?.lookup(name, catalog);
const hasLore = showLore && Boolean(loreText);
// The panel is positioned absolutely, so a long legend cannot push the header
// taller by itself and the last lines would be clipped - the longest entry in
// the export overruns a 15rem header at any width below about 1300px. Measure
// the text instead and let the header grow. Observed rather than measured
// once, because the wrap changes with the card width.
const loreRef = useRef(null);
const [loreHeight, setLoreHeight] = useState(0);
// hasLore is what mounts the observed element, so the effect has to re-run on
// it; a ref is not a reactive value, so the rule cannot see that.
// biome-ignore lint/correctness/useExhaustiveDependencies: see above
useEffect(() => {
const element = loreRef.current;
if (!element) return;
const observer = new ResizeObserver(() =>
setLoreHeight(element.scrollHeight),
);
observer.observe(element);
return () => observer.disconnect();
}, [hasLore]);
const weapons = [...meleeWeapons, ...rangedWeapons]; const weapons = [...meleeWeapons, ...rangedWeapons];
const weaponDescriptions = weapons const weaponDescriptions = weapons
@@ -315,23 +367,6 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
}} }}
> >
<div className="flex justify-end gap-3 pb-0.5"> <div className="flex justify-end gap-3 pb-0.5">
<label
className="print-display-none"
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 4,
userSelect: "none",
}}
>
<input
className="hide-model-selection"
type="checkbox"
onChange={(e) => setHideModelCount(e.target.checked)}
/>
<span className="print-display-none">Hide Unit Composition</span>
</label>
<label <label
className="print-display-none" className="print-display-none"
style={{ style={{
@@ -349,6 +384,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
<div <div
className="min-h-[15rem]" className="min-h-[15rem]"
style={{ style={{
// 15rem unless the legend needs more; 44px is the panel's padding.
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
paddingTop: 24, paddingTop: 24,
paddingBottom: 4, paddingBottom: 4,
background: background:
@@ -357,6 +394,9 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
backgroundSize: "cover", backgroundSize: "cover",
color: "#fff", color: "#fff",
position: "relative", position: "relative",
// Keeps the header's five layers to itself, so their z-indices
// never have to be compared against the rest of the card.
isolation: "isolate",
}} }}
> >
<div <div
@@ -375,6 +415,11 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
top: 0, top: 0,
width: "100%", width: "100%",
display: "flex", display: "flex",
// The model image sits between this accent bar and the text,
// as it does on the official cards. Every layer of the header
// therefore needs an explicit z-index: bar 1, image 2, text 3,
// lore 4 and 5.
zIndex: 1,
}} }}
> >
<div <div
@@ -415,25 +460,29 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
lineHeight: "1", lineHeight: "1",
fontWeight: 800, fontWeight: 800,
textTransform: "uppercase", textTransform: "uppercase",
zIndex: 1, zIndex: 3,
position: "relative", position: "relative",
display: "flex", display: "flex",
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginBottom: 2, marginBottom: 2,
// This box is as wide as the card and now lies over the image,
// where it would swallow the drag and the wheel that place the
// image. Only the glyphs need the pointer.
pointerEvents: "none",
}} }}
> >
{name} <span style={{ pointerEvents: "auto" }}>{name}</span>
<span style={{ textTransform: "initial", fontSize: "1.2rem" }}>
{cost.points}pts
</span>
</div> </div>
<div className="relative flex gap-4"> <div className="pointer-events-none relative z-[3] flex gap-4">
<div <div
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 6, gap: 6,
// See the note on the name above. The stat boxes are narrow,
// but the box around them reaches the far edge of the card.
pointerEvents: "auto",
}} }}
> >
{modelStats.map((model, index) => ( {modelStats.map((model, index) => (
@@ -450,20 +499,101 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
</div> </div>
</div> </div>
</div> </div>
{hasLore && (
<>
{/* Wider than the text it sits behind, so the model image fades
into the dark rather than ending at a hard edge. */}
<div <div
style={{ style={{
position: "absolute", position: "absolute",
right: 0, right: 0,
top: 0, top: 0,
bottom: 0,
width: "40%",
zIndex: 4,
pointerEvents: "none",
background:
"linear-gradient(90deg, rgba(0,0,0,0) 0%, rgba(0,0,0,.55) 40%, rgba(0,0,0,.7) 100%)",
}}
/>
<div
style={{
position: "absolute",
right: 0,
top: 0,
bottom: 0,
width: "29%",
zIndex: 5,
display: "flex",
alignItems: "center",
padding: "30px 14px 14px 6px",
overflow: "hidden",
}}
>
{/* Editable in place: see the note on `loreText` above. */}
<div
ref={loreRef}
contentEditable
suppressContentEditableWarning
spellCheck={false}
title="Click to correct this text. Clearing it restores the text from Lore.csv."
onBlur={(e) =>
setLoreOverride(e.currentTarget.innerText.trim())
}
style={{
fontStyle: "italic",
// index.css turns font synthesis off globally, so if the
// italic face fails to load this would print upright. Let
// this one element fall back to a slanted upright face.
fontSynthesis: "style",
fontSize: "1rem",
lineHeight: 1.32,
textShadow: "0 1px 2px rgba(0,0,0,.6)",
outline: "none",
}}
>
{loreText}
</div>
</div>
</>
)}
<div
style={{
position: "absolute",
// The image gives up its right-hand third to the lore panel, which
// is where the official cards put the flavour text. Cards without
// lore keep the full-width image they have always had.
right: hasLore ? "29%" : 0,
top: 0,
height: "100%", height: "100%",
bottom: 0, bottom: 0,
width: "60%", width: hasLore ? "40%" : "60%",
zIndex: 100, zIndex: 2,
overflow: "hidden", overflow: "hidden",
// A picture wider than its box would otherwise end at a hard
// vertical edge over the stat line. The official cards let it
// fade into the dark background instead. The mask fades the
// image itself, so the background stays untouched.
maskImage: imageFade,
WebkitMaskImage: imageFade,
}} }}
> >
{hasImage && <ImgEditor image={image} name={name} />} {hasImage && <ImgEditor image={image} name={name} />}
<div className="absolute right-[1px] top-[3px] flex items-center gap-1.5"> <div className="absolute right-[1px] top-[3px] flex items-center gap-1.5">
{showLore && lore && !loreText && (
<button
type="button"
className="button print-display-none border-none bg-[#f0f0f0e6] hover:bg-[#f0f0f0]"
style={{
padding: "1px 4px",
fontSize: "0.8rem",
}}
onClick={() => setLoreOverride(`Lore for ${name}.`)}
title="Lore.csv has no entry under this name. Add the text by hand."
>
Add lore
</button>
)}
{hasImage && !bgRemoved && ( {hasImage && !bgRemoved && (
<button <button
type="button" type="button"
@@ -900,7 +1030,7 @@ const InvulRow = ({ hasInvul }) => {
backgroundColor: "var(--primary-color)", backgroundColor: "var(--primary-color)",
}} }}
> >
INVULNERABLE SAVE{isSpecialInvul ? "*" : ""} INSV{isSpecialInvul ? "*" : ""}
</span> </span>
</div> </div>
); );
@@ -1378,8 +1508,15 @@ const makeKeywordsBold = (text) => {
} }
} }
// replace words wrapped in ^^ ^^ with <strong> tags // replace words wrapped in ^^ ^^ with <strong> tags. ^^ is the exporter's
newValue = newValue.replace(/\^\^([^\^]+)\^\^/g, "<strong>$1</strong>"); // datasheet-keyword marker, and the printed cards set keywords in bold
// uppercase - the same treatment boldKeywords gives the ones it knows. The
// case comes from CSS rather than toUpperCase() because the replacements above
// have already put HTML in here, and var(--primary-color) is case-sensitive.
newValue = newValue.replace(
/\^\^([^\^]+)\^\^/g,
'<strong style="text-transform: uppercase;">$1</strong>',
);
// replace words wrapped in ** ** with <strong> tags // replace words wrapped in ** ** with <strong> tags
newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>"); newValue = newValue.replace(/\*\*([^\*]+)\*\*/g, "<strong>$1</strong>");
@@ -1504,7 +1641,11 @@ const OtherAbilities = ({ abilities }) => {
paddingBottom: 4, paddingBottom: 4,
}} }}
> >
<span style={{ fontWeight: 700 }}>{name}:</span> {value} <span style={{ fontWeight: 700 }}>{name}:</span>{" "}
<span
className="whitespace-pre-line"
dangerouslySetInnerHTML={{ __html: makeKeywordsBold(value) }}
/>
</td> </td>
</tr> </tr>
))} ))}
+6 -24
View File
@@ -84,26 +84,15 @@ function App() {
const [roster, setRoster] = useState(); const [roster, setRoster] = useState();
const [edition, setEdition] = useState(10); // [9, 10, 11] const [edition, setEdition] = useState(10); // [9, 10, 11]
const [onePerPage, setOnePerPage] = useState(false); const [onePerPage, setOnePerPage] = useState(false);
const [showLore, setShowLore] = useState(true);
const [primaryColor, setPrimaryColor] = useState("#536766"); const [primaryColor, setPrimaryColor] = useState("#536766");
const [colorUserChoice, setColorUserChoice] = useState(false); const [colorUserChoice, setColorUserChoice] = useState(false);
const [hideModelSelections, setHideModelSelections] = useState(false);
const uploadRef = useRef(); const uploadRef = useRef();
const throttledSetPrimaryColor = useRef( const throttledSetPrimaryColor = useRef(
throttle((color) => setPrimaryColor(color), 50), throttle((color) => setPrimaryColor(color), 50),
).current; ).current;
const toggleHideModelSelections = (hide) => {
const checkboxes = document.querySelectorAll(
'input[type="checkbox"].hide-model-selection',
);
checkboxes.forEach((checkbox) => {
if (checkbox.checked !== hide) {
checkbox.click();
}
});
};
async function handleFileSelect(event) { async function handleFileSelect(event) {
const files = event?.target?.files; const files = event?.target?.files;
@@ -431,9 +420,6 @@ function App() {
One Datacard per Page when Printing One Datacard per Page when Printing
</span> </span>
</label> </label>
{
// only show when 10th or 11th edition
(edition === 10 || edition === 11) && (
<label <label
style={{ style={{
display: "flex", display: "flex",
@@ -441,20 +427,15 @@ function App() {
gap: 4, gap: 4,
minHeight: 26, minHeight: 26,
}} }}
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
> >
<input <input
type="checkbox" type="checkbox"
value={hideModelSelections} checked={showLore}
onChange={(e) => { onChange={(e) => setShowLore(e.target.checked)}
setHideModelSelections(e.target.checked);
toggleHideModelSelections(e.target.checked);
}}
className="hide-model-selection"
/> />
<span className="select-none">Hide all Unit Compositions</span> <span className="select-none">Show Lore Text</span>
</label> </label>
)
}
<div style={{ display: "flex", alignItems: "center", gap: 4 }}> <div style={{ display: "flex", alignItems: "center", gap: 4 }}>
<label style={{ display: "flex", alignItems: "center", gap: 4 }}> <label style={{ display: "flex", alignItems: "center", gap: 4 }}>
<input <input
@@ -500,6 +481,7 @@ function App() {
onePerPage={onePerPage} onePerPage={onePerPage}
colorUserChoice={colorUserChoice} colorUserChoice={colorUserChoice}
primaryColor={primaryColor} primaryColor={primaryColor}
showLore={showLore}
/> />
)} )}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 KiB

After

Width:  |  Height:  |  Size: 928 KiB

+23
View File
@@ -0,0 +1,23 @@
// Fork-local display decisions.
//
// BrevyScribe prints the generic datasheet for a unit rather than a record of
// one particular list, so a few of the things FancyScribe shows have nothing
// left to say here. They are switched off from this file - which upstream does
// not have, and which therefore can never conflict - so that the components
// upstream owns are touched on as few lines as possible.
// The roster overview table summarises the list that was uploaded: its total
// cost, a unit-by-unit breakdown and the charts drawn from them. None of that
// survives the transforms, so the card is not rendered at all.
//
// 10th/Roster.jsx imports this in place of ./ShortSummaryTable, which leaves the
// render site there byte-identical to upstream. ShortSummaryTable.jsx itself is
// kept in the tree, unused, so upstream changes to it keep merging cleanly (Vite
// tree-shakes it, and chart.js with it, out of the build).
export const ShortSummaryTable = () => null;
// The unit composition lists the models this particular list took, which is the
// list-specific detail the fork exists to strip - and after duplicate units are
// merged into one card it is misleading as well. It is always hidden, and the
// checkboxes that used to toggle it are gone.
export const HIDE_UNIT_COMPOSITION = true;
+158
View File
@@ -0,0 +1,158 @@
// Lookup of the flavour text that official datasheets print to the right of the
// model image. The data lives in `public/Lore.csv`, a pipe-delimited export of
// `name|legend` (plus an optional `faction` column, see `buildLoreIndex`).
//
// Nothing about roster names is reliable enough for an exact lookup: the same
// unit is spelled "Tech-priest Dominus" in one place and "Tech-Priest Dominus"
// in another, a roster may name a unit in the plural where the export uses the
// singular ("Myphitic Blight-haulers" / "Myphitic Blight-hauler"), and faction
// catalogues prefix names that the export does not ("Thousand Sons Chaos
// Spawn" / "Chaos Spawn"). So the index is keyed by a normalised form and
// consulted through three widening attempts.
/**
* Case, accents, curly quotes and punctuation all vary between the export and
* the roster, and none of them carry meaning here, so collapse the lot.
*/
export const normalizeName = (name) =>
String(name ?? "")
.normalize("NFKD")
.replace(/\p{M}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, " ")
.trim();
/**
* Crude singularisation of the last word, which is the only place a roster and
* the export tend to disagree on number. Deliberately not a real stemmer: it
* runs over both sides of the comparison, so it only has to be consistent, not
* correct.
*/
const singularize = (word) => {
if (word.length < 4) return word;
if (word.endsWith("ies")) return `${word.slice(0, -3)}y`;
if (/(?:ss|sh|ch|x|z)es$/.test(word)) return word.slice(0, -2);
if (word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
return word;
};
const stemKey = (normalized) => {
const words = normalized.split(" ");
if (!words.length) return normalized;
words[words.length - 1] = singularize(words[words.length - 1]);
return words.join(" ");
};
const splitLines = (text) =>
String(text ?? "")
.replace(/^\ufeff/, "")
.split(/\r?\n/);
/**
* Parses the export into `{ name, legend, faction }` rows. Rows without a
* legend are dropped - the export carries a few hundred of them, one per unit
* whose lore has not been transcribed yet, and they would otherwise shadow a
* usable entry for the same name.
*
* Columns are located by the header line, so adding a `faction` column (see
* `pickEntry`) does not need a code change.
*/
export const parseLore = (text) => {
const lines = splitLines(text).filter((line) => line.trim());
if (!lines.length) return [];
const header = lines[0].split("|").map((h) => h.trim().toLowerCase());
const nameCol = header.indexOf("name");
const legendCol = header.indexOf("legend");
const factionCol = header.indexOf("faction");
if (nameCol === -1 || legendCol === -1) return [];
const rows = [];
for (const line of lines.slice(1)) {
const fields = line.split("|");
const name = fields[nameCol]?.trim();
const legend = fields[legendCol]?.trim();
if (!name || !legend) continue;
rows.push({
name,
legend,
faction: factionCol === -1 ? "" : (fields[factionCol]?.trim() ?? ""),
});
}
return rows;
};
/**
* Several names carry more than one legend - either the same unit reworded
* between editions, or a genuinely different unit sharing a name across
* factions (Chaos Daemons and Death Guard both field Plaguebearers; three
* different armies field a Ministorum Priest).
*
* Given a `faction` column in the export, that ambiguity is resolvable and we
* prefer the entry whose faction matches the card. Without one - which is the
* case for today's export - fall back to the longest legend. That is not
* always the *right* variant, but it is deterministic, and the competing
* variants are near-identical rewrites in all but a handful of cases.
*/
const pickEntry = (entries, faction) => {
const wanted = normalizeName(faction);
if (wanted) {
const match = entries.find((entry) => {
const entryFaction = normalizeName(entry.faction);
return (
entryFaction &&
(entryFaction === wanted ||
wanted.includes(entryFaction) ||
entryFaction.includes(wanted))
);
});
if (match) return match;
}
return entries.reduce((best, entry) =>
entry.legend.length > best.legend.length ? entry : best,
);
};
/**
* Builds the lookup. `lookup(name, faction)` returns the legend string, or
* `undefined` when the unit has no entry.
*/
export const buildLoreIndex = (text) => {
const exact = new Map();
const stems = new Map();
for (const row of parseLore(text)) {
const key = normalizeName(row.name);
if (!key) continue;
if (!exact.has(key)) exact.set(key, []);
exact.get(key).push(row);
const stem = stemKey(key);
if (!stems.has(stem)) stems.set(stem, []);
stems.get(stem).push(row);
}
// Multi-word keys, longest first, for the trailing-name pass below. Single
// word keys are excluded: "Guard" or "Rangers" would match half the export.
const suffixKeys = [...stems.keys()]
.filter((key) => key.includes(" "))
.sort((a, b) => b.length - a.length);
const lookup = (name, faction) => {
const normalized = normalizeName(name);
if (!normalized) return undefined;
const entries =
exact.get(normalized) ??
stems.get(stemKey(normalized)) ??
// Last resort: the roster name ends with a name we know, which is how
// faction-prefixed datasheets ("Thousand Sons Chaos Spawn") arrive.
stems.get(
suffixKeys.find((key) => stemKey(normalized).endsWith(` ${key}`)),
);
return entries ? pickEntry(entries, faction).legend : undefined;
};
return { lookup, size: exact.size };
};
+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { buildLoreIndex, normalizeName, parseLore } from "./lore";
const csv = (...lines) => `name|legend\n${lines.join("\n")}\n`;
describe("normalizeName", () => {
it("collapses the casing, punctuation and quote style that vary between exports", () => {
expect(normalizeName("Tech-priest Dominus")).toBe("tech priest dominus");
expect(normalizeName("Tech-Priest Dominus")).toBe("tech priest dominus");
expect(normalizeName("Khornes Hounds")).toBe("khorne s hounds");
expect(normalizeName("Khorne's Hounds")).toBe("khorne s hounds");
});
it("survives missing input", () => {
expect(normalizeName(undefined)).toBe("");
expect(normalizeName(null)).toBe("");
});
});
describe("parseLore", () => {
it("reads the pipe-delimited export, BOM and CRLF included", () => {
const rows = parseLore("name|legend\r\nCustodian Guard|Stalwart.\r\n");
expect(rows).toEqual([
{ name: "Custodian Guard", legend: "Stalwart.", faction: "" },
]);
});
it("drops rows whose legend has not been filled in", () => {
const rows = parseLore(csv("Webway Gate|", "Bonesinger|Sings to bone."));
expect(rows.map((row) => row.name)).toEqual(["Bonesinger"]);
});
it("locates columns by header, so an added faction column just works", () => {
const rows = parseLore(
"legend|faction|name\nSings to bone.|Aeldari|Bonesinger\n",
);
expect(rows).toEqual([
{ name: "Bonesinger", legend: "Sings to bone.", faction: "Aeldari" },
]);
});
it("returns nothing for junk rather than throwing", () => {
expect(parseLore("")).toEqual([]);
expect(parseLore("unit;text\nfoo;bar")).toEqual([]);
});
});
describe("buildLoreIndex", () => {
it("matches names that differ only in case or punctuation", () => {
const { lookup } = buildLoreIndex(csv("Tech-priest Dominus|Theocrat."));
expect(lookup("Tech-Priest Dominus")).toBe("Theocrat.");
});
it("matches a plural roster name against a singular entry", () => {
const { lookup } = buildLoreIndex(csv("Myphitic Blight-hauler|Belching."));
expect(lookup("Myphitic Blight-haulers")).toBe("Belching.");
});
it("matches a singular roster name against a plural entry", () => {
const { lookup } = buildLoreIndex(csv("Plaguebearers|Foot soldiers."));
expect(lookup("Plaguebearer")).toBe("Foot soldiers.");
});
it("strips a faction prefix the export does not carry", () => {
const { lookup } = buildLoreIndex(csv("Chaos Spawn|Roiling flesh."));
expect(lookup("Thousand Sons Chaos Spawn")).toBe("Roiling flesh.");
});
it("will not match on a single trailing word", () => {
const { lookup } = buildLoreIndex(csv("Guard|Some other unit entirely."));
expect(lookup("Custodian Guard")).toBeUndefined();
});
it("prefers the entry whose faction matches the card", () => {
const { lookup } = buildLoreIndex(
"name|legend|faction\n" +
"Plaguebearers|Daemon version, which is the longer of the two.|Chaos Daemons\n" +
"Plaguebearers|Guard version.|Death Guard\n",
);
expect(lookup("Plaguebearers", "Death Guard")).toBe("Guard version.");
expect(lookup("Plaguebearers", "Chaos Daemons")).toBe(
"Daemon version, which is the longer of the two.",
);
});
it("falls back to the longest legend when the faction cannot decide it", () => {
const { lookup } = buildLoreIndex(
csv("Servitors|Short.", "Servitors|The longer, fuller entry."),
);
expect(lookup("Servitors")).toBe("The longer, fuller entry.");
expect(lookup("Servitors", "Adeptus Mechanicus")).toBe(
"The longer, fuller entry.",
);
});
it("returns undefined for a unit the export does not cover", () => {
const { lookup } = buildLoreIndex(csv("Bonesinger|Sings to bone."));
expect(lookup("Rein and Raus")).toBeUndefined();
expect(lookup("")).toBeUndefined();
});
});
// Guards the shipped export itself: a re-export that changes the delimiter or
// the header names would otherwise fail silently, every card simply losing its
// flavour text.
describe("public/Lore.csv", () => {
const index = buildLoreIndex(readFileSync("public/Lore.csv", "utf8"));
it("parses into a usable number of entries", () => {
expect(index.size).toBeGreaterThan(1000);
});
it("covers the units in the bundled example rosters", () => {
for (const name of [
"Custodian Guard",
"Bladeguard Veteran Squad",
"Plague Marines",
"Myphitic Blight-haulers",
"Thousand Sons Chaos Spawn",
]) {
expect(index.lookup(name), name).toBeTruthy();
}
});
});
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useState } from "react";
import { buildLoreIndex } from "./lore";
// `public/Lore.csv` is ~400KB, so it is fetched once, lazily, and shared by
// every card rather than bundled into the main chunk. The promise is cached at
// module scope: a roster renders 20-odd Units, and they must not each kick off
// their own request.
let pending;
const loadLore = () => {
if (!pending) {
pending = fetch("Lore.csv")
.then((response) => {
if (!response.ok) throw new Error(`Lore.csv: ${response.status}`);
return response.text();
})
.then(buildLoreIndex)
.catch((error) => {
// A missing or unreadable export is not worth failing a card over -
// the datasheet simply prints without its flavour text.
console.error(error);
return { lookup: () => undefined, size: 0 };
});
}
return pending;
};
/**
* Resolves to the lore index, or `null` until it has loaded.
*/
export const useLore = () => {
const [index, setIndex] = useState(null);
useEffect(() => {
let live = true;
loadLore().then((loaded) => live && setIndex(loaded));
return () => {
live = false;
};
}, []);
return index;
};
+21 -1
View File
@@ -4,7 +4,7 @@
@layer base { @layer base {
:root { :root {
font-family: "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica, font-family: ConduitITCStd, "Noto Sans", sans-serif, Inter, system-ui, Avenir, Helvetica,
Arial, sans-serif; Arial, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
@@ -40,6 +40,14 @@
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2"); src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
font-weight: 400; font-weight: 400;
} }
/* Datasheet lore text. Without a real italic face there would be none at all:
font-synthesis is off below, so the browser may not slant an upright one. */
@font-face {
font-family: "ConduitITCStd";
src: url("/fonts/ConduitITCStd Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -200,6 +208,18 @@
} }
@media print { @media print {
/* The cards get cut apart, so the white border around a cut card comes
from two different places: the page margin on the outer edges, and
half of the gap between two cards on the edges in between. The gap is
therefore twice the page margin, and every edge ends up the same. */
@page {
margin: 8mm;
}
.avoid-page-break {
margin-bottom: 16mm !important;
}
.print-display-none, .print-display-none,
.print-display-none * { .print-display-none * {
display: none !important; display: none !important;
+1 -8
View File
@@ -25,14 +25,7 @@ export const defaultConfig = {
// Abilities to split into per-option profiles, matched against the // Abilities to split into per-option profiles, matched against the
// profile's ``name`` attribute. // profile's ``name`` attribute.
abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols"], abilitiesToConvert: ["Canticles of the Omnissiah", "Battle Protocols", "Icon of War"],
// Title of the generated profile group, per ability. Anything not listed
// here uses the ability's own name, which is what the datasheets do.
groupTitleOverrides: {
"Canticles of the Omnissiah": "CANTICLES OF THE OMNISSIAH",
"Battle Protocols": "BATTLE PROTOCOLS",
},
}; };
// Which transforms run by default when a roster is loaded. // Which transforms run by default when a roster is loaded.
+19 -9
View File
@@ -12,7 +12,8 @@
// * the original ``Abilities`` profile is kept, trimmed to the intro // * the original ``Abilities`` profile is kept, trimmed to the intro
// paragraph (the "select one of the following" rule), and // paragraph (the "select one of the following" rule), and
// * each option becomes its own profile under a new profile type named after // * each option becomes its own profile under a new profile type named after
// the ability, so the renderer groups them into a single titled table. // the ability, in capitals, so the renderer groups them into a single table
// titled the way the datasheets title it.
// //
// Running it twice is a no-op: a converted ability has only an intro paragraph // Running it twice is a no-op: a converted ability has only an intro paragraph
// left, so there is nothing further to split. // left, so there is nothing further to split.
@@ -76,16 +77,23 @@ const cleanOptionName = (name) =>
* over from mis-nested pairs goes too - a stray ``**`` is punctuation the * over from mis-nested pairs goes too - a stray ``**`` is punctuation the
* reader never wanted to see either way. * reader never wanted to see either way.
* *
* The generated group renders through the renderer's generic ability table, * Only the option *name* goes through this. It ends up as a profile ``name``
* which prints its text verbatim, so the markers would otherwise show up as * attribute, which the renderer prints as plain text, so a marker left in there
* literal punctuation. The intro profile keeps its original typeName, renders * would show up as literal punctuation. The option text keeps its markers:
* through the normal path, and is therefore left alone. * ``makeKeywordsBold`` in 10th/Roster.jsx formats them into the bold uppercase
* keywords and bold emphasis the printed card uses, which is more than
* upper-casing here could manage. The intro profile keeps its original
* typeName, renders through the normal path, and was never touched either way.
*
* The Python original also stripped the option text, so this is the one place
* the port deliberately differs from it; transforms.test.js resolves markers on
* both sides when it compares against the fixtures.
* *
* Unlike the Python original this needs no entity bookkeeping: the DOM hands us * Unlike the Python original this needs no entity bookkeeping: the DOM hands us
* resolved text, so there is no ``&quot;`` here that upper-casing could turn * resolved text, so there is no ``&quot;`` here that upper-casing could turn
* into an entity no parser would recognise. * into an entity no parser would recognise.
*/ */
const stripMarkup = (text) => export const stripMarkup = (text) =>
text text
.replace(KEYWORD_RE, (_, keyword) => .replace(KEYWORD_RE, (_, keyword) =>
keyword.replaceAll("*", "").toUpperCase(), keyword.replaceAll("*", "").toUpperCase(),
@@ -201,7 +209,6 @@ function buildProfile(
*/ */
export function convertChoiceAbilities(doc, config) { export function convertChoiceAbilities(doc, config) {
const wanted = new Set(config.abilitiesToConvert); const wanted = new Set(config.abilitiesToConvert);
const overrides = config.groupTitleOverrides ?? {};
const root = doc.documentElement; const root = doc.documentElement;
if (!root) return []; if (!root) return [];
@@ -234,7 +241,10 @@ export function convertChoiceAbilities(doc, config) {
); );
if (options.length === 0) continue; // Already converted, or a plain ability. if (options.length === 0) continue; // Already converted, or a plain ability.
const groupTitle = overrides[ability] ?? ability; // The group's title becomes the generated profileType's typeName, which the
// renderer prints verbatim as the table heading - and the datasheets set
// that heading in capitals.
const groupTitle = ability.toUpperCase();
const typeId = makeId("profileType", groupTitle); const typeId = makeId("profileType", groupTitle);
const charTypeId = makeId( const charTypeId = makeId(
"characteristicType", "characteristicType",
@@ -265,7 +275,7 @@ export function convertChoiceAbilities(doc, config) {
typeId, typeId,
typeName: groupTitle, typeName: groupTitle,
charTypeId, charTypeId,
description: stripMarkup(option.text), description: option.text,
}), }),
); );
} }
+6 -4
View File
@@ -100,10 +100,12 @@ describe("splitting choice abilities", () => {
expect(group).toBeTruthy(); expect(group).toBeTruthy();
expect(group.size).toBeGreaterThan(1); expect(group.size).toBeGreaterThan(1);
// Options print as plain text: GW's ^^keyword^^ / **emphasis** markers // The option text keeps GW's ^^keyword^^ / **emphasis** markers for the
// are resolved, not passed through as punctuation. // renderer to format; only the row titles are plain text, since those
for (const text of group.values()) { // render as-is.
expect(JSON.stringify(text)).not.toMatch(/\^\^|\*\*/); expect([...group.values()].join("\n")).toMatch(/\^\^/);
for (const name of group.keys()) {
expect(name).not.toMatch(/\^\^|\*\*/);
} }
}); });
}); });
+11 -2
View File
@@ -21,6 +21,7 @@ import { defaultConfig } from "./config.js";
import { import {
convertChoiceAbilities, convertChoiceAbilities,
splitDescription, splitDescription,
stripMarkup,
} from "./convertChoiceAbilities.js"; } from "./convertChoiceAbilities.js";
import { applyTransforms } from "./index.js"; import { applyTransforms } from "./index.js";
import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js"; import { mergeDuplicateUnits } from "./mergeDuplicateUnits.js";
@@ -52,6 +53,12 @@ const load = (name) =>
* tokens preserve, not that the two implementations agree on a hash function. * tokens preserve, not that the two implementations agree on a hash function.
* Every other attribute - including `entryId` and `publicationId`, which are * Every other attribute - including `entryId` and `publicationId`, which are
* never generated - has to match exactly. * never generated - has to match exactly.
*
* Text goes through `stripMarkup` for the same reason. Python resolved GW's
* ^^keyword^^ / **emphasis** markers in the option text it split out, because
* the renderer printed that text verbatim; the renderer now formats the markers
* itself, so the transform leaves them in place. Resolving them on both sides
* keeps the fixtures an oracle for everything else about the split.
*/ */
function canonicalize(doc) { function canonicalize(doc) {
const tokens = new Map(); const tokens = new Map();
@@ -72,11 +79,13 @@ function canonicalize(doc) {
.map(([name, value]) => `${name}=${JSON.stringify(value)}`) .map(([name, value]) => `${name}=${JSON.stringify(value)}`)
.join(" "); .join(" ");
const text = Array.from(element.childNodes) const text = stripMarkup(
Array.from(element.childNodes)
.filter((node) => node.nodeType === 3 /* Text */) .filter((node) => node.nodeType === 3 /* Text */)
.map((node) => node.data) .map((node) => node.data)
.join("") .join("")
.trim(); .trim(),
);
lines.push( lines.push(
`${" ".repeat(depth)}<${element.localName} ${attributes}>` + `${" ".repeat(depth)}<${element.localName} ${attributes}>` +