Bingo board for a D&D session I play in bingo.lesbian.skin/
gleam bingo dnd

Theming support

Signed-off-by: Naomi Roberts <mia@naomieow.xyz>

lesbian.skin 98c15c58 88e8b252

verified
+288 -70
+1
gleam.toml
··· 1 1 name = "voyagers_bingo" 2 2 version = "1.0.0" 3 + target = "javascript" 3 4 4 5 # Fill out these fields if you intend to generate HTML documentation or publish 5 6 # your project to the Hex package manager.
+3 -1
index.html
··· 1 1 <!doctype html> 2 - <html lang="en"> 2 + <html lang="en" id="html-root"> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Voyagers Bingo</title> 7 7 <script type="module" src="/priv/static/voyagers_bingo.mjs"></script> 8 + <link rel="preconnect" href="https://fonts.bunny.net" /> 9 + <link rel="stylesheet" href="https://fonts.bunny.net/css?family=almarai:400" /> 8 10 <style> 9 11 html { 10 12 display: table;
+33
src/voyagers_bingo.ffi.mjs
··· 1 + /** 2 + * @returns {string} theme 3 + */ 4 + export function getPreferredTheme() { 5 + return window.localStorage.getItem("preferredTheme") === null 6 + ? window.matchMedia("(prefers-color-scheme: dark)") 7 + ? "Dark" 8 + : "Light" 9 + : window.localStorage.getItem("preferredTheme"); 10 + } 11 + 12 + /** 13 + * @param {string} theme 14 + */ 15 + export function setPreferredTheme(theme) { 16 + var bg = ""; 17 + switch (theme) { 18 + case "Light": 19 + bg = "white"; 20 + break; 21 + case "Dark": 22 + bg = "black"; 23 + break; 24 + case "Solarized Light": 25 + bg = "#fdf6e3"; 26 + break; 27 + case "Solarized Dark": 28 + bg = "#002b36"; 29 + break; 30 + } 31 + document.getElementById("html-root").style.backgroundColor = bg; 32 + window.localStorage.setItem("preferredTheme", theme); 33 + }
+103 -64
src/voyagers_bingo.gleam
··· 1 - import gleam/bool 2 1 import gleam/float 3 2 import gleam/int 4 3 import gleam/list ··· 15 14 import sketch/lustre/element 16 15 import sketch/lustre/element/html 17 16 import voyagers_bingo/styles 17 + import voyagers_bingo/theme.{type Theme, Dark} 18 18 19 19 const available_squares = [ 20 20 "Fall Asleep", "Accidental Racism", "Louis rolls 15+", "Wrong Name", ··· 28 28 UserRequestedNewSheet(rows: Int, columns: Int) 29 29 UserSelectedSquare(x: Int, y: Int) 30 30 UserDeselectedSquare(x: Int, y: Int) 31 - CreatedNewSheet(sheet: Array(Array(Square))) 32 - FailedToCreateSheet 33 31 UserSetX(x: Int) 34 32 UserSetY(y: Int) 33 + UserSetTheme(theme: String) 35 34 } 36 35 37 36 pub type Square { ··· 39 38 } 40 39 41 40 pub type Model { 42 - Model(sheet: Array(Array(Square)), failed: Bool, x: Int, y: Int) 43 - } 44 - 45 - pub fn sheet_with_size( 46 - rows rows: Int, 47 - columns columns: Int, 48 - ) -> Array(Array(Square)) { 49 - let assert Ok(random_squares) = 50 - list.shuffle(available_squares) 51 - |> list.sized_chunk(rows * columns) 52 - |> list.first() 53 - 54 - let square_rows = 55 - random_squares 56 - |> list.sized_chunk(columns) 57 - 58 - square_rows 59 - |> list.map(fn(row) { 60 - row 61 - |> list.map(fn(text) { Square(text, False) }) 62 - |> iv.from_list() 63 - }) 64 - |> iv.from_list() 65 - } 66 - 67 - fn init(_flags) -> #(Model, effect.Effect(Msg)) { 68 - let sqrt = 69 - int.square_root(list.length(available_squares)) 70 - |> result.unwrap(2.0) 71 - |> float.floor() 72 - |> float.round() 73 - |> int.min(5) 74 - 75 - #( 76 - Model(sheet: sheet_with_size(sqrt, sqrt), failed: False, x: sqrt, y: sqrt), 77 - effect.none(), 78 - ) 41 + Model(sheet: Array(Array(Square)), failed: Bool, x: Int, y: Int, theme: Theme) 79 42 } 80 43 81 44 fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) { 82 45 case msg { 83 46 UserRequestedNewSheet(rows:, columns:) -> { 84 - let too_big = { rows * columns } > list.length(available_squares) 85 - use <- bool.guard(when: too_big, return: #( 86 - model, 87 - effect.from(fn(dispatch) { dispatch(FailedToCreateSheet) }), 88 - )) 89 - #( 90 - model, 91 - effect.from(fn(dispatch) { 92 - let sheet = sheet_with_size(rows:, columns:) 93 - 94 - dispatch(CreatedNewSheet(sheet)) 95 - }), 96 - ) 47 + case { rows * columns } > list.length(available_squares) { 48 + True -> #(Model(..model, failed: True), effect.none()) 49 + False -> #( 50 + Model(..model, sheet: sheet_with_size(rows:, columns:), failed: False), 51 + effect.none(), 52 + ) 53 + } 97 54 } 98 55 UserSelectedSquare(x:, y:) -> { 99 56 let assert Ok(row) = iv.get(model.sheet, x) ··· 111 68 let assert Ok(updated) = iv.set(model.sheet, x, updated_row) 112 69 #(Model(..model, sheet: updated), effect.none()) 113 70 } 114 - CreatedNewSheet(sheet:) -> #( 115 - Model(..model, sheet:, failed: False), 116 - effect.none(), 117 - ) 118 - FailedToCreateSheet -> #(Model(..model, failed: True), effect.none()) 119 71 UserSetX(x:) -> #(Model(..model, x:, failed: False), effect.none()) 120 72 UserSetY(y:) -> #(Model(..model, y:, failed: False), effect.none()) 73 + UserSetTheme(theme: stringified_theme) -> { 74 + case theme.from_string(echo stringified_theme) { 75 + Ok(theme) -> #( 76 + Model(..model, theme:), 77 + set_preferred_theme(stringified_theme), 78 + ) 79 + Error(_) -> #(model, effect.none()) 80 + } 81 + } 121 82 } 122 83 } 123 84 124 85 fn view(model: Model) -> element.Element(Msg) { 125 86 html.div(styles.container(), [], [ 126 - html.h1_([], [html.text("Voyagers Bingo")]), 87 + html.h1(styles.h1(model.theme), [], [ 88 + html.text("Voyagers Bingo"), 89 + html.select( 90 + styles.dropdown(model.theme), 91 + [event.on_change(fn(theme) { UserSetTheme(theme) })], 92 + theme.all_themes 93 + |> list.map(fn(theme) { 94 + html.option_( 95 + [ 96 + case theme == theme.to_string(model.theme) { 97 + True -> attribute.selected(True) 98 + False -> attribute.selected(False) 99 + }, 100 + ], 101 + [html.text(theme)], 102 + ) 103 + }), 104 + ), 105 + ]), 127 106 html.div( 128 - styles.grid(), 107 + styles.grid(model.theme), 129 108 [], 130 109 model.sheet 131 110 |> iv.index_map(fn(row, x) { ··· 134 113 [], 135 114 iv.index_map(row, fn(square: Square, y) { 136 115 html.button( 137 - styles.square(square.crossed), 116 + styles.square(square.crossed, model.theme), 138 117 [ 139 118 event.on_click(case square.crossed { 140 119 True -> UserDeselectedSquare(x:, y:) ··· 149 128 }) 150 129 |> iv.to_list(), 151 130 ), 152 - html.input_([ 131 + html.input(styles.input(model.theme), [ 153 132 attribute.type_("number"), 154 133 attribute.placeholder("Rows: 2"), 155 134 event.on_input(fn(content) { ··· 157 136 UserSetX(num) 158 137 }), 159 138 ]), 160 - html.input_([ 139 + html.input(styles.input(model.theme), [ 161 140 attribute.type_("number"), 162 141 attribute.placeholder("Columns: 2"), 163 142 event.on_input(fn(content) { ··· 166 145 }), 167 146 ]), 168 147 html.button( 169 - css.class([css.width_("fit-content")]), 148 + styles.button(model.theme), 170 149 [event.on_click(UserRequestedNewSheet(model.x, model.y))], 171 150 [html.text("New Sheet")], 172 151 ), ··· 192 171 fn css_view(model: Model, stylesheet: StyleSheet) -> element.Element(Msg) { 193 172 use <- sklustre.render(stylesheet:, in: [sklustre.node()]) 194 173 view(model) 174 + } 175 + 176 + @external(javascript, "./voyagers_bingo.ffi.mjs", "getPreferredTheme") 177 + pub fn do_get_preferred_theme() -> String 178 + 179 + @external(javascript, "./voyagers_bingo.ffi.mjs", "setPreferredTheme") 180 + pub fn do_set_preferred_theme(theme: String) -> Nil 181 + 182 + pub fn set_preferred_theme(theme: String) { 183 + effect.from(fn(_) { do_set_preferred_theme(theme) }) 184 + } 185 + 186 + pub fn get_preferred_theme() { 187 + effect.from(fn(dispatch) { 188 + do_get_preferred_theme() 189 + |> UserSetTheme() 190 + |> dispatch() 191 + }) 192 + } 193 + 194 + pub fn sheet_with_size( 195 + rows rows: Int, 196 + columns columns: Int, 197 + ) -> Array(Array(Square)) { 198 + let assert Ok(random_squares) = 199 + list.shuffle(available_squares) 200 + |> list.sized_chunk(rows * columns) 201 + |> list.first() 202 + 203 + let square_rows = 204 + random_squares 205 + |> list.sized_chunk(columns) 206 + 207 + square_rows 208 + |> list.map(fn(row) { 209 + row 210 + |> list.map(fn(text) { Square(text, False) }) 211 + |> iv.from_list() 212 + }) 213 + |> iv.from_list() 214 + } 215 + 216 + fn init(_flags) -> #(Model, effect.Effect(Msg)) { 217 + let sqrt = 218 + int.square_root(list.length(available_squares)) 219 + |> result.unwrap(2.0) 220 + |> float.floor() 221 + |> float.round() 222 + |> int.min(5) 223 + 224 + #( 225 + Model( 226 + sheet: sheet_with_size(sqrt, sqrt), 227 + failed: False, 228 + x: sqrt, 229 + y: sqrt, 230 + theme: Dark, 231 + ), 232 + get_preferred_theme(), 233 + ) 195 234 } 196 235 197 236 pub fn main() -> Nil {
+76 -5
src/voyagers_bingo/styles.gleam
··· 1 1 import sketch/css 2 2 import sketch/css/length.{em, px, vw} 3 + import voyagers_bingo/theme 4 + 5 + pub fn h1(theme: theme.Theme) { 6 + css.class([ 7 + css.color(theme.foreground(theme)), 8 + css.display("flex"), 9 + css.align_items("center"), 10 + css.gap(em(0.5)), 11 + css.transition("0.25s"), 12 + css.font_family("'Almarai', sans-serif"), 13 + ]) 14 + } 15 + 16 + pub fn input(theme: theme.Theme) { 17 + css.class([ 18 + css.background(theme.square(theme)), 19 + css.color(theme.foreground(theme)), 20 + css.border_radius(px(4)), 21 + css.border("none"), 22 + css.width(em(13.0)), 23 + css.height(em(2.2)), 24 + css.outline_color(theme.border(theme)), 25 + css.outline_style("solid"), 26 + css.outline_width("2px"), 27 + css.transition("0.25s"), 28 + css.focus([css.outline_offset("1px")]), 29 + css.font_family("'Almarai', sans-serif"), 30 + ]) 31 + } 32 + 33 + pub fn button(theme: theme.Theme) { 34 + css.class([ 35 + css.border_color(theme.border(theme)), 36 + css.border_style("solid"), 37 + css.border_width(px(2)), 38 + css.background(theme.square(theme)), 39 + css.color(theme.foreground(theme)), 40 + css.width_("fit-content"), 41 + css.border_radius(px(4)), 42 + css.width(em(13.5)), 43 + css.height(em(2.3)), 44 + css.cursor("pointer"), 45 + css.font_family("'Almarai', sans-serif"), 46 + ]) 47 + } 3 48 4 49 pub fn container() { 5 50 css.class([ ··· 9 54 css.flex_direction("column"), 10 55 css.width_("fit-content"), 11 56 css.align_items("center"), 57 + css.transition("0.25s"), 58 + css.font_family("'Almarai', sans-serif"), 12 59 ]) 13 60 } 14 61 15 - pub fn grid() { 62 + pub fn grid(theme: theme.Theme) { 16 63 css.class([ 17 64 css.display("flex"), 18 65 css.flex_direction("column"), 19 - css.background_color("black"), 66 + css.background_color(theme.border(theme)), 20 67 css.width_("auto"), 21 68 css.gap(em(0.5)), 22 69 css.padding(em(0.5)), 23 70 css.border_radius(px(4)), 24 71 css.align_items("center"), 72 + css.transition("0.25s"), 25 73 css.width_("fit-content"), 74 + css.font_family("'Almarai', sans-serif"), 26 75 ]) 27 76 } 28 77 ··· 31 80 css.display("flex"), 32 81 css.flex_direction("row"), 33 82 css.gap(em(0.5)), 83 + css.transition("0.25s"), 34 84 css.width(length.percent(100)), 85 + css.font_family("'Almarai', sans-serif"), 35 86 ]) 36 87 } 37 88 38 - pub fn square(checked: Bool) { 89 + pub fn square(checked: Bool, theme: theme.Theme) { 39 90 css.class([ 40 91 css.width(em(10.0)), 41 92 css.aspect_ratio("1 / 1"), 42 93 css.width(em(6.0)), 94 + css.transition("0.25s"), 43 95 case checked { 44 - True -> css.background("yellow") 45 - False -> css.background("white") 96 + True -> css.background(theme.selected_square(theme)) 97 + False -> css.background(theme.square(theme)) 46 98 }, 99 + css.color(theme.foreground(theme)), 47 100 css.border("none"), 48 101 css.border_radius(px(4)), 102 + css.cursor("pointer"), 103 + css.font_family("'Almarai', sans-serif"), 104 + ]) 105 + } 106 + 107 + pub fn dropdown(theme: theme.Theme) { 108 + css.class([ 109 + css.border_color(theme.border(theme)), 110 + css.border_style("solid"), 111 + css.border_width(px(2)), 112 + css.background(theme.square(theme)), 113 + css.color(theme.foreground(theme)), 114 + css.width_("fit-content"), 115 + css.border_radius(px(4)), 116 + css.width(em(10.0)), 117 + css.height(em(2.3)), 118 + css.cursor("pointer"), 119 + css.font_family("'Almarai', sans-serif"), 49 120 ]) 50 121 }
+72
src/voyagers_bingo/theme.gleam
··· 1 + pub type Theme { 2 + Light 3 + Dark 4 + SolarizedLight 5 + SolarizedDark 6 + } 7 + 8 + pub fn from_string(theme: String) -> Result(Theme, Nil) { 9 + case theme { 10 + "Light" -> Ok(Light) 11 + "Dark" -> Ok(Dark) 12 + "Solarized Light" -> Ok(SolarizedLight) 13 + "Solarized Dark" -> Ok(SolarizedDark) 14 + _ -> Error(Nil) 15 + } 16 + } 17 + 18 + pub fn to_string(theme: Theme) -> String { 19 + case theme { 20 + Dark -> "Dark" 21 + Light -> "Light" 22 + SolarizedDark -> "Solarized Dark" 23 + SolarizedLight -> "Solarized Light" 24 + } 25 + } 26 + 27 + pub const all_themes = ["Light", "Dark", "Solarized Light", "Solarized Dark"] 28 + 29 + pub fn background(theme: Theme) -> String { 30 + case theme { 31 + Dark -> "black" 32 + Light -> "white" 33 + SolarizedDark -> "#002b36" 34 + SolarizedLight -> "#fdf6e3" 35 + } 36 + } 37 + 38 + pub fn foreground(theme: Theme) -> String { 39 + case theme { 40 + Dark -> "white" 41 + Light -> "black" 42 + SolarizedDark -> "#2aa198" 43 + SolarizedLight -> "#2aa198" 44 + } 45 + } 46 + 47 + pub fn square(theme: Theme) -> String { 48 + case theme { 49 + Dark -> "black" 50 + Light -> "white" 51 + SolarizedDark -> "#002b36" 52 + SolarizedLight -> "#fdf6e3" 53 + } 54 + } 55 + 56 + pub fn selected_square(theme: Theme) -> String { 57 + case theme { 58 + Dark -> "goldenrod" 59 + Light -> "khaki" 60 + SolarizedDark -> "#073642" 61 + SolarizedLight -> "#eee8d5" 62 + } 63 + } 64 + 65 + pub fn border(theme: Theme) -> String { 66 + case theme { 67 + Dark -> "gray" 68 + Light -> "black" 69 + SolarizedDark -> "#073642" 70 + SolarizedLight -> "#eee8d5" 71 + } 72 + }