diff --git a/web/README.md b/web/README.md index fbea1a3..c2d3c5d 100644 --- a/web/README.md +++ b/web/README.md @@ -5,3 +5,7 @@ Internal package of [at-record](../README.md). Not published to Hex. Lustre SPA (javascript target): login and shelf CRUD. Talks only to the `server` BFF, never directly to a PDS. Built and bundled into `server/priv/static/` by `make build`. See the [root README](../README.md) for build, run, and test instructions. + +## Where browser behavior lives + +Three tiers, in order of preference: **CSS** for pure presentation (animation, transitions; reduced-motion is handled centrally in `css/90-interaction.css`, never re-decided in JS). **Gleam** for all logic and policy (math, thresholds, decisions; if it is testable without a browser it must be reachable from `gleam test`). **`ffi.mjs`** only for irreducible browser primitives (DOM handles, timers, camera, wasm); each export stays a dumb binding with no policy in it. diff --git a/web/css/18-scan-batch.css b/web/css/18-scan-batch.css index f5c099f..bbec401 100644 --- a/web/css/18-scan-batch.css +++ b/web/css/18-scan-batch.css @@ -141,15 +141,32 @@ gap: 8px; font: 700 22px/1 var(--mono); margin: 6px 0 14px; - /* The old -> new counts animate (see effects.count_up); fixed-width - digits keep the layout still while the numbers tick up. */ + /* Fixed-width digits keep the layout still while the count ticks up. */ font-variant-numeric: tabular-nums; } .done-state__count-old { color: var(--ink-muted); } +/* Registered so --count interpolates; without @property support the + animation flips discretely and still lands on --count-to. */ +@property --count { + syntax: ""; + inherits: false; + initial-value: 0; +} .done-state__count-new { color: var(--owned); + --count: var(--count-to); + animation: count-up 300ms ease-out; +} +.done-state__count-new::before { + counter-reset: count var(--count); + content: counter(count); +} +@keyframes count-up { + from { + --count: var(--count-from); + } } .done-state .btn { width: 100%; @@ -157,6 +174,6 @@ @media (prefers-reduced-motion: reduce) { .done-state__count-new { - transition: none; + animation: none; } } diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index 4c80c69..c064bd5 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -29,6 +29,3 @@ pub fn start_scanner( @external(javascript, "./ffi.mjs", "stopScanner") pub fn stop_scanner() -> Nil - -@external(javascript, "./ffi.mjs", "countUp") -pub fn count_up(element_id: String, from: Int, to: Int, ms: Int) -> Nil diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index a982618..dc96d1b 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -538,13 +538,6 @@ pub fn adopt_scanned( ) } -/// Drive the done page's count-up from `from` to `to` over `ms`; a direct DOM -/// write (like `start_scanner`), not model-driven, and a no-op under -/// prefers-reduced-motion (handled in the FFI). -pub fn count_up(element_id: String, from: Int, to: Int) -> Effect(Msg) { - effect.from(fn(_dispatch) { browser.count_up(element_id, from, to, 300) }) -} - /// The shared catalog grid: releases published by known at-record users, /// folded against the viewer's own crate. pub fn load_browse() -> Effect(Msg) { diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs index b13da07..ac9a1fd 100644 --- a/web/src/at_record_web/ffi.mjs +++ b/web/src/at_record_web/ffi.mjs @@ -135,26 +135,3 @@ export function stopScanner() { } } -const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3); - -// Ticks an element's text content from `from` to `to`; a no-op jump under -// prefers-reduced-motion rather than a shorter animation, per the app's -// existing reduced-motion convention (see 90-interaction.css). -export function countUp(elementId, from, to, ms) { - const el = document.getElementById(elementId); - if (!el) return; - const reduceMotion = - globalThis.matchMedia && - globalThis.matchMedia("(prefers-reduced-motion: reduce)").matches; - if (reduceMotion || from === to) { - el.textContent = String(to); - return; - } - const start = performance.now(); - function tick(now) { - const t = Math.min(1, (now - start) / ms); - el.textContent = String(Math.round(from + (to - from) * easeOutCubic(t))); - if (t < 1) requestAnimationFrame(tick); - } - requestAnimationFrame(tick); -} diff --git a/web/src/at_record_web/pages/scan_done.gleam b/web/src/at_record_web/pages/scan_done.gleam index 39ca41f..8c22554 100644 --- a/web/src/at_record_web/pages/scan_done.gleam +++ b/web/src/at_record_web/pages/scan_done.gleam @@ -1,8 +1,6 @@ //// The post-import ceremony: how many records just landed, and the crate's -//// old -> new count. The new count renders as its final value up front (a -//// no-JS/reduced-motion-safe fallback); `effects.count_up` then ticks the -//// `#done-count` span from old to new on entry (see update.gleam's -//// OnRouteChange(ScanDone)). +//// old -> new count. The count-up is pure CSS (see 18-scan-batch.css); the +//// view only supplies --count-from/--count-to. import at_record_web/model.{type Model, Crate, Scan} import at_record_web/msg.{type Msg} @@ -28,9 +26,19 @@ pub fn view(model: Model) -> Element(Msg) { text(int.to_string(old)), ]), text(" → "), - html.span([attr.id("done-count"), attr.class("done-state__count-new")], [ - text(int.to_string(new_count)), - ]), + html.span( + [ + attr.class("done-state__count-new"), + attr.attribute( + "style", + "--count-from: " + <> int.to_string(old) + <> "; --count-to: " + <> int.to_string(new_count), + ), + ], + [], + ), ]), html.a( [ diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index d1ffa2c..5fcbe66 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -80,18 +80,7 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { release_info: None, browse_adding: None, ) - let new_count = updated.scan.import_old_count + updated.scan.import_added - #( - updated, - effect.batch([ - leaving_scan(model), - effects.count_up( - "done-count", - updated.scan.import_old_count, - new_count, - ), - ]), - ) + #(updated, effect.batch([leaving_scan(model), disarm_confirms()])) } // Add refreshes Discogs connection status; scan starts/stops the camera; diff --git a/web/test/scan_done_test.gleam b/web/test/scan_done_test.gleam index 7d8111a..d5f4292 100644 --- a/web/test/scan_done_test.gleam +++ b/web/test/scan_done_test.gleam @@ -21,9 +21,9 @@ pub fn done_page_shows_the_added_count_test() { pub fn done_page_renders_old_and_new_totals_test() { let html = rendered(4, 3) assert string.contains(html, ">4<") - assert string.contains(html, "id=\"done-count\"") - // new = old + added; rendered up front as a no-JS/reduced-motion fallback. - assert string.contains(html, ">7<") + // new = old + added; the CSS counter reads these custom properties. + assert string.contains(html, "--count-from: 4") + assert string.contains(html, "--count-to: 7") } pub fn done_page_zero_added_still_renders_test() {