Mar 11 – Mar 31, 2026 20 sessions

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

PeriodWhat
Mar 11–15Fighter card — layout, characteristics, weapons, damage bracket, runemarks, faction hierarchy, model image pan/zoom
Mar 16–17Text card — activation badge, body/flavour/prerequisite text, points table, label types
Mar 18–22Export & polish — dom-to-image-more quirks, mobile support, PWA, i18n (EN + DE), printer-friendly modes
Mar 23Card back — background image, centred name + runemark, text colour, mirrored name
Mar 23–29Deployment 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–29Cross-editor — inline runemark markup, independent faction selects, tags row, JSON save/load, banderole label
Mar 31Reference 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.

📋 Reference Topic Index

Topic Reference

Quick lookup table — find specific decisions, patterns, and configurations across all session logs.

🏛️ Architecture

Topic Where
Stack: SvelteKit 2 + Tailwind v4 + dom-to-image-more Mar 11
CSS/HTML card rendering (not Canvas) Mar 11
Separate routes: /fighter and /text Mar 11
Svelte 5 runes: $state prop mutation pattern Mar 11
dom-to-image-more: dynamic import to avoid SSR crash Mar 12
Runemarks bundled via import.meta.glob + ?raw — reliable export Mar 13
Runemark hierarchy: explicit Alliance → Faction → Subfaction in hierarchy.ts; all alliances complete, A→Z sorted Mar 13
Fighter runemarks: 26 SVGs, 3 selects, Hero/Monster mutual exclusion + always-top sort Mar 14
Pills mode when "Show Runemarks" unchecked — image-inner for mask, image-section unmasked for pills Mar 14
Runemark SVGs: white versions for faction circles, black for weapon icons Mar 14
Hierarchy fully complete — zero svg: null entries; shared icons for Gobbapalooza and Brand's Oathbound Mar 14
Base size field: <select> with 14 round (⌀) + 7 oval options, round-first ascending order Mar 15
Fighter card subtitle: optional second line below name, Germania One, conditionally rendered; 120-char limit + live counter Mar 15
Header text: full words (MOVE/TOUGHNESS/WOUNDS/RANGE/ATTACKS/STRENGTH/DAMAGE) instead of abbreviations Mar 15
Weapon name font 18px (matches header text) + fittext shrink for long names Mar 15
Ability card editor: /text route, TextCard.svelte + TextForm.svelte built from scratch Mar 15
SVG fill="#FFF" removed from 20 runemark source files — required for printer-friendly CSS override Mar 15
Runemark library audited against reference card creator — added Everchosen, Legions of Nagash, Ulfenkarn, Grombrindal, 10 Cities of Sigmar subfactions Mar 16
Ability card types: Lesser Artefact + Greater Artefact added Mar 16
Pipe | as manual line break in fighter name and text card name Mar 16
Faction hierarchy restructured: parent-prefixed naming (e.g. Slaves to Darkness: Iron Golem); CoS subfactions promoted; Ulfenkarn removed Mar 16
FactionSelect.svelte — filterable grouped list replaces 3 cascading selects on both Fighter + Ability editors Mar 16
TXT export workflow for human-editable hierarchy audits (runemark-hierarchy.txt) Mar 16
Battle Trait added as ability card type option (after Heroic Trait) Mar 19
Bold/italic markup buttons on ability body text; parseMarkup() renders **…**/*…* as <strong>/<em> via {@html} Mar 19
Torn-edge SVG overlay added to printer-friendly ability card Mar 19
Grand Alliance-only faction selection: alliance headers are now clickable buttons; card renders GA runemark alone when faction is empty Mar 18
Light/dark theme: CSS vars + [data-theme] on <html>; Tailwind class overrides in app.css; ThemeToggle on landing page; system pref default, localStorage override Mar 18
Mobile support: tab bar (Edit/Preview), flex-col h-dvh layout, min-h-0 flex scroll fix, explicit-dimension card wrapper Mar 17
iOS input zoom fix: font-size: 1rem on mobile for .field-input Mar 17
Touch dropdown: deferred document click-outside listener via $effect + setTimeout Mar 17
Tablet portrait: side-by-side layout breakpoint raised to lg: 1024px — phones + portrait tablets use tabbed layout Mar 17
Mobile image positioning: touch overlay on card image area — drag to pan, pinch to zoom; touch-action: none suppresses scroll without non-passive listeners Mar 18
Range input: custom 20px thumb via -webkit-appearance: none; track uses var(--ui-surface-2) for theme compat Mar 18
Code style rules added to CLAUDE.md: import sorting, key quoting, on-touch cleanup policy Mar 18
Mobile export: iOS Safari + dom-to-image-more incompatibility (foreignObject restriction); html2canvas attempted and reverted; fixed via modern-screenshot Mar 18
Mobile export: modern-screenshot (domToPng) + explicit { width: 574, height: 915, scale: 2 } to override CSS transform bounding rect Mar 18
iOS download: Web Share API (HTTPS) saves immediately on live URL — overlay only fires on localhost (HTTP, no secure context) Mar 18
isRealMobile pattern: plain const via navigator.userAgent regex — separates real devices from desktop mobile-viewport emulation Mar 18
Favicon + PWA icons: SVG, ICO, apple-touch-icon, 192/512px manifest PNGs, site.webmanifest; theme_color #5a0a14 Mar 19
Pills: font Alegreya 15px, padding 7px 13px; pills-break removed — all pills in one row; worst-case test combo documented Mar 19
Ability card type label: bottom moved from 80px → 60px (closer to torn edge) Mar 19
i18n: import.meta.glob locale auto-discovery; module-level $state + inline t() for cross-module reactivity Mar 20
i18n: stable English keys stored for icon lookup; translation at render time only (weapons, runemarks, cardLabel slug) Mar 20
text-transform: uppercase via CSS — locale files store natural case throughout Mar 20
Locale-aware sorting: $derived + localeCompare(b, i18n.code) for factions, weapons, runemarks — re-sorts reactively on locale change Mar 21
FactionSelect prop typed structurally as { grandAlliance, faction, bladeborn } — works with both card types Mar 21
Text card: prerequisiteText field — framed box between flavor text and body text; B/I markup buttons; wrapSelection refactored to take a field name Mar 22
Card Elements checkbox system — text card: showActivation, showFlavorText, showPrerequisite, showPointsTable; each collapses both card element and form field Mar 22
Fighter card: showSubtitle, showCaption flags; Card Elements section added to form; caption moved out of image block Mar 22
Text card parchment render order: flavor text → points table → prerequisite → body text; points table decoupled from cardLabel Mar 22
Fixed-size card overflow: header height stays fixed; content clips silently — no dynamic shrinking Mar 22
LangSwitch: <details> dropdown replaces native <select> — "Add a language..." is a real <a>, fixing iOS Safari popup-block Mar 22
Card back editor: /card-back route; CardBackData in types.ts; texture suppressed when custom image uploaded (has-bg-image class) Mar 23
--card-text-color CSS variable drives both name colour and SVG fill; printer-friendly forces #000 at variable level Mar 23
Runemark picker: searchable list (FactionSelect pattern) replaces native <select> for 200+ entries across all categories Mar 23
28 new runemark SVGs in 5 categories (Card Decks, Deployment, Misc, Treasure, Twists) — card back only; 5 new i18n namespaces Mar 23
SVG fill bug: inline fill="#000" on <path> beats inherited CSS — fix by stripping attribute at source Mar 23
Deployment card: SVG-based (not CSS/HTML); landscape 915×574px; 17 snap positions (3×3 inside + 8 outside); dagger ▽ / hammer ◇ / shield ⊙ icons; 2-player red/blue Mar 23
Deployment icons: deployment-*.svg imported ?raw; fill replaced with fill="white"; embedded in nested <svg> inside colored outer shape Mar 23
Deployment point form: add/remove points; position select (33 options); icon toggle; colour swatches; RND field Mar 24
Speech bubble replaces standalone icon + offset RND label — bubble unifies player colour, icon, and label into one visual unit with directional tail Mar 24
33 snap positions: OUT-LT/LB/RT/RB (side edges) + OUT-CNR-TL/TR/BL/BR (corners with diagonal tails); 4 player colours (red/blue/green/yellow) Mar 24
Deployment card rebuilt card-first: sidebar removed; interactive position dots; popovers; 8-way direction picker on SVG; overlays pill toggle; pinch-zoom Mar 25
overflow-x: auto clips position: absolute dropdowns — fix: position: fixed; top: 45px; right: 12px Mar 25
freeHierarchy toggle: 3 independent flat selects replace cascade; findFactionSvg/findSubfactionSvg helpers for parent-free SVG lookup Mar 28
Pills row replaced with plain Alegreya uppercase tags joined by ; fighter card tags inside .image-inner so SVG mask clips background strip Mar 28
Deployment card: View dropdown collapses display toggles; Show Runemarks toggle switches SVG icons ↔ letter fallback; dropdown anchored to its button via getBoundingClientRect() Mar 26
Popover overflow clamping: bind:this + $effect measures rendered height post-DOM-update, shifts up if it overflows viewport Mar 26
Deployment card: objective marker (black circle + label); three-action popover restructure (Place marker / Add measurement / Objective section); rAF overflow fix Mar 26

🃏 Card Design

Topic Where
Card types: Fighter vs Ability Mar 11
Card dimensions: 76×121mm → 574×915px at 2× scale Mar 11
Image section: 52.6mm (non-monster) / 37.9mm (monster) Mar 11
Full dimension table: card, image area, columns, rows, gaps Mar 11
2× scale: 1mm = 7.559px; all values fixed in px Mar 11
Monster layout: reduced image height + damage bracket table Mar 11
Ability card: 574×915px, torn-edge image area (no image upload), runemarks row + activation badge + card label Mar 15
Ability card: image caption field overlaid on fighter card image area Mar 15
Deployment card: landscape 915×574px; battlefield rect 100px L/R / 60px T/B inset; parchment #d9b8a8; dashed centre lines 8/6 Mar 23
Text card banderole variant: layoutVariant: 'standard' | 'banderole'; masked .banderole-bg extends ±35 px; SVG outline needs explicit width/height not just inset Mar 27

🎨 Visual & CSS

Topic Where
Color palette: maroon #5a0a14, dark #1a0408 Mar 11
Parchment texture on .card (not .parchment) Mar 12
SVG mask on .image-sectionimage-mask-1-cropped.svg, torn edges all sides, layout unchanged Mar 13
background-clip: text for parchment texture in table headers Mar 13
Fonts: Warcry (RodchenkoCTT) + Oldrichium (ITC Std Light) Mar 12
Fonts: Germania One (400, SIL OFL) + Alegreya (variable, SIL OFL); self-hosted; damage table uses Alegreya; letter-spacing removed Mar 15
Named Fighter: chevrons pinned to title edges, text padded clear Mar 13
Image height flexible: flex:1 image + flex:0 0 auto parchment Mar 13
fittext Svelte action — shrinks font until text fits cell width Mar 13
Runemark circles: card-level positioning, 76px, #5a0a14, 1px white border Mar 13
Monster bottom spacing: remove max-height cap, let flex handle it Mar 13
Alliance names: Agents of Chaos / Bringers of Death / Harbingers of Destruction / Sentinels of Order Mar 13
Placeholder X badge for entries with svg: null Mar 13
Cascading selects reset children on parent change (onchange handler) Mar 13
dom-to-image-more: SVG mask-image must be inlined as data URL (URLs not resolved) Mar 13
dom-to-image-more: .card * wildcard CSS does NOT propagate — add resets per-element Mar 13
dom-to-image-more: overflow: hidden on containers → white-box artifact — remove and move border-radius to inner rows Mar 13
dom-to-image-more: border-top on child rows not rendered — use box-shadow: inset 0 1px 0 0 #000 Mar 14
dom-to-image-more: SVG icon width: auto unreliable — use explicit px (28px) on both span and svg Mar 14
Colors: all dark fills → #000; all white fills → #FAF6F3 (parchment approximation) Mar 14
Organic shape runemark badges: 78px wrapper (#FAF6F3) + 76px inner (#5a0a14), both masked with same shape Mar 15
Runemark shape: copy SVG to src/lib/, ?raw import → data URL, apply inline via style attribute for export Mar 15
Table border-radii: 1mm = 7.5px outer box, 6.5px inner rows Mar 15
Empty weapon values: em dash with smaller font (16px) via class:is-empty Mar 15
iOS layout shift fix: placeholder="—" on characteristics inputs prevents keyboard-type switch on empty field Mar 18
Select centering on iOS: text-align-last: center on Base Size + Weapon Type selects Mar 18
Empty characteristics on card: class:stat-val-empty shows at 16px, matching weapon behaviour Mar 18
Ability card printer-friendly: white bg, mask: none !important on image-inner, black text/pills/labels Mar 15
Light theme palette: #e4ddd3 shell, #ede8e2 inputs, #2e2620 text — warm parchment tones. Contrast-audited: subtle text darkened to #5a5048 for WCAG AA. Mar 18
Runemark SVG checklist: strip editor class/id/style; add width/height="300" + <title>; normalize viewBox to 0 0 with translate; remove path ids and redundant fill Mar 20

🛠️ Config & Ops

Topic Where
Node v22 required — Herd path / make dev Mar 11
File structure & route scaffold Mar 11
GitHub Pages: adapter-static, paths.base, GitHub Actions deploy workflow Mar 14
LAN device testing: server: { host: true } in vite.config.ts — use Network URL from Vite output on same Wi-Fi Mar 18
2026-03-31 Reference card + group runemarks Deployment polish Fighter card export fix

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.svelte below Card Back, styled in zinc (secondary).

Card Layout

  • 5 × 8 grid: grid-template-columns: repeat(5, 1fr) + grid-template-rows: repeat(8, 1fr) with height: 100% on the grid — icons fill the full card space without wasted margins.
  • Fixed cell size: .rm-cell is 70 × 70 px with display: 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: #000 regardless of design or printer-friendly state.

Circle Type Design

  • Standard mode: .rm-badgeposition: absolute; inset: 0, maroon #5a0a14 background, SVG fill: #FAF6F3, masked with runemark-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; cardPages derived store chunks visibleItems into 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: transparent on its own CSS rule (no wildcard) — prevents dom-to-image-more from emitting black frames.
  • overflow: hidden removed from .rm-cell and .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-type in 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 miscRunemarks import from reference/+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: cardPages returns RmItem[][][] (pages → groups → items). Groups can split across pages at the ITEMS_PER_CARD boundary.
  • 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: pageGridRows simplified to always return repeat(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_RADII labels changed from S/M to Small/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 $effect used pv.y (the raw click Y) instead of rect.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 - overflowBottomrect.top - overflowBottom and pv.x - overflowRightrect.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/end and measurement-pick-end: shortened instruction strings
  • popover-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 of y=60. Controlled by a derived mpY = showOrientation ? 60 : 20 constant.

Fighter Card — Export Border & Dark-Spots Fix

  • Root cause: the March 29 "Finalize deployment card" commit removed ~30 per-element border: 0 declarations from FighterCard.svelte, replacing them with a single .card * { border-width: 0; border-style: solid } blanket rule. dom-to-image-more's CSS serializer preserves border-style: solid but silently drops border-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-style form was also wrong — the shorthand border: 0 additionally sets border-style: none, which survives serialization. border-width: 0 alone does not.
  • Fix: changed the blanket rule to border: 0 and restored border: 0; outline: none; background: transparent on 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: transparent resets also eliminated the printer-friendly "brownish bleed at SVG mask edge" artifact — leaked background: 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

FileChange
src/routes/reference/+page.svelteNew Reference Card page; added Group Runemarks toggle (filler cells, always repeat(8,1fr) grid) and grouped pagination
src/routes/+page.svelteAdded Reference Card button (zinc, below Card Back)
src/lib/components/DeploymentCard.svelteSVG 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.svelteRadius 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.svelteBlanket .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.jsonReference 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.jsonSame as en.json; form-new-line "Runenzeichen gruppieren"
2026-03-29 Zone drawing & mask cutouts PF hatching & perimeter snaps Mobile UX & code quality

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

  • ZonePreset type removed from types.ts. DeploymentZone changed from { preset: ZonePreset } to { startPos: DeploymentPosition; endPos: DeploymentPosition }.
  • Zone rect computation: zoneRect(zone) in DeploymentCard.svelte now calls getCoords(startPos) and getCoords(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: showPositionDots is showMarkers || zoneMode || measurementMode.

Zone Click-to-Edit

  • Zone rects are clickable: onZoneClick prop added to DeploymentCard; each zone <rect> gets onclick + cursor: pointer when 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: DeploymentZone gains mask?: boolean and radius?: number (SVG units, defaults to 89). When mask: true, startPos is the circle centre; endPos is 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) with fill-opacity=1 in 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

  • DeploymentPosition union extended with `PRM-${string}` template literal in types.ts. Coords are encoded in the ID string itself (PRM-{x}-{y}), parsed by a regex branch in getCoords() — 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 unless measurementMode is 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> gains stroke="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 white to rgba(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--pf CSS 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-name gains transform: rotate(-90deg); transform-origin: left bottom; white-space: nowrap so the caption reads upward along the left edge, anchored at left: 28px; bottom: 20px. right: 0 removed.

Mobile & Editor UX

  • JSON dropdown click-outside: $effect handler added to close the JSON dropdown on outside click — matching the existing View and Export dropdown behaviour.
  • scrollIntoView on popover inputs: all 5 popover text inputs call scrollIntoView({ 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: transparent declarations from individual CSS rules — all covered by the existing .card * reset rule.
  • src/app.d.ts cleanup: 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 zone R1C4 → R4C6; blue zone R6C1 → 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-radius in both locales.
  • Removed: all deployment.zone-* preset label keys (14 keys) and ui.form-zones, ui.form-add-zone from 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

StatusItem
openDark 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

FileChange
src/lib/types.tsRemoved ZonePreset; DeploymentZone{ startPos, endPos, mask?, radius? }; added `PRM-${string}` to DeploymentPosition union
src/lib/components/DeploymentCard.sveltePosition-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.svelteZone/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.svelteRemoved ~100 redundant border/outline/background resets from individual CSS rules (all covered by .card *)
src/app.d.tsRemoved SvelteKit boilerplate comments and commented-out interface stubs
src/lib/i18n/locales/en.jsonAdded 8 zone/mask UI keys; removed 16 stale zone preset + dropdown keys
src/lib/i18n/locales/de.jsonSame as en.json
_work/deployments/agents-of-belakor-battlefield-edge.jsonNew example JSON for the Be'lakor Battlefield Edge deployment layout
2026-03-28 Independent hierarchy selects Tags row replaces pills JSON save / load — fighter + text

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: boolean added to both FighterCardData and TextCardData in types.ts; defaults to false.
  • Form UI: "Use Independent Hierarchy" checkbox in Card Elements section of both FighterForm.svelte and TextForm.svelte. Uses onchange (not bind:checked) to clear grandAlliance, faction, and bladeborn when toggling off. When on: 3 sublabeled <select> elements (Grand Alliance / Faction / Subfaction) with alphabetically sorted options. When off: existing FactionSelect component 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 to hierarchy.ts and exported from index.ts. They search all alliances without needing a parent ID — required because free mode sets faction without necessarily setting a matching grandAlliance.
  • i18n: new keys ui.form-free-hierarchy, ui.form-grand-alliance, ui.form-faction, ui.form-subfaction in both en.json and de.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 SVG mask-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-inner caused 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-bottom and 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-border and .runemark-badge element — 8 times in FighterCard.svelte and 10 times in TextCard.svelte. Replaced with a --rm-mask CSS custom property set once on the .card element; the CSS rules for .runemark-border and .runemark-badge now reference var(--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.svelte gained an exporting?: boolean prop; 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 /fighter and /text pages, desktop and mobile. Reuses the same split-button chevron already present — no new toolbar space needed.
  • Save: saveLayout() serialises data to a pretty-printed .json file using makeSlug() for the filename. Fighter strips modelImage (blob URL, not serialisable). Both pages strip svg from runemark objects — only { id, label } is saved, keeping files compact.
  • Load: handleFileLoad() reads the file via FileReader, parses JSON, re-hydrates runemark SVGs from the bundled fighterRunemarks record by id, then replaces data. Malformed JSON is silently ignored.
  • Runemark state sync fix: rmKeys (the internal string array driving the runemark selects) was local $state in FighterForm / TextForm. On JSON load, setting data externally caused the form's $effect to immediately overwrite data.rightRunemarks / data.fighterRunemarks with the stale empty keys. Fix: rmKeys lifted to the parent page as $state, passed to the form as a $bindable prop. handleFileLoad sets rmKeys from loaded ids before replacing data.

Still Pending

StatusItem
openDark 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

FileChange
src/lib/types.tsAdded freeHierarchy: boolean to FighterCardData and TextCardData
src/lib/runemarks/hierarchy.tsAdded findFactionSvg() and findSubfactionSvg() flat-lookup helpers
src/lib/runemarks/index.tsExported findFactionSvg and findSubfactionSvg
src/lib/components/FighterCard.svelteFree-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.svelteFree-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.svelteAdded 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.svelteSame as FighterForm — "Use Independent Hierarchy" checkbox and conditional hierarchy selects; rmKeys changed from local $state to $bindable prop
src/routes/fighter/+page.svelteAdded 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.svelteAdded freeHierarchy: false to initial data; same JSON save/load additions as fighter page
src/lib/i18n/locales/en.jsonAdded form-free-hierarchy, form-grand-alliance, form-faction, form-subfaction
src/lib/i18n/locales/de.jsonAdded form-free-hierarchy, form-grand-alliance, form-faction, form-subfaction
2026-03-27 Banderole layout variant Inline runemark markup Small body text toggle

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 to TextCardData. When 'banderole': .image-inner is not rendered (top section shows card texture); the type label is wrapped in .banderole containing a masked .banderole-bg div for the maroon shape and a .card-label-text on top via z-index.
  • Shape and position: .banderole is position: absolute; left: -15px; right: -15px; bottom: 56px; padding: 4px 50px — overhangs the card edges slightly. .banderole-bg uses inset: -35px 0 to 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 at 56 + 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-bg set to background: transparent; a .banderole-outline SVG with the same path stroked in black renders only when printerFriendly. The full torn-edge overlay is suppressed in banderole mode (printerFriendly && !isBanderole). Outline SVG must have explicit width: 100%; height: calc(100% + 70px) — relying on inset alone 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 with row-gap: 28px.
  • i18n: new keys ui.form-card-design ("Card Design" / "Kartendesign") and ui.form-layout-banderole ("Banderole type" / "Banderole") in both locales.

Runemark Additions

  • Thyrielle's Zephyrites (factions-order-bladeborn-thyrielles-zephyrites.svg) — added to hierarchy.ts under Order › Bladeborn; i18n key thyrielles-zephyrites in both locales.
  • Warhammer Underworld (misc-whu.svg) — added to miscRunemarks in index.ts; i18n key misc.whu in both locales. SVG subsequently cropped.
  • Warcry (misc-warcry.svg) — added to miscRunemarks in index.ts; i18n key misc.warcry in 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 (Beastbeast, Icon Bearericon-bearer); faction/subfaction IDs are used directly.
  • slugify() + inlineRmMap: module-level IIFE in TextCard.svelte merges all runemark groups (fighters, weapons, characteristics, card-decks, deployment, misc, treasure, twists) plus the full hierarchy (alliances, factions, subfactions) into a single slug → svg lookup. 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 inherits fill: 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") and ui.form-insert-runemark ("Insert runemark" / "Runemark einfügen") in both locales.

Small Body Text Toggle

  • smallBodyText: boolean added to TextCardData; defaults to false. When active, .small-body class on .parchment reduces 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-white to text-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

FileChange
src/lib/types.tsAdded layoutVariant?: 'standard' | 'banderole' and smallBodyText: boolean to TextCardData
src/routes/text/+page.svelteDefault layoutVariant: 'standard' and smallBodyText: false in initial state
src/lib/components/TextCard.svelteBanderole 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.svelteTwo-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.jsonAdded 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.jsonAdded form-card-design, form-layout-banderole, misc.warcry, misc.whu, thyrielles-zephyrites, weapons-group, form-insert-runemark, form-small-text
README.mdMentioned banderole option in Text Card editor description
src/lib/runemarks/hierarchy.tsAdded Thyrielle's Zephyrites under Order › Bladeborn
src/lib/runemarks/index.tsAdded misc-warcry and misc-whu to miscRunemarks
src/lib/runemarks/svg/factions-order-bladeborn-thyrielles-zephyrites.svgNew runemark
src/lib/runemarks/svg/misc-warcry.svgNew runemark
src/lib/runemarks/svg/misc-whu.svgNew runemark (subsequently cropped)
src/routes/fighter/+page.svelteBack button hover effect
src/routes/text/+page.svelteBack button hover effect
src/routes/card-back/+page.svelteBack button hover effect
2026-03-26 Deployment point shapes 7×9 snap grid (99 positions) Template deduplicated

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-2px-4 py-3; all buttons h-7h-9; player colour circles w-7 h-7w-9 h-9; separators h-5h-7; text text-xstext-sm. HEADER_H updated 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().left captured at open time.
  • Show Runemarks toggle: showRunemarks = $state(true) in +page.svelte, passed as prop to DeploymentCard. 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: 12px so it appeared under the Export PNG button. Now captures jsonBtnEl.getBoundingClientRect().left at open time and uses left: {jsonDropdownLeft}px as 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, measures popoverEl.getBoundingClientRect().bottom against viewportHeight - 8; shifts popoverEl.style.top up by the overflow amount if needed. Safety net: max-height: calc(100dvh - 16px); overflow-y: auto on popover container.

Code Cleanup

  • Dead state removed: activeIcon (was always 'dagger', never updated), ZONE_PRESETS (declared but unused in template)
  • Bug fixed: loadLayout() was closing showDropdown instead of showJsonDropdown — layout loaded but dropdown stayed open
  • ZonePreset import 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')} in fighter/+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.css alignment: --ui-surface, --ui-surface-2, --ui-surface-hover, and --ui-border had one extra space vs other vars; all 14 variables in both :root and [data-theme="light"] now align at the same column (--ui-surface-hover sets the width)

Two-Point Measurement Lines

  • Type changes: DeploymentMeasurement fields anchorCol/anchorRow/direction/length removed; replaced with startPos: DeploymentPosition and endPos: DeploymentPosition. DeploymentDirection type removed entirely.
  • Interaction flow: tap any empty position dot → popover shows "Add Marker" + "Add Measurement" buttons. Tapping "Add Measurement" sets measurementMode = true + records measurementStart, 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 showDotsshowMarkers; remove buttons styled as text-xs text-zinc-400 underline hover:text-white to 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). R3 row lies on horizontal centre line; C3 column 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/ey aliases in measurement rendering (leftover from MEAS_INSET removal) 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_L 63→73, CNR_R 852→842. Brings outside dots visually closer to the battlefield edge.
  • DeploymentPosition type in types.ts expanded 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 at cy−22, tip at cy+22.
  • Icon fill via inheritance: innerContent() strips all fill attributes; outer <svg> sets fill={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 #ca8a04 to #f59e0b (brighter amber); synced across DeploymentCard.svelte and +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: 20px spacing): twists-orientation.svg (top-left) and card-decks-symmetrical.svg (top-right, Matched Play).
  • Text fallbacks: when showRunemarks is off the SVGs are replaced with (Orientation) and MP (Matched Play) in Germania One, same dark-maroon fill #5a0a14; printer-friendly uses #000.
  • Individual View toggles: showOrientation and showMatchedPlay booleans added to +page.svelte and 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 via feMorphology.
  • 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 at cx−CIRCLE_R−4, glyph ~12 px wide so centre at cx−CIRCLE_R−10), radius 9 gives ≈1–2 px clearance.

Dagger Letter Vertical Centring

  • Previous: y={cy + 8} (baseline) ≈ visual centre at cy — 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) with dominant-baseline="central" — splits the difference between centroid and mid-height, visually balanced. Circle and diamond shapes remain at cy.

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.svg copied from _work/ to src/lib/runemarks/svg/.
  • Hierarchy: imported as oCitiesOfSigmarAlt in hierarchy.ts; entry { id: 'cities-of-sigmar-alternative', label: 'Cities of Sigmar (Alternative)', … } inserted immediately after the base cities-of-sigmar entry.
  • i18n: factions.cities-of-sigmar-alternative added to en.json ("Cities of Sigmar (Alternative)") and de.json ("Städte Sigmars (Alternativ)").

Objective Markers (session four)

  • New type: DeploymentObjective { position, label } added to types.ts; objectives?: DeploymentObjective[] added to DeploymentCardData.
  • SVG rendering: black circle r=16 (half of CIRCLE_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 at top: cy + OBJECTIVE_R + 4px so it sits just below the circle. Black background, #faf6f3 text, white-space: nowrap.
  • Position dot coloring: objectivePositions derived set; dot gets pos-dot--objective class (black fill, #faf6f3 stroke) when an objective occupies that position.
  • Click priority: handlePositionClick checks 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 to PopoverMode; 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-700 divider with a popover-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.
  • popoverObjectiveLabel state resets to '' each time an empty popover opens.

Popover Overflow Fix (session four)

  • Problem: the existing $effect measured getBoundingClientRect() 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.right vs viewportWidth - 8 and shifts el.style.left leftward if needed.
  • Initial position guard: clampedStyle bottom clamp increased from viewportHeight - 320 to viewportHeight - 500; prevents a visible flash on the frame before rAF fires.

💡 Key Decisions

Why a View dropdown instead of individual header toggles?
The header was getting cluttered. Grouping display-only toggles (Show Runemarks, and future options) under a single View button keeps the toolbar scannable while leaving room to add more without another refactor.
Why anchor dropdowns to their button (52 px) instead of the header bottom (61 px)?
Conventional dropdown UX: the menu should appear immediately below the button that opened it. The 9 px difference was noticeable as a gap. Using button-bottom (h-9 = 36 px, plus py-3 = 12 px top padding + 4 px gap = 52 px) feels tight and natural.
Why mutual exclusion for all three dropdowns?
Opening a second dropdown left the first one layered underneath it. No use case requires two dropdowns open simultaneously. Each open handler now closes the other two before potentially opening itself.
Why CSS custom properties in the popover style block instead of Tailwind classes?
The popover is a scoped <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.
Why letter fallback (D/H/S) when runemarks are hidden?
The deployment bubbles need something in the icon slot for legibility — a blank bubble is ambiguous. A single uppercase letter in Germania One is visually consistent with the rest of the card's typography and makes the bubble type instantly readable even in printer-friendly mode.
Why two-point (startPos→endPos) instead of anchor+direction+length?
The old model required three separate interactions (tap snap grid → pick direction from 8-way radial → type length in popover). Two-point is tap-start, tap-end — the line length and angle are derived from getCoords() automatically. Caps and label are edited afterwards in the measurement popover. Simpler mental model, fewer steps.
Why a unified CNR coordinate system for inside and outside positions?
When inside dots used 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.
Why 5×5 inside grid (not 4×4 or 3×3)?
4×4 had no position at the battlefield centre — dots fell between rows/columns. 5×5 makes 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.
Why geometric shapes instead of speech bubbles?
The original Warcry card design uses triangle/diamond/circle as the visual vocabulary for deployment point types. Speech bubbles were a pragmatic first implementation. Shapes are more compact, visually faithful to the reference, and carry type meaning without needing a directional tail.
Why equal vertical extent (cy ± CIRCLE_R) for all three shapes?
Equal circumradius makes the triangle tip hang much further below centre than the circle/diamond because the centroid splits height 1:2. Equal bounding-box height produces the same problem. Equal vertical extent — all shapes spanning the same top and bottom bounds — was the only approach that made all three look aligned on the card.
Why strip fill attributes in innerContent() rather than replacing at render time?
The original approach called .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.
Why 7×9 (not 5×7 or 5×5) for the inside grid?
Counting dots in the reference card image gave exactly 99. The only structure that sums to 99 is 63 inside (7×9) + 36 outside (9+9+7+7+4). R4C5 lands at (BF_CX, BF_CY) confirming the centre alignment. Earlier analyses guessing 5×5 and 5×7 were both wrong.
Why do inside dots use BF_ coordinates while outside dots use CNR_ coordinates?
The reference shows outermost inside dots sitting exactly on the battlefield boundary lines, with outside dots sitting just beyond them on a slightly wider rail. Using BF_L/BF_T as the inside grid origin (step BF_W/8, BF_H/6) achieves this. Outside T/B share the same x as inside columns (aligned) but sit at CNR_T/CNR_B — the fixed outer rail, which is 26 px above/below the battlefield edge.
Why move CNR_L/CNR_R 10 px inward?
The outer left and right columns of dots appeared too far from the battlefield edge compared to the reference. The top/bottom outside rows use T/B CNR constants (unchanged) and looked correct. Moving CNR_L from 63→73 and CNR_R from 852→842 closed the gap on both sides symmetrically.
Why HTML div for the RND label background instead of an SVG rect or filter?
A fixed-width SVG <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.
Why individual toggles for Orientation and Matched Play rather than just relying on Show Runemarks?
Show Runemarks controls the visual mode (SVG icon vs text fallback); it doesn't remove the element. A card creator may want to publish a layout without either corner badge, or with only one. Independent boolean props and pill switches in the View dropdown give full control without coupling visibility to the icon-style preference.
Why cy − CIRCLE_R/6 for the dagger letter, not the mathematical centroid (CIRCLE_R/3)?
The centroid of the downward triangle is 2/3 of the height from the top — visually this reads as sitting in the upper third, confirmed by screenshot. The perceptual centre for text inside a wide-top shape is lower than the centroid, roughly midway between centroid and geometric mid-height. CIRCLE_R/6 (≈3.7 px above centre) landed correctly on device.
Why ← / ⊤ for start cap and → / ⊥ for end cap?
The original | and — symbols were both horizontal/vertical dashes that looked near-identical at small button size. Making the start cap buttons mirror the end cap buttons (← vs →, ⊤ vs ⊥) encodes directionality: you immediately read "this picker is for the left/start side of the line". The perpendicular-ground symbol ⊥ and its upside-down counterpart ⊤ are visually distinct at a glance.
Why is the objective a plain black circle with no border or colour?
Objectives are scenario-specific landmarks, not player-owned. A neutral black distinguishes them from the four player colours (red/blue/green/yellow) while remaining legible on the parchment battlefield. A border added visual noise; the circle alone reads cleanly at the 16 px radius chosen.
Why a divider section for the objective label rather than just showing a third button alongside Point and Measurement?
The objective action has a unique input (the label text) that the other two don't share. If the label field appeared in the general section with no visual grouping, users couldn't tell which action it belonged to. A thin divider + "OBJECTIVE" micro-label groups input and button unambiguously — the same pattern used for measurement caps in the measurement popover.
Why "Place marker" instead of "Add point"?
"Add point" is ambiguous — "point" in game language often means a scoring point, not a physical deployment marker. "Place marker" describes the action precisely: you are placing one of three icon markers (dagger/shield/hammer) at a grid position on the card.
Why requestAnimationFrame for the popover overflow correction?
Svelte's $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

StatusItem
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

FileChange
src/routes/deployment/+page.svelteHeader 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; showDotsshowMarkers; yellow colour updated to #f59e0b
src/lib/components/DeploymentCard.svelteshowRunemarks 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.tsDeploymentIconType 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.jsonAdded 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.jsonSame changes in German
README.mdDeployment card description updated: "Overlays switch" → "Show Markers"; 8-way arrow picker flow replaced with two-tap measurement description
src/lib/i18n/README.mddeployment namespace description corrected from "Card back only" to "deployment card UI — marker icon names, zone preset names, cap type names"
src/routes/fighter/+page.sveltealt="Exported card"alt={t('ui.exported-card-alt')}
src/routes/text/+page.svelteSame alt text i18n
src/routes/card-back/+page.svelteSame alt text i18n
src/app.cssCSS 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.tsoCitiesOfSigmarAlt import; cities-of-sigmar-alternative entry after base cities-of-sigmar
src/lib/runemarks/svg/factions-order-cities-of-sigmar-alternative.svgNew 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
2026-03-25 Card-first UI & position picker Header toolbar & i18n Printer-friendly & export fix

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: printerFriendly prop on DeploymentCard; 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 printerFriendly is true, using SVG <text> with paint-order="stroke" and a white stroke-width="4" halo for legibility over any background; stored as PLAYER_BADGES constant 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, calls doExport('_print'), then resets flag; avoids any flicker in the live preview

Form & Caption Polish

  • Form field styles: DeploymentForm.svelte root wrapper changed from ad-hoc Tailwind classes to space-y-10 text-sm matching other forms; all labels migrated to scoped .field-label; all text/select inputs migrated to scoped .field-input; labels inside flex rows get style="margin-bottom:0"
  • Card name caption: .card-name div 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: 1 in 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: currentColor on *, suppressed by border-width: 0. dom-to-image-more treats zero/default values as "skip" when serialising computed styles — so border-width: 0 is absent in the cloned foreignObject, leaving border-style: solid active. Overlay elements with color: #000, left: 0; right: 0, and opacity < 1 (stacking context) produce a full-width black frame. Fix: explicit border: 0; outline: none; background: transparent on every transparent overlay element's own CSS rule
  • Additional CSS fixes on .card: changed display: inline-blockdisplay: block (inline-block creates a descender gap → bottom bar in export); added border: 0; outline: none to .card itself; SVG given inline style="display:block;border:none;outline:none;" for reliable serialisation
  • Code cleanup: removed stale .card svg CSS rule (redundant with inline SVG style and incorrect indentation); removed all capturing state scaffolding from both DeploymentCard.svelte and +page.svelte; added explicit { width, height, scale: 2 } to domtoimage.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: showPositionDots prop renders white dots at all 25 positions; tap empty = popover to add a point for the active player; tap occupied = popover to edit/remove. Large r=22 transparent hit target, visual dot r=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=20 pulls 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 both showPositionDots and onMeasurementClick — 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: none on <main>

Header Toolbar & i18n

  • Header structure: ← Back | card name | player circles + ✕ | Overlays [pill] | + Add Measurement | Save JSON[▾] | Export PNG[▾]; all items shrink-0 inside a single overflow-x-auto row
  • 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: 12px to escape overflow-x: auto clipping
  • 22 new i18n keys in ui namespace added to both en.json and de.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

Why B&W only for printer-friendly? No grey shades?
User's explicit requirement: printer-friendly must be strictly black and white. No #333 or other greys. All decoration (battlefield background, bubble colours) becomes either #000 or white.
How to differentiate players in printer-friendly without colour?
Unicode circled numbers ①②③④ rendered as SVG text above each bubble. Visible in B&W print without any colour. White stroke halo (paint-order="stroke"; stroke-width="4") ensures legibility over dark backgrounds. Only shown in printer-friendly mode — live preview stays clean.
Root cause of the dark export frame — what exactly went wrong?
Tailwind v4 preflight applies 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.
Why not fix via the .card * universal selector reset?
dom-to-image-more copies computed styles per element. The .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.
Why does display: inline-block on .card cause a bottom bar in export?
Inline formatting contexts add a descender gap below the last inline element. dom-to-image captures this gap as a white strip at the card bottom. Using display: block (matching FighterCard) eliminates it.
Why remove the sidebar entirely?
The card-first position picker made the sidebar largely redundant — the user now interacts directly with the card. Zones and measurements moved to popovers and the header. Having both the card and a 480 px sidebar competing for space on mobile made the layout untenable.
Why are JSON and Export PNG separate split buttons?
JSON is save/load (non-destructive, round-trips the data model). Export PNG is a finalised render. Grouping them under one "Export" dropdown was confusing — "Export" implies a deliverable, not a save state. Two clearly labelled split buttons make intent obvious at a glance.
How do dropdowns escape the overflow-x: auto clipping?
CSS spec: setting 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.
Why does the Overlays toggle also gate measurement handles?
"Overlays" is the collective name for all interactive chrome on the card. Measurement midpoint handles are equally distracting when previewing. A single toggle that hides everything is simpler and more predictable than separate controls per overlay type. Implemented by passing onMeasurementClick={undefined} when off — the card component already renders handles conditionally on that prop.
Why did the centre line gap get reverted?
Added 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

StatusItem
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

FileChange
src/lib/components/DeploymentCard.sveltePrinter-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.svelteMigrated to field-label/field-input scoped CSS; root wrapper to space-y-10 text-sm; name label hint added
src/routes/deployment/+page.svelteExplicit { 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.sveltePosition dots overlay; ALL_POSITIONS; direction picker on card; MEAS_INSET=20; caption left-aligned; DeploymentDirection import added; dead constants removed
src/routes/deployment/+page.svelteSidebar 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.json22 new deployment editor UI keys; reordered into 13 logical groups (134 keys total)
src/lib/i18n/locales/de.jsonSame 22 new keys; same reorder
src/lib/components/DeploymentForm.svelteDeleted — unused after sidebar removal
README.mdDeployment card description updated to reflect card-first UI
.github/ISSUE_TEMPLATE/bug_report.mdAdded "Deployment card editor" to editor checklist
.github/ISSUE_TEMPLATE/feature_request.mdAdded "Deployment card editor" to editor checklist
2026-03-24 Speech bubbles & form Caption & SVG normalisation Layout & i18n

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 exact atan2 to the nearest battlefield corner (~35°); general makeTailPoints(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 — added showCaption: boolean and imageCaption: string
  • Card visual — caption at position: absolute; bottom: 20px inside .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; viewBox unchanged; all 203 runemarks now share width="300" height="300"
  • Card Back dark mode: .swatch-black gained border-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

Why a speech bubble instead of standalone icon + separate RND label?
Unifies colour, icon, and round label into one visual unit; tail makes positional intent clear; avoids off-card overflow for outside positions.
Why move colour to the player level?
Each colour represents one player. Per-point colour pickers created accidental inconsistencies. Player-level colour makes the data model match the conceptual model and simplifies the form.
Corner tail: atan2 vs hardcoded 45°?
The bubble centre for OUT-CNR-TL is (63, 34) and the battlefield corner is (100, 60) — atan2 gives ~35°, not 45°. The general 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.
Dynamic bubble width: character approximation vs DOM measurement?
DOM measurement (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.
Outside position coordinates: why fixed values (34, 540, 63, 852)?
Derived once from BH/2 + margin and BW_ICON/2 + margin, hardcoded for clarity. Must be updated if bubble dimensions change.
Why scale the landscape mobile card to viewport height rather than width?
Scaling to width on a 390 px phone gives a factor of ~0.43× — the 915 px card becomes unreadably small at 396 px tall. Scaling to viewport height (~0.87×) produces a comfortably large card; the 915 px width exceeds the screen, so overflow-x-auto provides horizontal scrolling for the overflow.
Why is the export button a sibling of the scroll container rather than inside it?
Placing the export button inside an 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.
Why is the Text Card caption anchored to .card rather than .image-section?
The Fighter Card caption uses 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.
Why normalise all runemark SVGs to 300×300 now?
The three disparate sizes (32, 512, 1024 px) gave no visual benefit — display size is always set by CSS. Uniform 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
2026-03-23 Card back editor Deployment card scaffold Deployment icon rendering

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 routesrc/routes/card-back/+page.svelte; same two-panel layout (form left, card preview right) as fighter and text editors
  • CardBackData interface in types.ts: title, backgroundImage, imageOffsetX/Y, imageZoom, runemark, textColor, showFlippedName
  • Card visual — 574×915px; default texture (static/background.jpg); has-bg-image class 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 nameshowFlippedName flag (default true); works for name-only and name+runemark; name-overlay-text-only class widens gap to 220px when no runemark between the two names
  • Text colour — three swatches: white (#fff), black (#000), red (#c0392b); drives --card-text-color CSS 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 uses t('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: adjustMode now resets to false when background image is removed (was sticky, would re-activate on next upload)

Landing Page

  • Build-time date injectionLAST_UPDATED hardcoded string removed; Vite define injects __BUILD_DATE__ as the ISO date at build time; ambient declare const added to src/app.d.ts for 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-600 fill with text-zinc-300 hover:text-white for a clear hover effect, while the primary editors keep bg-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 both en.json and de.json; group labels added to the ui namespace
  • LocaleData interface in types.ts updated with the 5 new fields
  • Card back picker — 5 new sorted groups appended to allRunemarkOptions; new records spread into allRunemarkSvgs
  • SVG fill bug fixcard-decks-scales-of-talaxis.svg and deployment-shield.svg had fill="#000" on their <path> elements; stripped so paths inherit --card-text-color from 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 cardDeploymentCard.svelte renders an inline <svg width="915" height="574"> directly; parchment texture via CSS background-image on the wrapper .card div (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 ?raw from deployment-dagger.svg / deployment-hammer.svg / deployment-shield.svg and embedded as white content in a nested <svg>
  • Player colours — 2-player for now: red #c0272d, blue #1a3a6e; set via style="color: {color}" on <use>, picked up by fill="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 typedDeploymentCardData, DeploymentPoint, DeploymentMeasurement, DeploymentPosition, DeploymentIconType, DeploymentColor, DeploymentCapType, DeploymentDirection all in src/lib/types.ts
  • Measurement model defined — each point supports 0–2 DeploymentMeasurement entries: direction (up/down/left/right), label (e.g. 8"), startCap + endCap ('arrow' | 'tick' | 'none'); rendering not yet implemented

💡 Key Decisions

Why suppress the CSS background texture when a custom image is uploaded?
Without suppression, the parchment texture renders underneath the custom image — both images are composited into the export, increasing file size unnecessarily. The 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.
Why a custom searchable list instead of a native <select> for the runemark picker?
The runemark library has 200+ entries across many groups — a native select is unnavigable at that scale. The FactionSelect pattern (search input + scrollable button list + selection badge) already existed in the project and is proven on both desktop and mobile. Reusing it gave instant locale-reactive filtering and a consistent UX at minimal extra code.
Why --card-text-color CSS custom property rather than directly inlining colour per element?
The colour must apply to both the .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.
Why is the mirrored name on by default?
The symmetrical playing-card style was the original design intent — it looks deliberate and purposeful when both name and runemark are set. Defaulting it on means new users immediately see the "full" effect. The checkbox exists for users who prefer a single name (e.g. text-only backs or minimalist designs).
Why 220px gap for text-only (no runemark) mirrored name?
With a runemark the visual separation between the two names is roughly 56 + 280 + 56 = 392px. Without a runemark the 220px gap preserves a similarly generous breathing room so the card doesn't look cramped. The number is tunable without touching logic.
Why strip fill="#000" from SVG path elements rather than fixing it in CSS?
SVG inline presentation attributes on child elements (<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.
Why SVG (not CSS/HTML) for the deployment card?
The card content is fundamentally a 2D diagram: a rectangle, dashed lines, icons at arbitrary coordinates, and annotation lines with arrows. SVG is the right tool — coordinate-based placement is natural, and all elements (shapes, icons, text) compose cleanly without fighting CSS layout. The other card editors use CSS/HTML because their content is largely text-flow + image; deployment cards are diagram-first.
Why 17 snap positions (3×3 inside + 8 outside) rather than free-form placement?
Analysing 13 example deployment cards, all icons landed at one of these 17 logical positions — quadrant centres, edge midpoints, centre, and outer-margin slots. Free-form placement would require drag-to-position UI which is complex and fiddly on desktop + mobile. Snap positions cover every real-world card in the examples and keep the form UI simple (a position picker, not a coordinate editor).
Why import deployment SVGs via ?raw and embed as white inner content, rather than using the existing weapon SVGs or drawing from scratch?
Three deployment-specific SVGs already existed in the runemark library (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.
Measurement lines: arrows or tick marks at each end?
Both are needed — real Warcry deployment cards use both double-headed arrows (↔) and bracket-style tick marks at edges (|——|). The data model stores 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
2026-03-22 Card Elements & translations Table row contrast Points table restyle

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
  • DefaultsshowRunemarks: true, showActivation: true, showFlavorText: true; prerequisite and points table default false
  • 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: string added to TextCardData; card renders .prerequisite-box wrapping .body-text with parseMarkup()
  • Files renamed: AbilityCard.svelteTextCard.svelte, AbilityForm.svelteTextForm.svelte, route /ability/text
  • Type AbilityCardDataTextCardData; card type slug 'ability' preserved in preset labels

Table Row Contrast — Fighter Card

  • White washrgba(255, 255, 255, 0.25) on .stats-values, .weapons-box, and .damage-box
  • Alternating tintsrgba(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 dividersbox-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-box vertical padding/gap tuned to 0
  • Removed text-transform: uppercase from .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 #000 outer borders and rgba(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) gets rgba(90, 10, 20, 0.06) (regular row = 2nd child)
  • Printer-friendly — container and tint both cleared
  • Dead code removedpoints-row-bottom class and .points-value.is-empty rule

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-4 for extra breathing room above and below
  • LAST_UPDATED constant + locale-aware "Last updated" line via Intl.DateTimeFormat + $derived

Export Filename — Faction Prefix

  • makeSlug() in both /fighter and /text pages now collects grandAlliance, faction, and bladeborn (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 selectdetails.open = false after i18n.setLocale()
  • Close on outside tapdocument click listener registered via $effect with cleanup
  • Redundant .lang-add CSS rule removed (duplicate of .lang-option color)

💡 Key Decisions

Why does each checkbox collapse both the card element AND its form field?
The form mirrors the card — if an element is hidden on the card there's nothing to configure, so hiding the form field eliminates noise. The user navigates a shorter form and sees a direct correspondence between what's checked and what's visible on both sides.
Why is the points table independent of card type instead of only on Divine Blessing?
The points cost increases table is a game mechanic that could apply to any text card (abilities, traits, artefacts). Tying it to a specific card type label was an arbitrary coupling. An independent checkbox is simpler and more flexible.
Why not make weapons/stats toggleable on the fighter card?
Stats and weapons are the core data on a fighter card — there's no meaningful fighter card without them. Toggling decorative extras (subtitle, caption) makes sense; toggling structural data does not.
Why "Is Named Fighter" / "Is Monster" instead of "Named Fighter" / "Monster (Damage Table)"?
Checkbox labels that describe a state read better with a verb. "Show Runemarks" (imperative) and "Is Named Fighter" (state predicate) are both consistent with the toggle pattern. The old labels were noun phrases that didn't clearly signal they were boolean flags.
Why "framed" for the prerequisite hint rather than "bordered" or "outlined"?
"Framed" fits card game vocabulary better than UI-system terms. The German "umrahmt" translates naturally.
What happens when all Card Elements are checked and text is long on the text card?
The card is fixed-size (574×915px). Overflow clips silently — deliberate choice. The header height is fixed; there is no dynamic shrinking. Users are expected to adjust text length or toggle elements off if content doesn't fit.
Why use <details> for LangSwitch instead of keeping the native <select>?
The native select's 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; AbilityCardDataTextCardData; 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
2026-03-21 Divine Blessing type Locale-aware sorting Code quality pass

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 card presetSlugs
  • Points value fieldsregularPointsValue and elitePointsValue added to TextCardData; 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 sortingFactionSelect sorts alliances, factions, and subfactions; FighterForm and TextForm sort weapon types and fighter runemarks — all via localeCompare(b, i18n.code) in $derived
  • FactionSelect refactor — extracted sortAll() for the no-search path; eliminated redundant !lc guard branches inside the filter map
  • FactionSelect type fix — replaced FighterCardData import with a structural prop type { grandAlliance, faction, bladeborn }, making it correctly usable with both card types
  • Info file cleanup — stale cardLabel comment in types.ts; ability card label list in CLAUDE.md; march-20 locale sorting pending item marked Done

Points Table Empty State

  • Pre-filled defaults — initial state changed from undefined to '15' / '20'; type changed from number? to string? to match what text inputs actually bind
  • is-empty class — card shows (em dash) at 16px with class:is-empty when field is cleared, matching the fighter card weapon stat pattern (.weapon-val.is-empty)
  • Stable row heightline-height: 1.1 (relative) replaced with line-height: 25px (absolute) on .points-value; font-size changes in .is-empty no longer affect the row height
  • Form placeholders — inputs show 15 / 20 as 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 1px to border: 1px solid #5a0a14 + border-radius: 6.5px 6.5px 0 0 on the inner header row, matching the fighter table pattern; removed overflow: hidden
  • i18n additions — added points-col-wounds ("Fighter Wounds" / "Lebenspunkte des Kämpfers") and points-col-cost ("Points Cost Increase" / "Punktwertzuschlag"); shortened points-regular / points-elite to just "Regular" / "Elite"

💡 Key Decisions

Why change the points table border from box-shadow to border: 1px solid?
Initially used 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.
Why use absolute line-height: 25px on .points-value instead of keeping 1.1?
The .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.
Why $derived for sorted weapon/runemark lists rather than sorting once at module load?
The sort order depends on 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.
Why type FactionSelect's prop as a structural type instead of a union of the two card types?
The component only reads and writes three fields: 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
2026-03-20 – 21 i18n + polish Subfaction data Docs & QA

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 modulesrc/lib/i18n/index.svelte.ts with module-level $state locale, reactive t(key, vars?) with fallback to en.json, i18n.setLocale() + i18n.init() (localStorage persistence).
  • Auto-discoveryimport.meta.glob('./locales/*.json', { eager: true }) discovers locale files automatically; contributors only need to add a .json file, 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 handles text-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 to t('card.card-name-placeholder'); ui.title and ui.github removed 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.ts and both locale files. SVG optimized from editor source: stripped class, id, and style attributes; normalized viewBox to 0 0 origin with translate; added width/height="300" and <title>; removed all path ids and redundant fill. Import wired up as dBbThanateksTithe.
  • 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 docssrc/lib/i18n/README.md expanded with namespace table, TODO convention, RTL note, and what not to translate; README.md Translations section added; CONTRIBUTING.md lists translations as a contribution type; .github/ISSUE_TEMPLATE/new_language.md created.

i18n Polish (2026-03-21)

  • | line-break in card headers — all nine col-* column headers in FighterCard.svelte now render via split('|') + <br>, allowing long translated labels (e.g. "Lebens|punkte") to wrap within narrow cells. col-base-size and col-points migrated to the same consistent pattern.
  • | line-break in weapon names — weapon name span in FighterCard.svelte uses the same split pattern (e.g. "Reichweiten|waffe"). FighterForm weapon <select> refactored to an #each loop 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 only card.* and weapons.* support it. False claim that "TODO suffix is stripped in display" corrected: the t() function returns raw values as-is.
  • Pre-release QA passde.json typos fixed (double-O in "Ordnung", "Geingeres" → "Geringeres"); duplicate translation on cities-of-sigmar-dispossessed resolved; all templates, README, CONTRIBUTING, and issue templates reviewed and confirmed accurate.

💡 Key Decisions

Why call 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.
Why store weapon names and cardLabel as English slugs rather than translated strings?
Weapon names are also used as keys to look up SVG icons in 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.
Why text-transform: uppercase in CSS rather than uppercase strings in the locale files?
Locale files store natural case ("Base Size", "Bewegung") so translations read naturally and contributors don't need to worry about casing. CSS applies text-transform: uppercase to .label-col, .header-text, .dcol-wide, and the existing pill / card-label classes.
Why a sentinel __custom__ for the custom card type?
The select's 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.
Why encode line breaks as | in locale values rather than using CSS word-break or overflow-wrap?
CSS word-breaking splits at arbitrary points mid-syllable, which looks wrong in a small all-caps header cell. The | 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
2026-03-19 Bold/italic markup Favicon & PWA Pill styling

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() in TextCard.svelte renders 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 when printerFriendly=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 unnecessary text-center class (inputs already centered).
  • Battle Trait card type — added as a new option in the ability card type select (after Heroic Trait). cardLabel comment in types.ts updated 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 under paths.base = '/warcry-card-creator-2026' on GitHub Pages. Without this, SvelteKit's prerender throws a 404 error on /favicon.ico during 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 10px to 15px / 7px 13px on 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-break div (a width: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.md expanded.
  • GitHub templates reworked: .github/ISSUE_TEMPLATE/bug_report.md, feature_request.md, and new PULL_REQUEST_TEMPLATE.md.

💡 Key Decisions

Why {@html} for body text instead of a Svelte markdown component?
Body text is user-entered, single-field content with minimal formatting (bold/italic only). A full markdown parser would be overkill. The parseMarkup() regex is narrow and outputs only <strong>/<em> — no script injection surface.
Why requestAnimationFrame after wrapping the selection?
The textarea value is updated via Svelte's reactive binding, which may re-render the DOM before setSelectionRange fires. Deferring to the next frame ensures the cursor/selection is restored after the DOM settles.
Card type label (ABILITY / HEROIC TRAIT / BATTLE TRAIT / REACTION / …) — where does it sit?
Changed 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
2026-03-18 Light/dark theme Touch pan & pinch Mobile export fixed

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 FactionSelect converted to clickable buttons — clicking sets data.grandAlliance and clears data.faction/data.bladeborn. Card already renders only the GA runemark when faction is empty.
  • Selected state highlighted in red; selectionLabel shows the alliance name alone.

Light/Dark Theme

  • data-theme attribute on <html> controls the active theme. Defaults to system prefers-color-scheme; manual toggle saves to localStorage and overrides permanently.
  • FOUC prevention: inline <script> in app.html reads localStorage (or system pref) and sets data-theme before first paint.
  • :root defines dark-mode vars; [data-theme="light"] overrides with warm parchment palette. Tailwind class overrides in app.css remap zinc/white classes — no component class changes needed. Form components use var(--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-white keeps button text white on maroon.
  • Contrast audit: --ui-text-subtle darkened 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: none on the overlay suppresses browser scroll/zoom without needing preventDefault().
  • Position X/Y/Zoom sliders in FighterForm wrapped in hidden 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 uses var(--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-empty shows at 16px — matches weapon value class:is-empty from Mar 15.
  • Select centering on iOS: style="text-align-last: center" on Base Size + Weapon Type selects. iOS Safari ignores text-align: center on <select>; text-align-last is the correct property.
  • .field-input font-size set to 1rem unconditionally; @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 Safari foreignObject restriction 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 to domToPng.
  • iOS download — Web Share API: on HTTPS, navigator.share saves immediately to Photos — no overlay. On localhost (HTTP, no secure context), navigator.share is undefined and 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 overrides text-white to near-black.
  • isRealMobile: plain const via navigator.userAgent regex (/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 } in vite.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

Grand Alliance selection: new UI element or extend existing alliance headers?
Extend existing headers — convert 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.
Theme: Tailwind dark-mode class approach vs CSS custom properties?
CSS vars + [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.
System pref vs manual toggle — which wins?
Manual toggle wins and persists. First visit uses prefers-color-scheme; after a manual toggle the choice is saved to localStorage and sticks. Page does not listen for live OS theme changes.
Cookie/consent notice for localStorage?
Not required. Stores a single preference string, never leaves the device, not used for tracking. GDPR/ePrivacy cookie rules do not apply.
Where should the theme toggle live?
Landing page only. Setting the preference once and having it persist is sufficient — cluttering every editor page header is unnecessary.

Mobile Image Positioning

Sliders in preview tab vs touch-to-pan directly on the card?
Touch overlay. Sliders require looking away from the card while dragging. The overlay means image and interaction are the same object — direct manipulation. Sliders remain on desktop (in the form, side by side with the card).
How to suppress scroll without 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.
Drag sensitivity?
50% offset per full image-area dimension: 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.
Range input thumb: custom CSS vs native?
Custom. Native thumbs feel imprecise when fine-tuning. Explicit -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.

Why modern-screenshot over manual canvas redraw?
Tried first, succeeded immediately. The key insight: passing explicit { width: 574, height: 915 } to override the transform-affected bounding rect. Manual canvas redraw was the backup and was not needed.
Why keep dom-to-image-more on desktop?
It works reliably and produces correct output. No reason to change a working path. modern-screenshot is mobile-only to minimise risk.
PNG at 2× for mobile — no compression?
Print quality is the primary use case — 2× (1148×1830px) PNG is correct. JPEG/WebP considered for sharing, rejected.
Why does the overlay only appear on localhost?
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.
Does resetting printerFriendly before overlay dismissal cause a bug?
No. The capture completes and the data URL is stored in exportedImageUrl while printerFriendly is still true. The reset only affects the live card render beneath the overlay, which is irrelevant.

Mobile Form Polish

Why did the layout shift only affect characteristics inputs, not weapon inputs?
Weapon inputs already had 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.
Why not use inputmode="numeric"?
Characteristic fields accept non-numeric values (* 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
2026-03-17 Mobile layout Tab bar Tablet breakpoint

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 isRestricted block 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-800 fill on active tab, dark bg-zinc-950 on inactive — hidden on desktop (sm:hidden)
  • Layout: flex-col h-dvh on mobile stacks tab bar → form/preview panel; sm:flex-row restores side-by-side on desktop
  • h-dvh (dynamic viewport height) instead of h-screen — accounts for iOS Safari browser chrome correctly
  • overflow-hidden on wrapper + min-h-0 on <aside> — fixes flex scroll trap so the inner overflow-y-auto form 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 with transform-origin: top left; flex sees the correct visual size instead of the raw 915px layout box
  • cardScale uses 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-5 to grid-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 both FighterForm.svelte and TextForm.svelte — prevents iOS Safari from auto-zooming on focus
  • Dropdown click-outside: $effect attaches a deferred document click listener when showDropdown is 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) to lg: (1024px) — tablets in portrait now use the tabbed layout instead of a cramped 480px form + ~288px preview side-by-side. isMobile threshold updated to match. iOS input zoom media query updated to max-width: 1023px.

💡 Key Decisions

Tab bar vs scroll vs drawer for mobile layout?
Tabs. A scroll layout (form on top, card below) is awkward because the card is tall. A drawer adds complexity. Two equal-width tabs with a clear active state are the simplest and most native-feeling pattern.
Why explicit-dimension wrapper instead of adjusting transform-origin?
CSS 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.
Where should the export button live on mobile?
Preview tab, above the card. On mobile the typical flow is: fill form → switch to preview → export. Keeping export in the Edit header would require switching back just to export. The preview panel is the natural place to export from.
How to fix the dropdown on touch without a click-outside library?
A $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.
When should the side-by-side layout kick in — at 640px or 1024px?
1024px (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
2026-03-16 Hierarchy restructured Runemark audit Faction select built

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 GolemSlaves to Darkness: Iron Golem); Cities of Sigmar subfactions promoted to factions; several Death/Destruction factions similarly reparented
  • Ulfenkarn removed from the hierarchy entirely; dUlfenkarn import removed from hierarchy.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.svelte and TextForm.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

Where does Ulfenkarn sit in the hierarchy?
Originally added as a Soulblight Gravelords subfaction (Cursed City setting). Later removed entirely — too niche and not a competitive warband in the current meta.
Where does Grombrindal sit?
Top-level Order faction, not a Cities of Sigmar subfaction. Grombrindal is an independent character and warband, not tied to a specific city. Source file named bladeborn-grombrindal.svg; saved as factions-order-bladeborn-grombrindal.svg to match project naming.
How to restructure factions without losing SVG references?
Export hierarchy to plain TXT for human editing, then apply changes back to hierarchy.ts. All SVG imports reused — only labels and nesting changed. Clean, repeatable workflow for future audits.
Should the filterable faction select replace or supplement the cascading dropdowns?
Replace entirely. With parent-prefixed naming (e.g. Slaves to Darkness: Iron Golem) the three-step cascade adds no value — you'd still need to know the parent. A single searchable list is faster at any selection depth.
Filter: search box narrowing or always-visible list?
Always-visible grouped list with a filter input at the top. Full list visible by default; typing narrows in real-time. Alliance headers sticky. Selecting any item sets all three data fields at once.

📁 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
2026-03-15 Ability card built Font swap Fighter card polish

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 rows 6.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:fittext applied so long names shrink to fit
  • Monster damage auto-dash: when Range/Attacks/Strength all empty, Damage shows with class:is-empty styling
  • Damage table font 15px → 17px; form labels: "Damage Table", "Damage Points Allocated"
  • Code cleanup: removed unused has-chevrons/chevron-* classes; removed dead Weapon.icon field
  • Dead assets removed: duplicate SVGs in static/, unreferenced favicon.svg
  • Base size changed to <select> — 14 round (⌀ 20 → ⌀ 160) + 7 oval options, round-first ascending
  • Page <title> tags added to /fighter and /text routes

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-spacing removed 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

How is the runemark border implemented without a CSS border (which doesn't follow the mask shape)?
A wrapper div (.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.
Where do the SVG mask files live, and why in 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.
Why does the activation badge live inside the runemarks row?
Requested to behave like a runemark circle, placed at the end of the horizontal row.

📁 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
2026-03-14 Fighter runemarks GitHub Pages live Runemark library complete

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.svgfighters-warrior.svg, A–Z); fighterRunemarks export added to index.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$effect sorts 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-section to a new .image-inner wrapper 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 explicit color: #FAF6F3 to be visible against the dark header (previously invisible because the parent had color: transparent for background-clip: text).
  • Color pass — all #1a0408 occurrences replaced with #000; all fill: white replaced with fill: #FAF6F3 (warm off-white matching parchment).
  • Code cleanup$props() declaration moved before $effect() in FighterForm.svelte (was logically unsafe ordering).

Export fidelity fixes

  • White dot top-left.runemarks container always in DOM even when empty; added border: 0; outline: none; background: transparent to its CSS rule.
  • White frame around card.card * {} wildcard doesn't reset .card itself; added border: 0; outline: none directly to .card. Added same reset to #cardEl wrapper in +page.svelte.
  • White frame around model image.image-section and .image-inner and .model-img all needed per-element explicit resets.
  • Damage table horizontal white linesborder-top: 1px solid black on .damage-row is silently dropped by dom-to-image-more; replaced with box-shadow: inset 0 1px 0 0 #000.
  • Header runemark icons smaller in export.header-runemark span lacked reset; SVG had width: auto which behaves differently in SVG foreignObject; changed to explicit width: 28px; height: 28px.
  • Weapon runemark SVG fill — added fill: #000 to .weapon-runemark :global(svg) for export consistency.

Git & GitHub Pages

  • Git repo initializedgit init run in project root; .claude/ added to .gitignore (contains local Claude Code permission settings, not for version control).
  • adapter-static — swapped @sveltejs/adapter-auto for @sveltejs/adapter-static; svelte.config.js sets paths.base = '/warcry-card-creator-2026' when NODE_ENV === 'production'.
  • Prerenderingsrc/routes/+layout.ts added with export const prerender = true; all pages are fully static.
  • Base-path links — all internal hrefs updated to use base from $app/paths (/fighter{base}/fighter etc.) in +page.svelte, fighter/+page.svelte, and ability/+page.svelte.
  • static/.nojekyll — prevents GitHub Pages from ignoring the _app/ directory.
  • GitHub Actions deploy workflow.github/workflows/deploy.yml; triggers on push to main; builds with Node 22 + npm ci; uploads build/ via actions/upload-pages-artifact and deploys via actions/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-sm font 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, and weapons-pistol.svg.
  • New SVGs added — 20 new files added from the white/ set (white versions for use in the maroon runemark circles); weapons-pistol.svg from the black/ set (weapon icons are black).
  • All new factions & subfactions wired up — every svg: null entry in hierarchy.ts filled 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.ts and option to FighterForm.svelte weapon select (between Mace and Ranged Weapon).
  • File naming normalised — 13 bladeborn-*.svg files renamed to the project's factions-{alliance}-bladeborn-*.svg convention; factions-death-teratic_cohort.svg underscore → hyphen; all imports in hierarchy.ts updated.

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 = true on the card, waits a frame, captures PNG with _print suffix, 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: added border: 0; outline: none; background: transparent directly to a .label-col span CSS 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 uses flex: 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

Fighter runemark deduplication: disabled options, not hidden
Options that are already selected in another slot stay visible in the dropdown but are set to 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.
Hero/Monster always sorted to top
These are special "rank" runemarks in the game that conventionally appear first on cards. A priority comparator in the $effect sorts them to index 0 regardless of which select slot they were chosen in.
Pills mode: mask on .image-inner, pills on .image-section
The original mask was on .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.
Color #FAF6F3 for white fills/icons
Pure white on the parchment background felt harsh. #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
Per-row 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.
SVG icon width: auto is unreliable in export
SVG elements with 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).
GitHub Actions source, not a branch
The 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
Setting 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.
Never commit or push from Claude
User handles all git operations. Claude stages nothing and never runs git commit or git push.
Portrait tablet breakpoint: <1024px wide + height > width
The editor needs ~1000px+ width to be usable (480px form panel + 574px card). Portrait tablets (e.g. iPad 768px wide) can't fit this. Landscape tablets (~1024px) can. The condition checks both width and orientation rather than a fixed breakpoint alone.
One shared message across all restricted views
Keeps the UX consistent and avoids maintaining separate copy. The same sentence appears on phones, portrait tablets, and the index page note. Future mobile support mentioned as a possibility, not a promise.
White SVGs for runemark circles, black for weapon icons
Faction/subfaction runemarks appear in maroon circles and need to be white to be legible. Weapon type icons appear directly on the parchment and need to be black. The reference repo provides both white/ and black/ versions; new faction icons sourced from white/, pistol from black/.
Gobbapalooza and Brand's Oathbound share parent icons
No unique SVGs exist for either warband. Gobbapalooza uses the Gloomspite Gitz icon; Brand's Oathbound uses the Darkoath icon (not Darkoath Savagers). Confirmed by user.
Hexbane's Hunters belongs under Order of Azyr
Initially placed under Daughters of Khaine by mistake. User confirmed it belongs under Order of Azyr.
fighters-leader = fighters-hero; fighters-gargantuan = fighters-monster
The reference repo uses "leader" and "gargantuan" as the canonical names; the project uses "hero" and "monster". User confirmed these are equivalent — no new files needed.
HTML <table> is unsuitable for dom-to-image-more export
Attempted to use a semantic <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.
Printer-friendly toggle: CSS class on card, not separate component
A 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.
Exposed spans inside .label-col need their own CSS export reset
The inline <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
2026-03-13 Fighter card polish Runemark hierarchy Export fidelity improved

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 sectionstatic/image-mask-1-cropped.svg applied as mask-image on .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 renamedsrc/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-section uses flex: 1 1 0; parchment is flex: 0 0 auto so 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 textfittext Svelte action on all value cells; shrinks font in 0.5px steps on content change via requestAnimationFrame.
  • 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 name to Weapon interface; 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 ?raw import; 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/ to src/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×), #5a0a14 background, 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 as svg: 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.glob faction logic removed; re-exports from hierarchy.ts plus weapon/characteristic exports.

Card Layout & UX

  • Image repositioning — Position X / Y sliders via object-position; Zoom slider via transform: scale() anchored to position origin.
  • Gradient removed.img-fade overlay deleted.
  • Monster bottom spacing fixed — removed max-height: 257px cap; image now shrinks naturally so bottom padding is always consistent.
  • Viewport-fit layout — outer container is h-screen; card scales to fit via reactive cardScale derived from window.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 exportsmask-image: url('/...') in CSS is not resolved by dom-to-image-more; inlined the SVG mask as a UTF-8 data URL via Vite ?raw import (src/lib/image-mask.svg) and applied as an inline style attribute.
  • Fighter name frame eliminated — added explicit border: 0; outline: none; background: transparent directly to .fighter-name and .chevron CSS rules.
  • All table cell frames eliminated — added explicit border: 0; outline: none; background: transparent to every transparent cell class: .parchment, .stats-values, .stat-col, .stat-val, .weapon-row, .wcol, .weapon-val, .weapon-art-placeholder, .dcol, .dcol-wide.
  • overflow: hidden removed 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 applying border-radius: 14px 14px 0 0 to header rows and 0 0 14px 14px to last value rows.

💡 Key Decisions

SVG mask — the aha moment
Many approaches failed: clip-path polygon (too mathematical, can't approximate curves), separate overlay elements (positioning fights the flex layout), wrong SVGs. The correct approach: take 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.
Flexible image height — flex: 1 image, flex: 0 0 auto parchment
Instead of a fixed image height that fights the parchment section, the image grows/shrinks to fill remaining space. Fighter name length naturally drives the split.
fittext via Svelte action with update()
The action takes the cell value as a parameter so Svelte calls update() on every change. requestAnimationFrame defers the measurement until after the DOM update, avoiding stale layout reads.
background-clip: text for header labels
Shows the parchment texture through the dark maroon header text without pixel-perfect alignment logic — the texture bleeds through naturally.
Runemarks bundled via import.meta.glob + ?raw
SVGs inlined into the JS bundle at build time — no HTTP requests at runtime, and dom-to-image-more captures them reliably in PNG export.
Explicit hierarchy over import.meta.glob auto-loading
The old glob approach mixed factions and bladeborn in a flat dict, making faction→bladeborn dependency impossible. Replacing with an explicit 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
Several new factions/bladeborn (Gorechosen of Dromm, Helsmiths of Hashut, etc.) exist in the game but have no SVG file yet. Keeping them in the hierarchy with 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.
Alliance display names updated to canonical Warcry names
Changed from "Chaos / Death / Destruction / Order" to "Agents of Chaos / Bringers of Death / Harbingers of Destruction / Sentinels of Order" — matching the actual game terminology.
Runemark circles at card level, not inside image section
Image section has overflow: hidden; moving circles to card root ensures they're never clipped regardless of image height.
Monster bottom spacing — remove max-height cap
The original 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 sorted A→Z at every level
Grand Alliances, Factions, and Subfactions/Bladeborn are all sorted alphabetically ascending in hierarchy.ts so UI dropdowns appear in predictable order without any runtime sort logic.

Export fidelity — key learnings

SVG mask-image URLs are not resolved by dom-to-image-more
Relative 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
dom-to-image-more copies computed styles per-element as inline styles on the cloned DOM. It appears to skip "zero/default" values like 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
Any element with 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-autoadapter-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
2026-03-12 Fonts & texture SVG mask Monster form UX

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.ttf and OldrichiumITCStdLight.otf to static/fonts/ and declared them in app.css
  • Applied background.jpg parchment 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 CSS mask-image on .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.svelte by switching dom-to-image-more to a dynamic import() inside exportCard()
  • Removed redundant CSS: .weapon-art-col (duplicated .wcol), .damage-val (duplicated .dcol-stat), no-op border-right: none, default background: transparent declarations, border-radius: 0, gap: 0

Fighter Card Polish

  • Fighter name changed to align-items: center; justify-content: center for 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 isMonster is 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 ø 32 to 32 (no ø prefix)

💡 Key Decisions

background.jpg on .card, not .parchment
The SVG mask makes parts of .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.
Dynamic import for dom-to-image-more
The library references Node at module evaluation time, which crashes SvelteKit SSR. Moving to await import() inside the click handler defers loading to the browser.
mask-position: 0 -40px with mask-size: 100% 100%
The SVG organic path has bumps that extend ~40px into the image from the top edge. Shifting the mask up hides those bumps above the card boundary, giving a clean straight top while the organic bottom edge forms the torn-paper transition into the parchment.

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'
2026-03-11 Scaffold built Fighter card Card dimensions

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

src/ ├── lib/ │ ├── types.ts — all TS interfaces (FighterCardData, TextCardData, Weapon, …) │ ├── index.ts │ └── assets/ ├── routes/ │ ├── +layout.svelte │ ├── +page.svelte — landing page │ ├── fighter/ │ │ └── +page.svelte — stub │ └── ability/ │ └── +page.svelte — stub └── app.css — Tailwind import + @font-face (rodchenkoctt) static/ └── fonts/ — empty, awaiting font files Makefile — make dev/build/preview (Herd Node v22) CLAUDE.md

💡 Key Decisions

Stack: SvelteKit 2 + Vite + TypeScript + Tailwind CSS v4
Consistent with hobbylog project, reactive, no extra state lib needed.
CSS/HTML card rendering (not Canvas)
Svelte reactivity gives live preview for free; fonts and textures are easier in CSS; dom-to-image-more handles export at 2× for crisp PNGs.
Separate routes: /fighter and /text
Different enough in structure that sharing a route would add complexity for no benefit.
Placeholder backgrounds for now
#f5f0e8 parchment, #5a0a14 dark sections. Texture image to be swapped in later via CSS variable.
Monster toggle on fighter card
When checked, appends the damage bracket table below weapons.

🃏 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)