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,996 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const GAP = 12
4
+ const STORAGE_KEY = "rori"
5
+ const WIDTHS = [1 / 3, 1 / 2, 2 / 3]
6
+ const SIZES = { sm: 1 / 3, md: 1 / 2, lg: 2 / 3, xl: 1 }
7
+ const MODAL_WIDTHS = { sm: 440, md: 640, lg: 900, xl: 1200 }
8
+ const MODIFIERS = ["Control", "Alt", "Meta", "Shift"]
9
+ const FIELDS = "input, textarea, select, [contenteditable]"
10
+ // Chrome can leave focus on a field inside a dialog that just closed (the
11
+ // palette); nobody is typing there, so it mustn't swallow desk keys.
12
+ const typing = (target) => Boolean(target.closest?.(FIELDS) && !target.closest("dialog:not([open])"))
13
+ const CLOSED_LIMIT = 20
14
+ // A resting pointer that jitters less than this doesn't re-arm hover keys.
15
+ const HOVER_JITTER = 4
16
+
17
+ const clamp = (n, min, max) => Math.max(min, Math.min(n, max))
18
+
19
+ // Scrollable tiling (niri/PaperWM): each workspace is an endless horizontal strip
20
+ // of columns, each column stacks one or more windows, workspaces stack vertically.
21
+ //
22
+ // Every window is a <dialog> around a <turbo-frame refresh="morph">, so links and
23
+ // forms inside it navigate just that window. Pages describe how they want to be
24
+ // shown with a <template data-window-meta> (Rori::WindowHelper#window), re-read on every
25
+ // frame load. New windows load hidden in `floating` and are placed once their page
26
+ // says where they belong; modals stay there, shown with showModal().
27
+ export default class extends Controller {
28
+ static targets = ["viewport", "stack", "floating", "window", "template", "workspaces", "minimap"]
29
+ static values = {
30
+ rootUrl: String, loadError: String, workspaceGroup: String, newWorkspaceLabel: String, discardPrompt: String,
31
+ keymap: Object, hoverKeymap: Object, hoverTimeout: { type: Number, default: 1500 }, native: Boolean,
32
+ }
33
+
34
+ initialize() {
35
+ this.offsets = new WeakMap() // workspace → strip scroll offset
36
+ this.spans = new WeakMap() // workspace → { spans, total } from the last layout
37
+ this.lastFocus = new WeakMap() // workspace → window
38
+ this.lastInColumn = new WeakMap() // column → window
39
+ this.histories = new WeakMap() // window → back stack
40
+ this.pendingReloads = new Set()
41
+ this.closed = [] // recently closed windows, for reopen_window
42
+ this.overview = false
43
+ }
44
+
45
+ connect() {
46
+ this.element.classList.add("is-booting")
47
+ this.#restore()
48
+ requestAnimationFrame(() => requestAnimationFrame(() => this.element.classList.remove("is-booting")))
49
+ }
50
+
51
+ // Chord ("Alt+Shift+Digit2") → { action, digit } from Rori.keymap (see app/models/desk.rb).
52
+ keymapValueChanged(keymap) {
53
+ this.bindings = this.#bindingsFrom(keymap)
54
+ }
55
+
56
+ // Rori.hover_keymap: bare keys for the window under the pointer (empty when off).
57
+ hoverKeymapValueChanged(keymap) {
58
+ this.hoverBindings = this.#bindingsFrom(keymap)
59
+ }
60
+
61
+ // Entry points ---------------------------------------------------------------
62
+
63
+ // Server-side commands (`run`) are rori-notifications' job.
64
+ command({ detail: { url, action, param, run, newWindow } }) {
65
+ if (run) return
66
+ if (url) return this.#spawn(url, { reuse: !newWindow })
67
+ this.#perform(action, { workspace: param })
68
+ }
69
+
70
+ // Client-side command lists (Rori::Command `source:`), for ⌘K and the terminal:
71
+ // workspaces only exist in the browser.
72
+ commandSource({ detail }) {
73
+ if (detail.source !== "workspaces" || !this.#focusIn(this.current)) return
74
+
75
+ const item = (label, param) => ({ label, group: this.workspaceGroupValue, action: "move_to_workspace", param })
76
+ detail.items = [
77
+ ...this.#workspaces().filter((ws) => ws !== this.current).map((ws) => item(this.#workspaceLabel(ws), ws.dataset.name)),
78
+ item(this.newWorkspaceLabelValue, ""),
79
+ ]
80
+ }
81
+
82
+ // rori_link_to (data-turbo-frame="_top") and any other top-level visit open a window instead.
83
+ interceptVisit(event) {
84
+ event.preventDefault()
85
+ this.#spawn(event.detail.url, { reuse: true })
86
+ }
87
+
88
+ // Remember which windows a refresh came from; their stream sources live inside them.
89
+ streamMessage(event) {
90
+ if (!event.target.matches?.("turbo-cable-stream-source")) return
91
+ if (typeof event.data === "string" && event.data.includes('action="refresh"')) {
92
+ const win = event.target.closest(".rori-win")
93
+ if (win) this.pendingReloads.add(win)
94
+ }
95
+ }
96
+
97
+ // A page refresh would morph the whole body and drop client-opened windows,
98
+ // so refreshes reload (and morph) only the affected window frames.
99
+ streamRender(event) {
100
+ if (event.target.getAttribute?.("action") !== "refresh") return
101
+ event.detail.render = () => this.#reloadSoon()
102
+ }
103
+
104
+ keydown(event) {
105
+ // Surfaces that own the keyboard (the terminal) opt out of every desk key.
106
+ if (event.target.closest?.("[data-rori-keys=off]")) return
107
+ if (this.#emptyDeskKey(event)) return
108
+ if (this.#bareKey(event)) return
109
+ if (event.key === "Escape") return this.#escape(event)
110
+
111
+ const binding = this.bindings.get(this.#chord(event))
112
+ // Chords edit text inside fields (⌥← jumps a word, ⌘← to line start): leave them be.
113
+ if (!binding || typing(event.target)) return
114
+
115
+ event.preventDefault()
116
+ this.#perform(binding.action, { workspace: this.#workspaceAt(binding.digit) })
117
+ if (this.focused && !this.focused.contains(document.activeElement)) this.focused.focus({ preventScroll: true })
118
+ }
119
+
120
+ // Blender-style: after the pointer deliberately moves onto an inactive window,
121
+ // bare hover keys act on that window (even while a field elsewhere has focus)
122
+ // until the pointer rests for hoverTimeout, leaves, or you type anything else.
123
+ hover(event) {
124
+ if (!this.hoverBindings?.size || this.overview || !["mouse", "pen"].includes(event.pointerType)) return
125
+ this.pointer = [event.clientX, event.clientY]
126
+ const win = event.target.closest(".rori-col > .rori-win")
127
+ if (!win || win === this.focused) return this.#disarm()
128
+ if (win !== this.armed && this.restingAt &&
129
+ Math.hypot(event.clientX - this.restingAt[0], event.clientY - this.restingAt[1]) < HOVER_JITTER) return
130
+ this.#arm(win)
131
+ }
132
+
133
+ unhover() {
134
+ this.#disarm()
135
+ }
136
+
137
+ // Esc closes the overview, or hands focus from a field back to its window so
138
+ // the keymap applies again. In modals Esc keeps closing the modal.
139
+ #escape(event) {
140
+ if (this.overview) {
141
+ event.preventDefault()
142
+ return this.toggleOverview()
143
+ }
144
+ const win = event.target.closest?.(".rori-win")
145
+ if (win && !win.matches(":modal") && typing(event.target)) {
146
+ event.preventDefault()
147
+ return win.focus({ preventScroll: true })
148
+ }
149
+ // Native shell: WebKit hands an Esc the page didn't claim to the window, and
150
+ // a macOS full-screen window leaves full screen — even when the Esc also
151
+ // closed a modal (its default action). So claim every Esc and close the
152
+ // topmost modal ourselves, the way the browser would.
153
+ if (this.nativeValue && !event.defaultPrevented) {
154
+ event.preventDefault()
155
+ this.#cancelTopModal()
156
+ }
157
+ }
158
+
159
+ // What Esc does to a modal <dialog>: a cancelable `cancel`, then close unless
160
+ // a listener objected (modal windows handle cancel themselves, see #cancel).
161
+ #cancelTopModal() {
162
+ const modal = [...document.querySelectorAll("dialog:modal")].at(-1)
163
+ if (modal?.dispatchEvent(new Event("cancel", { cancelable: true }))) modal.close()
164
+ }
165
+
166
+ // Bare keys (Rori.hover_keymap) act on the armed hovered window — even while a
167
+ // field elsewhere has focus — or, when no field has focus, on the focused
168
+ // window (vim-style normal mode). Held-down repeats never count.
169
+ #bareKey(event) {
170
+ if (!this.hoverBindings?.size || MODIFIERS.includes(event.key)) return false
171
+
172
+ const armed = this.armed?.isConnected ? this.armed : null
173
+ const binding = this.hoverBindings.get(this.#chord(event))
174
+ const blocked = event.repeat || event.isComposing || this.overview || document.querySelector("dialog:modal")
175
+ if (!binding || blocked) {
176
+ if (this.armed) this.#disarm()
177
+ return false
178
+ }
179
+ if (!armed && typing(event.target)) return false
180
+
181
+ event.preventDefault()
182
+ this.#perform(binding.action, { win: armed || undefined, workspace: this.#workspaceAt(binding.digit) })
183
+
184
+ if (armed) armed.isConnected && armed !== this.focused && armed.closest(".rori-workspace") === this.current ? this.#arm(armed) : this.#disarm()
185
+ // A field left behind on another workspace would take the next keys unseen.
186
+ const stayInField = armed && this.current?.contains(document.activeElement)
187
+ if (!stayInField && this.focused && !this.focused.contains(document.activeElement)) this.focused.focus({ preventScroll: true })
188
+ return true
189
+ }
190
+
191
+ // On an empty workspace there's nothing for keys to act on, so Space or Enter
192
+ // opens ⌘K — the one useful next step. Buttons and links keep their own.
193
+ #emptyDeskKey(event) {
194
+ if (!["Space", "Enter", "NumpadEnter"].includes(event.code) || event.repeat || event.defaultPrevented) return false
195
+ if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return false
196
+ if (this.current?.querySelector(".rori-win") || this.overview || document.querySelector("dialog:modal")) return false
197
+ if (typing(event.target) || event.target.closest?.("button, a, summary")) return false
198
+
199
+ event.preventDefault()
200
+ this.dispatch("open-palette")
201
+ return true
202
+ }
203
+
204
+ #arm(win) {
205
+ if (win !== this.armed) {
206
+ this.armed?.classList.remove("is-armed")
207
+ win.classList.add("is-armed")
208
+ this.armed = win
209
+ }
210
+ this.restingAt = null
211
+ clearTimeout(this.armTimer)
212
+ this.armTimer = setTimeout(() => this.#disarm(), this.hoverTimeoutValue)
213
+ }
214
+
215
+ // Re-arming needs fresh movement from where the pointer is now.
216
+ #disarm() {
217
+ clearTimeout(this.armTimer)
218
+ this.armed?.classList.remove("is-armed")
219
+ this.armed = null
220
+ this.restingAt = this.pointer
221
+ }
222
+
223
+ // Every desk action, whether from a key or a ⌘K command. `win` defaults to the
224
+ // focused window; `workspace` is a workspace name, "" meaning a new one.
225
+ #perform(action, { win, workspace } = {}) {
226
+ const target = () => (workspace ? this.#workspace(workspace) : null)
227
+ const actions = {
228
+ focus_left: () => this.#focusDirection("left"),
229
+ focus_right: () => this.#focusDirection("right"),
230
+ focus_up: () => this.#focusDirection("up"),
231
+ focus_down: () => this.#focusDirection("down"),
232
+ move_left: () => this.#move("left", win),
233
+ move_right: () => this.#move("right", win),
234
+ move_up: () => this.#move("up", win),
235
+ move_down: () => this.#move("down", win),
236
+ // Only to workspaces that exist: a number past the last isn't worth an empty
237
+ // one (⌥↓ and New workspace make those). Moving there does make one.
238
+ switch_to_workspace: () => target() && this.#switchTo(target()),
239
+ move_to_workspace: () => this.#moveToWorkspace(target(), win, { follow: true }),
240
+ new_workspace: () => this.#switchTo(this.#newWorkspace()),
241
+ consume_left: () => this.#consumeOrExpel(-1, win),
242
+ consume_right: () => this.#consumeOrExpel(1, win),
243
+ cycle_width: () => this.#cycleWidth(win),
244
+ full_width: () => this.#toggleFullWidth(win),
245
+ center_column: () => this.#center(win),
246
+ overview: () => this.toggleOverview(),
247
+ close_window: () => this.#remove(win || this.focused),
248
+ reopen_window: () => this.#reopen(),
249
+ toggle_terminal: () => this.dispatch("toggle-terminal"),
250
+ shortcuts: () => this.dispatch("open-shortcuts"),
251
+ }
252
+ actions[action]?.()
253
+ }
254
+
255
+ #bindingsFrom(keymap) {
256
+ const bindings = new Map()
257
+ for (const [action, chords] of Object.entries(keymap)) {
258
+ for (const chord of chords) {
259
+ const digits = chord.endsWith("Digit*") ? [1, 2, 3, 4, 5, 6, 7, 8, 9] : [null]
260
+ for (const digit of digits) {
261
+ const spelled = digit ? chord.replace(/\*$/, digit) : chord
262
+ bindings.set(this.#normalize(spelled.split("+")), { action, digit })
263
+ }
264
+ }
265
+ }
266
+ return bindings
267
+ }
268
+
269
+ #chord(event) {
270
+ return this.#normalize([...MODIFIERS.filter((key) => event.getModifierState(key)), event.code])
271
+ }
272
+
273
+ #normalize(keys) {
274
+ const code = keys.at(-1)
275
+ return [...MODIFIERS.filter((key) => keys.includes(key)), code].join("+")
276
+ }
277
+
278
+ // Digit bindings address the nth workspace; past the last one means a new one ("").
279
+ #workspaceAt(digit) {
280
+ return digit ? (this.#workspaces()[digit - 1]?.dataset.name ?? "") : undefined
281
+ }
282
+
283
+ // Horizontal wheel / trackpad swipes scroll the strip; focus follows once it settles.
284
+ wheel(event) {
285
+ if (this.overview || event.ctrlKey) return
286
+ const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY)
287
+ const delta = horizontal ? event.deltaX : event.shiftKey ? event.deltaY : 0
288
+ if (!delta || (horizontal && this.#scrollsSideways(event.target))) return
289
+ event.preventDefault()
290
+
291
+ const ws = this.current
292
+ this.offsets.set(ws, (this.offsets.get(ws) || 0) + delta)
293
+ this.element.classList.add("is-scrolling")
294
+ this.#layoutStrip(ws, { reveal: false })
295
+ this.#renderMinimap()
296
+
297
+ clearTimeout(this.wheelTimer)
298
+ this.wheelTimer = setTimeout(() => {
299
+ this.element.classList.remove("is-scrolling")
300
+ const win = this.#mostVisible(ws)
301
+ if (win && win !== this.focused) this.focus(win)
302
+ }, 160)
303
+ }
304
+
305
+ toggleOverview() {
306
+ this.overview = !this.overview
307
+ this.layout()
308
+ }
309
+
310
+ save() {
311
+ const workspaces = this.#workspaces().map((ws) => {
312
+ const columns = this.#columns(ws)
313
+ const focused = this.#focusIn(ws)
314
+ const column = focused ? columns.indexOf(focused.closest(".rori-col")) : -1
315
+ return {
316
+ name: ws.dataset.name,
317
+ focus: focused ? [column, this.#windowsIn(columns[column]).indexOf(focused)] : null,
318
+ columns: columns.map((col) => ({
319
+ w: Number(col.dataset.w),
320
+ windows: this.#windowsIn(col).map((win) => ({ url: this.#src(win), mode: win.dataset.mode, size: win.dataset.size })),
321
+ })),
322
+ }
323
+ })
324
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ current: this.current?.dataset.name, workspaces }))
325
+ }
326
+
327
+ // Window actions -------------------------------------------------------------
328
+
329
+ focus(win) {
330
+ if (win && win === this.armed) this.#disarm()
331
+ if (win) {
332
+ const ws = this.#wsOf(win)
333
+ if (ws) {
334
+ this.current = ws
335
+ this.lastFocus.set(ws, win)
336
+ this.lastInColumn.set(win.closest(".rori-col"), win)
337
+ }
338
+ if (this.#title(win).textContent) document.title = this.#title(win).textContent
339
+ }
340
+ this.focused = win || null
341
+ this.windowTargets.forEach((other) => other.classList.toggle("is-focused", other === win))
342
+ this.#syncUrl(win)
343
+ this.#prune()
344
+ this.layout()
345
+ }
346
+
347
+ // Pointer or keyboard (Tab / focusin) entering a window makes it the focused one.
348
+ focusWindow(event) {
349
+ const win = this.#win(event)
350
+ // Focus on the <dialog> itself is programmatic: dialog.show() moves focus to
351
+ // the window it opens (e.g. every window a reload restores, sometimes after
352
+ // the restore has finished). Only focus inside the content is the user's.
353
+ if (event.type === "focusin" && event.target === win) return
354
+ if (this.overview) {
355
+ if (event.type !== "pointerdown") return
356
+ event.preventDefault()
357
+ this.overview = false
358
+ return this.focus(win)
359
+ }
360
+ if (win === this.focused) return
361
+
362
+ // Revealing the column now would slide it under the pointer mid-click and
363
+ // the click would land elsewhere; scroll once the button is released.
364
+ if (event.type === "pointerdown") this.#holdRevealUntilPointerUp()
365
+ this.focus(win)
366
+ }
367
+
368
+ // Edited forms are skipped by broadcast reloads (see #busy).
369
+ markDirty(event) {
370
+ event.target.form?.toggleAttribute("data-rori-dirty", true)
371
+ }
372
+
373
+ layout() {
374
+ const H = this.viewportTarget.clientHeight
375
+ const workspaces = this.#workspaces()
376
+ workspaces.forEach((ws) => this.#layoutStrip(ws))
377
+
378
+ if (this.overview) {
379
+ const V = this.viewportTarget.clientWidth
380
+ const widest = Math.max(V, ...workspaces.map((ws) => this.spans.get(ws).total))
381
+ const scale = Math.min(0.5, (V * 0.92) / widest, 0.92 / workspaces.length)
382
+ const x = (V - widest * scale) / 2
383
+ const y = (H - H * workspaces.length * scale) / 2
384
+ this.stackTarget.style.transform = `translate(${x}px, ${y}px) scale(${scale})`
385
+ } else {
386
+ this.stackTarget.style.transform = `translateY(${-workspaces.indexOf(this.current) * H}px)`
387
+ }
388
+
389
+ this.element.classList.toggle("is-overview", this.overview)
390
+ this.viewportTarget.classList.toggle("is-empty", !this.current.querySelector(".rori-win"))
391
+ this.#renderWorkspaces()
392
+ this.#renderMinimap()
393
+ }
394
+
395
+ frameLoaded(event) {
396
+ const win = this.#win(event)
397
+ if (event.target === this.#frame(win)) this.#applyMeta(win)
398
+ }
399
+
400
+ frameMissing(event) {
401
+ const win = this.#win(event)
402
+ if (event.target !== this.#frame(win)) return
403
+ event.preventDefault()
404
+
405
+ const message = document.createElement("p")
406
+ message.className = "rori-win__error"
407
+ message.textContent = this.loadErrorValue
408
+ event.target.replaceChildren(message)
409
+ this.#title(win).textContent = event.detail.response.status
410
+ win.classList.remove("is-loading")
411
+ if (!win.dataset.placed) {
412
+ win.dataset.placed = "1"
413
+ this.#place(win, "tile", "md")
414
+ }
415
+ this.focus(win)
416
+ }
417
+
418
+ // Esc on a modal window closes it for good.
419
+ cancel(event) {
420
+ event.preventDefault()
421
+ this.#remove(this.#win(event))
422
+ }
423
+
424
+ close(event) {
425
+ this.#remove(this.#win(event))
426
+ }
427
+
428
+ toggleFullWidth(event) {
429
+ if (event.target.closest("button")) return
430
+ this.focus(this.#win(event))
431
+ this.#toggleFullWidth()
432
+ }
433
+
434
+ back(event) {
435
+ const win = this.#win(event)
436
+ const stack = this.histories.get(win) || []
437
+ if (stack.length < 2) return
438
+ stack.pop()
439
+ this.#frame(win).setAttribute("src", stack.pop())
440
+ }
441
+
442
+ detach(event) {
443
+ const url = this.#src(this.#win(event))
444
+ if (url) window.open(url, "_blank", "noopener")
445
+ }
446
+
447
+ // Placement ------------------------------------------------------------------
448
+
449
+ #spawn(url, { reuse = false, into = null } = {}) {
450
+ const win = this.templateTarget.content.firstElementChild.cloneNode(true)
451
+ const frame = this.#frame(win)
452
+ frame.id = `win_${crypto.randomUUID().slice(0, 8)}`
453
+ frame.setAttribute("src", url)
454
+ win.dataset.reuse = reuse
455
+ win.classList.add("is-loading")
456
+ ; (into || this.floatingTarget).append(win)
457
+ if (!into) setTimeout(() => this.#revealIfSlow(win), 600)
458
+ return win
459
+ }
460
+
461
+ #applyMeta(win) {
462
+ const template = this.#frame(win).querySelector(":scope > template[data-window-meta]")
463
+ if (!template) return
464
+
465
+ const meta = JSON.parse(template.dataset.windowMeta)
466
+ const first = !win.dataset.placed
467
+ const wasModal = win.dataset.mode === "modal"
468
+ win.dataset.key = meta.key || ""
469
+ this.#title(win).textContent = meta.title
470
+ win.classList.remove("is-loading")
471
+
472
+ // Reuse: a freshly opened window, or a modal that finished (e.g. redirected to
473
+ // a show page), takes the place of the existing window with the same key.
474
+ const reusable = meta.key && meta.mode !== "modal" && (wasModal || (first && win.dataset.reuse === "true"))
475
+ const twin = reusable && this.windowTargets.find((other) =>
476
+ other !== win && other.dataset.key === meta.key && other.dataset.mode !== "modal")
477
+ win.dataset.reuse = false
478
+ if (twin) return this.#takeOver(win, twin)
479
+
480
+ if (first) {
481
+ win.dataset.placed = "1"
482
+ this.#place(win, meta.mode, meta.size, this.#hintedWorkspace(meta.workspace))
483
+ } else if (meta.mode !== win.dataset.mode) {
484
+ this.#place(win, meta.mode, meta.size, this.#wsOf(win) || this.current)
485
+ }
486
+ this.#pushHistory(win)
487
+
488
+ if (first || wasModal !== (meta.mode === "modal")) return this.focus(win)
489
+ if (win === this.focused) this.#syncUrl(win)
490
+ this.layout()
491
+ }
492
+
493
+ #place(win, mode = "tile", size = "md", ws = this.current) {
494
+ win.dataset.mode = mode
495
+ win.dataset.size = size
496
+
497
+ if (mode === "modal") {
498
+ if (win.open && !win.matches(":modal")) win.close()
499
+ this.#moveTo(win, this.floatingTarget)
500
+ win.style.width = `${MODAL_WIDTHS[size] || MODAL_WIDTHS.sm}px`
501
+ if (!win.open) {
502
+ win.returnFocus = document.activeElement // see #remove
503
+ win.showModal()
504
+ }
505
+ return
506
+ }
507
+
508
+ if (win.matches(":modal")) win.close()
509
+ win.style.width = ""
510
+ const width = mode === "fullscreen" ? 1 : SIZES[size] || SIZES.md
511
+ const col = win.closest(".rori-col")
512
+ if (col) col.dataset.w = width
513
+ else this.#moveTo(win, this.#newColumnIn(ws, width))
514
+ this.#open(win)
515
+ }
516
+
517
+ // The loaded window takes the twin's slot and back-history, so to the user it
518
+ // looks like the twin navigated.
519
+ #takeOver(win, twin) {
520
+ if (win.matches(":modal")) win.close()
521
+ win.dataset.mode = twin.dataset.mode
522
+ win.dataset.size = twin.dataset.size
523
+ win.dataset.placed = "1"
524
+ this.histories.set(win, [...(this.histories.get(twin) || [])])
525
+ this.#moveTo(win, twin.parentElement, twin)
526
+ twin.remove()
527
+ this.#open(win)
528
+ this.#pushHistory(win)
529
+ this.focus(win)
530
+ }
531
+
532
+ #revealIfSlow(win) {
533
+ if (!win.isConnected || win.dataset.placed) return
534
+ win.dataset.placed = "1"
535
+ this.#place(win, "tile", "md")
536
+ this.focus(win)
537
+ }
538
+
539
+ // Every close (×, keys, Esc on a modal, ⌘K) ends here. Unsaved edits ask
540
+ // first; tiled windows can be brought back with reopen_window.
541
+ #remove(win) {
542
+ if (!win) return
543
+ if (win.querySelector("form[data-rori-dirty]") && !window.confirm(this.discardPromptValue)) return
544
+
545
+ const col = win.closest(".rori-col")
546
+ const neighbour = col && (win.nextElementSibling || win.previousElementSibling ||
547
+ this.#remembered(col.nextElementSibling) || this.#remembered(col.previousElementSibling))
548
+ if (col && this.#src(win)) this.#rememberClosed(win, col)
549
+ // Removing a modal (unlike dialog.close()) doesn't hand focus back to what
550
+ // opened it — e.g. the terminal prompt a `new user` came from.
551
+ const returnFocus = win.matches(":modal") ? win.returnFocus : null
552
+
553
+ win.remove()
554
+ if (col && !col.querySelector(".rori-win")) col.remove()
555
+ if (win !== this.focused && this.focused?.isConnected) this.layout()
556
+ else this.focus(neighbour || this.#focusIn(this.current) || this.current.querySelector(".rori-win"))
557
+ if (returnFocus?.isConnected && returnFocus.checkVisibility()) returnFocus.focus({ preventScroll: true })
558
+ }
559
+
560
+ #rememberClosed(win, col) {
561
+ const ws = this.#wsOf(col)
562
+ this.closed.push({
563
+ url: this.#src(win), mode: win.dataset.mode, size: win.dataset.size, w: col.dataset.w,
564
+ workspace: ws.dataset.name, index: this.#columns(ws).indexOf(col),
565
+ })
566
+ if (this.closed.length > CLOSED_LIMIT) this.closed.shift()
567
+ }
568
+
569
+ // Brings the last closed window back as a column where it was.
570
+ #reopen() {
571
+ const entry = this.closed.pop()
572
+ if (!entry) return
573
+
574
+ const ws = this.#workspace(entry.workspace) || this.current
575
+ const col = this.#createColumn(entry.w)
576
+ const at = this.#columns(ws)[entry.index]
577
+ at ? at.before(col) : this.#strip(ws).append(col)
578
+ const win = this.#spawn(entry.url, { into: col })
579
+ Object.assign(win.dataset, { placed: "1", mode: entry.mode, size: entry.size })
580
+ this.#open(win)
581
+ this.focus(win)
582
+ }
583
+
584
+ #moveTo(win, parent, before = null) {
585
+ // Re-inserting a modal dialog would drop it from the top layer.
586
+ if (win.parentElement === parent && !before) return
587
+ const col = win.closest(".rori-col")
588
+ parent.insertBefore(win, before)
589
+ if (col && col !== parent && !col.querySelector(".rori-win")) col.remove()
590
+ }
591
+
592
+ #open(win) {
593
+ if (!win.open && win.isConnected) win.dataset.mode === "modal" ? win.showModal() : win.show()
594
+ }
595
+
596
+ #restore() {
597
+ const saved = this.#load()
598
+ const here = location.pathname + location.search
599
+ const main = this.windowTargets.find((win) => this.#frame(win).id === "win_main")
600
+ let mainPlaced = false
601
+
602
+ for (const { name, columns = [], focus } of saved.workspaces || []) {
603
+ const ws = this.#createWorkspace(name)
604
+ columns.forEach(({ w, windows = [] }, c) => {
605
+ const col = this.#createColumn(w)
606
+ this.#strip(ws).append(col)
607
+ windows.filter(({ url }) => url).forEach(({ url, mode, size }, r) => {
608
+ const reuseMain = main && !mainPlaced && url === here
609
+ const win = reuseMain ? main : this.#spawn(url, { into: col })
610
+ if (reuseMain) { col.append(main); mainPlaced = true }
611
+ Object.assign(win.dataset, { placed: "1", mode: mode || "tile", size: size || "md" })
612
+ this.#open(win)
613
+ if (focus?.[0] === c && focus?.[1] === r) this.lastFocus.set(ws, win)
614
+ })
615
+ if (!col.children.length) col.remove()
616
+ })
617
+ }
618
+
619
+ this.current = this.#workspace(saved.current) || this.#workspaces()[0] || this.#createWorkspace("1")
620
+ if (!main) return this.#switchTo(this.current)
621
+ this.#applyMeta(main)
622
+ this.focus(main)
623
+ }
624
+
625
+ // Navigation -----------------------------------------------------------------
626
+
627
+ #focusDirection(direction) {
628
+ const win = this.#focusIn(this.current)
629
+ if (!win) {
630
+ if (direction === "up" || direction === "down") this.#stepWorkspace(direction)
631
+ return
632
+ }
633
+ const col = win.closest(".rori-col")
634
+ if (direction === "left") return this.focus(this.#remembered(col.previousElementSibling) || win)
635
+ if (direction === "right") return this.focus(this.#remembered(col.nextElementSibling) || win)
636
+
637
+ const next = direction === "up" ? win.previousElementSibling : win.nextElementSibling
638
+ next ? this.focus(next) : this.#stepWorkspace(direction)
639
+ }
640
+
641
+ // Past the last workspace there is always an empty one to go to.
642
+ #stepWorkspace(direction) {
643
+ const workspaces = this.#workspaces()
644
+ const i = workspaces.indexOf(this.current)
645
+ if (direction === "up" && i > 0) this.#switchTo(workspaces[i - 1])
646
+ if (direction === "down") {
647
+ const below = workspaces[i + 1] || (this.current.querySelector(".rori-win") && this.#newWorkspace())
648
+ if (below) this.#switchTo(below)
649
+ }
650
+ }
651
+
652
+ // Window actions below act on `win` — the focused window by default, or the
653
+ // hovered one for hover keys — and only move focus when it was the focused one.
654
+
655
+ #move(direction, win = this.#focusIn(this.current)) {
656
+ if (!win) return
657
+ const col = win.closest(".rori-col")
658
+
659
+ const workspaces = this.#workspaces()
660
+ const i = workspaces.indexOf(this.current)
661
+ if (direction === "up") return i > 0 && this.#moveToWorkspace(workspaces[i - 1], win)
662
+ if (direction === "down") return this.#moveToWorkspace(workspaces[i + 1], win)
663
+
664
+ if (direction === "left") col.previousElementSibling?.before(col)
665
+ else col.nextElementSibling?.after(col)
666
+ this.#settle(win)
667
+ }
668
+
669
+ // Moves the window's column to `target` — a new workspace when missing. Focus
670
+ // follows if the column holds the focused window; with `follow` (the move-to-
671
+ // workspace action, e.g. a hovered window's ⇧1–9) it always goes to `win`, so
672
+ // the moved window never just vanishes from view.
673
+ #moveToWorkspace(target, win = this.#focusIn(this.current), { follow = false } = {}) {
674
+ if (!win || target === this.#wsOf(win)) return follow && this.focus(win)
675
+
676
+ target ||= this.#newWorkspace()
677
+ const col = win.closest(".rori-col")
678
+ const anchor = this.#focusIn(target)?.closest(".rori-col")
679
+ anchor ? anchor.after(col) : this.#strip(target).append(col)
680
+ this.#windowsIn(col).forEach((other) => this.#open(other))
681
+ if (follow) this.focus(win)
682
+ else col.contains(this.focused) ? this.focus(this.focused) : this.#settle(win)
683
+ }
684
+
685
+ // niri's consume-or-expel: a window alone in its column joins the neighbouring
686
+ // column; a stacked window leaves into a new column on that side.
687
+ #consumeOrExpel(side, win = this.#focusIn(this.current)) {
688
+ if (!win) return
689
+ const col = win.closest(".rori-col")
690
+
691
+ if (this.#windowsIn(col).length > 1) {
692
+ const fresh = this.#createColumn(col.dataset.w)
693
+ side < 0 ? col.before(fresh) : col.after(fresh)
694
+ fresh.append(win)
695
+ } else {
696
+ const target = side < 0 ? col.previousElementSibling : col.nextElementSibling
697
+ if (!target) return
698
+ target.append(win)
699
+ col.remove()
700
+ }
701
+ this.#open(win)
702
+ this.#settle(win)
703
+ }
704
+
705
+ #settle(win) {
706
+ win === this.focused ? this.focus(win) : this.layout()
707
+ }
708
+
709
+ #cycleWidth(win = this.#focusIn(this.current)) {
710
+ const col = win?.closest(".rori-col")
711
+ if (!col) return
712
+ col.dataset.w = WIDTHS.find((width) => width > Number(col.dataset.w) + 0.01) || WIDTHS[0]
713
+ delete col.dataset.previous
714
+ this.layout()
715
+ }
716
+
717
+ #toggleFullWidth(win = this.#focusIn(this.current)) {
718
+ const col = win?.closest(".rori-col")
719
+ if (!col) return
720
+ if (Number(col.dataset.w) === 1) {
721
+ col.dataset.w = col.dataset.previous || SIZES.md
722
+ delete col.dataset.previous
723
+ } else {
724
+ col.dataset.previous = col.dataset.w
725
+ col.dataset.w = 1
726
+ }
727
+ this.layout()
728
+ }
729
+
730
+ #center(win = this.#focusIn(this.current)) {
731
+ const ws = this.current
732
+ const col = win?.closest(".rori-col")
733
+ if (!col || this.#wsOf(col) !== ws) return
734
+ const [left, right] = this.spans.get(ws).spans[this.#columns(ws).indexOf(col)]
735
+ this.offsets.set(ws, left - (this.viewportTarget.clientWidth - (right - left)) / 2)
736
+ this.#layoutStrip(ws, { reveal: false })
737
+ this.#renderMinimap()
738
+ }
739
+
740
+ #layoutStrip(ws, { reveal = true } = {}) {
741
+ const V = this.viewportTarget.clientWidth
742
+ const columns = this.#columns(ws)
743
+ let x = GAP
744
+ const spans = columns.map((col) => {
745
+ const width = Math.round(Number(col.dataset.w) * (V - GAP) - GAP)
746
+ col.style.width = `${width}px`
747
+ const span = [x, x + width]
748
+ x += width + GAP
749
+ return span
750
+ })
751
+
752
+ let offset = this.offsets.get(ws) || 0
753
+ const focused = columns.indexOf(this.#focusIn(ws)?.closest(".rori-col"))
754
+ if (reveal && !this.holdingReveal && focused >= 0) {
755
+ const [left, right] = spans[focused]
756
+ if (right + GAP > offset + V) offset = right + GAP - V
757
+ if (left - GAP < offset) offset = left - GAP
758
+ }
759
+ offset = clamp(offset, 0, Math.max(0, x - V))
760
+
761
+ this.offsets.set(ws, offset)
762
+ this.spans.set(ws, { spans, total: x })
763
+ this.#strip(ws).style.transform = this.overview ? "none" : `translateX(${-offset}px)`
764
+ }
765
+
766
+ #reloadSoon() {
767
+ clearTimeout(this.reloadTimer)
768
+ this.reloadTimer = setTimeout(() => {
769
+ const windows = this.pendingReloads.size ? [...this.pendingReloads] : this.windowTargets
770
+ this.pendingReloads.clear()
771
+ windows.filter((win) => win.isConnected && !this.#busy(win)).forEach((win) => this.#frame(win).reload())
772
+ }, 100)
773
+ }
774
+
775
+ #holdRevealUntilPointerUp() {
776
+ this.holdingReveal = true
777
+ const release = () => {
778
+ removeEventListener("pointerup", release)
779
+ removeEventListener("pointercancel", release)
780
+ this.holdingReveal = false
781
+ this.layout()
782
+ }
783
+ addEventListener("pointerup", release)
784
+ addEventListener("pointercancel", release)
785
+ }
786
+
787
+ // Don't morph a form out from under the user: modals, windows being typed in
788
+ // and windows with unsaved edits (even when you've moved on) are skipped.
789
+ #busy(win) {
790
+ return win.dataset.mode === "modal" ||
791
+ Boolean(win.querySelector("form[data-rori-dirty]")) ||
792
+ Boolean(win.contains(document.activeElement) && document.activeElement.closest("form"))
793
+ }
794
+
795
+ #pushHistory(win) {
796
+ const url = this.#src(win)
797
+ const stack = this.histories.get(win) || []
798
+ if (url && stack.at(-1) !== url) stack.push(url)
799
+ this.histories.set(win, stack)
800
+ this.#part(win, "back").disabled = stack.length < 2
801
+ }
802
+
803
+ #syncUrl(win) {
804
+ const url = (win && this.#src(win)) || this.rootUrlValue
805
+ if (url !== location.pathname + location.search) history.replaceState(history.state, "", url)
806
+ }
807
+
808
+ // Workspaces -----------------------------------------------------------------
809
+
810
+ #switchTo(ws) {
811
+ this.current = ws
812
+ this.focus(this.#focusIn(ws) || ws.querySelector(".rori-win"))
813
+ }
814
+
815
+ #hintedWorkspace(name) {
816
+ if (!name) return this.current
817
+ return name === "new" ? this.#newWorkspace() : this.#workspace(name) || this.#createWorkspace(name)
818
+ }
819
+
820
+ #createWorkspace(name) {
821
+ const ws = document.createElement("section")
822
+ ws.className = "rori-workspace"
823
+ ws.dataset.name = name
824
+ const strip = document.createElement("div")
825
+ strip.className = "rori-strip"
826
+ ws.append(strip)
827
+ this.stackTarget.append(ws)
828
+ return ws
829
+ }
830
+
831
+ #newWorkspace() {
832
+ const numbers = this.#workspaces().map((ws) => ws.dataset.name).filter((name) => /^\d+$/.test(name)).map(Number)
833
+ return this.#createWorkspace(String(Math.max(0, ...numbers) + 1))
834
+ }
835
+
836
+ // Empty workspaces disappear once you leave them.
837
+ #prune() {
838
+ this.#workspaces().forEach((ws) => {
839
+ if (ws !== this.current && !ws.querySelector(".rori-win")) ws.remove()
840
+ })
841
+ }
842
+
843
+ // Chrome ---------------------------------------------------------------------
844
+
845
+ #renderWorkspaces() {
846
+ this.workspacesTarget.replaceChildren(...this.#workspaces().map((ws, i) => {
847
+ const button = document.createElement("button")
848
+ button.type = "button"
849
+ button.className = "rori-workspace-button"
850
+ button.textContent = this.#workspaceLabel(ws, i)
851
+ if (ws === this.current) button.setAttribute("aria-current", "true")
852
+ button.addEventListener("click", () => {
853
+ this.overview = false
854
+ this.#switchTo(ws)
855
+ })
856
+ return button
857
+ }))
858
+ }
859
+
860
+ #renderMinimap() {
861
+ const ws = this.current
862
+ const V = this.viewportTarget.clientWidth
863
+ const offset = this.offsets.get(ws) || 0
864
+ const { spans = [] } = this.spans.get(ws) || {}
865
+
866
+ this.minimapTarget.replaceChildren(...this.#columns(ws).map((col, i) => {
867
+ const [left, right] = spans[i] || [0, 0]
868
+ const button = document.createElement("button")
869
+ button.type = "button"
870
+ button.className = "rori-minimap__col"
871
+ button.style.width = `${Number(col.dataset.w) * 72}px`
872
+ button.title = this.#windowsIn(col).map((win) => this.#title(win).textContent).join(" + ")
873
+ button.classList.toggle("is-visible", left >= offset - 1 && right <= offset + V + 1)
874
+ this.#windowsIn(col).forEach((win) => {
875
+ const cell = document.createElement("span")
876
+ cell.classList.toggle("is-focused", win === this.focused)
877
+ button.append(cell)
878
+ })
879
+ button.addEventListener("click", () => this.focus(this.#remembered(col)))
880
+ return button
881
+ }))
882
+ }
883
+
884
+ // Lookups --------------------------------------------------------------------
885
+
886
+ #workspaces() {
887
+ return [...this.stackTarget.children]
888
+ }
889
+
890
+ // Position, plus the name when a page gave the workspace one ("2 projects").
891
+ #workspaceLabel(ws, i = this.#workspaces().indexOf(ws)) {
892
+ const name = ws.dataset.name
893
+ return /^\d+$/.test(name) ? String(i + 1) : `${i + 1} ${name}`
894
+ }
895
+
896
+ #workspace(name) {
897
+ return this.#workspaces().find((ws) => ws.dataset.name === name)
898
+ }
899
+
900
+ #strip(ws) {
901
+ return ws.firstElementChild
902
+ }
903
+
904
+ #columns(ws) {
905
+ return [...this.#strip(ws).children]
906
+ }
907
+
908
+ #createColumn(width) {
909
+ const col = document.createElement("div")
910
+ col.className = "rori-col"
911
+ col.dataset.w = width
912
+ return col
913
+ }
914
+
915
+ #newColumnIn(ws, width) {
916
+ const col = this.#createColumn(width)
917
+ const anchor = this.#focusIn(ws)?.closest(".rori-col")
918
+ anchor ? anchor.after(col) : this.#strip(ws).append(col)
919
+ return col
920
+ }
921
+
922
+ #windowsIn(col) {
923
+ return col ? [...col.querySelectorAll(":scope > .rori-win")] : []
924
+ }
925
+
926
+ #wsOf(win) {
927
+ return win.closest(".rori-workspace")
928
+ }
929
+
930
+ #focusIn(ws) {
931
+ const win = this.lastFocus.get(ws)
932
+ return win?.isConnected && this.#wsOf(win) === ws ? win : null
933
+ }
934
+
935
+ #remembered(col) {
936
+ if (!col) return null
937
+ const win = this.lastInColumn.get(col)
938
+ return win?.parentElement === col ? win : col.querySelector(".rori-win")
939
+ }
940
+
941
+ #mostVisible(ws) {
942
+ const V = this.viewportTarget.clientWidth
943
+ const offset = this.offsets.get(ws) || 0
944
+ const { spans } = this.spans.get(ws)
945
+ let best = null
946
+ let bestSeen = 0
947
+ this.#columns(ws).forEach((col, i) => {
948
+ const [left, right] = spans[i]
949
+ const seen = Math.min(right, offset + V) - Math.max(left, offset)
950
+ if (seen > bestSeen) [best, bestSeen] = [col, seen]
951
+ })
952
+ return this.#remembered(best)
953
+ }
954
+
955
+ // Content under the pointer that scrolls sideways itself (a wide table) keeps
956
+ // the swipe. Only real scrollers count: clipped boxes like .rori-workspace are
957
+ // wider inside than out but don't scroll, and treating them as scrollers
958
+ // made swipes over the bare desk do nothing.
959
+ #scrollsSideways(target) {
960
+ for (let el = target; el && el !== this.viewportTarget; el = el.parentElement) {
961
+ if (el.scrollWidth > el.clientWidth && ["auto", "scroll"].includes(getComputedStyle(el).overflowX)) return true
962
+ }
963
+ return false
964
+ }
965
+
966
+ #win(event) {
967
+ return event.target.closest(".rori-win")
968
+ }
969
+
970
+ #frame(win) {
971
+ return win.querySelector(":scope > .rori-win__body > turbo-frame")
972
+ }
973
+
974
+ #part(win, name) {
975
+ return win.querySelector(`:scope > .rori-win__bar [data-window-part="${name}"]`)
976
+ }
977
+
978
+ #title(win) {
979
+ return this.#part(win, "title")
980
+ }
981
+
982
+ #src(win) {
983
+ const src = this.#frame(win)?.getAttribute("src")
984
+ if (!src) return null
985
+ const url = new URL(src, location.href)
986
+ return url.pathname + url.search
987
+ }
988
+
989
+ #load() {
990
+ try {
991
+ return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
992
+ } catch {
993
+ return {}
994
+ }
995
+ }
996
+ }