k0-board 0.3.0 → 0.4.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.
package/server/index.js CHANGED
@@ -27,7 +27,7 @@ import * as files from './files.js'
27
27
  import * as pdf from './pdf.js'
28
28
  import * as machine from './machine.js'
29
29
  import * as servers from './servers.js'
30
- import { report as platformReport } from '../platform/index.js'
30
+ import { report as platformReport, capabilities, why } from '../platform/index.js'
31
31
  // The same front-matter reader the viewer uses: the PDF has to be called what the page is
32
32
  // called, and two different readings of the same header would give two different names.
33
33
  import { matter, titleOf } from '../web/md.js'
@@ -341,6 +341,11 @@ function site(req, res, seg) {
341
341
  // is a much smaller loss than that, so the header is gone.
342
342
  const SANDBOX = 'sandbox'
343
343
 
344
+ // How much a save is allowed to carry. Well under the megabyte the body reader stops at, which
345
+ // would otherwise refuse a large file with a message about requests instead of about the file —
346
+ // and far above anything you would actually edit in a browser tab: k0's own README is 56 KB.
347
+ const MAX_SAVE = 1 << 18
348
+
344
349
  // ── The recap of a window ────────────────────────────────────────────────────
345
350
  // Held for a minute, and not as a saved result: the page asks for the facts, then asks for the
346
351
  // write-up to start, then asks every second whether it is done. Reading every repository's git
@@ -540,8 +545,11 @@ async function api(req, res, url) {
540
545
  const card = Number.isInteger(cardId) && cardId > 0 ? db.getCard(cardId) : null
541
546
  // Searching inside the files: the page asks for it as you type, and it needs neither the
542
547
  // listing nor git's state.
548
+ // `nerd` is the switch in the page: with it on, the configuration files are listed, so the
549
+ // search inside the text has to look in them too.
550
+ const nerd = url.searchParams.get('nerd') === '1'
543
551
  if (url.searchParams.get('only') === 'text') {
544
- return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '') })
552
+ return send(res, 200, { hits: await files.grep(root, url.searchParams.get('q') || '', nerd) })
545
553
  }
546
554
  const changed = await files.changed(root, card?.head_at_start)
547
555
  if (url.searchParams.get('only') === 'changed') return send(res, 200, { changed, now: Date.now() })
@@ -554,6 +562,13 @@ async function api(req, res, url) {
554
562
  truncated: all.truncated,
555
563
  changed,
556
564
  files: all.files,
565
+ // What this machine can do about a file, so the viewer greys the buttons out and says why
566
+ // rather than offering something that would quietly do nothing.
567
+ can: {
568
+ reveal: capabilities.revealInFileManager,
569
+ open: capabilities.openInFileManager,
570
+ whyNot: why('revealInFileManager') || why('openInFileManager'),
571
+ },
557
572
  now: Date.now(),
558
573
  })
559
574
  }
@@ -609,10 +624,27 @@ async function api(req, res, url) {
609
624
  return res.end(out)
610
625
  }
611
626
 
627
+ // One button, two neighbouring things: a file is pointed at inside its folder, a folder is
628
+ // opened. Which of the two is decided here rather than in the page, because the page would
629
+ // have to guess and the disk already knows.
612
630
  if (idRaw === 'reveal' && req.method === 'POST') {
613
- await files.reveal(abs)
631
+ if (fs.statSync(abs).isDirectory()) await files.openFolder(abs)
632
+ else await files.reveal(abs)
614
633
  return send(res, 200, { ok: true })
615
634
  }
635
+
636
+ // The first thing k0 writes into one of your repositories. Only configuration and notes, only
637
+ // a file that is already there, and only if nobody has touched it since the page read it.
638
+ if (idRaw === 'save' && req.method === 'POST') {
639
+ if (!files.isEditable(rel)) return send(res, 400, { error: 'This kind of file is not edited here' })
640
+ if (typeof body.text !== 'string') return send(res, 400, { error: 'Nothing to save' })
641
+ if (Buffer.byteLength(body.text) > MAX_SAVE) return send(res, 400, { error: 'That is too big to save from here' })
642
+ try {
643
+ return send(res, 200, files.write(abs, body.text, Number(body.mtime)))
644
+ } catch (e) {
645
+ return send(res, e.conflict ? 409 : 500, { error: e.message })
646
+ }
647
+ }
616
648
  if (!idRaw && req.method === 'GET') return send(res, 200, { path: rel, ...files.read(abs) })
617
649
  }
618
650
 
package/web/board.css CHANGED
@@ -387,10 +387,18 @@ body {
387
387
  gap: 7px;
388
388
  margin-bottom: 3px;
389
389
  }
390
+ /* The repository's name takes the room that is left, so everything after it — the git mark, the
391
+ memory chip — sits against the right edge of the note. The same shape as the heading of a
392
+ column, and for the same reason: hanging off the end of the name those marks landed in a
393
+ different place on every note, and a row of notes had no line to read down. */
390
394
  .postit .tag {
395
+ flex: 1;
391
396
  font: 500 1.05rem/1 var(--hand);
392
397
  color: rgba(0, 0, 0, .5);
393
398
  min-width: 0;
399
+ overflow: hidden;
400
+ text-overflow: ellipsis;
401
+ white-space: nowrap;
394
402
  }
395
403
  /* The name is the session's: it is in monospace like the k0 mark at the top.
396
404
  Monospace is heavier at the same size, which is why it drops to .94rem. */
package/web/conf.js ADDED
@@ -0,0 +1,118 @@
1
+ // ── Configuration files, laid out ─────────────────────────────────────────────
2
+ // An `.env` is a table pretending to be a text file: two columns, one per line, and the reason
3
+ // you opened it is always to compare a name with a value. Shown as text, the values start in a
4
+ // different place on every line and the eye has to walk each one to the `=`. Shown as a table,
5
+ // you read the column.
6
+ //
7
+ // The rest — YAML, TOML, INI — is not a table: nesting matters, and a table would flatten the one
8
+ // thing that carries the meaning. Those keep their shape and only get their keys, their values
9
+ // and their comments told apart by colour.
10
+ //
11
+ // Like the tree next door, this knows nothing about the page: text in, a string of HTML out.
12
+
13
+ /** Written again here for the same reason as in `json.js`: the tests run in Node. */
14
+ const esc = (s) =>
15
+ String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
16
+
17
+ /**
18
+ * An `.env` read line by line, keeping everything — the comments and the blank lines included.
19
+ *
20
+ * They are kept because they are the file: a comment above a key says what the key is for, and a
21
+ * view that dropped it would be a different file that happens to have the same values. Anything
22
+ * this does not understand comes back as it was written rather than being guessed at.
23
+ *
24
+ * Returns rows of `{ kind: 'pair' | 'comment' | 'blank' | 'raw' }`.
25
+ */
26
+ export function envRows(text) {
27
+ const rows = []
28
+ for (const raw of String(text ?? '').split('\n')) {
29
+ const line = raw.replace(/\r$/, '')
30
+ const trimmed = line.trim()
31
+ if (!trimmed) {
32
+ rows.push({ kind: 'blank' })
33
+ continue
34
+ }
35
+ if (trimmed.startsWith('#')) {
36
+ rows.push({ kind: 'comment', text: trimmed.replace(/^#\s?/, '') })
37
+ continue
38
+ }
39
+ // `export FOO=bar` is the same line with a word in front of it: shell files and `.env` files
40
+ // are copied back and forth, and both spellings turn up in the same repository.
41
+ const body = trimmed.replace(/^export\s+/, '')
42
+ const at = body.indexOf('=')
43
+ if (at <= 0) {
44
+ rows.push({ kind: 'raw', text: line })
45
+ continue
46
+ }
47
+ const key = body.slice(0, at).trim()
48
+ rows.push({ kind: 'pair', key, value: unquote(body.slice(at + 1).trim()) })
49
+ }
50
+ // A file ends with a newline, and that last empty line is not a blank line in the file: it is
51
+ // the end of it. Showing it would put a hole at the bottom of every table.
52
+ if (rows.length && rows[rows.length - 1].kind === 'blank') rows.pop()
53
+ return rows
54
+ }
55
+
56
+ /**
57
+ * The value without the quotes around it, and without the comment after it.
58
+ *
59
+ * The comment is only cut off an unquoted value: inside quotes a `#` is a character like any
60
+ * other, and a password with a hash in it must not lose half of itself on the way to the screen.
61
+ */
62
+ function unquote(value) {
63
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(value)
64
+ if (quoted) return quoted[2]
65
+ const hash = value.search(/\s#/)
66
+ return hash === -1 ? value : value.slice(0, hash).trimEnd()
67
+ }
68
+
69
+ /** The two columns. An empty value is said in words, or the row would look like a mistake. */
70
+ export function envTable(text) {
71
+ const body = envRows(text)
72
+ .map((r) => {
73
+ if (r.kind === 'pair') {
74
+ const value = r.value
75
+ ? `<td class="val">${esc(r.value)}</td>`
76
+ : '<td class="val none">empty</td>'
77
+ return `<tr><th>${esc(r.key)}</th>${value}</tr>`
78
+ }
79
+ if (r.kind === 'comment') return `<tr class="note"><td colspan="2">${esc(r.text)}</td></tr>`
80
+ if (r.kind === 'raw') return `<tr class="odd"><td colspan="2">${esc(r.text)}</td></tr>`
81
+ return '<tr class="gap"><td colspan="2"></td></tr>'
82
+ })
83
+ .join('')
84
+ return `<table class="env">${body}</table>`
85
+ }
86
+
87
+ /**
88
+ * YAML, TOML and INI: the same file, with the parts told apart.
89
+ *
90
+ * No parsing. A YAML parser written here would be a second thing to keep correct, and every one
91
+ * of its mistakes would show you a file that is not the file. Reading a line at a time never
92
+ * shows you anything that is not there — at worst it colours something in as though it were
93
+ * ordinary text, which is what it does with everything it does not recognise.
94
+ */
95
+ export function highlight(text) {
96
+ const out = String(text ?? '')
97
+ .split('\n')
98
+ .map((raw) => {
99
+ const line = raw.replace(/\r$/, '')
100
+ const lead = line.length - line.trimStart().length
101
+ const indent = esc(line.slice(0, lead))
102
+ const rest = line.slice(lead)
103
+ if (!rest) return ''
104
+ if (rest.startsWith('#') || rest.startsWith(';')) return `${indent}<span class="c">${esc(rest)}</span>`
105
+ // A TOML or INI heading: the one line that says where you are in the file.
106
+ if (/^\[.*]$/.test(rest)) return `${indent}<span class="sec">${esc(rest)}</span>`
107
+ // `- ` is a YAML list, and what follows it may itself be a pair.
108
+ const dash = /^-\s+/.exec(rest)
109
+ const item = dash ? rest.slice(dash[0].length) : rest
110
+ const bullet = dash ? `<span class="dash">${esc(dash[0])}</span>` : ''
111
+ const pair = /^([^:=#]+?)\s*([:=])\s*([\s\S]*)$/.exec(item)
112
+ if (!pair) return `${indent}${bullet}<span class="v">${esc(item)}</span>`
113
+ const value = pair[3] ? `<span class="v">${esc(pair[3])}</span>` : ''
114
+ return `${indent}${bullet}<span class="key">${esc(pair[1])}</span><span class="sep">${pair[2]}</span> ${value}`
115
+ })
116
+ .join('\n')
117
+ return `<pre class="conf">${out}</pre>`
118
+ }
package/web/files.css CHANGED
@@ -74,6 +74,28 @@ body {
74
74
  width: 100%;
75
75
  color: var(--ink);
76
76
  }
77
+ /* The switch that lets the configuration through. It was a bare glyph inside the search pill and
78
+ nobody found it: at that size, in that place, it read as part of the field rather than as
79
+ something you could press. So it is a button of its own, next to the search, outlined like the
80
+ ones over a document, and it says what it does in a word. Switched on it fills in, so from
81
+ across the room you can tell whether you are looking at everything or not. */
82
+ .topbar .nerd {
83
+ flex: none;
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 5px;
87
+ padding: 5px 11px;
88
+ border: 1px solid var(--line);
89
+ background: #fff;
90
+ border-radius: 999px;
91
+ font: inherit;
92
+ font-size: .74rem;
93
+ color: var(--muted);
94
+ cursor: pointer;
95
+ }
96
+ .topbar .nerd svg { width: 14px; height: 14px; display: block; flex: none; }
97
+ .topbar .nerd:hover { color: var(--ink); border-color: #bdb6a5; background: #fff; }
98
+ .topbar .nerd.on, .topbar .nerd.on:hover { background: var(--ink); border-color: var(--ink); color: #fff; }
77
99
 
78
100
  /* ── The two panes ───────────────────────────────────────────────────── */
79
101
  /* The divider is a column of the grid: drag it and the size stays. */
@@ -160,6 +182,16 @@ body {
160
182
  overflow: hidden;
161
183
  }
162
184
  .row q mark { background: #fde68a; color: var(--ink); border-radius: 3px; padding: 0 1px; }
185
+ /* The letters that made this row match, in its name. Underlined and not highlighted, so the two
186
+ answers stay apart at a glance: yellow means the word was found inside the file, a line under
187
+ the letters means they are in its name. */
188
+ .row b u {
189
+ color: var(--ink);
190
+ text-decoration: underline;
191
+ text-decoration-thickness: 2px;
192
+ text-underline-offset: 2px;
193
+ text-decoration-color: var(--alert);
194
+ }
163
195
  .row:hover { background: rgba(0, 0, 0, .035); }
164
196
  .row.on { background: var(--WORKING); }
165
197
  .row.active { box-shadow: inset 3px 0 0 var(--ink); }
@@ -215,6 +247,26 @@ body {
215
247
  .from-text .x:hover { opacity: 1; text-decoration: none; }
216
248
  .from-text .x svg { width: 15px; height: 15px; display: block; }
217
249
 
250
+ /* The line that says the switch is hiding what you asked for. It sits above everything, in the
251
+ same green as the other things that say "the listing is not showing you all of it", and it is a
252
+ button: the answer to "where is my .env" should be one click, not an instruction. */
253
+ .nudge {
254
+ display: block;
255
+ width: 100%;
256
+ border: 0;
257
+ border-bottom: 1px solid rgba(0, 0, 0, .08);
258
+ border-radius: 0;
259
+ background: var(--IDLE);
260
+ padding: 9px 16px;
261
+ font: inherit;
262
+ font-size: .78rem;
263
+ font-weight: 600;
264
+ color: var(--ink);
265
+ text-align: left;
266
+ cursor: pointer;
267
+ }
268
+ .nudge:hover { background: var(--IDLE); text-decoration: underline; }
269
+
218
270
  /* The heading of a group that opens and closes: the guesses arrive closed. */
219
271
  .list h2.fold { cursor: pointer; user-select: none; }
220
272
  .list h2.fold::after {
@@ -239,6 +291,45 @@ body {
239
291
  .row.fold b::after { content: ' ▸'; opacity: .6; font-weight: 400; }
240
292
  .row.fold.open b::after { content: ' ▾'; }
241
293
 
294
+ /* ── Folders ──────────────────────────────────────────────────────────
295
+ A folder is a row like any other, and deliberately so: the arrow keys walk it, ⌘-click opens
296
+ it in another tab, and nothing about it has to be learnt separately. The only difference is the
297
+ mark in front of the name and the slash after it, which is how a folder has always been told
298
+ apart from a file. */
299
+ .row.folder b::before {
300
+ content: '';
301
+ display: inline-block;
302
+ width: 12px;
303
+ height: 9px;
304
+ margin-right: 7px;
305
+ border: 1px solid var(--muted);
306
+ border-radius: 1px 2px 2px 2px;
307
+ border-top-width: 3px;
308
+ vertical-align: middle;
309
+ opacity: .75;
310
+ }
311
+ .row.folder b::after { content: '/'; opacity: .45; }
312
+
313
+ /* Where you are, one piece of the path at a time. It sits in the same green strip as the label of
314
+ a pasted text: both say the listing is a slice, and they must look like the same statement. */
315
+ .from-text.trail { gap: 3px; overflow-x: auto; scrollbar-width: none; }
316
+ .from-text.trail::-webkit-scrollbar { display: none; }
317
+ .from-text a, .from-text b {
318
+ font: inherit;
319
+ font-size: .78rem;
320
+ font-weight: 600;
321
+ color: var(--ink);
322
+ text-decoration: none;
323
+ white-space: nowrap;
324
+ cursor: pointer;
325
+ }
326
+ .from-text a:hover { text-decoration: underline; }
327
+ /* The last piece is where you are: it does not lead anywhere, and it does not pretend to. */
328
+ .from-text b { cursor: default; opacity: .95; }
329
+ .from-text i { font-style: normal; font-size: .72rem; opacity: .4; }
330
+ .from-text .act { margin-left: auto; padding: 2px 7px; background: rgba(255, 255, 255, .7); }
331
+ .from-text .act + .x { margin-left: 4px; }
332
+
242
333
  /* "7 more": it sits under the row it belongs to, off to one side. */
243
334
  .more {
244
335
  display: block;
@@ -328,6 +419,10 @@ body {
328
419
  .crumb b { font: 600 .82rem var(--mono); color: var(--ink); }
329
420
  .crumb i { font-style: normal; opacity: .5; }
330
421
  .crumb em { margin-left: auto; font-style: normal; }
422
+ /* The path above a document is the way to its neighbours: each piece opens the folder it names,
423
+ in the list on the left. It stays grey — it is still the path, not a row of buttons. */
424
+ .crumb a.up { color: inherit; text-decoration: none; border-bottom: 1px dotted transparent; }
425
+ .crumb a.up:hover { color: var(--ink); border-bottom-color: var(--muted); }
331
426
 
332
427
  /* ── What to do with it ───────────────────────────────────────────────
333
428
  Print the document, take it away as it is, or have it laid out into a PDF. They sit here at
@@ -350,8 +445,115 @@ body {
350
445
  }
351
446
  .act:hover { color: var(--ink); border-color: #bdb6a5; }
352
447
  .act svg { width: 14px; height: 14px; display: block; flex: none; }
448
+ /* A button this machine cannot honour stays where it is, faint, with the reason in its title. One
449
+ that vanished would leave you looking for it; one that did nothing would leave you wondering. */
450
+ .act[disabled] { opacity: .3; cursor: default; }
451
+ .act[disabled]:hover { color: var(--muted); border-color: var(--line); }
452
+ /* Save is the one that changes something, so it is the one that is filled in. */
453
+ .act.keep { background: var(--ink); border-color: var(--ink); color: #fff; font-weight: 600; }
454
+ .act.keep:hover { color: #fff; border-color: var(--ink); }
353
455
  .cut { padding: 12px 32px; color: var(--muted); font-size: .82rem; font-style: italic; }
354
456
 
457
+ /* ── Searching inside one file ────────────────────────────────────────
458
+ Only where the browser's own find cannot go: a tree is mostly folded, and ⌘F says "not found"
459
+ for something that is three lines away behind a closed branch. */
460
+ .findin { display: flex; align-items: center; gap: 6px; margin-left: 12px; }
461
+ .findin input {
462
+ border: 1px solid var(--line);
463
+ background: #fff;
464
+ border-radius: 999px;
465
+ padding: 4px 11px;
466
+ font: inherit;
467
+ font-size: .72rem;
468
+ color: var(--ink);
469
+ outline: 0;
470
+ width: 170px;
471
+ }
472
+ .findin input:focus { border-color: #bdb6a5; }
473
+ .findin em {
474
+ font-style: normal;
475
+ font-size: .72rem;
476
+ color: var(--muted);
477
+ font-variant-numeric: tabular-nums;
478
+ min-width: 1.4em;
479
+ }
480
+
481
+ /* ── A JSON file, as a tree ───────────────────────────────────────────
482
+ The folding is `<details>`: the browser already knows how to do it, so there is no state here
483
+ and it works before a single line of script has run. The colours are the types — you learn them
484
+ in one glance and then never read them again, you just see them. */
485
+ .tree { padding: 18px 32px; font: .8rem/1.55 var(--mono); }
486
+ .json .kids { margin-left: 1.05rem; padding-left: .7rem; border-left: 1px solid var(--line); }
487
+ .json summary { cursor: pointer; list-style: none; padding: 1px 0; border-radius: 4px; }
488
+ .json summary::-webkit-details-marker { display: none; }
489
+ .json summary::before {
490
+ content: '▸';
491
+ display: inline-block;
492
+ width: .9em;
493
+ opacity: .5;
494
+ transition: transform .12s;
495
+ }
496
+ .json details[open] > summary::before { transform: rotate(90deg); }
497
+ .json summary:hover { background: rgba(0, 0, 0, .04); }
498
+ .json .leaf { padding: 1px 0 1px .9em; }
499
+ .json .key { font-weight: 600; color: #1e40af; }
500
+ .json .key::after { content: ':'; opacity: .45; font-weight: 400; margin-right: .35em; }
501
+ .json .brace { opacity: .45; margin-left: .35em; }
502
+ .json em { font-style: normal; margin-left: .6em; font-size: .9em; color: var(--muted); }
503
+ .json .v.str { color: #15803d; }
504
+ .json .v.str::before, .json .v.str::after { content: '"'; opacity: .4; }
505
+ .json .v.num { color: #b45309; }
506
+ .json .v.bool { color: #7c3aed; }
507
+ .json .v.nil, .json .v.empty { color: var(--muted); font-style: italic; }
508
+ .json mark { background: #fde68a; color: var(--ink); border-radius: 3px; padding: 0 1px; }
509
+
510
+ /* ── An .env file, as the table it always was ─────────────────────────
511
+ Two columns and one line each: the reason you open one is to compare a name with a value, and
512
+ as text the values start in a different place on every line. The comments stay — a comment
513
+ above a key is what the key is for, and a view that dropped it would be a different file. */
514
+ .env { width: calc(100% - 64px); margin: 20px 32px; border-collapse: collapse; font: .8rem var(--mono); }
515
+ .env th, .env td { padding: 6px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
516
+ .env th { width: 34%; font-weight: 600; color: #1e40af; overflow-wrap: anywhere; }
517
+ .env .val { color: var(--ink); overflow-wrap: anywhere; }
518
+ .env .val.none { color: var(--muted); font-style: italic; }
519
+ .env .note td { color: var(--muted); font-style: italic; border-bottom: 0; padding-top: 14px; }
520
+ .env .odd td { color: var(--muted); }
521
+ .env .gap td { padding: 0; height: 8px; border-bottom: 0; }
522
+
523
+ /* ── YAML, TOML, INI ──────────────────────────────────────────────────
524
+ Not a table: the nesting is the meaning, and flattening it would throw away the one thing you
525
+ opened the file to see. So the file keeps its shape and only gets its parts told apart. */
526
+ .conf {
527
+ margin: 0;
528
+ padding: 20px 32px;
529
+ font: .82rem/1.6 var(--mono);
530
+ white-space: pre;
531
+ overflow-x: auto;
532
+ }
533
+ .conf .key { color: #1e40af; font-weight: 600; }
534
+ .conf .sep, .conf .dash { opacity: .45; }
535
+ .conf .sec { color: #7c3aed; font-weight: 600; }
536
+ .conf .c { color: var(--muted); font-style: italic; }
537
+
538
+ /* ── Writing a file ───────────────────────────────────────────────────
539
+ In the pane where the document was, not in a window over it: a dialog is the right size for a
540
+ title and the wrong size for a README, and here the pane is already as tall as the file. */
541
+ .crumb.editing { background: var(--IDLE); }
542
+ .crumb.editing em { font-style: italic; }
543
+ .edit {
544
+ display: block;
545
+ width: 100%;
546
+ height: calc(100vh - 96px);
547
+ border: 0;
548
+ outline: 0;
549
+ resize: none;
550
+ padding: 20px 32px;
551
+ background: #fff;
552
+ color: var(--ink);
553
+ font: .82rem/1.6 var(--mono);
554
+ tab-size: 2;
555
+ }
556
+
355
557
  /* ── The label ────────────────────────────────────────────────────────
356
558
  What is written between the two rules at the top of the file. It is not text of the document —
357
559
  it is what the document says it is — so it reads as a label: set apart, in a different colour,
@@ -511,7 +713,10 @@ body {
511
713
  .split { display: block; }
512
714
  .doc { overflow: visible; padding: 0 14mm; background: #fff; }
513
715
 
514
- .md, .plain { padding: 0; font-size: 10.5pt; line-height: 1.5; }
716
+ .md, .plain, .tree, .conf { padding: 0; font-size: 10.5pt; line-height: 1.5; }
717
+ .env { width: 100%; margin: 0; }
718
+ /* A file half-written is not a document: there is nothing to put on paper. */
719
+ .edit { display: none !important; }
515
720
  /* The label is hidden, but it is still there: the space that held it down is not. */
516
721
  .plate + .md { padding-top: 0; }
517
722
  .md a { color: #000; }
package/web/files.html CHANGED
@@ -20,6 +20,16 @@
20
20
  </button>
21
21
  <input id="q" autocomplete="off" spellcheck="false" placeholder="search a file…">
22
22
  </div>
23
+ <!-- The switch that lets the configuration through. Off by default: in a repository of prose
24
+ an `.env` is not what you came for, and on the day it is, it is the only thing you came
25
+ for. It sits beside the search and not inside it, and it says what it is in a word: put
26
+ in the pill as a bare glyph it read as part of the field's decoration, and nobody found
27
+ it. -->
28
+ <button type="button" class="nerd" id="nerd" aria-pressed="false"
29
+ title="Show configuration files too — .env, JSON, YAML, TOML, INI" aria-label="Show configuration files too">
30
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6l-5 6 5 6"/><path d="M16 6l5 6-5 6"/></svg>
31
+ config
32
+ </button>
23
33
  <span class="count" id="count"></span>
24
34
  </header>
25
35