paneltir 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.10.0
22
+ # npm install github:daifukus/paneltir#v0.11.0
23
23
  ```
24
24
 
25
25
  (Use the tag you were given; never install without pinning a version.)
@@ -237,7 +237,7 @@ identity** — never another project's.
237
237
 
238
238
  ```bash
239
239
  npx paneltir version # version, fingerprint, where it came from
240
- npx paneltir check 0.10.0 # exits non-zero if that is not what is installed
240
+ npx paneltir check 0.11.0 # exits non-zero if that is not what is installed
241
241
  npx paneltir board # read the board and say what is wrong with it
242
242
  ```
243
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.10.0
57
+ # npm install github:daifukus/paneltir#v0.11.0
58
58
  ```
59
59
 
60
60
  Installing from Git runs the `prepare` script, which builds `dist/`, so the
package/bin/paneltir.mjs CHANGED
@@ -130,10 +130,35 @@ function writeStamp(target, templateFiles, written) {
130
130
  const files = { ...(previous?.files ?? {}) }
131
131
  for (const file of written) files[file] = sha256(join(target, file))
132
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
+
133
157
  const stamp = {
134
- version: pkg.version,
135
- templateHash: fingerprint.templateHash ?? '',
136
- copiedAt: new Date().toISOString(),
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()),
137
162
  files,
138
163
  }
139
164
  try {
@@ -513,9 +538,33 @@ function doctor() {
513
538
  * behalf would be the second thing in this project writing to their
514
539
  * repository.
515
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
+
516
557
  async function run(args) {
517
- const flags = args.filter((a) => a.startsWith('--'))
518
- const plain = args.filter((a) => !a.startsWith('--'))
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))
519
568
  const summary = plain[0]
520
569
  const file = plain[1] ?? BOARD_SEED
521
570
 
@@ -543,7 +592,7 @@ async function run(args) {
543
592
  }
544
593
 
545
594
  const board = check.board
546
- const cards = (flags.find((f) => f.startsWith('--cards='))?.slice(8) ?? '')
595
+ const cards = (flag(args, 'cards') ?? '')
547
596
  .split(',')
548
597
  .map((id) => id.trim())
549
598
  .filter(Boolean)
@@ -557,10 +606,15 @@ async function run(args) {
557
606
  process.exit(1)
558
607
  }
559
608
 
560
- const by = flags.find((f) => f.startsWith('--by='))?.slice(5) === 'you' ? 'you' : 'claude'
609
+ const by = flag(args, 'by') === 'you' ? 'you' : 'claude'
561
610
  const date = new Date().toISOString().slice(0, 10)
562
611
  board.runs = Array.isArray(board.runs) ? board.runs : []
563
- board.runs.unshift({
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({
564
618
  id: `run-${date}-${String(board.runs.length + 1).padStart(3, '0')}`,
565
619
  date,
566
620
  by,
@@ -569,8 +623,27 @@ async function run(args) {
569
623
  fingerprint: shortHash,
570
624
  })
571
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
+
572
644
  writeFileSync(path, JSON.stringify(board, null, 2) + '\n')
573
645
  console.log(`Appended to ${file}: ${summary}`)
646
+ if (dropped) console.log(` dropped the ${dropped} oldest run(s), keeping the last ${KEEP_RUNS}`)
574
647
  console.log(` ${cards.length ? `cards ${cards.join(', ')}` : 'no cards named'} · by ${by} · ${shortHash}`)
575
648
  console.log('\nThe file is changed and not committed. The panel commits it on Save,')
576
649
  console.log('or commit it yourself — this writes nothing to your repository.')
package/dist/index.d.ts CHANGED
@@ -284,6 +284,14 @@ interface BoardColumnData<TCard extends BoardCardData = BoardCardData> {
284
284
  */
285
285
  hint?: string;
286
286
  cards: TCard[];
287
+ /**
288
+ * Drawn under the cards, inside the column's own scroll.
289
+ *
290
+ * It exists for what a column has to say about cards it is *not* showing —
291
+ * a done column hiding what was finished last month still owes the reader a
292
+ * line saying so, or the cards have not been hidden, they have been lost.
293
+ */
294
+ footer?: React.ReactNode;
287
295
  }
288
296
  interface BoardMoveResult {
289
297
  cardId: string;
@@ -521,6 +529,18 @@ interface SetupRequirement {
521
529
  enables?: React.ReactNode;
522
530
  /** Exactly what to do, shown only when it is missing. */
523
531
  fix?: React.ReactNode;
532
+ /**
533
+ * Information rather than a gate: drawn in the list, never walked as a step.
534
+ *
535
+ * Every other requirement here is an environment variable, and the
536
+ * walkthrough's one action — re-read the environment from the server —
537
+ * is what clears it. A requirement that action cannot clear parks the
538
+ * walkthrough on it for ever, and because `unknown` is judged across the
539
+ * whole list, one advisory row that nobody wired made every step answer a
540
+ * successful check with "the panel could not ask its own server". That is
541
+ * what the copied gate did the day it was added.
542
+ */
543
+ advisory?: boolean;
524
544
  }
525
545
  interface SetupGuideProps {
526
546
  requirements: SetupRequirement[];
@@ -1043,6 +1063,32 @@ declare function newId(): string;
1043
1063
  declare function today(): string;
1044
1064
  /** A card is created empty and named second — the same as tapping "+" on paper. */
1045
1065
  declare function emptyCard(column: string, area: string): Card;
1066
+ /**
1067
+ * How long a finished card stays on the board before it becomes history.
1068
+ *
1069
+ * A done column only grows. It is the one column nothing ever removes from,
1070
+ * so a board that is being worked turns into a wall of things already
1071
+ * finished, and the three columns that need reading get a third of the screen
1072
+ * between them. A week is the window in which "what did we ship" is still a
1073
+ * live question; after that it is a record, and a record does not need to be
1074
+ * in the way.
1075
+ */
1076
+ declare const ARCHIVE_AFTER_DAYS = 7;
1077
+ /**
1078
+ * Whether a finished card has been finished long enough to stop being drawn.
1079
+ *
1080
+ * Derived on every render and never written, which is the arrangement
1081
+ * `boardFacts()` already uses: a flag stored on the card would be wrong the
1082
+ * day after it was set, and setting it would mark the panel dirty for the
1083
+ * passage of time. Here the answer simply becomes true on its own.
1084
+ *
1085
+ * **A card with no `completedAt` is never archived**, whatever column it sits
1086
+ * in. It reached done without being stamped — a board edited by hand, or one
1087
+ * from before the stamp existed — so its age is not known, and hiding a card
1088
+ * whose age nobody knows is the same mistake as reporting a token missing
1089
+ * because the check could not run. `unknown` is not `old`.
1090
+ */
1091
+ declare function isArchived(card: Card, doneColumns: ReadonlySet<string>, now?: string, afterDays?: number): boolean;
1046
1092
  /**
1047
1093
  * Applies the column's own meaning to a card that just moved into it: a card
1048
1094
  * entering the working column is started, one reaching a done column is
@@ -1374,6 +1420,9 @@ interface UiStrings {
1374
1420
  needTokenEnables: string;
1375
1421
  needRepo: string;
1376
1422
  needRepoEnables: string;
1423
+ archivedOne: string;
1424
+ archivedMany: string;
1425
+ archivedHide: string;
1377
1426
  needGate: string;
1378
1427
  needGateEnables: string;
1379
1428
  fixGate: string;
@@ -1835,4 +1884,4 @@ interface CardSheetProps {
1835
1884
  }
1836
1885
  declare function CardSheet({ card, state, lang, ui, onChange, onDelete, onClose }: CardSheetProps): React.JSX.Element | null;
1837
1886
 
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 };
1887
+ export { ANALYSIS_SECTIONS, ARCHIVE_AFTER_DAYS, 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, isArchived, 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,8 +1,8 @@
1
1
  // src/version.ts
2
- var PANELTIR_VERSION = "0.10.0";
3
- var PANELTIR_FINGERPRINT = "sha256:4de7ef7076563e4c3118ac16b645737a44c4c0675115d9a9d7bbff562d5ecb74";
2
+ var PANELTIR_VERSION = "0.11.0";
3
+ var PANELTIR_FINGERPRINT = "sha256:2739462e763939de1d1aeb8ab97779149c2ddb0ec2d2fb45644c4c45ef9dc9fa";
4
4
  var PANELTIR_FILE_COUNT = 163;
5
- var PANELTIR_TEMPLATE_HASH = "sha256:7f9accd11920a0c189dd683bdb96635a606960afeca71e02c2a26a0257acefd8";
5
+ var PANELTIR_TEMPLATE_HASH = "sha256:034ba27266408af4f2ba5793d9cefbe448b168570d6a0b87dfe698dfe639feb1";
6
6
  var paneltirBuild = {
7
7
  version: PANELTIR_VERSION,
8
8
  fingerprint: PANELTIR_FINGERPRINT,
@@ -306,7 +306,8 @@ function Column({ column, renderCard, onAddCard }) {
306
306
  renderCard(card, index, column.id)
307
307
  ] }, card.id);
308
308
  }),
309
- dnd.isDragging && dnd.dropTarget?.columnId === column.id && dnd.dropTarget.index === column.cards.length && /* @__PURE__ */ jsx8("div", { className: "pt-card-placeholder" })
309
+ dnd.isDragging && dnd.dropTarget?.columnId === column.id && dnd.dropTarget.index === column.cards.length && /* @__PURE__ */ jsx8("div", { className: "pt-card-placeholder" }),
310
+ column.footer ? /* @__PURE__ */ jsx8("div", { className: "pt-column__footer", children: column.footer }) : null
310
311
  ] })
311
312
  ] });
312
313
  }
@@ -918,15 +919,16 @@ import { useState as useState5 } from "react";
918
919
 
919
920
  // src/components/Setup/wizard.ts
920
921
  function wizardStep(requirements) {
921
- const total = requirements.length;
922
- const done = requirements.filter((requirement) => requirement.status === "ok");
923
- const unknown = requirements.some((requirement) => requirement.status === "unknown");
924
- const at = requirements.findIndex((requirement) => requirement.status !== "ok");
922
+ const steps = requirements.filter((requirement) => !requirement.advisory);
923
+ const total = steps.length;
924
+ const done = steps.filter((requirement) => requirement.status === "ok");
925
+ const unknown = steps.some((requirement) => requirement.status === "unknown");
926
+ const at = steps.findIndex((requirement) => requirement.status !== "ok");
925
927
  if (at === -1) {
926
928
  return { current: null, index: 0, total, done, ready: total > 0, unknown: false };
927
929
  }
928
930
  return {
929
- current: requirements[at],
931
+ current: steps[at],
930
932
  index: at + 1,
931
933
  total,
932
934
  done,
@@ -1147,6 +1149,18 @@ function emptyCard(column, area) {
1147
1149
  completedAt: null
1148
1150
  };
1149
1151
  }
1152
+ var ARCHIVE_AFTER_DAYS = 7;
1153
+ function daysBefore(from, days) {
1154
+ const at = /* @__PURE__ */ new Date(`${from}T00:00:00Z`);
1155
+ at.setUTCDate(at.getUTCDate() - days);
1156
+ return at.toISOString().slice(0, 10);
1157
+ }
1158
+ function isArchived(card, doneColumns, now = today(), afterDays = ARCHIVE_AFTER_DAYS) {
1159
+ if (!doneColumns.has(card.column)) return false;
1160
+ const finished = card.completedAt;
1161
+ if (typeof finished !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(finished)) return false;
1162
+ return finished < daysBefore(now, afterDays);
1163
+ }
1150
1164
  function stampForColumn(card, columns) {
1151
1165
  const column = columns.find((candidate) => candidate.id === card.column);
1152
1166
  if (!column) return card;
@@ -1614,6 +1628,9 @@ var UI = {
1614
1628
  needTokenEnables: "Lets Save commit the board back to the repository. Without it everything else works and Save fails.",
1615
1629
  needRepo: "The repository to write to",
1616
1630
  needRepoEnables: "Which repository the board is committed to. There is no default on purpose: writing to the wrong one is worse than not writing.",
1631
+ archivedOne: "archived \u2014 finished over a week ago",
1632
+ archivedMany: "archived \u2014 finished over a week ago",
1633
+ archivedHide: "Hide them again",
1617
1634
  needGate: "The gate this project copied",
1618
1635
  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
1636
  fixGate: "The gate is older than the installed kit. See what changed before replacing anything:",
@@ -1938,6 +1955,9 @@ var UI = {
1938
1955
  needTokenEnables: "Permite que Guardar confirme el tablero en el repositorio. Sin \xE9l todo lo dem\xE1s funciona y Guardar falla.",
1939
1956
  needRepo: "El repositorio donde escribir",
1940
1957
  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.",
1958
+ archivedOne: "archivada \u2014 terminada hace m\xE1s de una semana",
1959
+ archivedMany: "archivadas \u2014 terminadas hace m\xE1s de una semana",
1960
+ archivedHide: "Volver a esconderlas",
1941
1961
  needGate: "La puerta que copi\xF3 este proyecto",
1942
1962
  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
1963
  fixGate: "La puerta es m\xE1s vieja que el kit instalado. Mira qu\xE9 cambi\xF3 antes de reemplazar nada:",
@@ -2939,7 +2959,7 @@ function applyMove(cards, move, visible) {
2939
2959
  function resolveTheme(saved, stored, themes, hasImported, fallback) {
2940
2960
  for (const name of [saved, stored]) {
2941
2961
  if (name === "imported" && hasImported) return "imported";
2942
- if (name && name in themes) return name;
2962
+ if (name && Object.prototype.hasOwnProperty.call(themes, name)) return name;
2943
2963
  }
2944
2964
  return fallback ?? Object.keys(themes)[0];
2945
2965
  }
@@ -3031,6 +3051,7 @@ function PanelApp({
3031
3051
  );
3032
3052
  const [state, setState] = useState10(initialState);
3033
3053
  const [filter, setFilter] = useState10("all");
3054
+ const [showArchived, setShowArchived] = useState10(() => /* @__PURE__ */ new Set());
3034
3055
  const [openCardId, setOpenCardId] = useState10(null);
3035
3056
  const [settingsOpen, setSettingsOpen] = useState10(false);
3036
3057
  const [update, setUpdate] = useState10(null);
@@ -3108,6 +3129,9 @@ function PanelApp({
3108
3129
  // `SetupWizard` already refuses to make.
3109
3130
  ...gate === void 0 ? [] : [{
3110
3131
  id: "gate",
3132
+ // Information, not a gate: the walkthrough's only action re-reads the
3133
+ // environment, and this is files on disk. See SetupRequirement.
3134
+ advisory: true,
3111
3135
  label: ui.needGate,
3112
3136
  status: gateReading.verdict === "current" ? "ok" : gateReading.verdict === "behind" ? "missing" : "unknown",
3113
3137
  enables: ui.needGateEnables,
@@ -3151,13 +3175,31 @@ function PanelApp({
3151
3175
  );
3152
3176
  const visibleCards = useMemo5(() => state.cards.filter(matches), [state.cards, matches]);
3153
3177
  const boardColumns = useMemo5(
3154
- () => state.columns.map((column) => ({
3155
- id: column.id,
3156
- title: t(column.title),
3157
- hint: column.hint ? t(column.hint) : void 0,
3158
- cards: visibleCards.filter((card) => card.column === column.id)
3159
- })),
3160
- [state.columns, visibleCards, t]
3178
+ () => state.columns.map((column) => {
3179
+ const mine = visibleCards.filter((card) => card.column === column.id);
3180
+ const archived = mine.filter((card) => isArchived(card, doneColumns));
3181
+ const showing = showArchived.has(column.id) ? mine : mine.filter((card) => !archived.includes(card));
3182
+ return {
3183
+ id: column.id,
3184
+ title: t(column.title),
3185
+ hint: column.hint ? t(column.hint) : void 0,
3186
+ cards: showing,
3187
+ footer: archived.length ? /* @__PURE__ */ jsx20(
3188
+ "button",
3189
+ {
3190
+ type: "button",
3191
+ onClick: () => setShowArchived((current) => {
3192
+ const next = new Set(current);
3193
+ if (next.has(column.id)) next.delete(column.id);
3194
+ else next.add(column.id);
3195
+ return next;
3196
+ }),
3197
+ children: showArchived.has(column.id) ? ui.archivedHide : `${archived.length} ${archived.length === 1 ? ui.archivedOne : ui.archivedMany}`
3198
+ }
3199
+ ) : void 0
3200
+ };
3201
+ }),
3202
+ [state.columns, visibleCards, doneColumns, showArchived, t, ui]
3161
3203
  );
3162
3204
  const notifications = useMemo5(() => {
3163
3205
  const items = [];
@@ -4259,6 +4301,7 @@ var CAPABILITIES = [
4259
4301
  ];
4260
4302
  export {
4261
4303
  ANALYSIS_SECTIONS,
4304
+ ARCHIVE_AFTER_DAYS,
4262
4305
  AlertBanner,
4263
4306
  AnalysisView,
4264
4307
  BOARD_VERSION,
@@ -4312,6 +4355,7 @@ export {
4312
4355
  emptyCard,
4313
4356
  explainBoard,
4314
4357
  gateStatus,
4358
+ isArchived,
4315
4359
  isCard,
4316
4360
  isFirstRun,
4317
4361
  ledgerForm,
package/dist/style.css CHANGED
@@ -594,6 +594,28 @@
594
594
  background: var(--pt-color-panel);
595
595
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
596
596
  }
597
+ .pt-column__footer {
598
+ padding-top: var(--pt-space-sm);
599
+ border-top: var(--pt-border-width) solid var(--pt-color-line-faint);
600
+ font-size: 11.5px;
601
+ color: var(--pt-color-ink-faint);
602
+ }
603
+ .pt-column__footer button {
604
+ border: none;
605
+ background: none;
606
+ padding: 0;
607
+ font: inherit;
608
+ color: inherit;
609
+ cursor: pointer;
610
+ text-decoration: underline;
611
+ text-underline-offset: 2px;
612
+ }
613
+ .pt-column__footer button:hover {
614
+ color: var(--pt-color-ink-dim);
615
+ }
616
+ .pt-column__list > * {
617
+ flex-shrink: 0;
618
+ }
597
619
 
598
620
  /* src/components/DetailSheet/DetailSheet.css */
599
621
  .pt-sheet-backdrop {
package/fingerprint.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "paneltir",
3
- "version": "0.10.0",
4
- "hash": "sha256:4de7ef7076563e4c3118ac16b645737a44c4c0675115d9a9d7bbff562d5ecb74",
3
+ "version": "0.11.0",
4
+ "hash": "sha256:2739462e763939de1d1aeb8ab97779149c2ddb0ec2d2fb45644c4c45ef9dc9fa",
5
5
  "fileCount": 163,
6
- "templateHash": "sha256:7f9accd11920a0c189dd683bdb96635a606960afeca71e02c2a26a0257acefd8",
7
- "generatedAt": "2026-09-11T02:49:51.562Z"
6
+ "templateHash": "sha256:034ba27266408af4f2ba5793d9cefbe448b168570d6a0b87dfe698dfe639feb1",
7
+ "generatedAt": "2026-09-13T08:39:02.027Z"
8
8
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paneltir",
3
- "version": "0.10.0",
3
+ "version": "0.11.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)",
@@ -99,42 +99,28 @@
99
99
  "node": ">=20"
100
100
  },
101
101
  "paneltirRelease": {
102
- "version": "0.10.0",
103
- "date": "2026-09-11",
102
+ "version": "0.11.0",
103
+ "date": "2026-09-13",
104
104
  "entries": [
105
105
  {
106
106
  "kind": "feature",
107
107
  "text": {
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."
108
+ "en": "A card finished more than a week ago stops being drawn in its column. The column says how many it is holding and one tap shows them nothing is hidden without being accounted for.",
109
+ "es": "Una tarjeta terminada hace más de una semana deja de dibujarse en su columna. La columna dice cuántas guarda y un toque las enseña nada se esconde sin quedar contado."
110
110
  }
111
111
  },
112
112
  {
113
113
  "kind": "feature",
114
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",
121
- "text": {
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."
124
- }
125
- },
126
- {
127
- "kind": "fix",
128
- "text": {
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`."
115
+ "en": "A board column can carry a footer, drawn inside its own scroll so it takes no height from the cards.",
116
+ "es": "Una columna del tablero puede llevar un pie, dibujado dentro de su propio scroll para que no le quite alto a las tarjetas."
131
117
  }
132
118
  },
133
119
  {
134
120
  "kind": "chore",
135
121
  "text": {
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é."
122
+ "en": "A card in a scrolling column can never shrink below its own content, so a title can never be cut in half.",
123
+ "es": "Una tarjeta en una columna con scroll no puede encogerse por debajo de su contenido, así que un título no puede quedar cortado por la mitad."
138
124
  }
139
125
  }
140
126
  ]
@@ -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',