About This Log
Warcry Card Creator 2026 is a browser-based fan tool for building custom Warcry cards — fighter cards, text/ability cards, deployment cards, card backs, and a runemark reference card. No backend, no login; everything runs client-side and exports as a print-ready PNG.
It was built over 20 sessions in March 2026 by the project owner with Claude Code as the coding partner. These logs document what was built each day, the reasoning behind key decisions, and what remains open.
→ What Was Built
| Period | What |
|---|---|
| Mar 11–15 | Fighter card — layout, characteristics, weapons, damage bracket, runemarks, faction hierarchy, model image pan/zoom |
| Mar 16–17 | Text card — activation badge, body/flavour/prerequisite text, points table, label types |
| Mar 18–22 | Export & polish — dom-to-image-more quirks, mobile support, PWA, i18n (EN + DE), printer-friendly modes |
| Mar 23 | Card back — background image, centred name + runemark, text colour, mirrored name |
| Mar 23–29 | Deployment card — SVG card, 99 snap positions, markers, measurements, zones, mask circles, perimeter snaps, PF hatch patterns; rebuilt once from form-based to card-first tap UI |
| Mar 27–29 | Cross-editor — inline runemark markup, independent faction selects, tags row, JSON save/load, banderole label |
| Mar 31 | Reference card — 5×8 runemark grid, category checkboxes, circle type design, multi-card export |
⇄ How We Worked
The owner directed with short messages — often a single sentence or a screenshot of a visual problem. Claude planned the approach first and waited for a go-ahead before writing any code. Changes stayed as working-tree edits; the owner reviewed and committed everything themselves.
What worked: the plan-first loop caught wrong assumptions early. Screenshots replaced long descriptions — "the yellow shape is invisible on the yellow zone" plus an image led straight to the right fix. Doing one thing at a time kept mistakes small.
What was hard: the dom-to-image-more iOS restriction and the brownish bleed on printer-friendly exports were genuinely unsolved — documented and left open rather than papered over. The deployment card was the most complex piece: rebuilt once, then extended across five sessions, one clearly scoped addition at a time.
Topic Reference
Quick lookup table — find specific decisions, patterns, and configurations across all session logs.
🏛️ Architecture
🃏 Card Design
🎨 Visual & CSS
🛠️ Config & Ops
Reference Card, Deployment Polish & Fighter Card Export Fix
A new /reference route was built for browsing and exporting the full
runemark library as 5×8 cards. A "Group Runemarks" toggle was added to force each
category to start at column 1 of a new row — implemented via invisible filler cells
that pad each group to a multiple of 5 before the next group begins.
The Deployment Card received several fixes and wording improvements: mask circles
are now clipped to the battlefield interior via an SVG <mask>,
deployment popup labels were clarified throughout, and Matched Play now slides up
into Orientation's corner slot when Orientation is toggled off.
A long-standing Fighter Card export regression was diagnosed and fixed: the March 29
cleanup commit had removed ~30 per-element border: 0 declarations,
trusting a blanket .card * rule that dom-to-image-more does not reliably
serialise. Restoring explicit resets on every selector also eliminated the
printer-friendly "dark spots" artifact.
✓ What Was Built
Reference Card Page
src/routes/reference/+page.svelte— new page; shares the same sidebar + card preview layout as the other editors.- Sidebar checkboxes: two-column layout (Card Elements / Card Design). Core categories first (Show Weapons, Show Fighters, Show Characteristics, Show Deployments, Show Card Decks, Show Treasures, Show Twists), alliance categories below. "Circle type" checkbox in the Card Design column.
- Faction category merging: each alliance entry includes the alliance SVG icon first, then all faction icons, then each faction's subfaction (bladeborn) icons inline — no separate Bladeborn group.
- Landing page button: Reference Card added to
src/routes/+page.sveltebelow Card Back, styled in zinc (secondary).
Card Layout
- 5 × 8 grid:
grid-template-columns: repeat(5, 1fr)+grid-template-rows: repeat(8, 1fr)withheight: 100%on the grid — icons fill the full card space without wasted margins. - Fixed cell size:
.rm-cellis 70 × 70 px withdisplay: flex; align-items: center; justify-content: center. SVG icons are 72 % of cell width in both raw and circle-type designs so position never jumps when switching design. - Labels: small text below each icon, always
color: #000regardless of design or printer-friendly state.
Circle Type Design
- Standard mode:
.rm-badge—position: absolute; inset: 0, maroon#5a0a14background, SVGfill: #FAF6F3, masked withrunemark-shape.svg(same blob as Fighter Card). - Printer-friendly mode: outer
.rm-border-pf(black, full size, same mask) wrapping inner.rm-badge-pf(calc(100% - 2px), white background, black SVG fill) — 1 px ring, matching the Fighter Card PF approach.
Multi-Card Export
- Pagination:
ITEMS_PER_CARD = 40;cardPagesderived store chunksvisibleItemsinto groups of 40. Each chunk renders its own scaled card element. - Export loop: iterates over
cardEls; 200 ms pause between desktop downloads; numbered suffix (_1, _2, …) appended when more than one card.
Export Artifact Fixes
- Every card element, grid, cell, and label carries explicit
border: 0; outline: none; background: transparenton its own CSS rule (no wildcard) — prevents dom-to-image-more from emitting black frames. overflow: hiddenremoved from.rm-celland.rm-badge— a known cause of white-box artifacts in dom-to-image-more.
i18n & Cleanup
- Added:
ui.reference-card,ui.form-show,ui.form-weapon-runemarks,ui.form-circle-typein both locales. - Pluralised:
ui.deployment-group"Deployment" → "Deployments" (DE: "Aufstellungen");ui.treasure-group"Treasure" → "Treasures" (DE: "Schätze"). - Removed unused keys from a scrapped group-header phase:
ui.form-core,ui.form-factions,ui.form-bladeborn,ui.form-grand-alliances. - Removed unused
miscRunemarksimport fromreference/+page.svelte.
Group Runemarks (Reference Card)
- Checkbox: "Group Runemarks" added under Card Design in the Reference Card sidebar (i18n key:
ui.form-new-line; DE: "Runenzeichen gruppieren"). - Pagination:
cardPagesreturnsRmItem[][][](pages → groups → items). Groups can split across pages at theITEMS_PER_CARDboundary. - Filler cells: when enabled, invisible filler
<div>s are injected after each group (except the last on a page) to pad the group's item count to the next multiple of 5. The next group then starts at column 1 with no gap penalty. - Grid rows:
pageGridRowssimplified to always returnrepeat(8, 1fr)— no 12 px separator tracks needed.
Deployment Card — Mask Circle Fix
- SVG mask: added
<mask id="inside-bf-mask">in<defs>— black rect over full SVG, white rect over battlefield. Mask circles are now invisible outside the battlefield boundary. - Split rendering: mask circle is two
<circle>elements — a masked visual circle (pointer-events="none") and a transparent full-size hit target, so the circle remains clickable anywhere within its radius. - Radius labels:
MASK_RADIIlabels changed fromS/MtoSmall/Large.
Deployment Card — Popup Position Fix
- Bug: clicking a marker near the bottom of the card opened the popup out of frame — the overflow correction in
$effectusedpv.y(the raw click Y) instead ofrect.top(the popup's actual rendered top). Subtracting overflow from the click position pushed the popup back below the viewport instead of up. - Fix: replaced
pv.y - overflowBottom→rect.top - overflowBottomandpv.x - overflowRight→rect.left - overflowRight— now the adjustment shifts from the current rendered position, placing the popup flush with the edge.
Deployment Card — Wording
form-icon: "Icon" → "Runemark"form-round-label: "Round" → "Round label" (added label to creation popup for consistency)popover-zone: "Zone from here" → "Draw zone from here"popover-mask: "Mask circle here" → "Place mask circle"popover-mask-title: "Mask zone" → "Mask Circle"zone-pick-start/endandmeasurement-pick-end: shortened instruction stringspopover-add-to-position: "Add to position" → "Place here"
Reference & Text Card — Wording
form-circle-type: "Circle type" → "Circles" (DE: "Kreise")form-layout-banderole: "Banderole type" → "Banderole" (already correct in DE)
Deployment Card — Matched Play Position
- When Orientation is toggled off and Matched Play is on, the Matched Play runemark/text now renders at
y=20(Orientation's slot) instead ofy=60. Controlled by a derivedmpY = showOrientation ? 60 : 20constant.
Fighter Card — Export Border & Dark-Spots Fix
- Root cause: the March 29 "Finalize deployment card" commit removed ~30 per-element
border: 0declarations fromFighterCard.svelte, replacing them with a single.card * { border-width: 0; border-style: solid }blanket rule. dom-to-image-more's CSS serializer preservesborder-style: solidbut silently dropsborder-width: 0, leaving every element with the UA-default border width — producing visible black frames in all exports. - Secondary cause: the split
border-width/border-styleform was also wrong — the shorthandborder: 0additionally setsborder-style: none, which survives serialization.border-width: 0alone does not. - Fix: changed the blanket rule to
border: 0and restoredborder: 0; outline: none; background: transparenton every individual selector — matching the TextCard pattern that has always worked. This mirrors rule #2 in the dom-to-image-more memory. - Dark-spots bonus fix: the same per-element
background: transparentresets also eliminated the printer-friendly "brownish bleed at SVG mask edge" artifact — leakedbackground: url('/background.jpg')from ancestor rules is now properly overridden on each element.
💡 Key Decisions
Why fill the full card height with the grid rather than stacking from the top?
grid-template-rows: repeat(8, 1fr) with height: 100% distributes rows evenly across the card, using every square centimetre of parchment. A top-anchored grid would leave empty space at the bottom for most selection sets.
Why a checkbox for "Circles" rather than a select with Filled/Outlined options?
The Text Card editor uses a checkbox for its "Card Design" toggle. Matching that pattern keeps the two sidebars consistent — a checkbox is faster for a binary choice, and the maroon circle already has a clear on/off state.
Why fix icon size at 72 % of cell width in both designs?
Initially the raw SVG filled 100 % of the cell while the badged version was 72 % — switching designs caused a visible jump. Matching both to 72 % and centering the cell with flexbox eliminates the jump. The badge uses position: absolute and doesn't participate in flex layout, so centering applies cleanly to both designs.
Why merge bladeborn into their alliance category rather than keeping a separate group?
Separate groups added UI noise — users think of bladeborn warbands as belonging to a faction, not a standalone category. Merging them inline (alliance icon → factions → subfactions per faction) makes the hierarchy readable without extra headers, and one checkbox controls the whole alliance block.
Why use filler cells to start groups on a new row rather than a separator line or a zero-height grid-column: 1/-1 div?
A 1 px separator line between groups required a dynamic grid-template-rows with 12 px separator tracks — complex and fragile. A zero-height full-span div avoids that but still consumes the grid's row-gap, adding unwanted space between groups. Filler cells pad the previous group to a multiple of 5 columns so the next group starts at column 1 naturally, with no extra rows and no gap penalty. The grid stays a simple repeat(8, 1fr).
Why use an SVG <mask> instead of clipPath to clip mask circles to the battlefield?
clipPath with clip-rule="evenodd" proved unreliable in browser rendering. An SVG <mask> with two simple rects (black full SVG, white battlefield area) is more robust and easier to reason about. The mask circle is additionally split into a masked visual element and a transparent hit-target so pointer events work across the full circle radius regardless of where the visual is clipped.
Why restore per-element border: 0 rather than strengthening the blanket .card * rule (e.g. with !important)?
!important on the blanket rule made things worse — it prevented intentional borders (.stats-box) from rendering and broke masked runemark backgrounds. TextCard has always used per-element resets alongside the blanket rule, and it has never shown export artifacts. That pattern is the proven one: the blanket rule acts as a comment and a fallback; the per-element declarations are what dom-to-image-more actually reads.
📁 Files Changed
| File | Change |
|---|---|
src/routes/reference/+page.svelte | New Reference Card page; added Group Runemarks toggle (filler cells, always repeat(8,1fr) grid) and grouped pagination |
src/routes/+page.svelte | Added Reference Card button (zinc, below Card Back) |
src/lib/components/DeploymentCard.svelte | SVG mask to clip mask circles to battlefield interior; split into masked visual + transparent hit-target circle; renamed mask ID to inside-bf-mask; Matched Play slides to y=20 when Orientation is off |
src/routes/deployment/+page.svelte | Radius labels S/M → Small/Large; Round label added to creation popup; popup overflow correction now uses rect.top/rect.left instead of raw click coords |
src/lib/components/FighterCard.svelte | Blanket .card * rule changed from border-width:0;border-style:solid to border:0; per-element border:0;outline:none;background:transparent restored on every selector — fixes black export frames and PF dark-spots artifact |
src/lib/i18n/locales/en.json | Reference card keys; wording cleanup for deployment popup, mask circle, zone instructions; "Circle type"/"Banderole type" → "Circles"/"Banderole"; form-separators → form-new-line "Group Runemarks" |
src/lib/i18n/locales/de.json | Same as en.json; form-new-line "Runenzeichen gruppieren" |
Deployment Zones, PF Hatching & Perimeter Snaps
A full day on the deployment card editor. In the morning, zones became interactive: click a snap position, choose "Zone from here", click a second corner to commit the bounding rectangle. Zones are click-to-edit (colour, redraw, remove). Mask circles carve circular exclusion areas through coloured zones beneath with a single tap.
In the afternoon, printer-friendly mode gained per-player SVG hatch patterns (forward-diagonal, back-diagonal, crosshatch, horizontal) replacing ambiguous grey fills, plus circled player numbers inside each zone. Mask circle edges now expose 8 perimeter snap points at cardinal/intercardinal angles — usable as measurement endpoints. Measurement lines longer than 60 SVG units get a midpoint dot for mobile tap discoverability. Shape stroke was reduced to 1 px semi-transparent white so yellow shapes remain visible on yellow zones. RND labels are clamped inside the card boundary and flip below the shape when they would be clipped at the top edge.
Mobile UX: the JSON dropdown now closes on outside click; all popover inputs call
scrollIntoView on focus so the iOS keyboard doesn't obscure them; the
WIP watermark was removed. Code quality: ~100 redundant CSS resets removed from
FighterCard, and the app.d.ts boilerplate stubs cleaned up.
✓ What Was Built
Zone Data Model
ZonePresettype removed fromtypes.ts.DeploymentZonechanged from{ preset: ZonePreset }to{ startPos: DeploymentPosition; endPos: DeploymentPosition }.- Zone rect computation:
zoneRect(zone)inDeploymentCard.sveltenow callsgetCoords(startPos)andgetCoords(endPos)and returns the bounding rectangle. Any two grid positions produce a valid zone.
Zone Drawing UI
- Entry point: "Zone from here" button in the empty-position popover (same popover as Add Point / Measurement). Auto-creates a red player if none exist, matching the behaviour of Add Point.
- Two-step flow: first click → zone start (highlighted with a ring on the dot, same as measurement start); second click → zone created for the active player. A hint bar shows the current step ("Click a position to set the zone start corner" / "…end corner").
- Position dots shown automatically during zone mode even if "Show Markers" is toggled off:
showPositionDotsisshowMarkers || zoneMode || measurementMode.
Zone Click-to-Edit
- Zone rects are clickable:
onZoneClickprop added toDeploymentCard; each zone<rect>getsonclick+cursor: pointerwhen the prop is set. Disabled during zone/measurement mode and export. - Zone popover: shows player colour title, position coordinates (
R1C1 → R4C6), colour picker row (all 4 colours, active highlighted), "Redraw" button (removes + re-enters zone mode for that player), and "Remove zone" link. reassignZoneToColor: moves a zone from one player to another, auto-creating the target player if needed. Updates the popover mode to point at the new player/zone indices.
Mask Zone Cutouts
- Data model extended:
DeploymentZonegainsmask?: booleanandradius?: number(SVG units, defaults to 89). Whenmask: true,startPosis the circle centre;endPosis unused. - Rendering: zone rendering split into two passes — regular zone rects first, then mask circles on top (before dashed centre lines). Mask circles are filled with the battlefield background colour (
#d9b8a8/ white in printer-friendly), fully opaque, visually punching a hole through any coloured zones beneath. No SVG clip-paths needed. - "Mask circle here" button in the empty-position popover. Single-tap creation — circle placed immediately with default radius.
- Mask zone popover: S / M size buttons (89 / 179 SVG units = 1 and 2 horizontal grid steps); Remove link. No colour picker.
Printer-Friendly Hatch Patterns
- SVG
<pattern>defs: four distinct hatch patterns defined inside<defs>— forward-diagonal (/), back-diagonal (\), crosshatch (×), and horizontal lines. Each maps to one player index 0–3. - Zone rects: fill switches to
url(#hatch-N)withfill-opacity=1in PF mode (was flat grey at varying opacity). Dotted zone borders removed entirely (stroke="none"). - Player number badges: white filled circle (r=13) + player number text centred inside each zone in PF mode — unambiguous per-player attribution even when hatches overlap. Badge block moved after the measurement lines in SVG paint order so badges always render on top of lines.
- Mask circles:
stroke="none"(dotted border removed in all modes).
Perimeter Snap Points
DeploymentPositionunion extended with`PRM-${string}`template literal intypes.ts. Coords are encoded in the ID string itself (PRM-{x}-{y}), parsed by a regex branch ingetCoords()— zero coupling, survives JSON serialisation.- 8 snap points per mask circle: cardinal and intercardinal angles (0°, 45°, 90°, 135°, 180°, 225°, 270°, 315°) computed as a
$derived.by()reactive over all mask zones. Rendered as dashed semi-transparent dots when position dots are visible. - PRM guard: in
handlePositionClick, perimeter positions are silently ignored unlessmeasurementModeis active — they only serve as measurement endpoints.
Measurement Line UX
- Midpoint dot: a small filled circle (r=4) rendered at the midpoint of each measurement line when the Euclidean length exceeds 60 SVG units. Guard prevents the dot overlapping the cap icons on very short lines.
- Label white halo (printer-friendly): measurement line label
<text>gainsstroke="white" stroke-width="4" paint-order="stroke fill". The white stroke acts as a knockout background, keeping the label legible over hatched zones without a separate<rect>.
Shape & Layout Fixes
- Shape stroke: colour changed from
whitetorgba(255,255,255,0.6), width reduced to 1px. Prevents the yellow shape (on yellow zone) from becoming invisible while keeping the stroke subtle everywhere else. - RND label clamping: label position flips below the shape when it would be clipped at the top card edge (
labelAbove < 24); also clamped horizontally to stay inside card bounds (Math.max(50, Math.min(865, cx))). .objective-label--pfCSS rule: added missing rule (background: white; color: #000) so objective labels render correctly in printer-friendly mode.- Corner runemarks consolidated to the right: Orientation moved from top-left to top-right (
x=863 y=20); Matched Play placed directly below it (x=863 y=60). Top-left corner is now clear. - Card name rotated upward:
.card-namegainstransform: rotate(-90deg); transform-origin: left bottom; white-space: nowrapso the caption reads upward along the left edge, anchored atleft: 28px; bottom: 20px.right: 0removed.
Mobile & Editor UX
- JSON dropdown click-outside:
$effecthandler added to close the JSON dropdown on outside click — matching the existing View and Export dropdown behaviour. scrollIntoViewon popover inputs: all 5 popover text inputs callscrollIntoView({ block: 'nearest', behavior: 'smooth' })on focus so the iOS virtual keyboard does not obscure the input.- WIP watermark removed:
<div class="wip-watermark">removed from the deployment page body.
Code Quality
- FighterCard CSS resets: removed ~100 redundant
border: 0; outline: none; background: transparentdeclarations from individual CSS rules — all covered by the existing.card *reset rule. src/app.d.tscleanup: removed all SvelteKit boilerplate comments and commented-out interface stubs. File retains only the active__BUILD_DATE__declaration.
Example JSON
_work/deployments/agents-of-belakor-battlefield-edge.json: approximation of the "Agents of Be'lakor: Battlefield Edge" deployment card. Red zoneR1C4 → R4C6; blue zoneR6C1 → R7C9. Three "Ritual Point" objectives; two 8" measurement lines from centre.
i18n & Cleanup
- Added:
ui.popover-zone,ui.popover-zone-redraw,ui.popover-remove-zone,ui.zone-pick-start,ui.zone-pick-end,ui.popover-mask,ui.popover-mask-title,ui.popover-mask-radiusin both locales. - Removed: all
deployment.zone-*preset label keys (14 keys) andui.form-zones,ui.form-add-zonefrom both locales — no longer referenced.
💡 Key Decisions
Why replace the preset system entirely rather than keeping it alongside position-based zones?
The preset approach required a dropdown in the header and produced zones that didn't snap to the grid the user sees. Position-based zones are more precise, more flexible, and use the same interaction model already established for measurements. Keeping both would double the complexity for no additional capability — any preset shape can be reproduced by picking two corners.
Why enter zone mode from the position popover rather than from a header button?
The original header "Zones" dropdown was unintuitive — users expected to interact with the card directly. Moving zone creation into the position popover ("Zone from here") is consistent with how measurements and objectives are added: click a position, pick an action, then click the end point. The first click becomes the start corner, eliminating the need for a separate "pick start" step.
Why paint the mask circle with the battlefield background colour instead of using SVG clip-paths?
Clip-paths require wrapping the entire zone layer in a <clipPath> definition that excludes each mask circle, which is complex and fragile with dynamic content. Painting an opaque circle on top achieves the identical visual result with a single <circle> element and no SVG structural changes. The battlefield background is a flat colour, so overpaint is indistinguishable from a true cutout.
Why use SVG hatch patterns instead of grey fills for printer-friendly zone distinction?
Grey opacity levels were ambiguous — overlapping zones compound unpredictably and even non-overlapping zones are hard to distinguish in print. SVG <pattern> elements give each player a unique texture (diagonal, cross-diagonal, crosshatch, horizontal) that is unambiguous at any printer output. Player number badges inside each zone provide a final belt-and-suspenders identifier.
Why encode perimeter snap coordinates in the ID string rather than looking them up at runtime?
getCoords() has no access to card data — it takes only a position string. Encoding the coords as PRM-{x}-{y} keeps the function pure and stateless, lets positions survive JSON serialisation unchanged, and avoids threading card data through every call site. The position string is the data.
Why guard the midpoint dot behind a length threshold (len > 60)?
On short lines the midpoint falls within the cap icon radius. Rendering the dot there adds visual noise and overlaps the icon. The 60-unit threshold corresponds roughly to the minimum line length where the midpoint is visually distinct from both caps and the tapping target is meaningful.
⏳ Still Pending
| Status | Item |
|---|---|
| open | Dark spots on printer-friendly export image area — brownish bleed at the torn-paper SVG mask edge; dom-to-image-more inlines all url() CSS refs regardless of cascade override. Needs a non-CSS strategy. |
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts | Removed ZonePreset; DeploymentZone → { startPos, endPos, mask?, radius? }; added `PRM-${string}` to DeploymentPosition union |
src/lib/components/DeploymentCard.svelte | Position-based zone rects; mask circle rendering; SVG hatch <pattern> defs; player number badges (paint order fixed); perimeter snap dots; midpoint dot; measurement label white halo; shape stroke; RND label clamping; corner runemarks to right column; card name rotate 90°; .objective-label--pf rule; indent fixes |
src/routes/deployment/+page.svelte | Zone/mask drawing flow; zone click-to-edit; JSON dropdown click-outside; scrollIntoView on all popover inputs; PRM guard in handlePositionClick; WIP watermark removed |
src/lib/components/FighterCard.svelte | Removed ~100 redundant border/outline/background resets from individual CSS rules (all covered by .card *) |
src/app.d.ts | Removed SvelteKit boilerplate comments and commented-out interface stubs |
src/lib/i18n/locales/en.json | Added 8 zone/mask UI keys; removed 16 stale zone preset + dropdown keys |
src/lib/i18n/locales/de.json | Same as en.json |
_work/deployments/agents-of-belakor-battlefield-edge.json | New example JSON for the Be'lakor Battlefield Edge deployment layout |
Independent Hierarchy, Tags Row & JSON Save / Load
New "Use Independent Hierarchy" checkbox on both fighter and text card editors.
When enabled, three flat selects replace the cascading FactionSelect
component — Grand Alliance, Faction, and Subfaction are chosen freely without
cascade dependency. Toggling off clears the three fields.
Pill-style runemark badges (shown when "Show Runemarks" is unchecked) replaced
with a plain text tags row: Alegreya uppercase, items joined by • .
On the fighter card the background strip sits inside .image-inner so
the SVG mask naturally clips its bottom edge to the torn-paper contour.
✓ What Was Built
Independent Hierarchy Toggle
freeHierarchy: booleanadded to bothFighterCardDataandTextCardDataintypes.ts; defaults tofalse.- Form UI: "Use Independent Hierarchy" checkbox in Card Elements section of both
FighterForm.svelteandTextForm.svelte. Usesonchange(notbind:checked) to cleargrandAlliance,faction, andbladebornwhen toggling off. When on: 3 sublabeled<select>elements (Grand Alliance / Faction / Subfaction) with alphabetically sorted options. When off: existingFactionSelectcomponent unchanged. - Card rendering: faction and subfaction runemark badges use ternary conditions —
freeHierarchy ? findFactionSvg(data.faction) : getFactionSvg(data.grandAlliance, data.faction)— so the correct SVG is shown regardless of mode. Tags row applies the same cascade conditions so it stays consistent with the badge display. findFactionSvg/findSubfactionSvg: two flat-lookup helpers added tohierarchy.tsand exported fromindex.ts. They search all alliances without needing a parent ID — required because free mode setsfactionwithout necessarily setting a matchinggrandAlliance.- i18n: new keys
ui.form-free-hierarchy,ui.form-grand-alliance,ui.form-faction,ui.form-subfactionin bothen.jsonandde.json.
Tags Row (Pills → Text)
- Design: Alegreya uppercase, 14 px,
letter-spacing: 0.04em, items joined by•(U+2022 bullet). Replaces pill badges (maroon background, border-radius, border) across both card types. - Fighter card: tags row placed inside
.image-inner(which carries the SVGmask-image). This means the background strip (rgba(0,0,0,0.35)) is naturally clipped at the torn-paper edge — the strip cannot bleed past the mask boundary. Previous placement outside.image-innercaused the rectangular strip to overlap the shape mask. - Text card: tags row sits directly in the dark header section; no background needed. Banderole mode overrides text colour to
#5a0a14; printer-friendly to#000. - Tags content: Alliance label → Faction label → Subfaction label (respecting cascade / free mode) → fighter runemark labels → activation label (text card only). Empty slots are filtered out with TypeScript type narrowing (
.filter((x): x is string => !!x)). - CSS removed:
.pills-row,.runemark-pill,.image-bottomand all their modifier variants (.is-printer-friendly,.is-banderole) deleted from both card components.
Code Quality Audit & Refactor
- Runemark mask CSS variable: the inline style string
mask-image: {runemarkMaskUrl}; -webkit-mask-image: {runemarkMaskUrl}; mask-size: 100% 100%; ...was repeated on every.runemark-borderand.runemark-badgeelement — 8 times inFighterCard.svelteand 10 times inTextCard.svelte. Replaced with a--rm-maskCSS custom property set once on the.cardelement; the CSS rules for.runemark-borderand.runemark-badgenow referencevar(--rm-mask). 18 inline style attributes removed. - SVGO investigation: ran SVGO 4.0.1 against the runemark SVG library (1.8 MB, 234 files). Savings were 0–1 bytes per file — files consist entirely of compressed path data with no redundant markup. SVGO not worth running; SVGs are already optimal.
Export Polish
- "Upload model image" hidden during exports: placeholder text in the model image area is suppressed for both the default export and the printer-friendly export.
FighterCard.sveltegained anexporting?: booleanprop; the span renders only when!exporting && !printerFriendly. The page passes{exporting}to the component.
JSON Save / Load — Fighter & Text Cards
- UI: "Save JSON" and "Load JSON" added to the existing export dropdown (below a
<hr>divider) on both/fighterand/textpages, desktop and mobile. Reuses the same split-button chevron already present — no new toolbar space needed. - Save:
saveLayout()serialisesdatato a pretty-printed.jsonfile usingmakeSlug()for the filename. Fighter stripsmodelImage(blob URL, not serialisable). Both pages stripsvgfrom runemark objects — only{ id, label }is saved, keeping files compact. - Load:
handleFileLoad()reads the file viaFileReader, parses JSON, re-hydrates runemark SVGs from the bundledfighterRunemarksrecord byid, then replacesdata. Malformed JSON is silently ignored. - Runemark state sync fix:
rmKeys(the internal string array driving the runemark selects) was local$stateinFighterForm/TextForm. On JSON load, settingdataexternally caused the form's$effectto immediately overwritedata.rightRunemarks/data.fighterRunemarkswith the stale empty keys. Fix:rmKeyslifted to the parent page as$state, passed to the form as a$bindableprop.handleFileLoadsetsrmKeysfrom loaded ids before replacingdata.
⏳ Still Pending
| Status | Item |
|---|---|
| open | Dark spots on printer-friendly export image area — brownish bleed at the torn-paper SVG mask edge. Root cause: dom-to-image-more scans all matching CSS rules for url() references and inlines them regardless of cascade override, so background.jpg renders under the mask even when overridden to white. Multiple CSS and DOM approaches attempted; no fix landed without breaking the live preview. Needs a different strategy (e.g. patching export pipeline or reworking the mask approach). |
💡 Key Decisions
Why move the tags row inside .image-inner on the fighter card?
The SVG mask-image is applied to .image-inner, not .image-section. A child positioned inside .image-inner is clipped by that mask; a sibling positioned alongside it is not. Moving the tags row (including its semi-transparent background rectangle) inside .image-inner means the mask naturally trims the strip's bottom edge to the torn-paper contour — no extra CSS needed.
Why add findFactionSvg / findSubfactionSvg rather than reusing getFactionSvg?
getFactionSvg(allianceId, factionId) requires both IDs to traverse the hierarchy. In free mode the user sets faction without necessarily selecting the matching grandAlliance first — or may set a mismatched pair. The flat helpers iterate all alliances and return the first match, making them safe for any combination. They are also simpler callers — one argument instead of two.
Why clear grandAlliance, faction, bladeborn when toggling free hierarchy off?
The free selects allow combinations the cascade mode cannot represent (e.g. a faction without its parent alliance set, or a subfaction from a different alliance). Leaving those values would cause the cascade FactionSelect to show a partially selected state or display wrong badges. Clearing on toggle-off ensures the user starts fresh with the cascade UI and avoids silent inconsistencies.
Why not run SVGO on the runemark SVG library?
Tested against the largest files (up to 14 KB). Savings were 0–21 bytes per file (0–0.1%). The SVGs consist almost entirely of long path data strings — the only content SVGO could touch. There are no comments, redundant attributes, or inline styles to strip. The files are already as compact as they can be without lossy path simplification.
Why add JSON save/load to the existing export dropdown rather than a separate button?
Fighter and text header toolbars use a fixed justify-between layout — no room for a second split-button without cramping. The export dropdown already has a chevron; adding a divider and two items keeps the surface clean. JSON save/load is treated as a power-user feature: discoverable by exploring, not prominently signposted.
Why lift rmKeys to the parent page rather than syncing inside the form component?
The form's $effect writes data.rightRunemarks = rmKeys.filter(...).map(...) — it runs whenever rmKeys changes. Setting data externally does not update rmKeys, so the effect immediately overwrites the loaded runemarks with the stale empty array. Lifting rmKeys to the parent and passing it as $bindable lets handleFileLoad set both rmKeys and data in the correct order — runemarks survive the load.
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts | Added freeHierarchy: boolean to FighterCardData and TextCardData |
src/lib/runemarks/hierarchy.ts | Added findFactionSvg() and findSubfactionSvg() flat-lookup helpers |
src/lib/runemarks/index.ts | Exported findFactionSvg and findSubfactionSvg |
src/lib/components/FighterCard.svelte | Free-hierarchy ternaries; tags row inside .image-inner; removed pills CSS; added .tags-row; added exporting prop; placeholder text hidden during export; --rm-mask CSS variable replaces 8 inline mask style attributes |
src/lib/components/TextCard.svelte | Free-hierarchy ternaries; tags row in header; removed pills CSS; added .tags-row with banderole/printer-friendly overrides; --rm-mask CSS variable replaces 10 inline mask style attributes |
src/lib/components/FighterForm.svelte | Added hierarchy import; "Use Independent Hierarchy" checkbox with clear-on-toggle-off; conditional flat selects vs FactionSelect; rmKeys changed from local $state to $bindable prop |
src/lib/components/TextForm.svelte | Same as FighterForm — "Use Independent Hierarchy" checkbox and conditional hierarchy selects; rmKeys changed from local $state to $bindable prop |
src/routes/fighter/+page.svelte | Added freeHierarchy: false to initial data; passes {exporting} to FighterCard; rmKeys lifted to page state; saveLayout / loadLayout / handleFileLoad; JSON items in export dropdown; hidden file input |
src/routes/text/+page.svelte | Added freeHierarchy: false to initial data; same JSON save/load additions as fighter page |
src/lib/i18n/locales/en.json | Added form-free-hierarchy, form-grand-alliance, form-faction, form-subfaction |
src/lib/i18n/locales/de.json | Added form-free-hierarchy, form-grand-alliance, form-faction, form-subfaction |
Text Card — Banderole, Inline Runemarks & UX Polish
Banderole layout variant for the Text Card type label — a full-width maroon torn-edge ribbon that overhangs the card edges slightly, with runemark inversion and a printer-friendly stroke outline mode.
Inline runemark markup: [beast] syntax in body/prerequisite text renders
the matching SVG icon inline, wrapped in parentheses. A searchable [⊕]
picker in both markup toolbars covers the full runemark library including factions.
UX polish: shared A↓ font size toggle reduces body/flavor/prerequisite
text for denser cards. Back button hover effect unified across all four editors.
✓ What Was Built
Banderole Layout Variant
- New layout variant:
layoutVariant?: 'standard' | 'banderole'added toTextCardData. When'banderole':.image-inneris not rendered (top section shows card texture); the type label is wrapped in.banderolecontaining a masked.banderole-bgdiv for the maroon shape and a.card-label-texton top via z-index. - Shape and position:
.banderoleisposition: absolute; left: -15px; right: -15px; bottom: 56px; padding: 4px 50px— overhangs the card edges slightly..banderole-bgusesinset: -35px 0to extend 35 px above and below, rendering the mask at ~150 px height so the SVG's ragged edge detail resolves to ~10 px of visible deviation. Text bottom aligns with standard mode at56 + 4 = 60 px. - Runemark inversion: in banderole mode the runemarks switch from white-on-maroon to black-on-cream (
.runemark-border→ maroon,.runemark-badge→#FAF6F3, SVG fill →#000). Pills invert to maroon text on transparent. - Printer-friendly banderole:
.banderole-bgset tobackground: transparent; a.banderole-outlineSVG with the same path stroked in black renders only whenprinterFriendly. The full torn-edge overlay is suppressed in banderole mode (printerFriendly && !isBanderole). Outline SVG must have explicitwidth: 100%; height: calc(100% + 70px)— relying oninsetalone does not size SVG elements correctly.
Form & i18n
- Two-column layout: Card Elements checkboxes (left) and a new Card Design column (right, banderole toggle) rendered as a CSS grid
1fr 1fr; on mobile (<1024 px) collapses to single column withrow-gap: 28px. - i18n: new keys
ui.form-card-design("Card Design" / "Kartendesign") andui.form-layout-banderole("Banderole type" / "Banderole") in both locales.
Runemark Additions
- Thyrielle's Zephyrites (
factions-order-bladeborn-thyrielles-zephyrites.svg) — added tohierarchy.tsunder Order › Bladeborn; i18n keythyrielles-zephyritesin both locales. - Warhammer Underworld (
misc-whu.svg) — added tomiscRunemarksinindex.ts; i18n keymisc.whuin both locales. SVG subsequently cropped. - Warcry (
misc-warcry.svg) — added tomiscRunemarksinindex.ts; i18n keymisc.warcryin both locales.
Inline Runemark Markup
- Syntax:
[slug]in body text or prerequisite text renders as(SVG). Unrecognised keys are left as literal text. Fighter/weapon keys are slugified at map-build time (Beast→beast,Icon Bearer→icon-bearer); faction/subfaction IDs are used directly. slugify()+inlineRmMap: module-level IIFE inTextCard.sveltemerges all runemark groups (fighters, weapons, characteristics, card-decks, deployment, misc, treasure, twists) plus the full hierarchy (alliances, factions, subfactions) into a singleslug → svglookup.parseMarkup()extended with a third regex pass.- CSS:
.body-text :global(.inline-rm)—inline-block,1.1em × 1.1em,vertical-align: -0.15em; inner SVG inheritsfill: currentColor. - Picker:
[⊕]button added to both markup toolbars (body text and prerequisite). Clicking opens an inline panel with a search input (auto-focused) and a grouped, scrollable list. Clicking an entry inserts[slug]at the current cursor position and closes the picker. Opening one picker closes the other. - Picker options: all groups present — Runemarks (fighters), Weapons, Characteristics, Card Decks, Deployment, Misc, Treasure, Twists, plus one group per grand alliance (Chaos/Death/Destruction/Order) containing factions and subfactions. Each entry shows
[slug]in monospace and the localised label. - i18n: new keys
ui.weapons-group("Weapons" / "Waffen") andui.form-insert-runemark("Insert runemark" / "Runemark einfügen") in both locales.
Small Body Text Toggle
smallBodyText: booleanadded toTextCardData; defaults tofalse. When active,.small-bodyclass on.parchmentreduces body text 20→16 px, flavor text 18→15 px, prerequisite text 18→14 px.A↓button added to both markup toolbars (between italic and[⊕]). Shared toggle — one flag affects all text areas. Active state renders with maroon fill.- i18n: new key
ui.form-small-text("Reduce text size" / "Textgröße reduzieren") in both locales.
Back Button Hover Effect
- Fighter, text, and card-back editors' back buttons updated from
text-zinc-500 hover:text-whitetotext-zinc-300 rounded px-2 py-1 hover:text-white hover:bg-zinc-800— matching the deployment editor.
💡 Key Decisions
Why use a child .banderole-bg div with mask rather than mask on the banderole itself?
Masking the container would also clip the text child. A separate absolutely-positioned background div allows the mask to apply only to the fill layer; the label text sits above it via z-index: 1.
Why does the outline SVG need explicit width/height in addition to inset?
SVG elements are replaced content and do not compute their dimensions from CSS inset the way block elements do — without explicit width and height the browser uses the SVG's intrinsic aspect ratio, rendering only the top sliver of the stroke. Adding width: 100%; height: calc(100% + 70px) (same pattern as .torn-edge-overlay) forces the correct size.
Why extend .banderole-bg by ±35 px rather than just padding the div?
The SVG shape's ragged edge detail is proportional to the rendered height. At the text-only height (~36 px) the edge deviation is <2 px and invisible. At ~150 px the same deviation resolves to ~10 px — clearly visible. Extending via negative inset keeps the text area compact while giving the mask enough height to show its texture.
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts | Added layoutVariant?: 'standard' | 'banderole' and smallBodyText: boolean to TextCardData |
src/routes/text/+page.svelte | Default layoutVariant: 'standard' and smallBodyText: false in initial state |
src/lib/components/TextCard.svelte | Banderole template branch, .banderole/.banderole-bg/.banderole-outline CSS, printer-friendly overrides, runemark inversion styles; inlineRmMap, extended parseMarkup(), .inline-rm CSS; .small-body CSS for reduced font sizes |
src/lib/components/TextForm.svelte | Two-column Card Elements / Card Design layout with responsive stack; [⊕] toolbar buttons, runemark picker UI + CSS, insertRunemark(), allInlineOpts/filteredInlineGroups derived state; A↓ small text toggle button with active state |
src/lib/i18n/locales/en.json | Added form-card-design, form-layout-banderole, misc.warcry, misc.whu, thyrielles-zephyrites, weapons-group, form-insert-runemark, form-small-text |
src/lib/i18n/locales/de.json | Added form-card-design, form-layout-banderole, misc.warcry, misc.whu, thyrielles-zephyrites, weapons-group, form-insert-runemark, form-small-text |
README.md | Mentioned banderole option in Text Card editor description |
src/lib/runemarks/hierarchy.ts | Added Thyrielle's Zephyrites under Order › Bladeborn |
src/lib/runemarks/index.ts | Added misc-warcry and misc-whu to miscRunemarks |
src/lib/runemarks/svg/factions-order-bladeborn-thyrielles-zephyrites.svg | New runemark |
src/lib/runemarks/svg/misc-warcry.svg | New runemark |
src/lib/runemarks/svg/misc-whu.svg | New runemark (subsequently cropped) |
src/routes/fighter/+page.svelte | Back button hover effect |
src/routes/text/+page.svelte | Back button hover effect |
src/routes/card-back/+page.svelte | Back button hover effect |
Deployment Card — Shapes, 7×9 Grid & Cleanup
Deployment point markers replaced with geometric shapes — triangle (dagger), diamond (hammer), circle (shield) — with icon fill via SVG inheritance and per-shape optical sizing. Objective marker added as a third placement type: black circle with an auto-width HTML label.
Snap grid redesigned to match the reference card: 7×9 inside (63 magenta dots, outermost on battlefield boundary) + 36 blue outside dots = 99 total. Template triplicate duplication removed (668 → 437 lines).
Two-point measurement lines with start/end cap picker and label. Header enlarged; View dropdown with Show Runemarks toggle; corner runemarks (Orientation & Matched Play); RND label redesigned; popover overflow clamping; i18n and CSS var cleanup.
✓ What Was Built
Header Resize & View Dropdown
- Header enlarged: padding
px-3 py-2→px-4 py-3; all buttonsh-7→h-9; player colour circlesw-7 h-7→w-9 h-9; separatorsh-5→h-7; texttext-xs→text-sm.HEADER_Hupdated to 61 (py-3=12 + h-9=36 + py-3=12 + border=1). - View dropdown: new split-style button added after the Overlays pill; dropdown contains "Show Runemarks" checkbox toggle. Opening it closes the other two dropdowns. Anchored via
getBoundingClientRect().leftcaptured at open time. - Show Runemarks toggle:
showRunemarks = $state(true)in+page.svelte, passed as prop toDeploymentCard. When on: SVG icon (dagger/hammer/shield) rendered inline in bubble. When off: letter fallback (D/H/S) rendered as<text>in Germania One.
Dropdown Fixes
- Load JSON anchored correctly: previously used hardcoded
right: 12pxso it appeared under the Export PNG button. Now capturesjsonBtnEl.getBoundingClientRect().leftat open time and usesleft: {jsonDropdownLeft}pxas the fixed-position anchor. - Mutual exclusion: each dropdown's open handler closes the other two first. Eliminates multiple open dropdowns overlapping each other.
- Dropdown top position: changed from header-bottom (61 px) to button-bottom (52 px = py-3 12 px + h-9 36 px + 4 px gap) so dropdowns sit immediately below their trigger button.
Popover Overflow Clamping
- Post-render clamping:
bind:this={popoverEl}+$effect— after each render, measurespopoverEl.getBoundingClientRect().bottomagainstviewportHeight - 8; shiftspopoverEl.style.topup by the overflow amount if needed. Safety net:max-height: calc(100dvh - 16px); overflow-y: autoon popover container.
Code Cleanup
- Dead state removed:
activeIcon(was always'dagger', never updated),ZONE_PRESETS(declared but unused in template) - Bug fixed:
loadLayout()was closingshowDropdowninstead ofshowJsonDropdown— layout loaded but dropdown stayed open ZonePresetimport removed from types destructure (unused after ZONE_PRESETS removal)- Stale runemark category imports removed from an earlier misimplementation (cardDecksRunemarks, fighterRunemarks, etc.)
i18n & CSS Variables
- New i18n keys:
ui.view(en: "View", de: "Ansicht"),ui.exported-card-alt(en: "Exported card", de: "Exportierte Karte") - All four editors:
alt="Exported card"→alt={t('ui.exported-card-alt')}infighter/+page.svelte,text/+page.svelte,card-back/+page.svelte,deployment/+page.svelte - Popover style block: all hardcoded hex colours (
#27272a,#3f3f46,#fff,#d4d4d8,#52525b) replaced with--ui-surface,--ui-border,--ui-text,--ui-surface-2,--ui-text-dim,--ui-surface-hover app.cssalignment:--ui-surface,--ui-surface-2,--ui-surface-hover, and--ui-borderhad one extra space vs other vars; all 14 variables in both:rootand[data-theme="light"]now align at the same column (--ui-surface-hoversets the width)
Two-Point Measurement Lines
- Type changes:
DeploymentMeasurementfieldsanchorCol/anchorRow/direction/lengthremoved; replaced withstartPos: DeploymentPositionandendPos: DeploymentPosition.DeploymentDirectiontype removed entirely. - Interaction flow: tap any empty position dot → popover shows "Add Marker" + "Add Measurement" buttons. Tapping "Add Measurement" sets
measurementMode = true+ recordsmeasurementStart, closes popover. A hint bar appears at the top of the card area. Tapping a second position completes the line; tapping the same position or cancelling aborts. - Measurement rendering: start/end coordinates from
getCoords(); vector normalised; start cap rendered before the line, end cap after. Full-line transparent hit area (stroke="transparent" stroke-width="24") opens the measurement popover anywhere along the line. - Cap picker UI: 4-button grid (● → | —) for both start cap and end cap; active cap highlighted red; stored as
DeploymentCapType('dot' | 'arrow' | 'tick' | 'none'). - Layer order fixed: measurements render between dashed centre lines and player bubbles in SVG source; position dots render last (on top of everything).
- Wording unified: "Overlays" → "Show Markers" throughout; state
showDots→showMarkers; remove buttons styled astext-xs text-zinc-400 underline hover:text-whiteto match app-wide pattern.
5×5 Snap Position Grid (session one)
- Old grid removed: 9 inside (
TL/TC/TR/ML/CC/MR/BL/BC/BR) + 12 outside edges + 4 corners = 25 positions. - New grid: 25 inside (
R1C1–R5C5) + 20 outside edges (OUT-T/B/L/R 1–5) + 4 corners = 49 positions.R3C3= exact battlefield centre (crosshair).R3row lies on horizontal centre line;C3column on vertical centre line. - Unified CNR coordinate system: all positions (inside and outside) derived from corner constants
CNR_L=63, CNR_R=852, CNR_T=34, CNR_B=540. Inside:x = CNR_L + c*(CNR_R-CNR_L)/6,y = CNR_T + r*(CNR_B-CNR_T)/6. Outside edge: same formula at the fixed edge coordinate. Corners: literal corner values. This ensures every column aligns vertically and every row aligns horizontally across inside and outside dots. - Even corner-to-corner distribution: the 5 outside edge positions per side span from corner to corner in 6 equal steps, so all 7 points per side (2 corners + 5 edge) are equally spaced.
- Regex-based helpers:
getCoords()uses.match()against the systematic naming — no switch statement needed. - Dead code removed: redundant
ax/ay/ex/eyaliases in measurement rendering (leftover fromMEAS_INSETremoval) eliminated.
7×9 Snap Grid & 99-Position Reference Match (session two)
- Grid redesigned to match the physical reference card: 63 magenta inside dots (7 rows × 9 cols, outermost positioned exactly on the BF boundary lines) + 36 blue outside dots = 99 total. Previous 5×5 grid was incorrect.
- Inside formula:
x = BF_L + (c−1)·BF_W/8,y = BF_T + (r−1)·BF_H/6. C1=BF_L, C9=BF_R, R1=BF_T, R7=BF_B. Centre R4C5 = (BF_CX, BF_CY) ✓ - Outside formula: T/B use same x as inside columns at CNR_T/CNR_B; L/R use same y as inside rows at CNR_L/CNR_R. 9 T + 9 B + 7 L + 7 R + 4 corners = 36.
- 3 blue dots per corner: OUT-T1 (above R1C1) + OUT-L1 (left of R1C1) + OUT-CNR-TL (diagonal) form a tight cluster at each corner.
- Outer left/right columns moved 10 px inward:
CNR_L63→73,CNR_R852→842. Brings outside dots visually closer to the battlefield edge. - DeploymentPosition type in
types.tsexpanded accordingly:R1C1–R7C9,OUT-T1–T9,OUT-B1–B9,OUT-L1–L7,OUT-R1–R7.
Template Deduplication
- Three copies removed: dashed centre lines, measurement lines, and deployment shapes were each duplicated three times in the SVG template — a leftover from iterative Python-based editing. Each section now appears exactly once.
- File reduced from 668 to 437 lines (−231 lines of pure duplication). Indentation normalised throughout.
Deployment Point Shapes
- Speech bubbles replaced with geometric shapes: triangle (dagger), diamond (hammer), circle (shield). Shapes filled with player colour; white icon via SVG fill inheritance; black icon on yellow or in printer-friendly mode.
- Uniform vertical extent: all three shapes span
cy ± CIRCLE_R (22px)from the snap point. Circle:r=22. Diamond: half-diagonal=22. Triangle: point-down equilateral with flat top atcy−22, tip atcy+22. - Icon fill via inheritance:
innerContent()strips allfillattributes; outer<svg>setsfill={iconColor}. Replaces fragile render-time.replace()approach. - Per-icon sizing: dagger 29px (shifted up to triangle centroid:
cy − CIRCLE_R/3 + 2); hammer 28px; shield 32px. - Printer-friendly badges moved from above shape to left-of-shape, vertically centred:
x={cx − CIRCLE_R − 4},text-anchor="end". - RND label above shape at uniform anchor
cy − SHAPE_R − RND_OFF; white with black stroke outline in colour mode, plain black in printer-friendly. - Yellow updated from
#ca8a04to#f59e0b(brighter amber); synced acrossDeploymentCard.svelteand+page.svelte. - Removed:
makeTailPoints(),getTailAngle(),bubbleWidth()and all tail/bubble constants (BH,BPAD,BRAD,TAIL_LEN,TAIL_W,GAP,BW_ICON,CHAR_W). Template indentation fixed.
Corner Runemarks — Orientation & Matched Play
- Two new fixed runemarks rendered in the card's top-left and top-right corners, 20 px inset from every edge (matching the caption's
bottom/left: 20pxspacing):twists-orientation.svg(top-left) andcard-decks-symmetrical.svg(top-right, Matched Play). - Text fallbacks: when
showRunemarksis off the SVGs are replaced with↑(Orientation) andMP(Matched Play) in Germania One, same dark-maroon fill#5a0a14; printer-friendly uses#000. - Individual View toggles:
showOrientationandshowMatchedPlaybooleans added to+page.svelteand passed as props; two new pill-switch rows added to the View dropdown. New i18n keys:ui.form-show-orientation,ui.form-show-matched-play(en + de).
RND Label Redesign
- Problem: the SVG
<rect>approach used a fixed 54 px width — too wide for "2", too narrow for "Hello, world". A stroke-on-white text approach produced staircase artefacts viafeMorphology. - Solution: RND labels are now rendered as HTML
<div>elements positioned absolutely over the card (position: absolute; transform: translate(-50%,-50%)). Background#faf6f3,border-radius: 4px,padding: 2px 6px,white-space: nowrap— the browser sizes the box to fit any text automatically. Printer-friendly: white background. All export-breaking properties (border: none; outline: none; box-shadow: none) explicitly reset. - Layer order: divs are rendered after
</svg>in DOM order and so naturally sit above the SVG content.
Player Badge — Circular White Background
- Moved out of the shapes loop: previously rendered inside the per-player/per-point loop (causing z-order issues with measurement lines). Now rendered in a separate final
{#each}block after position dots — guaranteed on top of all SVG content. - Circular background:
<circle cx={cx−CIRCLE_R−10} cy={cy} r="9" fill="white"/>— centred on the circled-numeral glyph (right edge atcx−CIRCLE_R−4, glyph ~12 px wide so centre atcx−CIRCLE_R−10), radius 9 gives ≈1–2 px clearance.
Dagger Letter Vertical Centring
- Previous:
y={cy + 8}(baseline) ≈ visual centre atcy— too low. - First fix:
y={cy − CIRCLE_R/3}(triangle centroid, ≈cy−7.3) — too high. - Final:
y={cy − CIRCLE_R/6}(≈cy−3.7) withdominant-baseline="central"— splits the difference between centroid and mid-height, visually balanced. Circle and diamond shapes remain atcy.
Measurement Cap Button Differentiation
- Start cap picker: arrow symbol changed to
←, tick symbol changed to⊤. - End cap picker: arrow symbol stays
→, tick symbol stays⊥. - Gives each button a distinct directional character so start and end pickers read differently at a glance.
New Faction Runemark — Cities of Sigmar (Alternative)
- SVG:
factions-order-cities-of-sigmar-alternative.svgcopied from_work/tosrc/lib/runemarks/svg/. - Hierarchy: imported as
oCitiesOfSigmarAltinhierarchy.ts; entry{ id: 'cities-of-sigmar-alternative', label: 'Cities of Sigmar (Alternative)', … }inserted immediately after the basecities-of-sigmarentry. - i18n:
factions.cities-of-sigmar-alternativeadded toen.json("Cities of Sigmar (Alternative)") andde.json("Städte Sigmars (Alternativ)").
Objective Markers (session four)
- New type:
DeploymentObjective { position, label }added totypes.ts;objectives?: DeploymentObjective[]added toDeploymentCardData. - SVG rendering: black circle
r=16(half ofCIRCLE_R× 1.5; smaller than deployment shapes) rendered below deployment point shapes in the SVG painter order. No stroke, fully opaque black. - HTML label: same div-over-SVG pattern as RND labels.
transform: translate(-50%, 0), positioned attop: cy + OBJECTIVE_R + 4pxso it sits just below the circle. Black background,#faf6f3text,white-space: nowrap. - Position dot coloring:
objectivePositionsderived set; dot getspos-dot--objectiveclass (black fill,#faf6f3stroke) when an objective occupies that position. - Click priority:
handlePositionClickchecks objectives first (before deployment points), then empty — prevents a stacked objective + point on the same position opening the wrong popover. - Popover — new mode:
{ kind: 'objective'; oi: number }branch added toPopoverMode; shows title, label input, and remove link. - i18n:
deployment.objective("Objective" / "Ziel");ui.form-objective-label,ui.popover-add-objective,ui.popover-remove-objective(en + de).
Empty Popover Restructure & Wording (session four)
- "Add point" → "Place marker": the old label implied a scoring point. "Place marker" correctly describes placing a dagger/shield/hammer marker at a position.
- "Measurement" → "Add measurement": consistent "Add X" verb across all three actions.
- Three stacked full-width buttons: "Place marker" (red) and "Add measurement" (zinc) are now separate full-width buttons, replacing the side-by-side layout that forced small text.
- Objective section: a
border-t border-zinc-700divider with apopover-label"OBJECTIVE" header separates the label input + Add objective button from the marker/measurement actions. Makes it immediately clear the label field belongs to the objective action only. popoverObjectiveLabelstate resets to''each time an empty popover opens.
Popover Overflow Fix (session four)
- Problem: the existing
$effectmeasuredgetBoundingClientRect()synchronously — before browser layout was complete, the element height could be 0 or stale, so the adjustment was a no-op. - Fix: wrapped measurement in
requestAnimationFrame()so it fires after paint. Element and popover values captured as local constants before the callback to avoid stale reactive reads. - Right-edge clamping added: the effect now also checks
rect.rightvsviewportWidth - 8and shiftsel.style.leftleftward if needed. - Initial position guard:
clampedStylebottom clamp increased fromviewportHeight - 320toviewportHeight - 500; prevents a visible flash on the frame before rAF fires.
💡 Key Decisions
<style> block — Tailwind utility classes don't apply there. Hardcoded dark hex values were visually wrong in light mode. Switching to --ui-surface, --ui-border, etc. gets correct colours in both themes automatically, matching every other themed element in the app.getCoords() automatically. Caps and label are edited afterwards in the measurement popover. Simpler mental model, fewer steps.BF_L + c*BF_W/6 and outside dots used CNR_L + n*(CNR_R-CNR_L)/6, the step sizes differed (119 px vs 132 px), so columns only aligned at the centre (n=3). Switching inside to the same CNR formula makes every column and row line up perfectly, which is both visually correct and meaningful — a measurement from OUT-L2 to R2C2 is a true horizontal line.R3C3 the exact centre, and puts dots directly on the dashed centre lines (all of row 3 and column 3). The extra granularity (49 positions vs 25) is necessary for precise deployment point placement in real Warcry scenarios..replace(/fill="white"/g, …) in the {@html} expression. This silently breaks when an SVG path uses fill inheritance (no explicit attribute). Stripping fills in innerContent() and setting fill={iconColor} on the outer <svg> works via CSS inheritance for any SVG structure and keeps the template readable.<rect> is either too wide for short labels or too narrow for long ones. An feMorphology dilate filter produces a staircase artefact because it operates on the rasterised pixel grid, not on geometry. A JS getBBox() action was implemented then reverted (uncertainty about timing with dom-to-image-more). An HTML <div> positioned absolutely over the card lets the browser's own text-layout engine size the background — exactly the same as a CSS-backed label, zero extra logic, captured correctly by both dom-to-image-more and modern-screenshot.$effect runs synchronously after the reactive graph is flushed, but browser layout (reflow) happens asynchronously. getBoundingClientRect() called in the effect body may see a height of 0 if the browser hasn't painted the element yet. Wrapping in requestAnimationFrame defers the measurement until after the next paint, guaranteeing a real bounding box. The visual difference is imperceptible (one frame ≈16 ms).⏳ Still Pending
| Status | Item |
|---|---|
| Deferred | Zones UI — addZone/removeZone functions exist but have no header entry point yet |
| Deferred | Measurement line tap discoverability — midpoint handle attempted and reverted (too small); no solution yet |
| Low priority | iOS virtual keyboard may obscure deployment popover inputs (RND + objective label) — fix: input.scrollIntoView on focus; defer until confirmed on device |
📁 Files Changed
| File | Change |
|---|---|
src/routes/deployment/+page.svelte | Header resize; View dropdown + showRunemarks toggle; Load JSON dropdown anchored; mutual exclusion; popover clamping; CSS vars; i18n; dead state removed; two-point measurement flow (measurementMode, measurementStart, hint bar); cap picker UI; showDots → showMarkers; yellow colour updated to #f59e0b |
src/lib/components/DeploymentCard.svelte | showRunemarks prop; SVG icon vs letter fallback; 5×5 → 7×9 inside grid; getCoords() rewritten (BF-origin inside, CNR-rail outside); CNR_L 63→73, CNR_R 852→842; measurement rendering (two-point, caps, full-line tap target, layer order); speech bubbles → geometric shapes (triangle/diamond/circle); fill-inheritance icon approach; uniform vertical extent; per-icon sizing; printer-friendly badge repositioned; tail/bubble code removed; triplicate template duplication removed (668→437 lines) |
src/lib/types.ts | DeploymentIconType reverted; DeploymentDirection removed; DeploymentMeasurement replaced anchorCol/anchorRow/direction/length with startPos/endPos; DeploymentPosition expanded from 25 → 49 → 99 positions; final: R1C1–R7C9, OUT-T1–T9, OUT-B1–B9, OUT-L1–L7, OUT-R1–R7, 4 corners |
src/lib/i18n/locales/en.json | Added ui.view, ui.exported-card-alt, ui.measurement-pick-end, ui.measurement-cancel; ui.overlays → "Show Markers"; removed stale measurement direction/length keys; removed 25 stale deployment.pos-* and deployment.dir-* keys (old position names and direction picker) |
src/lib/i18n/locales/de.json | Same changes in German |
README.md | Deployment card description updated: "Overlays switch" → "Show Markers"; 8-way arrow picker flow replaced with two-tap measurement description |
src/lib/i18n/README.md | deployment namespace description corrected from "Card back only" to "deployment card UI — marker icon names, zone preset names, cap type names" |
src/routes/fighter/+page.svelte | alt="Exported card" → alt={t('ui.exported-card-alt')} |
src/routes/text/+page.svelte | Same alt text i18n |
src/routes/card-back/+page.svelte | Same alt text i18n |
src/app.css | CSS variable column alignment normalised across all 14 vars in :root and [data-theme="light"] |
src/lib/components/DeploymentCard.svelte (session 3) | Orientation + Matched Play corner runemarks (showOrientation/showMatchedPlay props, SVG + text fallback); RND labels moved to HTML <div> with #faf6f3 background; player badges extracted to final SVG block with circular white <circle> behind each glyph; dagger letter y adjusted to cy−CIRCLE_R/6 with dominant-baseline="central"; sizeRndRect action added then removed; filter approach added then removed |
src/routes/deployment/+page.svelte (session 3) | showOrientation/showMatchedPlay state + View dropdown toggles; measurement start cap symbols ←/⊤, end cap →/⊥; new props passed to DeploymentCard |
src/lib/runemarks/hierarchy.ts | oCitiesOfSigmarAlt import; cities-of-sigmar-alternative entry after base cities-of-sigmar |
src/lib/runemarks/svg/factions-order-cities-of-sigmar-alternative.svg | New file — copied from _work/ |
src/lib/i18n/locales/en.json (session 3) | ui.form-show-orientation, ui.form-show-matched-play, factions.cities-of-sigmar-alternative |
src/lib/i18n/locales/de.json (session 3) | Same keys in German |
src/lib/types.ts (session 4) | DeploymentObjective interface; objectives?: DeploymentObjective[] added to DeploymentCardData |
src/lib/components/DeploymentCard.svelte (session 4) | OBJECTIVE_R=16; objective circles in SVG; objectivePositions derived set; pos-dot--objective class; HTML objective label divs; .objective-label CSS |
src/routes/deployment/+page.svelte (session 4) | Objective PopoverMode kind; popoverObjectiveLabel state; confirmAddObjective / removeObjectiveFromPopover functions; click priority (objectives before points); empty popover restructured (Place marker / Add measurement / Objective section with divider); rAF-based overflow fix (bottom + right); clampedStyle bottom guard 320→500 |
src/lib/i18n/locales/en.json (session 4) | deployment.objective, ui.form-objective-label, ui.popover-add-objective, ui.popover-remove-objective; ui.popover-add-point → "Place marker"; ui.popover-measurement → "Add measurement" |
src/lib/i18n/locales/de.json (session 4) | Same keys in German |
README.md (session 4) | Deployment card description updated to mention objective markers and the three-action popover |
Deployment Card — Card-First UI Rebuild
Morning: printer-friendly export mode, form style migration, dark export frame fix (Tailwind v4 preflight + dom-to-image-more interaction).
Afternoon: sidebar replaced with a compact header toolbar; interactive position dots
on the card itself with popovers for editing; 8-way direction picker on the SVG;
overlays toggle; JSON save/load split button; 22 new i18n keys; locale files
reordered into 13 logical groups. DeploymentForm.svelte deleted.
✓ What Was Built
Printer-Friendly Export
- Printer-friendly mode:
printerFriendlyprop onDeploymentCard; white card background (.card.is-printer-friendly { background: white }); all bubble colours forced to#000; battlefield fill changed to white with a black outline stroke; white<rect>as first SVG child covers any viewport-edge bleed - Unicode player badges: ①②③④ rendered above each bubble when
printerFriendlyis true, using SVG<text>withpaint-order="stroke"and a whitestroke-width="4"halo for legibility over any background; stored asPLAYER_BADGESconstant in script block (Svelte{@const}cannot appear at template top-level) - Printer-friendly export flow: dropdown option sets
printerFriendly = true, awaits RAF for re-render, callsdoExport('_print'), then resets flag; avoids any flicker in the live preview
Form & Caption Polish
- Form field styles:
DeploymentForm.svelteroot wrapper changed from ad-hoc Tailwind classes tospace-y-10 text-smmatching other forms; all labels migrated to scoped.field-label; all text/select inputs migrated to scoped.field-input; labels inside flex rows getstyle="margin-bottom:0" - Card name caption:
.card-namediv added below SVG —position: absolute; bottom: 20px; left: 0; right: 0; text-align: center; font-family: 'Alegreya'; font-size: 13px; color: #000; opacity: 0.5— matching FighterCard caption style;opacity: 1in printer-friendly mode; form name label annotated "— renders like caption"
Export Frame Bug Fix
- Root cause identified and fixed: Tailwind v4 preflight sets
border-style: solid; border-color: currentColoron*, suppressed byborder-width: 0. dom-to-image-more treats zero/default values as "skip" when serialising computed styles — soborder-width: 0is absent in the cloned foreignObject, leavingborder-style: solidactive. Overlay elements withcolor: #000,left: 0; right: 0, andopacity < 1(stacking context) produce a full-width black frame. Fix: explicitborder: 0; outline: none; background: transparenton every transparent overlay element's own CSS rule - Additional CSS fixes on
.card: changeddisplay: inline-block→display: block(inline-block creates a descender gap → bottom bar in export); addedborder: 0; outline: noneto.carditself; SVG given inlinestyle="display:block;border:none;outline:none;"for reliable serialisation - Code cleanup: removed stale
.card svgCSS rule (redundant with inline SVG style and incorrect indentation); removed allcapturingstate scaffolding from bothDeploymentCard.svelteand+page.svelte; added explicit{ width, height, scale: 2 }todomtoimage.toPng()call
SVG Optimisation
- 5 batches: all Chaos factions, Death factions, Destruction factions, Order factions, and fighters runemarks re-optimised (viewBox preserved, redundant attributes stripped, consistent
width="300" height="300"); 229 total SVGs in library after this pass
Card-First UI Rebuild
- Sidebar removed entirely: replaced with a compact single-row header toolbar; all editing now happens via popovers triggered from the card or header buttons
- Interactive position dots:
showPositionDotsprop renders white dots at all 25 positions; tap empty = popover to add a point for the active player; tap occupied = popover to edit/remove. Larger=22transparent hit target, visual dotr=7. - Popovers: contextual panel at tap coordinates (clamped to viewport). Empty-dot popover: player selection, icon selector (dagger/hammer/shield), RND input, Add Point / Add Measurement buttons. Occupied-dot popover: reassign colour, change icon, edit RND, remove. Measurement popover: 3×3 direction grid, length and label inputs, remove.
- 8-way direction picker on card: after placing a measurement anchor via snap grid, 8 arrow buttons radiate from the anchor point inside the SVG itself; click one to set direction and create the measurement.
MEAS_INSET=20pulls line endpoints 20 px inward from anchor/terminus to avoid overlapping bubbles; midpoint handle stays at true geometric midpoint. - Overlays toggle: pill switch (
role="switch",aria-checked) in header; gates bothshowPositionDotsandonMeasurementClick— turning it off hides all interactive dots and measurement handles for a clean preview - Pinch-to-zoom & pan: two-finger pinch scales card 0.5×–4×, centroid delta pans; double-tap resets to fit;
touch-action: noneon<main>
Header Toolbar & i18n
- Header structure: ← Back | card name | player circles + ✕ | Overlays [pill] | + Add Measurement | Save JSON[▾] | Export PNG[▾]; all items
shrink-0inside a singleoverflow-x-autorow - JSON split button: "Save JSON" as primary action; dropdown exposes "Load JSON". Intentionally separate from the Export PNG split button — JSON is round-trip data, PNG is a deliverable.
- Fixed-position dropdowns: both dropdowns use
position: fixed; top: 45px; right: 12pxto escapeoverflow-x: autoclipping - 22 new i18n keys in
uinamespace added to bothen.jsonandde.json: back, overlays, overlays-on/off-title, save-json, load-json, more-json/export-options, player select/remove titles, measurement anchor hint, and all five popover strings - Locale reorder: both JSON files restructured into 13 logical groups via Node.js script — 134 keys total. Groups: App/global → Navigation → Card types → Colors → Export & file → Image adjust → Faction filter → Common helpers → Fighter & text fields → Image/background → Card back → Deployment form → Deployment editor UI
💡 Key Decisions
#333 or other greys. All decoration (battlefield background, bubble colours) becomes either #000 or white.paint-order="stroke"; stroke-width="4") ensures legibility over dark backgrounds. Only shown in printer-friendly mode — live preview stays clean.border-style: solid; border-color: currentColor; border-width: 0 to every element. dom-to-image-more skips "zero/default" computed style values when building the cloned element's inline styles — so border-width: 0 is never written. In the SVG foreignObject context, border-style: solid remains active with no width suppressor. Any element with color: #000 (currentColor = black), spanning left: 0; right: 0, and with opacity < 1 (which creates a stacking context that dom-to-image composites in a separate pass — again losing border-width: 0) produces a full-card-width black border that looks like a frame. The trigger in this session was the .card-name caption div..card * universal selector reset?.card * rule IS applied by the browser (computed value is correct), but dom-to-image still skips border-width: 0 as a "default" value. The only reliable fix is border: 0; outline: none; background: transparent directly on each element's own CSS rule — not via wildcard inheritance.display: inline-block on .card cause a bottom bar in export?display: block (matching FighterCard) eliminates it.overflow-x: auto clipping?overflow-x: auto implicitly sets overflow-y: auto, creating a stacking context that clips position: absolute children. The only reliable escape is position: fixed. Hardcoded top: 45px = py-2 (16 px) + h-7 (28 px) + 1 px border; right: 12px matches px-3 padding.onMeasurementClick={undefined} when off — the card component already renders handles conditionally on that prop.CENTRE_LINE_GAP=38 to stop dashed lines before the battlefield edge (to avoid visual overlap with outside bubbles). A screenshot showed the gap made lines look unfinished — as if they were broken rather than terminating cleanly. Reverted to lines reaching exact BF_T/B/L/R edges.⏳ Still Pending
| Status | Item |
|---|---|
| Deferred | Zones UI — addZone/removeZone functions exist but have no header entry point yet |
| Deferred | More snap positions — user may need more than 25; deferred pending feedback |
| Deferred | Mobile visual testing — session ended before completing a full mobile test pass |
📁 Files Changed
| File | Change |
|---|---|
src/lib/components/DeploymentCard.svelte | Printer-friendly prop + mode; .card-name caption; .card CSS fixed (display:block, border:0); SVG inline style; PLAYER_BADGES constant; stale CSS removed |
src/lib/components/DeploymentForm.svelte | Migrated to field-label/field-input scoped CSS; root wrapper to space-y-10 text-sm; name label hint added |
src/routes/deployment/+page.svelte | Explicit { width, height, scale: 2 } in export call; exportPrinterFriendly() function; removed capturing state scaffolding |
src/lib/runemarks/svg/factions-chaos-* (Chaos batch) | Re-optimised; redundant attributes stripped; width="300" height="300" normalised |
src/lib/runemarks/svg/factions-death-* (Death batch) | Re-optimised; same normalisation pass |
src/lib/runemarks/svg/factions-destruction-* (Destruction batch) | Re-optimised; same normalisation pass |
src/lib/runemarks/svg/factions-order-* (Order batch) | Re-optimised; same normalisation pass |
src/lib/runemarks/svg/fighters-* (fighters batch) | Re-optimised; same normalisation pass; library total now 229 files |
src/lib/components/DeploymentCard.svelte | Position dots overlay; ALL_POSITIONS; direction picker on card; MEAS_INSET=20; caption left-aligned; DeploymentDirection import added; dead constants removed |
src/routes/deployment/+page.svelte | Sidebar removed; header toolbar; popovers; pinch-zoom; showDots toggle; JSON split button; fixed-position dropdowns; 22 i18n calls; dead DIRECTIONS/CAP_TYPES/.field-label/.field-input removed; saveLayout showDropdown bug fixed |
src/lib/i18n/locales/en.json | 22 new deployment editor UI keys; reordered into 13 logical groups (134 keys total) |
src/lib/i18n/locales/de.json | Same 22 new keys; same reorder |
src/lib/components/DeploymentForm.svelte | Deleted — unused after sidebar removal |
README.md | Deployment card description updated to reflect card-first UI |
.github/ISSUE_TEMPLATE/bug_report.md | Added "Deployment card editor" to editor checklist |
.github/ISSUE_TEMPLATE/feature_request.md | Added "Deployment card editor" to editor checklist |
Deployment Form, Caption, Layout & SVG Normalisation
Deployment point form: player colour, per-player point lists, speech bubbles with
atan2 tail fix, dynamic bubble width, unique position enforcement, and
CC-first defaulting. Sidebar widened; desktop preview scrollable; mobile scales to
viewport with pinned export button. 13 new i18n keys.
On main: caption feature added to Text Card editor; black colour swatch fixed in dark mode; issue templates updated; all 61 non-300 runemark SVGs normalised to 300×300.
✓ What Was Built
Deployment Card — Form & Bubbles
- Speech bubble rendering: coloured rounded-rect bubble (BH=48px) with white icon (32px) and optional RND label; directional tail pointing toward battlefield; 33 snap positions
- Corner tail fix: replaced hardcoded 45° diagonal tail with
getTailAngle()using exactatan2to the nearest battlefield corner (~35°); generalmakeTailPoints(BW, BH, angle)handles all directions and detects rounded-corner zone - Dashed centre lines from centre: split into four arms originating at
(BF_CX, BF_CY)so the first dash starts at the cross - Player-level form structure:
DeploymentPlayer { color, points[] }; colour exclusion, position exclusion, smart CC-first defaulting, dynamic bubble width, contrast-aware icon buttons - Layout & i18n: sidebar 360 → 480 px; desktop scroll; mobile horizontal scroll + pinned export; all strings moved to
t()with 13 new locale keys
Text Card — Caption Feature
TextCardData— addedshowCaption: booleanandimageCaption: string- Card visual — caption at
position: absolute; bottom: 20pxinside.card, matching Fighter Card; dark text, 50% opacity - Form order — Type select before Name; caption input below Name; keyed as
form-placeholder-caption-fighter/form-placeholder-caption-text
SVG Normalisation & Misc
- 61 runemark SVGs normalised: 34×32, 16×512, 11×1024 px all set to 300×300 via
sed;viewBoxunchanged; all 203 runemarks now sharewidth="300" height="300" - Card Back dark mode:
.swatch-blackgainedborder-color: rgba(255,255,255,0.3) - Issue templates: "Ability card editor" → "Text card editor"; Card back option added; preamble comment added to all three templates
💡 Key Decisions
makeTailPoints also detects when the exit point is in the rounded-corner zone (|ex| > halfW − BRAD && |ey| > halfH − BRAD) and switches to the two-edge base strategy, keeping the tail flush with the visible bubble surface.getComputedTextLength) requires a rendered element and is async. A CHAR_W=9 per-character estimate for Germania One at 17px is close enough for a decorative label — easily tunable via one constant.BH/2 + margin and BW_ICON/2 + margin, hardcoded for clarity. Must be updated if bubble dimensions change.overflow-x-auto provides horizontal scrolling
for the overflow.
overflow-x-auto wrapper causes it to scroll
out of view with the card. Making it a
shrink-0 flex sibling above the scroll area
keeps it pinned at the top of the preview pane regardless
of scroll position.
.card
rather than .image-section?
position: absolute; bottom: 20px relative to
.card, placing it over the parchment near the
bottom of the full 915 px card. Placing the Text Card
caption inside .image-section (height 280 px)
would have anchored it to that section's bottom — a
completely different visual position. Moving it out of the
image section and into .card gives identical
positioning in both editors.
width="300" height="300" attributes make the
library consistent and predictable for any future tooling
or re-vectorisation. The viewBox is left
intact so path geometry is preserved exactly.
⏳ Still Pending
| Status | Item |
|---|---|
| Planned |
Deployment card — visual position picker (clickable grid
replacing <select>)
|
| Planned | Deployment card — measurement lines (direction, label, start/end cap, SVG rendering with distance label at midpoint) |
| Done |
SVG re-vectorisation — all 234 runemarks normalised to 300×300; no remaining svg: null entries
|
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Added DeploymentPlayer; removed color from DeploymentPoint; DeploymentCardData.points[] → players[]; added showCaption, imageCaption to TextCardData |
src/lib/components/DeploymentCard.svelte |
Corner tail fix (getTailAngle + makeTailPoints); nested player/point loops; dynamic bubbleWidth(); dashed lines as 4 arms from centre |
src/lib/components/DeploymentForm.svelte |
Player-level structure; colour exclusion; position exclusion + smart defaulting; contrast-aware buttons; UX sizing lift; all hardcoded strings replaced with t() |
src/routes/deployment/+page.svelte |
Sidebar 360 → 480 px; desktop vertical centre; mobile horizontal scroll + viewport-height scale; pinned export button |
src/lib/components/TextCard.svelte |
Caption element + CSS; anchored to .card at bottom: 20px |
src/lib/components/TextForm.svelte |
Type select moved before Name; caption input below Name; Show Caption toggle in Card Elements |
src/lib/components/FighterForm.svelte |
Caption input placeholder wired to t('ui.form-placeholder-caption-fighter') |
src/routes/text/+page.svelte |
Initial state: showCaption: false, imageCaption: '' |
src/routes/card-back/+page.svelte |
.swatch-black border visible in dark mode |
src/lib/i18n/locales/en.json |
13 deployment keys + 2 caption placeholder keys |
src/lib/i18n/locales/de.json |
Same 15 keys in German |
src/lib/runemarks/svg/ (61 files) |
width/height normalised to 300 from 32, 512, or 1024 |
.github/ISSUE_TEMPLATE/bug_report.md |
"Ability card editor" → "Text card editor"; Card back option added; preamble comment |
.github/ISSUE_TEMPLATE/feature_request.md |
Same fixes |
.github/ISSUE_TEMPLATE/new_language.md |
Preamble comment added |
README.md |
Text card editor description updated to mention caption |
CLAUDE.md |
showCaption added to TextCardData flags list |
Card Back Editor + Deployment Card Foundation
Full card back editor at /card-back: name with pipe line-break, mirrored
name toggle, text/runemark colour swatches, searchable runemark picker, background
image with pan/zoom on desktop and touch drag + pinch-to-zoom on mobile.
Printer-friendly forces black on white. Runemark library expanded with 28 new SVGs
across 5 new categories (Card Decks, Deployment, Misc, Treasure, Twists).
Deployment card editor scaffolded at /deployment: 915×574 px layout,
battlefield rectangle with dashed centre lines, 17-position snap grid, and three
icon types (dagger, hammer, shield) via ?raw SVG imports. Data model
fully typed; form UI deferred to the next session.
✓ What Was Built
Card Back Editor — /card-back
-
New route —
src/routes/card-back/+page.svelte; same two-panel layout (form left, card preview right) as fighter and text editors -
CardBackDatainterface intypes.ts:title,backgroundImage,imageOffsetX/Y,imageZoom,runemark,textColor,showFlippedName -
Card visual — 574×915px; default texture
(
static/background.jpg);has-bg-imageclass suppresses CSS background when custom image is uploaded (avoids double-layering) - Name + runemark overlay — Germania One 56px; runemark 280×280px SVG; mirrored name (rotated 180°) above/below runemark or flanking name when no runemark
-
Mirrored name —
showFlippedNameflag (defaulttrue); works for name-only and name+runemark;name-overlay-text-onlyclass widens gap to 220px when no runemark between the two names -
Text colour — three swatches: white
(
#fff), black (#000), red (#c0392b); drives--card-text-colorCSS custom property on.card; printer-friendly always forces#000 -
Runemark picker — search input + scrollable
grouped list (identical pattern to
FactionSelect); covers all fighter, weapon, characteristic, and faction/hierarchy runemarks; selection badge with clear button; "no matches" empty state - Background image — upload, remove, sliders for X/Y position and zoom (desktop); Adjust Image button + full-card touch overlay for drag-to-pan + pinch-to-zoom (mobile)
- Export — PNG + printer-friendly PNG via dom-to-image-more (desktop) / modern-screenshot (mobile); Web Share API on iOS
i18n
-
New keys in both locales:
card-back,form-placeholder-card-back,form-runemark,form-filter-runemarks,form-text-color,form-show-flipped-name,adjust-image-hint-mobile -
Fixed hardcoded English "Adjust image position and zoom in the
Preview tab." in
FighterForm.svelte— now usest('ui.adjust-image-hint-mobile') -
JSON parse bug fixed in
de.json: stray ASCII"inside a German string containing typographic quotes was terminating the JSON value early
Docs & Code Cleanup
-
README.md— card back editor described; "Both editors" → "All three editors" -
CLAUDE.md— project description, routes list, and Card structure section updated -
Bug fix:
adjustModenow resets tofalsewhen background image is removed (was sticky, would re-activate on next upload)
Landing Page
-
Build-time date injection —
LAST_UPDATEDhardcoded string removed; Vitedefineinjects__BUILD_DATE__as the ISO date at build time; ambientdeclare constadded tosrc/app.d.tsfor TypeScript; "Last updated" on the landing page now advances automatically on every build with no manual edit needed -
Card Back button secondary style — button
visually de-emphasised relative to Fighter Card and Text Card
buttons; uses
bg-zinc-700 hover:bg-zinc-600fill withtext-zinc-300 hover:text-whitefor a clear hover effect, while the primary editors keepbg-red-900
Runemark Library — New Categories (card back only)
- 28 new SVG files — Card Decks (6), Deployment (3), Misc (3), Treasure (8), Twists (7)
-
5 new export Records in
src/lib/runemarks/index.ts:cardDecksRunemarks,deploymentRunemarks,miscRunemarks,treasureRunemarks,twistsRunemarks -
5 new i18n namespaces (
card-decks,deployment,misc,treasure,twists) with translations in bothen.jsonandde.json; group labels added to theuinamespace -
LocaleDatainterface intypes.tsupdated with the 5 new fields -
Card back picker — 5 new sorted groups appended
to
allRunemarkOptions; new records spread intoallRunemarkSvgs -
SVG fill bug fix —
card-decks-scales-of-talaxis.svganddeployment-shield.svghadfill="#000"on their<path>elements; stripped so paths inherit--card-text-colorfrom the parent<svg>correctly - i18n README — namespace table extended with 5 new entries
Deployment Card Editor — /deployment
- Landscape layout — 915×574px card (portrait dimensions rotated); sidebar 360px; scale formula
min((viewportWidth − 360) / 915, (viewportHeight − 64) / 574, 1)constrains both axes; mobile tab bar identical to other editors - Battlefield rectangle — parchment-coloured (
#d9b8a8) rect inset 100px left/right, 60px top/bottom; dashed centre lines (8px dash / 6px gap) divide it into 4 quadrants; card name renders faintly at centre of battlefield - SVG-based card —
DeploymentCard.svelterenders an inline<svg width="915" height="574">directly; parchment texture via CSSbackground-imageon the wrapper.carddiv (same as other cards) - 17 snap positions — 9 inside (3×3 grid: TL TC TR / ML CC MR / BL BC BR) + 8 outside (OUT-TL/TC/TR, OUT-LC, OUT-RC, OUT-BL/BC/BR); each maps to an (x, y) coordinate on the SVG canvas
- Deployment icon types — dagger (downward triangle ▽), hammer (diamond ◇), shield (circle ⊙); outer shapes drawn as SVG polygon/circle with
fill="currentColor"; inner icon imported via?rawfromdeployment-dagger.svg/deployment-hammer.svg/deployment-shield.svgand embedded as white content in a nested<svg> - Player colours — 2-player for now: red
#c0272d, blue#1a3a6e; set viastyle="color: {color}"on<use>, picked up byfill="currentColor"on outer shapes - RND labels — positioned adjacent to icon in the "outward" direction from the rectangle (above for top positions, below for bottom, left/right for side); colour matches player colour (or black in printer-friendly mode)
- Data model fully typed —
DeploymentCardData,DeploymentPoint,DeploymentMeasurement,DeploymentPosition,DeploymentIconType,DeploymentColor,DeploymentCapType,DeploymentDirectionall insrc/lib/types.ts - Measurement model defined — each point supports 0–2
DeploymentMeasuremententries:direction(up/down/left/right),label(e.g.8"),startCap+endCap('arrow' | 'tick' | 'none'); rendering not yet implemented
💡 Key Decisions
has-bg-image class sets
background-image: none so only the uploaded image
is used. The background-color: #5a0a14 fallback
still shows at any transparent edges.
<select> for the runemark picker?
--card-text-color CSS custom property rather
than directly inlining colour per element?
.card-name text
and the injected {@html} SVG fill. Setting a single
CSS variable on the .card div propagates to both
via color: var(--card-text-color) and
fill: var(--card-text-color) in scoped CSS. The
printer-friendly override sets the variable to
#000 at the same level, so no element-level
overrides are needed. Export risk:
getComputedStyle resolves CSS variables, so
dom-to-image-more should copy the resolved values — worth
verifying in a real export test.
fill="#000" from SVG path elements rather
than fixing it in CSS?
<path fill="#000">) override inherited CSS — the parent
<svg>'s
fill: var(--card-text-color) propagates via CSS
inheritance, but a child's own presentation attribute beats an
inherited value. The clean fix is to remove the attribute at
source: paths with no fill then correctly inherit from the
<svg>. An alternative would be adding
.card-runemark :global(svg *) { fill: var(--card-text-color)
}
(CSS author rule beats presentation attributes), but stripping
at source is simpler and correct in any rendering context.
?raw and embed as white inner content, rather than using the existing weapon SVGs or drawing from scratch?deployment-dagger.svg, deployment-hammer.svg, deployment-shield.svg). Using them via ?raw, stripping the outer <svg> wrapper, and replacing all fill attributes with fill="white" gives authentic Warcry icons inside the coloured shapes with zero path-drawing effort. A nested <svg> with its own viewBox handles scaling cleanly.startCap and endCap independently ('arrow' | 'tick' | 'none') so both styles are supported. Rendering is deferred to the next session.⏳ Still Pending
| Status | Item |
|---|---|
| done |
Verify color: var(--card-text-color) and
fill: var(--card-text-color) resolve correctly
in dom-to-image-more PNG exports for all three colour
options (card back) — confirmed working 2026-03-29
|
| Next | Deployment card form: snap position picker, icon type selector, player colour, RND field — add/remove points UI in DeploymentForm.svelte |
| Next | Measurement line rendering in DeploymentCard.svelte: arrow / tick-mark line from icon toward reference edge, with distance label at midpoint |
| Later | 4-player support (yellow + green colours) — currently 2-player only |
| Later | Optional quadrant colour fills (some official cards shade player zones) |
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Added CardBackData interface; added 5 new fields to LocaleData; added DeploymentCardData, DeploymentPoint, DeploymentMeasurement interfaces + deployment type aliases |
src/routes/card-back/+page.svelte |
New file — full card back editor; 5 new runemark categories wired into picker and SVG map |
src/lib/runemarks/index.ts |
5 new import groups + exported Records for Card Decks, Deployment, Misc, Treasure, Twists |
src/lib/runemarks/svg/ |
28 new SVG files added; fill="#000" stripped from card-decks-scales-of-talaxis.svg and deployment-shield.svg |
src/lib/i18n/locales/en.json |
Card back keys added; 5 new namespaces + group labels in ui |
src/lib/i18n/locales/de.json |
Same; stray ASCII " inside typographic-quote string fixed |
src/lib/i18n/README.md |
Namespace table extended with 5 new card-back-only namespaces |
src/lib/components/FighterForm.svelte |
Hardcoded "Adjust image position…" → t('ui.adjust-image-hint-mobile') |
README.md |
Card back editor described; "Both editors" → "All three editors" |
CLAUDE.md |
Routes, project description, and Card structure section updated for card back |
.github/ISSUE_TEMPLATE/new_language.md |
Relative link → absolute GitHub URL (relative resolved without repo name on issue creation page) |
vite.config.ts |
Added define: { __BUILD_DATE__ } — injects build date at compile time |
src/app.d.ts |
Added declare const __BUILD_DATE__: string ambient declaration |
src/routes/+page.svelte |
Replaced hardcoded LAST_UPDATED constant with __BUILD_DATE__; Card Back button restyled to secondary (zinc-700); Deployment Card button added |
src/lib/components/DeploymentCard.svelte |
New file — 915×574px SVG card; battlefield rect + dashed centre lines; 17 snap-position coordinate map; dagger/hammer/shield symbols using ?raw deployment SVG inner content |
src/lib/components/DeploymentForm.svelte |
New file — name field only (form UI for deployment points deferred to next session) |
src/routes/deployment/+page.svelte |
Replaced placeholder "Coming soon" with full editor page: 360px sidebar, landscape scale formula, export dropdown, mobile tab bar |
Card Elements, Fighter Parity & Table Visual Polish
Added an independent "Card Elements" checkbox system to both editors, prerequisite field, fighter subtitle/caption toggles, and fully renamed the editor from Ability Card to Text Card.
Visual polish pass — semi-transparent white backgrounds on all data rows, alternating maroon row tints, damage table row heights and spacing, points table restyled to match damage table (Alegreya 17px, shadow borders, no dark header), German label corrections, minor landing page spacing.
Landing page LangSwitch rewritten as a custom <details> dropdown — fixes "Add a language…" not working on mobile (blocked as a popup by iOS Safari). Export PNG filename now prefixes grand alliance, faction, and bladeborn; Hero/Monster/Thrall runemarks made mutually exclusive, Thrall sorting to top.
✓ What Was Built
Card Elements — Text Card
-
Four new boolean flags on
TextCardData:showActivation,showFlavorText,showPrerequisite,showPointsTable -
Defaults —
showRunemarks: true,showActivation: true,showFlavorText: true; prerequisite and points table defaultfalse - Card Elements checkbox order — Show Runemarks → Show Activation → Show Flavor Text → Show Points Cost Increases → Show Prerequisite
-
Card render gates —
{#if data.showActivation && data.activationType},{#if data.showFlavorText && data.flavorText},{#if data.showPointsTable},{#if data.showPrerequisite && data.prerequisiteText} - Parchment render order — flavor text → points table → prerequisite → body text
-
Points table decoupled — no longer gated on
cardLabel === 'divine-blessing'; works with any card type
Card Elements — Fighter Card
-
Two new boolean flags on
FighterCardData:showSubtitle,showCaption -
Caption field moved — extracted from the image
block, now appears below the subtitle input; gated on
showCaption
Prerequisite Field & Rename
-
prerequisiteText: stringadded toTextCardData; card renders.prerequisite-boxwrapping.body-textwithparseMarkup() -
Files renamed:
AbilityCard.svelte→TextCard.svelte,AbilityForm.svelte→TextForm.svelte, route/ability→/text -
Type
AbilityCardData→TextCardData; card type slug'ability'preserved in preset labels
Table Row Contrast — Fighter Card
-
White wash —
rgba(255, 255, 255, 0.25)on.stats-values,.weapons-box, and.damage-box -
Alternating tints —
rgba(90, 10, 20, 0.06)on even damage rows;rgba(90, 10, 20, 0.08)on second weapon row (:nth-child(odd)due to header being first child) -
Row dividers —
box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.4)on damage rows after the first -
Damage row height — bumped to 28px;
damage-boxvertical padding/gap tuned to 0 -
Removed
text-transform: uppercasefrom.dcol-wide(the "* Zugewiesener Schaden" label) - Gap between tables reduced to 20px (was 29px)
- Printer-friendly — all backgrounds and alternating tints reset to transparent
Points Table Restyle — Text Card
-
Matches damage table — removed dark maroon
header and outer border/radius; replaced with
box-shadow: inset 0 1px 0 0 #000, inset 0 -1px 0 0 #000outer borders andrgba(255, 255, 255, 0.25)container background -
All text — Alegreya 17px,
color: #000,padding: 0 8px; Germania One and colored header removed -
Row height — 28px for header, regular, and
elite rows; row dividers
rgba(0, 0, 0, 0.4) -
Alternating tint —
.points-table > *:nth-child(even)getsrgba(90, 10, 20, 0.06)(regular row = 2nd child) - Printer-friendly — container and tint both cleared
-
Dead code removed —
points-row-bottomclass and.points-value.is-emptyrule
German Translation Corrections
-
form-caption: "Beschriftung" → "Bildunterschrift" -
form-damage-points-allocated: "Zugeteilte Schadenspunkte" → "Zugewiesener Schaden" -
col-damage-points-allocated: "* Erlittener Schaden" → "* Zugewiesener Schaden"
Landing Page
-
Button group gains
my-4for extra breathing room above and below -
LAST_UPDATEDconstant + locale-aware "Last updated" line viaIntl.DateTimeFormat+$derived
Export Filename — Faction Prefix
-
makeSlug()in both/fighterand/textpages now collectsgrandAlliance,faction, andbladeborn(skipping empty values), slugifies each, and prepends them before the card name — e.g.stormcast-eternals_prosecutors_my-fighter_2026-03-22_14-30.png
Runemark Rules — Hero / Monster / Thrall Mutual Exclusion
- Picking any of Hero, Monster, or Thrall disables the other two in all three runemark selects
-
Thrall added to the priority sort (
priority = -1) so it floats to the top of the right-column runemark display, matching Hero and Monster behaviour
LangSwitch — Custom Dropdown
-
Replaced native
<select>+ separate link with a single<details>/<summary>pill dropdown - Summary — globe SVG icon + current language name, styled to match the GitHub and theme-toggle pills
-
Menu — language
<button>s (active state bold), horizontal divider,<a>link for "Add a language..." -
Close on select —
details.open = falseafteri18n.setLocale() -
Close on outside tap —
documentclick listener registered via$effectwith cleanup -
Redundant
.lang-addCSS rule removed (duplicate of.lang-optioncolor)
💡 Key Decisions
<details> for LangSwitch instead of
keeping the native <select>?
change event is not treated as
a trusted user gesture by iOS Safari — any
window.open() call from it is blocked as a popup.
By using a custom <details> element, the "Add
a language..." entry becomes a real
<a href> anchor, which always works on
mobile. As a side benefit, all language options and the link
live in a single dropdown instead of two separate UI elements.
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Added show flags to TextCardData and
FighterCardData; AbilityCardData →
TextCardData; added
prerequisiteText
|
src/lib/components/FighterCard.svelte |
Subtitle/caption gated; white wash + alternating tints on stats/weapons/damage rows; row heights, dividers, gap tuned; damage table uppercase removed; printer-friendly overrides updated |
src/lib/components/FighterForm.svelte |
Card Elements section; showSubtitle +
showCaption checkboxes; caption moved to name
section; "Is Named Fighter" / "Is Monster" labels;
Hero/Monster/Thrall mutually exclusive; Thrall added to
priority sort
|
src/lib/components/TextCard.svelte |
Show-flag gates; points table restyled to match damage table (Alegreya 17px, shadow borders, alternating tint, no dark header); dead class removed |
src/lib/components/TextForm.svelte |
Card Elements section; checkbox order: Runemarks → Activation → Flavor Text → Points Cost Increases → Prerequisite |
src/lib/i18n/locales/de.json |
"Beschriftung" → "Bildunterschrift"; "Zugeteilte Schadenspunkte" / "Erlittener Schaden" → "Zugewiesener Schaden"; all new keys translated |
src/lib/i18n/locales/en.json |
All new show-flag and prerequisite i18n keys added |
src/routes/+page.svelte |
Buttons stacked above description; my-4 margin
on button group; LAST_UPDATED + locale-aware
date
|
src/routes/text/+page.svelte |
Defaults for all four new show flags;
prerequisiteText: '';
makeSlug() prefixes
grandAlliance/faction/bladeborn
|
src/routes/fighter/+page.svelte |
Defaults showSubtitle: false,
showCaption: false;
makeSlug() prefixes
grandAlliance/faction/bladeborn
|
CLAUDE.md |
Text card structure updated: show/hide flags, parchment render order, prerequisite |
src/lib/components/LangSwitch.svelte |
Replaced native <select> + separate link
with <details> dropdown; "Add a
language..." is now a real <a> anchor —
fixes mobile popup-block bug
|
Divine Blessing Card Type & Locale-Aware Sorting
Added "Divine Blessing" card type with German translation and two conditional
points-value fields (Regular / Elite) shown as a horizontal table. Locale-aware
sorting for factions, subfactions, weapon types, and fighter runemarks — all
re-sort reactively on locale change. Code quality pass: structural typing bug in
FactionSelect, redundant code path in computeFiltered,
stale comments cleaned up.
Follow-up: points table restructured with proper header row, threshold qualifiers
on row labels (≤ 22 / ≥ 23), restyled to match fighter card pattern. Empty-state
behaviour fixed — fields pre-fill, show — when cleared, row height
locked.
✓ What Was Built
Divine Blessing & Locale Sorting
-
Divine Blessing card type — new preset slug
divine-blessing; German: "Göttlicher Segen"; added to both form select and cardpresetSlugs -
Points value fields —
regularPointsValueandelitePointsValueadded toTextCardData; form inputs appear conditionally below Card Name when Divine Blessing is selected - Form field order — Activation moved below Card Name and Points section, before Runemarks; Divine Blessing positioned before Lesser Artefact in the type dropdown
-
Locale-aware sorting —
FactionSelectsorts alliances, factions, and subfactions;FighterFormandTextFormsort weapon types and fighter runemarks — all vialocaleCompare(b, i18n.code)in$derived -
FactionSelect refactor — extracted
sortAll()for the no-search path; eliminated redundant!lcguard branches inside the filter map -
FactionSelect type fix — replaced
FighterCardDataimport with a structural prop type{ grandAlliance, faction, bladeborn }, making it correctly usable with both card types -
Info file cleanup — stale
cardLabelcomment intypes.ts; ability card label list inCLAUDE.md; march-20 locale sorting pending item marked Done
Points Table Empty State
-
Pre-filled defaults — initial state changed
from
undefinedto'15'/'20'; type changed fromnumber?tostring?to match what text inputs actually bind -
is-emptyclass — card shows—(em dash) at 16px withclass:is-emptywhen field is cleared, matching the fighter card weapon stat pattern (.weapon-val.is-empty) -
Stable row height —
line-height: 1.1(relative) replaced withline-height: 25px(absolute) on.points-value; font-size changes in.is-emptyno longer affect the row height -
Form placeholders — inputs show
15/20as placeholders when cleared
Points Table Refinements
- Header row — restructured divine blessing table with a dark maroon header row ("Fighter Wounds" / "Points Cost Increase") styled like the fighter stat table; row labels shortened to "Regular (≤ 22)" and "Elite (≥ 23)"
-
Border approach — switched from
box-shadow: 0 0 0 1pxtoborder: 1px solid #5a0a14+border-radius: 6.5px 6.5px 0 0on the inner header row, matching the fighter table pattern; removedoverflow: hidden -
i18n additions — added
points-col-wounds("Fighter Wounds" / "Lebenspunkte des Kämpfers") andpoints-col-cost("Points Cost Increase" / "Punktwertzuschlag"); shortenedpoints-regular/points-eliteto just "Regular" / "Elite"
💡 Key Decisions
box-shadow to border: 1px solid?
box-shadow: 0 0 0 1px as a
workaround for suspected export fidelity issues with
border-radius + border in
dom-to-image-more. On review, the fighter card's
.stats-box and .weapons-box already
use border: 1px solid #5a0a14 successfully in
exports. Switching to the same pattern gives a proper header row
with matching inner border-radius (6.5px inside the 7.5px outer)
without needing overflow: hidden.
line-height: 25px on
.points-value instead of keeping 1.1?
.is-empty class reduces
font-size from 22px to 16px to render the em dash
at a smaller size. With a relative
line-height: 1.1, the effective line height changes
from 24.2px to 17.6px, shrinking the row. Setting an absolute
value locks the line box height regardless of font-size changes,
keeping the row height identical whether a value is present or
not.
$derived for sorted weapon/runemark lists
rather than sorting once at module load?
i18n.code, which is
$state and changes at runtime when the user
switches locale. A $derived reading
i18n.code re-runs automatically on locale change. A
module-level sort would be frozen at the initial locale.
FactionSelect's prop as a structural type
instead of a union of the two card types?
grandAlliance, faction,
bladeborn. Typing the prop as the exact shape it
needs makes it work correctly with any object that has those
fields, avoids importing either card type, and documents the
actual contract at the call site.
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Added regularPointsValue? and
elitePointsValue? to TextCardData;
updated stale cardLabel comment; changed points
field types from number? to
string?
|
src/lib/i18n/locales/en.json |
Added label-divine-blessing,
points-regular, points-elite,
form-regular-points,
form-elite-points; added
points-col-wounds,
points-col-cost; shortened
points-regular / points-elite to
"Regular" / "Elite"
|
src/lib/i18n/locales/de.json |
Same keys in German; points-col-wounds =
"Lebenspunkte des Kämpfers", points-col-cost =
"Punktwertzuschlag"
|
src/lib/components/TextForm.svelte |
New divine-blessing option + conditional points fields;
locale-sorted runemarks via $derived;
Activation reordered; points placeholders updated to
15 / 20
|
src/lib/components/TextCard.svelte |
Points table HTML + CSS; printer-friendly overrides;
divine-blessing slug in
presetSlugs; restructured with header row,
threshold qualifiers,
border: 1px solid pattern;
is-empty class + em dash fallback;
line-height: 25px absolute to lock row height
|
src/lib/components/FighterForm.svelte |
Locale-sorted weapon types and runemarks via
$derived
|
src/lib/components/FactionSelect.svelte |
Removed FighterCardData import; structural prop
type; sortAll() helper; cleaned
computeFiltered
|
src/lib/runemarks/index.ts |
Alignment fix on Pistol entry |
src/routes/text/+page.svelte |
Initialized regularPointsValue and
elitePointsValue in default card data;
pre-filled to '15' / '20'
|
CLAUDE.md |
Updated ability card label list to reflect all current presets |
docs/index.html |
March-20 locale sorting item marked Done |
i18n, Subfaction Data & Contributor Docs
Full internationalisation system wired up across every component —
locale files, reactive t(), language switcher, and
German translation. Also added custom card type label, i18n bug
fixes, 1 new subfaction (Thanatek's Tithe) with optimized SVG,
removed 4 Underworlds-only factions deemed redundant, and a
contributor documentation pass (i18n README, issue template,
CONTRIBUTING). Follow-up session: | line-break
support in all column headers and weapon name cells, FighterForm
weapon select updated to strip |, and a full
pre-release QA pass on docs and locale files.
✓ What Was Built
i18n System
-
i18n module —
src/lib/i18n/index.svelte.tswith module-level$statelocale, reactivet(key, vars?)with fallback toen.json,i18n.setLocale()+i18n.init()(localStorage persistence). -
Auto-discovery —
import.meta.glob('./locales/*.json', { eager: true })discovers locale files automatically; contributors only need to add a.jsonfile, no code changes. -
EN + DE locale files — 7 namespaces each:
meta,alliances,factions,subfactions,ui(~55 keys),card(~20 keys incl. column headers),runemarks(26 keys),weapons(17 keys). All values natural case; CSS handlestext-transform: uppercase. - Stable key pattern — stored values remain English slugs/keys (weapon names, cardLabel, runemark IDs) for icon/SVG lookup; translation happens at render time only.
-
i18n bug fixes — activation options
(Double/Triple/Quad) now use
t('card.activation-*'); card name placeholder wired tot('card.card-name-placeholder');ui.titleandui.githubremoved from locale files and made static.
Components & UI
-
LangSwitch — native
<select>styled as pill (matches ThemeToggle), globe icon, "Add a language…" option opens GitHub Issues in a new tab. Hidden when only one locale present. -
All components wired — every visible string in
FighterForm, TextForm, FighterCard, TextCard, ThemeToggle,
FactionSelect, fighter page, and text page now uses
t(). - Card values translated — column headers (BASE SIZE, POINTS VALUE, MOVE, TOUGHNESS, WOUNDS, RANGE, ATTACKS, STRENGTH, DAMAGE, damage bracket header), runemark pills, weapon names, fighter name / card name placeholders, card type label.
- Custom card type option — "Custom…" sentinel in Type select; reveals free-text input with 30-char limit and live counter.
- Landing page pill row — GitHub link, LangSwitch, and ThemeToggle grouped into one consistent pill row; flex-wrap for mobile.
Faction Data
-
Thanatek's Tithe subfaction — added under Death
/ Ossiarch Bonereapers in
hierarchy.tsand both locale files. SVG optimized from editor source: strippedclass,id, andstyleattributes; normalizedviewBoxto0 0origin with translate; addedwidth/height="300"and<title>; removed all pathids and redundantfill. Import wired up asdBbThanateksTithe. - Removed 4 Underworlds-only factions — Drepur's Wraithcreepers and Lady Harrow's Mournflight (Nighthaunt); Ironsoul's Condemnors and Storm of Celestus (Stormcast Sacrosanct Chamber). These Warhammer Underworlds warbands are redundant for a Warcry card creator.
Contributor Docs
-
Contributor docs —
src/lib/i18n/README.mdexpanded with namespace table, TODO convention, RTL note, and what not to translate;README.mdTranslations section added;CONTRIBUTING.mdlists translations as a contribution type;.github/ISSUE_TEMPLATE/new_language.mdcreated.
i18n Polish (2026-03-21)
-
|line-break in card headers — all ninecol-*column headers inFighterCard.sveltenow render viasplit('|')+<br>, allowing long translated labels (e.g."Lebens|punkte") to wrap within narrow cells.col-base-sizeandcol-pointsmigrated to the same consistent pattern. -
|line-break in weapon names — weapon name span inFighterCard.svelteuses the same split pattern (e.g."Reichweiten|waffe"). FighterForm weapon<select>refactored to an#eachloop and strips|via.replaceAll('|', '')so the split character never appears in the dropdown. -
i18n README corrections — new "Line breaks in
card text" section documents the
|feature with examples and clarifies that onlycard.*andweapons.*support it. False claim that "TODO suffix is stripped in display" corrected: thet()function returns raw values as-is. -
Pre-release QA pass —
de.jsontypos fixed (double-O in "Ordnung", "Geingeres" → "Geringeres"); duplicate translation oncities-of-sigmar-dispossessedresolved; all templates, README, CONTRIBUTING, and issue templates reviewed and confirmed accurate.
💡 Key Decisions
t() inline in templates rather than using
$derived?
$derived({ label: t('x') }) in a
.svelte.ts module does not reliably track the
module-level $state locale dependency across the
module boundary. Calling t()
directly in the template expression works because Svelte 5
tracks reactive reads at render time. All reactive label updates
now happen this way.
cardLabel as English
slugs rather than translated strings?
weaponRunemarks. Storing a German label ("Axt")
would break the icon lookup. cardLabel was changed
from the display string ("ABILITY") to a slug ("ability") so
resolveCardLabel() in TextCard can translate it
reactively on every render.
text-transform: uppercase in CSS rather than
uppercase strings in the locale files?
text-transform: uppercase to
.label-col, .header-text,
.dcol-wide, and the existing pill / card-label
classes.
__custom__ for the custom card type?
bind:value already carries state — one
sentinel keeps the reactive graph simple (one
$effect, no extra boolean) and avoids the select
ever showing a blank option.
| in locale values rather
than using CSS word-break or
overflow-wrap?
| convention gives translators deliberate control
over exactly where the break falls
("Lebens|punkte",
"Reichweiten|waffe"), and it costs nothing for
languages whose strings already fit — no |, no
break. The character is stripped at the one place it would be
visible to the user (the editor dropdown).
⏳ Still Pending
| Status | Item |
|---|---|
| Done |
German QA pass — typos fixed (double-O "Oordnung",
"Geingeres"); duplicate
cities-of-sigmar-dispossessed translation
resolved; official game-term review by contributor
|
| Done | Additional community languages — architecture supports drop-in JSON files; not tracked here (community-driven, not a session deliverable) |
| Done |
Contributor README (src/lib/i18n/README.md) —
namespace table, TODO convention, RTL note,
| line-break section; false "TODO stripped"
claim corrected
|
| Done |
Locale-aware sorting — factions/subfactions in
FactionSelect, weapon types and fighter
runemarks in both form components now sorted by translated
label using localeCompare with the active
locale code. Re-sorts reactively on locale change.
|
📁 Files Changed
| File | Change |
|---|---|
src/lib/i18n/types.ts |
New — LocaleMeta,
LocaleData interfaces (7 namespaces)
|
src/lib/i18n/index.svelte.ts |
New — reactive locale store, t(),
i18n object, availableLocales
|
src/lib/i18n/locales/en.json |
New — English source of truth, ~130 keys across 7
namespaces; updated: 5 new subfaction keys, removed
title + github from
ui
|
src/lib/i18n/locales/de.json |
New — German translation, all keys covered; updated: 5 new subfaction entries; QA pass: typos + duplicate translation fixed |
src/lib/i18n/README.md |
New then expanded — namespace table, TODO convention, RTL note, partial translation guidance; added "Line breaks in card text" section; corrected false "TODO stripped in display" claim |
src/lib/components/LangSwitch.svelte |
New — pill-style language select with globe icon and GitHub Issues link |
src/lib/components/FactionSelect.svelte |
Updated — display via t(), stores IDs |
src/lib/components/FighterCard.svelte |
Updated — all column headers, weapon names, placeholder,
text-transform added to missing classes; all
nine col-* headers and weapon name span now use
split('|') + <br> for
translator-controlled line breaks
|
src/lib/components/TextCard.svelte |
Updated — resolveCardLabel(), pill labels,
activation labels
|
src/lib/components/FighterForm.svelte |
Updated — all form labels, weapon options, runemark options
+ labels; weapon select refactored to
#each loop with
.replaceAll('|', '') to strip line-break
markers from dropdown
|
src/lib/components/TextForm.svelte |
Updated — all form labels, card type slugs, custom option,
runemark labels; activation options via
t('card.activation-*'), card name placeholder
wired
|
src/lib/components/ThemeToggle.svelte |
Updated — dark/light mode labels via t() |
src/lib/runemarks/hierarchy.ts |
Updated — Thanatek's Tithe entry wired to
dBbThanateksTithe; 4 Underworlds subfactions
removed (Drepur's Wraithcreepers, Lady Harrow's Mournflight,
Ironsoul's Condemnors, Storm of Celestus)
|
src/lib/runemarks/svg/factions-death-bladeborn-thanateks-tithe.svg
|
New — optimized runemark SVG (Death / Ossiarch Bonereapers) |
src/routes/+page.svelte |
Updated — all landing page text, LangSwitch added; title/GitHub made static; pill row (LangSwitch + ThemeToggle + GitHub), mobile flex-wrap |
src/routes/+layout.svelte |
Updated — i18n.init() in onMount
|
src/routes/fighter/+page.svelte |
Updated — export buttons, tab labels, page header |
src/routes/text/+page.svelte |
Updated — export buttons, tab labels, page header, initial
cardLabel: 'ability'
|
README.md |
Updated — Translations section added |
CONTRIBUTING.md |
Updated — translations listed as contribution type |
.github/ISSUE_TEMPLATE/new_language.md |
New — issue template for language contributions |
Text Card Form & Export Polish
Bold/italic markup buttons for ability body text; printer-friendly torn-edge overlay on text card; form label cleanup; damage bracket alignment; Battle Trait added as ability card type.
✓ What Was Built
Markup Toolbar (Bold / Italic)
-
Bold / italic markup buttons on the ability
form body text field — B and I buttons wrap the current
selection in
**…**/*…*markers;parseMarkup()inTextCard.svelterenders them as<strong>/<em>via{@html}. -
Torn-edge SVG overlay on printer-friendly ability
card
— the torn-paper path is drawn as an absolute overlay inside
.image-section, matching the fighter card aesthetic. Only shown whenprinterFriendly=true.
Form Polish
- Form label cleanup — "Card Type" → "Type (select)", "Card Name" → "Card", "Body Text" → "Text"; default card name changed from "ABILITY NAME" to "CARD NAME" in both the form state and the card placeholder.
-
Damage bracket alignment — Damage Points
Allocated input is now
text-center; Move/Damage column headers lost unnecessarytext-centerclass (inputs already centered). -
Battle Trait card type — added as a new option
in the ability card type select (after Heroic Trait).
cardLabelcomment intypes.tsupdated to document the new value.
Favicon & PWA Icons
-
Full favicon set added to
static/: SVG (modern browsers), ICO (fallback), 96px PNG, apple-touch-icon (180px), 192px + 512px manifest icons,site.webmanifest. -
Link tag order in
app.html:.ico sizes="any"→.svg type="image/svg+xml"→ apple-touch-icon → manifest. Modern browsers pick SVG; ICO covers RSS readers and old browsers. -
Paths use
%sveltekit.assets%— all four link hrefs prefixed with%sveltekit.assets%so they resolve correctly underpaths.base = '/warcry-card-creator-2026'on GitHub Pages. Without this, SvelteKit's prerender throws a 404 error on/favicon.icoduring CI build. -
theme_color / background_color in manifest set
to
#5a0a14(dark maroon) to match card aesthetic. - PWA install prompt enabled on Android/Chrome via manifest; iOS home screen icon covered by apple-touch-icon.
Pill / Badge Styling
-
Size increased from
13px / 5px 10pxto15px / 7px 13pxon both Fighter and Ability cards. -
Font changed from
'Germania One'to'Alegreya'— Alegreya is more legible at small body-text sizes. -
Pills alignment fixed on Fighter card — the
pills-breakdiv (awidth:100%flex spacer that forced hierarchy pills and fighter runemark pills onto separate rows) was removed. All pills now flow in one continuous row. - Worst-case test combination identified and saved: Grand Alliance = Sentinels of Order, Faction = Stormcast Eternals: Vanguard Auxiliary Chamber, Bladeborn = The Farstriders, Fighter runemarks = Icon Bearer + Terrifying + Berserker, Activation = QUAD.
Label Rename
-
"Ability Card" → "Ability / Text Card" —
updated in both the landing page button
(
src/routes/+page.svelte) and the editor header (src/routes/text/+page.svelte) to better reflect that the card type is used for plain text cards beyond just abilities.
Community & GitHub Files
-
Repo community files added:
CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,LICENSE(MIT);README.mdexpanded. -
GitHub templates reworked:
.github/ISSUE_TEMPLATE/bug_report.md,feature_request.md, and newPULL_REQUEST_TEMPLATE.md.
💡 Key Decisions
{@html} for body text instead of a Svelte
markdown component?
parseMarkup() regex is narrow and
outputs only <strong>/<em>
— no script injection surface.
requestAnimationFrame after wrapping the
selection?
setSelectionRange fires. Deferring to the next
frame ensures the cursor/selection is restored after the DOM
settles.
bottom on .card-type-area from
80px to 60px, moving the label closer
to the torn edge. The theoretical midpoint between the label and
the ability name would be bottom: 29px (matching
parchment padding-top), but that was visually too close —
60px felt better.
📁 Files Changed
| File | Change |
|---|---|
src/lib/components/TextForm.svelte |
Markup toolbar (B/I buttons + wrapSelection());
label text cleanup; bind:this on body textarea;
Battle Trait option added to card type select
|
src/lib/types.ts |
cardLabel JSDoc comment updated to include
"BATTLE TRAIT"
|
src/lib/components/TextCard.svelte |
parseMarkup(); {@html} on body text;
torn-edge SVG overlay for printer-friendly; "CARD NAME"
placeholder; strong/em export CSS
fix; pill font/size tweaks; card-type-area bottom adjusted to
60px
|
src/lib/components/FighterForm.svelte |
Damage bracket input alignment — Damage Points Allocated input
now text-center
|
src/lib/components/FighterCard.svelte |
Pill font/size tweaks; removed pills-break div +
CSS — all pills now flow in one row
|
src/routes/text/+page.svelte |
Default card name changed to "CARD NAME" |
src/app.html |
Added favicon, apple-touch-icon, and manifest link tags; fixed
href paths to use %sveltekit.assets% prefix
(required by paths.base on GitHub Pages)
|
static/favicon.svg, favicon.ico,
favicon-96x96.png,
apple-touch-icon.png,
web-app-manifest-192x192.png,
web-app-manifest-512x512.png,
site.webmanifest
|
Full favicon/PWA asset set added; theme_color set to
#5a0a14
|
src/routes/+page.svelte,
src/routes/text/+page.svelte
|
"Ability Card" label renamed to "Ability / Text Card" |
CODE_OF_CONDUCT.md, CONTRIBUTING.md,
SECURITY.md, LICENSE,
README.md
|
Community files added / expanded |
.github/ISSUE_TEMPLATE/bug_report.md,
feature_request.md,
PULL_REQUEST_TEMPLATE.md
|
Issue & PR templates reworked |
Grand Alliance, Light/Dark Theme, Mobile Polish & Export Fix
Four areas in one session: faction selector gains Grand
Alliance-only selection; light/dark theme backed by CSS custom
properties; mobile image positioning redesigned as a touch overlay
directly on the card; mobile export fixed via
modern-screenshot with Web Share API download on iOS.
✓ What Was Built
Grand Alliance Selection
-
Alliance headers in
FactionSelectconverted to clickable buttons — clicking setsdata.grandAllianceand clearsdata.faction/data.bladeborn. Card already renders only the GA runemark when faction is empty. -
Selected state highlighted in red;
selectionLabelshows the alliance name alone.
Light/Dark Theme
-
data-themeattribute on<html>controls the active theme. Defaults to systemprefers-color-scheme; manual toggle saves tolocalStorageand overrides permanently. -
FOUC prevention: inline
<script>inapp.htmlreadslocalStorage(or system pref) and setsdata-themebefore first paint. -
:rootdefines dark-mode vars;[data-theme="light"]overrides with warm parchment palette. Tailwind class overrides inapp.cssremap zinc/white classes — no component class changes needed. Form components usevar(--ui-*)directly. -
Light palette: shell
#e4ddd3, inputs#ede8e2, text#2e2620. -
ThemeToggle: pill-shaped button (sun/moon + label), landing page only, below card buttons. -
Red button contrast fix:
[data-theme="light"] .bg-red-900.text-whitekeeps button text white on maroon. -
Contrast audit:
--ui-text-subtledarkened to#5a5048(~5.3:1 on page bg — WCAG AA).
Mobile Image Positioning
- Touch overlay on card image area in the Preview tab; "Adjust Image" toggle button appears when a model image is uploaded.
-
Single-finger drag → X/Y offset; two-finger pinch → zoom.
touch-action: noneon the overlay suppresses browser scroll/zoom without needingpreventDefault(). -
Position X/Y/Zoom sliders in
FighterFormwrapped inhidden lg:block— desktop only. Mobile shows a hint: "Adjust image position and zoom in the Preview tab." - Below-card slider strip removed entirely.
-
Range input thumb: custom 20px circle (
#991b1b) via-webkit-appearance: none+-moz-; track usesvar(--ui-surface-2)for theme compat.
Mobile Form Polish
-
iOS layout shift fix:
placeholder="—"added to characteristics inputs (Points, Move, Toughness, Wounds). Missing placeholder caused iOS to switch keyboard type on empty field, triggering a viewport shift. Matches existing weapon input pattern. -
Empty characteristics on card:
class:stat-val-emptyshows—at 16px — matches weapon valueclass:is-emptyfrom Mar 15. -
Select centering on iOS:
style="text-align-last: center"on Base Size + Weapon Type selects. iOS Safari ignorestext-align: centeron<select>;text-align-lastis the correct property. -
.field-inputfont-size set to1remunconditionally;@media (max-width: 1023px)override removed.
Mobile Export Fix
-
Library:
modern-screenshot(domToPng) replaces dom-to-image-more on real mobile devices. Uses Canvas 2D API internally — bypasses the iOS SafariforeignObjectrestriction that blocked background textures and model images. Desktop unchanged. -
Explicit dimensions: the card sits inside a CSS
transform: scale(cardScale)wrapper.getBoundingClientRect()includes the transform, so without explicit dimensions the captured size was wrong. Fix:{ width: 574, height: 915, scale: 2 }passed todomToPng. -
iOS download — Web Share API: on HTTPS,
navigator.sharesaves immediately to Photos — no overlay. On localhost (HTTP, no secure context),navigator.shareisundefinedand the overlay fallback fires instead. Confirmed on real device: overlay is a localhost artefact only. -
Inline overlay fallback: fullscreen dark
overlay + long-press-to-save instruction. Localhost-only. Uses
inline
style="color: #fff"— light theme CSS overridestext-whiteto near-black. -
isRealMobile: plainconstvianavigator.userAgentregex (/iPhone|iPad|iPod|Android/i). Separates real devices from desktop mobile-viewport emulation — desktop in mobile viewport keeps dom-to-image-more. - Split export button (PNG + printer-friendly dropdown) added to mobile Preview tab on both editor pages. Landing page "unavailable" notice removed.
Misc
-
LAN testing:
server: { host: true }invite.config.ts— dev server on all network interfaces. README updated. -
CLAUDE.md: import sorting, key quoting, on-touch cleanup policy documented.
💡 Key Decisions
Grand Alliance & Theme
div.alliance-header to a button with
onclick={() => select(alliance.label, '')}. Zero
extra UI; card already supports GA-only rendering when
data.faction is empty.
[data-theme] attribute overrides. All
existing Tailwind classes are dark values; switching to
Tailwind's dark-mode system would require
dark: prefixes everywhere. Instead,
app.css remaps zinc/white classes under
[data-theme="light"] — no component class changes
needed.
prefers-color-scheme; after a manual toggle the
choice is saved to localStorage and sticks. Page
does not listen for live OS theme changes.
Mobile Image Positioning
preventDefault()?
touch-action: none on the overlay. Svelte's passive
listeners cannot call preventDefault();
touch-action: none tells the browser to pass all
touch events to JS and skip its own scroll/zoom handling — same
result, no non-passive listeners needed.
sensX = 50/574, sensY = 50/503. Screen
pixels divided by cardScale first (card-space),
then × sensitivity. Direction inverted — drag right moves image
right, revealing the left side.
-webkit-appearance: none + 20px red thumb is
consistent across platforms. Track colour via
var(--ui-surface-2) for theme compat.
Mobile Export
Context: iOS Safari refuses to render
<img> and CSS
background-image inside SVG
foreignObject — the technique dom-to-image-more
uses. html2canvas was attempted (Canvas 2D, bypasses
foreignObject) but also failed: background-image
never appeared, background-clip: text unsupported,
model image overflowed without the mask, prerenderer followed
inline <img src="/background.jpg"> as a
route. All html2canvas code reverted. Fixed instead via
modern-screenshot.
modern-screenshot over manual canvas redraw?
{ width: 574, height: 915 } to override
the transform-affected bounding rect. Manual canvas redraw was
the backup and was not needed.
modern-screenshot is
mobile-only to minimise risk.
navigator.share requires a secure context (HTTPS).
Localhost is HTTP, so navigator.share is
undefined there and the overlay fires. On a live
HTTPS URL the Web Share API is available and saves immediately.
The overlay is a local dev artefact worth keeping.
printerFriendly before overlay
dismissal cause a bug?
exportedImageUrl while
printerFriendly is still true. The
reset only affects the live card render beneath the overlay,
which is irrelevant.
Mobile Form Polish
placeholder="—";
characteristics did not. The placeholder prevents iOS from
seeing the field as completely empty after deletion, which
prevents the keyboard-type switch that triggered the shift.
inputmode="numeric"?
*
for monsters, dashes). Locking to numeric keyboard is incorrect
UX. The placeholder approach is semantic and matches the
existing weapon input pattern.
📁 Files Changed
| File | Change |
|---|---|
src/lib/components/FactionSelect.svelte |
Alliance headers → buttons; Grand Alliance-only selection; CSS vars in style block |
src/lib/theme.svelte.ts |
NEW — reactive Theme class with
init() / toggle()
|
src/lib/components/ThemeToggle.svelte |
NEW — pill button with sun/moon icon + label |
src/app.html |
Inline script: set data-theme before first paint
(localStorage → falls back to
prefers-color-scheme)
|
src/app.css |
CSS vars for both themes; Tailwind class overrides for light mode; red button contrast fix; range input thumb; contrast audit |
src/routes/+layout.svelte |
theme.init() on mount to sync reactive state with
DOM attribute
|
src/routes/+page.svelte |
ThemeToggle added below card buttons;
"unavailable" mobile note removed
|
src/lib/components/FighterForm.svelte |
Hardcoded hex → CSS vars; sliders wrapped in
hidden lg:block; mobile hint text;
placeholder="—" on characteristics;
text-align-last on selects; font-size
1rem
|
src/lib/components/TextForm.svelte |
Hardcoded hex → CSS vars; font-size 1rem |
src/routes/fighter/+page.svelte |
Touch overlay (drag/pinch); "Adjust Image" toggle; below-card
sliders removed; TouchState + handlers;
modern-screenshot mobile export; Web Share API
download; isRealMobile; split export button on
mobile
|
src/routes/text/+page.svelte |
Same mobile export changes as fighter page; html2canvas code removed |
vite.config.ts |
server: { host: true } — LAN device testing
|
README.md |
Added "Testing on a phone or other device" section |
src/lib/runemarks/index.ts |
Unnecessary quotes removed from single-word keys;
'Icon Bearer' kept quoted
|
src/lib/runemarks/hierarchy.ts |
Import groups sorted A→Z; orphan imports folded in; three
(additional) headers eliminated; duplicate
section merged; CoS sort corrected
|
CLAUDE.md |
Code Style section added: import sorting, key quoting, on-touch cleanup policy |
Mobile Support
Full editor access on phones — tab-based layout, responsive form grids, iOS input zoom fix, touch-friendly dropdown dismissal, and export button relocated to the Preview tab on mobile.
✓ What Was Built
Pre-work
-
Removed
isRestrictedblock from both editor pages — editors now fully accessible on any screen size -
Removed mobile warning message and all viewport detection state
from the index page (
/)
Tab Bar & Layout
-
Tab bar on mobile (Edit / Preview): solid
bg-red-800fill on active tab, darkbg-zinc-950on inactive — hidden on desktop (sm:hidden) -
Layout:
flex-col h-dvhon mobile stacks tab bar → form/preview panel;sm:flex-rowrestores side-by-side on desktop -
h-dvh(dynamic viewport height) instead ofh-screen— accounts for iOS Safari browser chrome correctly -
overflow-hiddenon wrapper +min-h-0on<aside>— fixes flex scroll trap so the inneroverflow-y-autoform div scrolls correctly within the bounded height
Card Scaling
-
Explicit-dimension card wrapper:
width: 574×cardScale px; height: 915×cardScale px; position: relative— card absolutely positioned inside withtransform-origin: top left; flex sees the correct visual size instead of the raw 915px layout box -
cardScaleuses viewport width on mobile ((viewportWidth - 32) / 574), viewport height on desktop -
Export button
hidden from Edit tab header on mobile (
hidden sm:block); shown above the card in the Preview panel on mobile only (sm:hidden)
Form Polish
-
FighterForm grids: Characteristics and each
Weapon row changed from
grid-cols-5togrid-cols-3 sm:grid-cols-5— 3-column wrap on mobile -
iOS input zoom fix:
@media (max-width: 639px) { .field-input { font-size: 1rem; } }added to bothFighterForm.svelteandTextForm.svelte— prevents iOS Safari from auto-zooming on focus -
Dropdown click-outside:
$effectattaches a deferred document click listener whenshowDropdownis true; any tap outside closes it — fixes dropdown getting stuck open on touch devices -
Tablet portrait fix: raised the mobile/desktop
layout breakpoint from
sm:(640px) tolg:(1024px) — tablets in portrait now use the tabbed layout instead of a cramped 480px form + ~288px preview side-by-side.isMobilethreshold updated to match. iOS input zoom media query updated tomax-width: 1023px.
💡 Key Decisions
transform doesn't affect layout — the element's
layout box stays at 915px regardless of scale. Fighting this
with transform-origin still leaves the card in an
unpredictable position relative to flex siblings. Wrapping in a
div with explicit computed dimensions (574×scale ×
915×scale) is clean: flex and siblings see the
correct visual size.
$effect that watches showDropdown:
when true, adds a
document.addEventListener('click', close) via
setTimeout(..., 0) to skip the opening click.
Cleans up on effect teardown. No dependencies, no library.
lg:). At 768px (iPad portrait) the form
panel is 480px fixed, leaving only ~288px for the card preview —
too cramped to be useful. Raising the breakpoint to 1024px means
tablets in portrait get the full-screen tabbed layout, and the
side-by-side layout starts at iPad landscape / desktop where
there's enough room.
📁 Files Changed
| File | Change |
|---|---|
src/routes/+page.svelte |
Removed mobile warning, viewport state, resize effect,
isPhone/isPortraitTablet derived
|
src/routes/fighter/+page.svelte |
Removed isRestricted; added tab bar, mobile
layout, explicit card wrapper, mobile export button,
click-outside effect; layout breakpoint raised to
lg: (1024px)
|
src/routes/text/+page.svelte |
Same changes as fighter page; layout breakpoint raised to
lg: (1024px)
|
src/lib/components/FighterForm.svelte |
Characteristics + weapon grids:
grid-cols-3 sm:grid-cols-5; iOS font-size fix
raised to max-width: 1023px
|
src/lib/components/TextForm.svelte |
iOS font-size fix raised to max-width: 1023px
|
Hierarchy Restructure & Filterable Faction Select
Audited and restructured the faction hierarchy with proper parent-prefixed naming, replaced three cascading dropdowns with a single filterable list component used on both Fighter and Ability card editors.
✓ What Was Built
Runemark Library Audit
- Full audit against a reference card creator — cross-checked faction list and all subfactions
- Added Everchosen as a top-level Chaos faction (SVG + hierarchy entry)
- Added Legions of Nagash as a top-level Death faction (SVG + hierarchy entry)
- Added 10 Cities of Sigmar subfactions: Anvilgard, Castelite Hosts, Darkling Covens, Dispossessed, Greywater Fastness, Hallowheart, Hammerhal, Tempest's Eye, The Living City, The Phoenicium (SVGs + hierarchy entries)
- Added Grombrindal as a top-level Order faction (SVG sourced from reference project)
- Added Lesser Artefact and Greater Artefact to ability card type select
Hierarchy Restructure
- Standalone warbands namespaced under parent faction (e.g. Iron Golem → Slaves to Darkness: Iron Golem); Cities of Sigmar subfactions promoted to factions; several Death/Destruction factions similarly reparented
-
Ulfenkarn removed from the hierarchy entirely;
dUlfenkarnimport removed fromhierarchy.ts -
TXT export workflow: hierarchy exported to editable plain text,
user audited, changes applied back to
hierarchy.ts— SVG imports reused, only labels and nesting changed
Filterable Faction Select
-
Built
FactionSelect.svelte— replaces the three cascading Alliance/Faction/Subfaction<select>elements with a single filterable component: text filter input + scrollable grouped list (alliance headers sticky, factions indented, subfactions further indented); current selection shown as a dismissible badge; A→Z order preserved -
Applied to both editors —
FighterForm.svelteandTextForm.svelte; cascading selects removed from both
Card Polish
-
Activation badge font-size increased from 14px
→ 24px in
TextCard.svelte -
Pipe line break (
|) supported in fighter name and text card name — card renders<br>at each|; form label hints updated
💡 Key Decisions
bladeborn-grombrindal.svg; saved as
factions-order-bladeborn-grombrindal.svg to match
project naming.
hierarchy.ts. All SVG imports
reused — only labels and nesting changed. Clean, repeatable
workflow for future audits.
📁 Files Changed
| File | Change |
|---|---|
src/lib/components/TextCard.svelte |
Activation badge font-size 14px → 24px; pipe line break in ability name |
src/lib/components/TextForm.svelte |
Lesser Artefact + Greater Artefact options; pipe hint;
cascading selects → FactionSelect
|
src/lib/components/FighterCard.svelte |
Pipe line break in fighter name |
src/lib/components/FighterForm.svelte |
Pipe hint in fighter name label; cascading selects →
FactionSelect
|
src/lib/components/FactionSelect.svelte |
NEW — filterable faction/subfaction select component |
src/lib/runemarks/hierarchy.ts |
Full hierarchy restructure: parent-prefixed factions; Ulfenkarn + import removed |
src/lib/runemarks/svg/factions-chaos-everchosen.svg
|
NEW |
src/lib/runemarks/svg/factions-death-legions-of-nagash.svg
|
NEW |
src/lib/runemarks/svg/factions-order-bladeborn-grombrindal.svg
|
NEW |
src/lib/runemarks/svg/factions-order-cities-of-sigmar-*.svg
(×10)
|
NEW — 10 Cities of Sigmar subfaction SVGs |
runemark-hierarchy.txt |
NEW — human-editable TXT export for hierarchy audits |
Fighter Card Polish & Text Card
Completed final polish on the fighter card (custom runemark shape, image caption, table radii, empty value dashes) and built the text card editor from scratch.
✓ What Was Built
Fighter Card Polish
- Renamed faction "Chaotic Beasts" → "Monsters of Chaos (Chaotic Beasts)"; fixed alphabetical sort in hierarchy
-
Empty weapon stat values render
—at smaller font; weapon stat field placeholders changed to— -
Image caption field: Alegreya font, absolutely
positioned at
bottom: 20px, 50% opacity in preview / full in export -
Circular runemark badges replaced with custom organic shape
(
runemark-shape.svg) — CSS mask + wrapper border technique (78px border / 76px badge) -
Fixed hardcoded
fill="#FFF"in 20 runemark SVGs so printer-friendly fills work -
Table border-radii: outer boxes
7.5px, inner rows6.5px(1mm equivalent) -
Subtitle — optional secondary line below
fighter name; Germania One;
maxlength="120"+ live counter; rendered only when non-empty - Header text changed from abbreviations to full words: MOVE / TOUGHNESS / WOUNDS / RANGE / ATTACKS / STRENGTH / DAMAGE
-
Weapon name font 16px → 18px;
use:fittextapplied so long names shrink to fit -
Monster damage auto-dash: when Range/Attacks/Strength all empty,
Damage shows
—withclass:is-emptystyling - Damage table font 15px → 17px; form labels: "Damage Table", "Damage Points Allocated"
-
Code cleanup: removed unused
has-chevrons/chevron-*classes; removed deadWeapon.iconfield -
Dead assets removed: duplicate SVGs in
static/, unreferencedfavicon.svg -
Base size changed to
<select>— 14 round (⌀ 20 → ⌀ 160) + 7 oval options, round-first ascending -
Page
<title>tags added to/fighterand/textroutes
Font Swap
- Replaced RodchenkoCTT with Germania One (weight 400 only, self-hosted, SIL OFL 1.1)
- Replaced OldrichiumITCStdLight with Alegreya (variable, wght 100–900, regular + italic, SIL OFL 1.1)
-
Font family names updated throughout;
letter-spacingremoved from all Germania One usages - Monster damage table uses Alegreya (body font, not Germania One)
- Stale font files removed: RodchenkoCTT, OldrichiumITCStdLight, RobotoSlab
Text Card
- Built TextCard.svelte: 574×915px, torn-edge header with dark maroon background, horizontal runemarks row (GA + faction + subfaction + 2 fighter runemarks + activation badge), card type label, parchment with title / flavor text / body text
- Built TextForm.svelte: card type, name, runemarks selects, activation select, flavor + body textareas
- Built /text/+page.svelte: split-panel layout, Export PNG + printer-friendly export
- Printer-friendly: white background, no torn-edge mask, black text, white pills, black runemark fills
💡 Key Decisions
.runemark-border, 78px,
#FAF6F3 bg) and inner div
(.runemark-badge, 76px, #5a0a14 bg)
both masked to the same organic shape. The 1px size difference
on each side creates the visible border.
src/lib/?
src/lib/image-mask.svg and
src/lib/runemark-shape.svg imported via Vite
?raw to build data URLs for inline mask styles —
required for dom-to-image-more export fidelity. Duplicate
static/ copies removed.
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Added imageCaption, subtitle to
FighterCardData; replaced runemarks in
TextCardData with individual fields; removed
Weapon.icon
|
src/lib/runemarks/hierarchy.ts |
Renamed "Chaotic Beasts" → "Monsters of Chaos (Chaotic Beasts)"; fixed sort |
src/lib/runemarks/svg/*.svg (×20) |
Removed hardcoded fill="#FFF" from paths |
src/lib/runemark-shape.svg |
NEW — organic circle shape for ?raw import / data
URL export
|
src/lib/components/FighterCard.svelte |
Organic badge shape; image caption; empty value dashes; table radii; subtitle; full-word headers; weapon font 18px + fittext; monster damage dash; damage table 17px; fonts → Germania One / Alegreya |
src/lib/components/FighterForm.svelte |
Image caption field; placeholder dashes; base size → select; subtitle input + counter; de-abbreviated sub-labels; monster damage dash; label renames; fonts updated |
src/routes/fighter/+page.svelte |
Added defaults for imageCaption,
subtitle; base size default ⌀ 32;
<title> tag
|
src/lib/components/TextCard.svelte |
NEW — text card visual component; fonts → Germania One / Alegreya |
src/lib/components/TextForm.svelte |
NEW — text card form component |
src/routes/text/+page.svelte |
Full text card editor; <title> tag |
src/app.css |
Font-face declarations: Germania One + Alegreya (regular + italic) with license attributions |
static/fonts/GermaniaOne-Regular.ttf |
NEW — SIL OFL 1.1 |
static/fonts/GermaniaOne-OFL.txt |
NEW |
static/fonts/Alegreya-Regular.ttf |
NEW — SIL OFL 1.1 |
static/fonts/Alegreya-Italic.ttf |
NEW |
static/fonts/Alegreya-OFL.txt |
NEW |
static/fonts/RodchenkoCTT.ttf,
OldrichiumITCStdLight.otf,
RobotoSlab.*
|
Deleted — replaced by Germania One / Alegreya |
static/image-mask-1-cropped.svg,
static/runemark-shape.svg,
src/lib/assets/favicon.svg
|
Deleted — duplicates / unreferenced |
Fighter Runemarks, Git, UI Polish & Runemark Library
Four conversations across the day: added fighter runemarks with pills fallback and export fidelity fixes; initialized git and deployed to GitHub Pages; polished the UI with mobile restrictions and landing page copy; completed the runemark SVG library with all factions wired and file naming normalised.
✓ What Was Built
Fighter Runemarks & Pills
-
Fighter runemarks (right column) — 26 SVG files
imported (
fighters-agile.svg→fighters-warrior.svg, A–Z);fighterRunemarksexport added toindex.ts; 3 select dropdowns in form; card shows up to 3 circles in the right column, sorted Hero/Monster first. -
Deduplication via disabled options —
already-selected runemark stays visible in the other dropdowns
but is
disabled; Hero and Monster are mutually exclusive (selecting one disables the other across all three selects). -
Hero/Monster always on top —
$effectsorts selected runemarks so Hero or Monster always appears first in the right column. -
4 new monster factions — "Chaotic Beasts" in
Agents of Chaos, "Monsters of Death" in Bringers of Death,
"Monsters of Destruction" in Harbingers of Destruction,
"Monsters of Order" in Sentinels of Order; 4 new SVGs added to
src/lib/runemarks/svg/. - Pills mode — when "Show Runemarks" is unchecked, alliance/faction/bladeborn and fighter runemarks are shown as styled pills at the bottom of the image area instead of circles; fighter runemarks start on a new line; centered with 24px bottom spacing.
-
Image mask restructured — mask moved from
.image-sectionto a new.image-innerwrapper so absolutely-positioned pills are not clipped by the mask. -
Header text fallback labels — when "Show
Runemarks" is off, M / T / W / RNG / A / S / DMG text labels are
wrapped in
<span class="header-text">with explicitcolor: #FAF6F3to be visible against the dark header (previously invisible because the parent hadcolor: transparentforbackground-clip: text). -
Color pass — all
#1a0408occurrences replaced with#000; allfill: whitereplaced withfill: #FAF6F3(warm off-white matching parchment). -
Code cleanup —
$props()declaration moved before$effect()inFighterForm.svelte(was logically unsafe ordering).
Export fidelity fixes
-
White dot top-left —
.runemarkscontainer always in DOM even when empty; addedborder: 0; outline: none; background: transparentto its CSS rule. -
White frame around card —
.card * {}wildcard doesn't reset.carditself; addedborder: 0; outline: nonedirectly to.card. Added same reset to#cardElwrapper in+page.svelte. -
White frame around model image —
.image-sectionand.image-innerand.model-imgall needed per-element explicit resets. -
Damage table horizontal white lines —
border-top: 1px solid blackon.damage-rowis silently dropped by dom-to-image-more; replaced withbox-shadow: inset 0 1px 0 0 #000. -
Header runemark icons smaller in export —
.header-runemarkspan lacked reset; SVG hadwidth: autowhich behaves differently in SVG foreignObject; changed to explicitwidth: 28px; height: 28px. -
Weapon runemark SVG fill — added
fill: #000to.weapon-runemark :global(svg)for export consistency.
Git & GitHub Pages
-
Git repo initialized —
git initrun in project root;.claude/added to.gitignore(contains local Claude Code permission settings, not for version control). -
adapter-static — swapped
@sveltejs/adapter-autofor@sveltejs/adapter-static;svelte.config.jssetspaths.base = '/warcry-card-creator-2026'whenNODE_ENV === 'production'. -
Prerendering —
src/routes/+layout.tsadded withexport const prerender = true; all pages are fully static. -
Base-path links — all internal
hrefs updated to usebasefrom$app/paths(/fighter→{base}/fighteretc.) in+page.svelte,fighter/+page.svelte, andability/+page.svelte. -
static/.nojekyll— prevents GitHub Pages from ignoring the_app/directory. -
GitHub Actions deploy workflow —
.github/workflows/deploy.yml; triggers on push tomain; builds with Node 22 +npm ci; uploadsbuild/viaactions/upload-pages-artifactand deploys viaactions/deploy-pages. - Pages source set to "GitHub Actions" — not a branch; the official actions handle deployment directly.
- Site live at hennirocks.github.io/warcry-card-creator-2026/
UI Polish & Mobile
-
Landing page copy — title updated to "Warcry
Card Creator 2026"; descriptive text added: project summary +
fan disclaimer, combined into one paragraph.
<svelte:head>title tag added. - README rewritten — replaced SvelteKit boilerplate with a proper project description mirroring the landing page copy, plus concise dev instructions.
- Mobile restriction — phone screens (<640px) and portrait tablet screens (<1024px wide, portrait) see a "requires tablet in landscape or larger screen" notice with a back link instead of the editor. Reactive to window resize and orientation change.
- Index page note — same restriction note shown conditionally on the landing page for restricted screen sizes (JS-driven, same logic as the editor pages).
- Ability page restriction — same mobile restriction applied to the text card placeholder page.
- Form readability — sublabel color bumped from zinc-500 to zinc-400; upload hint text from zinc-400/zinc-600 to zinc-300/zinc-400.
-
Export PNG button — slightly larger padding,
text-smfont size, lightened base color one step for more visual prominence.
Runemark Library
-
SVG audit — compared the project's
svg/folder against a reference repo; identified 15 missing bladeborn warbands, 17 missing/new faction icons, andweapons-pistol.svg. -
New SVGs added — 20 new files added from the
white/set (white versions for use in the maroon runemark circles);weapons-pistol.svgfrom theblack/set (weapon icons are black). -
All new factions & subfactions wired up —
every
svg: nullentry inhierarchy.tsfilled in; Gobbapalooza shares Gloomspite Gitz icon; Brand's Oathbound shares Darkoath icon; Hexbane's Hunters moved to Order of Azyr. -
Hexbane's Hunters recovered — SVG existed in
git history but had been inadvertently deleted; restored via
git show HEAD:…. -
Pistol weapon type — added import to
index.tsand option toFighterForm.svelteweapon select (between Mace and Ranged Weapon). -
File naming normalised — 13
bladeborn-*.svgfiles renamed to the project'sfactions-{alliance}-bladeborn-*.svgconvention;factions-death-teratic_cohort.svgunderscore → hyphen; all imports inhierarchy.tsupdated.
Export dropdown & printer-friendly mode
- Split dropdown button — "Export PNG" button replaced with a split button: left part exports normally, right chevron (▾) reveals a dropdown with "Export printer-friendly PNG". Dropdown dismisses on mouseleave.
-
Printer-friendly export — sets
printerFriendly = trueon the card, waits a frame, captures PNG with_printsuffix, then resets. No separate component needed. - Printer-friendly visual overrides — white card background, gray image area, dark table headers become transparent, all text/SVG fills black, runemark badges white with black border, zebra-stripe tints removed.
-
Export frame fix:
.label-col span— in printer-friendly mode the dark header background is removed, exposing UA default borders on the inline<span>elements inside.label-col(covered only by the.card *wildcard which dom-to-image-more ignores). Fix: addedborder: 0; outline: none; background: transparentdirectly to a.label-col spanCSS rule. -
All printer-friendly dividers use
box-shadow— consistent with the existing pattern;border-*properties are dropped by dom-to-image-more.
Damage brackets table improvements
- Header label added — left column header now reads "* DAMAGE POINTS ALLOCATED".
-
Fixed column widths — MOVE and DAMAGE stat
columns use
flex: 0 0 100px; wide column usesflex: 1; consistent across header and all data rows. -
Alternating row colors — start on the first
data row (
i % 2 === 0), matching the weapons table style; suppressed in printer-friendly export. -
Monster damage
*→*/*— both card preview and disabled form field updated to reflect the damage fraction format.
💡 Key Decisions
disabled. This keeps
the full list scannable while preventing duplicates — the user
can see what exists and what's taken. Hiding selected options
was the alternative but made the list feel inconsistent.
$effect sorts them to index 0 regardless of
which select slot they were chosen in.
.image-inner, pills on
.image-section
.image-section, which
clipped absolutely-positioned pills inside it. Solution: wrap
the image/placeholder in .image-inner, apply mask
there, and keep .image-section unmasked as the
positioning context. Pills render outside the torn-edge mask
without any layout changes to the card.
#FAF6F3 is a warm off-white sampled from the
parchment texture highlights — visually softer while remaining
clearly legible on the dark maroon headers.
Export fidelity — additional learnings
border-top on child rows is not rendered by
dom-to-image-more
border-top: 1px solid black (inside a flex
container) is silently dropped during dom-to-image-more
serialization. Container-level borders work fine. Fix: replace
per-row border-top with
box-shadow: inset 0 1px 0 0 #000 on rows that need
a top divider — box shadows survive the foreignObject context.
width: auto is unreliable in export
width: auto can render at an
unexpected size inside dom-to-image-more's SVG foreignObject
context — icons appeared smaller than in the browser preview.
Fix: set explicit width: Npx; height: Npx on SVG
elements (and resets on their wrapper spans).
peaceiris/actions-gh-pages approach (push to
gh-pages branch) failed silently — the branch was
never created despite a green action run. Switched to the
official actions/upload-pages-artifact +
actions/deploy-pages pair with Pages source set to
"GitHub Actions". This is GitHub's recommended modern approach
and worked first time.
paths.base only in production
base unconditionally would break the local
dev server (links would point to
/warcry-card-creator-2026/fighter which the dev
server doesn't serve). Guarded by
process.env.NODE_ENV === 'production' so dev works
at localhost:5173 and the build works on GitHub
Pages.
git commit or git push.
white/ and black/ versions; new
faction icons sourced from white/, pistol from
black/.
<table> is unsuitable for
dom-to-image-more export
<table> for the
damage brackets to get automatic column-width synchronisation.
Two persistent export artifacts emerged that couldn't be
resolved: a vertical line between columns 1 and 2 in tbody rows
(likely from dom-to-image-more reading computed collapsed-border
values and writing them as inline styles), and a
border-collapse: collapse interaction that produced
unexpected borders. Reverted to div-based flex rows with fixed
flex: 0 0 100px stat columns — no table formatting
context, no export issues.
printerFriendly boolean prop on
FighterCard adds the
is-printer-friendly class to .card.
All overrides hang off that class in the component's scoped CSS.
The export function toggles it, waits a frame, captures, then
resets. No cloning, no separate render path.
.label-col need their own CSS
export reset
<span> elements inside
.label-col (containing the "BASE", "SIZE" etc.
text) were only covered by the .card * wildcard
reset. In regular exports this was hidden by the dark header
background. In printer-friendly exports the header background is
removed, exposing UA default borders/backgrounds on those spans.
Pattern: add
border: 0; outline: none; background: transparent
directly to a .label-col span CSS rule — same fix
as all other per-element export resets.
📁 Files Changed
| File | Change |
|---|---|
src/lib/runemarks/svg/fighters-*.svg (×26) |
Added — Agile → Warrior fighter runemark SVGs |
src/lib/runemarks/svg/monsters-*.svg (×4) |
Added — Chaotic Beasts, Monsters of Death/Destruction/Order |
src/lib/runemarks/svg/ |
20 new faction/bladeborn SVGs added; 14 files renamed for naming consistency |
src/lib/runemarks/index.ts |
Added fighterRunemarks export (26 entries); added
weapons-pistol import and
Pistol entry in weaponRunemarks
|
src/lib/runemarks/hierarchy.ts |
Added 4 monster faction entries across all four alliances; all
svg: null entries filled; Hexbane's Hunters moved
to Order of Azyr; file paths updated for renames
|
src/lib/components/FighterCard.svelte |
Pills mode, image-inner mask restructure, header-text spans,
color pass (#000/#FAF6F3), export resets (card, image, damage
rows, header icons); printer-friendly CSS class + overrides;
.label-col span export reset; damage brackets
header label, fixed stat column widths, alternating rows,
*/* damage
|
src/lib/components/FighterForm.svelte |
Fighter runemark selects (3×) with disabled logic;
$props() moved before $effect();
added Pistol option to weapon type select; sublabel and upload
hint text color improvements; */* placeholder for
monster damage field
|
src/routes/fighter/+page.svelte |
Export wrapper reset:
border:0; outline:none; background:transparent;
mobile/portrait restriction logic + notice; Export PNG split
dropdown button; printer-friendly export function; back link
uses {base}/
|
src/routes/text/+page.svelte |
Mobile/portrait restriction logic + notice; back link uses
{base}/
|
src/routes/+page.svelte |
Updated title, copy, page title tag, mobile restriction note;
links use {base}/fighter and
{base}/text
|
src/routes/+layout.ts |
New — export const prerender = true |
svelte.config.js |
Switched to adapter-static; added
paths.base (production only)
|
.gitignore |
Added .claude/ exclusion |
static/.nojekyll |
New — prevents Jekyll from hiding _app/ |
.github/workflows/deploy.yml |
New — GitHub Actions build & deploy workflow |
README.md |
Rewritten with project description, fan disclaimer, dev instructions |
Fighter Card Polish + Runemarks + Runemark Hierarchy + Export Fixes
Full pass over the fighter card UI, weapon type select, runemark infrastructure (SVGs inlined via Vite glob), image repositioning/zoom, Grand Alliance / Faction / Subfaction circles, form layout housekeeping, and a complete 3-level runemark hierarchy with cascading selects. Second session: significant PNG export fidelity improvements — model image now appears, cell frames eliminated.
✓ What Was Built
Image & Mask
- Image area inset — dark area sits with 16px margins on top/left/right, parchment background visible around it.
-
SVG mask on image section —
static/image-mask-1-cropped.svgapplied asmask-imageon.image-section; torn/rough edges on all four sides. The mask stretches with the div (mask-size: 100% 100%); image section layout is completely unchanged. -
Runemark directory renamed —
src/lib/runemarks/weapons/→src/lib/runemarks/svg/; all imports updated. -
Dead asset cleanup — 5 orphaned files removed:
static/image-area.svg, two old mask SVGs,factions-chaos-everchosen.svg,factions-death-legions-of-nagash.svg,factions-chaos-bladeborn-dread-pagent.svg(misspelled duplicate). -
Image height is flexible —
.image-sectionusesflex: 1 1 0; parchment isflex: 0 0 autoso the image grows/shrinks with content.
Table & Stats Polish
- Table headers — SVG icons replaced with text labels: BASE SIZE, POINTS VALUE, M, T, W / (empty), RNG, A, S, DMG.
-
Header text style — Warcry font, weight 400,
parchment texture via
background-clip: text. - Table borders — outer border 1px, all inner column/row borders removed.
- Row height — reduced from 64px to 55px across stats and weapons boxes.
-
Table width — changed from fixed 500px to
width: 100%, matching parchment padding (38px each side). -
Auto-fit text —
fittextSvelte action on all value cells; shrinks font in 0.5px steps on content change viarequestAnimationFrame. - Cell padding — 10px horizontal padding on stat/weapon value cells.
-
Damage bracket columns — all three columns
equally distributed (
flex: 1), form inputs also equal-width (grid-cols-3).
Weapons & Runemarks
-
Weapon type field — added
nametoWeaponinterface; form shows Type as first column. - Form field order — characteristics: Base Size → Points → Move → Toughness → Wounds; weapons: Type → RNG → A → S → DMG.
- Fighter name wraps — long names wrap to multiple lines; image area shrinks to absorb extra height.
-
Download naming — exported PNG named
slug_YYYY-MM-DD_HH-MM.png. - Checkbox grouping — Named Fighter and Monster checkboxes grouped together between Fighter Name and Model Image in the form.
-
Weapon type select — Type column is now a
<select>with 16 options (Axe → Unarmed); unselected shows em dash (—) on card. - Show Runemarks toggle — checkbox swaps M/T/W and RNG/A/S/DMG column headers and weapon type cells between text labels and SVG runemarks.
-
Characteristic runemarks — 7 SVGs (move,
toughness, wounds, range, attacks, strength, damage) inlined via
?rawimport; white fill applied via CSS. - Weapon runemarks — 16 SVGs inlined; displayed in weapon type cell when runemark mode active.
-
SVG optimisation — all runemarks run through
SVGO (5–22% reduction); moved from
static/tosrc/lib/runemarks/so Vite bundles them — no separate HTTP requests, reliable export.
Faction Hierarchy
- Grand Alliance circles — 4 SVGs (Agents of Chaos / Bringers of Death / Harbingers of Destruction / Sentinels of Order); top-left circle on card; select in form.
- Faction circles — 123 faction SVGs loaded; displayed as middle and bottom circles in the left runemark column.
-
Runemark circles — 76px (10mm at 2×),
#5a0a14background, 1px white border; card-level positioning so they never clip. -
3-level runemark hierarchy — new
src/lib/runemarks/hierarchy.ts; explicit Alliance → Faction → Subfaction/Bladeborn mapping for all four alliances; all 21 Sentinels of Order factions wired with correct subfaction/Bladeborn lists (17 new SVG imports); 3 new Order factions (Ruination Chamber, Twistweald, Ydrilan Riverblades) and 5 missing subfaction SVGs (The Knives of the Crone, Jaws of Itzl, Gladitorium Fighters, The Emberwatch, Heralds of Kurnoth) tracked assvg: null; labels corrected: "Lumineth Realm-lords", "Thunderstrike Chamber", "Vanguard Auxiliary Chamber"; all three levels sorted A→Z. - Placeholder SVG — filled X badge shown when an entry is selected but has no SVG yet.
- Cascading selects — Faction disabled until Alliance chosen; Subfaction/Bladeborn disabled until Faction chosen; each level resets children on change.
-
index.ts simplified — old
import.meta.globfaction logic removed; re-exports fromhierarchy.tsplus weapon/characteristic exports.
Card Layout & UX
-
Image repositioning — Position X / Y sliders
via
object-position; Zoom slider viatransform: scale()anchored to position origin. -
Gradient removed —
.img-fadeoverlay deleted. -
Monster bottom spacing fixed — removed
max-height: 257pxcap; image now shrinks naturally so bottom padding is always consistent. -
Viewport-fit layout — outer container is
h-screen; card scales to fit via reactivecardScalederived fromwindow.innerHeight; form scrolls independently. - Chevrons inline — named fighter chevrons moved to flow inline next to text (removed absolute positioning).
- Damage bracket header — "DMG" renamed to "DAMAGE".
- Form field labels — Points Value, Move (M), Toughness (T), Wounds (W), Range (RNG), Attacks (A), Strength (S), Damage (DMG), Wounds Taken.
- Form section order — Fighter → Checkboxes → Model Image → Runemarks → Characteristics → Weapons → Damage Brackets.
Export fidelity (second session)
-
Model image now exports —
mask-image: url('/...')in CSS is not resolved by dom-to-image-more; inlined the SVG mask as a UTF-8 data URL via Vite?rawimport (src/lib/image-mask.svg) and applied as an inlinestyleattribute. -
Fighter name frame eliminated — added explicit
border: 0; outline: none; background: transparentdirectly to.fighter-nameand.chevronCSS rules. -
All table cell frames eliminated — added
explicit
border: 0; outline: none; background: transparentto every transparent cell class:.parchment,.stats-values,.stat-col,.stat-val,.weapon-row,.wcol,.weapon-val,.weapon-art-placeholder,.dcol,.dcol-wide. -
overflow: hiddenremoved from layout containers — removed from.stats-box,.weapons-box,.damage-box,.image-section,.runemark-badge,.stat-col,.stat-val,.wcol,.weapon-val,.dcol. Rounded corners preserved by applyingborder-radius: 14px 14px 0 0to header rows and0 0 14px 14pxto last value rows.
💡 Key Decisions
image-mask-1-cropped.svg (black
filled shape, torn edges on all four sides), remove its internal
<clipPath> (it was cutting the tears flush to
a rectangle), add preserveAspectRatio="none", and
apply it as mask-image on
.image-section with
mask-size: 100% 100%. Critically: do NOT change the
image section layout at all — flex, margins, overflow all stay
exactly as they were.
flex: 1 image,
flex: 0 0 auto parchment
update()
update() on every change.
requestAnimationFrame defers the measurement until
after the DOM update, avoiding stale layout reads.
import.meta.glob + ?raw
dom-to-image-more captures them
reliably in PNG export.
import.meta.glob auto-loading
AllianceEntry → FactionEntry → SubfactionEntry
structure in hierarchy.ts gives full control over
ordering, missing-SVG handling, and future expansion.
svg: null + placeholder X for missing entries
svg: null and
rendering a filled-X badge means they're selectable and the card
visually indicates a missing runemark — without silently hiding
the selection.
overflow: hidden; moving circles
to card root ensures they're never clipped regardless of image
height.
max-height: 257px on monster image
caused leftover space below the damage brackets. Removing it
lets flex: 1 1 0 do the right thing naturally.
hierarchy.ts so
UI dropdowns appear in predictable order without any runtime
sort logic.
Export fidelity — key learnings
mask-image URLs are not resolved by
dom-to-image-more
url('/file.svg') in a CSS
mask-image property is not fetched/inlined by
dom-to-image-more during serialization — the masked element
becomes invisible. Fix: import the SVG with Vite
?raw, build a data URL
(data:image/svg+xml;charset=utf-8,${encodeURIComponent(raw)}),
and apply it as an inline style attribute on the
element. The file is kept in both static/ (URL
serving) and src/lib/ (Vite import).
.card * {} wildcard resets do NOT propagate through
dom-to-image-more
border-width: 0 and
background: transparent when they come from a
wildcard ancestor selector. In the SVG foreignObject context the
UA stylesheet then applies its own non-zero defaults (border,
white background). Fix: add
border: 0; outline: none; background: transparent
directly to each element's own CSS rule — not via a wildcard.
Elements with explicit non-transparent backgrounds (headers,
badges) are unaffected.
overflow: hidden on parent containers creates
white-box artifacts in export
overflow: hidden renders as an
opaque white rectangle in dom-to-image-more's SVG foreignObject
context, and its children inherit the white background. Fix:
remove overflow: hidden from layout/table
containers. For rounded-corner boxes that used it to clip inner
backgrounds to the radius, apply
border-radius directly to the inner header row (top
corners) and last value row (bottom corners) instead. Keep
overflow: hidden only on the root
.card element.
⏳ Still Pending
| Status | Item |
|---|---|
| todo | Right-column runemarks — SVGs + selection UI for the 3 right-side circles |
| Done | Missing SVGs — all 22 previously-null faction runemarks have since been added; no svg: null entries remain |
| todo |
Ability card editor — /text route (runemarks
reusable from same index)
|
| todo |
Export: remaining visual differences vs preview (image
frame, background-clip: text label headers,
font rendering)
|
| later |
GitHub Pages deployment — requires a GitHub repo first.
Steps: swap adapter-auto →
adapter-static, set paths.base to
the repo name, add a
.github/workflows/deploy.yml workflow.
|
📁 Files Changed
| File | Change |
|---|---|
src/lib/types.ts |
Added name, showRunemarks,
grandAlliance, faction,
bladeborn, imageOffsetX/Y,
imageZoom
|
src/lib/runemarks/hierarchy.ts |
New — full 3-level Alliance → Faction → Subfaction data, SVG imports, lookup helpers, PLACEHOLDER_SVG; updated: Order subfactions wired (17 new imports), 3 new factions, labels corrected, all levels A→Z sorted |
src/lib/runemarks/index.ts |
Simplified — re-exports hierarchy helpers; keeps weapon/characteristic exports; glob logic removed |
src/lib/runemarks/svg/ |
Renamed from weapons/; 23 weapon/characteristic
SVGs + 4 grand alliance + faction SVGs (5 orphaned files
deleted)
|
static/image-mask-1-cropped.svg |
Added — torn-edge mask, internal clipPath removed, preserveAspectRatio="none" |
src/lib/components/FighterCard.svelte |
Inline SVG runemarks, runemark toggle, image position/zoom, circle badges, monster spacing fix |
src/lib/components/FighterForm.svelte |
Weapon type select, runemark selects, image sliders, form reorder, label updates |
src/routes/fighter/+page.svelte |
h-screen layout, reactive cardScale, updated initial data |
src/lib/image-mask.svg |
Added — copy of static mask for Vite ?raw import
(dom-to-image-more inline data URL fix)
|
src/lib/components/FighterCard.svelte
(export fixes)
|
Mask inlined as data URL;
border: 0; outline: none; background: transparent
added to all transparent cell classes;
overflow: hidden removed from layout containers;
border-radius moved to inner header/value rows
|
Visual Polish, Mask & Monster UX
Applied custom fonts, parchment background texture, and an SVG organic mask. Fixed SSR export crash. Locked damage bracket rows to 5 (fixed). Added monster-aware field locking: Move and Damage show * and are non-editable on both form and card when isMonster is true.
✓ What Was Built
Fonts & Texture
-
Added
RodchenkoCTT.ttfandOldrichiumITCStdLight.otftostatic/fonts/and declared them inapp.css -
Applied
background.jpgparchment texture to.card(full-card coverage); parchment section and table value rows are now transparent so the texture shows through
Image Mask
-
Added
image-area.svg(organic/torn-edge shape) as a CSSmask-imageon.image-section; replaced the old solid dark background -
Iterated on mask sizing and position to hide the organic top
edge above the card boundary (
mask-position: 0 -40px)
Bug Fixes & Cleanup
-
Fixed SSR crash (Node is not defined) in
/fighter/+page.svelteby switchingdom-to-image-moreto a dynamicimport()insideexportCard() -
Removed redundant CSS:
.weapon-art-col(duplicated.wcol),.damage-val(duplicated.dcol-stat), no-opborder-right: none, defaultbackground: transparentdeclarations,border-radius: 0,gap: 0
Fighter Card Polish
-
Fighter name changed to
align-items: center; justify-content: centerfor vertical/horizontal centering (optical balance still being tuned) -
Damage bracket rows locked to 5 fixed rows — removed Add Row and
Remove Row (×) buttons and their handlers from
FighterForm.svelte -
Monster field locking: when
isMonsteris true, Move (characteristics) and Damage (both weapons) render as disabled inputs showing*in the form; card visual also shows*for those cells. Stored values are preserved so unchecking monster restores them. -
Base Size default changed from
ø 32to32(no ø prefix)
💡 Key Decisions
.card, not
.parchment
.image-section transparent. Putting the texture on
the card root means those transparent areas reveal the parchment
seamlessly rather than showing a bare browser background.
Node at module evaluation
time, which crashes SvelteKit SSR. Moving to
await import() inside the click handler defers
loading to the browser.
⏳ Still Pending
| Status | Item |
|---|---|
| tweak | Fighter name vertical centering — optical balance still slightly off due to mask overhang into parchment area |
| todo | Monster fighter card — image area full-width confirmed; mask scaling for shorter height to be verified visually |
| todo | Weapon icons — melee/ranged SVGs (currently placeholder icons) |
| todo |
Runemark library — SVGs in
src/lib/runemarks/svg/ + selection UI
|
| todo | Ability card editor — /text route |
📁 Files Changed
| File | Change |
|---|---|
static/fonts/RodchenkoCTT.ttf |
Added |
static/fonts/OldrichiumITCStdLight.otf |
Added |
static/background.jpg |
Added — parchment texture |
static/image-area.svg |
Added — organic mask shape |
src/app.css |
Added @font-face for Warcry + Oldrichium |
src/lib/components/FighterCard.svelte |
Mask, background, font, transparency, CSS cleanup; monster * rendering for Move + Damage |
src/lib/components/FighterForm.svelte |
Fixed 5-row damage brackets (no add/remove); monster field locking for Move + Damage |
src/routes/fighter/+page.svelte |
Dynamic import for dom-to-image-more (SSR fix); Base Size default changed to '32' |
Scaffold, Architecture & Fighter Card
Project initialized from scratch: stack chosen, types defined, routes created. Fighter card editor built end-to-end — visual card component, editable form, live preview and PNG export.
✓ What Was Built
💡 Key Decisions
#f5f0e8 parchment, #5a0a14 dark
sections. Texture image to be swapped in later via CSS variable.
🃏 Card Types (v9 Reference)
Fighter Card
- Portrait ~830×1300 ratio (approx 0.638 w:h)
- Top ~55%: full-bleed model photo, runemarks top-left and top-right (up to 3 each)
- Torn/jagged paper edge divider
- Bottom ~45%: parchment — fighter name, characteristics table (Base Size, Points, Move, Toughness, Wounds), weapons table (icon, Range, Attacks, Strength, Damage/Crit)
- Optional monster damage bracket table
Text Card (also Heroic Trait / Reaction)
- Same portrait ratio
- Top ~28%: dark maroon header — runemarks row (1–4), activation badge (DOUBLE/TRIPLE/QUAD), card-type label
- Torn paper divider
- Bottom ~72%: parchment — card name, optional italic flavor text, body text
Typography
| Use | Font |
|---|---|
| Card name, stats, activation badge | rodchenkoctt (custom, from old repo) |
| Damage table, body text |
OldrichiumITCStdLight (local, static/fonts/)
|
| Stat header labels | Small bold condensed caps |
📐 Sizing & Spacing — The Long Road
Most of this session was an iterative, sometimes messy process of extracting exact measurements from the v9 SVG reference and translating them into a faithful CSS layout. What looked simple turned into a long back-and-forth — with the user providing physical measurements in mm, then verifying by screenshot, then refining again.
Physical dimensions and the 2× scale
The card is 76×121mm physical. We render at 2×
screen scale to get crisp PNG exports at double resolution.
Conversion factor: 1mm = 7.559px (2 × 96 / 25.4). All
measurements were converted once and kept as fixed pixel values —
no CSS mm units, which behave unpredictably in
browsers.
| Element | mm | px (2×) |
|---|---|---|
| Card width | 76mm | 574px |
| Card height | 121mm | 915px |
| Image area (non-monster) | 52.6mm | 397px |
| Image area (monster) | 28mm (reduced to fit) | 212px |
| Table column width | 13.2mm | 100px |
| Table row height (stats/weapons) | 8.5mm | 64px |
| Damage bracket row height | 3mm | 23px |
| Gap between tables / image | 3.9mm | 29px |
| Parchment side/bottom padding | ~5mm / ~5mm | 38px |
Layout structure
The card is a flex-direction: column container. The
image section has a fixed height (flex: 0 0 397px);
the parchment fills the rest (flex: 1). Inside the
parchment, the fighter name uses flex: 1 to fill the
space above the tables, which are anchored at the bottom with
flex-shrink: 0.
The tables are 5 columns × 100px = 500px wide (66mm), centred within the 574px card by the parchment padding.
Monster layout
When isMonster: true, the image section shrinks to
212px and a damage bracket table appends below the weapons box.
The damage table is full-width (no border-radius, black borders,
transparent background) and capped at 5 data rows to prevent
overflow. The monster image height was reduced from the spec'd
37.9mm to ~28mm after overflow was observed in practice.
Why it took so long
Early attempts used percentage-based or approximate sizes — the card never matched the reference. Progress only clicked once every dimension was derived from the physical mm spec and consistently applied. Screenshots revealed mismatches multiple times before the spacing felt right. The table column and row sizes in particular required several passes: equal-width columns were not obvious from the SVG, and the gap between the image and first table was initially left unspecified then re-derived from the reference.
⚙ Node Version Note
System default is Node v16 (Laravel Herd). Project requires v22+. Herd has v22.22.0 at:
~/Library/Application Support/Herd/config/nvm/versions/node/v22.22.0/bin
Use make dev which sets PATH automatically, or switch
in Herd's UI.
⚡ State / Reactivity Pattern
Page creates
let data = $state<FighterCardData>({...}) and
passes the reactive proxy to both FighterForm and
FighterCard as a prop. The form mutates properties
directly (data.name = x,
weapon.range = x) — Svelte 5 deep reactivity
propagates all mutations back to the card in real time. No stores,
no $bindable needed.
⏳ Still Pending
| Status | Item |
|---|---|
| todo | Source RodchenkoCTT font → static/fonts/ |
| todo | CSS polish — spacing, torn edge, parchment texture |
| todo | Weapon icons (melee / ranged SVGs) |
| todo | Runemark library + selection UI |
| todo | Ability card editor (/text route) |