roundhouse_ui 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +620 -46
  3. data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
  4. data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
  5. data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
  6. data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
  7. data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
  8. data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
  9. data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
  10. data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
  11. data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
  12. data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
  13. data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
  14. data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
  15. data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
  16. data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
  17. data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
  18. data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
  19. data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
  20. data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
  21. data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
  22. data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
  23. data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
  24. data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
  25. data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
  26. data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
  27. data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
  28. data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
  29. data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
  30. data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
  31. data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
  32. data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
  33. data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
  34. data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
  35. data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
  36. data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
  37. data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
  38. data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
  39. data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
  40. data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
  41. data/config/routes.rb +9 -0
  42. data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
  43. data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
  44. data/lib/roundhouse_ui/demo.rb +130 -0
  45. data/lib/roundhouse_ui/error_groups.rb +25 -4
  46. data/lib/roundhouse_ui/filter_query.rb +504 -0
  47. data/lib/roundhouse_ui/health.rb +43 -4
  48. data/lib/roundhouse_ui/history.rb +54 -0
  49. data/lib/roundhouse_ui/icons.rb +104 -0
  50. data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
  51. data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
  52. data/lib/roundhouse_ui/observability.rb +55 -7
  53. data/lib/roundhouse_ui/pause.rb +6 -11
  54. data/lib/roundhouse_ui/queue_summary.rb +11 -0
  55. data/lib/roundhouse_ui/recurring.rb +137 -0
  56. data/lib/roundhouse_ui/runbooks.rb +75 -0
  57. data/lib/roundhouse_ui/snapshots.rb +13 -2
  58. data/lib/roundhouse_ui/tags.rb +112 -0
  59. data/lib/roundhouse_ui/theme.rb +298 -0
  60. data/lib/roundhouse_ui/version.rb +1 -1
  61. data/lib/roundhouse_ui.rb +235 -5
  62. data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
  63. metadata +32 -7
@@ -0,0 +1,112 @@
1
+ module RoundhouseUi
2
+ # Resolves host-defined tags for a job at read time (no middleware, no
3
+ # storage — works on every backend and applies retroactively to jobs already
4
+ # in the sets). The host supplies the resolver; see ADR 0002.
5
+ #
6
+ # RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
7
+ # # or any callable:
8
+ # RoundhouseUi.job_tags = ->(klass:, item:) { { squad: :growth } }
9
+ #
10
+ # Contract: resolver output is normalized to string keys/values, masked via
11
+ # Redaction (redact_args patterns apply to tag keys), and a raising resolver
12
+ # yields no tags — tagging must never break a page.
13
+ module Tags
14
+ EMPTY = {}.freeze
15
+
16
+ module_function
17
+
18
+ # Tags for one job entry, as a { "key" => "value" } Hash (EMPTY when no
19
+ # resolver is configured, the resolver declines, or it raises).
20
+ #
21
+ # `klass`/`item` come from the backend entry (entry.klass / entry.item);
22
+ # the ActiveJob adapter wrapper is unwrapped here, so resolvers always see
23
+ # the real job class. Pass a Hash as `cache` to memoize across a
24
+ # request — keyed by class normally, by jid in per-job mode.
25
+ def for(klass:, item:, cache: nil)
26
+ resolver = RoundhouseUi.job_tags
27
+ return EMPTY unless resolver
28
+
29
+ effective = effective_klass(klass, item)
30
+ return EMPTY unless effective
31
+
32
+ if RoundhouseUi.job_tags_per_job
33
+ # Per-job mode still memoizes, keyed by jid rather than class: a job's
34
+ # tags cannot change within one request, and the same entry is resolved
35
+ # twice otherwise — once while scanning, once when its badge renders.
36
+ jid = item["jid"] if item.is_a?(Hash)
37
+ return resolve(resolver, effective, item) unless cache && jid
38
+
39
+ cache.key?(jid) ? cache[jid] : cache[jid] = resolve(resolver, effective, item)
40
+ elsif cache
41
+ # Class-cached mode: item is withheld (deliberately — see resolve).
42
+ cache.key?(effective) ? cache[effective] : cache[effective] = resolve(resolver, effective, nil)
43
+ else
44
+ resolve(resolver, effective, nil)
45
+ end
46
+ end
47
+
48
+ # The class-constant convention (Trainual's OWNER pattern) as a resolver:
49
+ #
50
+ # RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
51
+ #
52
+ # Tags every job whose class (or ancestor — inherited constants count, so a
53
+ # base-class OWNER covers subclasses) defines the constant.
54
+ def from_constant(const_name, as: const_name.to_s.downcase)
55
+ lambda do |klass:, item:|
56
+ k = RoundhouseUi.job_class(klass)
57
+ { as => k.const_get(const_name) } if k&.const_defined?(const_name)
58
+ end
59
+ end
60
+
61
+ # The declared filter vocabulary, normalized to { "key" => ["value", ...] },
62
+ # or nil when the host declared none (the filter UI then discovers values
63
+ # from the entries it scans). Both the whole setting and individual values
64
+ # may be callables, so vocabularies can be dynamic.
65
+ def filters
66
+ declared = RoundhouseUi.tag_filters
67
+ declared = declared.call if declared.respond_to?(:call)
68
+ return nil unless declared.is_a?(Hash)
69
+
70
+ declared.each_with_object({}) do |(key, values), out|
71
+ values = values.call if values.respond_to?(:call)
72
+ out[key.to_s] = Array(values).map(&:to_s)
73
+ end
74
+ rescue => e
75
+ warn_once("tag_filters failed: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ # Does a resolved tag Hash match a key/value filter? Exact match on the
80
+ # normalized (post-redaction) value — so a redacted tag matches only its
81
+ # mask, and the filter can't be used to probe redacted values.
82
+ def match?(tags, key, value)
83
+ tags[key.to_s] == value.to_s
84
+ end
85
+
86
+ # -- internals ----------------------------------------------------------
87
+
88
+ # Resolvers always see the real job class. The unwrap now lives on
89
+ # RoundhouseUi so search, grouping and APM links resolve to the identical
90
+ # string — see RoundhouseUi.unwrapped_class.
91
+ def effective_klass(klass, item)
92
+ RoundhouseUi.unwrapped_class(klass, item)
93
+ end
94
+
95
+ # `item` is nil in class-cached mode: an args-reading resolver cached by
96
+ # class would poison the cache with first-job-wins values — withholding the
97
+ # payload makes it fail deterministically (rescued → no tags) instead.
98
+ # Hosts that need the payload set RoundhouseUi.job_tags_per_job = true.
99
+ def resolve(resolver, klass, item)
100
+ tags = resolver.call(klass: klass, item: item)
101
+ return EMPTY unless tags.is_a?(Hash)
102
+
103
+ normalized = tags.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s }
104
+ Redaction.apply(normalized)
105
+ rescue => e
106
+ warn_once("job_tags resolver failed for #{klass}: #{e.message}")
107
+ EMPTY
108
+ end
109
+
110
+ def warn_once(message) = RoundhouseUi.warn_once(message)
111
+ end
112
+ end
@@ -0,0 +1,298 @@
1
+ module RoundhouseUi
2
+ # Host-configurable colours, as pure CSS custom properties.
3
+ #
4
+ # RoundhouseUi.theme = { accent: "#FF00FF", accent_2: "#00E5FF" }
5
+ #
6
+ # or per mode, since a colour that reads well on near-black rarely reads well
7
+ # on near-white:
8
+ #
9
+ # RoundhouseUi.theme = {
10
+ # dark: { bg: "#0B0E14", accent: "#FF2BD1" },
11
+ # light: { bg: "#FFF7FB", accent: "#B3009E" }
12
+ # }
13
+ #
14
+ # Keys are the token names without the leading dashes; underscores become
15
+ # dashes, so `accent_2` sets `--accent-2`. Anything the layout already defines
16
+ # can be overridden and nothing else — an unknown key is ignored rather than
17
+ # emitted, because a typo silently adding a property is worse than a typo that
18
+ # visibly does nothing.
19
+ #
20
+ # A preset ships as PRESETS[:cyberpunk]; merge or replace as you like.
21
+ module Theme
22
+ # The tokens the layout actually consumes. An allowlist rather than a
23
+ # denylist: this is interpolated into a <style> block, so the set of things
24
+ # a host can name has to be closed.
25
+ TOKENS = %i[
26
+ bg panel panel_2 panel_3 line line_soft text muted faint
27
+ accent accent_2 good warn crit mono sans
28
+ ].freeze
29
+
30
+ # Values land inside `--token: VALUE;`. Anything that could close the
31
+ # declaration, open a new rule, or start a comment would let a config value
32
+ # inject arbitrary CSS, so the shape is constrained rather than escaped —
33
+ # there is no HTML-escaping that makes a stylesheet safe.
34
+ SAFE_VALUE = /\A[#\w\s,.()%\-"'\/]+\z/
35
+ UNSAFE = /[;{}<>@\\]|\/\*|\*\//
36
+
37
+ MODES = %i[dark light].freeze
38
+
39
+ # A palette name lands in a CSS attribute selector, so it is constrained for
40
+ # the same reason the values are — a quote would close the selector and
41
+ # everything after it is arbitrary CSS.
42
+ SAFE_PALETTE_NAME = /\A[a-z0-9_-]{1,40}\z/i
43
+
44
+ # Eleven palettes: `cyberpunk`, plus ten themes that each ship their own
45
+ # light AND dark variant, so picking one is not a choice to also give up
46
+ # light mode. Every value below is traced to its project's own palette file
47
+ # rather than typed by hand:
48
+ #
49
+ # Catppuccin catppuccin/palette@v1.8.0 palette.json (one light flavour,
50
+ # Latte, so the three dark flavours share it — upstream's own
51
+ # design, which is why the names say which flavour you get)
52
+ # Rosé Pine rose-pine/palette palette.json (main, moon, dawn)
53
+ # Nord nordtheme/nord src/nord.css (nord0–15)
54
+ # Gruvbox morhetz/gruvbox colors/gruvbox.vim
55
+ # Everforest sainnhe/everforest palette.md (medium variant)
56
+ # Kanagawa rebelot/kanagawa.nvim colors.lua (wave, lotus)
57
+ # Solarized altercation/solarized README
58
+ #
59
+ # Roles, since every project names its swatches differently: `panel` has to
60
+ # read as distinct from `bg`, `panel_2` has to be dark/light enough for
61
+ # `muted` text to sit on it, and `line_soft` has to be distinct from `panel`
62
+ # or card borders vanish. Solarized ships exactly two surfaces per mode, so
63
+ # its pills recess to the page colour instead of rising above the card —
64
+ # anything lighter is a mid grey that `muted` text cannot sit on.
65
+ PRESETS = {
66
+ # Because someone will ask. Deliberately loud, and dark-only — Settings
67
+ # labels it so, since a dark-only palette is inert in light mode.
68
+ cyberpunk: {
69
+ dark: {
70
+ bg: "#0A0511", panel: "#140A24", panel_2: "#1C0F31", panel_3: "#26154180",
71
+ line: "#3A1F63", line_soft: "#24123D", text: "#F2E9FF", muted: "#B69BE0", faint: "#7C63A8",
72
+ accent: "#FF2BD1", accent_2: "#00E5FF", good: "#3DF5A5", warn: "#FFC93C", crit: "#FF3B6B"
73
+ }
74
+ },
75
+ # Catppuccin Mocha / Latte
76
+ catppuccin: {
77
+ dark: {
78
+ bg: "#181825", panel: "#1E1E2E", panel_2: "#313244", panel_3: "#45475A", line: "#45475A",
79
+ line_soft: "#313244", text: "#CDD6F4", muted: "#A6ADC8", faint: "#7F849C", accent: "#CBA6F7",
80
+ accent_2: "#B4BEFE", good: "#A6E3A1", warn: "#F9E2AF", crit: "#F38BA8"
81
+ },
82
+ light: {
83
+ bg: "#E6E9EF", panel: "#EFF1F5", panel_2: "#DCE0E8", panel_3: "#BCC0CC", line: "#CCD0DA",
84
+ line_soft: "#DCE0E8", text: "#4C4F69", muted: "#6C6F85", faint: "#8C8FA1", accent: "#8839EF",
85
+ accent_2: "#7287FD", good: "#40A02B", warn: "#DF8E1D", crit: "#D20F39"
86
+ }
87
+ },
88
+ # Catppuccin Macchiato / Latte
89
+ catppuccin_macchiato: {
90
+ dark: {
91
+ bg: "#1E2030", panel: "#24273A", panel_2: "#363A4F", panel_3: "#494D64", line: "#494D64",
92
+ line_soft: "#363A4F", text: "#CAD3F5", muted: "#A5ADCB", faint: "#8087A2", accent: "#C6A0F6",
93
+ accent_2: "#B7BDF8", good: "#A6DA95", warn: "#EED49F", crit: "#ED8796"
94
+ },
95
+ light: {
96
+ bg: "#E6E9EF", panel: "#EFF1F5", panel_2: "#DCE0E8", panel_3: "#BCC0CC", line: "#CCD0DA",
97
+ line_soft: "#DCE0E8", text: "#4C4F69", muted: "#6C6F85", faint: "#8C8FA1", accent: "#8839EF",
98
+ accent_2: "#7287FD", good: "#40A02B", warn: "#DF8E1D", crit: "#D20F39"
99
+ }
100
+ },
101
+ # Catppuccin Frappe / Latte
102
+ catppuccin_frappe: {
103
+ dark: {
104
+ bg: "#292C3C", panel: "#303446", panel_2: "#414559", panel_3: "#51576D", line: "#51576D",
105
+ line_soft: "#414559", text: "#C6D0F5", muted: "#A5ADCE", faint: "#838BA7", accent: "#CA9EE6",
106
+ accent_2: "#BABBF1", good: "#A6D189", warn: "#E5C890", crit: "#E78284"
107
+ },
108
+ light: {
109
+ bg: "#E6E9EF", panel: "#EFF1F5", panel_2: "#DCE0E8", panel_3: "#BCC0CC", line: "#CCD0DA",
110
+ line_soft: "#DCE0E8", text: "#4C4F69", muted: "#6C6F85", faint: "#8C8FA1", accent: "#8839EF",
111
+ accent_2: "#7287FD", good: "#40A02B", warn: "#DF8E1D", crit: "#D20F39"
112
+ }
113
+ },
114
+ # Rose Pine Main / Dawn
115
+ rose_pine: {
116
+ dark: {
117
+ bg: "#191724", panel: "#1F1D2E", panel_2: "#26233A", panel_3: "#26233A", line: "#26233A",
118
+ line_soft: "#26233A", text: "#E0DEF4", muted: "#908CAA", faint: "#6E6A86", accent: "#C4A7E7",
119
+ accent_2: "#9CCFD8", good: "#31748F", warn: "#F6C177", crit: "#EB6F92"
120
+ },
121
+ light: {
122
+ bg: "#FAF4ED", panel: "#FFFAF3", panel_2: "#F2E9E1", panel_3: "#F2E9E1", line: "#F2E9E1",
123
+ line_soft: "#F2E9E1", text: "#464261", muted: "#797593", faint: "#9893A5", accent: "#907AA9",
124
+ accent_2: "#56949F", good: "#286983", warn: "#EA9D34", crit: "#B4637A"
125
+ }
126
+ },
127
+ # Rose Pine Moon / Dawn
128
+ rose_pine_moon: {
129
+ dark: {
130
+ bg: "#232136", panel: "#2A273F", panel_2: "#393552", panel_3: "#393552", line: "#393552",
131
+ line_soft: "#393552", text: "#E0DEF4", muted: "#908CAA", faint: "#6E6A86", accent: "#C4A7E7",
132
+ accent_2: "#9CCFD8", good: "#3E8FB0", warn: "#F6C177", crit: "#EB6F92"
133
+ },
134
+ light: {
135
+ bg: "#FAF4ED", panel: "#FFFAF3", panel_2: "#F2E9E1", panel_3: "#F2E9E1", line: "#F2E9E1",
136
+ line_soft: "#F2E9E1", text: "#464261", muted: "#797593", faint: "#9893A5", accent: "#907AA9",
137
+ accent_2: "#56949F", good: "#286983", warn: "#EA9D34", crit: "#B4637A"
138
+ }
139
+ },
140
+ # Nord Polar Night / Snow Storm
141
+ nord: {
142
+ dark: {
143
+ bg: "#2E3440", panel: "#3B4252", panel_2: "#434C5E", panel_3: "#4C566A", line: "#4C566A",
144
+ line_soft: "#434C5E", text: "#ECEFF4", muted: "#E5E9F0", faint: "#D8DEE9", accent: "#88C0D0",
145
+ accent_2: "#81A1C1", good: "#A3BE8C", warn: "#EBCB8B", crit: "#BF616A"
146
+ },
147
+ light: {
148
+ bg: "#E5E9F0", panel: "#ECEFF4", panel_2: "#D8DEE9", panel_3: "#D8DEE9", line: "#D8DEE9",
149
+ line_soft: "#E5E9F0", text: "#2E3440", muted: "#434C5E", faint: "#4C566A", accent: "#5E81AC",
150
+ accent_2: "#81A1C1", good: "#A3BE8C", warn: "#EBCB8B", crit: "#BF616A"
151
+ }
152
+ },
153
+ # Gruvbox Dark / Light
154
+ gruvbox: {
155
+ dark: {
156
+ bg: "#282828", panel: "#32302F", panel_2: "#3C3836", panel_3: "#504945", line: "#504945",
157
+ line_soft: "#3C3836", text: "#EBDBB2", muted: "#BDAE93", faint: "#928374", accent: "#D3869B",
158
+ accent_2: "#83A598", good: "#B8BB26", warn: "#FABD2F", crit: "#FB4934"
159
+ },
160
+ light: {
161
+ bg: "#F2E5BC", panel: "#FBF1C7", panel_2: "#EBDBB2", panel_3: "#D5C4A1", line: "#D5C4A1",
162
+ line_soft: "#EBDBB2", text: "#3C3836", muted: "#665C54", faint: "#7C6F64", accent: "#8F3F71",
163
+ accent_2: "#076678", good: "#79740E", warn: "#B57614", crit: "#9D0006"
164
+ }
165
+ },
166
+ # Everforest Dark / Light medium
167
+ everforest: {
168
+ dark: {
169
+ bg: "#232A2E", panel: "#2D353B", panel_2: "#343F44", panel_3: "#3D484D", line: "#475258",
170
+ line_soft: "#3D484D", text: "#D3C6AA", muted: "#9DA9A0", faint: "#859289", accent: "#7FBBB3",
171
+ accent_2: "#83C092", good: "#A7C080", warn: "#DBBC7F", crit: "#E67E80"
172
+ },
173
+ light: {
174
+ bg: "#EFEBD4", panel: "#FDF6E3", panel_2: "#F4F0D9", panel_3: "#EFEBD4", line: "#E6E2CC",
175
+ line_soft: "#EFEBD4", text: "#5C6A72", muted: "#829181", faint: "#939F91", accent: "#3A94C5",
176
+ accent_2: "#35A77C", good: "#8DA101", warn: "#DFA000", crit: "#F85552"
177
+ }
178
+ },
179
+ # Kanagawa Wave / Lotus
180
+ kanagawa: {
181
+ dark: {
182
+ bg: "#1F1F28", panel: "#2A2A37", panel_2: "#363646", panel_3: "#223249", line: "#54546D",
183
+ line_soft: "#363646", text: "#DCD7BA", muted: "#C8C093", faint: "#727169", accent: "#7E9CD8",
184
+ accent_2: "#957FB8", good: "#98BB6C", warn: "#E6C384", crit: "#E46876"
185
+ },
186
+ light: {
187
+ bg: "#E5DDB0", panel: "#F2ECBC", panel_2: "#DCD5AC", panel_3: "#D5CEA3", line: "#D5CEA3",
188
+ line_soft: "#DCD5AC", text: "#545464", muted: "#716E61", faint: "#8A8980", accent: "#4D699B",
189
+ accent_2: "#624C83", good: "#6F894E", warn: "#CC6D00", crit: "#B35B79"
190
+ }
191
+ },
192
+ # Solarized Dark / Light
193
+ solarized: {
194
+ dark: {
195
+ bg: "#002B36", panel: "#073642", panel_2: "#002B36", panel_3: "#073642", line: "#586E75",
196
+ line_soft: "#002B36", text: "#93A1A1", muted: "#839496", faint: "#586E75", accent: "#268BD2",
197
+ accent_2: "#6C71C4", good: "#859900", warn: "#B58900", crit: "#DC322F"
198
+ },
199
+ light: {
200
+ bg: "#EEE8D5", panel: "#FDF6E3", panel_2: "#EEE8D5", panel_3: "#EEE8D5", line: "#93A1A1",
201
+ line_soft: "#EEE8D5", text: "#586E75", muted: "#657B83", faint: "#93A1A1", accent: "#268BD2",
202
+ accent_2: "#6C71C4", good: "#859900", warn: "#B58900", crit: "#DC322F"
203
+ }
204
+ }
205
+ }.freeze
206
+
207
+ module_function
208
+
209
+ # CSS for every selectable palette: one block per palette per mode.
210
+ def selectable_css(config = RoundhouseUi.themes)
211
+ blocks = selectable(config).filter_map do |name, tokens|
212
+ normalize(tokens).filter_map { |mode, toks|
213
+ decls = declarations(toks)
214
+ next if decls.empty?
215
+
216
+ "#{palette_selector(name, mode)} { #{decls} }"
217
+ }.join("\n").presence
218
+ end
219
+ blocks.any? ? blocks.join("\n") : nil
220
+ end
221
+
222
+ # The one mode a palette covers, or nil when it covers both. A dark-only
223
+ # palette silently does nothing in light mode — the tokens are there but the
224
+ # selector never matches — so the menu has to say so rather than looking
225
+ # broken.
226
+ def only_mode(config)
227
+ modes = normalize(config).keys
228
+ modes.first if modes.size == 1 && MODES.include?(modes.first)
229
+ end
230
+
231
+ def palette_selector(name, mode)
232
+ base = %(:root[data-rh-palette="#{name}"])
233
+ case mode
234
+ when :light then %(#{base}[data-theme="light"])
235
+ when :dark then %(#{base}:not([data-theme="light"]))
236
+ else base
237
+ end
238
+ end
239
+
240
+ # Named palettes a viewer may pick from on the Settings page. Emitted as
241
+ # [data-rh-palette="name"] blocks so a choice is one attribute on :root —
242
+ # the browser stores which name, never the colours. Storing values would mean
243
+ # applying viewer-controlled strings as CSS, which is the thing the
244
+ # server-side allowlist exists to prevent.
245
+ def selectable(config = RoundhouseUi.themes)
246
+ return {} unless RoundhouseUi.allow_theme_selection
247
+ return {} if config.blank?
248
+
249
+ config.to_h.symbolize_keys.select { |name, _| name.to_s.match?(SAFE_PALETTE_NAME) }
250
+ end
251
+
252
+ # The CSS to append after the layout's own :root blocks, or nil when the host
253
+ # configured nothing. Later declarations win, so this needs no !important and
254
+ # the shipped defaults remain the fallback for any token left unset.
255
+ def css(config = RoundhouseUi.theme)
256
+ return nil if config.blank?
257
+
258
+ blocks = normalize(config).filter_map do |mode, tokens|
259
+ decls = declarations(tokens)
260
+ next if decls.empty?
261
+
262
+ "#{selector(mode)} { #{decls} }"
263
+ end
264
+ blocks.any? ? blocks.join("\n") : nil
265
+ end
266
+
267
+ # A flat hash is treated as applying to both modes; a hash keyed by mode is
268
+ # taken as given.
269
+ def normalize(config)
270
+ config = config.symbolize_keys
271
+ return config.slice(*MODES) if config.keys.any? { |k| MODES.include?(k) }
272
+
273
+ { all: config }
274
+ end
275
+
276
+ def selector(mode)
277
+ case mode
278
+ when :light then ':root[data-theme="light"]'
279
+ when :dark then ":root:not([data-theme=\"light\"])"
280
+ else ":root"
281
+ end
282
+ end
283
+
284
+ def declarations(tokens)
285
+ tokens.to_h.symbolize_keys.filter_map { |key, value|
286
+ next unless TOKENS.include?(key)
287
+ next unless safe?(value)
288
+
289
+ "--#{key.to_s.tr("_", "-")}: #{value};"
290
+ }.join(" ")
291
+ end
292
+
293
+ def safe?(value)
294
+ str = value.to_s
295
+ str.present? && str.length <= 120 && str.match?(SAFE_VALUE) && !str.match?(UNSAFE)
296
+ end
297
+ end
298
+ end
@@ -1,3 +1,3 @@
1
1
  module RoundhouseUi
2
- VERSION = "0.9.1"
2
+ VERSION = "0.11.0"
3
3
  end