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.
- checksums.yaml +4 -4
- data/README.md +620 -46
- data/app/controllers/concerns/roundhouse_ui/job_set_browsing.rb +246 -12
- data/app/controllers/roundhouse_ui/application_controller.rb +74 -0
- data/app/controllers/roundhouse_ui/audit_controller.rb +1 -0
- data/app/controllers/roundhouse_ui/busy_controller.rb +25 -5
- data/app/controllers/roundhouse_ui/dashboard_controller.rb +27 -3
- data/app/controllers/roundhouse_ui/dead_controller.rb +27 -10
- data/app/controllers/roundhouse_ui/errors_controller.rb +60 -1
- data/app/controllers/roundhouse_ui/jobs_controller.rb +5 -1
- data/app/controllers/roundhouse_ui/queues_controller.rb +57 -6
- data/app/controllers/roundhouse_ui/recurring_controller.rb +12 -0
- data/app/controllers/roundhouse_ui/retries_controller.rb +34 -10
- data/app/controllers/roundhouse_ui/scheduled_controller.rb +4 -7
- data/app/controllers/roundhouse_ui/settings_controller.rb +15 -0
- data/app/controllers/roundhouse_ui/snapshots_controller.rb +5 -6
- data/app/controllers/roundhouse_ui/workers_controller.rb +2 -7
- data/app/helpers/roundhouse_ui/application_helper.rb +293 -0
- data/app/helpers/roundhouse_ui/nav_helper.rb +3 -3
- data/app/helpers/roundhouse_ui/observability_helper.rb +121 -12
- data/app/helpers/roundhouse_ui/tags_helper.rb +128 -0
- data/app/views/layouts/roundhouse_ui/application.html.erb +1277 -84
- data/app/views/roundhouse_ui/busy/index.html.erb +30 -6
- data/app/views/roundhouse_ui/dashboard/show.html.erb +62 -17
- data/app/views/roundhouse_ui/dead/index.html.erb +54 -32
- data/app/views/roundhouse_ui/errors/index.html.erb +51 -11
- data/app/views/roundhouse_ui/jobs/show.html.erb +8 -3
- data/app/views/roundhouse_ui/metrics/show.html.erb +26 -3
- data/app/views/roundhouse_ui/queues/index.html.erb +54 -10
- data/app/views/roundhouse_ui/queues/show.html.erb +55 -0
- data/app/views/roundhouse_ui/recurring/index.html.erb +71 -0
- data/app/views/roundhouse_ui/retries/index.html.erb +30 -17
- data/app/views/roundhouse_ui/scheduled/index.html.erb +17 -11
- data/app/views/roundhouse_ui/settings/show.html.erb +91 -0
- data/app/views/roundhouse_ui/shared/_filter_error.html.erb +23 -0
- data/app/views/roundhouse_ui/shared/_pager.html.erb +2 -2
- data/app/views/roundhouse_ui/shared/_search_bar.html.erb +55 -0
- data/app/views/roundhouse_ui/shared/_search_help.html.erb +37 -0
- data/app/views/roundhouse_ui/shared/_tag_filter.html.erb +28 -0
- data/app/views/roundhouse_ui/shared/bulk_preview.html.erb +69 -0
- data/config/routes.rb +9 -0
- data/lib/roundhouse_ui/backends/sidekiq.rb +124 -5
- data/lib/roundhouse_ui/backends/solid_queue.rb +54 -3
- data/lib/roundhouse_ui/demo.rb +130 -0
- data/lib/roundhouse_ui/error_groups.rb +25 -4
- data/lib/roundhouse_ui/filter_query.rb +504 -0
- data/lib/roundhouse_ui/health.rb +43 -4
- data/lib/roundhouse_ui/history.rb +54 -0
- data/lib/roundhouse_ui/icons.rb +104 -0
- data/lib/roundhouse_ui/marks/datadog-lockup-white.svg +30 -0
- data/lib/roundhouse_ui/marks/datadog-lockup.svg +41 -0
- data/lib/roundhouse_ui/observability.rb +55 -7
- data/lib/roundhouse_ui/pause.rb +6 -11
- data/lib/roundhouse_ui/queue_summary.rb +11 -0
- data/lib/roundhouse_ui/recurring.rb +137 -0
- data/lib/roundhouse_ui/runbooks.rb +75 -0
- data/lib/roundhouse_ui/snapshots.rb +13 -2
- data/lib/roundhouse_ui/tags.rb +112 -0
- data/lib/roundhouse_ui/theme.rb +298 -0
- data/lib/roundhouse_ui/version.rb +1 -1
- data/lib/roundhouse_ui.rb +235 -5
- data/lib/tasks/roundhouse_ui_tasks.rake +84 -4
- 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
|