diff --git a/docs/superpowers/contracts/kcd2-dice-solver-deploy-responsive-io-2026-07-26.json b/docs/superpowers/contracts/kcd2-dice-solver-deploy-responsive-io-2026-07-26.json new file mode 100644 index 00000000..6882ee64 --- /dev/null +++ b/docs/superpowers/contracts/kcd2-dice-solver-deploy-responsive-io-2026-07-26.json @@ -0,0 +1,25 @@ +{ + "title": "Serve the KCD2 dice solver at dice.kuhy.duckdns.org, fit all 43 dice on any screen, and move inventories between devices", + "objective": "The solver only ran behind ./run.sh, had a single media query in the whole stylesheet, and kept its 43-dice inventory in a 46vh scroll box. Make it reachable at a URL without a terminal, usable on a phone, able to show every die at once at any viewport, and able to carry an inventory between devices so the list is filled in once rather than per browser. Succeeds when the live HTTPS URL renders all 43 dice above the fold on the measured phone viewport and on a 2560px desktop, the solver returns a recommendation through the real module worker, and an inventory set up on one device can be reproduced on another via a file, the clipboard, or a link.", + "acceptance_criteria": [ + "https://dice.kuhy.duckdns.org/ returns 200 over HTTP/2 with a valid Let's Encrypt certificate, and the four pre-existing sites on the shared gitea-caddy edge still return 200", + "The serving container listens on 127.0.0.1:8089 only — never :80/:443 (which would contend with the edge via SO_REUSEPORT and break ACME) and never a wildcard address (which host networking would expose to the LAN); setup asserts this positively and refuses to finish otherwise", + "All 43 dice render with no inner scrollbar and the list ends above the fold at 375x667, 423x845 (the measured device), 768x1024, 1366x768, 1000x800, 1920x1080, 2560x1296 and 2560x1440", + "Painter's die B, G and R stay distinguishable — never truncated to a common prefix — at every one of those viewports", + "The dice grid fills the height it owns rather than banding at the top: row height, type and buttons scale together, reaching 44px touch targets on a tall desktop", + "An inventory round-trips through all three transports (JSON file, clipboard, URL fragment) and through localStorage, all via one validator that keeps valid entries and reports what it discarded", + "A shared link never silently overwrites a saved inventory: it offers Keep or Discard, is only applied when it actually carries dice or badges, and its fragment is cleared so a reload cannot resurrect it", + "Committing to main with changes under kcd2_dice_solver/ republishes the site, without slowing the commit or risking an OOM, and the publish detects a detached bind mount and re-attaches it", + "100% statement/branch/function/line coverage on the TypeScript suite and the Python suite, zero lint suppressions added, pre-commit clean", + "python_pkg/tests/test_file_length.py passes — parse.py and report.py brought under the 500-line limit with byte-identical report output on real data" + ], + "out_of_scope": [ + "The solver mathematics, the dice/badge data, and the scoring model — untouched", + "run.sh, which remains the development entry point", + "The repo colour palette: the app's existing :root tokens are reused and its divergence from the frozen unified-design-system tokens is left as a separate decision", + "Reaching 44px touch targets at phone width — arithmetically incompatible with 43 dice on one screen, and explicitly traded away", + "Rebuilding from the committed tree rather than the working tree; the working-tree hazard is documented instead", + "The LAN-exposed *:8088 listener on the older personal-website container, noted during review but a separate change" + ], + "verifier": "bash ~/.claude/scripts/finish.sh gate (pre-commit + full suites); pnpm run lint && pnpm exec vitest run --coverage (288 tests, 100%); python3 -m pytest python_pkg (911 tests, 100%); headless Chrome via CDP measuring 8 viewports and driving the solver on the live HTTPS URL; curl + openssl s_client for status and certificate; setup_kcd2_dice_solver.sh status; worktree diff of session_autopsy report output against HEAD" +} diff --git a/docs/superpowers/evidence/kcd2-dice-inventory-io-2026-07-26.json b/docs/superpowers/evidence/kcd2-dice-inventory-io-2026-07-26.json new file mode 100644 index 00000000..ace4b190 --- /dev/null +++ b/docs/superpowers/evidence/kcd2-dice-inventory-io-2026-07-26.json @@ -0,0 +1,69 @@ +{ + "intent": "Let a KCD2 dice-solver inventory move between devices — download/load a .json file, copy or paste the JSON, and share a link that carries the inventory in its URL fragment — and deploy the result at https://dice.kuhy.duckdns.org/.", + "scope": [ + "kcd2_dice_solver/src/lib/inventoryIo.ts — one validator plus the clipboard/save-file/url-hash ports", + "kcd2_dice_solver/src/components/InventoryIo.tsx — the collapsible import/export panel", + "kcd2_dice_solver/src/App.tsx — shared-link arrival, deferred persistence, dropped-payload notices", + "kcd2_dice_solver/deploy_build.sh — the single build path used by both setup and the post-commit hook", + "linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh — serves dist/ behind the shared gitea-caddy edge", + "Non-goal: no change to the solver core, the dice/badge data, or the scoring model" + ], + "changes": [ + "Moved inventory persistence out of App.tsx into src/lib/inventoryIo.ts, so localStorage, a pasted file, the clipboard and a shared link all funnel through one validator that sanitises rather than rejects (unknown ids, bad counts and duplicates are dropped and reported, not thrown away silently).", + "Added import/export UI: download .json, copy JSON, copy share link, load a file, and paste-to-import, with clipboard and download injected as ports because navigator.clipboard and URL.createObjectURL are absent under jsdom.", + "Shared links encode base64url of the JSON rather than a positional scheme, because DICE is sorted alphabetically and a die added in a later patch would silently redefine every previously-shared link.", + "A link-loaded inventory is NOT written to storage on arrival; persistence is deferred until the visitor presses Keep or edits anything, so a shared link cannot silently overwrite a saved loadout.", + "FIX (found in review, was a real defect): a damaged fragment such as #i=@@@ still parses to a non-null result holding an EMPTY inventory, so `fromLink?.inventory ?? loadInventory(store)` never fell through — the visitor's own saved dice vanished from the page with no explanation. Extracted isEmptyInventory/isUsable into inventoryIo.ts as the single definition of 'worth applying', used now by both the paste box and the link path; App.tsx applies a link only when usable and otherwise falls back to storage.", + "Surfaced ParseResult.dropped on the link path, which was previously computed and discarded: a partially-usable link now shows 'Ignored: Unknown die ...' and an unusable one shows 'That link carried nothing usable, so this is what is saved here.'", + "Added a .notice.bad style (warning accent, same shape as .notice) for the damaged/incomplete case.", + "deploy_build.sh builds into dist.staging/ and rsyncs into dist/, and asserts the dist/ inode is unchanged: the serving container bind-mounts dist/ at /srv, so any rm -rf + mv would silently detach the mount and serve stale files forever with no error." + ], + "verification": [ + { + "command": "pnpm exec vitest run --coverage", + "result": "pass", + "evidence": "15 files, 280 tests passed. Coverage 100%: statements 856/856, branches 373/373, functions 179/179, lines 804/804. Includes 2 new regression tests that fail against the pre-fix code." + }, + { + "command": "regression check — new tests fail before the fix", + "result": "pass", + "evidence": "Before the fix, 'keeps the saved inventory, and says so, when the link is damaged' failed with the rendered DOM showing '0 dice' plus the 'Loaded from a shared link' banner, proving the saved lucky:1 inventory was discarded. After the fix both new tests pass." + }, + { + "command": "pnpm exec tsc --noEmit; pnpm exec eslint src", + "result": "pass", + "evidence": "TSC=0 ESLINT=0 (exit codes asserted directly; an earlier ${PIPESTATUS[0]} check was a bash-ism under zsh and asserted nothing)." + }, + { + "command": "./deploy_build.sh", + "result": "pass", + "evidence": "Published 4 files to dist (inode 87041124, unchanged before and after rsync, so the container bind mount is still attached)." + }, + { + "command": "setup_kcd2_dice_solver.sh status", + "result": "pass", + "evidence": "All 13 checks OK: container running, listening only on 8089, Caddy snippet present, DNS resolves, external https OK, post-commit rebuild hook and unit installed." + }, + { + "command": "curl https://dice.kuhy.duckdns.org/", + "result": "pass", + "evidence": "HTTP 200 public and 200 on 127.0.0.1:8089; served bundle is assets/index-CjacUXQ5.js, byte-identical to the hash just built, confirming the live site serves this change and not a stale build." + }, + { + "command": "grep -hoP '^\\s*id:\\s*\"[^\"]*\"' src/data/*.ts | grep -P '[^\\x00-\\x7F]'", + "result": "pass", + "evidence": "No matches across all 77 dice/badge ids, so btoa() in toBase64Url cannot throw on current data." + } + ], + "risks": [ + "NOT verified in a real browser: headless chromium is blocked by this environment's permission layer, so the fragment round-trip was verified only in jsdom (with the real solver and real modules, not stubs) plus HTTP/bundle-hash checks against the live site. Browser-specific APIs on this path — btoa/atob, URLSearchParams, history.replaceState — are standard, but a human should open the share link once to confirm.", + "toBase64Url uses btoa, which throws on any character above U+00FF. All 77 current ids are ASCII and the validator only ever admits known ids, so this is unreachable today; a future die added with a non-ASCII id would white-screen the page at render, since shareUrl is computed during render.", + "A shared link is applied over the local inventory on arrival (by design — following a link is deliberate). It is not persisted until Keep or an edit, so a reload recovers the saved loadout.", + "The inner Caddyfile needs auto_https off and an explicit http:// scheme: a bare 127.0.0.1:8089 address reads as a hostname, so Caddy would serve TLS on 8089 and start an HTTP->HTTPS redirect listener on :80 that, under host networking, would contend with the shared edge and could break ACME for every domain on this host. This is encoded in setup_kcd2_dice_solver.sh, not only in the generated file." + ], + "rollback": [ + "git revert the commit, then run kcd2_dice_solver/deploy_build.sh to republish the previous bundle into the existing dist/ inode.", + "Validate: setup_kcd2_dice_solver.sh status reports all checks OK, and curl -o /dev/null -w '%{http_code}' https://dice.kuhy.duckdns.org/ returns 200.", + "Saved inventories survive a rollback: STORAGE_KEY is unchanged and the older loadInventory tolerates the same JSON shape." + ] +} diff --git a/docs/superpowers/evidence/kcd2-dice-solver-deploy-responsive-io-2026-07-26.json b/docs/superpowers/evidence/kcd2-dice-solver-deploy-responsive-io-2026-07-26.json new file mode 100644 index 00000000..a2866139 --- /dev/null +++ b/docs/superpowers/evidence/kcd2-dice-solver-deploy-responsive-io-2026-07-26.json @@ -0,0 +1,85 @@ +{ + "intent": "Make the KCD2 dice solver reachable without a terminal (live at https://dice.kuhy.duckdns.org/), usable on a phone, showing all 43 dice on one screen at every width, and able to move an inventory between devices via JSON file, clipboard, or share link.", + "scope": [ + "kcd2_dice_solver/src/index.css \u2014 container-query dice grid + full mobile responsiveness", + "kcd2_dice_solver/src/lib/inventoryIo.ts (new) \u2014 one validator behind localStorage, file, paste and link", + "kcd2_dice_solver/src/components/InventoryIo.tsx (new) \u2014 import/export panel", + "kcd2_dice_solver/src/App.tsx \u2014 share-link precedence, deferred persistence, Keep banner", + "kcd2_dice_solver/src/components/{DieRow,QuantityStepper,DiceList}.tsx \u2014 compact cell", + "kcd2_dice_solver/src/data/dice.ts \u2014 MAX_PER_DIE moved here so lib/ need not import a component", + "kcd2_dice_solver/deploy_build.sh (new) \u2014 inode-preserving publish", + "linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh (new) \u2014 deployment", + "Non-goal: changing the solver mathematics, the palette, or run.sh's role as the dev entry point" + ], + "changes": [ + "Dice list is a CSS grid whose column count and size tier are chosen jointly from a container query on section.inventory, with an explicit two-column floor so the row count is capped at 22 at every width. Removed max-height:46vh/overflow-y. All 43 dice now fit above the fold with no inner scrollbar at 375x667, 423x845, 768x1024, 1366x768, 1000x800 and 1920x1080.", + "Compact tier is the baseline, not a phone-only fallback: below a ~256px cell the comfortable tier's wider bar strip and third button cost 64px, which buys fewer name characters than the smaller font loses. A min-height:900px rule prefers two wide columns over three narrow ones when the screen can hold 22 rows.", + "Removed the type-a-number input from QuantityStepper (28px of a ~155px cell; max is 6) in favour of an readout; dropped the per-face numbered labels; rescaled the face bars x1.6 -> x3 for the shorter strip.", + "New src/lib/inventoryIo.ts holds STORAGE_KEY, SavedInventory, a sanitise-and-report parseInventory, base64url hash codec, and three injectable ports (clipboard, file download, URL) so the jsdom-hostile browser APIs stay testable without untestable optional chaining.", + "Share links never silently overwrite a saved inventory: persistence is deferred behind one boolean until Keep is pressed or anything is edited, and the fragment is cleared on mount.", + "deploy_build.sh builds into dist.staging and rsync --delete's into dist/, asserting the inode is unchanged \u2014 the serving container bind-mounts dist/ and a bind mount follows the inode.", + "setup_kcd2_dice_solver.sh deploys behind the existing gitea-caddy edge, installs a MemorySwapMax=0 systemd user unit plus a post-commit hook for auto-rebuild, and skips the nftables regeneration when 80/443 are already accepted.", + "Restructured the two-column layout on kuhy's feedback: the left column now holds the dice grid alone. The header, subtitle and search/count/Clear toolbar moved into a new section.controls on the right; BadgePicker is wrapped in a
collapsed by default; the 'scroll the wheel over its counter' hint was deleted outright. Stacked below 1000px the side column becomes display:contents so its children can be ordered independently \u2014 controls, then dice, then results. With the left column no longer carrying a header the two-wide-column rule's height threshold dropped from 900px to 640px, so a 1366x768 laptop now gets 13px type in two columns instead of 11px in three.", + "Sized the dice grid to FILL the height it owns rather than sit in a band at the top: --die-row is clamp(24px, (100dvh - 9rem)/22, 56px) and the type, buttons and bar strip are all fractions of it. A 2560x1296 screen goes from a 24px row / 13px type / 22px buttons using 526px of ~1300px, to a 52px row / 20px type / 44px buttons using 1194px. Type caps at 20px while the row runs to 56px \u2014 beyond that names truncate, whereas a taller row is pure click-target gain. .app max-width raised 1560px -> 2000px so a 2560px monitor is not 40% margin." + ], + "verification": [ + { + "command": "pnpm exec vitest run --coverage", + "result": "pass", + "evidence": "280 tests, 15 files. Statements 100% (856/856), Branches 100% (373/373), Functions 100% (179/179), Lines 100% (804/804). Exit 0 against the repo's fail_under=100 thresholds." + }, + { + "command": "pnpm run lint", + "result": "pass", + "evidence": "tsc --noEmit && eslint src (strictTypeChecked + stylisticTypeChecked) \u2014 no output, exit 0. No lint suppressions added." + }, + { + "command": "Headless Chrome 138 via CDP: measure layout at 6 viewports (dev server, production preview, and the live HTTPS site)", + "result": "pass", + "evidence": "Every viewport: 43 rows rendered, innerScroll=false, list ends above the fold, no horizontal page scroll, and Painter's die B/G/R never clipped. Phone 423x845 (measured from adb: 1080px / (density 408/160)) -> 2 cols, list 460px ending at 556px of 845." + }, + { + "command": "Headless Chrome: functional drive of the PRODUCTION bundle and then the live site", + "result": "pass", + "evidence": "Six clicks -> '6 dice'; solver (real module worker, which jsdom cannot host) returned loadout ['6x Weighted die'] EV 2,675. Share link round-trip: loads, Keep banner shown, fragment cleared, localStorage untouched until Keep, then written. Console clean." + }, + { + "command": "curl -sI https://dice.kuhy.duckdns.org/ + openssl s_client", + "result": "pass", + "evidence": "HTTP/2 200, Let's Encrypt cert CN=dice.kuhy.duckdns.org valid to 2026-10-24. kuhy/gitea/kuhy-sync all still 200. Listener is 127.0.0.1:8089 only; LAN IP 192.168.1.43:8089 refused." + }, + { + "command": "systemctl --user start kcd2-dice-build.service (auto-rebuild proof)", + "result": "pass", + "evidence": "Unit finished, 440.2M memory peak against MemoryMax=3G. dist inode 87041124 before and after. Live site then served the new asset hashes (index-CjacUXQ5.js, index-7hcb7ih-.css)." + }, + { + "command": "shellcheck deploy_build.sh setup_kcd2_dice_solver.sh", + "result": "pass", + "evidence": "Only informational SC1091 (external source), SC2015 (the repo's existing status-line idiom) and SC2317." + }, + { + "command": "Headless Chrome: re-measure all 6 viewports after the layout restructure, then re-drive the live site", + "result": "pass", + "evidence": "All viewports still fit with no inner scroll and no clipped Painter's names. 1366x768 improved from 3 cols/11px ending at 514px to 2 cols/13px ending at 567px; 1920x1080 list now ends at 567px (was 727px). Live site re-verified: 43 rows, solver EV 2,675, console clean. 283 tests, 100% coverage, lint clean." + }, + { + "command": "Headless Chrome: measure 8 viewports incl. 2560x1296 and 2560x1440, on dev then the live site", + "result": "pass", + "evidence": "All 8 fit with no inner scroll and zero clipped names. 2560x1296: font 20px, row 52px, buttons 44px, list 1194px, 61px slack. 2560x1440: row 56px, list 1274px. 1920x1080: font 17px, row 43px. Phone and stacked layouts unchanged. Live site re-verified: 43 rows, solver EV 2,675, console clean. 283 tests, 100% coverage, lint clean." + } + ], + "risks": [ + "Two bugs were found and fixed only because the live site was driven in a browser rather than curl'd: (1) a site address of '127.0.0.1:8089' made Caddy treat it as a hostname, enable automatic HTTPS and start an HTTP->HTTPS redirect listener that bound *:80 alongside the public edge \u2014 SO_REUSEPORT would then split inbound HTTP and intermittently break ACME for every domain on the host; (2) the same address created a Host matcher, so the edge's forwarded 'Host: dice.kuhy.duckdns.org' matched no route and served an empty 200 while the local health check passed. Both are now guarded: the health check sends the real Host header and requires a non-empty body, and setup asserts the container binds neither :80/:443 nor a wildcard address.", + "The auto-rebuild builds the working tree, not the commit, so an unrelated uncommitted edit present at rebuild time ships to the live site. Stated in the setup report and the README rather than fixed; a detached worktree would decouple them at the cost of a worktree to manage.", + ".git/hooks/post-commit is not version-controlled and is lost on a fresh clone. 'setup_kcd2_dice_solver.sh status' reports it and re-running setup restores it.", + "Sub-44px tap targets on phone (16px stepper buttons) are a deliberate, user-accepted trade for fitting 43 dice. Mitigated: the whole cell is an add target and '+' is hidden on narrow layouts because the cell supersedes it.", + "Three safe-design-rules are knowingly broken inside .die-list only: body text below 16px (11-13px names), square icon buttons rather than 2:1 padding, and sub-8px intra-cell gaps. 43 items with a name, a chart and a counter in one viewport is arithmetically incompatible with 16px.", + "The height scaling is scoped to @media (min-width: 1001px): applying it to the stacked layout overflowed the fold by ~100px at 768x1024 and 1000x800, because there the title and search box sit above the grid and the viewport height is not the grid's to claim. Caught by measurement, not by inspection." + ], + "rollback": [ + "App: git revert the commit; run.sh and the dev server are untouched, and the old localStorage key/shape is unchanged so saved inventories survive a revert.", + "Deployment: 'docker rm -f kcd2-dice && rm ~/gitea/sites/dice.caddy' then 'docker exec gitea-caddy caddy validate --config /etc/caddy/Caddyfile && docker exec gitea-caddy caddy reload --config /etc/caddy/Caddyfile'. Also 'rm ~/.config/systemd/user/kcd2-dice-build.service ~/testsAndMisc/.git/hooks/post-commit && systemctl --user daemon-reload'.", + "After rollback validate: the other three sites (kuhy, gitea.kuhy, kuhy-sync) still return 200, and 'sudo ss -ltnp | grep -E :(80|443)' shows only the gitea-caddy pid." + ] +} diff --git a/kcd2_dice_solver/.gitignore b/kcd2_dice_solver/.gitignore index cda7b285..8106c647 100644 --- a/kcd2_dice_solver/.gitignore +++ b/kcd2_dice_solver/.gitignore @@ -1,4 +1,5 @@ node_modules dist +dist.staging coverage *.tsbuildinfo diff --git a/kcd2_dice_solver/README.md b/kcd2_dice_solver/README.md index 92a98130..c5d3bdd1 100644 --- a/kcd2_dice_solver/README.md +++ b/kcd2_dice_solver/README.md @@ -5,9 +5,12 @@ _Kingdom Come: Deliverance II_. Enter what you own; the solver evaluates every loadout your inventory allows and reports the best one with the numbers behind it. +**Live at ** — no terminal needed, and usable on +a phone. See [Deployment](#deployment) for how it gets there. + ```bash pnpm install -pnpm dev # http://localhost:5174 +pnpm dev # http://localhost:5174 — for working on it pnpm test # full suite pnpm coverage # 100% statements / branches / functions / lines pnpm lint # tsc --noEmit + eslint (strictTypeChecked) @@ -25,6 +28,76 @@ Three ways, all wired to the same state: The inventory is saved to `localStorage`, so it survives a reload. +All 43 dice are on screen at once at every width, phone included — there is no +inner scroll box. The column count and the type size are picked together from a +container query on the inventory panel (not the viewport: at a 1000px viewport +the panel is ~918px wide and one pixel later it drops to ~472px). Two rules +govern the choice: the list must fit above the fold, and the name column must +stay wide enough to tell `Painter's die B`, `G` and `R` apart, which is the +tightest constraint in the layout. + +**The left column holds the dice grid and nothing else.** The title, the search +box and the badges all live on the right, where horizontal room is going spare, +rather than spending the vertical budget the grid needs. Badges are collapsed by +default — open, they are the tallest thing on the page. Stacked on a phone the +order becomes title/search, then the dice, then the results. + +Because the grid owns that column outright, it is sized to **fill** the height +rather than sit in a band at the top: the 22 rows divide up whatever the +viewport has left, so the row height, the type and the −/+ buttons all scale +together off one `--die-row` value. A 1296px-tall screen gets a 52px row, 20px +type and 44px buttons — the accessibility minimum this layout cannot afford at +phone width, where the same rows are 20px. The type stops growing at 20px while +the row keeps going to 56px: past that the names start truncating, whereas a +taller row is pure gain. The scaling is deliberately **not** applied to the +stacked layout, where the title and search box sit above the grid and the height +is not the grid's to take. + +## Moving an inventory between devices + +`Import / export`, below the badges, offers three routes — set your dice up once +and carry them anywhere: + +- **Download `.json`** and load it back with the file picker. +- **Copy JSON** to the clipboard, paste it into the box on the other device. + (If the clipboard is unavailable — an insecure origin, say — the payload is + put in the box so it can still be copied by hand.) +- **Copy link**: the inventory is base64url-encoded into the URL fragment. + Opening that link elsewhere loads it. A realistic inventory is a ~150–260 + character URL. + +Everything imported goes through one validator (`src/lib/inventoryIo.ts`), which +**keeps what it can and tells you what it dropped** rather than rejecting a +whole payload over one bad entry — a die added in a future game patch, or a link +a chat client truncated, should not cost you the rest of your inventory. + +A link never silently overwrites what is already saved here. It loads, shows a +banner, and is only written to `localStorage` once you press **Keep** or edit +something. The fragment is cleared on arrival so a later reload does not +resurrect it. + +## Deployment + +`linux_configuration/scripts/single_use/features/setup_kcd2_dice_solver.sh` +builds `dist/` and serves it read-only from a loopback-bound `caddy` container +on `127.0.0.1:8089`, fronted by the shared `gitea-caddy` edge. It is idempotent; +`setup_kcd2_dice_solver.sh status` self-diagnoses. + +Committing to `main` with changes under `kcd2_dice_solver/` republishes the site +via a `post-commit` hook that starts `kcd2-dice-build.service`. Two details that +are load-bearing rather than incidental: + +- `deploy_build.sh` builds into `dist.staging/` and `rsync --delete`s into + `dist/`. The container bind-mounts `dist/`, and a bind mount follows the + **inode** — replacing the directory would detach the mount silently, and a + plain `vite build` would empty it mid-request. The script asserts the inode is + unchanged. +- The build unit sets `MemorySwapMax=0`. This box has zram (swap held in RAM), + where a memory-capped cgroup without it thrashes instead of dying cleanly. + +The rebuild builds the **working tree**, not the commit — an unrelated +uncommitted edit present at rebuild time will ship. + ## How the recommendation is computed **Scoring** (`src/core/scoring.ts`) is the wiki's table: 100 per one, 50 per diff --git a/kcd2_dice_solver/deploy_build.sh b/kcd2_dice_solver/deploy_build.sh new file mode 100755 index 00000000..34dc05fb --- /dev/null +++ b/kcd2_dice_solver/deploy_build.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# +# deploy_build.sh — build the static bundle that dice.kuhy.duckdns.org serves. +# +# The single build path, used by both setup_kcd2_dice_solver.sh and the +# post-commit auto-rebuild, so the live site can never be produced two +# different ways. +# +# Why it does not just run `vite build`: +# +# The serving container bind-mounts dist/ at /srv. A bind mount follows the +# INODE, so anything that replaces the directory (rm -rf dist && mv new dist) +# silently detaches the mount until the container is restarted — the site then +# serves the old files forever with no error anywhere. And a plain `vite build` +# empties dist/ before writing, so the site 404s for the length of the build. +# +# Building into dist.staging/ and rsyncing into dist/ avoids both, but only +# with --delete-after --delay-updates: plain --delete removes stale files +# BEFORE writing the new ones, so a client that fetched index.html moments +# earlier can 404 on the very asset it is asking for. +# +# Preserving the inode is necessary but not sufficient, so the publish is +# followed by a check that the container is still mounted on this directory — +# see assert_mount_live for why that cannot be done by watching the inode here. +# +# Usage: +# deploy_build.sh Build and publish into dist/. + +set -euo pipefail + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +readonly SCRIPT_DIR +readonly STAGING="${SCRIPT_DIR}/dist.staging" +readonly LIVE="${SCRIPT_DIR}/dist" +# Must match setup_kcd2_dice_solver.sh's DICE_CONTAINER. +readonly CONTAINER="kcd2-dice" + +die() { + echo "deploy_build: $1" >&2 + exit 1 +} + +# nvm-installed toolchains are not on PATH for systemd/git-hook invocations. +ensure_toolchain() { + if [[ -s ${NVM_DIR:-$HOME/.nvm}/nvm.sh ]]; then + # shellcheck disable=SC1091 # sourced at runtime, path varies by machine + source "${NVM_DIR:-$HOME/.nvm}/nvm.sh" + fi + command -v node >/dev/null || die "node not found." + command -v pnpm >/dev/null || die "pnpm not found." + command -v rsync >/dev/null || die "rsync not found." +} + +# Confirm the serving container is still mounted on THIS directory. +# +# Watching dist/'s own inode across the rsync cannot detect a detached mount and +# never could: rsync into an existing directory never changes it, and if dist/ +# had already been removed (it is gitignored, so `git clean -xdff` removes it) +# the mkdir above recreates it at a fresh inode before anything is measured. +# Either way the check passes while the container serves the pre-deletion +# snapshot forever, with no error anywhere. The only thing that actually +# answers the question is what the CONTAINER sees. +assert_mount_live() { + local running host_inode container_inode + command -v docker >/dev/null 2>&1 || return 0 + running="$(docker ps --format '{{.Names}}' 2>/dev/null || true)" + grep -qx "$CONTAINER" <<<"$running" || return 0 + + host_inode="$(stat -c %i "$LIVE")" + container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)" + if [[ -z $container_inode ]]; then + echo "deploy_build: could not read /srv inode from ${CONTAINER}; skipping mount check." >&2 + return 0 + fi + if [[ $host_inode != "$container_inode" ]]; then + echo "deploy_build: dist/ was replaced — ${CONTAINER} is serving a detached copy." >&2 + echo "deploy_build: restarting ${CONTAINER} to re-attach the bind mount…" >&2 + docker restart "$CONTAINER" >/dev/null + container_inode="$(docker exec "$CONTAINER" stat -c %i /srv 2>/dev/null || true)" + [[ $host_inode == "$container_inode" ]] || + die "${CONTAINER} still not mounted on ${LIVE} after a restart." + echo "deploy_build: ${CONTAINER} re-attached." >&2 + fi +} + +main() { + ensure_toolchain + cd "$SCRIPT_DIR" + + echo "Installing dependencies…" + pnpm install --frozen-lockfile + + echo "Building into dist.staging…" + pnpm exec tsc -b + pnpm exec vite build --outDir dist.staging --emptyOutDir + + # Fail before touching the live directory, never after. + [[ -f "${STAGING}/index.html" ]] || die "build produced no index.html." + + mkdir -p "$LIVE" + rsync -a --delete-after --delay-updates "${STAGING}/" "${LIVE}/" + assert_mount_live + + echo "Published $(find "$LIVE" -type f | wc -l) files to ${LIVE} (inode $(stat -c %i "$LIVE"))." +} + +main "$@" diff --git a/kcd2_dice_solver/src/App.test.tsx b/kcd2_dice_solver/src/App.test.tsx index 3eaad234..e83cf059 100644 --- a/kcd2_dice_solver/src/App.test.tsx +++ b/kcd2_dice_solver/src/App.test.tsx @@ -10,8 +10,9 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; -import { App, STORAGE_KEY, loadInventory } from "./App.tsx"; -import type { SavedInventory } from "./App.tsx"; +import { App } from "./App.tsx"; +import { STORAGE_KEY, encodeInventoryHash, toBase64Url } from "./lib/inventoryIo.ts"; +import type { SavedInventory, UrlHashPort } from "./lib/inventoryIo.ts"; import { solve } from "./core/solve.ts"; import type { SolverPort } from "./hooks/useSolver.ts"; import type { WorkerReply, WorkerRequest } from "./worker/solver.worker.ts"; @@ -70,35 +71,34 @@ function readSaved(storage: Pick): SavedInventory { return JSON.parse(storage.getItem(STORAGE_KEY) ?? "{}") as SavedInventory; } -const renderApp = (initial?: string) => - render(); +/** + * A URL port that reports a fragment without touching the real location. + * + * @param hash - The fragment the page was "opened with". + * @returns The port plus a record of whether the app cleared the fragment. + */ +function stubUrl(hash = ""): UrlHashPort & { cleared: () => number } { + let clears = 0; + return { + read: () => hash, + clear: () => { + clears += 1; + }, + base: () => "https://dice.example/", + cleared: () => clears, + }; +} -describe("loadInventory", () => { - it("starts empty when nothing is saved", () => { - expect(loadInventory(memoryStorage())).toEqual({ diceCounts: {}, badgeIds: [] }); - }); - - it("restores a saved inventory", () => { - const saved = JSON.stringify({ diceCounts: { weighted: 3 }, badgeIds: ["tin_might"] }); - expect(loadInventory(memoryStorage(saved))).toEqual({ - diceCounts: { weighted: 3 }, - badgeIds: ["tin_might"], - }); - }); - - it("survives a corrupt entry rather than taking the page down with it", () => { - expect(loadInventory(memoryStorage("{not json"))).toEqual({ diceCounts: {}, badgeIds: [] }); - }); - - it("survives an entry of the wrong shape", () => { - expect(loadInventory(memoryStorage("42"))).toEqual({ diceCounts: {}, badgeIds: [] }); - expect(loadInventory(memoryStorage("null"))).toEqual({ diceCounts: {}, badgeIds: [] }); - expect(loadInventory(memoryStorage('{"diceCounts":"nope","badgeIds":"nope"}'))).toEqual({ - diceCounts: {}, - badgeIds: [], - }); - }); -}); +const renderApp = (initial?: string, urlHash: UrlHashPort = stubUrl()) => + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); describe("App", () => { it("asks for dice before anything is entered", () => { @@ -140,11 +140,11 @@ describe("App", () => { for (let i = 0; i < 6; i += 1) { fireEvent.wheel(stepper, { deltaY: -120 }); } - expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); expect(screen.getByText("6 dice")).toBeInTheDocument(); fireEvent.wheel(stepper, { deltaY: 120 }); - expect(screen.getByLabelText("How many Weighted die")).toHaveValue(5); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("5"); expect(screen.getByText("5 dice")).toBeInTheDocument(); }); @@ -162,7 +162,7 @@ describe("App", () => { fireEvent.click(add); } }); - expect(screen.getByLabelText("How many Weighted die")).toHaveValue(4); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("4"); const stepper = screen.getByLabelText("How many Weighted die").parentElement; if (!stepper) { @@ -173,7 +173,7 @@ describe("App", () => { fireEvent.wheel(stepper, { deltaY: -120 }); } }); - expect(screen.getByLabelText("How many Weighted die")).toHaveValue(6); + expect(screen.getByLabelText("How many Weighted die")).toHaveTextContent("6"); expect(screen.getByText("6 dice")).toBeInTheDocument(); }); @@ -296,3 +296,217 @@ describe("App", () => { }); }); }); + +describe("shared links", () => { + const shared = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] }); + + it("prefers a link over whatever this browser had saved", () => { + const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); + renderApp(mine, stubUrl(shared)); + expect(screen.getByText("3 dice")).toBeInTheDocument(); + }); + + it("drops the fragment so a reload does not resurrect the link", () => { + const url = stubUrl(shared); + renderApp(undefined, url); + expect(url.cleared()).toBe(1); + }); + + it("leaves the URL alone when there was no link", () => { + const url = stubUrl(); + renderApp(undefined, url); + expect(url.cleared()).toBe(0); + }); + + it("does not overwrite the saved inventory until the visitor commits", () => { + // Silently clobbering somebody's own loadout with a link they opened is + // unrecoverable, so nothing is written until they say so. + const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] })); + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + expect(screen.getByText("3 dice")).toBeInTheDocument(); + expect(readSaved(storage).diceCounts).toEqual({ lucky: 1 }); + }); + + it("persists once Keep is pressed", async () => { + const storage = memoryStorage(); + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Keep" })); + expect(readSaved(storage).diceCounts).toEqual({ weighted: 3 }); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + }); + + it("persists as soon as the visitor edits anything, banner and all", async () => { + const storage = memoryStorage(); + render( + Promise.resolve() }} + saveFile={() => undefined} + />, + ); + await userEvent.type(screen.getByLabelText("Search dice and badges"), "wei"); + await userEvent.click(screen.getByLabelText("Remove one Weighted die")); + expect(readSaved(storage).diceCounts).toEqual({ weighted: 2 }); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + }); + + it("shows no banner on an ordinary visit", () => { + renderApp(); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + }); + + it("falls through to storage when the fragment carries no inventory", () => { + const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); + renderApp(mine, stubUrl("#nothing=here")); + expect(screen.getByText("1 die")).toBeInTheDocument(); + }); + + it("keeps the saved inventory when the link is damaged", () => { + const mine = JSON.stringify({ diceCounts: { lucky: 1 }, badgeIds: [] }); + // "@" is not a base64 character, so decoding throws rather than yielding + // an inventory — the case a truncated or mangled URL actually produces. + renderApp(mine, stubUrl("#i=@@@")); + expect(screen.getByText("1 die")).toBeInTheDocument(); + // Nothing was taken from the link, so there is nothing to accept or refuse. + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Discard" })).not.toBeInTheDocument(); + }); + + it("reports what a shared link had to drop", () => { + const link = `#i=${toBase64Url( + JSON.stringify({ diceCounts: { weighted: 2, nosuchdie: 1 }, badgeIds: [] }), + )}`; + renderApp(undefined, stubUrl(link)); + expect(screen.getByText("2 dice")).toBeInTheDocument(); + expect(screen.getByText(/Unknown die/)).toBeInTheDocument(); + }); + + it("replaces the whole inventory on import", async () => { + const storage = memoryStorage(JSON.stringify({ diceCounts: { lucky: 2 }, badgeIds: [] })); + render( + , + ); + fireEvent.change(screen.getByLabelText("Inventory JSON"), { + target: { value: '{"diceCounts":{"weighted":4},"badgeIds":[]}' }, + }); + await userEvent.click(screen.getByRole("button", { name: "Import pasted" })); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + expect(readSaved(storage).diceCounts).toEqual({ weighted: 4 }); + }); +}); + +describe("layout", () => { + it("gives the left column to the dice and nothing else", () => { + renderApp(); + const inventory = document.querySelector("section.inventory"); + if (!inventory) { + throw new Error("no inventory section"); + } + // The search box, the title and the badges all moved to the right column so + // the 43-row grid gets the whole vertical budget. + expect(inventory.querySelector(".die-list")).toBeInTheDocument(); + expect(inventory.querySelector(".toolbar")).toBeNull(); + expect(inventory.querySelector("h1")).toBeNull(); + expect(inventory.querySelector(".badges")).toBeNull(); + }); + + it("keeps the badges collapsed until asked for", () => { + renderApp(); + const badges = document.querySelector("details.badges"); + expect(badges).toBeInTheDocument(); + expect(badges).not.toHaveAttribute("open"); + // Still reachable — collapsed, not removed. + expect(within(badges as HTMLElement).getByText("Badges")).toBeInTheDocument(); + }); + + it("no longer tells touch users to scroll a mouse wheel", () => { + renderApp(); + expect(screen.queryByText(/scroll the wheel/i)).not.toBeInTheDocument(); + }); +}); + +describe("shared links cannot destroy what is already saved", () => { + it("ignores a link that carries no dice or badges", () => { + // A count of zero parses cleanly, so "did the parser complain?" is the + // wrong question — this used to blank the inventory on screen, disable + // Clear, and leave Keep as the only enabled control, writing the emptiness + // over the visitor's own dice. + const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: [] }); + const empty = encodeInventoryHash({ diceCounts: { weighted: 0 }, badgeIds: [] }); + const storage = memoryStorage(mine); + render( + , + ); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Keep" })).not.toBeInTheDocument(); + expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); + }); + + it("restores the visitor's own inventory when a link is discarded", async () => { + const mine = JSON.stringify({ diceCounts: { lucky: 4 }, badgeIds: ["tin_might"] }); + const link = encodeInventoryHash({ diceCounts: { weighted: 3 }, badgeIds: [] }); + const storage = memoryStorage(mine); + render( + , + ); + expect(screen.getByText("3 dice")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Discard" })); + expect(screen.getByText("4 dice")).toBeInTheDocument(); + expect(readSaved(storage)).toEqual({ + diceCounts: { lucky: 4 }, + badgeIds: ["tin_might"], + }); + }); + + it("clears a fragment even when it decoded to nothing", () => { + // A fragment left in the bar is read again on the next reload, where it + // would land on top of whatever has been edited since. + for (const hash of ["#i=@@@", "#other=1", "#i="]) { + const url = stubUrl(hash); + renderApp(undefined, url); + expect(url.cleared()).toBe(1); + } + }); +}); + +describe("a saved inventory that no longer parses cleanly", () => { + const stale = JSON.stringify({ + diceCounts: { lucky: 4, future_patch_die: 6 }, + badgeIds: [], + }); + + it("is not silently truncated in storage on load", () => { + const storage = memoryStorage(stale); + render(); + // Still the original text: nothing was written over it on first paint. + expect(readSaved(storage).diceCounts).toEqual({ lucky: 4, future_patch_die: 6 }); + }); + + it("says what it could not read, and only commits once accepted", async () => { + const storage = memoryStorage(stale); + render(); + expect(screen.getByText(/Unknown die “future_patch_die”/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Keep" })); + expect(readSaved(storage).diceCounts).toEqual({ lucky: 4 }); + }); +}); diff --git a/kcd2_dice_solver/src/App.tsx b/kcd2_dice_solver/src/App.tsx index 10e7c032..99ce0321 100644 --- a/kcd2_dice_solver/src/App.tsx +++ b/kcd2_dice_solver/src/App.tsx @@ -9,56 +9,42 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { JSX } from "react"; import { BadgePicker } from "./components/BadgePicker.tsx"; import { DiceList } from "./components/DiceList.tsx"; +import { InventoryIo } from "./components/InventoryIo.tsx"; import { ResultPanel } from "./components/ResultPanel.tsx"; import type { CountUpdater } from "./components/QuantityStepper.tsx"; import { SET_SIZE } from "./core/search.ts"; import type { SolveRequest } from "./core/solve.ts"; import { useSolver } from "./hooks/useSolver.ts"; import type { SolverPort } from "./hooks/useSolver.ts"; - -/** localStorage key for the saved inventory. */ -export const STORAGE_KEY = "kcd2-dice-solver.inventory"; - -/** What gets persisted between visits. */ -export interface SavedInventory { - readonly diceCounts: Record; - readonly badgeIds: string[]; -} - -const EMPTY: SavedInventory = { diceCounts: {}, badgeIds: [] }; - -/** - * Read the saved inventory, tolerating anything unparsable. - * - * @param storage - Where to read from; injectable for tests. - * @returns The saved inventory, or an empty one. - */ -export function loadInventory(storage: Pick): SavedInventory { - const raw = storage.getItem(STORAGE_KEY); - if (raw === null) { - return EMPTY; - } - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null) { - return EMPTY; - } - const { diceCounts, badgeIds } = parsed as Partial; - return { - diceCounts: typeof diceCounts === "object" ? { ...diceCounts } : {}, - badgeIds: Array.isArray(badgeIds) ? [...badgeIds] : [], - }; - } catch { - // A corrupt entry should cost the saved inventory, not the whole page. - return EMPTY; - } -} +import { + EMPTY, + STORAGE_KEY, + browserClipboard, + browserSaveFile, + browserUrlHash, + decodeInventoryHash, + encodeInventoryHash, + isEmptyInventory, + loadInventory, +} from "./lib/inventoryIo.ts"; +import type { + ClipboardPort, + SavedInventory, + SaveFilePort, + UrlHashPort, +} from "./lib/inventoryIo.ts"; export interface AppProps { /** Overridden in tests to avoid depending on jsdom worker support. */ readonly createPort?: () => SolverPort; /** Overridden in tests to isolate persistence. */ readonly storage?: Pick; + /** Overridden in tests; jsdom has no navigator.clipboard. */ + readonly clipboard?: ClipboardPort; + /** Overridden in tests; jsdom has no URL.createObjectURL. */ + readonly saveFile?: SaveFilePort; + /** Overridden in tests to keep location/history changes from leaking. */ + readonly urlHash?: UrlHashPort; } /** @@ -67,16 +53,73 @@ export interface AppProps { * @param props - Optional injection points for tests. * @returns The app element. */ -export function App({ createPort, storage }: AppProps = {}): JSX.Element { +export function App({ + createPort, + storage, + clipboard = browserClipboard, + saveFile = browserSaveFile, + urlHash = browserUrlHash, +}: AppProps = {}): JSX.Element { const store = storage ?? window.localStorage; const [query, setQuery] = useState(""); - const [saved, setSaved] = useState(() => loadInventory(store)); + + // What this browser already had. Kept around so a shared link can be turned + // down without losing it. + const [stored] = useState(() => loadInventory(store)); + + // A link beats whatever is in this browser: following one is a deliberate act + // in a way that "opening the page again" is not. + const [fromLink] = useState(() => decodeInventoryHash(urlHash.read())); + + /* + * ...but only a link that actually carried dice or badges. Testing "did the + * parser complain?" is not the same question: a link encoding a count of zero + * parses perfectly and yields an EMPTY inventory, which would then replace the + * visitor's own dice on screen, disable Clear (nothing to clear), and leave + * Keep as the sole enabled control — writing that emptiness over their saved + * loadout. Requiring the link to actually carry something removes the case. + */ + const linkApplied = fromLink !== null && !isEmptyInventory(fromLink.inventory); + const [saved, setSaved] = useState(() => + linkApplied ? fromLink.inventory : stored.inventory, + ); + + // Whatever was discarded on the way in, from whichever source was used. The + // parser sanitises rather than rejects, so without surfacing this a die added + // in a later game patch — or a URL a chat client truncated — would vanish in + // complete silence. + const dropped = linkApplied ? fromLink.dropped : stored.dropped; + + /* + * Nothing is written to storage until the visitor accepts it. Silently + * overwriting a saved loadout — with somebody else's dice, or with a + * sanitised copy of their own that just lost an entry — is unrecoverable, and + * a confirm() dialog is both untestable and hostile. Persistence is deferred + * until they do something that implies consent: press Keep, or simply edit + * anything. One boolean, and the banner clears itself the moment they engage. + */ + const [persist, setPersist] = useState(!linkApplied && dropped.length === 0); useEffect(() => { - store.setItem(STORAGE_KEY, JSON.stringify(saved)); - }, [saved, store]); + if (persist) { + store.setItem(STORAGE_KEY, JSON.stringify(saved)); + } + }, [saved, store, persist]); + + useEffect(() => { + /* + * Clear ANY fragment, not just one that decoded. A fragment left in the bar + * is a loaded gun: it is read once, at mount, so a link arriving in an + * already-open tab is a same-document navigation that changes nothing now — + * but survives to be applied over edited state on the next reload. + */ + if (urlHash.read() !== "") { + urlHash.clear(); + } + }, [urlHash]); const setDieCount = useCallback((id: string, update: CountUpdater): void => { + setPersist(true); setSaved((previous) => { // Applied to the previous state, not to a value captured at render time, // so a burst of clicks or wheel ticks accumulates instead of collapsing. @@ -94,6 +137,7 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element { }, []); const toggleBadge = useCallback((id: string, owned: boolean): void => { + setPersist(true); setSaved((previous) => ({ ...previous, badgeIds: owned @@ -102,6 +146,11 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element { })); }, []); + const importInventory = useCallback((inventory: SavedInventory): void => { + setPersist(true); + setSaved(inventory); + }, []); + const total = Object.values(saved.diceCounts).reduce((sum, count) => sum + count, 0); const request = useMemo( @@ -117,55 +166,111 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element { return (
-
-

KCD2 Dice Solver

-

- Pick what you own; get the best six dice and a badge for each tier. -

-
- + {/* + * The left column is the dice list and nothing else. Everything that is + * not "which dice do I own" — the title, the search box, the badges, the + * results — lives on the right, where there is horizontal room going + * spare, rather than eating the vertical budget the 43-row grid needs. + */}
-
- { - setQuery(event.target.value); - }} - /> - - {total} {total === 1 ? "die" : "dice"} - - -
- -

- Click a die to add one, or scroll the wheel over its counter. -

- - -

Badges

-
-
- +
+
+
+

KCD2 Dice Solver

+

+ Pick what you own; get the best six dice and a badge for each tier. +

+
-
+
+ { + setQuery(event.target.value); + }} + /> + + {total} {total === 1 ? "die" : "dice"} + + +
+ + {!persist && ( +

+ {linkApplied + ? "Loaded from a shared link — not saved on this device yet." + : "Some saved entries could not be read — not saved over yet."}{" "} + {" "} + {/* Without this, turning down a link means pressing Clear — + which writes an empty inventory over dice the visitor never + even saw, with no undo. */} + {linkApplied && ( + + )} +

+ )} + + {dropped.length > 0 && ( +

+ {`Ignored: ${dropped.join(" ")}`} +

+ )} +
+ +
+ + + {/* Collapsed by default: badges matter far less than the dice, and + open they cost more vertical space than everything else here. */} +
+ Badges + +
+ + + +

What these numbers are

  • @@ -192,8 +297,9 @@ export function App({ createPort, storage }: AppProps = {}): JSX.Element { UNVERIFIED in the source.
-
-
+ +
+
); diff --git a/kcd2_dice_solver/src/components/DiceList.tsx b/kcd2_dice_solver/src/components/DiceList.tsx index 56505d9c..a4762c76 100644 --- a/kcd2_dice_solver/src/components/DiceList.tsx +++ b/kcd2_dice_solver/src/components/DiceList.tsx @@ -28,7 +28,9 @@ export function DiceList({ query, counts, onChange }: DiceListProps): JSX.Elemen } return ( -
    + // `role="list"` is not redundant: the list-style:none needed for the grid + // layout makes Safari/VoiceOver drop list semantics entirely. +
      {matches.map(({ item, indices }) => ( 0; + // One tooltip for the whole strip rather than one per bar. The per-face + // numbered labels that used to sit under the bars are gone: they cost a line + // of vertical space in every one of 43 rows, and the strip is now 11-14px + // tall, where a 0.55rem label would not have been legible anyway. + const facesTitle = die.weights + .map((weight, face) => `${face + 1}: ${(weight * 100).toFixed(1)}%`) + .join(" "); return (
    • 0 ? "die-row owned" : "die-row"}> + + + + + + +