diff --git a/web/css/00-tokens.css b/web/css/00-tokens.css index 38f7ec1..3b87828 100644 --- a/web/css/00-tokens.css +++ b/web/css/00-tokens.css @@ -38,8 +38,9 @@ --dur-slower: 300ms; } +/* Duplicated below for :root[data-theme=dark]: a media query and a selector can't share one custom-property block. */ @media (prefers-color-scheme: dark) { - :root { + :root:not([data-theme="light"]) { --bg: #191008; --surface: #251a10; --ink: #f1e7d2; @@ -52,6 +53,18 @@ } } +:root[data-theme="dark"] { + --bg: #191008; + --surface: #251a10; + --ink: #f1e7d2; + --ink-muted: #a08a6b; + --line: #3a2d1e; + --owned: #4d7046; + --wanted: #d07a4d; + --danger: #c25736; + --shadow-color: #0c0805; +} + * { box-sizing: border-box; border-radius: 0; diff --git a/web/priv/static/index.html b/web/priv/static/index.html index 8cb4cde..20dfd2b 100644 --- a/web/priv/static/index.html +++ b/web/priv/static/index.html @@ -4,6 +4,17 @@ + at-record diff --git a/web/src/at_record_web.gleam b/web/src/at_record_web.gleam index 73128fa..f68887e 100644 --- a/web/src/at_record_web.gleam +++ b/web/src/at_record_web.gleam @@ -37,6 +37,7 @@ fn init(_flags) -> #(Model, Effect(Msg)) { login_error:, view: "owned", display: initial_display(), + theme: initial_theme(), shelf: model.ShelfLoading, crate_window: model.crate_window_size, crate_cursor: None, @@ -130,6 +131,12 @@ fn initial_display() -> model.Display { |> result.unwrap(model.Grid) } +fn initial_theme() -> model.Theme { + prefs.get(prefs.theme_key) + |> result.map(model.theme_from_string) + |> result.unwrap(model.System) +} + /// The persisted locally-ignored edit-proposal uri+cid pairs, same /// synchronous-at-init reasoning as `initial_display`. fn initial_ignored_proposals() -> List(#(String, String)) { diff --git a/web/src/at_record_web/browser.gleam b/web/src/at_record_web/browser.gleam index 9e9e179..d6eec59 100644 --- a/web/src/at_record_web/browser.gleam +++ b/web/src/at_record_web/browser.gleam @@ -6,6 +6,9 @@ pub fn current_search() -> String @external(javascript, "./ffi.mjs", "clearQuery") pub fn clear_query() -> Nil +@external(javascript, "./ffi.mjs", "applyTheme") +pub fn apply_theme(theme: String) -> Nil + @external(javascript, "./ffi.mjs", "navigate") pub fn navigate(url: String) -> Nil diff --git a/web/src/at_record_web/effects.gleam b/web/src/at_record_web/effects.gleam index 9f45e61..ca681cc 100644 --- a/web/src/at_record_web/effects.gleam +++ b/web/src/at_record_web/effects.gleam @@ -13,7 +13,7 @@ import at_record_web/browser import at_record_web/model.{ type AmendDraft, type BrowseRelease, type DiscogsResult, type Display, type EditProposal, type Entry, type Form, type ImportRun, type NetworkMatch, - type ReleaseInfo, type Suggestion, BrowseRelease, DiscogsResult, + type ReleaseInfo, type Suggestion, type Theme, BrowseRelease, DiscogsResult, DiscogsSearchPage, EditProposal, Entry, HandleSuggestion, ImportRun, NetworkMatch, ProposalFields, ReleaseInfo, Suggestion, } @@ -441,6 +441,14 @@ pub fn persist_display(display: Display) -> Effect(Msg) { }) } +pub fn persist_theme(theme: Theme) -> Effect(Msg) { + effect.from(fn(_dispatch) { + let value = model.theme_to_string(theme) + prefs.set(prefs.theme_key, value) + browser.apply_theme(value) + }) +} + /// Persist the locally-ignored edit-proposal uri+cid pairs. pub fn persist_ignored_proposals( ignored: List(#(String, String)), diff --git a/web/src/at_record_web/ffi.mjs b/web/src/at_record_web/ffi.mjs index 732892d..53d834f 100644 --- a/web/src/at_record_web/ffi.mjs +++ b/web/src/at_record_web/ffi.mjs @@ -10,6 +10,16 @@ export function clearQuery() { } } +export function applyTheme(theme) { + const root = globalThis.document && globalThis.document.documentElement; + if (!root) return; + if (theme === "light" || theme === "dark") { + root.setAttribute("data-theme", theme); + } else { + root.removeAttribute("data-theme"); + } +} + // A real full-page navigation, since modem would otherwise intercept a same-origin click. export function navigate(url) { if (globalThis.location) { diff --git a/web/src/at_record_web/model.gleam b/web/src/at_record_web/model.gleam index 380885d..77b4fa4 100644 --- a/web/src/at_record_web/model.gleam +++ b/web/src/at_record_web/model.gleam @@ -82,6 +82,28 @@ pub fn display_from_string(value: String) -> Display { } } +pub type Theme { + Light + Dark + System +} + +pub fn theme_to_string(theme: Theme) -> String { + case theme { + Light -> "light" + Dark -> "dark" + System -> "system" + } +} + +pub fn theme_from_string(value: String) -> Theme { + case value { + "light" -> Light + "dark" -> Dark + _ -> System + } +} + /// Provenance for a crate entry, narrowed to just what the detail page's /// source line needs: the capture origin and the external provider's slug. pub type Source { @@ -568,6 +590,7 @@ pub type Model { login_error: Option(String), view: String, display: Display, + theme: Theme, shelf: Shelf, // How many crate items are currently revealed (client-side windowing). crate_window: Int, diff --git a/web/src/at_record_web/msg.gleam b/web/src/at_record_web/msg.gleam index 536937a..9798113 100644 --- a/web/src/at_record_web/msg.gleam +++ b/web/src/at_record_web/msg.gleam @@ -3,7 +3,7 @@ import at_record_web/model.{ type ArtistHit, type BrowseRelease, type DiscogsResult, type DiscogsSearchPage, type Display, type EditProposal, type Entry, type HandleSuggestion, type ImportRun, type NetworkMatch, type ReleaseInfo, type Route, type ScanMode, - type Suggestion, + type Suggestion, type Theme, } import at_record_web/photo_scan.{type DecodeError} import gleam/dict @@ -74,6 +74,7 @@ pub type Msg { GotLogout SetView(String) SetDisplay(Display) + SetTheme(Theme) ShowMoreCrate GotShelf(Result(ShelfData, rsvp.Error(String))) // The response to a cursor-driven LOAD MORE fetch, once the local window diff --git a/web/src/at_record_web/pages/settings.gleam b/web/src/at_record_web/pages/settings.gleam index 8999938..ce80f58 100644 --- a/web/src/at_record_web/pages/settings.gleam +++ b/web/src/at_record_web/pages/settings.gleam @@ -2,10 +2,10 @@ //// here from Add), a link into the edit inbox with its pending count, and log //// out. Reached by tapping the app-bar avatar. -import at_record_web/model.{type Model, EditInbox, LoggedIn} +import at_record_web/model.{type Model, type Theme, EditInbox, LoggedIn} import at_record_web/msg.{ type Msg, ArmLogout, DiscogsConnect, DiscogsDisconnect, DiscogsImport, - DiscogsImportWantlist, Logout, + DiscogsImportWantlist, Logout, SetTheme, } import at_record_web/route import at_record_web/ui/app_bar as bar @@ -22,6 +22,7 @@ import lustre/event pub fn view(model: Model) -> Element(Msg) { html.div([attr.class("body page-scroll")], [ account_header(model), + theme_row(model.theme), discogs_account_view(model), inbox_row(model), logout_button(model.confirm_logout), @@ -50,6 +51,18 @@ fn current_handle(model: Model) -> String { } } +fn theme_row(theme: Theme) -> Element(Msg) { + html.div([attr.class("settings-row")], [ + html.span([attr.class("settings-row__label")], [text("THEME")]), + html.span([attr.class("settings-row__spacer")], []), + frm.segment( + [#("light", "LIGHT"), #("system", "SYSTEM"), #("dark", "DARK")], + model.theme_to_string(theme), + fn(key) { SetTheme(model.theme_from_string(key)) }, + ), + ]) +} + // One link (label, badge, spacer, chevron) so the whole row is a single tap target. fn inbox_row(model: Model) -> Element(Msg) { let count = model.inbox_pending_count(model.inbox) diff --git a/web/src/at_record_web/prefs.gleam b/web/src/at_record_web/prefs.gleam index e0c25f6..db53b81 100644 --- a/web/src/at_record_web/prefs.gleam +++ b/web/src/at_record_web/prefs.gleam @@ -17,6 +17,8 @@ fn ffi_set(key: String, value: String) -> Nil /// The crate page's grid/rows toggle. pub const display_key = "display" +pub const theme_key = "theme" + /// Locally-ignored `catalog.edit` proposal uri+cid pairs, capped and JSON-encoded. pub const ignored_proposals_key = "ignored-proposals" diff --git a/web/src/at_record_web/update.gleam b/web/src/at_record_web/update.gleam index d51d55e..915cd28 100644 --- a/web/src/at_record_web/update.gleam +++ b/web/src/at_record_web/update.gleam @@ -34,9 +34,9 @@ import at_record_web/msg.{ GotPublicEntry, GotPublicShelf, GotScanResult, GotScanSeen, GotShelf, GotShelfMore, GotTimeline, HandleChanged, HandleSearch, IgnoreProposal, LinkCopied, Logout, OnRouteChange, PhotoCaptured, Rate, Regrade, RemoveScanRow, - RetryBatchItem, RetryShelf, SetDisplay, SetScanMode, SetView, ShowMoreCrate, - StartBatchImport, StartLogin, SubmitAdd, SubmitAmend, TakePhoto, ToggleAmend, - ToggleAmendCover, ToggleBrowseFilters, ToggleEdit, ToggleEntry, + RetryBatchItem, RetryShelf, SetDisplay, SetScanMode, SetTheme, SetView, + ShowMoreCrate, StartBatchImport, StartLogin, SubmitAdd, SubmitAmend, TakePhoto, + ToggleAmend, ToggleAmendCover, ToggleBrowseFilters, ToggleEdit, ToggleEntry, TriggerBrowseSearch, UseArtist, UseDiscogs, UseHandleSuggestion, } import at_record_web/photo_scan @@ -264,6 +264,8 @@ pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { effects.persist_display(display), ) + SetTheme(theme) -> #(Model(..model, theme:), effects.persist_theme(theme)) + // Widening the window is free when enough is already fetched; only once // the window catches up does this reach for the next page over the wire. ShowMoreCrate -> { diff --git a/web/test/prefs_test.gleam b/web/test/prefs_test.gleam index 3614f4a..5f7220c 100644 --- a/web/test/prefs_test.gleam +++ b/web/test/prefs_test.gleam @@ -2,10 +2,10 @@ //// that persists/loads them. import at_record_web/model.{ - type EditProposal, EditProposal, Grid, InboxCard, InboxLoaded, Model, - ProposalFields, ProposalReviewing, Rows, + type EditProposal, Dark, EditProposal, Grid, InboxCard, InboxLoaded, Light, + Model, ProposalFields, ProposalReviewing, Rows, System, } -import at_record_web/msg.{GotEditInbox, IgnoreProposal, SetDisplay} +import at_record_web/msg.{GotEditInbox, IgnoreProposal, SetDisplay, SetTheme} import at_record_web/prefs import at_record_web/update.{update} import gleam/int @@ -58,6 +58,25 @@ pub fn set_display_fires_a_persist_effect_test() { assert effect != empty_effect() } +pub fn theme_string_codec_test() { + [#(Light, "light"), #(System, "system"), #(Dark, "dark")] + |> list.each(fn(pair) { + let #(theme, str) = pair + assert model.theme_to_string(theme) == str + assert model.theme_from_string(str) == theme + }) + ["", "garbage"] + |> list.each(fn(value) { + assert model.theme_from_string(value) == System + }) +} + +pub fn set_theme_updates_model_and_fires_a_persist_effect_test() { + let #(model, effect) = update(logged_in(), SetTheme(Dark)) + assert model.theme == Dark + assert effect != empty_effect() +} + pub fn ignored_proposals_round_trip_through_json_test() { let ignored = [#("at://a/edit/1", "cid-1"), #("at://a/edit/2", "cid-2")] let decoded = diff --git a/web/test/settings_test.gleam b/web/test/settings_test.gleam index 658dda3..004a5b1 100644 --- a/web/test/settings_test.gleam +++ b/web/test/settings_test.gleam @@ -1,5 +1,6 @@ import at_record_web/model.{ - Discogs, Failure, ImportRun, Model, Notice, Settings, Success, blank_discogs, + Dark, Discogs, Failure, ImportRun, Model, Notice, Settings, Success, + blank_discogs, } import at_record_web/msg.{ ArmLogout, DisarmLogout, DiscogsImportWantlist, GotDiscogsImport, @@ -91,6 +92,15 @@ pub fn settings_view_renders_discogs_section_logout_and_inbox_test() { assert string.contains(html, "@alice.test") } +pub fn settings_view_renders_theme_segment_with_dark_active_test() { + let html = + Model(..logged_in(), theme: Dark) |> settings.view |> element.to_string + assert string.contains(html, "THEME") + assert string.contains(html, "LIGHT") + assert string.contains(html, "SYSTEM") + assert string.contains(html, "class=\"is-active\" type=\"button\">DARK") +} + pub fn arm_logout_then_disarm_logout_clears_confirm_test() { let #(armed, _) = update(logged_in(), ArmLogout) assert armed.confirm_logout == True diff --git a/web/test/support.gleam b/web/test/support.gleam index 61aee46..03d7254 100644 --- a/web/test/support.gleam +++ b/web/test/support.gleam @@ -4,7 +4,7 @@ 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, blank_amend, + Entry, Grid, LoggedIn, LoggedOut, Model, ShelfLoaded, System, blank_amend, blank_discogs, blank_form, blank_scan, } import at_record_web/pages/crate @@ -32,6 +32,7 @@ pub fn base() -> Model { login_error: None, view: "owned", display: Grid, + theme: System, shelf: ShelfLoaded([]), crate_window: model.crate_window_size, crate_cursor: None,