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,