testsAndMisc/kcd2_dice_solver/src/index.css
Krzysztof kuhy Rudnicki 9d7751cebb
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
The solver only ran behind ./run.sh, had one media query in the whole
stylesheet, and kept the inventory in a 46vh scroll box that showed maybe a
third of the 43 dice. Setting it up on the PC did nothing for the phone.

Deployment
  dice.kuhy.duckdns.org, behind the existing gitea-caddy edge. DuckDNS already
  wildcards *.kuhy.duckdns.org (checked against 1.1.1.1 and 8.8.8.8), so no
  dashboard step. deploy_build.sh builds to dist.staging and rsyncs into dist/
  with --delete-after --delay-updates; plain --delete removes stale assets
  before writing the new ones, which 404s a client mid-load. A post-commit hook
  starts a MemorySwapMax=0 systemd user unit, so a rebuild can neither slow a
  commit nor thrash zram.

  Three traps found by driving the live URL in a browser rather than curling it:
  a site address of "127.0.0.1:8089" reads as a HOSTNAME, so Caddy enabled
  auto-HTTPS and started an HTTP->HTTPS redirect listener that bound *:80 next
  to the edge -- SO_REUSEPORT would then split inbound HTTP and intermittently
  break ACME for every domain here. The same address also made a Host matcher,
  so the edge's forwarded Host matched nothing and served an empty 200 while the
  local health check passed. And watching dist/'s own inode could never detect a
  detached bind mount: rsync into an existing directory never changes it. Each
  now has a guard that asserts positively rather than failing open.

Dice list
  All 43 on screen at every width, no inner scrollbar. Column count and size
  tier are chosen together from a container query on the panel -- not the
  viewport, which mispredicts by 2x at the point the columns split. Two
  invariants govern it: the list fits above the fold, and the name column stays
  wide enough to tell Painter's die B, G and R apart. The left column holds the
  grid alone, so it is sized to fill the height it owns: one --die-row value
  drives the type, buttons and bar strip, giving a 52px row and 44px buttons on
  a 1296px-tall screen and 20px rows on a phone.

  Three safe-design rules are broken inside .die-list deliberately: sub-16px
  body text, square icon buttons, sub-8px intra-cell gaps. 43 items with a name,
  a chart and a counter in one viewport is arithmetically incompatible with 16px.

Import / export
  A file, the clipboard, or a link carrying the inventory in the URL fragment.
  One validator behind all four entry points including localStorage; it keeps
  what it can and reports what it dropped, so a die added in a later game patch
  costs you that entry, not the inventory. A link never silently overwrites what
  is saved here -- it offers Keep or Discard, and only a link that actually
  carries dice is applied at all, because one encoding a count of zero parsed
  cleanly, blanked the screen, disabled Clear and left Keep writing the emptiness
  over the visitor's own loadout.

session_autopsy
  parse.py (551) and report.py (504) were over the repo's 500-line limit, so
  test_file_length has been failing on main since 5ffd035. Extracted TurnTracker
  and the two candidate-cell helpers. Report output is byte-identical on real
  data apart from its timestamp, checked against HEAD in a worktree.

Verified: 288 TS tests and 911 Python tests, 100% coverage both, lint and
pre-commit clean, 8 viewports measured on the live HTTPS site, solver confirmed
through the real module worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:01:21 +02:00

775 lines
17 KiB
CSS

:root {
--bg: #17150f;
--panel: #201d16;
--panel-2: #2a261d;
--line: #736750;
--text: #e8e0cf;
--muted: #9d947f;
--accent: #c8a24a;
--good: #7fb069;
--bad: #c96b5b;
--tier-tin: #8a8175;
--tier-silver: #d4cfc2;
--radius-sm: 3px;
--radius-md: 8px;
color-scheme: dark;
font-family: "Iosevka", "Cascadia Code", ui-monospace, "SF Mono", Menlo, monospace;
font-size: 16px;
background: var(--bg);
color: var(--text);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
/* Wide enough that a 2560px monitor is not 40% empty margin, but still capped:
past ~2000px the caveats column turns into unreadably long lines. The 1560px
floor of the old value mattered because the panel is 1.2/2.2 of the content
width and had to clear the 772px container query that unlocks the larger
type. */
.app {
max-width: 2000px;
margin: 0 auto;
padding: clamp(0.75rem, 3vw, 1.5rem);
}
header h1 {
margin: 0;
font-size: clamp(1.15rem, 4vw, 1.5rem);
color: var(--accent);
letter-spacing: -0.01em;
}
.subtitle {
margin: 0.25rem 0 1.25rem;
color: var(--muted);
}
.columns {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.side {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-width: 0;
}
@media (max-width: 1000px) {
.columns {
grid-template-columns: 1fr;
}
/* Stacked, the side column's own children become the grid items, so they can
be ordered independently: title and search first, then the dice, then the
results. Without this the whole right column would land either entirely
above the dice (burying them) or entirely below (hiding the search box). */
.side {
display: contents;
}
.controls {
order: -1;
}
section.inventory {
order: 0;
}
.results {
order: 1;
/* Sticky in a stacked layout pins the panel over the page and puts the
caveats below it out of reach. */
position: static;
}
}
/* The subtitle is the block that costs the most vertical space for the least
value on a phone. */
@media (max-width: 600px) {
.subtitle {
display: none;
}
header {
margin-bottom: 0.75rem;
}
}
section.inventory,
section.controls,
.result,
.caveats {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: clamp(0.75rem, 2vw, 1rem);
}
/* The dice grid sizes itself against THIS panel, not the viewport: at a 1000px
viewport the panel is ~918px wide, and one pixel later the .columns split
engages and it drops to ~472px. A viewport media query would be wrong by
about a factor of two exactly where it matters. */
section.inventory {
container-type: inline-size;
container-name: inv;
}
/* Not sticky any more: with the dice list alone on the left, the right column
is now the taller of the two, so there is nothing for it to stick against. */
.results {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
/* The two collapsible panels sit as siblings of .result, so they get the same
container treatment rather than looking like loose text between panels. */
.badges,
.io {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius-md);
padding: clamp(0.75rem, 2vw, 1rem);
}
/* --- toolbar ---------------------------------------------------------- */
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.search {
flex: 1 1 8rem;
min-width: 0;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
}
.search:focus {
outline: 2px solid var(--accent);
outline-offset: -1px;
}
.count {
color: var(--muted);
min-width: 4rem;
font-size: 0.8125rem;
text-align: right;
}
.clear,
.stepper-button {
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
cursor: pointer;
}
.clear {
padding: 0.4rem 0.7rem;
font-size: 0.8125rem;
}
/* Above phone width the toolbar has room to breathe again. */
@media (min-width: 601px) {
.search {
padding: 0.5rem 1rem;
font-size: 1rem;
}
.count {
min-width: 5rem;
font-size: 1rem;
}
.clear {
padding: 0.5rem 1rem;
font-size: 1rem;
}
}
.clear:disabled,
.stepper-button:disabled {
opacity: 0.35;
cursor: default;
}
.clear:not(:disabled):hover,
.stepper-button:not(:disabled):hover {
border-color: var(--accent);
}
section.controls header {
margin-bottom: 0.75rem;
}
section.controls h1 {
margin-bottom: 0.15rem;
}
.subtitle {
margin: 0 0 0.75rem;
}
/* Badges are a secondary concern next to the dice and, open, they are the
tallest thing on the page — so they stay shut until asked for. */
.badges > summary {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.badges[open] > summary {
margin-bottom: 0.5rem;
}
/* --- dice list -------------------------------------------------------- */
.die-list,
.badge-list,
.loadout,
.alternatives ul,
.caveats ul {
list-style: none;
margin: 0;
padding: 0;
}
/*
* All 43 dice on one screen, at every width, with no inner scrollbar.
*
* The column count is explicit (never `auto-fill`) because `auto-fill` cannot
* express "never fewer than two columns": below its track minimum it collapses
* to one column and 43 rows, roughly 1200px tall. A hard floor of two columns
* caps the row count at 22 at every width, which is what makes the total height
* bounded by construction rather than by luck.
*
* The baseline IS the compact tier. That looks backwards until you count the
* fixed cost per cell: comfortable spends 115px on the bar strip, the gaps and
* a three-button stepper; compact spends 51px. Below a cell width of ~256px
* that 64px difference buys more name characters than the larger font loses, so
* compact is the more readable tier on narrow layouts, not merely the smaller
* one.
*
* `minmax(0, 1fr)` rather than `1fr` so the ellipsised name can shrink below
* its min-content width instead of forcing the track wider.
*/
.die-list {
/* --die-row drives everything else: the type, the buttons and the bar strip
are all fractions of it, so one number scales the whole row. */
--die-row: 20px;
--die-font: 0.6875rem;
--die-bar-w: 2px;
--die-bar-h: 11px;
--die-btn: 16px;
--die-pad-x: 3px;
--die-gap: 2px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 2px;
}
/* The whole cell is already an add button, so "+" is the first thing to go
when horizontal space is scarce; dropping it reclaims 24px for the name. */
.die-list .stepper-add,
.die-list .die-tag {
display: none;
}
@container inv (min-width: 472px) {
.die-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container inv (min-width: 772px) {
.die-list {
--die-font: 0.8125rem;
--die-bar-w: 5px;
--die-bar-h: 14px;
--die-btn: 22px;
--die-pad-x: 4px;
--die-gap: 6px;
}
.die-list .stepper-add {
display: inline-flex;
}
.die-list .die-tag {
display: inline;
}
}
@container inv (min-width: 1140px) {
.die-list {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/*
* On a screen tall enough to hold all 22 rows of a two-column list, spend the
* space on wider cells rather than more columns. Three columns has to buy its
* shorter list with a narrower cell, which truncates the longer die names; two
* columns at the same container width fits every name in full AND affords the
* larger type. Must come last so it wins over the column rules above.
*
* 640px: 22 rows at a ~24px pitch is 526px, plus the panel and page padding.
* The threshold is this low only because the left column holds nothing but the
* grid — no title, no toolbar — so nearly the whole viewport height is its to
* spend.
*/
@media (min-height: 640px) {
@container inv (min-width: 514px) and (max-width: 1139px) {
.die-list {
--die-font: 0.8125rem;
--die-bar-w: 5px;
--die-bar-h: 14px;
--die-btn: 22px;
--die-row: 24px;
--die-pad-x: 4px;
--die-gap: 6px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.die-list .stepper-add {
display: inline-flex;
}
.die-list .die-tag {
display: inline;
}
}
}
/*
* Side by side, the dice column holds nothing but the grid — so give the grid
* the height instead of leaving it in a band at the top with 700px of dead
* space under it. 22 rows share whatever is left after the page and panel
* padding, which on a 1296px-tall screen is a 52px row and 20px type rather
* than a 24px row and 13px type.
*
* Deliberately NOT applied to the stacked layout: below 1001px the title and
* the search box sit above the grid, so the height is not the grid's to take —
* assuming otherwise overflowed the fold by ~100px at 768x1024 and 1000x800.
*
* The type caps at 20px while the row goes on to 56px: past that the names stop
* fitting the cell and start truncating, whereas a taller row is pure gain — a
* bigger click target, which is the point. The -/+ buttons reach 44px, the
* accessibility minimum this layout could not afford at phone width. 24px is
* the shortest row that still holds 13px text.
*/
@media (min-width: 1001px) and (min-height: 640px) {
@container inv (min-width: 514px) and (max-width: 1139px) {
.die-list {
--die-row: clamp(24px, calc((100dvh - 9rem) / 22), 56px);
--die-font: clamp(0.8125rem, calc(var(--die-row) * 0.4), 1.25rem);
--die-btn: clamp(22px, calc(var(--die-row) * 0.85), 44px);
--die-bar-h: clamp(14px, calc(var(--die-row) * 0.55), 28px);
--die-bar-w: clamp(5px, calc(var(--die-row) * 0.17), 10px);
--die-pad-x: 0.5rem;
--die-gap: 0.75rem;
}
}
}
/*
* No per-row divider and no border around the list. A hairline under each cell
* would sit right next to the 2px column gap, stacking two hard divides; and
* painting the gaps with a line colour would render the one-to-three empty
* trailing grid cells (43 never divides evenly into 2, 3 or 4) as solid blocks.
* Cells are transparent chips instead, and an owned one is marked by fill plus
* an accent edge.
*/
.die-row {
display: flex;
align-items: center;
gap: 0;
padding: 0;
border-radius: var(--radius-sm);
min-width: 0;
}
.die-row.owned {
background: var(--panel-2);
box-shadow: inset 2px 0 0 var(--accent);
}
.die-main {
flex: 1;
display: flex;
align-items: center;
gap: var(--die-gap);
background: none;
border: none;
color: inherit;
font-family: inherit;
font-size: var(--die-font);
text-align: left;
cursor: pointer;
padding: 2px var(--die-pad-x);
/* The row's height is set, not merely implied by its contents — that is what
lets the list be sized to fill the viewport. */
min-height: var(--die-row);
border-radius: var(--radius-sm);
min-width: 0;
}
.die-main:hover {
background: rgba(200, 162, 74, 0.1);
}
.die-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.die-name mark {
background: none;
color: var(--accent);
font-weight: 700;
}
.die-faces {
display: flex;
gap: 1px;
align-items: flex-end;
height: var(--die-bar-h);
flex: none;
}
.die-face {
width: var(--die-bar-w);
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
background: rgba(255, 255, 255, 0.04);
}
.die-face-bar {
display: block;
background: var(--accent);
opacity: 0.75;
max-height: 100%;
}
.die-tag {
font-size: 0.7rem;
color: var(--bg);
background: var(--accent);
border-radius: var(--radius-sm);
padding: 0 0.3rem;
flex: none;
}
/* --- stepper ---------------------------------------------------------- */
.stepper {
display: flex;
align-items: center;
gap: 1px;
flex: none;
}
.stepper-button {
padding: 0;
width: var(--die-btn, 22px);
height: var(--die-btn, 22px);
line-height: 1;
font-size: calc(var(--die-font, 0.8125rem) + 1px);
display: inline-flex;
align-items: center;
justify-content: center;
}
/* A readout, not a field — the number input it replaced is gone. */
.stepper-value {
min-width: calc(var(--die-btn, 22px) - 3px);
text-align: center;
font-size: var(--die-font, 0.8125rem);
color: var(--text);
background: none;
border: none;
padding: 0;
}
/* --- badges ----------------------------------------------------------- */
.tier {
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tier-tin {
color: var(--tier-tin);
}
.tier-silver {
color: var(--tier-silver);
}
.tier-gold {
color: var(--accent);
}
.badge-list label {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0;
cursor: pointer;
}
/* `column-width column-count` shorthand: at most three columns, but only as
many as fit a 14rem track — so it self-adjusts to one column on a phone
instead of always splitting in two. Multi-column is right here, unlike in the
dice grid, because the tier sections vary in height and have no height
budget to keep. */
.badge-picker {
columns: 14rem 3;
column-gap: 1.5rem;
}
.badge-picker section {
break-inside: avoid;
}
/* --- import / export -------------------------------------------------- */
.io > summary {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.io-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.5rem 0;
}
.io-file {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin: 0.5rem 0;
}
.io-file input {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text);
max-width: 100%;
}
.io-text {
display: block;
width: 100%;
padding: 0.4rem 0.6rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 0.8rem;
resize: vertical;
}
.io-status {
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--good);
}
.io-status.bad {
color: var(--bad);
}
/* Arrival banner: shown until a link-loaded inventory is kept or edited.
The .bad variant below reports what a link lost and stays for the visit. */
.notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0.6rem 0 0;
padding: 0.5rem 0.6rem;
background: var(--panel-2);
border-radius: var(--radius-sm);
box-shadow: inset 2px 0 0 var(--accent);
color: var(--muted);
font-size: 0.8rem;
}
/* Something arrived damaged or incomplete — same shape, warning accent. */
.notice.bad {
box-shadow: inset 2px 0 0 var(--bad);
}
/* --- results ---------------------------------------------------------- */
.result h2,
.caveats h3 {
margin: 1rem 0 0.5rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.result h2:first-child {
margin-top: 0;
}
.result.stale {
opacity: 0.55;
}
.loadout li {
display: flex;
gap: 0.6rem;
padding: 0.2rem 0;
}
.loadout-count {
color: var(--accent);
font-weight: 700;
min-width: 2rem;
}
/* 8rem, not 9: two 128px tiles plus the gap still fit a ~295px panel, so the
stats stay side by side on a phone rather than stacking into four rows. */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.5rem;
margin: 0.75rem 0;
}
.stats div {
background: var(--panel-2);
border-radius: var(--radius-sm);
padding: 0.5rem 0.6rem;
}
.stats dt {
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stats dd {
margin: 0.15rem 0 0;
font-size: clamp(1rem, 3.5vw, 1.25rem);
color: var(--good);
}
.stats small {
font-size: 0.7rem;
color: var(--muted);
}
.provenance,
.badge-reason,
.empty {
color: var(--muted);
font-size: 0.85rem;
}
.empty {
margin: 0;
padding: 0.75rem 0;
}
.alt-ev {
color: var(--accent);
display: inline-block;
min-width: 3.5rem;
}
/* Long die names must wrap rather than push the results panel wider than the
phone viewport. */
.loadout-name,
.caveats li {
overflow-wrap: anywhere;
}
.alternatives li,
.badge-result details li {
padding: 0.15rem 0;
font-size: 0.85rem;
}
summary {
cursor: pointer;
color: var(--muted);
font-size: 0.85rem;
padding: 0.35rem 0;
}
.badge-result {
border-top: 1px solid var(--line);
padding-top: 0.5rem;
}
.badge-pick {
margin: 0.2rem 0;
}
.badge-value {
color: var(--good);
}
.caveats {
font-size: 0.8rem;
color: var(--muted);
}
.caveats li {
padding: 0.2rem 0 0.2rem 0.9rem;
position: relative;
}
.caveats li::before {
content: "·";
position: absolute;
left: 0.2rem;
color: var(--accent);
}
.caveats strong {
color: var(--text);
}