Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7f39439
docs(spec): 077 deck shape panel at extreme counts (draft 0.3.0, #320)
plainprogrammer Nov 12, 2026
43c982a
docs(spec): 077 approved at Gate A, mana-label band ruled option A
plainprogrammer Nov 12, 2026
f952f4a
docs(spec): 077 patch 0.3.1, measured label shares, leading dot found…
plainprogrammer Nov 12, 2026
8bee6a5
docs(plan): 077 deck shape panel at extreme counts
plainprogrammer Nov 12, 2026
5bd0820
feat(deck-shape): short_count and caption_items on the figures (#320)
plainprogrammer Nov 12, 2026
9c1d80a
test(deck-shape): extreme-count examples, red (#320)
plainprogrammer Nov 12, 2026
dfbd685
feat(deck-shape): abbreviate the curve count and the segment label, c…
plainprogrammer Nov 12, 2026
e15e1cb
feat(deck-shape): card types rows share columns, caption items stay w…
plainprogrammer Nov 12, 2026
2136f0c
docs: deck shape panel at extreme counts (spec 077, #320)
plainprogrammer Nov 12, 2026
c451aa8
docs(spec-077): before and after screenshots of the worst-case panel
plainprogrammer Nov 12, 2026
86e5403
docs(spec-077): verification record and #330 pointer (spec 0.3.2)
plainprogrammer Nov 12, 2026
430295a
test(deck-shape): guard the geometry examples against an undrawn pane…
plainprogrammer Nov 12, 2026
366ae43
docs: the abbreviation is four characters through 999m (spec 077)
plainprogrammer Nov 12, 2026
a8169cd
docs(spec-077): record the implementation review, add deviations
plainprogrammer Nov 12, 2026
b4a7d2f
docs(architecture): distil spec 077 into collection and lots
plainprogrammer Nov 12, 2026
9d7a878
docs(specs): mark spec 077 frozen by the collection and lots area doc
plainprogrammer Nov 12, 2026
ceb5354
docs(spec-077): record the distillation
plainprogrammer Nov 12, 2026
28a862a
Merge origin/main into 077-deck-shape-extreme-counts
plainprogrammer Nov 12, 2026
4b251f5
docs(lessons): two lessons and a screenshot-workflow line from spec 077
plainprogrammer Nov 12, 2026
28858af
fix(spec-077): AC-2.2's label bound follows the Linux runner's font (…
plainprogrammer Nov 12, 2026
36e94f8
Merge origin/main into 077-deck-shape-extreme-counts
plainprogrammer Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/memory/pr-screenshots-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ metadata:

**Emulating a "before" shot and drawing an open select (spec 065, 2026-11-10):** when the old code is the new code minus a collaborator's output (spec 065's pickers listed places only), take "before" in the same throwaway system spec by stubbing the new collaborator (`allow_any_instance_of(Location::Choices).to receive(:decks).and_return([])`; fine in an uncommitted spec) and say in `verification.md` that "before" is emulated, not built from `main`. Headless Firefox doesn't capture an open native `<select>`, so a second "-options" shot sets `select.setAttribute('size', select.options.length)` plus `height:auto !important` (the design system's fixed 28/36px height clips the list) in the page and inside the narrow frame; it distorts the neighbouring row, so judge layout from the closed shots. 48 JPEGs at 1000px width came to 1.3 MB.

- **Tab bar in narrow-frame shots (spec 077, 2026-11-11):** the fixed tab bar sits at the frame's bottom: a frame taller than the window (e.g. `open_in_narrow_frame` with `height: 1500`) keeps it out of an element shot of a panel; a full-page phone shot needs a frame no taller than the window's viewport to show it (spec 074).

**A frame taller than the window's viewport loses the fixed tab bar (spec 074, 2026-11-11):** a narrow frame is clipped to the window, and the phone tab bar is fixed to the frame's bottom, so the picture shows no bar. Reproduced: an 815px frame in a 1280×800 window (a 715px viewport) gave an 815px shot with no bar, in a 1280×900 window (815px viewport) the same shot had it, and a 1700px frame in this suite's 1400×1400 window (1315px viewport) gave a 1315px shot with no bar. Spec 071's shots 03, 04, 05 and others look like this; its window can't be read back, so the cause is reproduced, not confirmed. Make the window taller than the frame (`page.current_window.resize_to(…)`, and the after hook restores it) or shoot in a frame no taller than the viewport, and look for the bar in every phone shot.
14 changes: 10 additions & 4 deletions app/assets/stylesheets/collector/additions.css
Original file line number Diff line number Diff line change
Expand Up @@ -444,10 +444,15 @@ body:has(.c-locations) .c-appbar__back { min-height:var(--touch-floor); min-widt
.c-curve__avg b { font:600 13px/18px var(--font-mono); color:var(--ink); }

/* ---------- DeckShape card types (spec 060): a name, a count, and the proxies in a muted suffix ---------- */
.c-types { margin:0; padding:0; list-style:none; border-top:1px solid var(--line); }
.c-types li { display:flex; align-items:baseline; gap:var(--space-2); min-height:32px; padding:var(--space-1) 0; border-bottom:1px solid var(--line); font:400 14px/20px var(--font-sans); }
.c-types__n { margin-left:auto; font:400 14px/20px var(--font-mono); }
.c-types__proxy { min-width:4.5rem; text-align:right; font:400 12px/16px var(--font-mono); color:var(--ink-muted); } /* reserved even when empty, so the counts line up */
/* Spec 077 (#320): the list is a grid of three columns and every row takes them as its own (subgrid), so the count column is as
wide as the widest count and the suffix column as wide as the widest suffix, and every count ends at one x whatever a suffix
says. The suffix cell keeps its 4.5rem floor so a list of short suffixes looks as it did; the name cell may wrap, even mid-word,
so a five-digit count and suffix at a 320px viewport never push a row past the list. */
.c-types { display:grid; grid-template-columns:minmax(0, 1fr) auto auto; column-gap:var(--space-2); margin:0; padding:0; list-style:none; border-top:1px solid var(--line); }
.c-types li { display:grid; grid-template-columns:subgrid; grid-column:1 / -1; align-items:baseline; min-height:32px; padding:var(--space-1) 0; border-bottom:1px solid var(--line); font:400 14px/20px var(--font-sans); }
.c-types li > :first-child { min-width:0; overflow-wrap:anywhere; }
.c-types__n { margin-left:auto; font:400 14px/20px var(--font-mono); } /* in a grid cell an auto margin right-aligns it, as in the old flex row */
.c-types__proxy { min-width:4.5rem; text-align:right; font:400 12px/16px var(--font-mono); color:var(--ink-muted); } /* reserved even when empty, so a list with short suffixes keeps its look */

/* ---------- 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
Expand All @@ -463,6 +468,7 @@ body:has(.c-locations) .c-appbar__back { min-height:var(--touch-floor); min-widt
.c-manabar__seg--g { background:var(--mana-g); color:var(--on-mana-g); }
.c-manabar__seg--narrow span { display:none; }
.c-manacap { margin:0; font:400 13px/18px var(--font-sans); color:var(--ink-muted); }
.c-manacap__item { white-space:nowrap; } /* spec 077 (#320): a caption line breaks only between colours, so a name stays with its count and no line starts with a dot */

/* ---------- Deck page layout and the panel's two copies (spec 060) ---------- */
/* The shape panel is rendered twice, an aside and a closed details, and each hides the other: a closed details hides its
Expand Down
53 changes: 50 additions & 3 deletions app/models/mtg/deck_shape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ def unvalued_note
# The five colours in the order the bar draws them, with the words the caption and label use.
COLOURS = { "W" => "white", "U" => "blue", "B" => "black", "R" => "red", "G" => "green" }.freeze

# Counts below this are written in full; from it a count in a slot of fixed width takes a unit, and each unit is this many of the one
# before (spec 077 decision 5). The abbreviation is uniform, not width-driven: the server cannot know the width, and a count that
# fits at a 360px viewport still overflows at 320px.
SHORT_COUNT_STEP = 1_000
# The units of an abbreviated count: thousands, millions, billions.
SHORT_COUNT_UNITS = %w[k m b].freeze

# The segments of the mana bar for the colours that have a symbol (decision 8), in W, U, B, R, G order.
# @return [Array<Segment>]
def symbols
Expand All @@ -135,10 +142,50 @@ def symbols
end
end

# "White 2 · 25% · Blue 4 · 50%": every colour present with its count and percentage.
# "White 2 · 25% · Blue 4 · 50%": every colour present with its count and percentage, exact (the abbreviation is for a slot of
# fixed width and this is prose). Built from {#caption_items}.
# @return [String, nil] nil when there is no coloured symbol
def caption
symbols.map { |s| "#{COLOURS[s.colour].capitalize} #{delimited(s.count)} · #{s.percent}%" }.join(" · ").presence
def caption = caption_items.join(" ").presence

# The caption as one string per colour, "White 2 · 25% ·" and, last, "Blue 4 · 50%": every item but the last ends in the separator, so a
# view that sets each item as an unbreakable unit lets a line break only between items and never before a "·" (spec 077 decision 4,
# #320). Joined with one space they are {#caption}, character for character.
# @return [Array<String>] empty when there is no coloured symbol
def caption_items
items = symbols.map { |s| "#{COLOURS[s.colour].capitalize} #{delimited(s.count)} · #{s.percent}%" }
items.each_with_index.map { |item, index| index == items.size - 1 ? item : "#{item} ·" }
end

# A count written for a slot of fixed width, the curve's column count and a mana segment's label (spec 077 decisions 1, 2 and 5, #320).
# Below 1,000 it is the number as it is. From there it takes a unit: one decimal under 10 of the unit with a trailing ".0" dropped
# ("1.4k", "2k"), whole from 10 ("12k", "123k"), rounded half up on exact integers (1,350 is "1.4k", 1,349 "1.3k"). A value that
# rounds to 1,000 of its unit moves to the next unit (999,500 is "1m"), and one that rounds to 10.0 is written whole (9,999 is "10k"),
# so nothing is longer than four characters through "999m". It is not `number_to_human`, which keeps two significant digits (123,456
# would read "120k"). The exact number stays in every `aria-label`, the legend, the caption and the types.
#
# @example
# short_count(999) # => "999"
# short_count(1_357) # => "1.4k"
# short_count(9_999) # => "10k"
# short_count(999_500) # => "1m"
# @param number [Integer] a count, 0 or more
# @return [String]
def short_count(number)
return delimited(number) if number < SHORT_COUNT_STEP

value = Rational(number, SHORT_COUNT_STEP)
unit = 0
while value.round >= SHORT_COUNT_STEP && unit < SHORT_COUNT_UNITS.size - 1
value /= SHORT_COUNT_STEP
unit += 1
end
tenths = (value * 10).round
body = if value < 10 && tenths < 100
(tenths % 10).zero? ? (tenths / 10).to_s : format("%d.%d", *tenths.divmod(10))
else
value.round.to_s
end
"#{body}#{SHORT_COUNT_UNITS[unit]}"
end

# "Coloured mana symbols in Main: white 2, blue 4" for the bar's `aria-label`.
Expand Down
8 changes: 4 additions & 4 deletions app/views/mtg/deck_shapes/_parts.html.erb
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
<%# The three parts of the deck shape panel for Magic (spec 069, DeckShape.md): a part of zero has no element. %>
<%# The three parts of the deck shape panel for Magic (spec 069, DeckShape.md): a part of zero has no element. A curve column's count and a mana segment's label are in slots of fixed width, so they are abbreviated from 1,000 (spec 077, MTG::DeckShape#short_count); every other number is exact. %>
<div class="c-shape__part"><h3 class="c-shape__heading">Mana curve</h3>
<div class="c-curve">
<div role="img" aria-label="<%= figures.curve_label %>">
<div class="c-curve__cols" aria-hidden="true">
<% figures.curve.each do |column| %>
<div class="c-curve__col"><span class="c-curve__count"><%= number_with_delimiter(column.total) %></span>
<div class="c-curve__col"><span class="c-curve__count"><%= figures.short_count(column.total) %></span>
<div class="c-curve__bar">
<% if column.proxy.positive? %><div class="c-curve__proxy" style="--proxy:<%= column.proxy %>;--max:<%= figures.curve_max %>"></div><% end %>
<% if column.owned.positive? %><div class="c-curve__owned" style="--owned:<%= column.owned %>;--max:<%= figures.curve_max %>"></div><% end %>
Expand Down Expand Up @@ -36,10 +36,10 @@
<% if figures.symbols.any? %>
<ul class="c-manabar" role="img" aria-label="<%= figures.symbols_label %>">
<% figures.symbols.each do |segment| %>
<li class="<%= class_names("c-manabar__seg", "c-manabar__seg--#{segment.colour.downcase}", "c-manabar__seg--narrow": segment.narrow) %>" style="--n:<%= segment.count %>"><span><%= segment.colour %> <%= number_with_delimiter(segment.count) %></span></li>
<li class="<%= class_names("c-manabar__seg", "c-manabar__seg--#{segment.colour.downcase}", "c-manabar__seg--narrow": segment.narrow) %>" style="--n:<%= segment.count %>"><span><%= segment.colour %> <%= figures.short_count(segment.count) %></span></li>
<% end %>
</ul>
<p class="c-manacap"><%= figures.caption %></p>
<p class="c-manacap"><%= safe_join(figures.caption_items.map { |item| tag.span(item, class: "c-manacap__item") }, " ") %></p>
<% else %>
<p class="c-manacap">No coloured mana symbols.</p>
<% end %>
Expand Down
Loading
Loading