From 76fba3ea075bd0860fd6901d1799aff3ef56b627 Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sun, 19 Jul 2026 23:55:40 +0200 Subject: [PATCH] fix(web): threshold-delay loading skeletons and fold the masthead into the hero --- web/css/17-crate-states.css | 57 ++++++++++++++++++++++++- web/css/90-interaction.css | 7 +++ web/src/at_record_web/pages/crate.gleam | 52 ++++++++++++++++++---- web/src/at_record_web/ui/nav.gleam | 9 ++++ web/src/at_record_web/view.gleam | 7 ++- web/test/crate_test.gleam | 35 ++++++++++++--- web/test/support.gleam | 10 ++++- 7 files changed, 159 insertions(+), 18 deletions(-) diff --git a/web/css/17-crate-states.css b/web/css/17-crate-states.css index 0dc8c54..ea36985 100644 --- a/web/css/17-crate-states.css +++ b/web/css/17-crate-states.css @@ -5,12 +5,32 @@ color: var(--ink-muted); margin: 0 0 14px; } +/* Invisible until the delay elapses, then fades in over --dur-fast: a fast + load resolves before 200ms and this never paints at all, so it reads as + "instant" instead of a one-frame flash. `both` holds opacity:0 through + the delay (backwards) and opacity:1 after (forwards). */ +@keyframes skeleton-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +.skeleton-card, +.skeleton-row { + animation: skeleton-in var(--dur-fast) var(--ease-out-cubic) 200ms both; +} .skeleton-card { border: 2px dashed var(--ink); background: var(--surface); } -.skeleton-card--dim { - opacity: 0.66; +/* `filter` rather than `opacity`: the fade-in animation above already owns + `opacity` on this element, and its forwards fill would otherwise clobber + a plain `opacity: .66` the moment the animation ends. */ +.skeleton-card--dim, +.skeleton-row--dim { + filter: opacity(66%); } .skeleton-cover { display: flex; @@ -44,6 +64,39 @@ width: 33%; } +/* --- crate loading skeletons (rows variant) ---------------------------- */ +/* Matches `.row` (06-crate.css)'s flush box exactly, not a boxed card: the + swap from skeleton to real rows must not shift layout. */ +.skeleton-row { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; +} +.skeleton-row + .skeleton-row { + border-top: 1px solid var(--line); +} +.skeleton-row__thumb { + width: 48px; + height: 48px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg); + border: 2px dashed var(--ink); +} +.skeleton-row__thumb .skeleton-cover__glyph { + font-size: 20px; +} +.skeleton-row__lines { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + /* --- crate empty (first-run) state ------------------------------------- */ .empty-state { display: flex; diff --git a/web/css/90-interaction.css b/web/css/90-interaction.css index 18b77fe..d683fb1 100644 --- a/web/css/90-interaction.css +++ b/web/css/90-interaction.css @@ -72,6 +72,13 @@ button.chip:active, .dropdown-panel { animation: none; } + /* `animation: none` here would also drop the 200ms delay, reintroducing + the one-frame flash on a fast load that the delay exists to prevent. + A 1ms animation keeps the delayed appearance without any motion. */ + .skeleton-card, + .skeleton-row { + animation: skeleton-in 1ms linear 200ms both; + } .btn, .icon-btn, button.chip, diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index 450b105..f918c3a 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -14,6 +14,7 @@ import at_record_web/route import at_record_web/ui/controls as ctl import at_record_web/ui/covers as cov import at_record_web/ui/infinite_scroll as scroll +import at_record_web/ui/nav import gleam/int import gleam/list import gleam/option.{type Option} @@ -24,8 +25,9 @@ import lustre/element/svg import lustre/event pub fn view(model: Model) -> Element(Msg) { - // NAV1 chrome owns brand/browse/scan/avatar; the hero keeps no inset bar. - let hero_bar = element.none() + // The standalone masthead is skipped for this route (view.gleam authed_view); + // its wordmark folds into the hero's own inset bar instead. + let hero_bar = nav.masthead_bar() case model.shelf { ShelfLoading -> loading_state(hero_bar, model.view, model.display) ShelfFailed -> failed_state(hero_bar) @@ -262,17 +264,35 @@ fn loading_state( html.p([attr.class("loading-status")], [ text("◌ FETCHING FROM YOUR PDS…"), ]), - html.div([attr.class("grid")], [ - skeleton_card(False), - skeleton_card(False), - skeleton_card(True), - skeleton_card(True), - ]), + skeleton_body(display), ]), ]), ]) } +/// The skeleton placeholders match whichever display mode the user has +/// chosen, so switching to Rows never bounces back to a grid mid-load. Each +/// skeleton fades in only after a ~200ms threshold (17-crate-states.css), +/// so a fast load never flashes them at all. +fn skeleton_body(display: Display) -> Element(Msg) { + case display { + Grid -> + html.div([attr.class("grid")], [ + skeleton_card(False), + skeleton_card(False), + skeleton_card(True), + skeleton_card(True), + ]) + Rows -> + html.div([attr.class("rows")], [ + skeleton_row(False), + skeleton_row(False), + skeleton_row(True), + skeleton_row(True), + ]) + } +} + fn skeleton_card(dim: Bool) -> Element(Msg) { let class = case dim { True -> "skeleton-card skeleton-card--dim" @@ -290,6 +310,22 @@ fn skeleton_card(dim: Bool) -> Element(Msg) { ]) } +fn skeleton_row(dim: Bool) -> Element(Msg) { + let class = case dim { + True -> "skeleton-row skeleton-row--dim" + False -> "skeleton-row" + } + html.div([attr.class(class)], [ + html.div([attr.class("skeleton-row__thumb")], [ + html.span([attr.class("skeleton-cover__glyph")], [text("◇")]), + ]), + html.div([attr.class("skeleton-row__lines")], [ + html.div([attr.class("skeleton-bar skeleton-bar--w70")], []), + html.div([attr.class("skeleton-bar skeleton-bar--w33")], []), + ]), + ]) +} + /// The shelf is fetched per filter view, so an empty load means "this filter /// is empty", not "the crate is empty": only the owned (default) view gets /// the first-run panel, and the toolbar always survives for switching back. diff --git a/web/src/at_record_web/ui/nav.gleam b/web/src/at_record_web/ui/nav.gleam index 7198da4..48fc025 100644 --- a/web/src/at_record_web/ui/nav.gleam +++ b/web/src/at_record_web/ui/nav.gleam @@ -12,6 +12,15 @@ pub fn masthead() -> Element(msg) { ]) } +/// The masthead folded into a hero band's own inset bar instead of standing +/// as a separate band above it (crate home): same wordmark, styled with the +/// hero's existing overlay-on-photo chrome (06-crate.css `.hero__bar`). +pub fn masthead_bar() -> Element(msg) { + html.div([attr.class("hero__bar")], [ + html.span([attr.class("hero__brand")], [text("CRATE")]), + ]) +} + pub fn bottom_bar(children: List(Element(msg))) -> Element(msg) { html.nav( [attr.class("bottom-bar"), attr.attribute("aria-label", "Primary")], diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index fb52f43..616a247 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -52,7 +52,12 @@ pub fn view(model: Model) -> Element(Msg) { fn authed_view(model: Model) -> Element(Msg) { html.div([attr.class("shell")], [ - nav.masthead(), + // Crate home folds the masthead into its own hero band instead of + // stacking a second chrome band above it (crate.gleam's `hero_bar`). + case model.route { + Crate -> element.none() + _ -> nav.masthead() + }, page(model), bottom_bar(model), ]) diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index ad3174d..4f50f9e 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -1,6 +1,6 @@ import at_record_web/model.{ - type Entry, Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Rows, - ShelfFailed, ShelfLoaded, ShelfLoading, Warning, blank_form, + type Entry, Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Record, + Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, blank_form, } import at_record_web/msg.{ ClearNotice, FormTitle, GotBrowseAdd, GotShelf, GotShelfMore, HandleChanged, @@ -19,7 +19,7 @@ import lustre/element import rsvp import support.{ an_entry, base, empty_effect, logged_in, rendered_empty_crate, - rendered_loaded_crate, unauthorized, + rendered_loaded_crate, rendered_loading_crate, unauthorized, } pub fn blank_form_defaults_to_owned_test() { @@ -199,16 +199,41 @@ pub fn loaded_rows_render_title_and_muted_artist_format_meta_test() { assert !string.contains(html, "class=\"cover-card\"") } +pub fn loading_shelf_renders_skeleton_rows_matching_the_chosen_display_test() { + let html = rendered_loading_crate(Rows) + assert string.contains(html, "skeleton-row") + assert !string.contains(html, "skeleton-card") +} + +pub fn loading_shelf_renders_skeleton_cards_matching_the_chosen_display_test() { + let html = rendered_loading_crate(Grid) + assert string.contains(html, "skeleton-card") + assert !string.contains(html, "skeleton-row") +} + // --- NAV1 chrome: masthead + bottom tab bar (app bar / popout retired) --- -pub fn masthead_shows_only_the_wordmark_test() { - let html = view.view(logged_in()) |> element.to_string +pub fn masthead_shows_only_the_wordmark_on_hero_less_routes_test() { + let seeded = + Model(..logged_in(), route: Record("e1"), shelf: ShelfLoaded([an_entry()])) + let html = view.view(seeded) |> element.to_string assert string.contains( html, "
CRATE
", ) } +// Crate home has its own hero band, so the standalone masthead would double +// up with it; the wordmark folds into the hero's inset bar instead. +pub fn crate_home_folds_the_masthead_into_the_hero_test() { + let html = view.view(logged_in()) |> element.to_string + assert !string.contains(html, "class=\"masthead\"") + assert string.contains( + html, + "
CRATE
", + ) +} + pub fn you_tab_links_to_settings_test() { let html = view.view(logged_in()) |> element.to_string assert string.contains(html, "class=\"tab\" href=\"/settings\"") diff --git a/web/test/support.gleam b/web/test/support.gleam index 264bfc8..f0763ff 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -4,8 +4,8 @@ import at_record/gen/defs.{type Snapshot, Price, Snapshot} import at_record_web/model.{ type DiscogsResult, type Display, type Entry, type Model, Crate, DiscogsResult, - Entry, Grid, LoggedIn, LoggedOut, Model, ShelfLoaded, System, blank_amend, - blank_discogs, blank_form, blank_scan, + Entry, Grid, LoggedIn, LoggedOut, Model, ShelfLoaded, ShelfLoading, System, + blank_amend, blank_discogs, blank_form, blank_scan, } import at_record_web/pages/crate import gleam/dict @@ -87,6 +87,12 @@ pub fn rendered_loaded_crate(display: Display) -> String { |> element.to_string } +pub fn rendered_loading_crate(display: Display) -> String { + Model(..logged_in(), display:, shelf: ShelfLoading) + |> crate.view + |> element.to_string +} + fn a_snapshot() -> Snapshot { Snapshot( artist_display: "Slint", -- 2.51.2