From a635ef19741e60f14c3c22be0f144c237b7bc83b Mon Sep 17 00:00:00 2001 From: Niels Mokkenstorm Date: Sat, 18 Jul 2026 19:53:02 +0200 Subject: [PATCH] feat(web): replace app bar and fab with masthead and bottom tab bar --- web/css/01-layout.css | 30 ++++- web/css/02-app-bar.css | 39 +++--- web/css/08-fab.css | 24 ---- web/css/08-nav-bar.css | 78 ++++++++++++ web/css/90-interaction.css | 12 +- web/src/at_record_web/model.gleam | 1 + web/src/at_record_web/pages/crate.gleam | 28 +---- web/src/at_record_web/pages/feed.gleam | 15 +++ web/src/at_record_web/route.gleam | 7 +- web/src/at_record_web/ui/app_bar.gleam | 47 +------ web/src/at_record_web/ui/nav.gleam | 71 +++++++++++ web/src/at_record_web/view.gleam | 159 +++++++++++++++--------- web/test/crate_test.gleam | 36 ++++-- 13 files changed, 357 insertions(+), 190 deletions(-) delete mode 100644 web/css/08-fab.css create mode 100644 web/css/08-nav-bar.css create mode 100644 web/src/at_record_web/pages/feed.gleam create mode 100644 web/src/at_record_web/ui/nav.gleam diff --git a/web/css/01-layout.css b/web/css/01-layout.css index e631dc1..3f98401 100644 --- a/web/css/01-layout.css +++ b/web/css/01-layout.css @@ -22,14 +22,32 @@ overflow-y: auto; } -/* The authed shell: app bar (fixed) over the routed page (fills the rest). */ +/* The authed shell: masthead (fixed) over the routed page (fills the rest); + the bottom tab bar is viewport-fixed (see .bottom-bar), not a flex child. */ .shell { flex: 1; min-height: 0; display: flex; flex-direction: column; } -.shell > *:not(.app-bar) { +.shell > *:not(.masthead):not(.bottom-bar) { + flex: 1; + min-height: 0; +} + +/* A drill-down page (add/scan/record/inbox): its own back+title header over + a single scrolling body, same shape as .list-screen below. */ +.sub-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.sub-page > .app-bar { + flex-shrink: 0; +} +.sub-page > *:not(.app-bar) { flex: 1; min-height: 0; } @@ -51,12 +69,14 @@ flex: 1; min-height: 0; overflow-y: auto; - /* Clear the fixed FAB so the last items / LOAD MORE aren't hidden by it. */ - padding-bottom: calc(88px + env(safe-area-inset-bottom)); + /* Clear the fixed bottom bar so the last items / LOAD MORE aren't hidden. */ + padding-bottom: calc(72px + env(safe-area-inset-bottom)); } -/* A sub-page (add/settings/record/scan/inbox) scrolls as a whole. */ +/* A page that scrolls as a whole (add/settings/record/scan/inbox/browse/ + feed); same fixed-bottom-bar clearance as .list-scroll above. */ .page-scroll { overflow-y: auto; + padding-bottom: calc(72px + env(safe-area-inset-bottom)); } diff --git a/web/css/02-app-bar.css b/web/css/02-app-bar.css index 836b30e..5ddd344 100644 --- a/web/css/02-app-bar.css +++ b/web/css/02-app-bar.css @@ -1,4 +1,23 @@ -/* --- app bar ---------------------------------------------------------- */ +/* --- masthead ----------------------------------------------------------- */ +/* Persistent top chrome on every authed screen: wordmark only, nothing + else (browse/scan/avatar shortcuts moved to the bottom tab bar). */ +.masthead { + flex-shrink: 0; + z-index: 20; + height: 52px; + display: flex; + align-items: center; + padding: 0 18px; + background: var(--bg); + border-bottom: 2px solid var(--ink); +} +.masthead__word { + font: 400 16px/1 var(--display); + letter-spacing: 1px; + color: var(--ink); +} + +/* --- sub-page header (back + title, drill-down pages only) ------------- */ .app-bar { flex-shrink: 0; z-index: 20; @@ -6,29 +25,17 @@ align-items: center; justify-content: space-between; gap: 12px; - padding: 10px 16px 12px; + /* 4px shorter than the masthead per the Figma spec: every remaining + app-bar is a sub-page back bar. */ + padding: 8px 16px 10px; background: var(--bg); border-bottom: 2px solid var(--ink); } -/* Sub-page back bars are 4px shorter than the brand bar in the Figma spec. */ -.app-bar:has(> .app-bar__title--sub) { - padding: 8px 16px 10px; -} -.app-bar__title { - font: 400 22px/1 var(--display); - letter-spacing: 1.5px; - padding: 2px 0; -} .app-bar__title--sub { font: 700 13px/1 var(--mono); letter-spacing: 1px; text-transform: uppercase; } -.app-bar__right { - display: flex; - align-items: center; - gap: 10px; -} .icon-btn { width: 34px; diff --git a/web/css/08-fab.css b/web/css/08-fab.css deleted file mode 100644 index cc64da4..0000000 --- a/web/css/08-fab.css +++ /dev/null @@ -1,24 +0,0 @@ -/* --- fab -------------------------------------------------------------- */ -.fab { - position: fixed; - right: max(16px, calc(50vw - 195px + 16px)); - bottom: calc(24px + env(safe-area-inset-bottom)); - display: inline-flex; - align-items: center; - gap: 6px; - background: var(--accent); - border: 2px solid var(--ink); - box-shadow: var(--shadow-md); - color: var(--ink-fixed); - padding: 14px 20px; - text-decoration: none; - z-index: 5; -} -.fab__plus { - font: 400 22px/1 var(--display); -} -.fab__label { - font: 700 13px/1 var(--mono); - letter-spacing: 0.5px; -} - diff --git a/web/css/08-nav-bar.css b/web/css/08-nav-bar.css new file mode 100644 index 0000000..f708539 --- /dev/null +++ b/web/css/08-nav-bar.css @@ -0,0 +1,78 @@ +/* --- bottom tab bar ----------------------------------------------------- */ +/* Viewport-fixed (not a flex child), aligned to the same centered column as + .app; .list-scroll/.page-scroll carry matching bottom padding so content + never sits under it. box-sizing is overridden so the safe-area padding + adds below the 56px row instead of squeezing it. */ +.bottom-bar { + box-sizing: content-box; + position: fixed; + left: max(0px, calc(50vw - 195px)); + right: max(0px, calc(50vw - 195px)); + bottom: 0; + z-index: 20; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 18px env(safe-area-inset-bottom); + background: var(--bg); + border-top: 2px solid var(--ink); +} + +.tab { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: var(--ink-muted); + text-decoration: none; + font: 700 10px/1 var(--mono); + letter-spacing: 0.5px; + text-transform: uppercase; +} +.tab.is-active { + color: var(--ink); +} +.tab__label { + display: inline-flex; + align-items: center; +} +.tab__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + height: 14px; + margin-left: 4px; + padding: 0 3px; + background: var(--mustard); + color: var(--ink-fixed); + border: 1px solid var(--ink); + font: 700 9px/1 var(--mono); + font-variant-numeric: tabular-nums; +} +/* Always mounted so the active/inactive swap never shifts layout; only the + fill toggles. */ +.tab__underline { + width: 20px; + height: 3px; + background: transparent; +} +.tab__underline.is-active { + background: var(--ink); +} + +/* The center ADD slot, replacing the old floating action button. */ +.tab-add { + width: 40px; + height: 40px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--mustard); + border: 2px solid var(--ink); + color: var(--ink); + font: 400 20px/1 var(--display); + text-decoration: none; +} diff --git a/web/css/90-interaction.css b/web/css/90-interaction.css index 0e2636e..542c589 100644 --- a/web/css/90-interaction.css +++ b/web/css/90-interaction.css @@ -15,7 +15,8 @@ textarea { .icon-btn:focus-visible, .display-toggle__btn:focus-visible, .segment button:focus-visible, -.fab:focus-visible, +.tab:focus-visible, +.tab-add:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { @@ -51,14 +52,16 @@ button.chip::before, .icon-btn, button.chip, .display-toggle__btn, -.fab, +.tab, +.tab-add, .segment button { transition: transform 100ms ease; } .icon-btn:active, button.chip:active, .display-toggle__btn:active, -.fab:active, +.tab:active, +.tab-add:active, .segment button:active { transform: scale(0.97); } @@ -74,7 +77,8 @@ button.chip:active, .icon-btn, button.chip, .display-toggle__btn, - .fab, + .tab, + .tab-add, .segment button { transition: none; } diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index cffa0d9..47ff704 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -22,6 +22,7 @@ pub type Route { ScanDone Record(entry_id: String) Browse + Feed EditInbox /// One proposal's detail screen, off `EditInbox`. EditProposalDetail(id: String) diff --git a/web/src/at_record_web/pages/crate.gleam b/web/src/at_record_web/pages/crate.gleam index f7085e7..a52db7f 100644 --- a/web/src/at_record_web/pages/crate.gleam +++ b/web/src/at_record_web/pages/crate.gleam @@ -5,14 +5,13 @@ import at_record/gen/defs.{type Snapshot} import at_record_web/model.{ - type Display, type Entry, type Model, Add, Grid, LoggedIn, LoggedOut, Record, - Rows, Scan, ShelfFailed, ShelfLoaded, ShelfLoading, + type Display, type Entry, type Model, Add, Grid, Record, Rows, Scan, + ShelfFailed, ShelfLoaded, ShelfLoading, } import at_record_web/msg.{ type Msg, RetryShelf, SetDisplay, SetView, ShowMoreCrate, } import at_record_web/route -import at_record_web/ui/app_bar as bar 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 @@ -27,7 +26,8 @@ import lustre/element/svg import lustre/event pub fn view(model: Model) -> Element(Msg) { - let hero_bar = crate_bar(model) + // NAV1 chrome owns brand/browse/scan/avatar; the hero keeps no inset bar. + let hero_bar = element.none() case model.shelf { ShelfLoading -> loading_state(hero_bar, model.view, model.display) ShelfFailed -> failed_state(hero_bar) @@ -45,22 +45,6 @@ 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, @@ -91,7 +75,6 @@ fn loaded_state( ], ), ]), - bar.fab("ADD", route.to_path(Scan)), ]) } @@ -302,7 +285,6 @@ fn loading_state( ]), ]), ]), - bar.fab("ADD", route.to_path(Scan)), ]) } @@ -357,7 +339,6 @@ fn empty_filter_state( html.p([attr.class("empty")], [text(hint)]), ]), ]), - bar.fab("ADD", route.to_path(Scan)), ]) } @@ -469,6 +450,5 @@ fn failed_state(hero_bar: Element(Msg)) -> Element(Msg) { ]), ]), ]), - bar.fab("ADD", route.to_path(Scan)), ]) } diff --git a/web/src/at_record_web/pages/feed.gleam b/web/src/at_record_web/pages/feed.gleam new file mode 100644 index 0000000..af4e5d5 --- /dev/null +++ b/web/src/at_record_web/pages/feed.gleam @@ -0,0 +1,15 @@ +//// The Feed page: the network activity feed tab. Placeholder until there's +//// a feed endpoint to back it - the tab exists so NAV1's five-slot bottom +//// bar is real today. + +import at_record_web/model.{type Model} +import at_record_web/msg.{type Msg} +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn view(_model: Model) -> Element(Msg) { + html.div([attr.class("body page-scroll")], [ + html.p([attr.class("hint")], [text("Your network feed is coming soon.")]), + ]) +} diff --git a/web/src/at_record_web/route.gleam b/web/src/at_record_web/route.gleam index 222ba7f..d968c05 100644 --- a/web/src/at_record_web/route.gleam +++ b/web/src/at_record_web/route.gleam @@ -1,8 +1,9 @@ //// URL <-> Route mapping for modem. import at_record_web/model.{ - type Route, Add, Browse, Crate, EditInbox, EditProposalDetail, PressingDetail, - PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, Settings, + type Route, Add, Browse, Crate, EditInbox, EditProposalDetail, Feed, + PressingDetail, PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, + Settings, } import gleam/uri.{type Uri} @@ -13,6 +14,7 @@ pub fn parse(target: Uri) -> Route { ["scan", "review"] -> ScanReview ["scan", "done"] -> ScanDone ["browse"] -> Browse + ["feed"] -> Feed ["inbox"] -> EditInbox ["inbox", id] -> EditProposalDetail(id) ["settings"] -> Settings @@ -32,6 +34,7 @@ pub fn to_path(route: Route) -> String { ScanReview -> "/scan/review" ScanDone -> "/scan/done" Browse -> "/browse" + Feed -> "/feed" EditInbox -> "/inbox" EditProposalDetail(id) -> "/inbox/" <> id Settings -> "/settings" diff --git a/web/src/at_record_web/ui/app_bar.gleam b/web/src/at_record_web/ui/app_bar.gleam index 7b2dbb0..68e777a 100644 --- a/web/src/at_record_web/ui/app_bar.gleam +++ b/web/src/at_record_web/ui/app_bar.gleam @@ -1,9 +1,8 @@ -//// App bar chrome: nav bar, brand/title text, icon buttons, avatar, popout -//// links, and the floating action button. +//// Sub-page header chrome: the back-arrow + title row drill-down pages use +//// (add/scan/record/inbox), plus small shared bits (icon buttons, avatar) +//// that aren't part of the persistent NAV1 chrome in `ui/nav.gleam`. -import at_record_web/ui/controls.{Accent} import at_record_web/ui/covers -import gleam/int import gleam/option.{type Option, None, Some} import gleam/string import lustre/attribute as attr @@ -40,31 +39,10 @@ pub fn icon_button( ) } -pub fn icon_disabled(glyph: String, label: String) -> Element(msg) { - html.button( - [attr.class("icon-btn"), attr.aria_label(label), attr.disabled(True)], - [text(glyph)], - ) -} - 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) { @@ -73,22 +51,3 @@ pub fn avatar_content(avatar: Option(String), seed: String) -> Element(msg) { None -> text(covers.cover_initial(seed)) } } - -/// A full-width popout row that navigates on click, with an accent count -/// badge when `count` is positive (0 renders no badge at all). -pub fn popout_link(label: String, href: String, count: Int) -> Element(msg) { - html.a([attr.class("popout__link"), attr.href(href)], [ - text(label), - case count > 0 { - True -> controls.badge(int.to_string(count), Accent) - False -> element.none() - }, - ]) -} - -pub fn fab(label: String, href: String) -> Element(msg) { - html.a([attr.class("fab"), attr.href(href)], [ - html.span([attr.class("fab__plus")], [text("+")]), - html.span([attr.class("fab__label")], [text(label)]), - ]) -} diff --git a/web/src/at_record_web/ui/nav.gleam b/web/src/at_record_web/ui/nav.gleam new file mode 100644 index 0000000..7198da4 --- /dev/null +++ b/web/src/at_record_web/ui/nav.gleam @@ -0,0 +1,71 @@ +//// NAV1 chrome: the persistent masthead (top) and tab bar (bottom), rendered +//// on every authed screen in place of the old per-route app bar and FAB. + +import gleam/int +import lustre/attribute as attr +import lustre/element.{type Element, text} +import lustre/element/html + +pub fn masthead() -> Element(msg) { + html.div([attr.class("masthead")], [ + html.span([attr.class("masthead__word")], [text("CRATE")]), + ]) +} + +pub fn bottom_bar(children: List(Element(msg))) -> Element(msg) { + html.nav( + [attr.class("bottom-bar"), attr.attribute("aria-label", "Primary")], + children, + ) +} + +/// A tab link; `badge` renders a small mustard count pill when positive (0 +/// renders none). The underline always mounts so switching tabs never shifts +/// layout - only its fill colour toggles with `active`. +pub fn tab( + label: String, + href: String, + active: Bool, + badge: Int, +) -> Element(msg) { + html.a([attr.class(tab_class(active)), attr.href(href)], [ + html.span([attr.class("tab__label")], [text(label), badge_view(badge)]), + html.span( + [ + attr.class(underline_class(active)), + attr.attribute("aria-hidden", "true"), + ], + [], + ), + ]) +} + +/// The center ADD slot: a square link straight into the scan flow, replacing +/// the old floating action button. +pub fn add_tab(href: String) -> Element(msg) { + html.a( + [attr.class("tab-add"), attr.href(href), attr.aria_label("Add a record")], + [text("+")], + ) +} + +fn tab_class(active: Bool) -> String { + case active { + True -> "tab is-active" + False -> "tab" + } +} + +fn underline_class(active: Bool) -> String { + case active { + True -> "tab__underline is-active" + False -> "tab__underline" + } +} + +fn badge_view(count: Int) -> Element(msg) { + case count > 0 { + True -> html.span([attr.class("tab__badge")], [text(int.to_string(count))]) + False -> element.none() + } +} diff --git a/web/src/at_record_web/view.gleam b/web/src/at_record_web/view.gleam index 83f1dc6..0ad5734 100644 --- a/web/src/at_record_web/view.gleam +++ b/web/src/at_record_web/view.gleam @@ -3,9 +3,9 @@ import at_record_web/model.{ type Entry, type Model, type Notice, type NoticeLevel, Add, Browse, Crate, - EditInbox, EditProposalDetail, Failure, Info, LoggedIn, LoggedOut, Notice, - PressingDetail, PublicCrate, PublicRecord, Record, Scan, ScanDone, ScanReview, - Settings, Success, Warning, + EditInbox, EditProposalDetail, Failure, Feed, Info, LoggedIn, LoggedOut, + Notice, PressingDetail, PublicCrate, PublicRecord, Record, Scan, ScanDone, + ScanReview, Settings, Success, Warning, inbox_pending_count, } import at_record_web/msg.{type Msg, ClearNotice} import at_record_web/pages/add @@ -13,6 +13,7 @@ import at_record_web/pages/browse import at_record_web/pages/crate import at_record_web/pages/edit_inbox import at_record_web/pages/edit_proposal +import at_record_web/pages/feed import at_record_web/pages/login import at_record_web/pages/pressing import at_record_web/pages/public_crate @@ -24,6 +25,7 @@ import at_record_web/pages/scan_review import at_record_web/pages/settings import at_record_web/route import at_record_web/ui/app_bar as bar +import at_record_web/ui/nav import gleam/list import gleam/option.{type Option, None, Some} import lustre/attribute as attr @@ -47,7 +49,11 @@ pub fn view(model: Model) -> Element(Msg) { } fn authed_view(model: Model) -> Element(Msg) { - html.div([attr.class("shell")], [app_bar(model), page(model)]) + html.div([attr.class("shell")], [ + nav.masthead(), + page(model), + bottom_bar(model), + ]) } /// Read-only shell for `/u/:handle` routes: no login gate, since the crate @@ -61,7 +67,12 @@ fn public_app_bar(model: Model) -> Element(Msg) { case model.route { // Public-crate chrome is inset in its hero band (public_crate.hero). PublicCrate(_) -> element.none() - PublicRecord(_, _) -> back_bar("RECORD") + PublicRecord(_, _) -> + bar.app_bar( + bar.icon_link("←", "Back", "/"), + bar.bar_title("RECORD"), + element.none(), + ) _ -> element.none() } } @@ -74,81 +85,111 @@ fn public_page(model: Model) -> Element(Msg) { } } +/// Every screen but the four tab destinations is a drill-down reached by +/// tapping into something (a record, the scan/add flow, an inbox proposal), +/// so it keeps its own back+title header instead of one of the bottom tabs +/// tracking it as active. fn page(model: Model) -> Element(Msg) { case model.route { Crate -> crate.view(model) - Add -> add.view(model) - Scan -> scan.view(model) - ScanReview -> scan_review.view(model) - ScanDone -> scan_done.view(model) Browse -> browse.view(model) - EditInbox -> edit_inbox.view(model) - EditProposalDetail(id) -> - case model.inbox { - model.InboxLoaded(_) -> - case model.find_proposal_card(model.inbox, id) { - Some(card) -> edit_proposal.view(card) - // Unknown/expired id (stale link, or already ignored elsewhere): - // fall back to the list rather than a dead end. - None -> edit_inbox.view(model) - } - model.InboxLoading -> element.none() - model.InboxFailed -> edit_inbox.view(model) - } + Feed -> feed.view(model) Settings -> settings.view(model) + Add -> sub_page("ADD RECORD", element.none(), add.view(model)) + Scan -> + sub_page( + "SCAN BARCODE", + bar.icon_link("✎", "Add manually", route.to_path(Add)), + scan.view(model), + ) + ScanReview -> + sub_page_to( + "REVIEW YOUR HAUL", + route.to_path(Scan), + element.none(), + scan_review.view(model), + ) + ScanDone -> + sub_page("IMPORT COMPLETE", element.none(), scan_done.view(model)) + EditInbox -> sub_page("EDIT INBOX", element.none(), edit_inbox.view(model)) + EditProposalDetail(id) -> + sub_page_to( + "SUGGESTED FIX", + route.to_path(EditInbox), + element.none(), + edit_proposal_page(model, id), + ) Record(entry_id) -> case find_entry(model.entries(model), entry_id) { - Some(entry) -> record.view(model, entry) + Some(entry) -> + sub_page("RECORD", element.none(), record.view(model, entry)) None -> crate.view(model) } - PressingDetail(_, _) -> pressing.view(model) + PressingDetail(_, _) -> + sub_page("PRESSING", element.none(), pressing.view(model)) // Dispatched by `public_page` before `authed_view`/`page` are reached. PublicCrate(_) | PublicRecord(_, _) -> crate.view(model) } } -fn find_entry(items: List(Entry), entry_id: String) -> Option(Entry) { - case list.find(items, fn(entry) { entry.entry_id == entry_id }) { - Ok(entry) -> Some(entry) - Error(_) -> None - } +fn sub_page( + title: String, + right: Element(Msg), + body: Element(Msg), +) -> Element(Msg) { + sub_page_to(title, "/", right, body) } -fn app_bar(model: Model) -> Element(Msg) { - case model.route { - // Crate-home chrome lives inside the hero band (crate.crate_bar). - Crate -> element.none() - Add -> back_bar("ADD RECORD") - Scan -> - bar.app_bar( - bar.icon_link("←", "Back", "/"), - bar.bar_title("SCAN BARCODE"), - bar.icon_link("✎", "Add manually", route.to_path(Add)), - ) - ScanReview -> back_bar_to("REVIEW YOUR HAUL", route.to_path(Scan)) - ScanDone -> back_bar("IMPORT COMPLETE") - Browse -> back_bar("BROWSE") - EditInbox -> back_bar("EDIT INBOX") - EditProposalDetail(_) -> - back_bar_to("SUGGESTED FIX", route.to_path(EditInbox)) - Settings -> back_bar("SETTINGS") - Record(_) -> back_bar("RECORD") - PressingDetail(_, _) -> back_bar("PRESSING") - // Dispatched by `public_app_bar` before `authed_view`/`app_bar` are reached. - PublicCrate(_) | PublicRecord(_, _) -> back_bar("RECORD") +fn sub_page_to( + title: String, + back_href: String, + right: Element(Msg), + body: Element(Msg), +) -> Element(Msg) { + html.div([attr.class("sub-page")], [ + bar.app_bar( + bar.icon_link("←", "Back", back_href), + bar.bar_title(title), + right, + ), + body, + ]) +} + +fn edit_proposal_page(model: Model, id: String) -> Element(Msg) { + case model.inbox { + model.InboxLoaded(_) -> + case model.find_proposal_card(model.inbox, id) { + Some(card) -> edit_proposal.view(card) + // Unknown/expired id (stale link, or already ignored elsewhere): + // fall back to the list rather than a dead end. + None -> edit_inbox.view(model) + } + model.InboxLoading -> element.none() + model.InboxFailed -> edit_inbox.view(model) } } -fn back_bar(title: String) -> Element(Msg) { - back_bar_to(title, "/") +fn find_entry(items: List(Entry), entry_id: String) -> Option(Entry) { + case list.find(items, fn(entry) { entry.entry_id == entry_id }) { + Ok(entry) -> Some(entry) + Error(_) -> None + } } -fn back_bar_to(title: String, href: String) -> Element(Msg) { - bar.app_bar( - bar.icon_link("←", "Back", href), - bar.bar_title(title), - bar.icon_disabled("⋯", "More actions"), - ) +fn bottom_bar(model: Model) -> Element(Msg) { + nav.bottom_bar([ + nav.tab("CRATE", route.to_path(Crate), model.route == Crate, 0), + nav.tab("BROWSE", route.to_path(Browse), model.route == Browse, 0), + nav.add_tab(route.to_path(Scan)), + nav.tab("FEED", route.to_path(Feed), model.route == Feed, 0), + nav.tab( + "YOU", + route.to_path(Settings), + model.route == Settings, + inbox_pending_count(model.inbox), + ), + ]) } fn notice_view(notice: Option(Notice)) -> Element(Msg) { diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 584106d..ad3174d 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -148,27 +148,23 @@ pub fn empty_owned_view_shows_first_run_panel_with_toolbar_test() { assert string.contains(html, "NOTHING IN THE CRATE YET") assert string.contains(html, "WANTED") assert string.contains(html, "HISTORY") - // The first-run state is the only crate state without the FAB. - assert !string.contains(html, "class=\"fab\"") } -pub fn empty_wanted_view_keeps_toolbar_and_fab_test() { +pub fn empty_wanted_view_keeps_toolbar_test() { let html = rendered_empty_crate("wanted") assert string.contains(html, "OWNED") assert string.contains(html, "HISTORY") assert string.contains(html, "0 on your wishlist") assert string.contains(html, "Nothing on your wishlist yet") - assert string.contains(html, "class=\"fab\"") assert !string.contains(html, "NOTHING IN THE CRATE YET") } -pub fn empty_history_view_keeps_toolbar_and_fab_test() { +pub fn empty_history_view_keeps_toolbar_test() { let html = rendered_empty_crate("history") assert string.contains(html, "OWNED") assert string.contains(html, "WANTED") assert string.contains(html, "0 in your history") assert string.contains(html, "No history yet") - assert string.contains(html, "class=\"fab\"") assert !string.contains(html, "NOTHING IN THE CRATE YET") } @@ -203,19 +199,35 @@ pub fn loaded_rows_render_title_and_muted_artist_format_meta_test() { assert !string.contains(html, "class=\"cover-card\"") } -pub fn crate_app_bar_avatar_links_to_settings_test() { +// --- 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 + 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, "href=\"/settings\"") + assert string.contains(html, "class=\"tab\" href=\"/settings\"") // The account popout is gone: its actions now live on the Settings page, - // never in the crate app bar itself. + // reached via the YOU tab, never inline in the nav chrome itself. assert !string.contains(html, "EDIT INBOX") assert !string.contains(html, "LOG OUT") } -pub fn crate_app_bar_icon_links_carry_aria_labels_test() { +pub fn bottom_bar_tabs_link_to_their_routes_test() { let html = view.view(logged_in()) |> element.to_string - assert string.contains(html, "aria-label=\"Browse the network\"") - assert string.contains(html, "aria-label=\"Scan barcodes\"") + // Crate is the default route, so its tab is the active one. + assert string.contains(html, "class=\"tab is-active\" href=\"/\"") + assert string.contains(html, "class=\"tab\" href=\"/browse\"") + assert string.contains(html, "class=\"tab\" href=\"/feed\"") + assert string.contains( + html, + "aria-label=\"Add a record\" class=\"tab-add\" href=\"/scan\"", + ) } fn many_entries(n: Int) -> List(Entry) { -- 2.51.2