diff --git a/web/css/06-crate.css b/web/css/06-crate.css index 3c123c2..00f2431 100644 --- a/web/css/06-crate.css +++ b/web/css/06-crate.css @@ -15,15 +15,37 @@ var(--ink-fixed) ); } -.hero__sticker { +/* Crate-home chrome inset over the hero; padding mirrors .app-bar so chrome lines up across routes. */ +.hero__bar { position: absolute; - left: 16px; - top: 92px; - background: var(--ink-fixed); - border: 2px solid var(--cream-fixed); + inset: 0 0 auto 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 16px 12px; + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--ink-fixed) 60%, transparent), + transparent + ); +} +.hero__brand { color: var(--cream-fixed); - padding: 6px 12px; - font: 400 28px/1 var(--display); + font: 400 22px/1 var(--display); + letter-spacing: 1.5px; + padding: 2px 0; +} +/* Public crate: back button and handle cluster left instead of space-between. */ +.hero__bar--public { + justify-content: flex-start; +} +.hero__brand--handle { + text-transform: uppercase; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .hero__count { position: absolute; @@ -37,7 +59,7 @@ .hero__badge { position: absolute; right: 16px; - top: 16px; + bottom: 10px; } /* --- toolbar / tags --------------------------------------------------- */ diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index 177becc..3bc5f58 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -5,8 +5,8 @@ import at_record/gen/defs.{type Snapshot} import at_record_web/model.{ - type Display, type Entry, type Model, Add, Grid, Record, Rows, Scan, - ShelfFailed, ShelfLoaded, ShelfLoading, + type Display, type Entry, type Model, Add, Grid, LoggedIn, LoggedOut, Record, + Rows, Scan, ShelfFailed, ShelfLoaded, ShelfLoading, } import at_record_web/msg.{ type Msg, RetryShelf, SetDisplay, SetView, ShowMoreCrate, @@ -26,12 +26,14 @@ import lustre/element/svg import lustre/event pub fn view(model: Model) -> Element(Msg) { + let hero_bar = crate_bar(model) case model.shelf { - ShelfLoading -> loading_state(model.view, model.display) - ShelfFailed -> failed_state() - ShelfLoaded([]) -> empty_state(model.view) + ShelfLoading -> loading_state(hero_bar, model.view, model.display) + ShelfFailed -> failed_state(hero_bar) + ShelfLoaded([]) -> empty_state(hero_bar, model.view) ShelfLoaded(items) -> loaded_state( + hero_bar, model.view, model.display, items, @@ -42,7 +44,24 @@ pub fn view(model: Model) -> Element(Msg) { } } +/// Crate-home chrome (brand, browse/scan, avatar) inset in the hero band; view.app_bar renders no bar for this route. +fn crate_bar(model: Model) -> Element(Msg) { + let handle = case model.auth { + LoggedIn(handle) -> handle + LoggedOut -> "" + } + html.div([attr.class("hero__bar")], [ + html.span([attr.class("hero__brand")], [text("CRATE")]), + html.div([attr.class("app-bar__right")], [ + bar.icon_link("⌗", "Browse the network", "/browse"), + bar.icon_link("⦀", "Scan barcodes", "/scan"), + bar.avatar_link(handle, model.avatar), + ]), + ]) +} + fn loaded_state( + hero_bar: Element(Msg), view: String, display: Display, items: List(Entry), @@ -53,7 +72,7 @@ fn loaded_state( let total = list.length(items) let visible = list.take(items, window) html.div([attr.class("list-screen")], [ - hero(hero_count(view, items), ctl.badge("◆ GROWING", ctl.Accent)), + hero(hero_bar, hero_count(view, items), ctl.badge("◆ GROWING", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, display_toggle(display)), html.div([attr.class("list-scroll")], [ @@ -147,9 +166,13 @@ fn bool_attr(value: Bool) -> String { } } -fn hero(count: String, badge: Element(Msg)) -> Element(Msg) { +fn hero( + hero_bar: Element(Msg), + count: String, + badge: Element(Msg), +) -> Element(Msg) { html.div([attr.class("hero")], [ - html.span([attr.class("hero__sticker")], [text("THE CRATE")]), + hero_bar, html.span([attr.class("hero__count")], [text(count)]), html.div([attr.class("hero__badge")], [badge]), ]) @@ -230,9 +253,13 @@ fn format_line(snap: Snapshot) -> String { |> string.join(" / ") } -fn loading_state(view: String, display: Display) -> Element(Msg) { +fn loading_state( + hero_bar: Element(Msg), + view: String, + display: Display, +) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero("…fetching your records", ctl.badge("◆ LOADING", ctl.Accent)), + hero(hero_bar, "…fetching your records", ctl.badge("◆ LOADING", ctl.Accent)), html.div([attr.class("body list-body")], [ // Filter chips + view toggle are navigation, not content: keep them // stable across loading so the controls do not flicker out and back. @@ -273,25 +300,31 @@ fn skeleton_card(dim: Bool) -> Element(Msg) { /// 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. -fn empty_state(view: String) -> Element(Msg) { +fn empty_state(hero_bar: Element(Msg), view: String) -> Element(Msg) { case view { "wanted" -> empty_filter_state( + hero_bar, view, "Nothing on your wishlist yet. Spot something in BROWSE or mark a record WANTED.", ) "history" -> empty_filter_state( + hero_bar, view, "No history yet. It fills up as your crate changes.", ) - _ -> empty_crate_state(view) + _ -> empty_crate_state(hero_bar, view) } } -fn empty_filter_state(view: String, hint: String) -> Element(Msg) { +fn empty_filter_state( + hero_bar: Element(Msg), + view: String, + hint: String, +) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero(hero_count(view, []), ctl.badge("◆ GROWING", ctl.Accent)), + hero(hero_bar, hero_count(view, []), ctl.badge("◆ GROWING", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, element.none()), html.div([attr.class("list-scroll")], [ @@ -302,9 +335,9 @@ fn empty_filter_state(view: String, hint: String) -> Element(Msg) { ]) } -fn empty_crate_state(view: String) -> Element(Msg) { +fn empty_crate_state(hero_bar: Element(Msg), view: String) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero("0 in your crate", ctl.badge("◆ FRESH", ctl.Accent)), + hero(hero_bar, "0 in your crate", ctl.badge("◆ FRESH", ctl.Accent)), html.div([attr.class("body list-body")], [ toolbar(view, element.none()), html.div([attr.class("list-scroll")], [ @@ -376,9 +409,13 @@ fn vinyl_glyph() -> Element(Msg) { ) } -fn failed_state() -> Element(Msg) { +fn failed_state(hero_bar: Element(Msg)) -> Element(Msg) { html.div([attr.class("list-screen")], [ - hero("couldn't count your records", ctl.badge("✕ OFFLINE", ctl.Alert)), + hero( + hero_bar, + "couldn't count your records", + ctl.badge("✕ OFFLINE", ctl.Alert), + ), html.div([attr.class("body list-body")], [ html.div([attr.class("list-scroll")], [ html.div([attr.class("error-state")], [ diff --git a/web/src/at_record_web/pages/public_crate.gleam b/web/src/at_record_web/pages/public_crate.gleam index 8162c03..cdc1a43 100644 --- a/web/src/at_record_web/pages/public_crate.gleam +++ b/web/src/at_record_web/pages/public_crate.gleam @@ -9,6 +9,7 @@ import at_record_web/model.{ } import at_record_web/msg.{type Msg} import at_record_web/route +import at_record_web/ui/app_bar as bar import at_record_web/ui/covers as cov import gleam/int import gleam/list @@ -41,7 +42,12 @@ fn loaded_state(handle: String, items: List(Entry)) -> Element(Msg) { fn hero(handle: String, count: Int) -> Element(Msg) { html.div([attr.class("hero")], [ - html.span([attr.class("hero__sticker")], [text("@" <> handle)]), + html.div([attr.class("hero__bar hero__bar--public")], [ + bar.icon_link("←", "Back", "/"), + html.span([attr.class("hero__brand hero__brand--handle")], [ + text("@" <> handle), + ]), + ]), html.span([attr.class("hero__count")], [ text(int.to_string(count) <> " in the crate"), ]), diff --git a/web/src/at_record_web/ui/app_bar.gleam b/web/src/at_record_web/ui/app_bar.gleam index 0af90b0..7b2dbb0 100644 --- a/web/src/at_record_web/ui/app_bar.gleam +++ b/web/src/at_record_web/ui/app_bar.gleam @@ -19,10 +19,6 @@ pub fn app_bar( html.div([attr.class("app-bar")], [left, middle, right]) } -pub fn brand(label: String) -> Element(msg) { - html.span([attr.class("app-bar__title")], [text(label)]) -} - pub fn bar_title(label: String) -> Element(msg) { html.span([attr.class("app-bar__title--sub")], [text(label)]) } @@ -55,6 +51,20 @@ pub fn avatar(initial: String) -> Element(msg) { html.span([attr.class("avatar")], [text(string.uppercase(initial))]) } +/// The avatar is a plain nav anchor to Settings (which holds the account +/// actions), mirroring how `icon_link` builds its bar anchors. +pub fn avatar_link(handle: String, avatar: Option(String)) -> Element(msg) { + html.a( + [ + attr.class("avatar"), + attr.href("/settings"), + attr.title("Settings"), + attr.aria_label("Settings"), + ], + [avatar_content(avatar, handle)], + ) +} + /// The avatar's inner content: the real avatar image over the letter-tile /// fallback (the uppercase initial of `seed`). pub fn avatar_content(avatar: Option(String), seed: String) -> Element(msg) { diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 310e69f..d602978 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -37,14 +37,14 @@ pub fn view(model: Model) -> Element(Msg) { case model.auth, model.busy { LoggedOut, True -> login.restoring() LoggedOut, False -> login.view(model) - LoggedIn(handle), _ -> authed_view(model, handle) + LoggedIn(_), _ -> authed_view(model) } }, ]) } -fn authed_view(model: Model, handle: String) -> Element(Msg) { - html.div([attr.class("shell")], [app_bar(model, handle), page(model)]) +fn authed_view(model: Model) -> Element(Msg) { + html.div([attr.class("shell")], [app_bar(model), page(model)]) } /// Read-only shell for `/u/:handle` routes: no login gate, since the crate @@ -56,7 +56,8 @@ fn public_view(model: Model) -> Element(Msg) { fn public_app_bar(model: Model) -> Element(Msg) { case model.route { - PublicCrate(handle) -> back_bar("@" <> handle) + // Public-crate chrome is inset in its hero band (public_crate.hero). + PublicCrate(_) -> element.none() PublicRecord(_, _) -> back_bar("RECORD") _ -> element.none() } @@ -97,14 +98,10 @@ fn find_entry(items: List(Entry), entry_id: String) -> Option(Entry) { } } -fn app_bar(model: Model, handle: String) -> Element(Msg) { +fn app_bar(model: Model) -> Element(Msg) { case model.route { - Crate -> - bar.app_bar( - bar.brand("AT·RECORD"), - element.none(), - crate_actions(handle, model.avatar), - ) + // Crate-home chrome lives inside the hero band (crate.crate_bar). + Crate -> element.none() Add -> back_bar("ADD RECORD") Scan -> bar.app_bar( @@ -135,28 +132,6 @@ fn back_bar_to(title: String, href: String) -> Element(Msg) { ) } -fn crate_actions(handle: String, avatar: Option(String)) -> Element(Msg) { - html.div([attr.class("app-bar__right")], [ - bar.icon_link("⌗", "Browse the network", "/browse"), - bar.icon_link("⦀", "Scan barcodes", "/scan"), - avatar_link(handle, avatar), - ]) -} - -/// The avatar is a plain nav anchor to Settings (which holds the account -/// actions), mirroring how `bar.icon_link` builds its bar anchors. -fn avatar_link(handle: String, avatar: Option(String)) -> Element(Msg) { - html.a( - [ - attr.class("avatar"), - attr.href("/settings"), - attr.title("Settings"), - attr.aria_label("Settings"), - ], - [bar.avatar_content(avatar, handle)], - ) -} - fn notice_view(notice: Option(Notice)) -> Element(Msg) { case notice { Some(Notice(level, message)) ->