diff --git a/docs/architecture/catalog-core.md b/docs/architecture/catalog-core.md index f2a279b3..7b041c19 100644 --- a/docs/architecture/catalog-core.md +++ b/docs/architecture/catalog-core.md @@ -267,4 +267,4 @@ A card image fits its slot with `object-fit: contain`, centred, at the largest s | 2026-11-11 | Spec 073 (the card page's "Free to use" and "In decks", #50: the card page's order gains the stats and the "In decks" section for a collector, the visitor rule names "In decks" and `deck_proxies`, the open-issues line says they are built; the stats and the section are collection and lots') | [#333](https://github.com/plainprogrammer/Collector/pull/333) | | 2026-11-13 | Spec 084 (Collector's licence line ends every page's footer, for everyone, worded from the build facts; the footer is always present and every shell footed, superseding spec 018 AC-2.3 and FR-2's "absent"; the shared partial `layouts/notice_lines` a focused page's sheet foot renders too; #374). Written during the build (T4.2) and checked here | [#378](https://github.com/plainprogrammer/Collector/pull/378) | | 2026-11-13 | Spec 087 (the licence line's third sentence and link, and the third-party licences page: who sees it, the sections and a row, "Licence text not found", where the list comes from (the running bundle, the manifest and its texts), the memo, the guard, the page's ceiling; superseding the wording of spec 084's licence-line ACs; #376). Written during the build (T6.1) and checked here | [#412](https://github.com/plainprogrammer/Collector/pull/412) | -| 2026-11-13 | Spec 082's notice on the scanner (decision 16, patch 1.0.3): a one-screen focused page has no footer and carries the small print, the licence line last, in its sheets' foot and its noscript notice, the focused-page exception to spec 084 decisions 1 and 11, AC-1.1 and AC-4.3; supersedes spec 018 FR-2 for the scanner page | spec 082's pull request | +| 2026-11-13 | Spec 082's notice on the scanner (decision 16, patch 1.0.3): a one-screen focused page has no footer and carries the small print, the licence line last, in its sheets' foot and its noscript notice, the focused-page exception to spec 084 decisions 1 and 11, AC-1.1 and AC-4.3; supersedes spec 018 FR-2 for the scanner page | [#409](https://github.com/plainprogrammer/Collector/pull/409) | diff --git a/docs/architecture/scanner.md b/docs/architecture/scanner.md index f8dec697..b3a04ac6 100644 --- a/docs/architecture/scanner.md +++ b/docs/architecture/scanner.md @@ -310,4 +310,4 @@ The findings tasks take `FRAMING=` (`full` or `shipped`): unset scores `/fu | 2026-11-06 | Specs 005, 008 and 010 (spikes); specs 007, 009 and 011 but for the name index, query cleaning and the build's run; the scanner parts of specs 040 and 042; spec 047; ADRs 0001, 0002, 0004–0007 and 0012; and CLAUDE.md's card scanner paragraph | [#222](https://github.com/plainprogrammer/Collector/pull/222) | | 2026-11-09 | Spec 053's scanner side (an index built with other settings is served and refused by the browser; one for an older catalog is used) and issue #176 | [#241](https://github.com/plainprogrammer/Collector/pull/241) | | 2026-11-10 | Spec 065 as it touches the sitting (Story 5, decisions 2, 3, 8, 9): the Location select lists places and zones, the 422 covers a deck's own row, the messages name the label; supersedes spec 040 decision 3 and AC-3.1 for zones | [#304](https://github.com/plainprogrammer/Collector/pull/304) | -| 2026-11-13 | Spec 082 (the one-screen scanner, #344): the focused page and its shell, the two sheets with a live region each, decision 10's table of regions, the frozen frame, "Scan again", the after-add line, the stage notices, the live guide's rule (`LIVE_GUIDE`) and the photo path's shipped framing, measurement mode on the shell with the framing in the session and the paired findings; supersedes the parts of specs 007, 009, 011, 018, 040, 042 and 047 its Supersedes names; Story 7's re-measure withdrawn after one pass (spec 082 1.1.0, research.md, #419) | spec 082's pull request | +| 2026-11-13 | Spec 082 (the one-screen scanner, #344): the focused page and its shell, the two sheets with a live region each, decision 10's table of regions, the frozen frame, "Scan again", the after-add line, the stage notices, the live guide's rule (`LIVE_GUIDE`) and the photo path's shipped framing, measurement mode on the shell with the framing in the session and the paired findings; supersedes the parts of specs 007, 009, 011, 018, 040, 042 and 047 its Supersedes names; Story 7's re-measure withdrawn after one pass (spec 082 1.1.0, research.md, #419) | [#409](https://github.com/plainprogrammer/Collector/pull/409) | diff --git a/docs/specs/082-scanner-capture-always-in-view/plan.md b/docs/specs/082-scanner-capture-always-in-view/plan.md deleted file mode 100644 index 5b131e31..00000000 --- a/docs/specs/082-scanner-capture-always-in-view/plan.md +++ /dev/null @@ -1,1703 +0,0 @@ -# Implementation Plan: Scanner: Capture Always in View (spec 082, issue #344) - -**Spec:** `docs/specs/082-scanner-capture-always-in-view/spec.md` (Approved, 1.0.0) · **Issue:** #344 · **Branch:** `082-scanner-capture-always-in-view` · **Created:** 2026-11-13 -**Status:** Approved at Gate B on 2026-11-13 (the coordinator, under the maintainer's delegation), with spec patch 1.0.2. Plan review (Opus, read-only) 2026-11-13: round 1 REVISE (2 blocking, 8 major, 16 minor), all applied; round 2 APPROVE WITH CHANGES (2 major, 2 partly resolved, minors), all applied; spec patch 1.0.1 (the notice partial shared with spec 084, maintainer's ruling) is already in `spec.md`. - -## Summary for the maintainer - -Ten phases. Phases 1 to 8 need nothing from you; Phase 9, the paired re-measure on your cards, is last and nothing waits on it. Implementers (Sonnet 5.5) take 2 to 4 consecutive tasks each, test first, one commit per task; I check every batch's diff. - -| Phase | What lands | Criteria | Your calls in it | -|---|---|---|---| -| 0 | Spec patch 1.0.2, plan, index; #345's line | — | 1 (Scan again, the #345 line) | -| 1 | The live guide's geometry; the synthetic card drawn where the stage's guide falls | AC-3.1 to 3.5 | 2 (the guide in the aiming area) | -| 2 | The notice's lines in one partial, shared with spec 084 | decision 16 | | -| 3 | The focused layout; the shell: top bar, stage, bottom bar, the pane, the sheets (markup, CSS, the shell's state) | FR-1; AC-1.1 to 1.9, 2.1, 2.2, 2.4, 6.1 | 3 (Use a photo in the sheet), 5 (the 480px column) | -| 4 | Capture, the frozen frame, a result in the pane, "Scan again" | AC-4.1 to 4.10 | 1 (Scan again), 4 (the pane, the canvas) | -| 5 | The sitting's answers: Set, add, Undo, the after-add line, focus, the flash | AC-2.3, 2.5, 5.1 to 5.6, 6.2 to 6.5 | 4 (a live region per sheet) | -| 6 | The other states, the old CSS, the touch floor, the footer and request specs | AC-1.9; Error Scenarios; decision 16 | | -| 7 | Measurement mode on the shell, the framing, the paired findings task | AC-7.1, 7.2, 7.4's tooling | | -| 8 | Documents, screenshots and GIFs, verification, `bin/ci`, Mode B, push, draft PR | Documentation; Verification | | -| 9 | **The re-measure (you):** corpus, manifest checks, the paired run, findings, bar A | AC-7.3 to 7.5 | | - -**One thing planning found that changes the spec (patch 1.0.2, for approval with this plan):** decision 5 sends measurement mode's stored notice and a store failure to the status line, but a capture is stored while its result shows, when the status line is hidden (decision 10's table) and its text is emptied. So the stored notice shows as the top-bar button's new row and count (the button is redrawn with every stored capture), and a store failure goes to the pane's `#status`, which is in view while the result shows. AC-7.1's last clause changes to match. - -**Not run:** by the project's rule nobody runs a plan's code before execution. The blocks are strong drafts; each implementer sees red, then green, and records a deviation as a `Ruling:` line in the commit body. "Risks" lists what only a run can confirm. - -## Context - -Spec 080 drew the shell (`c-scan`), the sheet (`c-sheet`), the lines over the camera and `c-shell--focused` in `additions.css` and the design system's documents; no view uses them. This spec builds the scanner page on them, keeps today's result partial inside the pane, moves the sitting's control and list into two sheets, and makes the live guide one geometry shared by the page and the crop. Every server route stays; the Turbo Stream answers gain targets. - -## Global Constraints - -- **Repo rules:** `CLAUDE.md`, `.claude/rules/*.md`. Before touching a view, CSS or copy, use the `collector-design-system` skill and read `docs/design-system/README.md`, `components/Scanner.md` and `components/Sheet.md`. Tokens and `c-*` classes only; no colour literal, no font family outside the tokens; every new rule that sets a size sets `box-sizing:border-box` (spec 080 decision 15). -- **Where CSS goes.** One new block, headed `/* ---------- Scanner page (spec 082): … ---------- */`, inserted directly above the heading `/* ---------- Reduced motion, app-wide (spec 080 decision 14) ---------- */`, which stays last. No new comment contains "spec 019", "spec 021" or "spec 023" (`spec/design_system_files_spec.rb`). The frozen spec 080 rules are not edited; where this block overrides one it does so by a selector of equal specificity later in the file, or a higher one. -- **Core files name no collectible.** The sitting's partials and `ScannersHelper` (`spec/scanner_sitting_core_spec.rb`), the layout and the notice partials (`spec/footer_core_spec.rb`): no `mtg`, `scryfall`, `magic`, `wizards`, "fan content" anywhere, comments included. -- **No raw strings into a stream.** `turbo_stream.update(target, "…")` marks its string `html_safe`; every text that can hold a card or place name goes through `ERB::Util.html_escape` or a partial. -- **Specs:** `type: :system` explicit; `RSpec/ExampleLength` is 15 lines, so measures live in helper methods; multi-expectation examples carry `:aggregate_failures`; no `sleep`; no constants in a `describe`. A phone viewport is a frame opened from the collection page (`open_in_narrow_frame`; the scanner's own policy frames nothing). The scanner doesn't scroll, so its frame has no scrollbar and the frame's width is the viewport's. The window (1400 × 1400) is taller than every frame. -- **Comments:** YARD summaries on every public method added or changed in `app/helpers`, `app/models`, `app/controllers/concerns`, `lib`, `spec/support` (the comment ratchet); the why, with spec 082's decision or criterion as its source; no `{…}` in a comment. -- **Commits:** one per task, Conventional Commits ending `(#344)`, with the trailer of the model that wrote it (an implementer's: `Co-Authored-By: Claude Sonnet 5.5 `). `git add` and `git commit` in separate calls; one git operation per call. Never read `.kamal/secrets`, `config/master.key`, `.env*`, `storage/` or a `*.sqlite3`; every `git grep` ends with `':!.kamal/secrets'`. No `bin/rails console`, `sqlite3`, `bundle add`, `importmap pin`. -- **Shared test database:** one test run at a time in this worktree; never `bin/jobs`. -- **Selector map** for every existing scanner example the new markup touches (Phases 3 to 7): - - | Today | After | - |---|---| - | `.c-scanner` (the controllers' element) | `.c-scan` | - | `.c-scanner__stage:not([hidden])` | `.c-scan__feed:not([hidden])` | - | `.c-scanner__stage video`, `.c-scanner__video` | `.c-scan__feed video`, `.c-scan__video` | - | `.c-scanner__shutter` | `.c-scan__main` | - | `button[aria-pressed=true]` with text "Torch" | `button[aria-label=Torch][aria-pressed=true]` | - | `#status` after an add | `#scanner_added_text`, with the new sentence ("Added Lightning Bolt MOM · 123, foil") | - | `#status` after "Set" | `#scanner_destination_status` (open the destination sheet first) | - | `#status` after an Undo | `#scanner_list_status` (list sheet open) or `#scanner_added_text` | - | `#status` after a refused or failed add, Other printings | `#status` (now in the pane; unchanged) | - | `#scanner_sitting h2` | `#scanner-sitting-heading` (open the list sheet first) | - | `.c-scanner__status` | `#status` (the pane's) for a result's sentence; `#scanner_status_text` for the status line | - | `.c-scanner__art` | the same, inside the destination sheet: open it, or match with `visible: :all` | - -## Goal - -The scanner is the one-screen shell: the destination and Done in the top bar, the live camera with a guide the crop really uses, the main button, count and torch in the bottom bar, a result over the stage, the sitting's control and list in sheets that keep the camera, the after-add line with Undo; measurement mode on the same shell with a framing switch; and, last, a paired re-measure that decides the guide's size. - -## Pre-implementation gates - -- **Simplicity: pass.** The page's parts are the views, three small Stimulus controllers (one new for the shell's state, one new for the sheets, one changed for focus), the existing camera and card-reader controllers, and stream targets on the existing routes. Measurement adds one development-only form and a findings report. -- **Anti-abstraction: pass.** Native `` and `showModal()`, a `MutationObserver` for the state, a `` with `object-fit: cover` for the frozen frame. No client-side store: the state is the markup's (decision 19). -- **Integration-first: pass.** The contract is the markup and the stream targets, written in Phase 3 (the ids table below) before any answer writes to them in Phase 5. - -**The stream targets (the contract):** - -| Id | Element | Written by | -|---|---|---| -| `scanner_sitting_bar` | the destination button and Done (`display:contents`) | add, Undo, Set (replace) | -| `scanner_count` | the count button | add, Undo, Set (replace) | -| `scanner_destination_status` | the destination sheet's `role="status"` region | Set (update) | -| `scanner_picker` | the form's wrapper in the destination sheet | Set (replace) | -| `scanner-sitting-heading` | the list sheet's title (`h2`, `tabindex="-1"`) | add, Undo, Set (update, escaped text) | -| `scanner_list_status` | the list sheet's `role="status"` region | Undo (update) | -| `scanner_sitting` | the list sheet's entries section | add, Undo, Set (replace) | -| `scanner_status_text` | the status line's `role="status"` paragraph | the card reader; add (emptied) | -| `scanner_added_text` | the after-add line's `role="status"` paragraph | add, Undo (update, escaped text) | -| `scanner_added_action` | the after-add line's action (Undo, a link) | add, Undo (replace) | -| `status` | the pane's `role="status"` region | readings' sentence (client), refused adds, replays, Other printings, store failures | -| `scanner_result` | the reading's result, in the pane | readings, add (emptied) | -| `measurement_panel`, `measurement_run_button` | the run sheet's panel, the top bar's run button | measurement captures and skips (replace) | - -## Spec patch 1.0.2 (found while planning; applied in T0.1 on approval) - -| Where | Change | -|---|---| -| Header, Changelog | Version 1.0.2; a row: "PATCH, found while planning, approved with the plan: a capture is stored while its result shows, when the status line is hidden, so measurement mode's stored notice shows as the top-bar button's new row and count and a store failure in the pane's `#status` (decision 5, AC-7.1). No other criterion changes" | -| Decision 5 | "The stored notice ("Stored IMG_7001 as the measured capture…") and a store failure ("The capture wasn't stored…") go to the status line as well as the sheet, so the row and a failure are in view while capturing" becomes "The stored notice ("Stored IMG_7001 as the measured capture…") shows in the sheet, and the top-bar button, redrawn with every stored capture, shows the next row and the new count; a store failure ("The capture wasn't stored…") shows in the sheet and in the pane's `#status`, which is in view while the capture's result shows" | -| AC-7.1 | "and the stored notice or a store failure also shows on the status line" becomes "the top-bar button shows the next row and the new count after a stored capture, and a store failure also shows in the pane's `#status`" | - -## Files - -| File | Change | Responsibility | -|---|---|---| -| `app/javascript/scanner/geometry.js` | edit | `LIVE_GUIDE`, `liveGuideRect`, `shippedGuideWidth`; `guideInFrame` takes a rect | -| `app/javascript/controllers/camera_controller.js` | edit | the feed wrapper, the live guide, the framing value | -| `app/javascript/controllers/card_reader_controller.js` | edit | the main button, the frozen frame, the pane's sentences, "Scan again" | -| `app/javascript/controllers/scan_shell_controller.js` | create | `c-scan--result` and the lines' `hidden` from the markup | -| `app/javascript/controllers/sheets_controller.js` | create | open, backdrop close, empty a sheet's region on close | -| `app/javascript/controllers/sitting_focus_controller.js` | edit | "set", "heading" (or the main button), "main" | -| `app/javascript/controllers/measurement_controller.js` | edit | the read event's sizes; a store failure in the pane | -| `app/views/layouts/application.html.erb`, `_footer.html.erb`, `_notice_lines.html.erb` (new), `_notice_foot.html.erb` (new) | edit, create | the focused body; the lines in one place | -| `app/views/scanners/show.html.erb`, `_scanner.html.erb` | edit | the shell | -| `app/views/scanners/_sitting_bar.html.erb`, `_count.html.erb`, `_added_line.html.erb`, `_added_action.html.erb`, `_destination_sheet.html.erb`, `_list_sheet.html.erb` | create | the shell's sitting parts | -| `app/views/scanners/_sitting.html.erb`, `_picker.html.erb` | edit | the list section; the form's wrapper | -| `app/views/scanners/_summary.html.erb` | delete | the summary is the after-add line (AC-5.6) | -| `app/views/icons/_torch.html.erb`, `_chevron_down.html.erb` | create | the bars' icons | -| `app/views/scanner/measurements/show.html.erb`, `_panel.html.erb`, `_run_button.html.erb` (new), `_run_sheet.html.erb` (new) | edit, create | measurement mode on the shell | -| `app/helpers/scanners_helper.rb` | edit | `scanner_destination`, `scanner_added`, `sitting_title`; `sitting_focus_data` takes `:main` | -| `app/controllers/concerns/scanner_sitting.rb` | edit | `sitting_redraw`, `picker_redraw`, `say_in`, `added_line` | -| `app/controllers/scanners_controller.rb`, `scanner/sittings_controller.rb`, `scanner/sittings/entries_controller.rb`, `scanner/sittings/entries/undos_controller.rb` | edit | the new answers; the flash line | -| `app/controllers/scanner/readings_controller.rb`, `concerns/measurement_mode.rb`, `scanner/measurements_controller.rb`, `scanner/measurements/captures_controller.rb`, `scanner/measurements/framings_controller.rb` (new), `config/routes.rb` | edit, create | the framing in the session | -| `app/models/scanner/measurement_run.rb` | edit | `current(framing:)`, `FRAMINGS`, the sizes in `EXTRA_FIELDS` | -| `lib/collector/scanner_findings/paired_report.rb` (new), `lib/tasks/scanner.rake` | create, edit | the paired findings | -| `app/assets/stylesheets/collector/additions.css` | edit | the spec 082 block; the dead `c-scanner` rules removed | -| `spec/support/scanner_helpers.rb` | edit | the live synthetic card; `open_destination`, `open_sitting_list`, `open_scanner_frame`, `one_screen` | -| `spec/system/scanner_geometry_spec.rb`, `scanner_page_spec.rb`, `scanner_result_spec.rb` | create | Phases 1, 3, 4 | -| `spec/system/scanner_spec.rb`, `scanner_sitting_spec.rb`, `scanner_adding_spec.rb`, `scanner_art_spec.rb`, `scanner_detection_spec.rb`, `scanner_timing_spec.rb`, `scanner_measurement_spec.rb`, `footer_spec.rb`, `small_controls_touch_floor_spec.rb` | edit | the selector map; the new answers | -| `spec/requests/scanners_spec.rb`, `footer_spec.rb`, `scanner/sitting_entries_spec.rb`, `scanner/sittings_spec.rb`, `scanner/measurements_spec.rb` | edit | the new answers and page | -| `spec/scanner_sitting_core_spec.rb`, `spec/footer_core_spec.rb`, `spec/helpers/scanners_helper_spec.rb` | edit | the new partials; the helpers | -| `spec/models/scanner/measurement_run_spec.rb`, `spec/lib/collector/scanner_findings/paired_report_spec.rb` (new) | edit, create | Phase 7 | -| `docs/architecture/scanner.md`, `catalog-core.md`; `docs/design-system/README.md`, `components/Scanner.md`, `Sheet.md`, `Footer.md`; `docs/specs/README.md` | edit | Phase 8 | -| `docs/specs/082-scanner-capture-always-in-view/verification.md`, `research.md`, `screenshots/` | create | Phases 8, 9 | - ---- - -## Phase 0: Preflight (no code) - -**Implements:** execution preamble; decision 25 · **Satisfies:** — - -- [ ] **T0.1** On Gate B approval: apply spec patch 1.0.2 to `spec.md` (the table above), set spec 082's Plan column in `docs/specs/README.md` to "present", stage `docs/specs/082-scanner-capture-always-in-view docs/specs/README.md` in one call and commit in another: `docs(specs): spec 082 patch 1.0.2 and plan (#344)`. -- [ ] **T0.2** Issue #345 (decision 25): read its body (`gh issue view 345 --json body --jq .body`), insert before the "**Mock-ups**" line the bullet "- **From spec 082** (decision 25): \"Scan again\", the after-add line with Undo and the frozen frame (a canvas over the stage) are built by #344; this issue keeps them. #344 holds today's result in an interim scrolling body, `c-scanpane__results`, which this issue removes with the layout it replaces.", write the result with the Write tool to the scratchpad and run `gh issue edit 345 --body-file `. -- [ ] **T0.3** Baseline: `bin/rspec spec/system/scanner_spec.rb spec/system/scanner_sitting_spec.rb spec/system/scanner_adding_spec.rb spec/scanner_sitting_core_spec.rb spec/footer_core_spec.rb` (39 examples, 0 failures on 2026-11-13). - ---- - -## Phase 1: The live guide - -**Implements:** FR-3; decisions 2, 3 · **Satisfies:** AC-3.1 (the boxes), AC-3.3, AC-3.5 -**Files:** `app/javascript/scanner/geometry.js`, `spec/system/scanner_geometry_spec.rb`, `spec/support/scanner_helpers.rb` -**Interfaces:** Produces `LIVE_GUIDE`, `shippedGuideWidth(stageWidth)`, `liveGuideRect(stageWidth, stageHeight, framing = "full")`, `guideInFrame(frameWidth, frameHeight, viewWidth, viewHeight, rect = guideRect(viewWidth, viewHeight))`. `guideRect`, `GUIDE`, `STAGE_ASPECT`, `STRIPS`, `DETECTED_STRIPS` and every reading setting keep their values: they are the shipped framing, used by the photo path. - -### T1.1 The live guide's rule (test first) - -Create `spec/system/scanner_geometry_spec.rb`: - -```ruby -require "rails_helper" - -# Spec 082 decisions 2 and 3: the live guide sits in the stage's aiming area (the stage less its top 64px), as large as 80% of -# that height allows, never narrower than the shipped framing's guide for the stage's width, at most 97.5% of the width and 95% -# of the area's height. The shipped framing (the photo path's) is unchanged. The module is loaded into the page as the scanner -# helpers load it. -RSpec.describe "The scanner's guide geometry", type: :system do - before do - system_sign_in_as(create(:user)) - visit scanner_path - end - - def geometry(script) - page.evaluate_async_script(<<~JS) - const done = arguments[0] - #{ScannerHelpers::MODULES_JS} - window.__modules.then(({ geometry }) => done((#{script})(geometry))) - JS - end - - def rounded(rect) = rect.transform_values { it.round(1) } - - it "gives the live guide decision 2's boxes at the three stages (AC-3.1)", :aggregate_failures do - boxes = geometry("(g) => [ [390, 692], [360, 488], [360, 408] ].map(([w, h]) => g.liveGuideRect(w, h))") - expect(rounded(boxes[0])).to eq("x" => 15.2, "y" => 126.8, "width" => 359.7, "height" => 502.4) - expect(rounded(boxes[1])).to eq("x" => 42.5, "y" => 84.0, "width" => 274.9, "height" => 384.0) - expect(rounded(boxes[2])).to eq("x" => 63.0, "y" => 72.6, "width" => 234.0, "height" => 326.8) - end - - it "draws the shipped framing's card size in the tall stage, for measurement mode (AC-7.2)" do - box = geometry("(g) => g.liveGuideRect(390, 692, 'shipped')") - expect(rounded(box)).to eq("x" => 46.1, "y" => 170.0, "width" => 297.8, "height" => 416.0) - end - - it "keeps the shipped framing for a picked photo (AC-3.3)", :aggregate_failures do - box = geometry("(g) => ({ guide: g.guideRect(900, 1200), photo: g.guideInFrame(1200, 1600, g.STAGE_ASPECT, 1), aspect: g.STAGE_ASPECT, shares: g.GUIDE })") - expect(rounded(box["guide"])).to eq("x" => 106.4, "y" => 120.0, "width" => 687.3, "height" => 960.0) - expect(rounded(box["photo"])).to eq("x" => 141.8, "y" => 160.0, "width" => 916.4, "height" => 1280.0) - expect(box["aspect"]).to eq(0.75) - expect(box["shares"]).to eq("height" => 0.8, "maxWidth" => 0.9) - end - - it "maps a live guide into a 3:4 frame covering a taller stage, inside the frame (AC-3.2)", :aggregate_failures do - box = geometry("(g) => g.guideInFrame(900, 1200, 390, 692, g.liveGuideRect(390, 692))") - expect(box["x"]).to be >= 0 - expect(box["x"] + box["width"]).to be <= 900 - expect(box["y"]).to be >= 0 - expect(box["y"] + box["height"]).to be <= 1200 - end -end -``` - -Run it: red (`liveGuideRect` is not a function). Then in `geometry.js`, after `GUIDE`, add (and change the file's header comment's second sentence to "The shipped framing (a 3:4 stage, the guide 80% of its height) is the photo path's; the live guide is the full framing of spec 082, drawn and cropped from one computation."): - -```js -// The live guide (spec 082 decision 2): it sits in the stage's aiming area, the stage less its top `top` px, where the status -// line and the after-add line sit (16px above a one-line line of 38px, and a 10px gap), so a one-line line never covers it. It -// is as large as `height` of the area allows, never narrower than the shipped framing's guide for the stage's width (so a card -// never gets fewer pixels than it did on a portrait stage that has room), at most `maxWidth` of the stage's width and -// `maxHeight` of the area's height. The re-measure (spec 082 Story 7) decides whether it ships at this size or at the shipped -// card size ("shipped"). -export const LIVE_GUIDE = { top: 64, height: 0.8, maxWidth: 0.975, maxHeight: 0.95 } - -// The width the shipped framing's guide has in a 3:4 stage `stageWidth` wide (0.7636 of it). -export function shippedGuideWidth(stageWidth) { - return guideRect(stageWidth, stageWidth / STAGE_ASPECT).width -} - -// The live guide's box in a stage of this size, in stage pixels. framing "shipped" draws the shipped card size in the same -// place (measurement mode's other half, spec 082 decision 5). -export function liveGuideRect(stageWidth, stageHeight, framing = "full") { - const area = Math.max(stageHeight - LIVE_GUIDE.top, 0) - const shipped = shippedGuideWidth(stageWidth) - let width = framing === "shipped" ? shipped : Math.max(area * LIVE_GUIDE.height * CARD_ASPECT, shipped) - width = Math.min(width, stageWidth * LIVE_GUIDE.maxWidth, area * LIVE_GUIDE.maxHeight * CARD_ASPECT) - const height = width / CARD_ASPECT - return { x: (stageWidth - width) / 2, y: LIVE_GUIDE.top + (area - height) / 2, width, height } -} -``` - -and give `guideInFrame` a fifth parameter, `rect = guideRect(viewWidth, viewHeight)`, used in place of its `const guide = guideRect(viewWidth, viewHeight)` (the comment above it: "The guide (by default the shipped one) in image pixels when a frameWidth × frameHeight image fills a view with object-fit: cover."). Run: green. Then `ruby script/scanner/detector_parity.rb` (AC-3.5): passes. Commit `feat(scanner): the live guide's geometry (#344)`. - -Arithmetic, for the reviewer: at 390 × 692 the area is 628, 0.8 × 628 × 63/88 = 359.67 > 297.80 (shipped), under 380.25 and 427.13; height 502.40; y = 64 + (628 − 502.4)/2 = 126.8; x = (390 − 359.67)/2 = 15.17. At 360 × 488: area 424, 242.83 < 274.89, so 274.89; height 383.98 ≤ 402.8; y = 64 + 20.01 = 84.0; x = (360 − 274.909)/2 = 42.545, rounded 42.5. At 360 × 408: area 344, 274.89 wide would be 383.98 high > 326.8, so 326.8 high, 233.96 wide; y = 64 + 8.6 = 72.6; x = 63.02. Shipped at 390 × 692: 297.80 × 416.0, y = 64 + (628 − 416)/2 = 170.0, x = 46.1. `guideRect(900, 1200)`: 960 high, 687.27 wide; the photo's `guideInFrame(1200, 1600, 0.75, 1)`: scale 1/1200, offset 0, rect of a 0.75 × 1 view (0.8 high, 0.5727 wide, x 0.0886, y 0.1) × 1600 → 141.8, 160, 916.4, 1280. - -### T1.2 The synthetic card where the live guide falls - -`ScannerHelpers::CARD_JS` draws the card at `guideRect(900, 1200)`: the shipped framing's box in a 3:4 frame, which a taller stage would crop. In `CARD_JS` replace `window.__syntheticCard = async (name, lines) => { const { guideRect } = await window.__geometry … const card = guideRect(canvas.width, canvas.height)` with: - -```js - // A live card is drawn where the page's guide falls in the frame (spec 082 AC-3.1): the stage's live guide mapped into the - // 3:4 frame the way the camera controller maps it; a photo's card where the shipped guide is (the photo path's framing). - window.__syntheticCard = async (name, lines, live = false) => { - const { guideRect, guideInFrame, liveGuideRect } = await window.__geometry - const canvas = Object.assign(document.createElement("canvas"), { width: 900, height: 1200 }) - const context = canvas.getContext("2d") - const cardBox = () => { - const stage = document.querySelector(".c-scan__stage")?.getBoundingClientRect() - if (!live || !stage) return guideRect(canvas.width, canvas.height) - const framing = document.querySelector(".c-scan")?.dataset.cameraFramingValue || "full" - return guideInFrame(canvas.width, canvas.height, stage.width, stage.height, liveGuideRect(stage.width, stage.height, framing)) - } -``` - -and inside `draw`, start with `const card = cardBox()` (the stage can resize between frames). `show_synthetic_card` calls `window.__syntheticCard(name, lines, true)`; `pick_synthetic_photo` and `detect_guide_card` keep calling it with two arguments. In `show_synthetic_card` change `.c-scanner__video` to `.c-scan__video, .c-scanner__video`, and in `restart_camera` `.c-scanner` to `.c-scan, .c-scanner` (Phase 3 renames them, and T3.5 drops the old names; this task changes the helper only, and the old page's examples keep passing until Phase 3 because the helper falls back to `guideRect` when there is no `.c-scan__stage`; both selectors match the old page now and the new one after Phase 3). Run `bin/rspec spec/system/scanner_spec.rb spec/system/scanner_detection_spec.rb`: green. Commit `test(scanner): draw the synthetic card where the live guide falls (#344)`. - ---- - -## Phase 2: The notice's lines in one partial - -**Implements:** decision 16 (spec 1.0.1: the partial is shared with spec 084) · **Satisfies:** `spec/footer_core_spec.rb` -**Files:** `app/views/layouts/_notice_lines.html.erb` (new), `_notice_foot.html.erb` (new), `_footer.html.erb`, `spec/footer_core_spec.rb` - -### T2.1 One partial for the notice's lines - -Spec 084 (#374, another branch) builds `layouts/_notice_lines.html.erb` with the licence line after the types' lines and makes the footer always present. This task creates the same file under the same name with the types' lines only, so the merge is a take-theirs on one file (maintainer's ruling, 2026-11-13). Keep `_footer.html.erb`'s change to the one line below, in this commit alone. - -Test first: in `spec/footer_core_spec.rb` the file list becomes `%w[app/views/layouts/application.html.erb app/views/layouts/_footer.html.erb app/views/layouts/_notice_lines.html.erb app/views/layouts/_notice_foot.html.erb app/models/catalog/content_notice.rb]` and the first example's description "covers the layout, the footer's and the sheets' foot's partials and the model that collects the notices" (red: two files missing). Create `app/views/layouts/_notice_lines.html.erb` (no locals): - -```erb -<%# The content notices' lines, one per paragraph, their links as the footer draws them (spec 018 FR-1, FR-4). The footer renders - them, and so does the foot of a sheet on a one-screen focused page, which has no footer (spec 082 decision 16). Shared with - spec 084, which adds the licence line after them. %> -<% Catalog::ContentNotice.lines.each do |line| %> -

<% line.parts.each do |text, address| %><%= address ? link_to(text, address, rel: "noopener") : text %><% end %>

-<% end %> -``` - -`app/views/layouts/_notice_foot.html.erb`: - -```erb -<%# A sheet's foot on a one-screen focused page (spec 080 decision 13; spec 082 decision 16): a sheet always has one, which is how - such a page meets the rule that every page shows the notice (spec 084). %> -
<%= render "layouts/notice_lines" %>
-``` - -and in `_footer.html.erb` the loop becomes `<%= render "layouts/notice_lines" %>` inside `