rori 0.1.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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE +21 -0
  4. data/README.md +279 -0
  5. data/app/assets/stylesheets/rori/_init.css +79 -0
  6. data/app/assets/stylesheets/rori/layout.css +162 -0
  7. data/app/assets/stylesheets/rori/menubar.css +117 -0
  8. data/app/assets/stylesheets/rori/notifications.css +120 -0
  9. data/app/assets/stylesheets/rori/palette.css +114 -0
  10. data/app/assets/stylesheets/rori/shortcuts.css +110 -0
  11. data/app/assets/stylesheets/rori/terminal.css +98 -0
  12. data/app/assets/stylesheets/rori/themes.css +156 -0
  13. data/app/assets/stylesheets/rori/typing_badge.css +56 -0
  14. data/app/assets/stylesheets/rori/wallpaper.css +52 -0
  15. data/app/assets/stylesheets/rori/window.css +174 -0
  16. data/app/controllers/concerns/rori/windowed.rb +15 -0
  17. data/app/controllers/rori/commands/bars_controller.rb +9 -0
  18. data/app/controllers/rori/commands/runs_controller.rb +22 -0
  19. data/app/controllers/rori/commands/themes_controller.rb +9 -0
  20. data/app/controllers/rori/commands/ui_controller.rb +9 -0
  21. data/app/controllers/rori/commands/wallpapers_controller.rb +9 -0
  22. data/app/controllers/rori/commands_controller.rb +7 -0
  23. data/app/controllers/rori/desktops_controller.rb +7 -0
  24. data/app/helpers/rori/assets_helper.rb +9 -0
  25. data/app/helpers/rori/keymap_helper.rb +50 -0
  26. data/app/helpers/rori/prefs_helper.rb +13 -0
  27. data/app/helpers/rori/theme_helper.rb +14 -0
  28. data/app/helpers/rori/wallpaper_helper.rb +19 -0
  29. data/app/helpers/rori/window_helper.rb +36 -0
  30. data/app/javascript/rori/clock_controller.js +18 -0
  31. data/app/javascript/rori/command_index.js +84 -0
  32. data/app/javascript/rori/fuzzy.js +50 -0
  33. data/app/javascript/rori/index.js +21 -0
  34. data/app/javascript/rori/notifications_controller.js +45 -0
  35. data/app/javascript/rori/palette_controller.js +307 -0
  36. data/app/javascript/rori/prefs_controller.js +68 -0
  37. data/app/javascript/rori/rori_controller.js +996 -0
  38. data/app/javascript/rori/terminal_controller.js +198 -0
  39. data/app/javascript/rori/theme_controller.js +35 -0
  40. data/app/javascript/rori/wallpaper_controller.js +67 -0
  41. data/app/models/rori/command.rb +105 -0
  42. data/app/models/rori/notification.rb +20 -0
  43. data/app/models/rori/theme/color.rb +30 -0
  44. data/app/models/rori/theme.rb +87 -0
  45. data/app/models/rori/wallpaper.rb +54 -0
  46. data/app/views/layouts/rori/_head.html.haml +2 -0
  47. data/app/views/layouts/rori/shell.html.haml +97 -0
  48. data/app/views/layouts/rori/window.html.haml +10 -0
  49. data/app/views/rori/_menubar.html.haml +14 -0
  50. data/app/views/rori/_notification.html.haml +9 -0
  51. data/app/views/rori/_notifications.html.haml +10 -0
  52. data/app/views/rori/_palette.html.haml +13 -0
  53. data/app/views/rori/_shortcuts.html.haml +47 -0
  54. data/app/views/rori/_terminal.html.haml +9 -0
  55. data/app/views/rori/_wallpaper.html.haml +7 -0
  56. data/app/views/rori/_wallpaper_credit.html.haml +3 -0
  57. data/app/views/rori/_window.html.haml +15 -0
  58. data/app/views/rori/commands/_command.html.haml +17 -0
  59. data/app/views/rori/commands/index.html.haml +5 -0
  60. data/app/views/rori/desktops/show.html.haml +1 -0
  61. data/config/importmap.rb +3 -0
  62. data/config/locales/rori.en.yml +133 -0
  63. data/config/routes/rori.rb +13 -0
  64. data/lib/generators/rori/install/install_generator.rb +67 -0
  65. data/lib/generators/rori/install/templates/rori.rb.tt +22 -0
  66. data/lib/rori/engine.rb +19 -0
  67. data/lib/rori/version.rb +3 -0
  68. data/lib/rori.rb +180 -0
  69. data/lib/tasks/rori.rake +9 -0
  70. data/vendor/themes/LICENSE +25 -0
  71. data/vendor/themes/ghostty/Catppuccin Latte +22 -0
  72. data/vendor/themes/ghostty/Catppuccin Mocha +22 -0
  73. data/vendor/themes/ghostty/Everforest Dark Med +22 -0
  74. data/vendor/themes/ghostty/Flexoki Light +22 -0
  75. data/vendor/themes/ghostty/GitHub Dark Dimmed +22 -0
  76. data/vendor/themes/ghostty/Gruvbox Material Dark +22 -0
  77. data/vendor/themes/ghostty/Kanagawa Wave +22 -0
  78. data/vendor/themes/ghostty/Melange Dark +22 -0
  79. data/vendor/themes/ghostty/Nord +22 -0
  80. data/vendor/themes/ghostty/Rose Pine Dawn +22 -0
  81. data/vendor/themes/ghostty/Rose Pine Moon +22 -0
  82. data/vendor/themes/ghostty/TokyoNight Storm +22 -0
  83. data/vendor/themes/ghostty/Tomorrow Night Eighties +22 -0
  84. data/vendor/themes/ghostty/Zenbones Light +22 -0
  85. data/vendor/wallpapers/unsplash.yml +23 -0
  86. metadata +219 -0
@@ -0,0 +1,198 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { CommandIndex, isNested } from "rori/command_index"
3
+
4
+ const BUS = "rori-command"
5
+ const HISTORY_KEY = "rori:terminal:history"
6
+ const HISTORY_LIMIT = 100
7
+ const SUGGESTIONS = 8
8
+ const FIELDS = "input, textarea, select, [contenteditable]"
9
+ const YES = /^y(es)?$/i
10
+
11
+ const format = (template, values) => template.replace(/%\{(\w+)\}/g, (_, key) => values[key] ?? "")
12
+ // "Pick theme…" → "pick-theme": one word per label, so a trail reads as words.
13
+ const token = (label) => label.toLowerCase().replace(/[…›✓]/g, "").trim().replace(/\s+/g, "-")
14
+ const words = (trail) => trail.map(({ label }) => token(label)).join(" ")
15
+
16
+ // A drop-down command line over the same command tree as ⌘K: the same
17
+ // server-rendered lists, client-side sources, fuzzy matcher and command bus.
18
+ // Input matches whole paths through the tree, so `ui wallpaper cover` and
19
+ // just `uthen` (UI › Theme › Nord) both work; a path ending on a nested item
20
+ // lists it. Opens with Rori.terminal_key outside text fields. Commands that
21
+ // ask first (`confirm`) wait for a `y` on the next line; anything else cancels.
22
+ export default class extends Controller {
23
+ static targets = ["panel", "output", "input", "suggestions"]
24
+ static values = { root: String, key: String, prompt: String, messages: Object }
25
+
26
+ connect() {
27
+ this.history = this.#loadHistory()
28
+ this.cursor = this.history.length
29
+ }
30
+
31
+ keydown(event) {
32
+ if (event.code !== this.keyValue || event.metaKey || event.ctrlKey || event.altKey) return
33
+ const inTerminal = this.panelTarget.contains(event.target)
34
+ // A field in a closed dialog (see rori_controller.js `typing`) isn't being typed in.
35
+ if (!inTerminal && event.target.closest?.(FIELDS) && !event.target.closest("dialog:not([open])")) return
36
+ if (document.querySelector("dialog:modal")) return
37
+ event.preventDefault()
38
+ this.toggle()
39
+ }
40
+
41
+ toggle() {
42
+ this.panelTarget.hidden ? this.open() : this.close()
43
+ }
44
+
45
+ open() {
46
+ this.returnFocus = document.activeElement
47
+ this.#reindex()
48
+ this.panelTarget.hidden = false
49
+ if (!this.outputTarget.childElementCount) this.#print(this.messagesValue.welcome, "muted")
50
+ this.inputTarget.focus()
51
+ this.suggest()
52
+ }
53
+
54
+ close() {
55
+ this.confirming = null
56
+ this.panelTarget.hidden = true
57
+ this.suggestionsTarget.replaceChildren()
58
+ if (this.returnFocus?.isConnected) this.returnFocus.focus({ preventScroll: true })
59
+ }
60
+
61
+ navigate(event) {
62
+ if (event.key === "Escape") {
63
+ event.preventDefault()
64
+ this.close()
65
+ } else if (event.key === "Enter") {
66
+ event.preventDefault()
67
+ this.#submit()
68
+ } else if (event.key === "Tab") {
69
+ event.preventDefault()
70
+ this.#complete()
71
+ } else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
72
+ event.preventDefault()
73
+ this.#recall(event.key === "ArrowUp" ? -1 : 1)
74
+ }
75
+ }
76
+
77
+ async suggest() {
78
+ const query = this.inputTarget.value.trim()
79
+ const ticket = (this.ticket = (this.ticket || 0) + 1)
80
+ const suggested = query
81
+ ? await this.index.search(query, { limit: SUGGESTIONS })
82
+ : (await this.index.list({ children: this.rootValue })).map((item) => ({ item, trail: [item] }))
83
+ if (ticket !== this.ticket) return
84
+
85
+ this.suggested = suggested
86
+ this.suggestionsTarget.replaceChildren(...suggested.slice(0, SUGGESTIONS).map(({ item, trail }, i) => {
87
+ const li = document.createElement("li")
88
+ li.className = "rori-terminal__suggestion"
89
+ li.toggleAttribute("aria-selected", i === 0)
90
+ const label = document.createElement("span")
91
+ label.textContent = words(trail) + (isNested(item) ? " ›" : "")
92
+ const group = document.createElement("span")
93
+ group.className = "rori-terminal__group"
94
+ group.textContent = (item.current ? "✓ " : "") + item.group
95
+ li.append(label, group)
96
+ return li
97
+ }))
98
+ }
99
+
100
+ // Tab: spell out the best match's whole path.
101
+ #complete() {
102
+ const best = this.suggested?.[0]
103
+ if (!best) return
104
+ this.inputTarget.value = words(best.trail) + (isNested(best.item) ? " " : "")
105
+ this.suggest()
106
+ }
107
+
108
+ async #submit() {
109
+ const line = this.inputTarget.value.trim()
110
+ this.inputTarget.value = ""
111
+ this.#print(`${this.promptValue} ${line}`, "echo")
112
+ if (this.confirming) return this.#answer(line)
113
+ if (!line) return this.suggest()
114
+ this.#remember(line)
115
+
116
+ if (line === "clear") {
117
+ this.outputTarget.replaceChildren()
118
+ } else if (line === "help") {
119
+ this.#printList(this.messagesValue.help, await this.index.list({ children: this.rootValue }))
120
+ } else {
121
+ await this.#run(line)
122
+ }
123
+ this.suggest()
124
+ }
125
+
126
+ async #run(query) {
127
+ const [best] = await this.index.search(query, { limit: 1 })
128
+ if (!best) return this.#print(format(this.messagesValue.no_match, { query }), "error")
129
+
130
+ const { item, trail } = best
131
+ const path = trail.map(({ label }) => label).join(" › ")
132
+ if (isNested(item)) return this.#printList(format(this.messagesValue.list, { path }), await this.index.list(item))
133
+ if (item.confirm) {
134
+ this.confirming = { item, path }
135
+ return this.#print(format(this.messagesValue.confirm, { path }), "muted")
136
+ }
137
+ this.#execute(item, path)
138
+ }
139
+
140
+ #answer(line) {
141
+ const { item, path } = this.confirming
142
+ this.confirming = null
143
+ if (YES.test(line)) this.#execute(item, path)
144
+ else this.#print(this.messagesValue.cancelled, "muted")
145
+ this.suggest()
146
+ }
147
+
148
+ #execute(item, path) {
149
+ const { url, action, param, run } = item
150
+ this.dispatch("run", { prefix: BUS, detail: { url, action, param, run, newWindow: false } })
151
+ this.#print(format(url ? this.messagesValue.opened : this.messagesValue.ran, { path }), "ok")
152
+ this.#reindex() // runs change state: the current theme, workspaces, records
153
+ }
154
+
155
+ #reindex() {
156
+ this.index = new CommandIndex(this.rootValue, {
157
+ source: (source) => this.dispatch("source", { prefix: BUS, detail: { source, items: [] } }).detail.items,
158
+ })
159
+ }
160
+
161
+ #print(text, kind) {
162
+ const line = document.createElement("div")
163
+ line.className = `rori-terminal__entry rori-terminal__entry--${kind}`
164
+ line.textContent = text
165
+ this.outputTarget.append(line)
166
+ this.outputTarget.scrollTop = this.outputTarget.scrollHeight
167
+ }
168
+
169
+ #printList(heading, items) {
170
+ this.#print(heading, "muted")
171
+ if (!items.length) return this.#print(this.messagesValue.empty, "muted")
172
+ for (const item of items) {
173
+ const nested = isNested(item) ? " ›" : ""
174
+ this.#print(` ${token(item.label)}${nested} ${item.current ? "✓ " : ""}${item.group}`, "item")
175
+ }
176
+ }
177
+
178
+ #recall(step) {
179
+ if (!this.history.length) return
180
+ this.cursor = Math.max(0, Math.min(this.history.length, this.cursor + step))
181
+ this.inputTarget.value = this.history[this.cursor] ?? ""
182
+ this.suggest()
183
+ }
184
+
185
+ #remember(line) {
186
+ this.history = [...this.history.filter((entry) => entry !== line), line].slice(-HISTORY_LIMIT)
187
+ this.cursor = this.history.length
188
+ localStorage.setItem(HISTORY_KEY, JSON.stringify(this.history))
189
+ }
190
+
191
+ #loadHistory() {
192
+ try {
193
+ return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []
194
+ } catch {
195
+ return []
196
+ }
197
+ }
198
+ }
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const ONE_YEAR = 60 * 60 * 24 * 365
4
+
5
+ // Applies palette "theme" commands. Moving through the theme list previews
6
+ // each one; closing the palette without picking reverts to the saved theme.
7
+ // The pick lives in a cookie so the server renders it on <html> (no flash).
8
+ export default class extends Controller {
9
+ static values = { cookie: String }
10
+
11
+ run({ detail: { action, param } }) {
12
+ if (action !== "theme") return
13
+ this.#apply(param)
14
+ document.cookie = `${this.cookieValue}=${encodeURIComponent(param)}; path=/; max-age=${param ? ONE_YEAR : 0}; samesite=lax`
15
+ }
16
+
17
+ preview({ detail: { action, param } }) {
18
+ action === "theme" ? this.#apply(param) : this.revert()
19
+ }
20
+
21
+ revert() {
22
+ this.#apply(this.#saved())
23
+ }
24
+
25
+ #apply(slug) {
26
+ const root = document.documentElement
27
+ if (slug) root.dataset.theme = slug
28
+ else delete root.dataset.theme
29
+ }
30
+
31
+ #saved() {
32
+ const pair = document.cookie.split("; ").find((entry) => entry.startsWith(`${this.cookieValue}=`))
33
+ return pair ? decodeURIComponent(pair.split("=")[1]) : ""
34
+ }
35
+ }
@@ -0,0 +1,67 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const ONE_YEAR = 60 * 60 * 24 * 365
4
+
5
+ // Applies the UI › Wallpaper commands:
6
+ // wallpaper (safe | cover | off) — classes on <body>: the photo inside the
7
+ // desk, behind the menu bar too, or none. Previews as you move through the
8
+ // list and reverts if you don't pick, like rori-theme.
9
+ // wallpaper_next — the next photo in the pool, swapped in place.
10
+ // wallpaper_pin — keep the current photo across launches (toggles).
11
+ // Mode and pin live in cookies so the server renders them on load.
12
+ export default class extends Controller {
13
+ static targets = ["credit"]
14
+ static values = { cookie: String, mode: String, pinCookie: String, pool: Array, current: String, pinned: Boolean }
15
+
16
+ run({ detail: { action, param } }) {
17
+ if (action === "wallpaper") {
18
+ this.modeValue = param
19
+ this.#setCookie(this.cookieValue, param)
20
+ } else if (action === "wallpaper_next") {
21
+ this.#next()
22
+ } else if (action === "wallpaper_pin") {
23
+ this.pinnedValue = !this.pinnedValue
24
+ this.#setCookie(this.pinCookieValue, this.pinnedValue ? this.currentValue : "")
25
+ }
26
+ }
27
+
28
+ preview({ detail: { action, param } }) {
29
+ action === "wallpaper" ? this.#apply(param) : this.revert()
30
+ }
31
+
32
+ revert() {
33
+ this.#apply(this.modeValue)
34
+ }
35
+
36
+ modeValueChanged(mode) {
37
+ this.#apply(mode)
38
+ }
39
+
40
+ #next() {
41
+ const pool = this.poolValue
42
+ if (pool.length < 2) return
43
+ const index = pool.findIndex(({ id }) => id === this.currentValue)
44
+ const wallpaper = pool[(index + 1) % pool.length]
45
+
46
+ this.element.style.setProperty("--rori-wallpaper", wallpaper.image)
47
+ this.currentValue = wallpaper.id
48
+ if (this.pinnedValue) this.#setCookie(this.pinCookieValue, wallpaper.id)
49
+ if (this.hasCreditTarget) {
50
+ this.creditTarget.hidden = !wallpaper.credit
51
+ if (wallpaper.credit) this.creditTarget.href = wallpaper.credit
52
+ }
53
+ this.#apply(this.modeValue)
54
+ }
55
+
56
+ #apply(mode) {
57
+ // Without a photo on the page (Rori.wallpapers off) there's nothing to show.
58
+ const photo = this.element.style.getPropertyValue("--rori-wallpaper") !== ""
59
+ this.element.classList.toggle("has-wallpaper", photo && mode !== "off")
60
+ this.element.classList.toggle("wallpaper-cover", mode === "cover")
61
+ }
62
+
63
+ // An empty value removes the cookie.
64
+ #setCookie(name, value) {
65
+ document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${value ? ONE_YEAR : 0}; samesite=lax`
66
+ }
67
+ }
@@ -0,0 +1,105 @@
1
+ # A command-palette entry. Running it either opens `url` in a desk window,
2
+ # fires a client-side `action` (with an optional `param`) that the desk or
3
+ # theme controller picks up, runs a server-side command (`run`, see
4
+ # `Rori.command`; `confirm` asks first), or drills into a nested list: served
5
+ # at the `children` URL, or built in the browser from a named `source` for
6
+ # state the server can't see (see rori-palette's `source` event).
7
+ class Rori::Command < Data.define(:label, :group, :url, :action, :param, :children, :source, :current, :run, :confirm)
8
+ ACTIONS = %w[ overview new_workspace cycle_width full_width center_column close_window reopen_window toggle_terminal shortcuts ].freeze
9
+
10
+ class << self
11
+ def all = routes + records + actions + [ workspace_mover, theme_picker, ui_menu ] + runs + app_commands
12
+
13
+ # Every parameterless GET route becomes a command once it has a label under
14
+ # `desk.commands.routes.<controller>.<action>` — adding the locale key opts it in.
15
+ def routes
16
+ Rails.application.routes.routes.filter_map do |route|
17
+ controller, action = route.defaults.values_at(:controller, :action)
18
+ next unless controller && route.verb == "GET" && route.required_parts.empty?
19
+
20
+ key = "rori.commands.routes.#{controller.tr("/", ".")}.#{action}"
21
+ new(label: I18n.t(key), group: group(:open), url: route.format({})) if I18n.exists?(key)
22
+ end.uniq(&:url)
23
+ end
24
+
25
+ # The most recently updated records of every model in `Rori.records`.
26
+ def records
27
+ Rori.records.map(&:constantize).flat_map do |model|
28
+ model.order(updated_at: :desc).limit(Rori.record_limit).map do |record|
29
+ new(label: record.to_s, group: model.model_name.human, url: url_helpers.polymorphic_path(record))
30
+ end
31
+ end
32
+ end
33
+
34
+ def actions
35
+ ACTIONS.map { new(label: I18n.t(it, scope: "rori.commands.actions"), group: group(:rori), action: it) }
36
+ end
37
+
38
+ # The app's server-side commands (`Rori.command`).
39
+ def runs
40
+ Rori.runnables.values.map { new(label: it.label, group: group(:run), run: it.name, confirm: it.confirm) }
41
+ end
42
+
43
+ def workspace_mover
44
+ new(label: I18n.t("rori.commands.workspaces.move"), group: group(:rori), source: "workspaces")
45
+ end
46
+
47
+ def theme_picker
48
+ new(label: I18n.t("rori.commands.themes.pick"), group: group(:rori), children: url_helpers.rori_commands_themes_path)
49
+ end
50
+
51
+ def ui_menu
52
+ new(label: I18n.t("rori.commands.ui.label"), group: group(:rori), children: url_helpers.rori_commands_ui_path)
53
+ end
54
+
55
+ # "UI ›": one nested list per appearance setting.
56
+ def ui
57
+ theme = new(label: I18n.t("rori.commands.ui.theme"), group: group(:ui), children: url_helpers.rori_commands_themes_path)
58
+ wallpaper = new(label: I18n.t("rori.commands.ui.wallpaper"), group: group(:ui), children: url_helpers.rori_commands_wallpapers_path)
59
+ bars = new(label: I18n.t("rori.commands.ui.bars"), group: group(:ui), children: url_helpers.rori_commands_bars_path)
60
+ Rori.wallpapers ? [ theme, wallpaper, bars ] : [ theme, bars ]
61
+ end
62
+
63
+ # "UI › Menu bar ›": top or bottom.
64
+ def bars(current:)
65
+ Rori::PrefsHelper::BAR_POSITIONS.map do |position|
66
+ new(label: I18n.t(position, scope: "rori.commands.bars"), group: I18n.t("rori.commands.ui.bars"),
67
+ action: "bars", param: position, current: position == current)
68
+ end
69
+ end
70
+
71
+ # The modes, then Next (swap the photo now) and Pin (keep it across launches).
72
+ def wallpapers(current:, pinned: false)
73
+ group = I18n.t("rori.commands.ui.wallpaper")
74
+ modes = Rori::Wallpaper::MODES.map do |mode|
75
+ new(label: I18n.t(mode, scope: "rori.commands.wallpapers"), group:, action: "wallpaper", param: mode, current: mode == current)
76
+ end
77
+ modes + [
78
+ new(label: I18n.t("rori.commands.wallpapers.next"), group:, action: "wallpaper_next"),
79
+ new(label: I18n.t("rori.commands.wallpapers.pin"), group:, action: "wallpaper_pin", current: pinned)
80
+ ]
81
+ end
82
+
83
+ # `param: ""` is the built-in theme (no data-theme attribute).
84
+ def themes(current:)
85
+ default = new(label: I18n.t("rori.commands.themes.default"), group: I18n.t("rori.commands.themes.auto"),
86
+ action: "theme", param: "", current: current.nil?)
87
+
88
+ [ default ] + Rori::Theme.all.map do |theme|
89
+ new(label: theme.name, group: I18n.t(theme.dark? ? "dark" : "light", scope: "rori.commands.themes"),
90
+ action: "theme", param: theme.slug, current: theme.slug == current&.slug)
91
+ end
92
+ end
93
+
94
+ private
95
+ def app_commands = Rori.commands.flat_map { Array(it.call) }
96
+
97
+ def group(name) = I18n.t(name, scope: "rori.commands.groups")
98
+
99
+ def url_helpers = Rails.application.routes.url_helpers
100
+ end
101
+
102
+ def initialize(label:, group:, url: nil, action: nil, param: nil, children: nil, source: nil, current: false, run: nil, confirm: false) = super
103
+
104
+ def to_partial_path = "rori/commands/command"
105
+ end
@@ -0,0 +1,20 @@
1
+ # A notification in the desk's corner (rori/_notification): the result of a
2
+ # server-side command, or anything the app sends with `Rori.notify`.
3
+ # Errors stay until dismissed; the rest fade out on their own.
4
+ class Rori::Notification < Data.define(:title, :body, :kind)
5
+ KINDS = %i[ info success error ].freeze
6
+
7
+ def initialize(title:, body: nil, kind: :info)
8
+ kind = kind.to_sym
9
+ raise ArgumentError, "notification kind must be one of #{KINDS.join(", ")}" unless KINDS.include?(kind)
10
+
11
+ super
12
+ end
13
+
14
+ def sticky? = kind == :error
15
+
16
+ def broadcast
17
+ Turbo::StreamsChannel.broadcast_append_to Rori.notifications_stream,
18
+ target: "rori-notifications", partial: "rori/notification", locals: { notification: self }
19
+ end
20
+ end
@@ -0,0 +1,30 @@
1
+ # An OKLCH color. Theme files are sRGB hex; the app's CSS speaks oklch().
2
+ class Rori::Theme::Color < Data.define(:lightness, :chroma, :hue)
3
+ class << self
4
+ # sRGB hex → linear RGB → OKLab → OKLCH (https://bottosson.github.io/posts/oklab/).
5
+ def from_hex(hex)
6
+ r, g, b = hex.delete_prefix("#").scan(/../).map { linear(it.to_i(16) / 255.0) }
7
+
8
+ l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
9
+ m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
10
+ s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
11
+
12
+ lab_l = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
13
+ lab_a = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
14
+ lab_b = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
15
+
16
+ new(lightness: lab_l, chroma: Math.hypot(lab_a, lab_b), hue: Math.atan2(lab_b, lab_a) * 180 / Math::PI % 360)
17
+ end
18
+
19
+ private
20
+ def linear(channel) = channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055)**2.4
21
+ end
22
+
23
+ def darken(amount) = with(lightness: (lightness - amount).clamp(0, 1))
24
+
25
+ # Near-greys have no meaningful hue; pin it so rounding noise doesn't churn the stylesheet.
26
+ def to_s
27
+ hue = chroma < 0.002 ? 0 : self.hue
28
+ format("oklch(%.4f %.4f %.2f)", lightness, chroma, hue)
29
+ end
30
+ end
@@ -0,0 +1,87 @@
1
+ # A terminal colour scheme in Ghostty's format, vendored from
2
+ # iTerm2-Color-Schemes into `Rori.themes_directory`. Each theme overrides the
3
+ # host's semantic color tokens (--color-*), so it re-colours the whole app; the
4
+ # desk's own --rori-* tokens derive from them (rori/_init.css); `bin/rails rori:themes:build` compiles them all into
5
+ # `Rori.themes_stylesheet`. Drop in another file and rebuild to add one.
6
+ class Rori::Theme
7
+ # Window gutters sit this much darker than windows, like the default theme.
8
+ CANVAS_DARKEN = 0.04
9
+
10
+ attr_reader :name, :colors
11
+
12
+ class << self
13
+ # Bundled themes plus the app's `Rori.themes_folder`; an app theme with a
14
+ # bundled theme's name replaces it.
15
+ def all = (bundled + local).index_by(&:slug).values.sort_by(&:name)
16
+
17
+ def bundled = load(Rori.themes_directory)
18
+
19
+ def local = Rori.themes_folder ? load(Rails.root.join(Rori.themes_folder)) : []
20
+
21
+ def find(slug) = slug.presence && all.find { it.slug == slug }
22
+
23
+ def from_file(path)
24
+ colors = path.readlines.each_with_object({}) do |line, colors|
25
+ key, value = line.split("=", 2).map(&:strip)
26
+ next if value.blank?
27
+
28
+ if key == "palette"
29
+ index, hex = value.split("=", 2)
30
+ colors[index.to_i] = hex.strip
31
+ else
32
+ colors[key] = value
33
+ end
34
+ end
35
+ new(path.basename.to_s, colors)
36
+ end
37
+
38
+ # The compiled file holds the bundled themes; app themes are rendered into
39
+ # the shell instead (Rori::ThemeHelper), so apps never write into the gem.
40
+ def stylesheet
41
+ <<~CSS
42
+ /* Generated by `bin/rails rori:themes:build` from vendor/themes/ghostty — do not edit. */
43
+ #{css(bundled)}
44
+ CSS
45
+ end
46
+
47
+ def css(themes) = "@layer rori.themes {\n#{themes.map(&:to_css).join("\n")}}"
48
+
49
+ private
50
+ def load(directory) = Pathname(directory).glob("*").select(&:file?).sort.map { from_file(it) }
51
+ end
52
+
53
+ def initialize(name, colors)
54
+ @name = name
55
+ @colors = colors
56
+ end
57
+
58
+ def slug = name.parameterize
59
+
60
+ def dark? = background.lightness < 0.6
61
+
62
+ def tokens
63
+ {
64
+ "--color-canvas" => background.darken(CANVAS_DARKEN),
65
+ "--color-surface" => background,
66
+ "--color-ink" => color("foreground"),
67
+ "--color-danger" => color(1),
68
+ "--color-success" => color(2),
69
+ "--color-warning" => color(3),
70
+ "--color-info" => color(4)
71
+ }
72
+ end
73
+
74
+ def to_css
75
+ declarations = [ "color-scheme: #{dark? ? "dark" : "light"}", *tokens.map { |token, value| "#{token}: #{value}" } ]
76
+ <<~CSS.indent(2)
77
+ :root[data-theme="#{slug}"] {
78
+ #{declarations.map { " #{it};" }.join("\n")}
79
+ }
80
+ CSS
81
+ end
82
+
83
+ private
84
+ def background = color("background")
85
+
86
+ def color(key) = Color.from_hex(colors.fetch(key))
87
+ end
@@ -0,0 +1,54 @@
1
+ # An optional background photo behind the strips (Rori.wallpapers). One is
2
+ # picked at random per full page load — the desk's "launch" — unless one is
3
+ # pinned; window frames never re-render the shell, so it stays put while you
4
+ # work. UI › Wallpaper › Next swaps it in place (rori-wallpaper).
5
+ #
6
+ # The pool is the bundled Unsplash photos (hotlinked, resized by their CDN), or
7
+ # the images in the app's own `Rori.wallpapers_folder` asset folder.
8
+ class Rori::Wallpaper < Data.define(:id, :local)
9
+ CDN = "https://images.unsplash.com"
10
+ IMAGE = /\.(jpe?g|png|webp|avif)\z/i
11
+
12
+ # safe: inside the desk only; cover: behind the menu bar too.
13
+ MODES = %w[ safe cover off ].freeze
14
+
15
+ class << self
16
+ def all = Rori.wallpapers_folder ? local : unsplash
17
+
18
+ def unsplash = YAML.load_file(Rori.wallpapers_file).map { new(id: it.to_s) }
19
+
20
+ # Images under the folder, as asset logical paths (`paths` for tests).
21
+ def local(paths = asset_paths)
22
+ prefix = "#{Rori.wallpapers_folder.to_s.delete_suffix("/")}/"
23
+ paths.select { it.start_with?(prefix) && it.match?(IMAGE) }.sort.map { new(id: it, local: true) }
24
+ end
25
+
26
+ def find(id) = id.presence && all.find { it.id == id }
27
+
28
+ # The pinned one if it's still in the pool, a random one otherwise.
29
+ def pick(pinned = nil)
30
+ (find(pinned) || all.sample) if Rori.wallpapers
31
+ end
32
+
33
+ def mode(value) = MODES.include?(value) ? value : MODES.first
34
+
35
+ private
36
+ def asset_paths = Rails.application.assets.load_path.assets.map { it.logical_path.to_s }
37
+ end
38
+
39
+ def initialize(id:, local: false) = super
40
+
41
+ # imgix resizes on the fly; image-set lets hi-dpi screens take the sharper one.
42
+ def url(width) = "#{CDN}/photo-#{id}?auto=format&fit=crop&w=#{width}&q=75"
43
+
44
+ def css_image
45
+ local ? %(url("#{ActionController::Base.helpers.asset_path(id)}")) : %(image-set(url("#{url(1920)}") 1x, url("#{url(3200)}") 2x))
46
+ end
47
+
48
+ # Where the status-bar credit links: the full photo on Unsplash; local
49
+ # wallpapers are the app's own and get no credit.
50
+ def credit_url = (url(3200) unless local)
51
+
52
+ # What rori-wallpaper needs to swap it in without a reload.
53
+ def as_json(*) = { id:, image: css_image, credit: credit_url }
54
+ end
@@ -0,0 +1,2 @@
1
+ -# App-wide <head> tags for the desk shell (favicons, meta). Override by
2
+ -# adding app/views/layouts/rori/_head.html.haml to the host app.