diff --git a/web/css/02-app-bar.css b/web/css/02-app-bar.css index 5ddd344..dcc08a5 100644 --- a/web/css/02-app-bar.css +++ b/web/css/02-app-bar.css @@ -21,9 +21,9 @@ .app-bar { flex-shrink: 0; z-index: 20; - display: flex; + display: grid; + grid-template-columns: 34px minmax(0, 1fr) 34px; align-items: center; - justify-content: space-between; gap: 12px; /* 4px shorter than the masthead per the Figma spec: every remaining app-bar is a sub-page back bar. */ @@ -31,10 +31,37 @@ background: var(--bg); border-bottom: 2px solid var(--ink); } +.app-bar__side { + width: 34px; + min-width: 0; +} +.app-bar__middle { + min-width: 0; + display: flex; + justify-content: center; +} +.app-bar__breadcrumb { + min-width: 0; + display: inline-flex; + align-items: baseline; + justify-content: center; + gap: 7px; + white-space: nowrap; +} +.app-bar__brand { + font: 400 14px/1 var(--display); + letter-spacing: 0.8px; +} +.app-bar__separator { + color: var(--ink-muted); + font: 700 12px/1 var(--mono); +} .app-bar__title--sub { font: 700 13px/1 var(--mono); letter-spacing: 1px; text-transform: uppercase; + overflow: hidden; + text-overflow: ellipsis; } .icon-btn { @@ -98,4 +125,3 @@ reuses the panel-in keyframe defined in 09-record.css. */ animation: panel-in var(--dur-fast) var(--ease-out-cubic); } - diff --git a/web/src/crate_web/route.gleam b/web/src/crate_web/route.gleam index 530f243..49bdcfd 100644 --- a/web/src/crate_web/route.gleam +++ b/web/src/crate_web/route.gleam @@ -7,6 +7,13 @@ import crate_web/model.{ } import gleam/uri.{type Uri} +pub type Layout { + CrateLayout + TopLevelLayout + DrillDownLayout + PublicLayout +} + pub fn parse(target: Uri) -> Route { case uri.path_segments(target.path) { ["add"] -> Add @@ -66,3 +73,22 @@ pub fn section(route: Route) -> Route { PublicCrate(_) | PublicRecord(_, _) -> route } } + +pub fn layout(route: Route) -> Layout { + case route { + Crate -> CrateLayout + Browse | Feed | Settings -> TopLevelLayout + ConnectionsFollowing + | ConnectionsFollowers + | Add + | Scan + | ScanReview + | ScanDone + | EditInbox + | EditProposalDetail(_) + | Record(_) + | RecordAmend(_) + | PressingDetail(_, _) -> DrillDownLayout + PublicCrate(_) | PublicRecord(_, _) -> PublicLayout + } +} diff --git a/web/src/crate_web/ui/app_bar.gleam b/web/src/crate_web/ui/app_bar.gleam index 5ea5f64..a58e8f4 100644 --- a/web/src/crate_web/ui/app_bar.gleam +++ b/web/src/crate_web/ui/app_bar.gleam @@ -15,13 +15,27 @@ pub fn app_bar( middle: Element(msg), right: Element(msg), ) -> Element(msg) { - html.div([attr.class("app-bar")], [left, middle, right]) + html.div([attr.class("app-bar")], [ + html.div([attr.class("app-bar__side app-bar__side--left")], [left]), + html.div([attr.class("app-bar__middle")], [middle]), + html.div([attr.class("app-bar__side app-bar__side--right")], [right]), + ]) } pub fn bar_title(label: String) -> Element(msg) { html.span([attr.class("app-bar__title--sub")], [text(label)]) } +pub fn breadcrumb_title(brand: String, label: String) -> Element(msg) { + html.span([attr.class("app-bar__breadcrumb")], [ + html.span([attr.class("app-bar__brand")], [text(brand)]), + html.span([attr.class("app-bar__separator"), attr.aria_hidden(True)], [ + text("/"), + ]), + bar_title(label), + ]) +} + pub fn icon_link(glyph: String, label: String, href: String) -> Element(msg) { html.a([attr.class("icon-btn"), attr.aria_label(label), attr.href(href)], [ text(glyph), diff --git a/web/src/crate_web/ui/layout.gleam b/web/src/crate_web/ui/layout.gleam new file mode 100644 index 0000000..7e293e6 --- /dev/null +++ b/web/src/crate_web/ui/layout.gleam @@ -0,0 +1,28 @@ +//// Shared page armatures. Routed page modules provide content; this module +//// owns the shell and chrome hierarchy around that content. + +import crate_web/ui/app_bar +import lustre/attribute as attr +import lustre/element.{type Element} +import lustre/element/html + +pub fn shell( + chrome: Element(msg), + page: Element(msg), + navigation: Element(msg), +) -> Element(msg) { + html.div([attr.class("shell")], [chrome, page, navigation]) +} + +pub fn drill_down( + brand: String, + title: String, + left: Element(msg), + right: Element(msg), + body: Element(msg), +) -> Element(msg) { + html.div([attr.class("sub-page")], [ + app_bar.app_bar(left, app_bar.breadcrumb_title(brand, title), right), + body, + ]) +} diff --git a/web/src/crate_web/view.gleam b/web/src/crate_web/view.gleam index 88c7061..d6ea319 100644 --- a/web/src/crate_web/view.gleam +++ b/web/src/crate_web/view.gleam @@ -29,6 +29,7 @@ import crate_web/pages/scan_review import crate_web/pages/settings import crate_web/route import crate_web/ui/app_bar as bar +import crate_web/ui/layout import crate_web/ui/nav import crate_web/ui/record_detail as rd import crate_web/ui/states @@ -54,16 +55,15 @@ pub fn view(model: Model) -> Element(Msg) { } fn authed_view(model: Model) -> Element(Msg) { - html.div([attr.class("shell")], [ - // 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() + layout.shell( + case route.layout(model.route) { + route.TopLevelLayout -> nav.masthead() + route.CrateLayout | route.DrillDownLayout | route.PublicLayout -> + element.none() }, page(model), bottom_bar(model), - ]) + ) } /// Read-only shell for `/u/:handle` routes: no login gate, since the crate @@ -192,14 +192,13 @@ fn sub_page_to( right: Element(Msg), body: Element(Msg), ) -> Element(Msg) { - html.div([attr.class("sub-page")], [ - bar.app_bar( - bar.icon_button("←", "Back", Back(back_href)), - bar.bar_title(title), - right, - ), + layout.drill_down( + "CRATE", + title, + bar.icon_button("←", "Back", Back(back_href)), + right, body, - ]) + ) } fn amend_title(model: Model, entry_id: String) -> String { diff --git a/web/test/crate_test.gleam b/web/test/crate_test.gleam index 57f13c0..ec07a10 100644 --- a/web/test/crate_test.gleam +++ b/web/test/crate_test.gleam @@ -1,8 +1,8 @@ import crate/gen/shelf/list_entries import crate_web/model.{ - type Entry, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Own, OwnCrate, - Record, Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, blank_form, - crate_of, set_crate, + type Entry, Browse, Failure, Grid, LoggedIn, LoggedOut, Model, Notice, Own, + OwnCrate, Record, Rows, ShelfFailed, ShelfLoaded, ShelfLoading, Warning, + blank_form, crate_of, set_crate, } import crate_web/msg.{ ClearNotice, FormTitle, GotBrowseAdd, GotShelf, GotShelfMore, HandleChanged, @@ -212,7 +212,7 @@ pub fn loading_shelf_renders_skeleton_cards_matching_the_chosen_display_test() { // --- NAV1 chrome: masthead + bottom tab bar (app bar / popout retired) --- -pub fn masthead_shows_only_the_wordmark_on_hero_less_routes_test() { +pub fn drill_down_consolidates_brand_and_title_into_one_app_bar_test() { let seeded = set_crate( Model(..logged_in(), route: Record("e1")), @@ -220,6 +220,13 @@ pub fn masthead_shows_only_the_wordmark_on_hero_less_routes_test() { ShelfLoaded(Own, [an_entry()]), ) let html = view.view(seeded) |> element.to_string + assert !string.contains(html, "class=\"masthead\"") + assert string.contains(html, "class=\"app-bar__brand\">CRATE") + assert string.contains(html, "class=\"app-bar__title--sub\">RECORD") +} + +pub fn top_level_route_keeps_the_standalone_masthead_test() { + let html = view.view(Model(..logged_in(), route: Browse)) |> element.to_string assert string.contains( html, "