Skip to content
Draft
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
73e4e32
docs(design-system): spec and plan for the phone-first scanner (#343)
plainprogrammer Nov 13, 2026
be5d014
feat(design-system): on-success, on-warning and scrim tokens (#343)
plainprogrammer Nov 13, 2026
d6e8087
refactor(spec): one home for the design-system probe helpers (#343)
plainprogrammer Nov 13, 2026
e2a6804
feat(design-system): transitions and animations stop app-wide under r…
plainprogrammer Nov 13, 2026
f596ea8
test(design-system): markup of the phone-first scanner's parts (#343)
plainprogrammer Nov 13, 2026
5cccf1d
feat(design-system): StatusBand, the filled status band (#343)
plainprogrammer Nov 13, 2026
11b4b4a
feat(design-system): c-shell--focused, a focused page's body is exact…
plainprogrammer Nov 13, 2026
a228906
feat(design-system): Sheet, a bottom sheet on a native dialog (#343)
plainprogrammer Nov 13, 2026
e3ae3e4
feat(design-system): the one-screen scan shell (#343)
plainprogrammer Nov 13, 2026
3542f6b
feat(design-system): the scan pane, its capture strip and the short-s…
plainprogrammer Nov 13, 2026
2ec7cd9
test(design-system): the spec 080 blocks hold no colour literal and s…
plainprogrammer Nov 13, 2026
71da946
docs(design-system): focused pages, sheets, swipes, motion and the st…
plainprogrammer Nov 13, 2026
cea748d
docs(design-system): StatusBand and Sheet (#343)
plainprogrammer Nov 13, 2026
3c926ab
docs(design-system): the phone-first scanner in Scanner.md, beside th…
plainprogrammer Nov 13, 2026
343f6da
docs(design-system): previews of the scan flow, the pane's confidence…
plainprogrammer Nov 13, 2026
bc21f9c
docs(specs): screenshots of spec 080's four previews, light and dark …
plainprogrammer Nov 13, 2026
8a652ee
docs(design-system): verification of spec 080 (#343)
plainprogrammer Nov 13, 2026
c68d3bc
fix(design-system): box-sizing on every sized rule in spec 080's bloc…
plainprogrammer Nov 13, 2026
20947c3
docs(specs): why the probe and markup helpers exist, not their histor…
plainprogrammer Nov 13, 2026
f1dad84
test(design-system): prove the partly-proved spec 080 criteria in ful…
plainprogrammer Nov 13, 2026
6e9f458
docs(design-system): record the implementation review of spec 080 (#343)
plainprogrammer Nov 13, 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
174 changes: 174 additions & 0 deletions app/assets/stylesheets/collector/additions.css

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions app/assets/stylesheets/collector/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@
--on-mana-c: #1f1d1a;
--focus: #1d6fd1;
--success: #256a3a;
--on-success: #ffffff;
--warning: #8a5a00;
--on-warning: #ffffff;
--danger: #b3362b;
--scrim: #00000066;
color-scheme: light;
}

Expand Down Expand Up @@ -77,8 +80,11 @@
--on-mana-c: #1f1d1a;
--focus: #7fb2ff;
--success: #6cc488;
--on-success: #0c1f1f;
--warning: #e8b75a;
--on-warning: #231a08;
--danger: #ff8a7a;
--scrim: #00000066;
color-scheme: dark;
}

Expand Down Expand Up @@ -118,8 +124,11 @@
--on-mana-c: #1f1d1a;
--focus: #7fb2ff;
--success: #6cc488;
--on-success: #0c1f1f;
--warning: #e8b75a;
--on-warning: #231a08;
--danger: #ff8a7a;
--scrim: #00000066;
color-scheme: dark;
}
}
Expand Down
22 changes: 18 additions & 4 deletions docs/design-system/README.md

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions docs/design-system/components/Scanner.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,111 @@
# Scanner

The card scanner. Two sections: the phone-first scanner, drawn by spec 080 (#343) and built by #344 (the one-screen page) and #345 (the selection pane); and the scanner as built today, which stays true until those merge. Each of them deletes what it replaces in the second section.

## The phone-first scanner (drawn by spec 080, built by #344 and #345)

One screen that never scrolls: a top bar with the sitting's destination, the camera filling everything between the bars, and a bottom bar whose main button is always in view. After a capture the frozen capture shrinks into a strip and the selection pane slides up over the rest of the stage. Nothing here is live yet: no view renders these parts, and the scanner works as the second section says until #344 and #345 merge. Previews: `ScannerFlow.html` (the five steps), `ScannerConfidence.html` (the pane's three states), `Sheet.html` (the two sheets).

**Markup** — no view renders it yet. Shown for a Magic: The Gathering card, with a result.

```html
<body class="c-shell c-shell--untabbed c-shell--focused">
<div class="c-scan c-scan--result">
<div class="c-scan__top">
<a class="c-scan__back" href="/collection" aria-label="Back"><svg …back…></svg></a>
<button type="button" class="c-scan__dest"><span class="c-scan__dest-label">Adding to</span> <span class="c-scan__dest-value"><span class="c-scan__dest-text">Atraxa › Main · NM</span><svg …chevron…></svg></span></button>
<a class="c-btn c-btn--ghost c-scan__done" href="…">Done</a>
</div>
<div class="c-scan__stage">
<video class="c-scan__video" playsinline muted></video>
<div class="c-scan__guide" aria-hidden="true"></div>
<div class="c-scan__line c-scan__line--status" hidden><p class="c-scan__line-text" role="status"></p></div>
<div class="c-scan__line c-scan__line--added" hidden><p class="c-scan__line-text" role="status"></p><form class="c-scan__undo">…<button class="c-btn c-btn--ghost">Undo</button></form></div>
<div class="c-scan__strip">
<canvas class="c-scan__capture"></canvas>
<div class="c-scan__read"><p class="c-scan__read-label">Read from the card</p><p class="c-scan__read-name">Lightning Bolt</p><p class="c-scan__read-line">MOM · 123 · EN</p></div>
</div>
<div class="c-scanpane">
<p class="c-band c-band--success" role="status"><svg …check…></svg><span>Matched by its collector line</span></p>
<div class="c-scanpane__body">
<div class="c-scanpane__rowwrap">
<ul class="c-scanpane__row">
<li class="c-scanpane__item" aria-current="true"><img src="…" alt="Lightning Bolt, March of the Machine, MOM · 123"></li>
<li class="c-scanpane__item"><img src="…" alt="…"></li>
</ul>
<button type="button" class="c-btn c-btn--secondary c-scanpane__nav c-scanpane__nav--prev" aria-label="Previous printing" disabled><svg …></svg></button>
<button type="button" class="c-btn c-btn--secondary c-scanpane__nav c-scanpane__nav--next" aria-label="Next printing"><svg …></svg></button>
</div>
<div class="c-scanpane__card">
<div class="c-scanpane__details">
<h2 class="c-scanpane__name">Lightning Bolt</h2>
<p class="c-scanpane__set">March of the Machine</p>
<p class="c-scanpane__meta"><span class="c-tag">MOM · 123</span> <span>2023</span> <span class="c-scanpane__position">1 of 14 printings</span></p>
</div>
<div class="c-scanpane__adds">
<form class="c-scanpane__add">…<button class="c-btn c-btn--secondary" aria-label="Add Lightning Bolt MOM · 123 Nonfoil">Add nonfoil</button></form>
<form class="c-scanpane__add">…<button class="c-btn c-btn--secondary" aria-label="Add Lightning Bolt MOM · 123 Foil"><svg …star… aria-hidden="true"></svg>Add foil</button></form>
</div>
</div>
<div class="c-scanpane__card" hidden>…</div>
</div>
</div>
</div>
<div class="c-scan__bottom">
<button type="button" class="c-btn c-btn--secondary c-scan__count" aria-label="12 added. Show this sitting's cards."><b>12</b><span>added</span></button>
<button type="button" class="c-btn c-btn--secondary c-scan__main">Scan again</button>
<button type="button" class="c-btn c-btn--secondary c-scan__torch" aria-label="Torch" aria-pressed="false"><svg …></svg></button>
</div>
</div>
</body>
```

Without a result the shell has no `c-scan--result`, the stage holds no strip and no pane, and the main button is the primary "Capture". The two lines over the camera, while aiming and after an add:

```html
<div class="c-scan__line c-scan__line--status"><p class="c-scan__line-text" role="status">Line the card up with the guide, then capture.</p></div>
<div class="c-scan__line c-scan__line--added" hidden><p class="c-scan__line-text" role="status"></p><form class="c-scan__undo">…<button class="c-btn c-btn--ghost">Undo</button></form></div>

<div class="c-scan__line c-scan__line--status" hidden><p class="c-scan__line-text" role="status"></p></div>
<div class="c-scan__line c-scan__line--added"><p class="c-scan__line-text" role="status">Added Lightning Bolt MOM · 123, foil</p><form class="c-scan__undo">…<button class="c-btn c-btn--ghost">Undo</button></form></div>
```

- **A focused page** (README, Mobile and Hotwire): no app header and no tab bar. The `<body>` carries `c-shell--focused` beside `c-shell--untabbed`, so the shell (`c-scan`, as tall as the dynamic viewport) is exactly the screen and the document never scrolls. The top bar is 56px, the bottom bar 96px, and the stage is everything between.
- **Controls** are 44px or more at every pointer: Back (44px), the destination, Done, the count (84 × 56px), the main button (64px high, taking the room that is left; the torch's too when there is none) and the torch (56px, only where the camera reports one).
- **The destination** opens the sitting's choices in a `Sheet`; the count opens the sitting's list in another. Neither navigates: every visit to the scanner releases the camera. "Done" keeps its confirm page and "Details" of an add still leaves for Edit copy. The destination sheet's form keeps `c-scanner__place` (its fieldset's `min-width:0`, spec 042 AC-7.1), and the list keeps `c-scanner__sitting`.
- **State is in the markup, never in a script's memory.** `c-scan--result` on the shell shows the capture strip and the pane; without it the stage is the live camera. The main button's `disabled` is the reading state. The row's centred item carries `aria-current="true"`. The server renders one `c-scanpane__card` per image, and every one but the centred card's carries `hidden`. Parts are shown and hidden with the `hidden` attribute (`.c-scan [hidden]` and `.c-scanpane [hidden]` keep a hidden `c-btn` hidden).
- **The stage follows the theme**: `surface-sunken` under the picture, which covers it. The guide is drawn as today (`brand`, with an `on-brand` dashed line inside), its box set inline by the camera controller. The guide's geometry is #344's, which re-measures the reading at the new framing.
- **Two lines over the camera**, 16px under the stage's top, on `surface-raised` with a `line-strong` border: the status line, centred, and the after-add line, which takes its place and spans the stage. While one shows, the other's wrapper carries `hidden`. Each line's `role="status"` element stays in the document and its text is inserted or replaced, which is what gets announced; Undo sits in the wrapper, outside the live region. The status line, the after-add line and the band are the page's three live regions.
- **Capture and add stay the collector's taps**, and one reading adds at most one copy. The frozen frame and the capture strip are drawn in the page from the captured frame: the picture never leaves the device and nothing new is sent.
- **The capture strip** is 144px with a 124px picture, 96px with a 76px one when the shell (on a phone, the screen) is less than 720px high, and never taller: the name read is one line, ending in an ellipsis.
- **The pane** fills the rest of the stage. The `StatusBand` is its top edge and never scrolls. Under it the image row gives way on a short screen: the images are 288px high at most and 160px at least. When the pane still does not fit (below 640px of screen, or with a wrapped band or set name), its content scrolls inside it; the bars, the strip and the band never move.
- **The row** scrolls sideways with scroll snap and draws no scrollbar. It holds the candidates first (three at most, in ranked order), then the centred card's other printings in today's Other printings order; when the scanner is not sure of the card it holds the candidate cards (design decision 17). The centred image has a 2px `brand` outline, and the images carry no text.
- **A swipe is never the only way**: Previous and Next sit over the neighbours (44px, the one at the row's end disabled). The centred card is announced through the band, which follows it (design decision 14): on a printing other than the matched one it reads "Not the printing that was read". The band is one element whose form, icon and sentence are replaced in place (`StatusBand`).
- **One details block** describes the centred card, each fact on its own line: the name; the set's full name, free to wrap; the set code and number as a tag with the year, and the position at the end.
- **One add button per finish**, sharing the pane's width, 52px high, wrapping onto two lines when it must; a special finish's button carries the star in `foil`. A foil marker read off the card relabels its button and chooses nothing.
- **Motion**: the capture's shrink and the pane's slide take 200ms and stop under reduced motion (README, Motion). The shrink starts from `--from` times the picture's size (4 unless the script sets it on `c-scan__capture`).

**Copy**

| Where | Words |
|---|---|
| Status line | "Line the card up with the guide, then capture." while aiming; "Reading the card…" while a reading runs |
| Top bar | "Adding to" over the destination: "Atraxa › Main · NM" (a place and a condition), "Blue binder" (a place alone), "No location · NM" (a condition alone), "No location". "Done". The back arrow is named "Back" |
| Count | "12 added" ("0 added", "1 added"); its accessible name is "12 added. Show this sitting's cards." |
| Main button | "Capture"; "Scan again" while a result shows. The torch is named "Torch" |
| Capture strip | "Read from the card", the name read, and the collector line read or "nothing read" |
| Band, success | "Matched by its collector line"; "Matched by its collector line, one digit corrected"; "Matched by its artwork" |
| Band, warning | "Printing not confirmed. Swipe to check." (alert); "Not sure of this card. Check it against your capture." (question mark); "Not the printing that was read" (alert, on a swipe away from the match) |
| Position | "1 of 14 printings"; "1 of 3 possible cards" |
| Add buttons | "Add nonfoil", "Add foil", "Add" and a finish's name in lower case ("Add etched"); "Add" alone for one finish or none; "Add foil · read from the card" when the foil marker was read. Accessible names stay today's: "Add Lightning Bolt MOM · 123 Foil" |
| After-add line | "Added Lightning Bolt MOM · 123, foil"; "Added Lightning Bolt MOM · 123" for a printing with no finish; "Undo". It names no condition or place: the top bar shows them |
| Previous and Next | "Previous printing", "Next printing"; "Previous card", "Next card" while the row holds possible cards |
| Sheets | "Adding to" (the destination); "This sitting: 12 cards" (the list) |

**Placed by #344 and #345** (today's copy stands until then): "Use a photo", the artwork-matching line, the photo-framing hint, the camera-unavailable states, "Nothing could be read", "No card matches", the catalog-not-ready notice, the send failures and the art-overrule sentence; the layout from 640px up, landscape and the bars' safe-area insets; how many printings load at once.

## As built, until #344 and #345

The card scanner (spec 007): a live camera feed with a card-shaped guide, the controls under it, and what the scanner read with its candidate printings.

**Markup** — `scanners/_scanner.html.erb` renders it; the controllers `card-reader` and `camera` bring it to life.
Expand Down
29 changes: 29 additions & 0 deletions docs/design-system/components/Sheet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Sheet

A panel that opens from the bottom edge over a page, for content that would otherwise take the collector off a page whose state a navigation would lose (spec 080). It is a native `<dialog>` opened as a modal.

**Markup** — the consumer provides the title, the body and, on a one-screen focused page, the content notice's lines; the page's script opens it with `showModal()`.

```html
<dialog class="c-sheet" id="destination" aria-labelledby="destination_title">
<div class="c-sheet__head">
<h2 class="c-sheet__title" id="destination_title">Adding to</h2>
<form method="dialog" class="c-sheet__dismiss"><button class="c-btn c-btn--ghost c-sheet__close" aria-label="Close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button></form>
</div>
<div class="c-sheet__body">
…
<div class="c-sheet__foot"><p>…the content notice's first line…</p><p>…its second…</p></div>
</div>
</dialog>
```

- Opened as a modal (`showModal()`): focus moves into the sheet on opening, and on closing it returns to the control that opened it. Both are the element's own.
- Three ways to close: the Close button (the cross at 20px and stroke 1.5 in a 44 × 44px button, named "Close", in a `form method="dialog"`, so it needs no script), Escape, and a tap on the backdrop. The first two are the element's own; the tap on the backdrop is closed by the page's script, which is #344's and not built yet.
- Fixed to the bottom edge, as wide as the screen, 85% of its height at most. The head (the title in `heading`, Close) stays in view and the body scrolls, without passing the scroll on to the page behind (`overscroll-behavior: contain`). The body's last padding clears a phone's home indicator (`env(safe-area-inset-bottom)`).
- `surface-raised` with a 1px `line-strong` top border and `radius-md` top corners; no shadow. The backdrop is `scrim`, its only use; a browser whose `::backdrop` does not inherit the tokens draws no dimming, and the top border still separates the sheet. It slides up in 200ms and closes at once; under reduced motion it does not slide (README, Motion).
- **It needs script to open.** Use it only on a page that already needs script, or give its opener a plain link to a page that holds the same content. It is the one exception to "Nothing needs JavaScript" (README, Mobile and Hotwire).
- **It never navigates** on a page that holds the camera: a visit would release it. A form in a sheet answers with a Turbo Stream.
- **The foot.** A focused page that is one screen high has no footer, so each of its sheets ends with the content notice's lines in `c-sheet__foot`, the last thing in its body, in the footer's text style (12px/16px `ink-muted`, links in `brand`, underlined on hover, with the focus ring), under a 1px `line` rule; such a page has a sheet or keeps the footer, and a focused page that scrolls keeps the footer. A source that supplies no notice gives no foot. No page does this yet: the scanner keeps its footer until #344 builds the one-screen page (spec 080 decision 13).
- Parts shown and hidden inside it use the `hidden` attribute; `.c-sheet [hidden]` keeps a hidden `c-btn` hidden. The layout is on `.c-sheet[open]`, so never set `display` on `.c-sheet` itself.
- First two uses, on the phone-first scanner (`Scanner`, built by #344; no view renders a sheet yet): the sitting's destination ("Adding to") and the sitting's list. Preview: `Sheet.html`, where the sheets are drawn with the `open` attribute and so have no backdrop.
- It names no collectible.
19 changes: 19 additions & 0 deletions docs/design-system/components/StatusBand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# StatusBand

A filled band across the top of a result that says, in an icon and a sentence, how sure the app is of it (spec 080). It is the one filled use of a status colour.

**Markup** — the consumer provides the form, the icon and the sentence. Shown with sentences of its first use (the scanner's, below).

```html
<p class="c-band c-band--success" role="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg><span>Matched by its collector line</span></p>
<p class="c-band c-band--warning" role="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4 2.5 20h19z"/><path d="M12 10v4M12 17h.01"/></svg><span>Printing not confirmed. Swipe to check.</span></p>
<p class="c-band c-band--warning" role="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01"/></svg><span>Not sure of this card. Check it against your capture.</span></p>
```

- Three forms: success with a check, warning with the alert triangle, warning with a question mark in a circle. The two warnings differ by icon and words, never by colour.
- The ground is `success` or `warning`; the sentence and the icon are `on-success` or `on-warning` (4.5:1 or better in both themes). It uses no other colour. `danger` has no band: something that went wrong is a `StatusMessage`.
- A band always carries an icon and a sentence. It holds no control and is never decoration.
- 14px/20px, weight 600, `space-4` at the sides. One line is 40px high with the line centred; a longer sentence wraps, the band grows (56px for two lines) and the icon stays on the first line. The icon is 16px at stroke 2 (the markup keeps `stroke-width="1.5"`; the band's CSS sets the 2, README Iconography).
- It is a live region (`role="status"`) and one element: when what it describes changes, its form class, icon and sentence are replaced in place, which is what gets announced. Never render a second band and reveal it with `hidden`.
- First use: the top edge of the scanner's selection pane (`Scanner`, built by #345; no view renders a band yet). Its sentences there: "Matched by its collector line", "Matched by its collector line, one digit corrected" and "Matched by its artwork" (success); "Printing not confirmed. Swipe to check." and "Not the printing that was read" (warning, alert); "Not sure of this card. Check it against your capture." (warning, question mark). Preview: `StatusBand.html`.
- It names no collectible: the component is the coloured band, and each use writes its own sentences.
Loading
Loading