Add lore texts
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
@@ -1,56 +1,87 @@
|
||||
# BrevyScribe
|
||||
|
||||
A fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe) that prints
|
||||
**generic datasheets** rather than a record of one particular army list.
|
||||
BrevyScribe is a fork of [FancyScribe](https://github.com/NilsUeter/fancyscribe).
|
||||
It prints **generic datasheets** instead of a record of one army list.
|
||||
|
||||
FancyScribe renders a BattleScribe or New Recruit roster as 10th-edition-style
|
||||
datacards, showing exactly the wargear you picked. That is the right thing for a
|
||||
list you are about to play, but the wrong thing for a reference card you want to
|
||||
keep: the official cards show *every* option a unit could take. BrevyScribe
|
||||
rewrites the roster on the way in so the printed cards read like the official
|
||||
ones.
|
||||
FancyScribe shows a BattleScribe or New Recruit roster as 10th-edition
|
||||
datacards. The cards show only the wargear that you selected. That is correct
|
||||
for a list that you play today. It is not correct for a reference card that you
|
||||
keep, because the official cards show *every* option of a unit. BrevyScribe
|
||||
rewrites the roster before it renders the cards. As a result, the printed cards
|
||||
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 |
|
||||
| --- | --- |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **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** | 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 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,
|
||||
no account, and no analytics; rosters are held in `localStorage` and never leave
|
||||
the machine.
|
||||
The app does all of its work in the browser. There is no server component, no
|
||||
upload, no account and no analytics. Rosters stay in `localStorage` and never
|
||||
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
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
npm test # the parity suite, see below
|
||||
npm run build # static files into dist/
|
||||
npm test
|
||||
npm run build # static files in dist/
|
||||
```
|
||||
|
||||
`npm run lint` reports pre-existing findings inherited from upstream (mostly
|
||||
`a11y/useButtonType`), so it is deliberately not part of CI. Compare against
|
||||
`git show upstream/main:<file>` before treating any of them as new.
|
||||
## Deploy to scribe.luxick.de
|
||||
|
||||
## 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
|
||||
for nginx to proxy to. The app does all its work in the browser, which is why
|
||||
upstream can live on GitHub Pages.
|
||||
|
||||
One-time setup on the server. It is in two stages because of a chicken and egg:
|
||||
the real config names a certificate, and nginx refuses to load a config whose
|
||||
certificate does not exist yet - so nginx first comes up on port 80 only, just
|
||||
far enough for certbot to answer the challenge there.
|
||||
The server needs no Node. Do the setup one time, in two stages. The two stages
|
||||
are necessary because the real configuration names a certificate, and nginx
|
||||
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
|
||||
the challenge.
|
||||
|
||||
```sh
|
||||
sudo mkdir -p /var/www/brevyscribe /var/www/certbot
|
||||
sudo chown "$USER" /var/www/brevyscribe
|
||||
|
||||
# Stage 1: HTTP only, so nginx starts without a certificate.
|
||||
# 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;
|
||||
@@ -62,132 +93,46 @@ EOF
|
||||
sudo ln -s /etc/nginx/sites-available/scribe.luxick.de /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
|
||||
sudo certbot --nginx -d scribe.luxick.de
|
||||
sudo certbot certonly --webroot -w /var/www/certbot -d scribe.luxick.de
|
||||
|
||||
# Stage 2: the real config, now that the certificate is on disk.
|
||||
# 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
|
||||
```
|
||||
|
||||
`certonly --webroot` rather than `--nginx`: the config already carries its own
|
||||
redirect and TLS block, and the nginx plugin would rewrite the installed file,
|
||||
leaving it drifted from the one in the repo. The port 80 block keeps its
|
||||
`acme-challenge` location for exactly this reason, so renewals go on working
|
||||
unattended - but certbot's timer will not reload nginx by itself, so drop a
|
||||
one-line hook in `/etc/letsencrypt/renewal-hooks/deploy/` that runs
|
||||
`systemctl reload nginx`, or a renewed certificate will not be served until the
|
||||
next restart.
|
||||
Use `certonly --webroot`, not `--nginx`. The configuration in the repository
|
||||
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.
|
||||
|
||||
Then every deploy is one command from a checkout on your own machine - it runs
|
||||
the tests, builds, and rsyncs `dist/` over. The server needs no Node.
|
||||
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
|
||||
./deploy/deploy.sh
|
||||
```
|
||||
|
||||
`--delete` is deliberate: asset filenames are content-hashed, so without it old
|
||||
bundles would pile up forever. There is no state on the server and nothing to
|
||||
back up; every roster lives in the browser's `localStorage`.
|
||||
The script uses `--delete` on purpose. The asset filenames contain a hash of
|
||||
the content, so old bundles collect on the server without this flag. There is
|
||||
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 -
|
||||
upstream sets `/fancyscribe` for GitHub Pages. If you ever serve it from a
|
||||
subpath, that is the setting to change.
|
||||
|
||||
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.
|
||||
|
||||
The fork also hides three things upstream shows, because a generic datasheet has
|
||||
nothing to say with them: the roster overview card and its charts, the unit
|
||||
composition, and the per-unit points cost. Those decisions live in
|
||||
[`src/fork.js`](src/fork.js) - another file upstream does not have - and
|
||||
`src/10th/Roster.jsx` reaches for them on as few lines as possible:
|
||||
|
||||
- it imports `ShortSummaryTable` from `../fork` instead of `./ShortSummaryTable`,
|
||||
a one-line change that leaves the render site untouched. The upstream
|
||||
component is still in the tree, unused, so its future diffs keep applying.
|
||||
- `hideModelCount` is pinned to `HIDE_UNIT_COMPOSITION` instead of being a piece
|
||||
of checkbox state.
|
||||
|
||||
Only the two checkboxes and the `pts` span are deleted outright, so a merge that
|
||||
touches them will say so rather than quietly bringing them back.
|
||||
`index.html` loads Noto Sans from Google Fonts, so a page load contacts
|
||||
`fonts.googleapis.com`. If you do not want this request, delete the `<link>`
|
||||
tags. Then host the font next to the fonts in `public/fonts/`.
|
||||
|
||||
## Credit
|
||||
|
||||
All the hard parts - the parsing, the card layout, the print CSS - are
|
||||
[Nils Ueter's](https://github.com/NilsUeter/fancyscribe), with parsing logic
|
||||
descended in turn from
|
||||
The difficult parts are the work of
|
||||
[Nils Ueter](https://github.com/NilsUeter/fancyscribe): the parser, the card
|
||||
layout and the print CSS. The parsing logic comes in turn from
|
||||
[PrettyScribe](https://github.com/rweyrauch/PrettyScribe). Upstream ships no
|
||||
licence file, so treat this fork as a private, personal deployment rather than
|
||||
something to redistribute.
|
||||
license file. Treat this fork as a private, personal deployment, not as
|
||||
something that you redistribute.
|
||||
|
||||
+1713
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+128
-5
@@ -19,7 +19,11 @@ import { Arrow, wavyLine } from "../assets/icons";
|
||||
import { Weapons, hasDifferentProfiles } from "./Weapons";
|
||||
import { useIndexedDB } from "../helpers/useIndexedDB"; // New hook for IndexedDB
|
||||
import { ImgEditor } from "./ImgEditor";
|
||||
import { trySettingLocalStorage } from "../helpers/useLocalStorage";
|
||||
import {
|
||||
trySettingLocalStorage,
|
||||
useLocalStorage,
|
||||
} from "../helpers/useLocalStorage";
|
||||
import { useLore } from "../helpers/useLore";
|
||||
import { HIDE_UNIT_COMPOSITION, ShortSummaryTable } from "../fork";
|
||||
|
||||
const getShortSummarySubtitle = (force) => {
|
||||
@@ -46,6 +50,7 @@ export const Roster = ({
|
||||
onePerPage,
|
||||
colorUserChoice,
|
||||
primaryColor,
|
||||
showLore,
|
||||
}) => {
|
||||
if (!roster) {
|
||||
return null;
|
||||
@@ -70,6 +75,7 @@ export const Roster = ({
|
||||
force={force}
|
||||
onePerPage={onePerPage}
|
||||
colorUserChoice={colorUserChoice}
|
||||
showLore={showLore}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
@@ -77,7 +83,7 @@ export const Roster = ({
|
||||
);
|
||||
};
|
||||
|
||||
const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
const Force = ({ force, onePerPage, colorUserChoice, showLore }) => {
|
||||
const { units, factionRules, rules, catalog } = force;
|
||||
const mergedRules = new Map([...factionRules, ...rules]);
|
||||
|
||||
@@ -173,6 +179,7 @@ const Force = ({ force, onePerPage, colorUserChoice }) => {
|
||||
onePerPage={onePerPage}
|
||||
forceRules={rules}
|
||||
colorUserChoice={colorUserChoice}
|
||||
showLore={showLore}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -181,7 +188,14 @@ 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 hideModelCount = HIDE_UNIT_COMPOSITION;
|
||||
const uploadRef = useRef();
|
||||
@@ -201,6 +215,38 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
const hasImage = image && image !== "undefined";
|
||||
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 weaponDescriptions = weapons
|
||||
@@ -332,6 +378,8 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
<div
|
||||
className="min-h-[15rem]"
|
||||
style={{
|
||||
// 15rem unless the legend needs more; 44px is the panel's padding.
|
||||
minHeight: hasLore ? `max(15rem, ${loreHeight + 44}px)` : undefined,
|
||||
paddingTop: 24,
|
||||
paddingBottom: 4,
|
||||
background:
|
||||
@@ -430,20 +478,95 @@ const Unit = ({ unit, catalog, onePerPage, forceRules, colorUserChoice }) => {
|
||||
</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
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "40%",
|
||||
zIndex: 101,
|
||||
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: 102,
|
||||
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",
|
||||
right: 0,
|
||||
// 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%",
|
||||
bottom: 0,
|
||||
width: "60%",
|
||||
width: hasLore ? "40%" : "60%",
|
||||
zIndex: 100,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{hasImage && <ImgEditor image={image} name={name} />}
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+18
@@ -84,6 +84,7 @@ function App() {
|
||||
const [roster, setRoster] = useState();
|
||||
const [edition, setEdition] = useState(10); // [9, 10, 11]
|
||||
const [onePerPage, setOnePerPage] = useState(false);
|
||||
const [showLore, setShowLore] = useState(true);
|
||||
const [primaryColor, setPrimaryColor] = useState("#536766");
|
||||
const [colorUserChoice, setColorUserChoice] = useState(false);
|
||||
const uploadRef = useRef();
|
||||
@@ -419,6 +420,22 @@ function App() {
|
||||
One Datacard per Page when Printing
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
minHeight: 26,
|
||||
}}
|
||||
title="Print the flavour text from public/Lore.csv beside the model image, the way the official datasheets do."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLore}
|
||||
onChange={(e) => setShowLore(e.target.checked)}
|
||||
/>
|
||||
<span className="select-none">Show Lore Text</span>
|
||||
</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
@@ -464,6 +481,7 @@ function App() {
|
||||
onePerPage={onePerPage}
|
||||
colorUserChoice={colorUserChoice}
|
||||
primaryColor={primaryColor}
|
||||
showLore={showLore}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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("Khorne’s 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -40,6 +40,14 @@
|
||||
src: url("/fonts/ConduitITCStd-Regular.woff2") format("woff2");
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user