paneltir 0.9.0 → 0.10.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.
@@ -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.0
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.0 # 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.0
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,43 @@ 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
+ const stamp = {
134
+ version: pkg.version,
135
+ templateHash: fingerprint.templateHash ?? '',
136
+ copiedAt: new Date().toISOString(),
137
+ files,
138
+ }
139
+ try {
140
+ mkdirSync(dirname(join(target, STAMP)), { recursive: true })
141
+ writeFileSync(join(target, STAMP), JSON.stringify(stamp, null, 2) + '\n')
142
+ } catch (error) {
143
+ // Never fatal. The gate is copied and working; the stamp only makes a
144
+ // later report sharper, and failing the install over bookkeeping would
145
+ // trade something that matters for something that does not.
146
+ console.error(`\npaneltir: could not write ${STAMP} (${error.message}).`)
147
+ console.error('The gate is in place; `paneltir doctor` will have less to go on.')
148
+ }
149
+ }
150
+
113
151
  function reportSurvey(found) {
114
152
  const lines = []
115
153
 
@@ -149,6 +187,36 @@ Remove it once the board is moved over, or the project ships both.`)
149
187
  /** The board. Never replaced once it exists — see the loop below. */
150
188
  const BOARD_SEED = 'src/data/panel-state.json'
151
189
 
190
+ /**
191
+ * Where `init` records what it copied and what it copied it from.
192
+ *
193
+ * The gate is the one part of this package that leaves as files a project
194
+ * keeps, and a kept file can never be told it is old. That has been paid for
195
+ * twice: the `HTTP 500` fix landed in `template/api/panel-state.ts`, so every
196
+ * project installed before it still has a save that cannot work, and the skill
197
+ * file is copied the same way, so a rule learned upstream never reaches the
198
+ * Claude that reads it.
199
+ *
200
+ * A plain diff cannot close that, because it cannot tell "you edited this"
201
+ * from "we changed this underneath you" — and those need opposite actions.
202
+ * Hashing each file as written is what separates them.
203
+ */
204
+ const STAMP = '.paneltir/gate.json'
205
+
206
+ function sha256(path) {
207
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
208
+ }
209
+
210
+ function readStamp(target) {
211
+ try {
212
+ return JSON.parse(readFileSync(join(target, STAMP), 'utf8'))
213
+ } catch {
214
+ // Absent or unreadable is not a fault: every project installed before
215
+ // stamping existed is here, and `doctor` still compares the files.
216
+ return null
217
+ }
218
+ }
219
+
152
220
  function init(args) {
153
221
  const force = args.includes('--force')
154
222
  const target = process.cwd()
@@ -180,6 +248,8 @@ function init(args) {
180
248
  written.push(file)
181
249
  }
182
250
 
251
+ writeStamp(target, files, written);
252
+
183
253
  console.log(`paneltir ${pkg.version} — the panel's gate, into ${target}\n`)
184
254
 
185
255
  const notes = reportSurvey(survey(target))
@@ -299,7 +369,7 @@ function check(args) {
299
369
  * neither helps the person who has just edited the file by hand and wants to
300
370
  * know before they push. This is that answer, from the same check.
301
371
  */
302
- function board(args) {
372
+ async function board(args) {
303
373
  const file = args.find((a) => !a.startsWith('-')) || process.env.PANEL_FILE || 'src/data/panel-state.json'
304
374
  const path = resolve(process.cwd(), file)
305
375
 
@@ -309,7 +379,7 @@ function board(args) {
309
379
  process.exit(1)
310
380
  }
311
381
 
312
- const check = readBoard(readFileSync(path, 'utf8'))
382
+ const check = (await boardReader())(readFileSync(path, 'utf8'))
313
383
  if (check.ok) {
314
384
  const cards = check.board.cards.length
315
385
  console.log(`ok ${file} — ${cards} card${cards === 1 ? '' : 's'}, ${check.board.columns.length} columns`)
@@ -328,7 +398,205 @@ function board(args) {
328
398
  process.exit(1)
329
399
  }
330
400
 
331
- const { readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href)
401
+
402
+ /**
403
+ * Says whether the files this project copied are still the files it shipped.
404
+ *
405
+ * The three answers it exists to tell apart:
406
+ *
407
+ * - **yours** — you edited it. Nothing to do; the gate is meant to be edited,
408
+ * and this is only here so the next line is believable.
409
+ * - **behind** — you did not touch it and the template moved. This is the one
410
+ * that has cost real money twice, and the one a bare `git diff` cannot see
411
+ * at all, because there is nothing in the project's history to diff
412
+ * against.
413
+ * - **both** — you edited it *and* the template moved. The only case that
414
+ * needs a person: `--force` would throw your edit away.
415
+ *
416
+ * It reports and never repairs, for the same reason the panel records a request
417
+ * instead of running it: replacing somebody's front door is a decision, and
418
+ * `init --force` is where a person makes it having read what changed.
419
+ */
420
+ function doctor() {
421
+ const target = process.cwd()
422
+ const templateDir = join(packageRoot, 'template')
423
+ if (!existsSync(templateDir)) {
424
+ console.error('paneltir: this install has no template/ directory.')
425
+ process.exit(1)
426
+ }
427
+
428
+ const stamp = readStamp(target)
429
+ const files = walk(templateDir).sort()
430
+
431
+ console.log(`paneltir ${pkg.version} — the gate in ${target}\n`)
432
+
433
+ if (!stamp) {
434
+ console.log(`No ${STAMP}, so this gate was copied before stamping existed.`)
435
+ console.log('Files can still be compared; "you edited it" and "we changed it"')
436
+ console.log('cannot be told apart until the next `init` writes one.\n')
437
+ } else {
438
+ const behind = stamp.templateHash !== (fingerprint.templateHash ?? '')
439
+ console.log(`Copied from v${stamp.version} on ${String(stamp.copiedAt).slice(0, 10)}`)
440
+ console.log(behind ? 'The template has changed since.\n' : 'The template has not changed since.\n')
441
+ }
442
+
443
+ const rows = []
444
+ for (const file of files) {
445
+ // The board is meant to diverge: it stops being a template on the first
446
+ // card. Calling that drift would put a warning on every working project.
447
+ if (file === BOARD_SEED) {
448
+ rows.push([file, existsSync(join(target, file)) ? 'yours' : 'missing', 'the board — never compared'])
449
+ continue
450
+ }
451
+
452
+ const here = join(target, file)
453
+ if (!existsSync(here)) {
454
+ rows.push([file, 'missing', 'never copied; `paneltir init` writes it'])
455
+ continue
456
+ }
457
+
458
+ const mine = sha256(here)
459
+ const theirs = sha256(join(templateDir, file))
460
+ const stamped = stamp?.files?.[file]
461
+
462
+ if (mine === theirs) {
463
+ rows.push([file, 'current', ''])
464
+ } else if (!stamped) {
465
+ rows.push([file, 'differs', 'no stamp, so who moved cannot be said'])
466
+ } else if (mine === stamped) {
467
+ rows.push([file, 'BEHIND', 'you did not touch it; the template moved'])
468
+ } else if (stamped === theirs) {
469
+ rows.push([file, 'yours', 'you edited it; the template has not moved'])
470
+ } else {
471
+ rows.push([file, 'BOTH', 'you edited it AND the template moved — read both'])
472
+ }
473
+ }
474
+
475
+ const width = Math.max(...rows.map(([file]) => file.length))
476
+ for (const [file, state, note] of rows) {
477
+ console.log(` ${state.padEnd(8)} ${file.padEnd(width)}${note ? ` ${note}` : ''}`)
478
+ }
479
+
480
+ const stale = rows.filter(([, state]) => state === 'BEHIND' || state === 'BOTH')
481
+ const missing = rows.filter(([, state]) => state === 'missing')
482
+
483
+ if (!stale.length && !missing.length) {
484
+ console.log('\nNothing to do.')
485
+ return
486
+ }
487
+
488
+ if (stale.length) {
489
+ console.log(`\n${stale.length} file(s) are older than the package they came from.`)
490
+ console.log('See what changed, then decide:')
491
+ console.log(` diff -u <file> node_modules/paneltir/template/<file>`)
492
+ console.log('`paneltir init --force` replaces the gate and never the board.')
493
+ console.log('Anything marked BOTH would lose your edit — read that diff first.')
494
+ }
495
+ if (missing.length) {
496
+ console.log(`\n${missing.length} file(s) were never copied. \`paneltir init\` writes them.`)
497
+ }
498
+ // A gate that cannot work is a failure, not a remark: this runs in CI for
499
+ // some projects, and an exit code is the only part of it a script reads.
500
+ process.exit(1)
501
+ }
502
+
503
+ /**
504
+ * Appends a run to the board, so the record does not depend on remembering.
505
+ *
506
+ * `runs` is what each pass actually did, and until now it was written by hand
507
+ * — by Claude, into JSON, at the end of a session, from memory. That is the
508
+ * shape of record that is complete right up until the session that mattered.
509
+ * A command can be called the moment the work lands.
510
+ *
511
+ * It writes the file and nothing else: the board is committed by the panel's
512
+ * Save or by the project's own commit, and a CLI that pushed on somebody's
513
+ * behalf would be the second thing in this project writing to their
514
+ * repository.
515
+ */
516
+ async function run(args) {
517
+ const flags = args.filter((a) => a.startsWith('--'))
518
+ const plain = args.filter((a) => !a.startsWith('--'))
519
+ const summary = plain[0]
520
+ const file = plain[1] ?? BOARD_SEED
521
+
522
+ if (!summary) {
523
+ console.error('paneltir run "what this pass did" [--cards a,b] [--by you] [board.json]')
524
+ process.exit(1)
525
+ }
526
+
527
+ const path = resolve(process.cwd(), file)
528
+ if (!existsSync(path)) {
529
+ console.error(`paneltir: no board at ${file}`)
530
+ process.exit(1)
531
+ }
532
+
533
+ const text = readFileSync(path, 'utf8')
534
+ const check = (await boardReader())(text)
535
+ if (!check.ok) {
536
+ // Refused before writing, not after: appending to a board that is already
537
+ // broken buries the fault under a change nobody asked for.
538
+ console.error(`paneltir: ${file} is not a board this kit can read, so nothing was appended.\n`)
539
+ for (const problem of check.problems) {
540
+ console.error(problem.at ? ` ${problem.at}\n ${problem.says}` : ` ${problem.says}`)
541
+ }
542
+ process.exit(1)
543
+ }
544
+
545
+ const board = check.board
546
+ const cards = (flags.find((f) => f.startsWith('--cards='))?.slice(8) ?? '')
547
+ .split(',')
548
+ .map((id) => id.trim())
549
+ .filter(Boolean)
550
+
551
+ // A run naming a card the board does not have is a claim that cannot be
552
+ // checked, which is the only thing `cards` was for.
553
+ const known = new Set(board.cards.map((card) => card.id))
554
+ const unknown = cards.filter((id) => !known.has(id))
555
+ if (unknown.length) {
556
+ console.error(`paneltir: ${file} has no card called ${unknown.join(', ')}.`)
557
+ process.exit(1)
558
+ }
559
+
560
+ const by = flags.find((f) => f.startsWith('--by='))?.slice(5) === 'you' ? 'you' : 'claude'
561
+ const date = new Date().toISOString().slice(0, 10)
562
+ board.runs = Array.isArray(board.runs) ? board.runs : []
563
+ board.runs.unshift({
564
+ id: `run-${date}-${String(board.runs.length + 1).padStart(3, '0')}`,
565
+ date,
566
+ by,
567
+ summary,
568
+ cards,
569
+ fingerprint: shortHash,
570
+ })
571
+
572
+ writeFileSync(path, JSON.stringify(board, null, 2) + '\n')
573
+ console.log(`Appended to ${file}: ${summary}`)
574
+ console.log(` ${cards.length ? `cards ${cards.join(', ')}` : 'no cards named'} · by ${by} · ${shortHash}`)
575
+ console.log('\nThe file is changed and not committed. The panel commits it on Save,')
576
+ console.log('or commit it yourself — this writes nothing to your repository.')
577
+ }
578
+
579
+ /**
580
+ * The board reader, loaded only by the commands that read a board.
581
+ *
582
+ * It used to be imported at module scope, which meant every command pulled in
583
+ * `dist/index.js` — and that bundle imports React, because it is a component
584
+ * library. So `paneltir init`, whose entire job is to copy seven files, and
585
+ * `paneltir doctor`, whose job is to hash them, both failed outright in a
586
+ * project that had not installed React yet. Which is exactly when somebody runs
587
+ * `init`.
588
+ *
589
+ * It is the same rule as the gate's, one layer up: a thing whose job is files
590
+ * does not carry a component library to do it. Here the fix is smaller than
591
+ * duplicating anything — the import just moves to where it is used.
592
+ */
593
+ let readBoard
594
+ async function boardReader() {
595
+ if (!readBoard) {
596
+ ;({ readBoard } = await import(pathToFileURL(join(here, '../dist/index.js')).href))
597
+ }
598
+ return readBoard
599
+ }
332
600
 
333
601
  const [command, ...rest] = process.argv.slice(2)
334
602
 
@@ -343,7 +611,13 @@ switch (command) {
343
611
  check(rest)
344
612
  break
345
613
  case 'board':
346
- board(rest)
614
+ await board(rest)
615
+ break
616
+ case 'doctor':
617
+ doctor()
618
+ break
619
+ case 'run':
620
+ await run(rest)
347
621
  break
348
622
  default:
349
623
  console.log(`paneltir v${pkg.version} · ${shortHash}
@@ -352,6 +626,8 @@ switch (command) {
352
626
  paneltir version what is installed, and where it came from
353
627
  paneltir check [version] fail if the installed version is not the one meant
354
628
  paneltir board [file] read this project's board and say what is wrong
629
+ paneltir doctor is the gate you copied still the gate we ship?
630
+ paneltir run "summary" append what this pass did to the board's runs
355
631
 
356
632
  The components install as a library and are never edited. The gate — password,
357
633
  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. */
@@ -1361,6 +1374,9 @@ interface UiStrings {
1361
1374
  needTokenEnables: string;
1362
1375
  needRepo: string;
1363
1376
  needRepoEnables: string;
1377
+ needGate: string;
1378
+ needGateEnables: string;
1379
+ fixGate: string;
1364
1380
  fixTokenCreate: string;
1365
1381
  fixTokenScope: string;
1366
1382
  fixTokenPaste: string;
@@ -1591,6 +1607,21 @@ interface PanelAppProps {
1591
1607
  * collides with a preset replaces it, because the caller named it.
1592
1608
  */
1593
1609
  extraThemes?: PanelThemes;
1610
+ /**
1611
+ * `.paneltir/gate.json` from this project, if the caller imports it.
1612
+ *
1613
+ * The gate is copied once and then belongs to the project, which means it is
1614
+ * the one part of this package that can never be told it is old — and that
1615
+ * has cost two releases already. Handing the stamp in lets the panel say so,
1616
+ * with no filesystem and no network, because both halves of the comparison
1617
+ * are already data: the stamp from the project and `templateHash` from the
1618
+ * installed package.
1619
+ *
1620
+ * Omitted, the panel says nothing about the gate rather than guessing. This
1621
+ * is a prop and not a fetch for the reason the Tools screen is a prop in the
1622
+ * demo: the kit does not decide what a caller's server can be asked.
1623
+ */
1624
+ gate?: unknown;
1594
1625
  /** Which one to draw before the board has said. Defaults to the first. */
1595
1626
  defaultTheme?: string;
1596
1627
  /**
@@ -1657,7 +1688,94 @@ interface PanelAppProps {
1657
1688
  fileCount: number;
1658
1689
  };
1659
1690
  }
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;
1691
+ 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;
1692
+
1693
+ /**
1694
+ * Whether the gate a project is running is the gate this version ships.
1695
+ *
1696
+ * `template/` is the one thing here that leaves as *files a project keeps*.
1697
+ * The components arrive in `node_modules` and are replaced on every update;
1698
+ * the gate is copied once by `init` and is then that project's own — which is
1699
+ * the point, since it holds their password rule and points at their
1700
+ * repository, and a front door that moves when a library updates is not a
1701
+ * front door.
1702
+ *
1703
+ * The cost of that is a copy that can never be told it is old. It has already
1704
+ * been paid twice. The `HTTP 500` fix landed in `template/api/panel-state.ts`,
1705
+ * so every project installed before it still has a save that cannot work, and
1706
+ * nothing anywhere says so; `.claude/skills/panel/SKILL.md` is copied the same
1707
+ * way, so a rule learned upstream never reaches the Claude that reads it.
1708
+ * Upstream the copy is held honest by `test:auth` and `test:board`. Downstream
1709
+ * there was nothing at all.
1710
+ *
1711
+ * So `init` records what it copied and what it copied it from, and this says
1712
+ * what that record means. Two rules shape it:
1713
+ *
1714
+ * - **It compares hashes, not version numbers.** Most releases do not touch
1715
+ * `template/`, so "you are on 0.6.0 and 0.9.0 exists" would cry wolf for
1716
+ * every release and be ignored by the one that mattered. `templateHash`
1717
+ * ships inside `fingerprint.json`, so the comparison is exact.
1718
+ * - **It needs no filesystem and no network**, because the panel runs in a
1719
+ * browser and has neither. Everything it judges is data the caller already
1720
+ * holds: the stamp from the project, the hash from the installed package.
1721
+ *
1722
+ * It reports and never repairs. Replacing somebody's gate is `init --force`,
1723
+ * typed by a person who has read what changed.
1724
+ */
1725
+ /**
1726
+ * What `init` wrote, and what it wrote it from.
1727
+ *
1728
+ * Lives at `.paneltir/gate.json` in the project. It is not a lockfile and
1729
+ * nothing resolves against it — it exists so a later session can tell "you
1730
+ * edited this" from "we changed this underneath you", which is the distinction
1731
+ * a bare diff cannot make.
1732
+ */
1733
+ interface GateStamp {
1734
+ /** The kit version that copied these files. */
1735
+ version: string;
1736
+ /** `templateHash` from that version's `fingerprint.json`. */
1737
+ templateHash: string;
1738
+ /** When, so a reader can date it against their own git history. */
1739
+ copiedAt: string;
1740
+ /**
1741
+ * SHA-256 of each file as written, keyed by its path in the project.
1742
+ *
1743
+ * This is what separates a file the project edited from one the template
1744
+ * moved under. Absent for a file `init` left alone: we did not write it, so
1745
+ * we cannot claim to know where it came from.
1746
+ */
1747
+ files: Record<string, string>;
1748
+ }
1749
+ type GateVerdict =
1750
+ /** The gate is the one this version ships. */
1751
+ 'current'
1752
+ /** `template/` has changed since this gate was copied. */
1753
+ | 'behind'
1754
+ /**
1755
+ * No stamp. Every project installed before stamping existed is here, and it
1756
+ * is not a fault — it is the absence of an answer, which is why it is not
1757
+ * called one. `paneltir doctor` can still compare the files themselves.
1758
+ */
1759
+ | 'unstamped';
1760
+ interface GateReading {
1761
+ verdict: GateVerdict;
1762
+ /** The version that copied the gate, when a stamp says so. */
1763
+ copiedFrom?: string;
1764
+ /** The version running now. */
1765
+ installed: string;
1766
+ }
1767
+ /**
1768
+ * Reads a stamp against the installed package.
1769
+ *
1770
+ * Deliberately total: a malformed or absent stamp answers `unstamped` rather
1771
+ * than throwing. This is called while a panel is drawing, and a panel that
1772
+ * fails to open because it could not classify its own gate would be a worse
1773
+ * outcome than the drift it was looking for.
1774
+ */
1775
+ declare function gateStatus(stamp: unknown, installed: {
1776
+ version: string;
1777
+ templateHash?: string;
1778
+ }): GateReading;
1661
1779
 
1662
1780
  /**
1663
1781
  * Applies a board move to the flat card list.
@@ -1717,4 +1835,4 @@ interface CardSheetProps {
1717
1835
  }
1718
1836
  declare function CardSheet({ card, state, lang, ui, onChange, onDelete, onClose }: CardSheetProps): React.JSX.Element | null;
1719
1837
 
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 };
1838
+ 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.0";
3
+ var PANELTIR_FINGERPRINT = "sha256:4de7ef7076563e4c3118ac16b645737a44c4c0675115d9a9d7bbff562d5ecb74";
4
+ var PANELTIR_FILE_COUNT = 163;
5
+ var PANELTIR_TEMPLATE_HASH = "sha256:7f9accd11920a0c189dd683bdb96635a606960afeca71e02c2a26a0257acefd8";
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. */
@@ -1472,6 +1475,18 @@ function offeredThemes(themes, extraThemes) {
1472
1475
  return offered;
1473
1476
  }
1474
1477
 
1478
+ // src/gate/stamp.ts
1479
+ function gateStatus(stamp, installed) {
1480
+ const reading = { verdict: "unstamped", installed: installed.version };
1481
+ if (typeof stamp !== "object" || stamp === null) return reading;
1482
+ const candidate = stamp;
1483
+ if (typeof candidate.templateHash !== "string" || candidate.templateHash === "") return reading;
1484
+ if (typeof candidate.version === "string" && candidate.version) reading.copiedFrom = candidate.version;
1485
+ if (typeof installed.templateHash !== "string" || installed.templateHash === "") return reading;
1486
+ reading.verdict = candidate.templateHash === installed.templateHash ? "current" : "behind";
1487
+ return reading;
1488
+ }
1489
+
1475
1490
  // src/panel/AnalysisView.tsx
1476
1491
  import { useMemo as useMemo3, useState as useState7 } from "react";
1477
1492
 
@@ -1599,6 +1614,9 @@ var UI = {
1599
1614
  needTokenEnables: "Lets Save commit the board back to the repository. Without it everything else works and Save fails.",
1600
1615
  needRepo: "The repository to write to",
1601
1616
  needRepoEnables: "Which repository the board is committed to. There is no default on purpose: writing to the wrong one is worse than not writing.",
1617
+ needGate: "The gate this project copied",
1618
+ 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.",
1619
+ fixGate: "The gate is older than the installed kit. See what changed before replacing anything:",
1602
1620
  fixTokenCreate: "On GitHub, make a fine-grained personal access token.",
1603
1621
  fixTokenScope: "Give it Contents: read and write, on this repository only \u2014 nothing else, and no other repository.",
1604
1622
  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 +1938,9 @@ var UI = {
1920
1938
  needTokenEnables: "Permite que Guardar confirme el tablero en el repositorio. Sin \xE9l todo lo dem\xE1s funciona y Guardar falla.",
1921
1939
  needRepo: "El repositorio donde escribir",
1922
1940
  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.",
1941
+ needGate: "La puerta que copi\xF3 este proyecto",
1942
+ 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.",
1943
+ fixGate: "La puerta es m\xE1s vieja que el kit instalado. Mira qu\xE9 cambi\xF3 antes de reemplazar nada:",
1923
1944
  fixTokenCreate: "En GitHub, crea un token de acceso personal de tipo fine-grained.",
1924
1945
  fixTokenScope: "Dale Contents: read and write, solo sobre este repositorio \u2014 nada m\xE1s, y ning\xFAn otro repositorio.",
1925
1946
  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.",
@@ -2977,6 +2998,7 @@ function PanelApp({
2977
2998
  state: initialState,
2978
2999
  themes: exactThemes,
2979
3000
  extraThemes,
3001
+ gate,
2980
3002
  defaultTheme,
2981
3003
  brand,
2982
3004
  decoration,
@@ -2994,6 +3016,7 @@ function PanelApp({
2994
3016
  fingerprint
2995
3017
  }) {
2996
3018
  const themes = useMemo5(() => offeredThemes(exactThemes, extraThemes), [exactThemes, extraThemes]);
3019
+ const gateReading = useMemo5(() => gateStatus(gate, paneltirBuild), [gate]);
2997
3020
  const [themeName, setThemeName] = useState10(
2998
3021
  () => resolveTheme(
2999
3022
  initialState.preferences?.theme,
@@ -3065,9 +3088,39 @@ function PanelApp({
3065
3088
  variable: "PANEL_REPO",
3066
3089
  enables: ui.needRepoEnables,
3067
3090
  fix: /* @__PURE__ */ jsx20("p", { children: ui.fixRepo })
3068
- }
3091
+ },
3092
+ // The gate is the only requirement here that is not an environment
3093
+ // variable: it is files this project owns, and the only one whose
3094
+ // failure is silent. A stale `api/panel-state.ts` does not read as
3095
+ // missing configuration — it reads as the panel being broken, which is
3096
+ // how the HTTP 500 cost a week.
3097
+ //
3098
+ // Drawn only when a stamp was passed. The kit does not invent a
3099
+ // requirement out of the absence of a prop: this project's own panel has
3100
+ // no copied gate — `template/` is the original, held honest by
3101
+ // `test:auth` — so a row here would invite wiring something that cannot
3102
+ // exist. A caller who passes nothing still has `paneltir doctor`, which
3103
+ // is what the skill file sends their Claude to.
3104
+ //
3105
+ // Passed but unreadable is a different thing and *is* reported, as
3106
+ // `unknown` rather than `missing`: telling somebody their gate is old on
3107
+ // the strength of a file we failed to parse is the mistake
3108
+ // `SetupWizard` already refuses to make.
3109
+ ...gate === void 0 ? [] : [{
3110
+ id: "gate",
3111
+ label: ui.needGate,
3112
+ status: gateReading.verdict === "current" ? "ok" : gateReading.verdict === "behind" ? "missing" : "unknown",
3113
+ enables: ui.needGateEnables,
3114
+ fix: /* @__PURE__ */ jsxs18(Fragment5, { children: [
3115
+ /* @__PURE__ */ jsxs18("p", { children: [
3116
+ ui.fixGate,
3117
+ gateReading.copiedFrom ? ` (v${gateReading.copiedFrom} \u2192 v${gateReading.installed})` : ""
3118
+ ] }),
3119
+ /* @__PURE__ */ jsx20("pre", { className: "pt-copy__pre", children: "npx paneltir doctor" })
3120
+ ] })
3121
+ }]
3069
3122
  ];
3070
- }, [setup, ui]);
3123
+ }, [setup, ui, gate, gateReading]);
3071
3124
  useEffect9(() => {
3072
3125
  document.documentElement.lang = lang;
3073
3126
  if (showLanguage) storeLang(lang);
@@ -4236,6 +4289,7 @@ export {
4236
4289
  OWNERS,
4237
4290
  PANELTIR_FILE_COUNT,
4238
4291
  PANELTIR_FINGERPRINT,
4292
+ PANELTIR_TEMPLATE_HASH,
4239
4293
  PANELTIR_VERSION,
4240
4294
  PRESET_PANEL_THEMES,
4241
4295
  PanelApp,
@@ -4257,6 +4311,7 @@ export {
4257
4311
  decided,
4258
4312
  emptyCard,
4259
4313
  explainBoard,
4314
+ gateStatus,
4260
4315
  isCard,
4261
4316
  isFirstRun,
4262
4317
  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.0",
4
+ "hash": "sha256:4de7ef7076563e4c3118ac16b645737a44c4c0675115d9a9d7bbff562d5ecb74",
5
+ "fileCount": 163,
6
+ "templateHash": "sha256:7f9accd11920a0c189dd683bdb96635a606960afeca71e02c2a26a0257acefd8",
7
+ "generatedAt": "2026-09-11T02:49:51.562Z"
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.0",
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,42 @@
98
99
  "node": ">=20"
99
100
  },
100
101
  "paneltirRelease": {
101
- "version": "0.9.0",
102
- "date": "2026-09-10",
102
+ "version": "0.10.0",
103
+ "date": "2026-09-11",
103
104
  "entries": [
104
105
  {
105
106
  "kind": "feature",
106
107
  "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»."
108
+ "en": "`paneltir doctor` says whether the files this project copied are still the ones the installed kit ships and separates \"you edited it\" from \"we changed it underneath you\", which a diff cannot.",
109
+ "es": "`paneltir doctor` dice si los archivos que copió este proyecto siguen siendo los del kit instalado y separa «lo editaste tú» de «lo cambiamos nosotros debajo», que un diff no puede."
109
110
  }
110
111
  },
111
112
  {
112
- "kind": "fix",
113
+ "kind": "feature",
114
+ "text": {
115
+ "en": "The setup list can carry a row for the copied gate. Import `.paneltir/gate.json` and pass it as `gate`; without it the panel says nothing about those files rather than guessing.",
116
+ "es": "La lista de configuración puede llevar una fila para la puerta copiada. Importa `.paneltir/gate.json` y pásalo como `gate`; sin él el panel no dice nada de esos archivos en vez de suponer."
117
+ }
118
+ },
119
+ {
120
+ "kind": "feature",
113
121
  "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."
122
+ "en": "`paneltir run \"what this pass did\"` appends to the board's revisions, so the record does not depend on anyone remembering at the end of a session.",
123
+ "es": "`paneltir run \"qué hizo esta pasada\"` añade una entrada a las revisiones del tablero, así el registro no depende de que alguien se acuerde al final de la sesión."
116
124
  }
117
125
  },
118
126
  {
119
127
  "kind": "fix",
120
128
  "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."
129
+ "en": "`paneltir init` and `paneltir doctor` no longer need React installed. They loaded the component bundle at startup, so they failed in a project that had not installed it yet — which is exactly when `init` is run.",
130
+ "es": "`paneltir init` y `paneltir doctor` ya no necesitan React instalado. Cargaban el bundle de componentes al arrancar, así que fallaban en un proyecto que aún no lo tenía que es justo cuando se ejecuta `init`."
123
131
  }
124
132
  },
125
133
  {
126
134
  "kind": "chore",
127
135
  "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."
136
+ "en": "The rules a project needs are checked to actually reach it: the documents a project keeps must name `extraThemes` and `paneltir doctor`, or the build fails saying why.",
137
+ "es": "Se comprueba que las reglas que un proyecto necesita le lleguen de verdad: los documentos que el proyecto conserva tienen que nombrar `extraThemes` y `paneltir doctor`, o la build falla diciendo por qué."
130
138
  }
131
139
  }
132
140
  ]
@@ -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