@ok-ggwp/git-gloss 0.4.0 → 0.5.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.
@@ -1,401 +0,0 @@
1
- // The ary shell: pick a branch, a base to compare it with, and a commit (or the
2
- // whole branch), and the chosen view loads in the frame. Text only goes
3
- // through textContent; nothing from the repo is parsed as HTML.
4
- (function () {
5
- 'use strict'
6
- var app = document.getElementById('app')
7
- var PAGE = 100
8
-
9
- // What is selected. head/base choose which commits are listed; rev is what
10
- // the frame shows (a sha, "wip", a range, or anything typed in).
11
- var state = { head: null, base: null, rev: null, q: '' }
12
- var refs = null
13
- var log = { commits: [], more: false, total: null }
14
- var logError = null
15
- var parts = {}
16
-
17
- function el(tag, cls, text) {
18
- var e = document.createElement(tag)
19
- if (cls) e.className = cls
20
- if (text !== undefined && text !== null) e.textContent = String(text)
21
- return e
22
- }
23
- function add(parent) {
24
- for (var i = 1; i < arguments.length; i++) if (arguments[i]) parent.appendChild(arguments[i])
25
- return parent
26
- }
27
- function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's') }
28
- function api(path) {
29
- return fetch(path).then(function (r) {
30
- return r.json().then(function (body) {
31
- if (!r.ok) throw new Error(body.error || r.statusText)
32
- return body
33
- })
34
- })
35
- }
36
-
37
- // ---- location: the selection is kept in the hash, so reload and links work ----
38
- function saveHash() {
39
- var p = new URLSearchParams()
40
- if (state.rev) p.set('rev', state.rev)
41
- if (state.head) p.set('head', state.head)
42
- if (state.base) p.set('base', state.base)
43
- history.replaceState(null, '', '#' + p.toString())
44
- }
45
- function readHash() {
46
- var p = new URLSearchParams(location.hash.slice(1))
47
- return { rev: p.get('rev'), head: p.get('head'), base: p.get('base') }
48
- }
49
-
50
- // ---- sidebar ----
51
- // A branch picker with a search box: a repo can have hundreds of branches,
52
- // too many to scan in a <select>. Matching is case-insensitive and every
53
- // space-separated word must appear in the name.
54
- var SHOWN = 200
55
- function picker(onPick) {
56
- var root = el('div', 'combo')
57
- var button = el('button', 'combo-btn')
58
- button.type = 'button'
59
- button.setAttribute('aria-haspopup', 'listbox')
60
- var panel = el('div', 'combo-panel')
61
- panel.hidden = true
62
- var input = el('input')
63
- input.type = 'search'
64
- input.spellcheck = false
65
- input.placeholder = 'Search branches…'
66
- input.setAttribute('aria-label', 'Search branches')
67
- var list = el('div', 'combo-list')
68
- list.setAttribute('role', 'listbox')
69
- add(panel, input, list)
70
- add(root, button, panel)
71
-
72
- var groups = []
73
- var noneLabel = null
74
- var value = null
75
- var items = [] // the rows now shown: { value, node }
76
- var active = 0
77
-
78
- function matches(name, words) {
79
- var n = name.toLowerCase()
80
- return words.every(function (w) { return n.indexOf(w) >= 0 })
81
- }
82
- function paint() {
83
- items.forEach(function (it, i) { it.node.classList.toggle('active', i === active) })
84
- if (items[active]) items[active].node.scrollIntoView({ block: 'nearest' })
85
- }
86
- function render() {
87
- var words = input.value.toLowerCase().split(/\s+/).filter(Boolean)
88
- list.textContent = ''
89
- items = []
90
- var shown = 0
91
- var hidden = 0
92
- function item(label, v) {
93
- var node = el('button', 'combo-item', label)
94
- node.type = 'button'
95
- node.setAttribute('role', 'option')
96
- node.setAttribute('aria-selected', String(v === value))
97
- node.addEventListener('click', function () { pick(v) })
98
- items.push({ value: v, node: node })
99
- return node
100
- }
101
- if (noneLabel && !words.length) list.appendChild(item(noneLabel, null))
102
- groups.forEach(function (g) {
103
- var found = g.names.filter(function (n) { return matches(n, words) })
104
- var room = found.slice(0, Math.max(0, SHOWN - shown))
105
- hidden += found.length - room.length
106
- shown += room.length
107
- if (!room.length) return
108
- add(list, add(el('div', 'combo-group', g.label), el('span', 'count', found.length)))
109
- room.forEach(function (n) { list.appendChild(item(n, n)) })
110
- })
111
- if (hidden) add(list, el('div', 'combo-more', hidden + ' more: keep typing to narrow the list'))
112
- if (!items.length) add(list, el('div', 'combo-more', 'No branches match.'))
113
- // Start on the current value, or on the first match when searching.
114
- var at = words.length ? -1 : items.findIndex(function (it) { return it.value === value })
115
- active = at >= 0 ? at : 0
116
- paint()
117
- }
118
- function open() {
119
- panel.hidden = false
120
- button.setAttribute('aria-expanded', 'true')
121
- input.value = ''
122
- render()
123
- input.focus()
124
- }
125
- function close() {
126
- panel.hidden = true
127
- button.setAttribute('aria-expanded', 'false')
128
- }
129
- function pick(v) {
130
- close()
131
- button.focus()
132
- if (v !== value) onPick(v)
133
- }
134
- button.addEventListener('click', function () { if (panel.hidden) open(); else close() })
135
- input.addEventListener('input', render)
136
- input.addEventListener('keydown', function (ev) {
137
- if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') {
138
- ev.preventDefault()
139
- if (items.length) active = (active + (ev.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length
140
- paint()
141
- } else if (ev.key === 'Enter') {
142
- ev.preventDefault()
143
- if (items[active]) pick(items[active].value)
144
- } else if (ev.key === 'Escape') {
145
- ev.preventDefault()
146
- close()
147
- button.focus()
148
- }
149
- })
150
- document.addEventListener('mousedown', function (ev) { if (!panel.hidden && !root.contains(ev.target)) close() })
151
-
152
- return {
153
- root: root,
154
- // Local and remote branches, and what choosing "no branch" is called (if allowed).
155
- setOptions: function (g, none) { groups = g; noneLabel = none },
156
- setValue: function (v) {
157
- value = v
158
- button.textContent = v || noneLabel || 'choose a branch'
159
- button.title = button.textContent
160
- },
161
- }
162
- }
163
-
164
- function branchGroups() {
165
- return [
166
- { label: 'Local branches', names: refs.branches.map(function (b) { return b.name }) },
167
- { label: 'Remote branches', names: refs.remotes.map(function (b) { return b.name }) },
168
- ]
169
- }
170
-
171
- // One entry in the list: the title gets the full width (two lines at most),
172
- // with the details and status marks on a line below it.
173
- function row(title, details, marks, current, onClick) {
174
- var b = el('button', 'row')
175
- b.type = 'button'
176
- b.setAttribute('aria-current', String(current))
177
- b.title = title
178
- add(b, el('div', 'subject', title))
179
- var meta = el('div', 'meta')
180
- details.forEach(function (d) { meta.appendChild(d) })
181
- add(meta, add(el('span', 'marks'), marks[0], marks[1], marks[2]))
182
- add(b, meta)
183
- b.addEventListener('click', onClick)
184
- return b
185
- }
186
- function mark(kind, text, tip) {
187
- var m = el('span', 'mark ' + kind, text)
188
- if (tip) m.title = tip
189
- return m
190
- }
191
- function glossMark(glossed) {
192
- return glossed ? mark('glossed', '● glossed', 'This commit has a gloss') : mark('none', '○ no gloss', 'This commit has no gloss')
193
- }
194
-
195
- function renderList() {
196
- var list = parts.list
197
- list.textContent = ''
198
- if (refs.wip) {
199
- add(list, el('h2', null, 'Uncommitted work'))
200
- var w = refs.wip
201
- add(list, row('wip snapshot', [el('span', null, w.stale || 'the latest snapshot of your working tree')],
202
- [glossMark(w.glossed), w.stale ? mark('warn', 'stale', w.stale) : null], state.rev === 'wip', function () { select('wip') }))
203
- }
204
- add(list, el('h2', null, state.head ? 'Commits on ' + state.head : 'Commits'))
205
- if (logError) { add(list, el('p', 'error', logError)); return }
206
- // A search narrows the list to some commits, so it isn't the whole branch.
207
- if (state.base && state.head && log.commits.length && !state.q) {
208
- var range = state.base + '..' + state.head
209
- add(list, row('Whole branch', [el('span', null, range + ' · ' + plural(log.total === null ? log.commits.length : log.total, 'commit'))], [], state.rev === range, function () { select(range) }))
210
- }
211
- if (!log.commits.length) {
212
- add(list, el('p', 'empty', state.q ? 'No commits match “' + state.q + '”.' : state.base ? 'No commits beyond ' + state.base + '.' : 'No commits.'))
213
- }
214
- log.commits.forEach(function (c) {
215
- add(list, row(c.subject,
216
- [el('span', 'sha', c.sha.slice(0, 7)), el('span', null, ' · ' + c.author + ' · ' + new Date(c.date).toLocaleDateString())],
217
- c.merge ? [mark('none', 'merge')] : [glossMark(c.glossed), c.reviewed ? mark('reviewed', '✓ reviewed', 'This commit has a recorded review') : null],
218
- state.rev === c.sha, function () { select(c.sha) }))
219
- })
220
- if (log.more) {
221
- var more = el('button', 'btn more', 'Load more')
222
- more.type = 'button'
223
- more.addEventListener('click', function () { loadLog(true) })
224
- add(list, more)
225
- }
226
- }
227
-
228
- function renderSide() {
229
- parts.head.setOptions(branchGroups(), null)
230
- parts.head.setValue(state.head)
231
- parts.base.setOptions(branchGroups(), '(none: all of the branch’s history)')
232
- parts.base.setValue(state.base)
233
- parts.rev.value = state.rev && state.rev !== 'wip' ? state.rev : ''
234
- renderList()
235
- }
236
-
237
- // ---- viewer ----
238
- function renderViewer() {
239
- var v = parts.viewer
240
- v.textContent = ''
241
- if (!state.rev) {
242
- add(v, el('div', 'placeholder', 'Pick a commit, a whole branch, or type any revision or range.'))
243
- return
244
- }
245
- var src = '/view?rev=' + encodeURIComponent(state.rev)
246
- var open = el('a', null, 'open in a tab ↗')
247
- open.href = src
248
- open.target = '_blank'
249
- open.rel = 'noopener'
250
- var reload = el('button', 'btn', 'Reload')
251
- reload.type = 'button'
252
- var frame = el('iframe')
253
- frame.title = 'gloss of ' + state.rev
254
- frame.src = src
255
- // So the page's keys (j, k, o, e, c) work without clicking into it first.
256
- frame.addEventListener('load', function () { frame.contentWindow.focus() })
257
- reload.addEventListener('click', function () { frame.src = src })
258
- add(v, add(el('div', 'viewer-bar'), el('span', 'what', state.rev === 'wip' ? 'uncommitted work (wip)' : state.rev), reload, open), frame)
259
- }
260
-
261
- // ---- actions ----
262
- function select(rev) {
263
- state.rev = rev
264
- saveHash()
265
- renderList()
266
- parts.rev.value = rev === 'wip' ? '' : rev
267
- renderViewer()
268
- }
269
-
270
- function loadLog(append) {
271
- if (!state.head) { log = { commits: [], more: false, total: null }; renderList(); return Promise.resolve() }
272
- var q = new URLSearchParams({ head: state.head, limit: PAGE, skip: append ? log.commits.length : 0 })
273
- if (state.base) q.set('base', state.base)
274
- if (state.q) q.set('q', state.q)
275
- var asked = state.head + '..' + state.base + '?' + state.q
276
- return api('/api/log?' + q).then(function (data) {
277
- if (asked !== state.head + '..' + state.base + '?' + state.q) return // the selection moved on
278
- logError = null
279
- log = append ? { commits: log.commits.concat(data.commits), more: data.more, total: data.total } : data
280
- renderList()
281
- }).catch(function (err) {
282
- logError = err.message
283
- log = { commits: [], more: false, total: null }
284
- renderList()
285
- })
286
- }
287
-
288
- function pickHead(name) {
289
- state.head = name
290
- // A feature branch is most useful against the usual base; the base itself
291
- // is listed whole.
292
- state.base = name === refs.defaultBase ? null : refs.defaultBase
293
- saveHash()
294
- renderSide()
295
- loadLog(false)
296
- }
297
-
298
- function load() {
299
- return api('/api/refs').then(function (data) {
300
- parts.error.hidden = true
301
- refs = data
302
- var h = readHash()
303
- // A link or bookmark may name a branch that has since been deleted or
304
- // renamed: use the usual choice instead, and say so.
305
- var known = {}
306
- refs.branches.concat(refs.remotes).forEach(function (b) { known[b.name] = true })
307
- var head = h.head && known[h.head] ? h.head : null
308
- state.head = head || refs.current || refs.defaultBase || (refs.branches[0] && refs.branches[0].name) || null
309
- var notice = null
310
- if (h.head && !head) {
311
- // The default base goes with the default branch.
312
- state.base = state.head === refs.defaultBase ? null : refs.defaultBase
313
- notice = 'Branch “' + h.head + '” no longer exists; showing ' + (state.head || 'the default') + ' instead.'
314
- } else if (h.base && !known[h.base]) {
315
- state.base = null
316
- notice = 'The branch to compare with, “' + h.base + '”, no longer exists; showing all of ' + state.head + '’s history.'
317
- } else if (head) {
318
- state.base = h.base || null
319
- } else {
320
- state.base = state.head === refs.defaultBase ? null : refs.defaultBase
321
- }
322
- if (notice) {
323
- parts.error.textContent = notice
324
- parts.error.hidden = false
325
- }
326
- state.rev = h.rev || (refs.wip ? 'wip' : null)
327
- parts.title.textContent = refs.repo
328
- renderSide()
329
- renderViewer()
330
- return loadLog(false).then(function () {
331
- // With nothing chosen, show the whole branch, or its newest commit.
332
- if (!state.rev && state.head) select(state.base && log.commits.length ? state.base + '..' + state.head : log.commits.length ? log.commits[0].sha : null)
333
- })
334
- })
335
- }
336
-
337
- function build() {
338
- parts.title = el('span', 'repo')
339
- var refresh = el('button', 'btn', 'Refresh')
340
- refresh.type = 'button'
341
- refresh.title = 'Re-read branches and commits'
342
- refresh.addEventListener('click', function () { load().catch(fail) })
343
-
344
- parts.head = picker(pickHead)
345
- parts.base = picker(function (name) {
346
- state.base = name
347
- parts.base.setValue(name)
348
- saveHash()
349
- loadLog(false)
350
- })
351
-
352
- parts.rev = el('input')
353
- parts.rev.type = 'text'
354
- parts.rev.spellcheck = false
355
- parts.rev.placeholder = 'HEAD~3, abc123, main..feature'
356
- var go = el('button', 'btn', 'View')
357
- go.type = 'submit'
358
- var form = add(el('form', 'rev-row'), parts.rev, go)
359
- form.addEventListener('submit', function (ev) {
360
- ev.preventDefault()
361
- var v = parts.rev.value.trim()
362
- if (v) select(v)
363
- })
364
-
365
- parts.search = el('input')
366
- parts.search.type = 'search'
367
- parts.search.spellcheck = false
368
- parts.search.placeholder = 'Search commit messages and authors…'
369
- parts.search.setAttribute('aria-label', 'Search commits')
370
- var searching = null
371
- parts.search.addEventListener('input', function () {
372
- // Wait for a pause in typing: each search asks git.
373
- clearTimeout(searching)
374
- searching = setTimeout(function () {
375
- state.q = parts.search.value.trim()
376
- loadLog(false)
377
- }, 250)
378
- })
379
-
380
- parts.error = el('p', 'error')
381
- parts.error.hidden = true
382
- parts.list = el('div', 'list')
383
- parts.viewer = el('section', 'viewer')
384
-
385
- var top = add(el('div', 'side-top'),
386
- add(el('div', 'title'), el('strong', null, 'git gloss ary'), parts.title, refresh),
387
- parts.error,
388
- add(el('div', 'field'), el('span', null, 'Branch'), parts.head.root),
389
- add(el('div', 'field'), el('span', null, 'Compare with'), parts.base.root),
390
- add(el('label', 'field'), document.createTextNode('Any revision or range'), form))
391
- add(app, add(el('div', 'shell'), add(el('aside', 'side'), top, add(el('div', 'search-bar'), parts.search), parts.list), parts.viewer))
392
- }
393
-
394
- function fail(err) {
395
- parts.error.textContent = 'Could not read the repository: ' + err.message
396
- parts.error.hidden = false
397
- }
398
-
399
- build()
400
- load().catch(fail)
401
- })()
@@ -1,13 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>git gloss ary</title>
7
- <link rel="stylesheet" href="/ary.css">
8
- </head>
9
- <body>
10
- <div id="app"><noscript>This page needs JavaScript.</noscript></div>
11
- <script src="/ary.js"></script>
12
- </body>
13
- </html>
@@ -1,115 +0,0 @@
1
- :root {
2
- --bg: #f7f7f5;
3
- --surface: #ffffff;
4
- --surface-2: #f1f1ee;
5
- --border: #e2e2dc;
6
- --text: #1d1d1b;
7
- --muted: #6b6b66;
8
- --accent: #3b5bdb;
9
- --ok: #1a7f37;
10
- --warn-bg: #fff4e5;
11
- --warn-border: #f0b35b;
12
- --warn-text: #7a4300;
13
- --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
14
- --sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
15
- color-scheme: light;
16
- }
17
- @media (prefers-color-scheme: dark) {
18
- :root {
19
- --bg: #151514;
20
- --surface: #1e1e1c;
21
- --surface-2: #262624;
22
- --border: #34342f;
23
- --text: #e8e8e3;
24
- --muted: #9a9a92;
25
- --accent: #8ea2ff;
26
- --ok: #57c47a;
27
- --warn-bg: #33270f;
28
- --warn-border: #a2701e;
29
- --warn-text: #f3c77a;
30
- color-scheme: dark;
31
- }
32
- }
33
-
34
- * { box-sizing: border-box; }
35
- html, body, #app { height: 100%; }
36
- body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 var(--sans); }
37
- button, input, select { font: inherit; color: inherit; }
38
-
39
- .shell { display: grid; grid-template-columns: 340px minmax(0, 1fr); height: 100%; }
40
- @media (max-width: 800px) {
41
- .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 45%) minmax(0, 1fr); }
42
- }
43
-
44
- /* Sidebar */
45
- .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--border); background: var(--surface); }
46
- .side-top { padding: 12px; border-bottom: 1px solid var(--border); display: grid; gap: 10px; }
47
- .title { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
48
- .title strong { font-size: 15px; }
49
- .title .repo { color: var(--muted); font-family: var(--mono); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
- label.field, div.field { display: grid; gap: 3px; color: var(--muted); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; }
51
- label.field > input, .rev-row input, .combo-btn, .combo-panel input {
52
- width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px;
53
- background: var(--bg); text-transform: none; letter-spacing: 0; font-size: 13px;
54
- }
55
- .combo { position: relative; }
56
- .combo-btn { text-align: left; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding-right: 24px; position: relative; }
57
- .combo-btn::after { content: "▾"; position: absolute; right: 8px; color: var(--muted); }
58
- .combo-panel {
59
- position: absolute; z-index: 10; left: 0; right: 0; top: calc(100% + 4px);
60
- display: grid; gap: 6px; padding: 6px; border: 1px solid var(--border); border-radius: 8px;
61
- background: var(--surface); box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
62
- }
63
- .combo-panel[hidden] { display: none; }
64
- .combo-list { max-height: min(50vh, 320px); overflow: auto; }
65
- .combo-group {
66
- margin-top: 8px; padding: 10px 8px 4px; border-top: 1px solid var(--border);
67
- font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text);
68
- }
69
- .combo-group:first-child { margin-top: 0; padding-top: 4px; border-top: 0; }
70
- .combo-group .count { margin-left: 6px; color: var(--muted); font-weight: 400; }
71
- .combo-item { display: block; width: 100%; text-align: left; border: 0; background: none; border-radius: 5px; padding: 4px 8px 4px 20px; cursor: pointer; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
- .combo-item:hover, .combo-item.active { background: var(--surface-2); }
73
- .combo-item[aria-selected="true"] { color: var(--accent); font-weight: 600; }
74
- .combo-more { padding: 6px 8px; color: var(--muted); font-size: 12.5px; }
75
- .rev-row { display: flex; gap: 6px; }
76
- .rev-row input { font-family: var(--mono); font-size: 12.5px; }
77
- .btn {
78
- border: 1px solid var(--border); background: var(--surface); border-radius: 6px;
79
- padding: 4px 10px; cursor: pointer; white-space: nowrap;
80
- }
81
- .btn:hover { background: var(--surface-2); }
82
- .error { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text); font-size: 12.5px; }
83
-
84
- .list { flex: 1; min-height: 0; overflow: auto; padding: 6px; }
85
- .list h2 { margin: 10px 8px 4px; font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 600; }
86
- .row {
87
- display: block; width: 100%; text-align: left; cursor: pointer;
88
- border: 1px solid transparent; background: none; border-radius: 6px; padding: 6px 8px;
89
- }
90
- .row:hover { background: var(--surface-2); }
91
- .row[aria-current="true"] { background: var(--surface-2); border-color: var(--accent); }
92
- .row .subject {
93
- /* the whole point of the list: enough of the subject to tell commits apart */
94
- display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
95
- overflow: hidden; overflow-wrap: anywhere;
96
- }
97
- .row .meta { display: flex; flex-wrap: wrap; align-items: baseline; margin-top: 2px; color: var(--muted); font-size: 12px; }
98
- .row .marks { margin-left: auto; padding-left: 10px; display: inline-flex; gap: 10px; white-space: nowrap; }
99
- .sha { font-family: var(--mono); font-size: 12px; }
100
- .mark.glossed { color: var(--ok); }
101
- .mark.reviewed { color: var(--accent); }
102
- .mark.warn { color: var(--warn-text); background: var(--warn-bg); border-radius: 4px; padding: 0 5px; }
103
- .search-bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
104
- .search-bar input { width: 100%; padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); font-size: 13px; }
105
- .empty { color: var(--muted); padding: 8px; }
106
- .more { margin: 6px 8px; }
107
-
108
- /* Viewer */
109
- .viewer { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
110
- .viewer-bar { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--surface); }
111
- .viewer-bar .what { flex: 1; min-width: 0; font-family: var(--mono); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
- .viewer-bar a { color: var(--accent); text-decoration: none; }
113
- .viewer-bar a:hover { text-decoration: underline; }
114
- iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: var(--bg); }
115
- .placeholder { flex: 1; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; }