diff --git a/app/assets/stylesheets/collector/additions.css b/app/assets/stylesheets/collector/additions.css index dadcf3be..ef9146af 100644 --- a/app/assets/stylesheets/collector/additions.css +++ b/app/assets/stylesheets/collector/additions.css @@ -471,10 +471,20 @@ body:has(.c-locations) .c-appbar__back { min-height:var(--touch-floor); min-widt /* ---------- DeckShape mana bar (spec 060): one bar split by colour, the only chart that may use the mana fills ---------- */ /* README Charts rule: every segment has its letter and count (the caption carries a segment under 10%, which the markup - marks narrow), and a 1px line-strong outline and divider, because the white and black fills vanish on the ground. */ + marks narrow, and one whose label does not fit (spec 078)), and a 1px line-strong outline and divider, because the white + and black fills vanish on the ground. */ .c-manabar { display:flex; height:32px; margin:0; padding:0; list-style:none; border:1px solid var(--line-strong); border-radius:var(--radius-sm); overflow:hidden; } -.c-manabar__seg { flex:var(--n) 1 0; min-width:0; display:flex; align-items:center; justify-content:center; background:var(--mana-c); color:var(--on-mana-c); +/* Spec 078 (#330): a label is drawn only if it fits its segment, on one line, whatever its length or the bar's width. The segment is a + wrapping flex container whose first item, the ::before, is zero wide and as high as the segment, so it always stays on the first line + and gives that line the segment's height (the label stays centred); the label does not shrink. The browser's line breaking then keeps + the label on that line only if it fits the segment's content width, and otherwise moves it to a second line below the segment, where + overflow:clip hides it (clip, not hidden, so the segment is no scroll container and find-in-page or scrollIntoView cannot scroll to + it). No number about the font: the rule follows the rendered label. The 10% narrow class below stays: a segment under 10% has no + label at all; the caption carries the count of a colour whose label is not drawn. */ +.c-manabar__seg { flex:var(--n) 1 0; min-width:0; display:flex; flex-wrap:wrap; align-content:flex-start; align-items:center; justify-content:center; overflow:clip; background:var(--mana-c); color:var(--on-mana-c); font:600 12px/16px var(--font-mono); } +.c-manabar__seg::before { content:""; height:100%; } +.c-manabar__seg > span { flex:none; } .c-manabar__seg + .c-manabar__seg { border-left:1px solid var(--line-strong); } .c-manabar__seg--w { background:var(--mana-w); color:var(--on-mana-w); } .c-manabar__seg--u { background:var(--mana-u); color:var(--on-mana-u); } diff --git a/docs/architecture/collection-and-lots.md b/docs/architecture/collection-and-lots.md index 6529e980..4bf84140 100644 --- a/docs/architecture/collection-and-lots.md +++ b/docs/architecture/collection-and-lots.md @@ -134,7 +134,7 @@ The Decks section is the second link of the header and of the tab bar, after Col The page's collectible part is two optional answers of the collecting vocabulary, after its first, `formats`: `deck_card(entry, format_key)` returns a `Catalog::DeckCards::Answer` (group, position, legality word, whether a `deck_cost` partial draws the cost); the contract is written in `Catalog::DeckCards`' comment, and a vocabulary that answers nothing gets one flat table, a muted dash and no marker (spec 066 decision 5, AC-6.1). Magic groups by the first face's type line: a Creature is a Creature, otherwise a Land is a Land, otherwise the first of Planeswalkers, Battles, Instants, Sorceries, Artifacts, Enchantments, then Other (decision 6, `MTG::DeckGroups`); the cost is the first face's (decision 11, `mtg/printings/_deck_cost`); the legality marker is the printing's stored legality in the deck's own format, nothing for Casual or an unregistered format, "Not legal" for a missing entry, written twice and shown once by width (decision 8, `MTG::Collecting::LEGALITY_FORMATS`). The third answer is `deck_shape(zones)` (spec 069 decision 1, #256), below. -The shape panel is the collecting vocabulary's third answer, after `formats` and `deck_card`: `deck_shape(zones)` takes `Deck::Page#zones` as the page built them and returns a `Catalog::DeckShape::Answer` (`partial`, `figures`, `scope`, `facts`) or nil, and `DecksController#show` asks once through `Catalog::DeckShape.answer`, one line after building the page; a vocabulary without it, a collectible type no longer registered and a nil answer draw no panel and leave the second column empty from 992px; the contract is written in `Catalog::DeckShape`'s comment, which names no collectible, format or zone (spec 069 decision 1, FR-1, AC-1.4; spec 060 AC-3.12). The core partial `decks/_shape` draws each copy's frame (the `aside`'s `section`, `h2` and scope line; the `details`' summary with the facts line and its `c-shape--bare` box) and renders the extension's partial inside, in the `details` copy, then the `aside` copy, before the zones; the figures are built once and rendered twice (decisions 1, 12, AC-1.1, AC-6.3). Magic's answer is `MTG::Collecting.deck_shape` over the figures object `MTG::DeckShape` and its partial `mtg/deck_shapes/_parts`. It counts the Commander and Main zones only (`MTG::DeckShape::COUNTED_ZONES`, in the deck's zone order; Sideboard and Considering never count, and the extension, not the core, names them), so the scope line reads "Main · 60 cards" or "Commander + Main · 100 cards" ("1 card"), a deck with neither zone has no panel, a deck of a format no longer registered keeps its Main zone's panel, and a deck with no card shows the panel's empty states (decisions 2, 10, 14, AC-1.5, AC-1.8, AC-2.8). A card is one copy or one proxy, so owned and proxy count together; a row of another collectible's card (page answer `Catalog::DeckCards::NONE`, no position) is skipped by every part, the scope line's number included (decision 3, AC-3.5). Card types: one type per card, the zone tables' own group (`row.answer.position`, `MTG::DeckGroups`) in the order of `MTG::DeckGroups::NAMES`, only the groups that have a card, each with its count and "N proxy" when it has proxies, so the counts add up to the scope line's number (decision 3, AC-3.1–AC-3.4). The curve counts nonland cards (not in Lands, so Dryad Arbor counts and an Artifact Land does not) whose card has a mana value, from `mtg_cards.mana_value` through the printing's identity (Scryfall's number, spec 058); column `n` from 0 to 5 holds the values from `n` up to but not including `n + 1` (floor, so 0.5 is in 0 and 2.5 in 2) and 6+ holds every value from 6, owned and proxy kept apart (decision 4, AC-2.1–AC-2.3, AC-2.6). The average is the exact mean of the curve's cards (rational arithmetic, so a mean of 0.15 shows 0.2), shown to one decimal rounded half up with the trailing zero kept ("3.0") and thousands delimited, and left out when no card is in the curve (decision 5, AC-2.4). A nonland card with no mana value is left out of the curve, the average and the legend, never counted as 0, and a second muted line under the average says "N nonland cards have no mana value yet." ("1 nonland card has no mana value yet."); the card is still in its type and the scope line (decision 6, AC-2.5). The mana bar counts coloured symbols of the first face's `mana_cost` on the loaded printing, the cost the zone tables show, never the joined `mtg_cards.mana_cost`, for every card in the counted zones, Lands included, each symbol times the card's count: `{W}`, `{U}`, `{B}`, `{R}` and `{G}` count one toward their colour; a hybrid (`{W/U}`) counts one toward each colour; a Phyrexian symbol (`{G/P}`) counts one toward its colour and a Phyrexian hybrid (`{G/W/P}`) one toward each; `{2/W}` and `{C/W}` count one toward the colour only; generic, `{X}`, `{C}`, snow and half symbols (`{HW}`) are never counted (decisions 7, 8, AC-4.1–AC-4.5). Each colour present shows its count and its share of all coloured symbols, each percentage rounded half up on its own so a caption may add to 99 or 101, in W, U, B, R, G order; a segment under 10% of the symbols (`count × 100 < total × 10`) is narrow and shows no label inside, its count staying in the caption; with no coloured symbol the part says "No coloured mana symbols." and an empty Card types part says "No cards yet." (decisions 9, 14, AC-4.6–AC-4.8). The visible number in a curve column's count and in a mana segment's label is abbreviated from 1,000 (`MTG::DeckShape#short_count`: 1,357 is "1.4k", 12,345 "12k", 1,250,000 "1.3m", half up on integers, at most four characters through 999m), because both sit in a slot of fixed width; every other number and every `aria-label` stays exact and delimited; the caption is a run of unbreakable items (`caption_items`, one per colour, so a line never starts with "·"); and the Card types list is a grid whose rows are subgrids, so the counts line up whatever the suffix (spec 077 decisions 1 to 5, #320; it supersedes, in part, spec 060 decision 2, AC-2.2, AC-2.4, AC-2.5 and AC-2.6 (the written-in-full numbers, the fixed 4.5rem minimum, the 279px) and spec 069 decision 14 for those two numbers). A mana segment narrower than its label (labels of four, five and six characters need about 14%, 17% and 20% of the bar at a 320px viewport; the 10% rule is spec 060 decision 4) is a known limit, [#330](https://github.com/plainprogrammer/Collector/issues/330) (spec 077 decision 2, ruled option A). **A split card's second half adds no symbol to the bar, and that is deliberate:** a card is read through its first face for its type, cost and symbols, as the zone tables read it, while its mana value is Scryfall's number for the card, both halves added, so a split card is in the curve column of that sum while its symbols are the first half's only (spec 069 Non-Goals, decision 7, AC-4.5). The panel costs one statement of its own, `MTG::Card` for the identity ids of the counted zones' printings, none when those zones hold no card, and reads everything else from the rows `Deck::Page` loaded, so a page's count is the same however large the deck is (decision 11, AC-6.1, AC-6.2); it stores nothing and the export is unchanged (decision 13). +The shape panel is the collecting vocabulary's third answer, after `formats` and `deck_card`: `deck_shape(zones)` takes `Deck::Page#zones` as the page built them and returns a `Catalog::DeckShape::Answer` (`partial`, `figures`, `scope`, `facts`) or nil, and `DecksController#show` asks once through `Catalog::DeckShape.answer`, one line after building the page; a vocabulary without it, a collectible type no longer registered and a nil answer draw no panel and leave the second column empty from 992px; the contract is written in `Catalog::DeckShape`'s comment, which names no collectible, format or zone (spec 069 decision 1, FR-1, AC-1.4; spec 060 AC-3.12). The core partial `decks/_shape` draws each copy's frame (the `aside`'s `section`, `h2` and scope line; the `details`' summary with the facts line and its `c-shape--bare` box) and renders the extension's partial inside, in the `details` copy, then the `aside` copy, before the zones; the figures are built once and rendered twice (decisions 1, 12, AC-1.1, AC-6.3). Magic's answer is `MTG::Collecting.deck_shape` over the figures object `MTG::DeckShape` and its partial `mtg/deck_shapes/_parts`. It counts the Commander and Main zones only (`MTG::DeckShape::COUNTED_ZONES`, in the deck's zone order; Sideboard and Considering never count, and the extension, not the core, names them), so the scope line reads "Main · 60 cards" or "Commander + Main · 100 cards" ("1 card"), a deck with neither zone has no panel, a deck of a format no longer registered keeps its Main zone's panel, and a deck with no card shows the panel's empty states (decisions 2, 10, 14, AC-1.5, AC-1.8, AC-2.8). A card is one copy or one proxy, so owned and proxy count together; a row of another collectible's card (page answer `Catalog::DeckCards::NONE`, no position) is skipped by every part, the scope line's number included (decision 3, AC-3.5). Card types: one type per card, the zone tables' own group (`row.answer.position`, `MTG::DeckGroups`) in the order of `MTG::DeckGroups::NAMES`, only the groups that have a card, each with its count and "N proxy" when it has proxies, so the counts add up to the scope line's number (decision 3, AC-3.1–AC-3.4). The curve counts nonland cards (not in Lands, so Dryad Arbor counts and an Artifact Land does not) whose card has a mana value, from `mtg_cards.mana_value` through the printing's identity (Scryfall's number, spec 058); column `n` from 0 to 5 holds the values from `n` up to but not including `n + 1` (floor, so 0.5 is in 0 and 2.5 in 2) and 6+ holds every value from 6, owned and proxy kept apart (decision 4, AC-2.1–AC-2.3, AC-2.6). The average is the exact mean of the curve's cards (rational arithmetic, so a mean of 0.15 shows 0.2), shown to one decimal rounded half up with the trailing zero kept ("3.0") and thousands delimited, and left out when no card is in the curve (decision 5, AC-2.4). A nonland card with no mana value is left out of the curve, the average and the legend, never counted as 0, and a second muted line under the average says "N nonland cards have no mana value yet." ("1 nonland card has no mana value yet."); the card is still in its type and the scope line (decision 6, AC-2.5). The mana bar counts coloured symbols of the first face's `mana_cost` on the loaded printing, the cost the zone tables show, never the joined `mtg_cards.mana_cost`, for every card in the counted zones, Lands included, each symbol times the card's count: `{W}`, `{U}`, `{B}`, `{R}` and `{G}` count one toward their colour; a hybrid (`{W/U}`) counts one toward each colour; a Phyrexian symbol (`{G/P}`) counts one toward its colour and a Phyrexian hybrid (`{G/W/P}`) one toward each; `{2/W}` and `{C/W}` count one toward the colour only; generic, `{X}`, `{C}`, snow and half symbols (`{HW}`) are never counted (decisions 7, 8, AC-4.1–AC-4.5). Each colour present shows its count and its share of all coloured symbols, each percentage rounded half up on its own so a caption may add to 99 or 101, in W, U, B, R, G order; a segment under 10% of the symbols (`count × 100 < total × 10`) is narrow and shows no label inside, its count staying in the caption; with no coloured symbol the part says "No coloured mana symbols." and an empty Card types part says "No cards yet." (decisions 9, 14, AC-4.6–AC-4.8). The visible number in a curve column's count and in a mana segment's label is abbreviated from 1,000 (`MTG::DeckShape#short_count`: 1,357 is "1.4k", 12,345 "12k", 1,250,000 "1.3m", half up on integers, at most four characters through 999m), because both sit in a slot of fixed width; every other number and every `aria-label` stays exact and delimited; the caption is a run of unbreakable items (`caption_items`, one per colour, so a line never starts with "·"); and the Card types list is a grid whose rows are subgrids, so the counts line up whatever the suffix (spec 077 decisions 1 to 5, #320; it supersedes, in part, spec 060 decision 2, AC-2.2, AC-2.4, AC-2.5 and AC-2.6 (the written-in-full numbers, the fixed 4.5rem minimum, the 279px) and spec 069 decision 14 for those two numbers). A mana label is drawn only if it fits its segment on one line (a segment under 10% never has one, spec 060 decision 4): the browser's own line breaking moves a label that cannot fit onto a second line that `overflow:clip` hides, and the caption carries that colour's count (spec 078, #330, which closes the limit spec 077 decision 2 had recorded; see `DeckShape.md`). **A split card's second half adds no symbol to the bar, and that is deliberate:** a card is read through its first face for its type, cost and symbols, as the zone tables read it, while its mana value is Scryfall's number for the card, both halves added, so a split card is in the curve column of that sum while its symbols are the first half's only (spec 069 Non-Goals, decision 7, AC-4.5). The panel costs one statement of its own, `MTG::Card` for the identity ids of the counted zones' printings, none when those zones hold no card, and reads everything else from the rows `Deck::Page` loaded, so a page's count is the same however large the deck is (decision 11, AC-6.1, AC-6.2); it stores nothing and the export is unchanged (decision 13). | From | Action | Result | |---|---|---| @@ -313,7 +313,7 @@ Measured bars, not constants (recorded in each spec's `verification.md` where on - The card page's "Your copies" shows its Location column, every row "No location", for an account with no location; whether it should hide as the grid and table do is open for the maintainer (spec 032 `verification.md`, Implementation review). - An unknown sort direction with a known column sorts that column ascending, not the default order spec 006 AC-3.7 names (`CollectionTable::Sort.parse`). - No sort by location (spec 037 Non-Goals); a move's merged lots stay merged when moved back (spec 041 decision 1); no partial lots in bulk (decision 2). -- [#50](https://github.com/plainprogrammer/Collector/issues/50) built the card page's Free to use and In decks (spec 073). [#49](https://github.com/plainprogrammer/Collector/issues/49) built the deck itself and [#252](https://github.com/plainprogrammer/Collector/issues/252) put copies into it: a copy reaches a zone from the lot form, Move copies, bulk Move to…, number entry and the scanner, and the filter finds a zone or a whole deck (spec 065). The deck page is built (#253, spec 066), with proxies (#254, spec 068), Add to deck from the card page (#255, spec 071), replacing a proxy with a copy (#257, spec 070: "Use my copy" takes only free copies, so a copy in another deck's zone or another zone of this deck is moved out first with Move copies) and its shape panel (#256, spec 069); a deck's format changes in the app since spec 064 ([#251](https://github.com/plainprogrammer/Collector/issues/251)), and the "Free to use" rule for Considering and the shape panel's counted zones are under "A deck" above (spec 062 Non-Goals, superseded by spec 064 for the format). Open on the panel: its mana labels in segments narrower than their label, [#330](https://github.com/plainprogrammer/Collector/issues/330) (spec 077 decision 2; the other limits of [#320](https://github.com/plainprogrammer/Collector/issues/320) are fixed by spec 077). +- [#50](https://github.com/plainprogrammer/Collector/issues/50) built the card page's Free to use and In decks (spec 073). [#49](https://github.com/plainprogrammer/Collector/issues/49) built the deck itself and [#252](https://github.com/plainprogrammer/Collector/issues/252) put copies into it: a copy reaches a zone from the lot form, Move copies, bulk Move to…, number entry and the scanner, and the filter finds a zone or a whole deck (spec 065). The deck page is built (#253, spec 066), with proxies (#254, spec 068), Add to deck from the card page (#255, spec 071), replacing a proxy with a copy (#257, spec 070: "Use my copy" takes only free copies, so a copy in another deck's zone or another zone of this deck is moved out first with Move copies) and its shape panel (#256, spec 069); a deck's format changes in the app since spec 064 ([#251](https://github.com/plainprogrammer/Collector/issues/251)), and the "Free to use" rule for Considering and the shape panel's counted zones are under "A deck" above (spec 062 Non-Goals, superseded by spec 064 for the format). The panel's limits: the mana-label band that spec 077 decision 2 left open ([#330](https://github.com/plainprogrammer/Collector/issues/330)) is closed by spec 078 and the other limits of [#320](https://github.com/plainprogrammer/Collector/issues/320) were fixed by spec 077; the one that remains is a Card types row with a five-digit count and suffix at a 320px viewport, whose name wraps (`DeckShape.md`). - The card page's `Add to deck` is hidden for a retired printing or a token, even for My copies (spec 071 decision 2). - `Deck#zones` is ordered by id, which is the format's order only while every zone was written with the deck: a zone the import's repair adds later, and now a zone a change of format adds (Commander to Modern lists Sideboard after Considering), gets a later id, so it shows last there. The deck page orders what it reads by the format's registered order (spec 066 decision 3, `Deck::Page`), so it reads Main, Sideboard, Considering; `Deck#zones` itself is unchanged (spec 062 verification, open items; spec 064 "Supersedes and updates"). - `POST /collection/selection` accepts any location id as a stored filter (spec 037 decision 7), so any id can be stored, a deck's own among them, although no page offers one (a zone's and `deck:` are values the filter offers since spec 065, a deck's own id is not); `spec/requests/bulk_zone_filter_spec.rb` pins that each bulk path then acts on the account's own lots in that zone, keeps a lot in its zone unless the collector moves it, and never unplaces one by accident (spec 062 decision 6). @@ -351,7 +351,8 @@ Measured bars, not constants (recorded in each spec's `verification.md` where on - [Spec 069](../specs/069-deck-shape-panel/spec.md) (issue [#256](https://github.com/plainprogrammer/Collector/issues/256)): all (the deck page's shape panel: the seam `Catalog::DeckShape` and Magic's answer `MTG::Collecting.deck_shape` over `MTG::DeckShape`, the counted zones, the types, the curve, the average, the missing mana value, the symbols, the percentages, the split-card limit and the statement count). It supersedes spec 066 AC-1.6's clause "it has no shape panel", AC-5.1's "the second column is empty" for a deck that draws a panel, and its Goals and Non-Goals lines that leave the right column empty (spec 066 is not edited; spec 060 AC-3.12 stands for a deck that draws none). - [Spec 070](../specs/070-replace-proxy-with-copy/spec.md) (issue [#257](https://github.com/plainprogrammer/Collector/issues/257)): all (`Lot.free`, `Lot::MoveIntoZone`, `Deck::Proxy::UseCopies` and `AddCopy`, `Deck::Proxy#lower_by!`, the two replacement pages and their controllers, the row menu's two new items, the notices and sentences); the pages' 404 rule is [tenancy and accounts](tenancy-and-accounts.md)'. It supersedes spec 068 decision 5 ("proxy items only"), AC-2.6, AC-2.7's absence of the two items, and in part decision 11 and AC-4.2 (the two replacement pages are writers that move or add a copy and lower the proxy), and records a departure from spec 006 AC-8.1 for lot ids in Use my copy's form. - [Spec 073](../specs/073-card-page-deck-stats/spec.md) (issue [#50](https://github.com/plainprogrammer/Collector/issues/50)): decisions 1 to 10, Stories 1 to 6 and FR-1 to FR-3 (the card page's stats and In decks section, the card as the scope, the reads, `Lot.free` as the one definition of free). It supersedes spec 004 FR-9's single stat, its Non-Goal and Out of Scope entries for "In decks" and "Free to use" and AC-8.1's order in part, and fulfils spec 071 AC-1.4 and the Non-Goals of specs 029, 032, 033, 057 and 070 that name #50 (spec 073 "Supersedes and updates"; no spec but 073 is edited). -- [Spec 077](../specs/077-deck-shape-extreme-counts/spec.md) (issues [#320](https://github.com/plainprogrammer/Collector/issues/320) and [#330](https://github.com/plainprogrammer/Collector/issues/330)): all (the deck shape panel at extreme counts: `MTG::DeckShape#short_count` and the abbreviated curve count and segment label, the Card types grid, the caption's unbreakable items, `caption_items`, the mana-label band as a documented limit, ruled option A). It supersedes in part spec 060 decision 2 and AC-2.2, AC-2.4 to AC-2.6 and spec 069 decision 14 and its Findings 1 to 4 (neither is edited). +- [Spec 077](../specs/077-deck-shape-extreme-counts/spec.md) (issues [#320](https://github.com/plainprogrammer/Collector/issues/320) and [#330](https://github.com/plainprogrammer/Collector/issues/330)): all (the deck shape panel at extreme counts: `MTG::DeckShape#short_count` and the abbreviated curve count and segment label, the Card types grid, the caption's unbreakable items, `caption_items`, the mana-label band as a documented limit, ruled option A (closed by spec 078)). It supersedes in part spec 060 decision 2 and AC-2.2, AC-2.4 to AC-2.6 and spec 069 decision 14 and its Findings 1 to 4 (neither is edited). +- [Spec 078](../specs/078-mana-bar-label-fit/spec.md) (issue [#330](https://github.com/plainprogrammer/Collector/issues/330)): the mana bar's fit rule (a label is drawn on one line inside its segment or clipped, `additions.css`). It supersedes in part spec 077 decision 2's documented limit, its Goal 1 qualification, Open Questions, Error Scenarios row, AC-5.1 and AC-5.3 and the 060 AC-2.4 exception (neither spec is edited). - [Spec 057](../specs/057-deck-design-rules/spec.md): decision 12 (the design system is the project's own, so the notes that said an override waits for a re-export are retired). - Cited for a neighbour's fact only: [spec 009](../specs/009-card-scanner-confirm-flow/spec.md) AC-8.2 (the collection page's "Scan cards"), [spec 023](../specs/023-signed-out-catalog-browsing/spec.md) FR-3 (a visitor holds nothing), [spec 028](../specs/028-apply-import/spec.md) decision 7 (an apply clears working state). - ADRs [0019](../adr/0019-lot-as-the-unit-of-ownership.md), [0025](../adr/0025-a-lots-location-is-part-of-its-identity.md), [0028](../adr/0028-decks-are-locations-with-zones.md); [0023](../adr/0023-locations-make-export-format-version-2.md) as far as locations and their names use it; [0017](../adr/0017-row-level-tenancy-one-user-per-account.md) and [0018](../adr/0018-collectible-agnostic-catalog-core.md) as far as lots use them; [0015](../adr/0015-admin-progress-by-polling.md) for number entry's refresh. @@ -385,3 +386,4 @@ Measured bars, not constants (recorded in each spec's `verification.md` where on | 2026-11-11 | Spec 071 (add to a deck from the card page, #255: the card page's head action, the Add to deck page and `Deck::Addition`, the free-copies reading `Lot::FreeCopies` over `Lot.free` and the take order, the "My copies" sentence, a proxy per finish, the all-or-nothing take through `Lot::MoveIntoZone`, the refusals and the result on the card page; the glossary's free copy, constants, decisions, the open-issues line and sources): scope, glossary, the card-page paragraph, constants, decisions and sources updated | [#325](https://github.com/plainprogrammer/Collector/pull/325) | | 2026-11-11 | Spec 073 (the card page's "Free to use" and "In decks", #50: the three-stat strip, the one definition of free read through `Lot::FreeCopies` with "In decks" as its complement, the "In decks" section from `Deck::CardUses` and its head count, the card as the scope with the Proxy column counting any printing, a visitor's overview reading nothing, the constant number of statements, the three scoped CSS rules and "Your copies"' place class; the Considering and card-page paragraphs, scope, sources and the open-issues line; supersedes spec 004 FR-9's single stat and fulfils spec 071 AC-1.4) | [#333](https://github.com/plainprogrammer/Collector/pull/333) | | 2026-11-11 | Spec 077 (the deck shape panel at extreme counts, #320: `MTG::DeckShape#short_count` and `#caption_items`, the curve column's count and the mana segment's label abbreviated from 1,000 and every other number exact, the Card types list a grid of subgrid rows, the caption's unbreakable items, the mana-label band recorded as a limit and offered as #330; the two `SHORT_COUNT_*` Named constants; the Issues bullet; specs 060 and 069 superseded in part) | [#331](https://github.com/plainprogrammer/Collector/pull/331) | +| 2026-11-12 | Spec 078 (a mana bar label fits its segment or isn't drawn, #330: the fit rule in `additions.css`, a wrapping flex segment with `overflow:clip` and a first-item `::before`, so the browser's line breaking clips a label that cannot fit; the 10% narrow class kept; the panel's paragraph, the Issues bullet and Sources; spec 077 decision 2's documented limit superseded in part) | spec 078's pull request | diff --git a/docs/design-system/components/DeckShape.md b/docs/design-system/components/DeckShape.md index ac1c03a8..8d3520a0 100644 --- a/docs/design-system/components/DeckShape.md +++ b/docs/design-system/components/DeckShape.md @@ -74,6 +74,8 @@ The `details` copy, shown below 640px of the page container, holds the same thre - One bar, 32px high, with a 1px `line-strong` outline and `radius-sm` corners, split into segments whose widths are proportional to their symbol counts (`--n`). Consecutive segments are divided by a 1px `line-strong` border. - Each segment fills with its `mana-*` token and its text is the matching `on-mana-*` token, the same in both themes. The segment's label is its letter and count ("U 44"), in mono. From 1,000 the count is abbreviated ("W 2.6k": six characters, about 43.5px on a Mac and 44.7px on Linux, which fits one line in the narrowest five-equal-segment bar at a 320px viewport); the caption and the `aria-label` keep the exact count (spec 077 decision 2). - A segment under 10% of the symbols adds `c-manabar__seg--narrow`, which hides its label: the server sets the class, with no JavaScript. The caption carries that segment's count. +- **A label that does not fit is not drawn** (spec 078 decision 1, #330). The server's 10% class is a floor; above it the browser decides from the label's rendered width. A segment is a wrapping flex container with `overflow:clip`; its first flex item is a zero-width `::before` as high as the segment, and the label does not shrink (`flex:none`). The label stays on the first line only if it fits the segment's content width (its box less the 1px divider); otherwise flex line breaking moves it to a second line below the segment, where `overflow:clip` hides it. So a label is on one line inside its segment or is not drawn, at any width of the bar and for any label length ("U 44", "W 999", "W 2.6k"), with no number about fonts in the rule, and a label exactly as wide as its content width is drawn. The caption and the bar's `aria-label` carry every colour's exact count, so nothing is lost; the caption carries the count of a colour whose label is not drawn as well as one under 10%. A clipped label stays in the DOM (the bar is `role="img"`): find-in-page may match it, and `overflow:clip` means nothing scrolls to show it. +- **Browsers:** the rule relies on flex line breaking (an item moves to a new line when the line's items exceed the container, and the first item of a line always stays) and on a percentage height in a stretched flex item (the `::before`'s `height:100%`). Safari 17.2, Chrome 120 and Firefox 121 and later are the floor (`allow_browser versions: :modern`) and all have it. The suite runs it in headless Firefox; a check on a phone (WebKit) is a manual item of spec 078's verification. A label within half a pixel of its content width may be drawn on one platform and clipped on another (a Mac and the Linux runner draw the mono face a pixel apart). - The caption (`c-manacap`) names every colour present with its count and its percentage: "Blue 44 · 73% · Red 16 · 27%". In a five-colour deck whose white segment is under 10%, that segment shows no label and the caption still gives white's count and percentage. The caption is a run of `c-manacap__item` spans, "Blue 44 · 73% ·" and, last, "Red 16 · 27%", separated by one space, each `white-space:nowrap`: a line breaks only between colours, so no line begins with "·" and a name never parts from its count; the paragraph's text is the same string as before (spec 077 decision 4; `MTG::DeckShape#caption_items`). - The bar is `role="img"` and its `aria-label` reads the counts ("Coloured mana symbols in Main: blue 44, red 16"). - With no coloured symbol the part has no bar and no caption and says "No coloured mana symbols." in a `c-manacap` paragraph (spec 069 decision 14). @@ -85,7 +87,7 @@ The `details` copy, shown below 640px of the page container, holds the same thre - The panel itself is `surface-raised` with a 1px `line` border, `radius-md` corners, `space-4` padding and `space-6` between its parts. The title is the sans family at 20px and weight 600; the scope line is mono and `ink-muted`. - Fills and outlines hold 3:1 on `surface`, `surface-raised` and `surface-sunken` in both themes (the tokens' contrast is guarded by spec 057 AC-2.3). -**Width.** The panel works in 238px, the space inside its border and padding at a 320px viewport (a phone's 320px less 16px body margin, 32px page padding, 2px border and 32px panel padding), and every count, the legend, the types and the mana labels fit with no horizontal overflow at the worst-case counts of spec 069's fixture (spec 077 AC-1.3, AC-2.2, AC-3.1). It was drawn for 279px (spec 060 AC-2.4); the measured interiors are 238px (a 320px viewport), 263px (a 360px browser frame, which keeps a 15px scrollbar a phone doesn't), 278px (a 360px viewport) and 286px (the 320px aside; beside the zones the panel is 320px wide, spec 060 decision 6). **Known limits:** (1) a mana segment narrower than its label: a label of four characters ("U 44") needs about 14% of the bar, five ("W 999") about 17% and six ("W 2.6k") about 20% at a 320px viewport (measured to the half percent on a 236px bar), and the rule hides a label only under 10% (spec 060 decision 4), so a segment between 10% and those shares wraps its label and cuts it off; the caption carries the count; it exists at ordinary counts too, and is [#330](https://github.com/plainprogrammer/Collector/issues/330) (spec 077 decision 2, ruled option A). (2) A Card types row with a five-digit count and suffix at a 320px viewport wraps its name, even mid-word; counts of six digits are not specified. +**Width.** The panel works in 238px, the space inside its border and padding at a 320px viewport (a phone's 320px less 16px body margin, 32px page padding, 2px border and 32px panel padding), and every count, the legend, the types and every drawn mana label fit with no horizontal overflow at the worst-case counts of spec 069's fixture (spec 077 AC-1.3, AC-2.2, AC-3.1). It was drawn for 279px (spec 060 AC-2.4); the measured interiors are 238px (a 320px viewport), 263px (a 360px browser frame, which keeps a 15px scrollbar a phone doesn't), 278px (a 360px viewport) and 286px (the 320px aside; beside the zones the panel is 320px wide, spec 060 decision 6). **Known limit:** a Card types row with a five-digit count and suffix at a 320px viewport wraps its name, even mid-word; counts of six digits are not specified. (A mana segment too narrow for its label no longer wraps it: the label is not drawn and the caption carries the count; spec 078, [#330](https://github.com/plainprogrammer/Collector/issues/330), which closed the limit spec 077 had recorded.) **The two copies** (spec 060 decision 7, AC-3.4). A closed `details` hides its content whatever the CSS says, and `::details-content` would put a browser floor under the page, so the deck page renders the panel twice and each copy hides the other with `display: none`. diff --git a/docs/lessons/2026-11-12-probe-a-candidate-before-asking-for-a-ruling.md b/docs/lessons/2026-11-12-probe-a-candidate-before-asking-for-a-ruling.md new file mode 100644 index 00000000..6fd79f80 --- /dev/null +++ b/docs/lessons/2026-11-12-probe-a-candidate-before-asking-for-a-ruling.md @@ -0,0 +1,26 @@ +--- +date: 2026-11-12 +spec: 078 (#330) +tags: [design-system, css, process, specs] +--- + +# Lesson: when an issue lists options with costs, probe the cheapest missing candidate before asking for the ruling + +## Context + +Issue #330 (a mana bar label wider than its segment wraps and is cut off) offered three options: A, leave it as the documented limit; B, a server-side rule by label length; C, a container query with the label's length as a class. B and C both hard-coded font metrics or added a class per length, and the maintainer's ruling was needed because B superseded a ruled rule (the 10% narrow class). + +## What happened + +- Before writing to the coordinator, a throwaway system spec (a deck per label length, 4, 5 and 6 characters, at a 320px viewport, a 360px viewport and the 1280px aside) measured the labels. It showed the real cause: a label wraps at its own space ("W" over "12"), and a label is 29.2px, 36.4px or 43.6px wide, so whether it fits depends on the bar's width as well as the label's length. +- The same probe tried a fourth candidate that the issue did not list: make each segment a wrapping flex container with a zero-width first item, so the browser's own line breaking moves a label that cannot fit onto a second line that `overflow:clip` hides. Every label that fit stayed drawn (including a 12% "W 12" on a 360px phone), every label that did not fit left its segment, and none wrapped or was cut off. +- The question to the coordinator then carried the measurement and a fifth option with its trade-offs, and the maintainer ruled it (D1) in one round trip, keeping the 10% rule. The spec's review rounds spent their effort on the examples' tolerance (the integer `clientWidth` would have failed the Linux runner), not on whether the approach worked. + +## What to do next time + +When an issue lists options whose costs are about hard-coded numbers or extra markup, spend a few minutes on a throwaway probe (an uncommitted spec that builds the decks and measures the candidate in the real browser, as the screenshots method does) for an option the issue does not list, before asking for the ruling. Put the measurement in the question. A probe that finds nothing better costs the same few minutes and still turns the options' costs into numbers. + +## Signals to watch for + +- Options in an issue that all trade one literal (a pixel threshold, a font width) for another. +- A "documented limit" option recorded because the fix looked server-side or JavaScript-only; check whether the browser's own layout can make the decision (line breaking, `overflow`, container size) before accepting that. diff --git a/docs/specs/078-mana-bar-label-fit/plan.md b/docs/specs/078-mana-bar-label-fit/plan.md new file mode 100644 index 00000000..30cc2149 --- /dev/null +++ b/docs/specs/078-mana-bar-label-fit/plan.md @@ -0,0 +1,262 @@ +# Plan 078: A Mana Bar Label Fits Its Segment or Isn't Drawn + +**Spec:** [spec.md](spec.md) 0.2.1 (approved, Gate A at 0.2.1; D1 ruled by the maintainer) | **Issue:** [#330](https://github.com/plainprogrammer/Collector/issues/330) | **Branch:** `078-mana-bar-label-fit` + +## Global Constraints + +- Test first, one commit per task. Targeted `bin/rspec ` runs need no CI slot; a full `bin/ci`, or a push whose pre-push hook runs it, needs a slot from the coordinator. +- Never run two test runs at once in this worktree (they share the test database). The untracked `spec/system/zz_probe_label_spec.rb` is the supervisor's throwaway probe: don't run, edit or commit it, and never `git add -A` or `git add spec` (stage by path). +- The app diff is exactly `app/assets/stylesheets/collector/additions.css`, inside the DeckShape mana bar block (AC-3.1): no view, model or script, no new class (a pseudo-element is not a class), no colour, font family or spacing literal. Nothing else in `additions.css` is touched, reordered or reformatted (another worker shares the file). `spec/design_system_files_spec.rb` counts the strings "spec 019", "spec 021" and "spec 023" across the whole file; "spec 078" in a comment is fine. +- Commit trailer: the `Co-Authored-By` line the session's attribution reminder gives, as a second message: `git commit -m "" -m "Co-Authored-By: …"` (earlier commits of this branch carry a different one; leave them). One git operation per shell call: stage with `git add ` in one call and `git commit` in the next (the project's pre-commit hook reads a command that contains `git add` and would run RuboCop on every untracked Ruby file, the probe included). +- Off limits: `.kamal/secrets`, `config/master.key`, `.env*`, `storage/`, `*.sqlite3`; every `git grep` ends with `':!.kamal/secrets'`. +- Run `bin/rubocop -a` on each Ruby file written (the project's hook does too); array literals use the omakase style `[ a, b ]`. +- Geometry examples size every viewport frame from the measured scrollbar (`open_fold_in_frame` does) and read `documentElement.clientWidth`, never `innerWidth` (lessons of 2026-11-11). Label examples compare the **fractional** content width with a 0.5px band (spec decision 3), never the integer `clientWidth`, and never name a per-label result that differs by platform (a label is 43.56px on a Mac and about 44.70px on the Linux runner). +- Every geometry example has a failing variant in the same file (lesson `an-example-that-cannot-fail-proves-nothing`): the old rule injected into the page, expecting the predicate to be false. +- Do not edit `spec/support/narrow_frame.rb` (the #318 worker is changing it to compare with `clientWidth`; if it lands on `main` first, merge `main`). + +## Goal + +A mana bar label is on one line inside its segment or is not drawn, at any width of the bar and for any label length: `.c-manabar__seg` becomes a wrapping flex container with `overflow:clip`, a zero-width `::before` the segment's height is its first item and the label does not shrink, so the browser's line breaking moves a label that cannot fit onto a clipped second line (spec decision 1, FR-1). The 10% narrow class stays. The component document, the area doc and the index say so (Story 2), with before and after screenshots. + +## Pre-implementation gates + +- **Simplicity:** one stylesheet block changes (about eight declarations in three rules). No component, class, dependency or abstraction. Pass. +- **Anti-abstraction:** no helper, no Ruby, no JavaScript; the spec's decision 1 rejects the server rule, the container query and the script. Pass. +- **Integration-first:** the contract is what the browser draws (AC-1.1 to AC-1.3), written as system examples that fail first in Task 1, before any CSS. Pass. + +## The existing examples this changes (from a grep, not from memory) + +Command (run 2026-11-12 from the repo root): `git grep -n -i -E "c-manabar|manacap|labels_fit|narrow" -- spec docs/design-system docs/architecture app ':!.kamal/secrets'`, read hit by hit. + +| Hit | Disposition | +|---|---| +| `spec/system/deck_shape_panel_spec.rb:155` (the paint example reads `aside .c-manabar__seg` colours), `:250` (`measure`'s `labels`: the visible spans), `:315` (AC-2.2's variant) | kept unchanged; they stay green after the CSS (AC-3.2): the five equal segments of the 077 fixture all fit (46.2px of content against a 43.56px label on a Mac, 44.70px on Linux) so none is clipped | +| `spec/requests/deck_shape_panel_spec.rb:62-91, 118, 193-218` (the bar's `aria-label`, segment texts and classes, the caption) | kept unchanged: no markup changes (AC-1.6) | +| `spec/system/design_system_deck_components_spec.rb:99-111, 437-468` (builds its own `c-manabar` markup at 288px: "U 44" and "R 16"; measures the bar's box, outline, divider and segment paints) | kept unchanged; run after the CSS (Task 2): both labels still fit, and the segments' widths do not change (`min-width:0` is already set, `overflow:clip` and `flex-wrap` leave a flex item's width alone) | +| `docs/design-system/components/DeckShape.md:26, 72-77, 88` (the markup example, the mana bar paragraph, the Width paragraph's Known limit (1)) | the paragraph gains the fit rule and the Width paragraph's limit (1) is replaced (Task 3) | +| `docs/design-system/previews/DeckShape.html`, `DeckPage.html`, `DeckPagePhone.html` (draw the real stylesheet; `c-manabar__seg--narrow` examples) | read for a statement of the limit or the rule; no markup changes expected (Task 3, AC-2.2) | +| `docs/architecture/collection-and-lots.md:137, 316, 354` | extended or edited (Task 4) | +| `docs/specs/**` (060, 069, 077 and their `verification.md`) | history, not edited | + +## File structure + +| File | Responsibility | +|---|---| +| `app/assets/stylesheets/collector/additions.css` | the mana bar block: the fit rule (three rules, one comment) | +| `spec/system/deck_shape_panel_spec.rb` | a nested `describe "mana label fit (spec 078, #330)"` inside the "extreme counts" describe (it reuses `at_width`, `inject_css`, `set_theme`, `widths`, `card`, `create_lot_in_zone`), with AC-1.1 to AC-1.5 and their failing variants | +| `docs/design-system/components/DeckShape.md` (and the README and previews if they say otherwise) | AC-2.1, AC-2.2 | +| `docs/architecture/collection-and-lots.md`, `docs/specs/README.md`, `docs/specs/078-mana-bar-label-fit/spec.md` | AC-2.3 (the Distillation log row is the `distill-feature` step's) | +| `docs/specs/078-mana-bar-label-fit/{screenshots/,verification.md}` | evidence, the grep, the review record, the manual phone item (AC-2.4) | + +Batches for implementer subagents (`sonnet`, read `plan.md` by line range, one TDD commit per task, the supervisor checks each batch's diff): **A** = Tasks 1–2 (red examples, then the CSS), **B** = Tasks 3–4 (documents). Tasks 5 and 6 are the supervisor's. + +--- + +## Phase 1: The examples, red first, then the rule + +**Implements:** FR-1, FR-2 | **Satisfies:** AC-1.1, AC-1.2, AC-1.3, AC-1.4, AC-1.5, AC-1.6, AC-3.1, AC-3.2 | **Files:** `spec/system/deck_shape_panel_spec.rb`, `app/assets/stylesheets/collector/additions.css` + +### Task 1: the failing examples + +The file's memoized helpers (`user`, `account`, `deck`, `rows_deck`, `wide_deck`) are at the cop's limit of five (the comment above the "extreme counts" describe says so): the band decks are built by a method and held in locals, not by `let`. The helpers below go inside a new `describe` nested in `describe "extreme counts (spec 077, #320)"`, placed just before that describe's final `end` (after the AC-4.3 variant example, before the last two `end` lines of the file). + +**Step 1: write the examples.** Insert before the two final `end`s of `spec/system/deck_shape_panel_spec.rb`: + +```ruby + + # Spec 078 (#330): a mana label is on one line inside its segment or is not drawn. The three decks put W, U, B, R and G symbols in the + # 10-20% band for labels of four, five and six characters (measured 2026-11-12, a 320px viewport, a 238px bar: "W 12" 29.2px in a 27.83px + # segment is not drawn; "W 120" 36.4px, "W 1.2k" 43.6px). A label's width differs by platform (the Mac's 43.56px, the Linux runner's about + # 44.70px for six characters), so the examples assert the rule, with a 0.5px band around the fractional content width (spec decision 3), + # never a per-label result, except "W 12" on a 320px viewport, which cannot fit on either platform. + describe "mana label fit (spec 078, #330)" do + def band_counts + { four: { "W" => 12, "U" => 13, "B" => 15, "R" => 16, "G" => 44 }, + five: { "W" => 120, "U" => 160, "B" => 180, "R" => 190, "G" => 350 }, + six: { "W" => 1_200, "U" => 1_800, "B" => 2_000, "R" => 2_100, "G" => 3_900 } } + end + + # One creature per colour with that many copies of a one-symbol cost: the bar's counts are the numbers above. + def build_band_deck(key) + band = create(:deck, account:, name: "Band #{key}") + band_counts.fetch(key).each do |symbol, copies| + create_lot_in_zone(band, "main", entry: card(name: "Band #{key} #{symbol}", cost: "{#{symbol}}", mana_value: 1.0), quantity: copies) + end + band + end + + # Per label of the panel's bar: its line count, its text width, its segment's fractional content width (the box less the divider's + # border), whether its text box is inside the segment's box (0.5px of tolerance), whether it lies wholly below it, the segment's + # overflow and how far the text's centre is from the segment's. A narrow segment's label has no box at all. + def label_states(selector) + page.evaluate_script(<<~JS) + (() => { const panel = document.querySelector(#{selector.to_json}) + return [...panel.querySelectorAll(".c-manabar__seg")].map((seg) => { const span = seg.querySelector("span"), range = document.createRange(); range.selectNodeContents(span) + const text = range.getBoundingClientRect(), box = seg.getBoundingClientRect(), edge = parseFloat(getComputedStyle(seg).borderLeftWidth) + return { name: span.textContent, narrow: span.getClientRects().length === 0, + lines: new Set([...range.getClientRects()].map((q) => Math.round(q.top))).size, + width: text.width, content: box.width - edge, overflow: getComputedStyle(seg).overflowX, + inside: text.left >= box.left - 0.5 && text.right <= box.right + 0.5 && text.top >= box.top - 0.5 && text.bottom <= box.bottom + 0.5, + below: text.top >= box.bottom - 0.5, + off: Math.abs((text.top + text.bottom) / 2 - (box.top + box.bottom) / 2) } }) })() + JS + end + + def drawn?(label) = label["lines"] == 1 && label["inside"] + def clipped?(label) = label["below"] && %w[clip hidden].include?(label["overflow"]) + # AC-1.1: each label is drawn, clipped away or has no box (a narrow segment); never on two lines, never partly out. + def clean?(label) = label["narrow"] || drawn?(label) || clipped?(label) + # AC-1.2: a label at least 0.5px narrower than its content width is drawn, one at least 0.5px wider is not. + def decided?(label) + label["narrow"] || ((label["width"] > label["content"] - 0.5 || drawn?(label)) && (label["width"] < label["content"] + 0.5 || !drawn?(label))) + end + def centred?(label) = !drawn?(label) || label["off"] <= 1 + def tidy?(states) = states.size >= 2 && states.all? { clean?(it) && decided?(it) && centred?(it) } + + def expect_labels_in_both_themes(width, panel) + %w[light dark].each do |theme| + set_theme(theme) + found = label_states(panel) + expect(yield(found)).to be(true), "at #{width} in #{theme}: #{found.inspect}" + end + end + + # The old layout: no wrapping, nothing clipped, the label a shrinkable item, no first item (what `main` draws). + def inject_old_label_layout + inject_css(".c-manabar__seg{flex-wrap:nowrap !important;overflow:visible !important} .c-manabar__seg::before{content:none !important} .c-manabar__seg>span{flex:0 1 auto !important}") + end + + %i[four five six].each do |key| + it "draws each label on one line inside its segment or not at all, centred, at every width, for the #{key}-label deck (AC-1.1, AC-1.2, AC-1.3)", :aggregate_failures do + band = build_band_deck(key) + widths.each { |width| at_width(width, deck_path(band)) { |panel| expect_labels_in_both_themes("#{key} at #{width}", panel) { tidy?(it) } } } + end + end + + it "draws some labels and clips others at a 320px viewport, so the rule is seen acting and is not vacuous (AC-1.1, AC-1.2)", :aggregate_failures do + at_width(320, deck_path(build_band_deck(:four))) do |panel| + states = label_states(panel).index_by { it["name"] } + expect(states.fetch("W 12")).to include("lines" => 1, "narrow" => false) + expect(clipped?(states.fetch("W 12"))).to be(true), states["W 12"].inspect # 29.2px of label in a 27.83px segment, on every platform + expect(states.values.count { drawn?(it) }).to be >= 1, states.inspect + end + end + + it "draws a label at 360px and in the aside where it fits, a 12% segment's among them (AC-1.2)", :aggregate_failures do + band = build_band_deck(:four) + [ 360, :aside ].each do |width| + at_width(width, deck_path(band)) do |panel| + expect(label_states(panel).find { it["name"] == "W 12" }).to satisfy("be drawn at #{width}") { drawn?(it) } # 32.6px and 33.6px of content against 29.2px + end + end + end + + it "draws every label of the five-equal-segment fixture at every width, none clipped (AC-1.5)", :aggregate_failures do + widths.each do |width| + at_width(width, deck_path(deck)) do |panel| + expect_labels_in_both_themes("fixture at #{width}", panel) { |found| found.size == 5 && found.all? { drawn?(it) } } + end + end + end + + it "can fail: with the old layout a label wraps onto two lines at a 320px viewport (AC-1.4)", :aggregate_failures do + at_width(320, deck_path(build_band_deck(:four))) do |panel| + inject_old_label_layout + found = label_states(panel) + expect(tidy?(found)).to be(false), found.inspect + expect(found.find { it["name"] == "W 12" }["lines"]).to eq(2) + end + end + + it "can fail: without the segment's first item the label sits off its segment's centre (AC-1.3)", :aggregate_failures do + at_width(360, deck_path(build_band_deck(:four))) do |panel| + inject_css(".c-manabar__seg::before{content:none !important}") + found = label_states(panel) + expect(tidy?(found)).to be(false), found.inspect + expect(found.select { drawn?(it) }.map { it["off"] }.max).to be > 1 + end + end + end +``` + +`card` takes `name:`, `type_line:` (default "Creature — Elf"), `cost:` and `mana_value:` (from the file's top). Run `bin/rubocop -a spec/system/deck_shape_panel_spec.rb`; keep the examples' behaviour when it reformats. + +**Step 2: run it and see it fail.** `bin/rspec spec/system/deck_shape_panel_spec.rb -e "mana label fit"`. Expected on the unchanged CSS (traced, not guessed): **red** are (1) the three "draws each label on one line inside its segment or not at all…" examples, one per deck (at a 320px viewport "W 12", "W 120" and "W 1.2k" wrap onto two lines, so each is neither drawn nor clipped; check that the five- and six-label decks are red too: if one is green on the old code its fixture does not reach the band, and the counts are to be changed), (2) "draws some labels and clips others…" (nothing is clipped yet) and (3) "can fail: without the segment's first item the label sits off its segment's centre" (there is no `::before` to remove yet, so the labels are centred and `tidy?` is true where the example expects false). **Green** are (4) "draws a label at 360px and in the aside where it fits" (on the old layout "W 12" fits at 360px: a guard against a rule that hides too much), (5) "draws every label of the five-equal-segment fixture…" (a guard likewise) and (6) "can fail: with the old layout a label wraps…" (the old layout is what the unchanged code draws, so it proves the predicate can fail). Record the red and the green in the commit body; if any differs, stop and read the printed label states before going on. + +**Step 3: commit.** `git add spec/system/deck_shape_panel_spec.rb`, then `git commit -m "test(deck shape): a mana label fits its segment or isn't drawn, red first (spec 078, #330)" -m ""`. + +### Task 2: the fit rule + +**Step 1: edit the CSS.** In `app/assets/stylesheets/collector/additions.css`, in the mana bar block (after "DeckShape mana bar (spec 060)"), change the `.c-manabar__seg` rule and add two rules and a comment. The rule today is: + +```css +.c-manabar__seg { flex:var(--n) 1 0; min-width:0; display:flex; align-items:center; justify-content:center; background:var(--mana-c); color:var(--on-mana-c); + font:600 12px/16px var(--font-mono); } +``` + +Make it: + +```css +/* Spec 078 (#330): a label is drawn only if it fits its segment, on one line, whatever its length or the bar's width. The segment is a + wrapping flex container whose first item, the ::before, is zero wide and as high as the segment, so it always stays on the first line + and gives that line the segment's height (the label stays centred); the label does not shrink. The browser's line breaking then keeps + the label on that line only if it fits the segment's content width, and otherwise moves it to a second line below the segment, where + overflow:clip hides it (clip, not hidden, so the segment is no scroll container and find-in-page or scrollIntoView cannot scroll to + it). No number about the font: the rule follows the rendered label. The 10% narrow class below stays: a segment under 10% has no + label at all; the caption carries the count of a colour whose label is not drawn. */ +.c-manabar__seg { flex:var(--n) 1 0; min-width:0; display:flex; flex-wrap:wrap; align-content:flex-start; align-items:center; justify-content:center; overflow:clip; background:var(--mana-c); color:var(--on-mana-c); + font:600 12px/16px var(--font-mono); } +.c-manabar__seg::before { content:""; height:100%; } +.c-manabar__seg > span { flex:none; } +``` + +(The comment goes above the `.c-manabar__seg` rule. The existing comment above `.c-manabar`, "the caption carries a segment under 10%, which the markup marks narrow", becomes partial: change it to "the caption carries a segment under 10%, which the markup marks narrow, and one whose label does not fit (spec 078)". Keep `.c-manabar__seg + .c-manabar__seg`, the colour rules and `.c-manabar__seg--narrow span { display:none; }` unchanged.) + +**Step 2: run the examples and see them pass.** `bin/rspec spec/system/deck_shape_panel_spec.rb -e "mana label fit"`, then the whole file `bin/rspec spec/system/deck_shape_panel_spec.rb`. Expected: all green, among them spec 077's AC-2.2 label examples. If a label state differs, read the message: it prints every label's state. + +**Step 3: the neighbours.** `bin/rspec spec/system/design_system_deck_components_spec.rb spec/design_system_files_spec.rb spec/deck_core_spec.rb spec/requests/deck_shape_panel_spec.rb spec/requests/query_counts_spec.rb`. Expected: all green (AC-3.2). Then `git diff --stat origin/main...HEAD -- app/` names `additions.css` only (AC-3.1). + +**Step 4: commit.** `git add app/assets/stylesheets/collector/additions.css` then `git commit -m "fix(design system): a mana bar label fits its segment or isn't drawn (spec 078, #330)" -m ""`. + +--- + +## Phase 2: The documents + +**Implements:** FR-3 | **Satisfies:** AC-2.1, AC-2.2, AC-2.3 | **Files:** `docs/design-system/components/DeckShape.md`, `docs/architecture/collection-and-lots.md`, `docs/specs/README.md` + +### Task 3: `DeckShape.md`, the README and the previews + +**Step 1:** in `docs/design-system/components/DeckShape.md`, in **The mana bar** list, after the bullet that begins "A segment under 10% of the symbols adds `c-manabar__seg--narrow`", insert: + +```markdown +- **A label that does not fit is not drawn** (spec 078 decision 1, #330). The server's 10% class is a floor; above it the browser decides from the label's rendered width. A segment is a wrapping flex container with `overflow:clip`; its first flex item is a zero-width `::before` as high as the segment, and the label does not shrink (`flex:none`). The label stays on the first line only if it fits the segment's content width (its box less the 1px divider); otherwise flex line breaking moves it to a second line below the segment, where `overflow:clip` hides it. So a label is on one line inside its segment or is not drawn, at any width of the bar and for any label length ("U 44", "W 999", "W 2.6k"), with no number about fonts in the rule, and a label exactly as wide as its content width is drawn. The caption and the bar's `aria-label` carry every colour's exact count, so nothing is lost; the caption carries the count of a colour whose label is not drawn as well as one under 10%. A clipped label stays in the DOM (the bar is `role="img"`): find-in-page may match it, and `overflow:clip` means nothing scrolls to show it. +- **Browsers:** the rule relies on flex line breaking (an item moves to a new line when the line's items exceed the container, and the first item of a line always stays) and on a percentage height in a stretched flex item (the `::before`'s `height:100%`). Safari 17.2, Chrome 120 and Firefox 121 and later are the floor (`allow_browser versions: :modern`) and all have it. The suite runs it in headless Firefox; a check on a phone (WebKit) is a manual item of spec 078's verification. A label within half a pixel of its content width may be drawn on one platform and clipped on another (a Mac and the Linux runner draw the mono face a pixel apart). +``` + +**Step 2:** in the **Width** paragraph of `DeckShape.md`, replace the sentence "and every count, the legend, the types and the mana labels fit with no horizontal overflow at the worst-case counts of spec 069's fixture (spec 077 AC-1.3, AC-2.2, AC-3.1)" so that it ends "… the types and every drawn mana label fit with no horizontal overflow …". Replace "**Known limits:** (1) a mana segment narrower than its label: … and is [#330](…) (spec 077 decision 2, ruled option A). (2) A Card types row …" with "**Known limit:** a Card types row with a five-digit count and suffix at a 320px viewport wraps its name, even mid-word; counts of six digits are not specified. (A mana segment too narrow for its label no longer wraps it: the label is not drawn and the caption carries the count; spec 078, #330, which closed the limit spec 077 had recorded.)" Keep the rest of the paragraph as it is. + +**Step 3 (AC-2.2, reading):** `git grep -n -i -E "label|narrow|10%|#330|limit" -- docs/design-system/README.md docs/design-system/previews/DeckShape.html docs/design-system/previews/DeckPage.html docs/design-system/previews/DeckPagePhone.html ':!.kamal/secrets'`; read each hit that states the mana label rule or the limit. Correct only a statement that now differs from Step 1 (none is expected: the README's DeckShape line lists classes, and the previews draw the real stylesheet with no limit text). Record what you read and what you changed (or "no change") in the commit body. + +**Step 4: commit.** `git add docs/design-system/components/DeckShape.md` (and any README or preview file Step 3 changed, by path) then `git commit -m "docs(design system): DeckShape says a mana label that doesn't fit isn't drawn (spec 078, #330)" -m ""`. + +### Task 4: the area doc and the index + +**Step 1:** in `docs/architecture/collection-and-lots.md`, read line 137 (the panel's paragraph), line 316 (the Issues bullet) and line 354 (Sources). (a) In the panel's paragraph, replace the sentence "A mana segment narrower than its label (labels of four, five and six characters need about 14%, 17% and 20% of the bar at a 320px viewport; the 10% rule is spec 060 decision 4) is a known limit, [#330](https://github.com/plainprogrammer/Collector/issues/330) (spec 077 decision 2, ruled option A)." with "A mana label is drawn only if it fits its segment on one line (a segment under 10% never has one, spec 060 decision 4): the browser's own line breaking moves a label that cannot fit onto a second line that `overflow:clip` hides, and the caption carries that colour's count (spec 078, #330, which closes the limit spec 077 decision 2 had recorded; see `DeckShape.md`)." (b) In the Issues bullet, replace the whole sentence "Open on the panel: its mana labels in segments narrower than their label, [#330](https://github.com/plainprogrammer/Collector/issues/330) (spec 077 decision 2; the other limits of [#320](https://github.com/plainprogrammer/Collector/issues/320) are fixed by spec 077)." with "The panel's limits: the mana-label band that spec 077 decision 2 left open ([#330](https://github.com/plainprogrammer/Collector/issues/330)) is closed by spec 078 and the other limits of [#320](https://github.com/plainprogrammer/Collector/issues/320) were fixed by spec 077; the one that remains is a Card types row with a five-digit count and suffix at a 320px viewport, whose name wraps (`DeckShape.md`)." Then `git grep -n -i -E "14%, 17%|narrower than their label|narrower than its label" -- docs/architecture docs/design-system ':!.kamal/secrets'` must find nothing outside the specs. (c) In Sources, append to the spec 077 bullet's "the mana-label band as a documented limit, ruled option A" the words "(closed by spec 078)" and add a bullet after it: "- [Spec 078](../specs/078-mana-bar-label-fit/spec.md) (issue [#330](https://github.com/plainprogrammer/Collector/issues/330)): the mana bar's fit rule (a label is drawn on one line inside its segment or clipped, `additions.css`). It supersedes in part spec 077 decision 2's documented limit, its Goal 1 qualification, Error Scenarios row, AC-5.1 and AC-5.3 and the 060 AC-2.4 exception (neither spec is edited)." Do not add the Distillation log row: the `distill-feature` step does. + +**Step 2:** `docs/specs/README.md`: spec 078's row exists (Status Approved at Gate A; the distillation step changes it); 077's "Superseded by" names 078. Read both rows and confirm; no change expected. + +**Step 3: commit.** `git add docs/architecture/collection-and-lots.md docs/specs/README.md` then `git commit -m "docs(architecture): the panel's mana label fit rule and the closed #330 (spec 078)" -m ""`. + +--- + +## Phase 3: Evidence (the supervisor's) + +**Satisfies:** AC-2.4, the visual evidence of the non-functional requirements | **Files:** `docs/specs/078-mana-bar-label-fit/screenshots/`, `docs/specs/078-mana-bar-label-fit/verification.md` + +### Task 5: before and after screenshots + +A throwaway, uncommitted `spec/system/zz_078_screenshots_spec.rb` (method: `.claude/memory/pr-screenshots-workflow.md` and `.claude/memory/headless-firefox-narrow-frame.md`): build the `:four` and `:six` band decks as Task 1 does; for each deck, each of 320px and 360px viewports (frames from the measured scrollbar) and the 1280px aside, each theme (set `data-theme` explicitly and wait 0.6s), save an element screenshot of the mana symbols part (`.c-shape__part` holding the bar and caption) "before" (inject the old layout of Task 1's `inject_old_label_layout`) and "after" (without it, in the same page load: take "after" first, then inject). Compress with `magick … -quality 82` to JPEG into `docs/specs/078-mana-bar-label-fit/screenshots/` named `before-|after---.jpg` (24 shots). Look at one shot of each theme and width before trusting the rest; the four-character deck's "W 12" must be wrapped in "before" at 320px and absent in "after"; the six-character deck's "W 1.2k" and "U 1.8k" likewise at 320px. Say in `verification.md` that "before" is the old layout restored by a `