paneltir 0.9.0 → 0.10.1

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.
@@ -31,6 +31,13 @@ library shared with other projects. Rules:
31
31
  src/dashboard-theme.ts -->. Colour changes go there, never in the kit. The
32
32
  dashboard's **shape** is a `DashboardThemeForm` passed to the same provider
33
33
  (`panel` or `ledger`); it too is this project's choice, not the kit's.
34
+ - **This project's theme goes to `PanelApp` as `extraThemes`, never as
35
+ `themes`.** `extraThemes` offers it alongside the four the kit ships;
36
+ `themes` means "exactly these and nothing else", so passing one theme there
37
+ deletes all four and the settings sheet ends up with a single row. That is
38
+ not hypothetical — it happened to two projects before the distinction
39
+ existed, and it looks like the kit having no themes rather than like a prop
40
+ being wrong.
34
41
  - **The panel's gate is this project's, not the library's.** `middleware.ts`,
35
42
  `lib/session.ts` and `api/login.ts` / `api/logout.ts` / `api/panel-state.ts`
36
43
  were copied here by `npx paneltir init`. They hold this project's password
@@ -38,6 +45,13 @@ library shared with other projects. Rules:
38
45
  change when the kit updates, and `paneltir init` will not overwrite them.
39
46
  The password, the GitHub token and the target repository are environment
40
47
  variables where this project is hosted — never in this repository.
48
+ - **Those copied files do not update, so they have to be asked.** Run
49
+ `npx paneltir doctor` when the panel misbehaves for no visible reason: it
50
+ compares each copied file against the version installed now and separates
51
+ the three answers that need different actions — you edited it, the template
52
+ moved underneath you, or both. The second is the one no `git diff` can find,
53
+ because there is nothing in this project's history to diff against, and it is
54
+ how a fix released upstream stayed out of a project for two releases.
41
55
  - **The panel explains itself once.** New readers get a short guided tour the
42
56
  first time they open the board; each note is dismissed and stays dismissed.
43
57
  **Help** in the header reports what the panel needs — the password, the
package/INSTALL.md CHANGED
@@ -19,7 +19,7 @@ identity** — never another project's.
19
19
  npm install paneltir
20
20
 
21
21
  # or, without npm access, straight from the repository:
22
- # npm install github:daifukus/paneltir#v0.9.0
22
+ # npm install github:daifukus/paneltir#v0.10.1
23
23
  ```
24
24
 
25
25
  (Use the tag you were given; never install without pinning a version.)
@@ -165,6 +165,7 @@ identity** — never another project's.
165
165
  | `extraThemes` | This project's own identities, offered **as well as** the kit's presets. Theirs come first, so the panel opens in one of them. This is the one you want. |
166
166
  | `themes` | The exact set: these and nothing else, presets included. Only for a caller deliberately curating the list — it replaces rather than adds. |
167
167
  | `defaultTheme` | Which one to draw before the board has said. Defaults to the first key. |
168
+ | `gate` | `.paneltir/gate.json` from this project, imported. With it the setup list gains a row saying whether the files `init` copied are still the ones this version ships; without it the panel says nothing about them rather than guessing. |
168
169
  | `brand` | What sits where a name goes in the header. Defaults to the board's `project`. |
169
170
  | `capabilities`, `extras` | Sections of the caller's own. Not drawn when absent. |
170
171
  | `links`, `footer`, `decoration`, `className` | Where else to go, what sits under the panel, and anything the caller draws behind it. |
@@ -192,6 +193,38 @@ identity** — never another project's.
192
193
  there. On a board already in use the same walkthrough is behind **Help**,
193
194
  offered only while something is actually missing.
194
195
 
196
+ **The files `init` copied do not update, so ask them.** Everything under
197
+ `api/`, `lib/` and `middleware.ts` is this project's from the moment it is
198
+ copied — that is the point, since it holds this project's password rule —
199
+ and the cost is that a fix released upstream can be missing here while every
200
+ version number on screen looks current. It has happened: the `HTTP 500` on
201
+ saving was fixed in `template/api/panel-state.ts`, and projects installed
202
+ before that still had it.
203
+
204
+ ```bash
205
+ npx paneltir doctor
206
+ ```
207
+
208
+ **Commit `.paneltir/gate.json`.** `init` writes it to record what it copied
209
+ and what it copied it from, and it is provenance rather than a build
210
+ artifact: ignored by git it disappears on the next clone and every answer
211
+ below degrades to "differs", which is the one that cannot say who moved.
212
+
213
+ It separates the three answers that need different actions — *yours* (you
214
+ edited it), *BEHIND* (you did not touch it and the template moved), *BOTH* —
215
+ and the middle one is invisible to `git diff`, because this project's history
216
+ has nothing to compare against. `init --force` replaces the gate and never
217
+ the board; read the diff first where a file is marked BOTH, or the edit is
218
+ lost.
219
+
220
+ ```bash
221
+ npx paneltir run "what this pass did" --cards=a,b
222
+ ```
223
+
224
+ appends an entry to the board's `runs` so the record does not depend on
225
+ anyone remembering at the end of a session. It writes the file and commits
226
+ nothing.
227
+
195
228
  Two things to check before trusting it: `/admin` signed out must land on
196
229
  `/login`, and **the panel's own JavaScript bundle must answer 401 signed
197
230
  out**. If it does not, point the last entry of the matcher in
@@ -204,7 +237,7 @@ identity** — never another project's.
204
237
 
205
238
  ```bash
206
239
  npx paneltir version # version, fingerprint, where it came from
207
- npx paneltir check 0.9.0 # exits non-zero if that is not what is installed
240
+ npx paneltir check 0.10.1 # exits non-zero if that is not what is installed
208
241
  npx paneltir board # read the board and say what is wrong with it
209
242
  ```
210
243
 
package/README.md CHANGED
@@ -54,7 +54,7 @@ tag rather than a branch, so an unfinished push cannot reach a project:
54
54
  npm install paneltir
55
55
 
56
56
  # or, without npm access, straight from the repository:
57
- # npm install github:daifukus/paneltir#v0.9.0
57
+ # npm install github:daifukus/paneltir#v0.10.1
58
58
  ```
59
59
 
60
60
  Installing from Git runs the `prepare` script, which builds `dist/`, so the
package/bin/paneltir.mjs CHANGED
@@ -14,7 +14,8 @@
14
14
  * to the project: readable, editable, in its own git history. Re-running never
15
15
  * overwrites what is already there.
16
16
  */
17
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
17
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
18
+ import { createHash } from 'node:crypto'
18
19
  import { dirname, join, relative, resolve } from 'node:path'
19
20
  import { fileURLToPath, pathToFileURL } from 'node:url'
20
21
 
@@ -110,6 +111,68 @@ function survey(target) {
110
111
  return found
111
112
  }
112
113
 
114
+ /**
115
+ * Records the provenance of the gate now in the project.
116
+ *
117
+ * Merged rather than replaced: re-running `init` writes only the files that
118
+ * were missing, so a stamp that dropped the rest would forget where the
119
+ * majority of the gate came from. Only files this run actually wrote are
120
+ * re-stamped — for one it left alone we did not write it and cannot claim to
121
+ * know its origin, so the previous answer stands or there is none.
122
+ *
123
+ * The board is stamped like the rest and then deliberately ignored by
124
+ * `doctor`: it is meant to diverge from the seed on the first card, and a
125
+ * report that called that drift would be noise on every project that is
126
+ * working.
127
+ */
128
+ function writeStamp(target, templateFiles, written) {
129
+ const previous = readStamp(target)
130
+ const files = { ...(previous?.files ?? {}) }
131
+ for (const file of written) files[file] = sha256(join(target, file))
132
+
133
+ /*
134
+ * The headline only advances when this run actually rewrote the whole gate.
135
+ *
136
+ * It used to advance on every run, which quietly defeated the entire point.
137
+ * A project on v0.9.0, a template changed in v0.10.0, `npm update`, then a
138
+ * re-run of `init` — which writes nothing, because the files are already
139
+ * there — and the stamp said the gate came from v0.10.0 while the files on
140
+ * disk were still v0.9.0's. `doctor` then printed "The template has not
141
+ * changed since" directly above a row reading BEHIND, and the panel, which
142
+ * has only this hash to go on, said the gate was current. Reproduced before
143
+ * fixing.
144
+ *
145
+ * The board is excluded because it is never rewritten: requiring it would
146
+ * mean the headline never advanced at all after the first install.
147
+ *
148
+ * When the gate is mixed — some files written, some left — the previous
149
+ * answer stands. That can over-report staleness for a partly updated gate,
150
+ * and it is the safe direction: `doctor`'s per-file rows carry the truth,
151
+ * and over-reporting sends somebody to read a diff while under-reporting
152
+ * sends them nowhere at all.
153
+ */
154
+ const gateFiles = templateFiles.filter((file) => file !== BOARD_SEED)
155
+ const wholeGate = gateFiles.length > 0 && gateFiles.every((file) => written.includes(file))
156
+
157
+ const stamp = {
158
+ version: wholeGate ? pkg.version : (previous?.version ?? pkg.version),
159
+ templateHash: wholeGate ? (fingerprint.templateHash ?? '') : (previous?.templateHash ?? ''),
160
+ // When this gate was last written, not when this command last ran.
161
+ copiedAt: written.length ? new Date().toISOString() : (previous?.copiedAt ?? new Date().toISOString()),
162
+ files,
163
+ }
164
+ try {
165
+ mkdirSync(dirname(join(target, STAMP)), { recursive: true })
166
+ writeFileSync(join(target, STAMP), JSON.stringify(stamp, null, 2) + '\n')
167
+ } catch (error) {
168
+ // Never fatal. The gate is copied and working; the stamp only makes a
169
+ // later report sharper, and failing the install over bookkeeping would
170
+ // trade something that matters for something that does not.
171
+ console.error(`\npaneltir: could not write ${STAMP} (${error.message}).`)
172
+ console.error('The gate is in place; `paneltir doctor` will have less to go on.')
173
+ }
174
+ }
175
+
113
176
  function reportSurvey(found) {
114
177
  const lines = []
115
178
 
@@ -149,6 +212,36 @@ Remove it once the board is moved over, or the project ships both.`)
149
212
  /** The board. Never replaced once it exists — see the loop below. */
150
213
  const BOARD_SEED = 'src/data/panel-state.json'
151
214
 
215
+ /**
216
+ * Where `init` records what it copied and what it copied it from.
217
+ *
218
+ * The gate is the one part of this package that leaves as files a project
219
+ * keeps, and a kept file can never be told it is old. That has been paid for
220
+ * twice: the `HTTP 500` fix landed in `template/api/panel-state.ts`, so every
221
+ * project installed before it still has a save that cannot work, and the skill
222
+ * file is copied the same way, so a rule learned upstream never reaches the
223
+ * Claude that reads it.
224
+ *
225
+ * A plain diff cannot close that, because it cannot tell "you edited this"
226
+ * from "we changed this underneath you" — and those need opposite actions.
227
+ * Hashing each file as written is what separates them.
228
+ */
229
+ const STAMP = '.paneltir/gate.json'
230
+
231
+ function sha256(path) {
232
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
233
+ }
234
+
235
+ function readStamp(target) {
236
+ try {
237
+ return JSON.parse(readFileSync(join(target, STAMP), 'utf8'))
238
+ } catch {
239
+ // Absent or unreadable is not a fault: every project installed before
240
+ // stamping existed is here, and `doctor` still compares the files.
241
+ return null
242
+ }
243
+ }
244
+
152
245
  function init(args) {
153
246
  const force = args.includes('--force')
154
247
  const target = process.cwd()
@@ -180,6 +273,8 @@ function init(args) {
180
273
  written.push(file)
181
274
  }
182
275
 
276
+ writeStamp(target, files, written);
277
+
183
278
  console.log(`paneltir ${pkg.version} — the panel's gate, into ${target}\n`)
184
279
 
185
280
  const notes = reportSurvey(survey(target))
@@ -299,7 +394,7 @@ function check(args) {
299
394
  * neither helps the person who has just edited the file by hand and wants to
300
395
  * know before they push. This is that answer, from the same check.
301
396
  */
302
- function board(args) {
397
+ async function board(args) {
303
398
  const file = args.find((a) => !a.startsWith('-')) || process.env.PANEL_FILE || 'src/data/panel-state.json'
304
399
  const path = resolve(process.cwd(), file)
305
400
 
@@ -309,7 +404,7 @@ function board(args) {
309
404
  process.exit(1)
310
405
  }
311
406
 
312
- const check = readBoard(readFileSync(path, 'utf8'))
407
+ const check = (await boardReader())(readFileSync(path, 'utf8'))
313
408
  if (check.ok) {
314
409
  const cards = check.board.cards.length
315
410
  console.log(`ok ${file} — ${cards} card${cards === 1 ? '' : 's'}, ${check.board.columns.length} columns`)
@@ -328,7 +423,253 @@ function board(args) {
328
423
  process.exit(1)
329
424
  }
330
425
 
331
- const { readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href)
426
+
427
+ /**
428
+ * Says whether the files this project copied are still the files it shipped.
429
+ *
430
+ * The three answers it exists to tell apart:
431
+ *
432
+ * - **yours** — you edited it. Nothing to do; the gate is meant to be edited,
433
+ * and this is only here so the next line is believable.
434
+ * - **behind** — you did not touch it and the template moved. This is the one
435
+ * that has cost real money twice, and the one a bare `git diff` cannot see
436
+ * at all, because there is nothing in the project's history to diff
437
+ * against.
438
+ * - **both** — you edited it *and* the template moved. The only case that
439
+ * needs a person: `--force` would throw your edit away.
440
+ *
441
+ * It reports and never repairs, for the same reason the panel records a request
442
+ * instead of running it: replacing somebody's front door is a decision, and
443
+ * `init --force` is where a person makes it having read what changed.
444
+ */
445
+ function doctor() {
446
+ const target = process.cwd()
447
+ const templateDir = join(packageRoot, 'template')
448
+ if (!existsSync(templateDir)) {
449
+ console.error('paneltir: this install has no template/ directory.')
450
+ process.exit(1)
451
+ }
452
+
453
+ const stamp = readStamp(target)
454
+ const files = walk(templateDir).sort()
455
+
456
+ console.log(`paneltir ${pkg.version} — the gate in ${target}\n`)
457
+
458
+ if (!stamp) {
459
+ console.log(`No ${STAMP}, so this gate was copied before stamping existed.`)
460
+ console.log('Files can still be compared; "you edited it" and "we changed it"')
461
+ console.log('cannot be told apart until the next `init` writes one.\n')
462
+ } else {
463
+ const behind = stamp.templateHash !== (fingerprint.templateHash ?? '')
464
+ console.log(`Copied from v${stamp.version} on ${String(stamp.copiedAt).slice(0, 10)}`)
465
+ console.log(behind ? 'The template has changed since.\n' : 'The template has not changed since.\n')
466
+ }
467
+
468
+ const rows = []
469
+ for (const file of files) {
470
+ // The board is meant to diverge: it stops being a template on the first
471
+ // card. Calling that drift would put a warning on every working project.
472
+ if (file === BOARD_SEED) {
473
+ rows.push([file, existsSync(join(target, file)) ? 'yours' : 'missing', 'the board — never compared'])
474
+ continue
475
+ }
476
+
477
+ const here = join(target, file)
478
+ if (!existsSync(here)) {
479
+ rows.push([file, 'missing', 'never copied; `paneltir init` writes it'])
480
+ continue
481
+ }
482
+
483
+ const mine = sha256(here)
484
+ const theirs = sha256(join(templateDir, file))
485
+ const stamped = stamp?.files?.[file]
486
+
487
+ if (mine === theirs) {
488
+ rows.push([file, 'current', ''])
489
+ } else if (!stamped) {
490
+ rows.push([file, 'differs', 'no stamp, so who moved cannot be said'])
491
+ } else if (mine === stamped) {
492
+ rows.push([file, 'BEHIND', 'you did not touch it; the template moved'])
493
+ } else if (stamped === theirs) {
494
+ rows.push([file, 'yours', 'you edited it; the template has not moved'])
495
+ } else {
496
+ rows.push([file, 'BOTH', 'you edited it AND the template moved — read both'])
497
+ }
498
+ }
499
+
500
+ const width = Math.max(...rows.map(([file]) => file.length))
501
+ for (const [file, state, note] of rows) {
502
+ console.log(` ${state.padEnd(8)} ${file.padEnd(width)}${note ? ` ${note}` : ''}`)
503
+ }
504
+
505
+ const stale = rows.filter(([, state]) => state === 'BEHIND' || state === 'BOTH')
506
+ const missing = rows.filter(([, state]) => state === 'missing')
507
+
508
+ if (!stale.length && !missing.length) {
509
+ console.log('\nNothing to do.')
510
+ return
511
+ }
512
+
513
+ if (stale.length) {
514
+ console.log(`\n${stale.length} file(s) are older than the package they came from.`)
515
+ console.log('See what changed, then decide:')
516
+ console.log(` diff -u <file> node_modules/paneltir/template/<file>`)
517
+ console.log('`paneltir init --force` replaces the gate and never the board.')
518
+ console.log('Anything marked BOTH would lose your edit — read that diff first.')
519
+ }
520
+ if (missing.length) {
521
+ console.log(`\n${missing.length} file(s) were never copied. \`paneltir init\` writes them.`)
522
+ }
523
+ // A gate that cannot work is a failure, not a remark: this runs in CI for
524
+ // some projects, and an exit code is the only part of it a script reads.
525
+ process.exit(1)
526
+ }
527
+
528
+ /**
529
+ * Appends a run to the board, so the record does not depend on remembering.
530
+ *
531
+ * `runs` is what each pass actually did, and until now it was written by hand
532
+ * — by Claude, into JSON, at the end of a session, from memory. That is the
533
+ * shape of record that is complete right up until the session that mattered.
534
+ * A command can be called the moment the work lands.
535
+ *
536
+ * It writes the file and nothing else: the board is committed by the panel's
537
+ * Save or by the project's own commit, and a CLI that pushed on somebody's
538
+ * behalf would be the second thing in this project writing to their
539
+ * repository.
540
+ */
541
+ /**
542
+ * One flag, in either form, because the usage line prints the space one.
543
+ *
544
+ * It only read `--cards=a,b`, while its own error message printed
545
+ * `[--cards a,b]` — so the documented form took `a,b` as the board path and
546
+ * failed with "no board at a,b", and `--by you` was dropped silently, which is
547
+ * worse: the run was attributed to Claude when somebody said it was theirs.
548
+ */
549
+ function flag(args, name) {
550
+ const joined = args.find((a) => a.startsWith(`--${name}=`))
551
+ if (joined) return joined.slice(name.length + 3)
552
+ const at = args.indexOf(`--${name}`)
553
+ if (at !== -1 && typeof args[at + 1] === 'string' && !args[at + 1].startsWith('--')) return args[at + 1]
554
+ return undefined
555
+ }
556
+
557
+ async function run(args) {
558
+ // A flag's value is not a positional argument, whichever form it came in.
559
+ const taken = new Set()
560
+ for (const name of ['cards', 'by']) {
561
+ const at = args.indexOf(`--${name}`)
562
+ if (at !== -1) {
563
+ taken.add(at)
564
+ if (typeof args[at + 1] === 'string' && !args[at + 1].startsWith('--')) taken.add(at + 1)
565
+ }
566
+ }
567
+ const plain = args.filter((a, i) => !a.startsWith('--') && !taken.has(i))
568
+ const summary = plain[0]
569
+ const file = plain[1] ?? BOARD_SEED
570
+
571
+ if (!summary) {
572
+ console.error('paneltir run "what this pass did" [--cards a,b] [--by you] [board.json]')
573
+ process.exit(1)
574
+ }
575
+
576
+ const path = resolve(process.cwd(), file)
577
+ if (!existsSync(path)) {
578
+ console.error(`paneltir: no board at ${file}`)
579
+ process.exit(1)
580
+ }
581
+
582
+ const text = readFileSync(path, 'utf8')
583
+ const check = (await boardReader())(text)
584
+ if (!check.ok) {
585
+ // Refused before writing, not after: appending to a board that is already
586
+ // broken buries the fault under a change nobody asked for.
587
+ console.error(`paneltir: ${file} is not a board this kit can read, so nothing was appended.\n`)
588
+ for (const problem of check.problems) {
589
+ console.error(problem.at ? ` ${problem.at}\n ${problem.says}` : ` ${problem.says}`)
590
+ }
591
+ process.exit(1)
592
+ }
593
+
594
+ const board = check.board
595
+ const cards = (flag(args, 'cards') ?? '')
596
+ .split(',')
597
+ .map((id) => id.trim())
598
+ .filter(Boolean)
599
+
600
+ // A run naming a card the board does not have is a claim that cannot be
601
+ // checked, which is the only thing `cards` was for.
602
+ const known = new Set(board.cards.map((card) => card.id))
603
+ const unknown = cards.filter((id) => !known.has(id))
604
+ if (unknown.length) {
605
+ console.error(`paneltir: ${file} has no card called ${unknown.join(', ')}.`)
606
+ process.exit(1)
607
+ }
608
+
609
+ const by = flag(args, 'by') === 'you' ? 'you' : 'claude'
610
+ const date = new Date().toISOString().slice(0, 10)
611
+ board.runs = Array.isArray(board.runs) ? board.runs : []
612
+ // Appended, not prepended: `runs` is stored oldest-first and every reader
613
+ // reverses it. `unshift` put a new pass at the *oldest* position, so it
614
+ // rendered at the bottom of the revisions log and fell outside the bell's
615
+ // newest-first window — a record of work that read as the first thing ever
616
+ // done here.
617
+ board.runs.push({
618
+ id: `run-${date}-${String(board.runs.length + 1).padStart(3, '0')}`,
619
+ date,
620
+ by,
621
+ summary,
622
+ cards,
623
+ fingerprint: shortHash,
624
+ })
625
+
626
+ /*
627
+ * Bounded here, because here is where it grows.
628
+ *
629
+ * The validators used to refuse a board with more than 200 runs while
630
+ * nothing anywhere trimmed them, so the only reachable outcome was a Save
631
+ * that broke permanently on the two-hundredth pass and said "Too many runs".
632
+ * A bound belongs where the list is appended, not where it is read: trimming
633
+ * on the write path would commit a board shorter than the one the panel is
634
+ * holding, and the panel would read as saved while the repository disagreed.
635
+ *
636
+ * The oldest go, since `runs` is oldest-first. Three hundred is about a
637
+ * decade of daily passes, and the diff of a list that only grows grows with
638
+ * it.
639
+ */
640
+ const KEEP_RUNS = 300
641
+ const dropped = Math.max(0, board.runs.length - KEEP_RUNS)
642
+ if (dropped) board.runs = board.runs.slice(dropped)
643
+
644
+ writeFileSync(path, JSON.stringify(board, null, 2) + '\n')
645
+ console.log(`Appended to ${file}: ${summary}`)
646
+ if (dropped) console.log(` dropped the ${dropped} oldest run(s), keeping the last ${KEEP_RUNS}`)
647
+ console.log(` ${cards.length ? `cards ${cards.join(', ')}` : 'no cards named'} · by ${by} · ${shortHash}`)
648
+ console.log('\nThe file is changed and not committed. The panel commits it on Save,')
649
+ console.log('or commit it yourself — this writes nothing to your repository.')
650
+ }
651
+
652
+ /**
653
+ * The board reader, loaded only by the commands that read a board.
654
+ *
655
+ * It used to be imported at module scope, which meant every command pulled in
656
+ * `dist/index.js` — and that bundle imports React, because it is a component
657
+ * library. So `paneltir init`, whose entire job is to copy seven files, and
658
+ * `paneltir doctor`, whose job is to hash them, both failed outright in a
659
+ * project that had not installed React yet. Which is exactly when somebody runs
660
+ * `init`.
661
+ *
662
+ * It is the same rule as the gate's, one layer up: a thing whose job is files
663
+ * does not carry a component library to do it. Here the fix is smaller than
664
+ * duplicating anything — the import just moves to where it is used.
665
+ */
666
+ let readBoard
667
+ async function boardReader() {
668
+ if (!readBoard) {
669
+ ;({ readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href))
670
+ }
671
+ return readBoard
672
+ }
332
673
 
333
674
  const [command, ...rest] = process.argv.slice(2)
334
675
 
@@ -343,7 +684,13 @@ switch (command) {
343
684
  check(rest)
344
685
  break
345
686
  case 'board':
346
- board(rest)
687
+ await board(rest)
688
+ break
689
+ case 'doctor':
690
+ doctor()
691
+ break
692
+ case 'run':
693
+ await run(rest)
347
694
  break
348
695
  default:
349
696
  console.log(`paneltir v${pkg.version} · ${shortHash}
@@ -352,6 +699,8 @@ switch (command) {
352
699
  paneltir version what is installed, and where it came from
353
700
  paneltir check [version] fail if the installed version is not the one meant
354
701
  paneltir board [file] read this project's board and say what is wrong
702
+ paneltir doctor is the gate you copied still the gate we ship?
703
+ paneltir run "summary" append what this pass did to the board's runs
355
704
 
356
705
  The components install as a library and are never edited. The gate — password,
357
706
  session, write-back — is copied in by init and belongs to the project.`)
package/dist/index.d.ts CHANGED
@@ -15,6 +15,17 @@ declare const PANELTIR_VERSION: string;
15
15
  declare const PANELTIR_FINGERPRINT: string;
16
16
  /** How many files that fingerprint covers. */
17
17
  declare const PANELTIR_FILE_COUNT: number;
18
+ /**
19
+ * SHA-256 over `template/` alone — the gate, the seed board and the skill file.
20
+ *
21
+ * Separate from the fingerprint above because it answers a different question.
22
+ * The fingerprint says which build this is; this says whether the files a
23
+ * project *copied out of* the package are the ones it ships now. Most releases
24
+ * do not touch `template/`, so those two move at different rates, and a gate
25
+ * checked against the version number would be reported stale on every release
26
+ * and believed on none.
27
+ */
28
+ declare const PANELTIR_TEMPLATE_HASH: string;
18
29
  /**
19
30
  * Everything needed to report the kit in a panel's own footer or settings
20
31
  * sheet, and to check it against the released version without opening
@@ -24,6 +35,8 @@ declare const paneltirBuild: {
24
35
  readonly version: string;
25
36
  readonly fingerprint: string;
26
37
  readonly fileCount: number;
38
+ /** What `init` stamps, and what a gate is compared against. */
39
+ readonly templateHash: string;
27
40
  /** The tag this build came from, ready to compare or to link. */
28
41
  readonly tag: `v${string}`;
29
42
  /** Short form, for a footer that has one line to spare. */
@@ -508,6 +521,18 @@ interface SetupRequirement {
508
521
  enables?: React.ReactNode;
509
522
  /** Exactly what to do, shown only when it is missing. */
510
523
  fix?: React.ReactNode;
524
+ /**
525
+ * Information rather than a gate: drawn in the list, never walked as a step.
526
+ *
527
+ * Every other requirement here is an environment variable, and the
528
+ * walkthrough's one action — re-read the environment from the server —
529
+ * is what clears it. A requirement that action cannot clear parks the
530
+ * walkthrough on it for ever, and because `unknown` is judged across the
531
+ * whole list, one advisory row that nobody wired made every step answer a
532
+ * successful check with "the panel could not ask its own server". That is
533
+ * what the copied gate did the day it was added.
534
+ */
535
+ advisory?: boolean;
511
536
  }
512
537
  interface SetupGuideProps {
513
538
  requirements: SetupRequirement[];
@@ -1361,6 +1386,9 @@ interface UiStrings {
1361
1386
  needTokenEnables: string;
1362
1387
  needRepo: string;
1363
1388
  needRepoEnables: string;
1389
+ needGate: string;
1390
+ needGateEnables: string;
1391
+ fixGate: string;
1364
1392
  fixTokenCreate: string;
1365
1393
  fixTokenScope: string;
1366
1394
  fixTokenPaste: string;
@@ -1591,6 +1619,21 @@ interface PanelAppProps {
1591
1619
  * collides with a preset replaces it, because the caller named it.
1592
1620
  */
1593
1621
  extraThemes?: PanelThemes;
1622
+ /**
1623
+ * `.paneltir/gate.json` from this project, if the caller imports it.
1624
+ *
1625
+ * The gate is copied once and then belongs to the project, which means it is
1626
+ * the one part of this package that can never be told it is old — and that
1627
+ * has cost two releases already. Handing the stamp in lets the panel say so,
1628
+ * with no filesystem and no network, because both halves of the comparison
1629
+ * are already data: the stamp from the project and `templateHash` from the
1630
+ * installed package.
1631
+ *
1632
+ * Omitted, the panel says nothing about the gate rather than guessing. This
1633
+ * is a prop and not a fetch for the reason the Tools screen is a prop in the
1634
+ * demo: the kit does not decide what a caller's server can be asked.
1635
+ */
1636
+ gate?: unknown;
1594
1637
  /** Which one to draw before the board has said. Defaults to the first. */
1595
1638
  defaultTheme?: string;
1596
1639
  /**
@@ -1657,7 +1700,94 @@ interface PanelAppProps {
1657
1700
  fileCount: number;
1658
1701
  };
1659
1702
  }
1660
- declare function PanelApp({ state: initialState, themes: exactThemes, extraThemes, defaultTheme, brand, decoration, className, footer, links, capabilities, extras, showLanguage, showHistory, checkForUpdates, saveEndpoint, marketplaceEndpoint, marketplaceReport, fingerprint, }: PanelAppProps): React.JSX.Element;
1703
+ declare function PanelApp({ state: initialState, themes: exactThemes, extraThemes, gate, defaultTheme, brand, decoration, className, footer, links, capabilities, extras, showLanguage, showHistory, checkForUpdates, saveEndpoint, marketplaceEndpoint, marketplaceReport, fingerprint, }: PanelAppProps): React.JSX.Element;
1704
+
1705
+ /**
1706
+ * Whether the gate a project is running is the gate this version ships.
1707
+ *
1708
+ * `template/` is the one thing here that leaves as *files a project keeps*.
1709
+ * The components arrive in `node_modules` and are replaced on every update;
1710
+ * the gate is copied once by `init` and is then that project's own — which is
1711
+ * the point, since it holds their password rule and points at their
1712
+ * repository, and a front door that moves when a library updates is not a
1713
+ * front door.
1714
+ *
1715
+ * The cost of that is a copy that can never be told it is old. It has already
1716
+ * been paid twice. The `HTTP 500` fix landed in `template/api/panel-state.ts`,
1717
+ * so every project installed before it still has a save that cannot work, and
1718
+ * nothing anywhere says so; `.claude/skills/panel/SKILL.md` is copied the same
1719
+ * way, so a rule learned upstream never reaches the Claude that reads it.
1720
+ * Upstream the copy is held honest by `test:auth` and `test:board`. Downstream
1721
+ * there was nothing at all.
1722
+ *
1723
+ * So `init` records what it copied and what it copied it from, and this says
1724
+ * what that record means. Two rules shape it:
1725
+ *
1726
+ * - **It compares hashes, not version numbers.** Most releases do not touch
1727
+ * `template/`, so "you are on 0.6.0 and 0.9.0 exists" would cry wolf for
1728
+ * every release and be ignored by the one that mattered. `templateHash`
1729
+ * ships inside `fingerprint.json`, so the comparison is exact.
1730
+ * - **It needs no filesystem and no network**, because the panel runs in a
1731
+ * browser and has neither. Everything it judges is data the caller already
1732
+ * holds: the stamp from the project, the hash from the installed package.
1733
+ *
1734
+ * It reports and never repairs. Replacing somebody's gate is `init --force`,
1735
+ * typed by a person who has read what changed.
1736
+ */
1737
+ /**
1738
+ * What `init` wrote, and what it wrote it from.
1739
+ *
1740
+ * Lives at `.paneltir/gate.json` in the project. It is not a lockfile and
1741
+ * nothing resolves against it — it exists so a later session can tell "you
1742
+ * edited this" from "we changed this underneath you", which is the distinction
1743
+ * a bare diff cannot make.
1744
+ */
1745
+ interface GateStamp {
1746
+ /** The kit version that copied these files. */
1747
+ version: string;
1748
+ /** `templateHash` from that version's `fingerprint.json`. */
1749
+ templateHash: string;
1750
+ /** When, so a reader can date it against their own git history. */
1751
+ copiedAt: string;
1752
+ /**
1753
+ * SHA-256 of each file as written, keyed by its path in the project.
1754
+ *
1755
+ * This is what separates a file the project edited from one the template
1756
+ * moved under. Absent for a file `init` left alone: we did not write it, so
1757
+ * we cannot claim to know where it came from.
1758
+ */
1759
+ files: Record<string, string>;
1760
+ }
1761
+ type GateVerdict =
1762
+ /** The gate is the one this version ships. */
1763
+ 'current'
1764
+ /** `template/` has changed since this gate was copied. */
1765
+ | 'behind'
1766
+ /**
1767
+ * No stamp. Every project installed before stamping existed is here, and it
1768
+ * is not a fault — it is the absence of an answer, which is why it is not
1769
+ * called one. `paneltir doctor` can still compare the files themselves.
1770
+ */
1771
+ | 'unstamped';
1772
+ interface GateReading {
1773
+ verdict: GateVerdict;
1774
+ /** The version that copied the gate, when a stamp says so. */
1775
+ copiedFrom?: string;
1776
+ /** The version running now. */
1777
+ installed: string;
1778
+ }
1779
+ /**
1780
+ * Reads a stamp against the installed package.
1781
+ *
1782
+ * Deliberately total: a malformed or absent stamp answers `unstamped` rather
1783
+ * than throwing. This is called while a panel is drawing, and a panel that
1784
+ * fails to open because it could not classify its own gate would be a worse
1785
+ * outcome than the drift it was looking for.
1786
+ */
1787
+ declare function gateStatus(stamp: unknown, installed: {
1788
+ version: string;
1789
+ templateHash?: string;
1790
+ }): GateReading;
1661
1791
 
1662
1792
  /**
1663
1793
  * Applies a board move to the flat card list.
@@ -1717,4 +1847,4 @@ interface CardSheetProps {
1717
1847
  }
1718
1848
  declare function CardSheet({ card, state, lang, ui, onChange, onDelete, onClose }: CardSheetProps): React.JSX.Element | null;
1719
1849
 
1720
- export { ANALYSIS_SECTIONS, AlertBanner, type AlertBannerProps, type Analysis, type AnalysisSection, AnalysisView, BOARD_VERSION, Board, type BoardCardData, type BoardCheck, type BoardColumnData, type BoardFacts, type BoardMoveResult, type BoardProblem, type BoardProps, type BoardTag, CAPABILITIES, type Capability, Card$1 as Card, type CardProps, CardSheet, type Check, type Choice, type ChoiceOption, type ColumnDef, type Competitor, type Constraint, CopyBlock, type CopyBlockLabels, type CopyBlockProps, type Counts, DashboardHeader, type DashboardHeaderProps, type DashboardThemeForm, DashboardThemeProvider, type DashboardThemeTokens, DetailSection, type DetailSectionProps, DetailSheet, type DetailSheetProps, FilterChip, type FilterChipProps, FilterChipRow, type Goal, GuideNote, type GuideNoteProps, GuideTour, type GuideTourProps, type HealthFact, HealthPill, type HealthPillProps, HealthPillRow, type HealthStatus, INTENTS, INTENT_COPY, type ImportedTheme, type InstallState, type Intent, type IntentCopy, LANGS, LANG_LABELS, LEVELS, type Lang, type Level, MARKETPLACE_NAME, MARKETPLACE_URL, type Market, type MarketplaceReport, type MarketplaceTool, MarketplaceView, type Metric, type Note, NotificationBell, type NotificationBellProps, type NotificationLabels, type NotificationTone, OWNERS, type Owner, PANELTIR_FILE_COUNT, PANELTIR_FINGERPRINT, PANELTIR_VERSION, PRESET_PANEL_THEMES, PanelApp, type PanelAppProps, type Card as PanelCard, type PanelNotification, type PanelState, type PanelTheme, type PanelThemes, type Preferences, type Reading, type Run, SetupGuide, type SetupGuideProps, type SetupRequirement, type SetupStatus, SetupWizard, type SetupWizardLabels, type SetupWizardProps, type Severity, StatTile, StatTileGrid, type StatTileProps, type Strategy, type Suggestion, type SuggestionStatus, THEME_FORMS, THEME_PRESETS, type Text, type ThemeFormName, type ThemePresetName, type Trend, UI, type UiStrings, type Weight, type WizardStep, applyMove, boardFacts, checkProgress, claimedWithoutStarting, claudeTheme, commandSnippet, countCards, cyberpunkTheme, decided, emptyCard, explainBoard, isCard, isFirstRun, ledgerForm, midnightTheme, moveCardInColumns, newId, offeredThemes, oldMoneyTheme, panelForm, paneltirBuild, paperForm, readBoard, readStoredLang, repositorySnippet, resetGuide, sparkPoints, stampForColumn, storeLang, text, today, trendOf, undecided, untranslated, useDashboardForm, useDashboardTheme, useDelegatedClick, useGuideNote, useMediaQuery, validateBoard, wizardStep, writeText };
1850
+ export { ANALYSIS_SECTIONS, AlertBanner, type AlertBannerProps, type Analysis, type AnalysisSection, AnalysisView, BOARD_VERSION, Board, type BoardCardData, type BoardCheck, type BoardColumnData, type BoardFacts, type BoardMoveResult, type BoardProblem, type BoardProps, type BoardTag, CAPABILITIES, type Capability, Card$1 as Card, type CardProps, CardSheet, type Check, type Choice, type ChoiceOption, type ColumnDef, type Competitor, type Constraint, CopyBlock, type CopyBlockLabels, type CopyBlockProps, type Counts, DashboardHeader, type DashboardHeaderProps, type DashboardThemeForm, DashboardThemeProvider, type DashboardThemeTokens, DetailSection, type DetailSectionProps, DetailSheet, type DetailSheetProps, FilterChip, type FilterChipProps, FilterChipRow, type GateReading, type GateStamp, type GateVerdict, type Goal, GuideNote, type GuideNoteProps, GuideTour, type GuideTourProps, type HealthFact, HealthPill, type HealthPillProps, HealthPillRow, type HealthStatus, INTENTS, INTENT_COPY, type ImportedTheme, type InstallState, type Intent, type IntentCopy, LANGS, LANG_LABELS, LEVELS, type Lang, type Level, MARKETPLACE_NAME, MARKETPLACE_URL, type Market, type MarketplaceReport, type MarketplaceTool, MarketplaceView, type Metric, type Note, NotificationBell, type NotificationBellProps, type NotificationLabels, type NotificationTone, OWNERS, type Owner, PANELTIR_FILE_COUNT, PANELTIR_FINGERPRINT, PANELTIR_TEMPLATE_HASH, PANELTIR_VERSION, PRESET_PANEL_THEMES, PanelApp, type PanelAppProps, type Card as PanelCard, type PanelNotification, type PanelState, type PanelTheme, type PanelThemes, type Preferences, type Reading, type Run, SetupGuide, type SetupGuideProps, type SetupRequirement, type SetupStatus, SetupWizard, type SetupWizardLabels, type SetupWizardProps, type Severity, StatTile, StatTileGrid, type StatTileProps, type Strategy, type Suggestion, type SuggestionStatus, THEME_FORMS, THEME_PRESETS, type Text, type ThemeFormName, type ThemePresetName, type Trend, UI, type UiStrings, type Weight, type WizardStep, applyMove, boardFacts, checkProgress, claimedWithoutStarting, claudeTheme, commandSnippet, countCards, cyberpunkTheme, decided, emptyCard, explainBoard, gateStatus, isCard, isFirstRun, ledgerForm, midnightTheme, moveCardInColumns, newId, offeredThemes, oldMoneyTheme, panelForm, paneltirBuild, paperForm, readBoard, readStoredLang, repositorySnippet, resetGuide, sparkPoints, stampForColumn, storeLang, text, today, trendOf, undecided, untranslated, useDashboardForm, useDashboardTheme, useDelegatedClick, useGuideNote, useMediaQuery, validateBoard, wizardStep, writeText };
package/dist/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  // src/version.ts
2
- var PANELTIR_VERSION = "0.9.0";
3
- var PANELTIR_FINGERPRINT = "sha256:f65d491a7350ddce22f6981dbcb91531f5e88fc66b7f9f8f83115d802773064f";
4
- var PANELTIR_FILE_COUNT = 161;
2
+ var PANELTIR_VERSION = "0.10.1";
3
+ var PANELTIR_FINGERPRINT = "sha256:b2d1daf120972ce3bfc54f3a9f09bccaddd6ceb306a65a1682b810012d85f33d";
4
+ var PANELTIR_FILE_COUNT = 163;
5
+ var PANELTIR_TEMPLATE_HASH = "sha256:034ba27266408af4f2ba5793d9cefbe448b168570d6a0b87dfe698dfe639feb1";
5
6
  var paneltirBuild = {
6
7
  version: PANELTIR_VERSION,
7
8
  fingerprint: PANELTIR_FINGERPRINT,
8
9
  fileCount: PANELTIR_FILE_COUNT,
10
+ /** What `init` stamps, and what a gate is compared against. */
11
+ templateHash: PANELTIR_TEMPLATE_HASH,
9
12
  /** The tag this build came from, ready to compare or to link. */
10
13
  tag: `v${PANELTIR_VERSION}`,
11
14
  /** Short form, for a footer that has one line to spare. */
@@ -915,15 +918,16 @@ import { useState as useState5 } from "react";
915
918
 
916
919
  // src/components/Setup/wizard.ts
917
920
  function wizardStep(requirements) {
918
- const total = requirements.length;
919
- const done = requirements.filter((requirement) => requirement.status === "ok");
920
- const unknown = requirements.some((requirement) => requirement.status === "unknown");
921
- const at = requirements.findIndex((requirement) => requirement.status !== "ok");
921
+ const steps = requirements.filter((requirement) => !requirement.advisory);
922
+ const total = steps.length;
923
+ const done = steps.filter((requirement) => requirement.status === "ok");
924
+ const unknown = steps.some((requirement) => requirement.status === "unknown");
925
+ const at = steps.findIndex((requirement) => requirement.status !== "ok");
922
926
  if (at === -1) {
923
927
  return { current: null, index: 0, total, done, ready: total > 0, unknown: false };
924
928
  }
925
929
  return {
926
- current: requirements[at],
930
+ current: steps[at],
927
931
  index: at + 1,
928
932
  total,
929
933
  done,
@@ -1472,6 +1476,18 @@ function offeredThemes(themes, extraThemes) {
1472
1476
  return offered;
1473
1477
  }
1474
1478
 
1479
+ // src/gate/stamp.ts
1480
+ function gateStatus(stamp, installed) {
1481
+ const reading = { verdict: "unstamped", installed: installed.version };
1482
+ if (typeof stamp !== "object" || stamp === null) return reading;
1483
+ const candidate = stamp;
1484
+ if (typeof candidate.templateHash !== "string" || candidate.templateHash === "") return reading;
1485
+ if (typeof candidate.version === "string" && candidate.version) reading.copiedFrom = candidate.version;
1486
+ if (typeof installed.templateHash !== "string" || installed.templateHash === "") return reading;
1487
+ reading.verdict = candidate.templateHash === installed.templateHash ? "current" : "behind";
1488
+ return reading;
1489
+ }
1490
+
1475
1491
  // src/panel/AnalysisView.tsx
1476
1492
  import { useMemo as useMemo3, useState as useState7 } from "react";
1477
1493
 
@@ -1599,6 +1615,9 @@ var UI = {
1599
1615
  needTokenEnables: "Lets Save commit the board back to the repository. Without it everything else works and Save fails.",
1600
1616
  needRepo: "The repository to write to",
1601
1617
  needRepoEnables: "Which repository the board is committed to. There is no default on purpose: writing to the wrong one is worse than not writing.",
1618
+ needGate: "The gate this project copied",
1619
+ needGateEnables: "The sign-in, the session and the write-back are files this project owns, copied once and never updated with the library. When one of them is older than the package, the panel does not look misconfigured \u2014 it looks broken.",
1620
+ fixGate: "The gate is older than the installed kit. See what changed before replacing anything:",
1602
1621
  fixTokenCreate: "On GitHub, make a fine-grained personal access token.",
1603
1622
  fixTokenScope: "Give it Contents: read and write, on this repository only \u2014 nothing else, and no other repository.",
1604
1623
  fixTokenPaste: "Paste it into the hosting project as GH_TOKEN. Never into the repository: a token in a commit is a token to revoke.",
@@ -1920,6 +1939,9 @@ var UI = {
1920
1939
  needTokenEnables: "Permite que Guardar confirme el tablero en el repositorio. Sin \xE9l todo lo dem\xE1s funciona y Guardar falla.",
1921
1940
  needRepo: "El repositorio donde escribir",
1922
1941
  needRepoEnables: "En qu\xE9 repositorio se confirma el tablero. No hay valor por defecto a prop\xF3sito: escribir en el equivocado es peor que no escribir.",
1942
+ needGate: "La puerta que copi\xF3 este proyecto",
1943
+ needGateEnables: "El acceso, la sesi\xF3n y la escritura de vuelta son archivos de este proyecto, copiados una vez y que no se actualizan con la librer\xEDa. Cuando uno se queda m\xE1s viejo que el paquete, el panel no parece mal configurado: parece roto.",
1944
+ fixGate: "La puerta es m\xE1s vieja que el kit instalado. Mira qu\xE9 cambi\xF3 antes de reemplazar nada:",
1923
1945
  fixTokenCreate: "En GitHub, crea un token de acceso personal de tipo fine-grained.",
1924
1946
  fixTokenScope: "Dale Contents: read and write, solo sobre este repositorio \u2014 nada m\xE1s, y ning\xFAn otro repositorio.",
1925
1947
  fixTokenPaste: "P\xE9galo en el proyecto de hosting como GH_TOKEN. Nunca en el repositorio: un token en un commit es un token que hay que revocar.",
@@ -2918,7 +2940,7 @@ function applyMove(cards, move, visible) {
2918
2940
  function resolveTheme(saved, stored, themes, hasImported, fallback) {
2919
2941
  for (const name of [saved, stored]) {
2920
2942
  if (name === "imported" && hasImported) return "imported";
2921
- if (name && name in themes) return name;
2943
+ if (name && Object.prototype.hasOwnProperty.call(themes, name)) return name;
2922
2944
  }
2923
2945
  return fallback ?? Object.keys(themes)[0];
2924
2946
  }
@@ -2977,6 +2999,7 @@ function PanelApp({
2977
2999
  state: initialState,
2978
3000
  themes: exactThemes,
2979
3001
  extraThemes,
3002
+ gate,
2980
3003
  defaultTheme,
2981
3004
  brand,
2982
3005
  decoration,
@@ -2994,6 +3017,7 @@ function PanelApp({
2994
3017
  fingerprint
2995
3018
  }) {
2996
3019
  const themes = useMemo5(() => offeredThemes(exactThemes, extraThemes), [exactThemes, extraThemes]);
3020
+ const gateReading = useMemo5(() => gateStatus(gate, paneltirBuild), [gate]);
2997
3021
  const [themeName, setThemeName] = useState10(
2998
3022
  () => resolveTheme(
2999
3023
  initialState.preferences?.theme,
@@ -3065,9 +3089,42 @@ function PanelApp({
3065
3089
  variable: "PANEL_REPO",
3066
3090
  enables: ui.needRepoEnables,
3067
3091
  fix: /* @__PURE__ */ jsx20("p", { children: ui.fixRepo })
3068
- }
3092
+ },
3093
+ // The gate is the only requirement here that is not an environment
3094
+ // variable: it is files this project owns, and the only one whose
3095
+ // failure is silent. A stale `api/panel-state.ts` does not read as
3096
+ // missing configuration — it reads as the panel being broken, which is
3097
+ // how the HTTP 500 cost a week.
3098
+ //
3099
+ // Drawn only when a stamp was passed. The kit does not invent a
3100
+ // requirement out of the absence of a prop: this project's own panel has
3101
+ // no copied gate — `template/` is the original, held honest by
3102
+ // `test:auth` — so a row here would invite wiring something that cannot
3103
+ // exist. A caller who passes nothing still has `paneltir doctor`, which
3104
+ // is what the skill file sends their Claude to.
3105
+ //
3106
+ // Passed but unreadable is a different thing and *is* reported, as
3107
+ // `unknown` rather than `missing`: telling somebody their gate is old on
3108
+ // the strength of a file we failed to parse is the mistake
3109
+ // `SetupWizard` already refuses to make.
3110
+ ...gate === void 0 ? [] : [{
3111
+ id: "gate",
3112
+ // Information, not a gate: the walkthrough's only action re-reads the
3113
+ // environment, and this is files on disk. See SetupRequirement.
3114
+ advisory: true,
3115
+ label: ui.needGate,
3116
+ status: gateReading.verdict === "current" ? "ok" : gateReading.verdict === "behind" ? "missing" : "unknown",
3117
+ enables: ui.needGateEnables,
3118
+ fix: /* @__PURE__ */ jsxs18(Fragment5, { children: [
3119
+ /* @__PURE__ */ jsxs18("p", { children: [
3120
+ ui.fixGate,
3121
+ gateReading.copiedFrom ? ` (v${gateReading.copiedFrom} \u2192 v${gateReading.installed})` : ""
3122
+ ] }),
3123
+ /* @__PURE__ */ jsx20("pre", { className: "pt-copy__pre", children: "npx paneltir doctor" })
3124
+ ] })
3125
+ }]
3069
3126
  ];
3070
- }, [setup, ui]);
3127
+ }, [setup, ui, gate, gateReading]);
3071
3128
  useEffect9(() => {
3072
3129
  document.documentElement.lang = lang;
3073
3130
  if (showLanguage) storeLang(lang);
@@ -4236,6 +4293,7 @@ export {
4236
4293
  OWNERS,
4237
4294
  PANELTIR_FILE_COUNT,
4238
4295
  PANELTIR_FINGERPRINT,
4296
+ PANELTIR_TEMPLATE_HASH,
4239
4297
  PANELTIR_VERSION,
4240
4298
  PRESET_PANEL_THEMES,
4241
4299
  PanelApp,
@@ -4257,6 +4315,7 @@ export {
4257
4315
  decided,
4258
4316
  emptyCard,
4259
4317
  explainBoard,
4318
+ gateStatus,
4260
4319
  isCard,
4261
4320
  isFirstRun,
4262
4321
  ledgerForm,
package/fingerprint.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "paneltir",
3
- "version": "0.9.0",
4
- "hash": "sha256:f65d491a7350ddce22f6981dbcb91531f5e88fc66b7f9f8f83115d802773064f",
5
- "fileCount": 161,
6
- "generatedAt": "2026-09-10T03:50:24.162Z"
3
+ "version": "0.10.1",
4
+ "hash": "sha256:b2d1daf120972ce3bfc54f3a9f09bccaddd6ceb306a65a1682b810012d85f33d",
5
+ "fileCount": 163,
6
+ "templateHash": "sha256:034ba27266408af4f2ba5793d9cefbe448b168570d6a0b87dfe698dfe639feb1",
7
+ "generatedAt": "2026-09-11T07:08:17.308Z"
7
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paneltir",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "A React UI kit for admin panels: page frame, a board with touch-ready drag and drop, detail sheets, and a whole panel in one component. Structure and behaviour are fixed; colour and brand belong to each project.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "DFKlabs (https://DFKlabs.com)",
@@ -64,7 +64,7 @@
64
64
  "check:boards": "node scripts/check-boards.mjs",
65
65
  "demo:install": "npm --prefix examples/demo ci",
66
66
  "demo:build": "npm --prefix examples/demo run build",
67
- "verify": "npm run typecheck && npm run build && npm run icons:check && npm run seo:check && npm run test:auth && npm run test:marketplace && npm run test:init && npm run test:board && npm run test:move && npm run test:theme && npm run test:setup && npm run test:updates && npm run release-notes:check && npm run check:boards && npm run check:docs && npm run check:package && npm run fingerprint:check",
67
+ "verify": "npm run typecheck && npm run build && npm run icons:check && npm run seo:check && npm run test:auth && npm run test:marketplace && npm run test:init && npm run test:board && npm run test:gate && npm run test:move && npm run test:theme && npm run test:setup && npm run test:updates && npm run release-notes:check && npm run check:boards && npm run check:docs && npm run check:package && npm run fingerprint:check",
68
68
  "site:install": "npm --prefix site ci",
69
69
  "site:dev": "npm --prefix site run dev",
70
70
  "site:build": "npm --prefix site run build",
@@ -78,7 +78,8 @@
78
78
  "test:board": "node scripts/test-board.mjs",
79
79
  "test:move": "node scripts/test-move.mjs",
80
80
  "test:theme": "node scripts/test-theme.mjs",
81
- "test:setup": "node scripts/test-setup.mjs"
81
+ "test:setup": "node scripts/test-setup.mjs",
82
+ "test:gate": "node scripts/test-gate.mjs"
82
83
  },
83
84
  "peerDependencies": {
84
85
  "react": ">=18",
@@ -98,35 +99,56 @@
98
99
  "node": ">=20"
99
100
  },
100
101
  "paneltirRelease": {
101
- "version": "0.9.0",
102
- "date": "2026-09-10",
102
+ "version": "0.10.1",
103
+ "date": "2026-09-11",
103
104
  "entries": [
104
105
  {
105
- "kind": "feature",
106
+ "kind": "fix",
107
+ "text": {
108
+ "en": "Re-running `paneltir init` no longer tells the panel a stale gate is current. It stamped the new template over files it had not written, so a project that updated and re-ran init was told its gate was up to date when it was not.",
109
+ "es": "Re-ejecutar `paneltir init` ya no le dice al panel que una puerta vieja está al día. Sellaba la plantilla nueva sobre archivos que no había escrito, así que un proyecto que actualizaba y re-ejecutaba init recibía un «todo correcto» que no era cierto."
110
+ }
111
+ },
112
+ {
113
+ "kind": "fix",
114
+ "text": {
115
+ "en": "The save endpoint's own board check had drifted to a shallow one that accepted a board with no areas, an unknown intent, an unknown area and any version at all. All three copies of that check are now held to the same verdict.",
116
+ "es": "La comprobación del tablero en el endpoint de guardado había derivado a una superficial que aceptaba un tablero sin áreas, un intent desconocido, un área desconocida y cualquier versión. Las tres copias de esa comprobación se sujetan ahora al mismo veredicto."
117
+ }
118
+ },
119
+ {
120
+ "kind": "fix",
121
+ "text": {
122
+ "en": "`paneltir run` appends to the end of the revisions log instead of the front, so a new pass reads as the newest and not the oldest — and accepts `--cards a,b` and `--by you`, the spaced form its own usage line prints.",
123
+ "es": "`paneltir run` añade al final del registro de revisiones en vez de al principio, así una pasada nueva se lee como la más reciente y no como la más antigua — y acepta `--cards a,b` y `--by you`, la forma con espacio que imprime su propio uso."
124
+ }
125
+ },
126
+ {
127
+ "kind": "fix",
106
128
  "text": {
107
- "en": "A project's own theme can be offered alongside the kit's four instead of replacing them. Pass it as `extraThemes`; `themes` still means \"exactly these\".",
108
- "es": "El tema propio de un proyecto puede ofrecerse junto a los cuatro del kit en vez de sustituirlos. Pásalo como `extraThemes`; `themes` sigue significando «exactamente estos»."
129
+ "en": "A board naming a theme that only exists on Object's prototype `constructor`, `toString` no longer resolves as a theme and draws the panel in the browser's default colours.",
130
+ "es": "Un tablero que nombra un tema que sólo existe en el prototipo de Object `constructor`, `toString` ya no se resuelve como tema ni dibuja el panel con los colores por defecto del navegador."
109
131
  }
110
132
  },
111
133
  {
112
134
  "kind": "fix",
113
135
  "text": {
114
- "en": "A panel that passed one theme of its own showed a settings sheet with a single row: the four presets were silently gone. They are back, and the project's own identity is what the panel opens in.",
115
- "es": "Un panel que pasaba un tema propio mostraba un solo tema en los ajustes: los cuatro presets desaparecían sin decir nada. Han vuelto, y el panel abre con la identidad del proyecto."
136
+ "en": "Saving no longer breaks permanently once a board reaches two hundred revisions. The limit was refused on the write path and applied nowhere; it is bounded where the log is appended instead.",
137
+ "es": "Guardar ya no se rompe para siempre cuando un tablero llega a doscientas revisiones. El límite se rechazaba al escribir y no se aplicaba en ningún sitio; ahora se acota donde se añade al registro."
116
138
  }
117
139
  },
118
140
  {
119
141
  "kind": "fix",
120
142
  "text": {
121
- "en": "A theme derived from a project is drawn in the kit's own default shape rather than borrowing the shape of an unrelated theme.",
122
- "es": "Un tema derivado de un proyecto se dibuja con la forma por defecto del kit, en vez de tomar prestada la de un tema que no tiene nada que ver."
143
+ "en": "The copied gate's row in the setup list no longer parks the walkthrough on a step its one action cannot clear, nor makes every other step report that the panel could not reach its own server.",
144
+ "es": "La fila de la puerta copiada en la lista de configuración ya no deja el asistente atascado en un paso que su única acción no puede resolver, ni hace que el resto de pasos digan que el panel no pudo alcanzar su propio servidor."
123
145
  }
124
146
  },
125
147
  {
126
148
  "kind": "chore",
127
149
  "text": {
128
- "en": "The install instructions name every theme the kit ships. They named three of four, so the only light one was never offered to a project and a check now refuses to let that happen again.",
129
- "es": "Las instrucciones de instalación nombran todos los temas que trae el kit. Nombraban tres de cuatro, así que el único claro no se ofrecía nunca y ahora una comprobación impide que vuelva a pasar."
150
+ "en": "Two checks that could stop checking were closed: a travelling document that cannot be read is now a failure rather than a pass, and no gate file may read the environment at module scope where no error can be caught.",
151
+ "es": "Se cerraron dos comprobaciones que podían dejar de comprobar: un documento que viaja y no se puede leer ahora falla en vez de pasar, y ningún archivo de la puerta puede leer el entorno a nivel de módulo, donde ningún error se puede capturar."
130
152
  }
131
153
  }
132
154
  ]
@@ -132,6 +132,35 @@ Never invent a palette. If the project has no colours of its own, say so and
132
132
  leave the theme alone — the kit ships presets for exactly that case, and one
133
133
  of them is a light theme.
134
134
 
135
+ And when you hand this project's own theme to `PanelApp`, it goes in
136
+ **`extraThemes`** and never in `themes`. `extraThemes` offers it as well as the
137
+ kit's four; `themes` means "exactly these", so one theme passed there deletes
138
+ every preset and the settings sheet shows a single row. Two projects were
139
+ installed that way before the distinction existed, and the symptom reads as the
140
+ kit having no themes rather than as a prop being wrong.
141
+
142
+ ## When the panel behaves oddly for no visible reason
143
+
144
+ The gate — `middleware.ts`, `lib/session.ts`, `api/*.ts` — was copied into this
145
+ project once and does **not** update when the kit does. So a fix released
146
+ upstream can be missing here while every version number on the screen looks
147
+ current, and nothing says so: the `HTTP 500` on saving lived in
148
+ `api/panel-state.ts` and stayed in projects installed before the fix.
149
+
150
+ npx paneltir doctor
151
+
152
+ `.paneltir/gate.json` is what makes that answer sharp, and it belongs in git:
153
+ it records what `init` copied and from where. Ignored, it vanishes on the next
154
+ clone and every file degrades to "differs" — the one answer that cannot say
155
+ whether you moved it or we did.
156
+
157
+ It separates the three answers that need different actions: *yours* (you edited
158
+ it), *BEHIND* (you did not touch it and the template moved), and *BOTH*. The
159
+ middle one is invisible to `git diff`, because there is nothing in this
160
+ project's history to compare against. `paneltir init --force` replaces the gate
161
+ and never the board — read the diff first when a file is marked BOTH, or the
162
+ edit is lost.
163
+
135
164
  ## Setting the panel up in the first place
136
165
 
137
166
  The panel is asked for this by name: its walkthrough carries a line the owner
@@ -121,8 +121,22 @@ function readCookie(header: string | null | undefined, name: string): string | u
121
121
  * Where the board lives in this repository. Set PANEL_FILE to override; the
122
122
  * default is only a guess at a sensible place, and a wrong path here fails
123
123
  * loudly on the first save rather than quietly writing somewhere else.
124
+ *
125
+ * A function, and not a constant, for the reason `env()` above is wrapped: a
126
+ * bare `process.env.X` is a ReferenceError wherever `process` is undefined,
127
+ * and at module scope it runs *before* the handler's `try` — so it cannot be
128
+ * caught and the platform answers `FUNCTION_INVOCATION_FAILED`, a code that
129
+ * names the shape of the fault and never the fault. That is the exact failure
130
+ * this file has already had twice, from an import and then from a throw ahead
131
+ * of the wrapper. This was the last module-scope read left in any gate file.
124
132
  */
125
- const FILE_PATH = process.env.PANEL_FILE || 'src/data/panel-state.json'
133
+ function filePath(): string {
134
+ try {
135
+ return process.env.PANEL_FILE || 'src/data/panel-state.json'
136
+ } catch {
137
+ return 'src/data/panel-state.json'
138
+ }
139
+ }
126
140
  const MAX_BODY_BYTES = 512 * 1024
127
141
 
128
142
  interface VercelRequest {
@@ -277,6 +291,12 @@ function invalidState(state: unknown): string | null {
277
291
  }
278
292
 
279
293
  if (!Array.isArray(state.runs)) say('runs', 'must be an array')
294
+ // Deliberately no cap. The shape is checked here; how long a log may get is
295
+ // not a shape, and refusing a board for having too much history is a panel
296
+ // that stops saving one day and never says why. This copy used to refuse
297
+ // over 200 runs while nothing anywhere trimmed them, so the only outcome was
298
+ // a Save that broke permanently on the two-hundredth pass. Bounded on write
299
+ // instead, where a bound can be applied rather than only enforced.
280
300
 
281
301
  return problems.length ? problems.join('; ') : null
282
302
  }
@@ -302,7 +322,7 @@ function configuration() {
302
322
  // is how a misconfigured target gets noticed before it is written to.
303
323
  repo: repo ?? null,
304
324
  branch: process.env.PANEL_BRANCH || 'main',
305
- file: FILE_PATH,
325
+ file: filePath(),
306
326
  }
307
327
  }
308
328
 
@@ -373,7 +393,7 @@ async function handleRequest(request: VercelRequest, response: VercelResponse) {
373
393
  ? payload.message.trim().slice(0, 120)
374
394
  : 'chore(panel): update the board from the panel'
375
395
 
376
- const api = `https://api.github.com/repos/${repo}/contents/${FILE_PATH}`
396
+ const api = `https://api.github.com/repos/${repo}/contents/${filePath()}`
377
397
  const githubHeaders = {
378
398
  authorization: `Bearer ${token}`,
379
399
  accept: 'application/vnd.github+json',