@cspeach/cli 1.1.30 → 1.1.32

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.
Files changed (128) hide show
  1. package/README.md +11 -1
  2. package/dist/agent/loop.js +132 -9
  3. package/dist/cli.js +49 -18
  4. package/dist/commands/config-set.js +1 -1
  5. package/dist/commands/help.js +16 -1
  6. package/dist/commands/plan-chain.js +3 -0
  7. package/dist/commands/plan-continue.js +1 -1
  8. package/dist/commands/plan-gate.js +17 -1
  9. package/dist/commands/plan-resume.js +28 -18
  10. package/dist/commands/plan-startup.js +49 -6
  11. package/dist/commands/team-discover.js +195 -0
  12. package/dist/commands/team-plan.js +585 -0
  13. package/dist/commands/team-rules.js +187 -0
  14. package/dist/commands/team.js +971 -95
  15. package/dist/journey/chain.js +978 -0
  16. package/dist/journey/clean-core.js +235 -0
  17. package/dist/journey/flow.js +216 -0
  18. package/dist/journey/rebuild.js +464 -0
  19. package/dist/journey/run.js +628 -0
  20. package/dist/journey/session-deps.js +81 -0
  21. package/dist/journey/trigger.js +153 -0
  22. package/dist/lane/app-chain.js +21 -9
  23. package/dist/lane/chain-token.js +9 -5
  24. package/dist/lane/roots.js +97 -41
  25. package/dist/one-shot.js +28 -2
  26. package/dist/projects/clean-core-confirm.js +192 -3
  27. package/dist/projects/detail-path.js +9 -4
  28. package/dist/projects/extract-clean-core.js +3 -0
  29. package/dist/projects/migration.js +4 -105
  30. package/dist/projects/plan-lane.js +67 -17
  31. package/dist/projects/plan-sources.js +13 -7
  32. package/dist/projects/project-folder.js +131 -0
  33. package/dist/projects/promote-command.js +39 -0
  34. package/dist/projects/save-command.js +100 -12
  35. package/dist/projects/saved-lines.js +17 -3
  36. package/dist/projects/sort-command.js +499 -0
  37. package/dist/projects/sort-core.js +248 -0
  38. package/dist/projects/workspace-retire.js +48 -0
  39. package/dist/projects/workspace.js +182 -121
  40. package/dist/register/baseline.js +39 -2
  41. package/dist/register/decision-rules.js +35 -0
  42. package/dist/register/discover.js +931 -0
  43. package/dist/register/export-files.js +58 -0
  44. package/dist/register/findings.js +5 -2
  45. package/dist/register/measure.js +191 -9
  46. package/dist/register/offer.js +100 -8
  47. package/dist/register/picture.js +6 -2
  48. package/dist/register/plan-import.js +328 -0
  49. package/dist/register/records.js +40 -3
  50. package/dist/register/rule-proposal.js +4 -0
  51. package/dist/register/rules-edit.js +257 -0
  52. package/dist/register/status-file/estate-parts.js +168 -0
  53. package/dist/register/status-file/plan-parts.js +68 -0
  54. package/dist/register/status-file/progress-chart.js +110 -0
  55. package/dist/register/status-file/register-map.js +11 -3
  56. package/dist/register/status-file/register-model.js +40 -6
  57. package/dist/register/status-file/register-parts.js +57 -11
  58. package/dist/register/status-file/register-status-file.js +106 -90
  59. package/dist/register/status-file/report-model.js +569 -0
  60. package/dist/register/status-file/team-css.js +1 -1
  61. package/dist/register/status-file-sync.js +4 -0
  62. package/dist/register/status-html.js +27 -6
  63. package/dist/register/store.js +44 -5
  64. package/dist/register/variant.js +65 -0
  65. package/dist/register/workbooks.js +28 -42
  66. package/dist/register/write-whole.js +30 -2
  67. package/dist/register/xlsx.js +13 -3
  68. package/dist/register/xref.js +117 -0
  69. package/dist/renderer/startup-lines.js +11 -0
  70. package/dist/renderer/tool-labels.js +3 -1
  71. package/dist/renderer/tool-widget.js +15 -1
  72. package/dist/repl/at-completer.js +6 -2
  73. package/dist/repl/at-picker.js +1 -1
  74. package/dist/repl/builtin-commands.js +4 -0
  75. package/dist/repl/journey-entry.js +27 -0
  76. package/dist/repl/plan-hop.js +2 -9
  77. package/dist/repl/rules-menu.js +177 -0
  78. package/dist/repl/slash-completer.js +1 -0
  79. package/dist/repl/slash-picker.js +5 -4
  80. package/dist/repl.js +236 -59
  81. package/dist/router/classifier.js +8 -4
  82. package/dist/router/router-core.js +43 -7
  83. package/dist/router/routing-decision.js +1 -1
  84. package/dist/runs/bundled-runs.js +100 -0
  85. package/dist/runs/front-door.js +19 -0
  86. package/dist/runs/manifest-runs.js +50 -0
  87. package/dist/runs/run-command.js +24 -0
  88. package/dist/runs/runs.js +27 -0
  89. package/dist/runs/shadow.js +100 -0
  90. package/dist/runs/types.js +1 -0
  91. package/dist/session/backfill.js +342 -0
  92. package/dist/session/project-match.js +63 -0
  93. package/dist/session/queued-step.js +45 -0
  94. package/dist/session/resume.js +72 -24
  95. package/dist/session/schema.js +3 -1
  96. package/dist/session/store.js +96 -51
  97. package/dist/skill-catalog.js +2 -2
  98. package/dist/skills/bundled-skills.js +53 -53
  99. package/dist/tools/_filesystem-shared.js +174 -12
  100. package/dist/tools/_team-work-path.js +133 -0
  101. package/dist/tools/approval.js +7 -1
  102. package/dist/tools/dispatch-skill.js +8 -4
  103. package/dist/tools/filesystem/extract-xlsx.js +27 -3
  104. package/dist/tools/filesystem/file-edit.js +12 -4
  105. package/dist/tools/filesystem/file-read.js +8 -4
  106. package/dist/tools/filesystem/file-write.js +12 -4
  107. package/dist/tools/filesystem/glob.js +33 -8
  108. package/dist/tools/filesystem/grep.js +9 -4
  109. package/dist/tools/filesystem/read-document.js +6 -3
  110. package/dist/tools/lane/app-lane-resolve.js +1 -1
  111. package/dist/tools/project/cca_merge.js +8 -2
  112. package/dist/tools/project/rules_propose.js +59 -0
  113. package/dist/tools/project/team_estate.js +91 -0
  114. package/dist/tools/project/team_findings.js +89 -5
  115. package/dist/tools/project/upgrade_merge_progress.js +8 -2
  116. package/dist/tools/sap-read.js +21 -4
  117. package/dist/tools/sap-write.js +200 -10
  118. package/dist/tools/subagent/read_agent.js +3 -0
  119. package/dist/tools/tool-loading.js +1 -1
  120. package/dist/tools/transport-fit-check.js +117 -0
  121. package/dist/tools/transport-fit.js +15 -7
  122. package/dist/tools/transport.js +57 -2
  123. package/dist/ui/app.js +2 -2
  124. package/dist/ui/approval-modal.js +74 -6
  125. package/dist/ui/widgets/confirm-card.js +68 -5
  126. package/dist/upgrade-check.js +49 -4
  127. package/dist/util/open-in-browser.js +143 -0
  128. package/package.json +3 -3
@@ -2,4 +2,4 @@
2
2
  // GENERATED COPY — do not edit here. Edit viewer/src/team/team.css,
3
3
  // then run `npx tsx scripts/sync-status-file.ts` (prebuild runs it too).
4
4
  // src/register/__tests__/status-file-pin.test.ts fails when this copy drifts.
5
- export const TEAM_CSS = "/**\n * /team/ — one stylesheet, shared by the page and by the status file that\n * the page saves. That is why it pulls in no other stylesheet, no font file and\n * no image by address, and carries no selector that depends on anything outside\n * itself: the status file is one document on somebody's desktop, with no\n * network behind it.\n *\n * The nine state colours are the only colours on this page that mean anything.\n * Everything else is paper, ink and one edge, so nothing competes with the map.\n * The peach accent is kept for two things and no others: the button you are\n * meant to press, and where the keyboard currently is.\n */\n\n:root {\n --peach: oklch(0.74 0.13 35); --peach-deep: oklch(0.58 0.16 35);\n --paper: oklch(0.97 0.006 80); --paper-2: oklch(0.94 0.008 80);\n --ink: oklch(0.20 0.012 40); --ink-2: oklch(0.34 0.012 40); --ink-3: oklch(0.48 0.010 40);\n --edge: oklch(0.88 0.010 80);\n /* State colours: red = the system disagrees, grey = nobody has started, amber = words only, green = proven. */\n --s-regressed: oklch(0.52 0.20 27); --s-did-not-hold: oklch(0.64 0.19 40);\n --s-untouched: oklch(0.84 0.006 80); --s-decided: oklch(0.80 0.06 250); --s-working: oklch(0.70 0.10 250);\n --s-claimed: oklch(0.82 0.13 90); --s-verified: oklch(0.60 0.15 145); --s-clean: oklch(0.80 0.09 145);\n --s-retired: oklch(0.93 0.004 80);\n /* The tenth colour is not a state. It marks data holding a state this build\n does not know, and it is meant to look wrong: a cell nobody can explain\n must never pass for a quiet one. */\n --s-unknown: oklch(0.62 0.30 330);\n --font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n --mono: ui-monospace, \"Cascadia Mono\", \"SF Mono\", Menlo, monospace;\n /* The drawings on the empty screen are themed with the site's own names, so\n the page has to answer to all of them or a drawing falls back to a fixed\n light hex and goes wrong in the dark. Fonts included: font-src is 'none'\n here, so the display face names system serifs and never a downloaded one. */\n --paper-3: oklch(0.91 0.010 80); --ink-4: oklch(0.62 0.008 40); --edge-2: oklch(0.82 0.012 80);\n --peach-soft: oklch(0.92 0.05 35); --peach-wash: oklch(0.97 0.02 35);\n --ok: oklch(0.60 0.15 145); --warn: oklch(0.70 0.15 75); --bad: oklch(0.56 0.19 27); --slate: oklch(0.55 0.02 250);\n --dgm-node: #ffffff;\n --font-sans: var(--font); --font-mono: var(--mono); --font-display: Georgia, \"Times New Roman\", serif;\n color-scheme: light dark;\n font-family: var(--font);\n color: var(--ink); background: var(--paper);\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --paper: oklch(0.19 0.008 60); --paper-2: oklch(0.24 0.010 60); --ink: oklch(0.94 0.006 80);\n --ink-2: oklch(0.82 0.008 80); --ink-3: oklch(0.68 0.008 80); --edge: oklch(0.32 0.010 60);\n --s-untouched: oklch(0.36 0.006 80); --s-retired: oklch(0.27 0.004 80);\n --paper-3: oklch(0.29 0.012 60); --ink-4: oklch(0.56 0.008 80); --edge-2: oklch(0.40 0.012 60);\n --peach-soft: oklch(0.36 0.06 35); --peach-wash: oklch(0.27 0.03 35); --dgm-node: var(--paper-2);\n }\n}\n\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); line-height: 1.55; -webkit-text-size-adjust: 100%; }\n#app { max-width: 1180px; margin: 0 auto; padding: 32px 16px 64px; }\n[hidden] { display: none !important; }\n:focus-visible { outline: 2px solid var(--peach-deep); outline-offset: 2px; border-radius: 4px; }\n\n/* ── The answer, before anything else ─────────────────────────────────── */\n.masthead { margin: 0 0 40px; }\n/* SAP names run long and never break on their own (WAVE4_CLEANCORE_FINAL_V2),\n so anything that can hold one is allowed to break inside a word. */\n.provenance { margin: 0 0 10px; font-size: 14px; color: var(--ink-3); max-width: 70ch; overflow-wrap: anywhere; }\nh1 { margin: 0 0 12px; font-size: clamp(28px, 4.6vw, 44px); font-weight: 640; letter-spacing: -0.022em; line-height: 1.05; max-width: 22ch; overflow-wrap: anywhere; }\n.summary { margin: 0 0 22px; font-size: clamp(17px, 2.2vw, 21px); font-weight: 480; line-height: 1.4; color: var(--ink-2); max-width: 58ch; }\n.notice { margin: 0 0 20px; padding: 12px 14px; border-left: 3px solid var(--s-regressed); background: var(--paper-2); color: var(--ink); max-width: 70ch; }\n\n.actions { display: flex; flex-wrap: wrap; gap: 8px; }\nbutton { font: inherit; padding: 9px 15px; border-radius: 8px; cursor: pointer; max-width: 100%; overflow-wrap: anywhere; }\nbutton.primary { border: 1px solid var(--peach-deep); background: var(--peach); color: oklch(0.20 0.012 40); }\nbutton.quiet { border: 1px solid var(--edge); background: transparent; color: var(--ink-2); }\nbutton.quiet:hover { border-color: var(--ink-3); color: var(--ink); }\n/* The colour switch: the chosen mode reads as chosen, from the same tokens. */\nbutton.quiet[aria-pressed=\"true\"] { border-color: var(--ink); color: var(--ink); font-weight: 600; }\n.map > .actions { margin: 0 0 12px; }\n\n/* ── The map: the hero. No borders, no card, nothing to look at but colour ── */\n.map { margin: 0 0 48px; }\n.legend { list-style: none; display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 0; margin: 0 0 20px; font-size: 13px; color: var(--ink-2); }\n.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: baseline; }\n.blocks { display: flex; flex-wrap: wrap; gap: 28px 32px; align-items: flex-start; }\n.blocks figure { margin: 0; max-width: 100%; }\n.blocks h3 { margin: 0 0 8px; font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: var(--ink-2); letter-spacing: 0; }\n.blocks figcaption { margin-top: 6px; font-size: 11.5px; color: var(--ink-3); }\n.block-map { display: block; max-width: 100%; height: auto; }\n.cell { cursor: pointer; }\n.cell.selected { stroke: var(--ink); stroke-width: 2; }\n/* The map has one tab stop and the arrow keys move it, so where that stop\n currently sits has to be unmistakable — an outline alone is too thin at 10px. */\n.cell:focus-visible { outline: 2px solid var(--peach-deep); outline-offset: 2px; stroke: var(--ink); stroke-width: 2; }\n\n.s-regressed { fill: var(--s-regressed); background: var(--s-regressed); }\n.s-did-not-hold { fill: var(--s-did-not-hold); background: var(--s-did-not-hold); }\n.s-untouched { fill: var(--s-untouched); background: var(--s-untouched); }\n.s-decided { fill: var(--s-decided); background: var(--s-decided); }\n.s-working { fill: var(--s-working); background: var(--s-working); }\n.s-claimed { fill: var(--s-claimed); background: var(--s-claimed); }\n.s-verified { fill: var(--s-verified); background: var(--s-verified); }\n.s-clean { fill: var(--s-clean); background: var(--s-clean); }\n.s-retired { fill: var(--s-retired); background: var(--s-retired); }\n.s-unknown { fill: var(--s-unknown); background: var(--s-unknown); }\n\n/* The one bordered surface on the page, because it is a thing that opened. */\n.detail { margin-top: 28px; padding: 18px 20px; border: 1px solid var(--edge); border-radius: 12px; background: var(--paper-2); scroll-margin-top: 16px; }\n.detail h3 { margin: 0 0 10px; font-size: 17px; font-weight: 620; }\n.detail h3 small { font-family: var(--font); font-size: 13px; font-weight: 400; color: var(--ink-3); }\n.detail p { margin: 0 0 8px; font-size: 14px; }\n.detail .state { font-size: 14px; color: var(--ink-2); }\n.detail table { margin: 12px 0; }\n.ident { font-family: var(--mono); overflow-wrap: anywhere; }\n.warn { color: var(--s-regressed); }\n\n/* ── What needs a person, and who owns what ───────────────────────────── */\n.pair { display: grid; gap: 40px 48px; }\n@media (min-width: 900px) { .pair { grid-template-columns: 1.3fr 1fr; } }\n.needs, .lanes { min-width: 0; }\nh2 { margin: 0 0 14px; font-size: 15px; font-weight: 620; color: var(--ink-2); }\n.needs ul { list-style: none; margin: 0; padding: 0; }\n/* The bar carries the state's own colour where the item is a state, and a\n neutral one where it is not — so the colour never means a second thing. */\n.need { display: grid; gap: 3px; margin-bottom: 12px; padding: 2px 0 2px 12px; border-left: 3px solid var(--ink-3); }\n.need b { font-size: 14.5px; font-weight: 620; }\n.need span { font-size: 14px; color: var(--ink-2); overflow-wrap: anywhere; }\n.k-regressed { border-left-color: var(--s-regressed); }\n.k-did-not-hold { border-left-color: var(--s-did-not-hold); }\n\n.scroller { overflow-x: auto; }\ntable { width: 100%; border-collapse: collapse; font-size: 14px; }\nth, td { text-align: left; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--edge); white-space: nowrap; }\nth { font-weight: 600; color: var(--ink-3); font-size: 12.5px; }\n\n/* ── How it works (the empty screen) ──────────────────────────────────── */\n.how { margin-top: 48px; }\n.how p { font-size: 14px; color: var(--ink-2); max-width: 70ch; }\n/* The drawings follow the page's own tokens, so the card is the page's paper in\n both themes rather than a fixed light slab. Each drawing ships twice: a wide\n composition and a narrow one that stacks the same nodes and says the same\n words. This is the only thing that chooses between them — no script, and no\n style attribute, because the page's policy forbids one. */\n.diagram { margin-bottom: 16px; padding: 20px 16px; border: 1px solid var(--edge); border-radius: 12px; background: var(--paper); }\n.diagram svg { display: block; width: 100%; height: auto; margin: 0 auto; }\n.diagram .narrow { display: none; }\n@media (max-width: 640px) {\n .diagram { padding: 16px 8px; }\n .diagram .wide { display: none; }\n .diagram .narrow { display: block; max-width: 420px; margin: 0 auto; }\n}\n.how + .how { margin-top: 40px; }\n\nfooter { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--edge); font-size: 13px; color: var(--ink-3); }\nfooter p { margin: 0 0 8px; max-width: 78ch; }\n\n/* ── The way off the page ─────────────────────────────────────────────── */\n/* It sits outside the app, so it lines itself up with the app's own column\n instead of running the full width of the window. No rule above it: the\n page's own footer has already drawn one, and two lines in a row read as a\n mistake. It does not belong on paper — a printed record is not clickable. */\n.site-foot { max-width: 1180px; margin: 24px auto 0; padding: 0 16px 48px; border-top: 0; }\n.site-foot a { color: var(--ink-2); }\n.site-foot a:hover { color: var(--ink); }\n@media print { .site-foot { display: none; } }\n\n/* ── The status file ──────────────────────────────────────────────────── */\n/* The saved copy is this same page as one document, so it wears this same\n stylesheet and nearly all of the rules above. It has no buttons to press, so\n the peach accent never appears in it — the accent marks the thing you act on,\n and in a document there is nothing to act on. These are the only rules it\n adds.\n\n The stamp is the one mark that says this is a document and not a page: a\n moment, boxed and set in the face this page already uses for machine facts.\n It is bordered rather than filled so it survives a printer that drops\n colour. */\n.stamp {\n display: inline-block; margin: 0 0 22px; padding: 7px 11px; max-width: 100%;\n border: 1px solid var(--edge); border-radius: 6px;\n font-family: var(--mono); font-size: 12.5px; line-height: 1.5; color: var(--ink-2); overflow-wrap: anywhere;\n}\n\n/* Thousands of rows folded into one line per package: the count is in the line\n so the size of a package reads without opening it. Ruled, not carded — these\n are rows of a list, not a set of tiles. */\n.objects { margin-top: 48px; }\n.objects details { border-top: 1px solid var(--edge); }\n.objects details:last-of-type { border-bottom: 1px solid var(--edge); }\n.objects summary { padding: 11px 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; }\n.objects summary:hover, .objects details[open] > summary { color: var(--ink); }\n.objects .scroller { padding-bottom: 16px; }\n\n@media print {\n /* A printed record that quietly leaves out the folded half is not the record,\n so every package opens on paper. Two rules for the two ways a browser hides\n a closed fold; a browser that knows neither selector ignores its rule. */\n .objects details:not([open]) > :not(summary) { display: block; }\n .objects details::details-content { content-visibility: visible; }\n .objects summary { break-after: avoid; }\n .stamp { border-color: var(--ink-3); }\n}\n\n/* ── What was measured, and what was not ──────────────────────────────── */\n/* Two marks, and deliberately no new colour. The nine state colours are the\n only colours on this page that mean anything, and how a number was arrived at\n is not a tenth state — so the file tag is an outline, and the line about what\n nobody has measured borrows the amber the key already reads as \"words only\".\n Both are bars and pills the page has elsewhere; nothing new is introduced. */\n.unmeasured { margin: 0 0 20px; padding: 10px 14px; border-left: 3px solid var(--s-claimed); background: var(--paper-2); font-size: 15px; color: var(--ink-2); max-width: 70ch; }\n.detail .unmeasured { margin: 12px 0 0; }\n.runs { margin-top: 48px; min-width: 0; }\n.tag { display: inline-block; padding: 1px 7px; border: 1px solid var(--edge); border-radius: 999px; font-size: 12px; color: var(--ink-2); background: var(--paper); white-space: nowrap; }\n.quiet-note { font-size: 13px; color: var(--ink-3); }\n/* A note that belongs to the run above it. The run keeps no rule under it, so\n the two read as one thing rather than as a row and a stray sentence. */\ntr.has-note > td { border-bottom: 0; }\ntd.run-note { white-space: normal; padding-top: 0; }\n/* The runs nobody folded in. Indented off the table above and set quieter, so\n it reads as a footnote to the measurements rather than as more of them. */\n.aside { margin-top: 18px; padding-left: 12px; border-left: 1px solid var(--edge); font-size: 13px; color: var(--ink-3); max-width: 70ch; }\n.aside p { margin: 0 0 6px; }\n.aside ul { margin: 0; padding-left: 18px; }\n.aside li { overflow-wrap: anywhere; }\n\n/* ── Where it stands, and what was decided ────────────────────────────── */\n/* Five numbers a manager reads before the map. Not cards: each is a number on\n the page's paper with the same left bar the \"Needs you\" items carry, so the\n page keeps one way of marking a thing out. The bar is the edge colour until a\n number means trouble — then it borrows the red the key already reads as \"the\n system disagrees\", and nothing else on this strip is coloured. */\n.stands, .dispositions { margin: 0 0 40px; }\n.tiles { list-style: none; margin: 0 0 24px; padding: 0; display: grid; gap: 12px 20px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }\n.tile { padding: 2px 0 2px 12px; border-left: 3px solid var(--edge); display: grid; gap: 1px; align-content: start; }\n.tile-n { font-size: clamp(26px, 3.4vw, 32px); font-weight: 640; letter-spacing: -0.02em; line-height: 1.1; color: var(--ink); font-variant-numeric: tabular-nums; }\n.tile-label { font-size: 14.5px; font-weight: 620; color: var(--ink); }\n.tile-note { font-size: 13px; color: var(--ink-3); }\n.tile.bad { border-left-color: var(--s-regressed); }\n.tile.bad .tile-n { color: var(--s-regressed); }\n.empty-note { margin: 0 0 20px; color: var(--ink-2); max-width: 70ch; }\n\n/* The two level bars. A and B lean green, C amber, D red, \"not decided\" the\n grey of an object nobody has started — the key's own reading of each colour,\n so a level never asks the eye to learn a new palette. The words under each\n bar carry the same numbers: colour is never the only way to read it. */\n.levels { display: grid; gap: 14px; max-width: 640px; }\n.levelbar { margin: 0; }\n.levelbar figcaption { font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 4px; }\n.levelbar svg { display: block; width: 100%; height: 12px; border-radius: 2px; }\n.levelwords, .legend-line { margin: 4px 0 0; font-size: 13px; color: var(--ink-3); }\n.l-A { fill: var(--s-verified); background: var(--s-verified); }\n.l-B { fill: var(--s-clean); background: var(--s-clean); }\n.l-C { fill: var(--s-claimed); background: var(--s-claimed); }\n.l-D { fill: var(--s-regressed); background: var(--s-regressed); }\n.l-none { fill: var(--s-untouched); background: var(--s-untouched); }\n\n/* The five dispositions, one line. Outlined pills — the same mark as the\n \"measured from a file\" tag — because a disposition is a person's choice,\n not a state, and only states wear colour here. */\n.chips { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.chip { padding: 3px 11px; border: 1px solid var(--edge); border-radius: 999px; font-size: 14px; color: var(--ink-2); background: var(--paper); }\n.chip b { font-weight: 640; color: var(--ink); font-variant-numeric: tabular-nums; }\n.exceptions { max-width: 78ch; font-size: 14px; }\n.exceptions summary { cursor: pointer; color: var(--ink-2); padding: 4px 0; }\n.exceptions summary:hover, .exceptions[open] > summary { color: var(--ink); }\n.exceptions ul { margin: 6px 0 0; padding-left: 18px; }\n.exceptions li { margin-bottom: 4px; overflow-wrap: anywhere; }\n.lanes .quiet-note { margin: 8px 0 0; }\n.k-exception-no-reason { border-left-color: var(--warn); }\n\n/* ── Paper ────────────────────────────────────────────────────────────── */\n/* Paper is white, whatever the machine is set to. A manager on a dark-mode\n laptop who prints this — or prints the status file they were mailed, which\n carries this stylesheet inside it — would otherwise get white ink on white\n paper, and the map is the whole message. Every token the dark theme\n overrides is put back here to its light value; a test reads both lists and\n fails if one of them gains a token the other does not. */\n@media print {\n :root {\n --paper: oklch(0.97 0.006 80); --paper-2: oklch(0.94 0.008 80); --ink: oklch(0.20 0.012 40);\n --ink-2: oklch(0.34 0.012 40); --ink-3: oklch(0.48 0.010 40); --edge: oklch(0.88 0.010 80);\n --s-untouched: oklch(0.84 0.006 80); --s-retired: oklch(0.93 0.004 80);\n --paper-3: oklch(0.91 0.010 80); --ink-4: oklch(0.62 0.008 40); --edge-2: oklch(0.82 0.012 80);\n --peach-soft: oklch(0.92 0.05 35); --peach-wash: oklch(0.97 0.02 35); --dgm-node: #ffffff;\n }\n}\n\n@media print {\n .actions, .detail { display: none; }\n #app { max-width: none; padding: 0; }\n .pair { grid-template-columns: 1fr; }\n .cell { cursor: default; }\n /* The map is the message. A printer that drops the colour prints nothing.\n `.notice` is on this list for the status file: on paper it is the line that\n says the picture may be incomplete, and a caveat with its mark stripped off\n is a caveat somebody skims past. */\n .cell, .swatch, .need, .notice, .unmeasured, .tag, .diagram, .tile, .lvl, .chip { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n footer, .needs, .lanes, .runs, .stands, .dispositions { break-inside: avoid; }\n /* A printed list of exceptions that stays folded is a list nobody can read. */\n .exceptions:not([open]) > :not(summary) { display: block; }\n .exceptions::details-content { content-visibility: visible; }\n}\n";
5
+ export const TEAM_CSS = "/**\n * /team/ — one stylesheet, shared by the page and by the status file that\n * the page saves. That is why it pulls in no other stylesheet, no font file and\n * no image by address, and carries no selector that depends on anything outside\n * itself: the status file is one document on somebody's desktop, with no\n * network behind it.\n *\n * The nine state colours are the only colours on this page that mean anything.\n * Everything else is paper, ink and one edge, so nothing competes with the map.\n * The peach accent is kept for two things and no others: the button you are\n * meant to press, and where the keyboard currently is.\n */\n\n:root {\n --peach: oklch(0.74 0.13 35); --peach-deep: oklch(0.58 0.16 35);\n --paper: oklch(0.97 0.006 80); --paper-2: oklch(0.94 0.008 80);\n --ink: oklch(0.20 0.012 40); --ink-2: oklch(0.34 0.012 40); --ink-3: oklch(0.48 0.010 40);\n --edge: oklch(0.88 0.010 80);\n /* State colours: red = the system disagrees, grey = nobody has started, amber = words only, green = proven. */\n --s-regressed: oklch(0.52 0.20 27); --s-did-not-hold: oklch(0.64 0.19 40);\n --s-untouched: oklch(0.84 0.006 80); --s-decided: oklch(0.80 0.06 250); --s-working: oklch(0.70 0.10 250);\n --s-claimed: oklch(0.82 0.13 90); --s-verified: oklch(0.60 0.15 145); --s-clean: oklch(0.80 0.09 145);\n --s-retired: oklch(0.93 0.004 80);\n /* The tenth colour is not a state. It marks data holding a state this build\n does not know, and it is meant to look wrong: a cell nobody can explain\n must never pass for a quiet one. */\n --s-unknown: oklch(0.62 0.30 330);\n --font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n --mono: ui-monospace, \"Cascadia Mono\", \"SF Mono\", Menlo, monospace;\n /* The drawings on the empty screen are themed with the site's own names, so\n the page has to answer to all of them or a drawing falls back to a fixed\n light hex and goes wrong in the dark. Fonts included: font-src is 'none'\n here, so the display face names system serifs and never a downloaded one. */\n --paper-3: oklch(0.91 0.010 80); --ink-4: oklch(0.62 0.008 40); --edge-2: oklch(0.82 0.012 80);\n --peach-soft: oklch(0.92 0.05 35); --peach-wash: oklch(0.97 0.02 35);\n --ok: oklch(0.60 0.15 145); --warn: oklch(0.70 0.15 75); --bad: oklch(0.56 0.19 27); --slate: oklch(0.55 0.02 250);\n --dgm-node: #ffffff;\n --font-sans: var(--font); --font-mono: var(--mono); --font-display: Georgia, \"Times New Roman\", serif;\n color-scheme: light dark;\n font-family: var(--font);\n color: var(--ink); background: var(--paper);\n}\n@media (prefers-color-scheme: dark) {\n :root {\n --paper: oklch(0.19 0.008 60); --paper-2: oklch(0.24 0.010 60); --ink: oklch(0.94 0.006 80);\n --ink-2: oklch(0.82 0.008 80); --ink-3: oklch(0.68 0.008 80); --edge: oklch(0.32 0.010 60);\n --s-untouched: oklch(0.36 0.006 80); --s-retired: oklch(0.27 0.004 80);\n --paper-3: oklch(0.29 0.012 60); --ink-4: oklch(0.56 0.008 80); --edge-2: oklch(0.40 0.012 60);\n --peach-soft: oklch(0.36 0.06 35); --peach-wash: oklch(0.27 0.03 35); --dgm-node: var(--paper-2);\n }\n}\n\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); line-height: 1.55; -webkit-text-size-adjust: 100%; }\n#app { max-width: 1180px; margin: 0 auto; padding: 32px 16px 64px; }\n[hidden] { display: none !important; }\n:focus-visible { outline: 2px solid var(--peach-deep); outline-offset: 2px; border-radius: 4px; }\n\n/* ── The answer, before anything else ─────────────────────────────────── */\n.masthead { margin: 0 0 40px; }\n/* SAP names run long and never break on their own (WAVE4_CLEANCORE_FINAL_V2),\n so anything that can hold one is allowed to break inside a word. */\n.provenance { margin: 0 0 10px; font-size: 14px; color: var(--ink-3); max-width: 70ch; overflow-wrap: anywhere; }\nh1 { margin: 0 0 12px; font-size: clamp(28px, 4.6vw, 44px); font-weight: 640; letter-spacing: -0.022em; line-height: 1.05; max-width: 22ch; overflow-wrap: anywhere; }\n.summary { margin: 0 0 22px; font-size: clamp(17px, 2.2vw, 21px); font-weight: 480; line-height: 1.4; color: var(--ink-2); max-width: 58ch; }\n.notice { margin: 0 0 20px; padding: 12px 14px; border-left: 3px solid var(--s-regressed); background: var(--paper-2); color: var(--ink); max-width: 70ch; }\n\n.actions { display: flex; flex-wrap: wrap; gap: 8px; }\nbutton { font: inherit; padding: 9px 15px; border-radius: 8px; cursor: pointer; max-width: 100%; overflow-wrap: anywhere; }\nbutton.primary { border: 1px solid var(--peach-deep); background: var(--peach); color: oklch(0.20 0.012 40); }\nbutton.quiet { border: 1px solid var(--edge); background: transparent; color: var(--ink-2); }\nbutton.quiet:hover { border-color: var(--ink-3); color: var(--ink); }\n/* The colour switch: the chosen mode reads as chosen, from the same tokens. */\nbutton.quiet[aria-pressed=\"true\"] { border-color: var(--ink); color: var(--ink); font-weight: 600; }\n.map > .actions { margin: 0 0 12px; }\n\n/* ── The map: the hero. No borders, no card, nothing to look at but colour ── */\n.map { margin: 0 0 48px; }\n.legend { list-style: none; display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 0; margin: 0 0 20px; font-size: 13px; color: var(--ink-2); }\n.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: baseline; }\n.blocks { display: flex; flex-wrap: wrap; gap: 28px 32px; align-items: flex-start; }\n.blocks figure { margin: 0; max-width: 100%; }\n.blocks h3 { margin: 0 0 8px; font-family: var(--mono); font-size: 11.5px; font-weight: 600; color: var(--ink-2); letter-spacing: 0; }\n.blocks figcaption { margin-top: 6px; font-size: 11.5px; color: var(--ink-3); }\n.block-map { display: block; max-width: 100%; height: auto; }\n.cell { cursor: pointer; }\n.cell.selected { stroke: var(--ink); stroke-width: 2; }\n/* The map has one tab stop and the arrow keys move it, so where that stop\n currently sits has to be unmistakable — an outline alone is too thin at 10px. */\n.cell:focus-visible { outline: 2px solid var(--peach-deep); outline-offset: 2px; stroke: var(--ink); stroke-width: 2; }\n\n.s-regressed { fill: var(--s-regressed); background: var(--s-regressed); }\n.s-did-not-hold { fill: var(--s-did-not-hold); background: var(--s-did-not-hold); }\n.s-untouched { fill: var(--s-untouched); background: var(--s-untouched); }\n.s-decided { fill: var(--s-decided); background: var(--s-decided); }\n.s-working { fill: var(--s-working); background: var(--s-working); }\n.s-claimed { fill: var(--s-claimed); background: var(--s-claimed); }\n.s-verified { fill: var(--s-verified); background: var(--s-verified); }\n.s-clean { fill: var(--s-clean); background: var(--s-clean); }\n.s-retired { fill: var(--s-retired); background: var(--s-retired); }\n.s-unknown { fill: var(--s-unknown); background: var(--s-unknown); }\n\n/* The one bordered surface on the page, because it is a thing that opened. */\n.detail { margin-top: 28px; padding: 18px 20px; border: 1px solid var(--edge); border-radius: 12px; background: var(--paper-2); scroll-margin-top: 16px; }\n.detail h3 { margin: 0 0 10px; font-size: 17px; font-weight: 620; }\n.detail h3 small { font-family: var(--font); font-size: 13px; font-weight: 400; color: var(--ink-3); }\n.detail p { margin: 0 0 8px; font-size: 14px; }\n.detail .state { font-size: 14px; color: var(--ink-2); }\n.detail table { margin: 12px 0; }\n.ident { font-family: var(--mono); overflow-wrap: anywhere; }\n.warn { color: var(--s-regressed); }\n\n/* ── What needs a person, and who owns what ───────────────────────────── */\n.pair { display: grid; gap: 40px 48px; }\n@media (min-width: 900px) { .pair { grid-template-columns: 1.3fr 1fr; } }\n.needs, .lanes { min-width: 0; }\nh2 { margin: 0 0 14px; font-size: 15px; font-weight: 620; color: var(--ink-2); }\n.needs ul { list-style: none; margin: 0; padding: 0; }\n/* The bar carries the state's own colour where the item is a state, and a\n neutral one where it is not — so the colour never means a second thing. */\n.need { display: grid; gap: 3px; margin-bottom: 12px; padding: 2px 0 2px 12px; border-left: 3px solid var(--ink-3); }\n.need b { font-size: 14.5px; font-weight: 620; }\n.need span { font-size: 14px; color: var(--ink-2); overflow-wrap: anywhere; }\n.k-regressed { border-left-color: var(--s-regressed); }\n.k-did-not-hold { border-left-color: var(--s-did-not-hold); }\n\n.scroller { overflow-x: auto; }\ntable { width: 100%; border-collapse: collapse; font-size: 14px; }\nth, td { text-align: left; padding: 8px 12px 8px 0; border-bottom: 1px solid var(--edge); white-space: nowrap; }\nth { font-weight: 600; color: var(--ink-3); font-size: 12.5px; }\n\n/* ── How it works (the empty screen) ──────────────────────────────────── */\n.how { margin-top: 48px; }\n.how p { font-size: 14px; color: var(--ink-2); max-width: 70ch; }\n/* The drawings follow the page's own tokens, so the card is the page's paper in\n both themes rather than a fixed light slab. Each drawing ships twice: a wide\n composition and a narrow one that stacks the same nodes and says the same\n words. This is the only thing that chooses between them — no script, and no\n style attribute, because the page's policy forbids one. */\n.diagram { margin-bottom: 16px; padding: 20px 16px; border: 1px solid var(--edge); border-radius: 12px; background: var(--paper); }\n.diagram svg { display: block; width: 100%; height: auto; margin: 0 auto; }\n.diagram .narrow { display: none; }\n@media (max-width: 640px) {\n .diagram { padding: 16px 8px; }\n .diagram .wide { display: none; }\n .diagram .narrow { display: block; max-width: 420px; margin: 0 auto; }\n}\n.how + .how { margin-top: 40px; }\n\nfooter { margin-top: 56px; padding-top: 20px; border-top: 1px solid var(--edge); font-size: 13px; color: var(--ink-3); }\nfooter p { margin: 0 0 8px; max-width: 78ch; }\n\n/* ── The way off the page ─────────────────────────────────────────────── */\n/* It sits outside the app, so it lines itself up with the app's own column\n instead of running the full width of the window. No rule above it: the\n page's own footer has already drawn one, and two lines in a row read as a\n mistake. It does not belong on paper — a printed record is not clickable. */\n.site-foot { max-width: 1180px; margin: 24px auto 0; padding: 0 16px 48px; border-top: 0; }\n.site-foot a { color: var(--ink-2); }\n.site-foot a:hover { color: var(--ink); }\n@media print { .site-foot { display: none; } }\n\n/* ── The status file ──────────────────────────────────────────────────── */\n/* The saved copy is this same page as one document, so it wears this same\n stylesheet and nearly all of the rules above. It has no buttons to press, so\n the peach accent never appears in it — the accent marks the thing you act on,\n and in a document there is nothing to act on. These are the only rules it\n adds.\n\n The stamp is the one mark that says this is a document and not a page: a\n moment, boxed and set in the face this page already uses for machine facts.\n It is bordered rather than filled so it survives a printer that drops\n colour. */\n.stamp {\n display: inline-block; margin: 0 0 22px; padding: 7px 11px; max-width: 100%;\n border: 1px solid var(--edge); border-radius: 6px;\n font-family: var(--mono); font-size: 12.5px; line-height: 1.5; color: var(--ink-2); overflow-wrap: anywhere;\n}\n\n/* Thousands of rows folded into one line per package: the count is in the line\n so the size of a package reads without opening it. Ruled, not carded — these\n are rows of a list, not a set of tiles. */\n.objects { margin-top: 48px; }\n.objects details { border-top: 1px solid var(--edge); }\n.objects details:last-of-type { border-bottom: 1px solid var(--edge); }\n.objects summary { padding: 11px 0; font-family: var(--mono); font-size: 12.5px; font-weight: 600; color: var(--ink-2); cursor: pointer; }\n.objects summary:hover, .objects details[open] > summary { color: var(--ink); }\n.objects .scroller { padding-bottom: 16px; }\n\n@media print {\n /* A printed record that quietly leaves out the folded half is not the record,\n so every package opens on paper. Two rules for the two ways a browser hides\n a closed fold; a browser that knows neither selector ignores its rule. */\n .objects details:not([open]) > :not(summary) { display: block; }\n .objects details::details-content { content-visibility: visible; }\n .objects summary { break-after: avoid; }\n .stamp { border-color: var(--ink-3); }\n}\n\n/* ── What was measured, and what was not ──────────────────────────────── */\n/* Two marks, and deliberately no new colour. The nine state colours are the\n only colours on this page that mean anything, and how a number was arrived at\n is not a tenth state — so the file tag is an outline, and the line about what\n nobody has measured borrows the amber the key already reads as \"words only\".\n Both are bars and pills the page has elsewhere; nothing new is introduced. */\n.unmeasured { margin: 0 0 20px; padding: 10px 14px; border-left: 3px solid var(--s-claimed); background: var(--paper-2); font-size: 15px; color: var(--ink-2); max-width: 70ch; }\n.detail .unmeasured { margin: 12px 0 0; }\n.runs { margin-top: 48px; min-width: 0; }\n.tag { display: inline-block; padding: 1px 7px; border: 1px solid var(--edge); border-radius: 999px; font-size: 12px; color: var(--ink-2); background: var(--paper); white-space: nowrap; }\n.quiet-note { font-size: 13px; color: var(--ink-3); }\n/* A note that belongs to the run above it. The run keeps no rule under it, so\n the two read as one thing rather than as a row and a stray sentence. */\ntr.has-note > td { border-bottom: 0; }\ntd.run-note { white-space: normal; padding-top: 0; }\n/* The runs nobody folded in. Indented off the table above and set quieter, so\n it reads as a footnote to the measurements rather than as more of them. */\n.aside { margin-top: 18px; padding-left: 12px; border-left: 1px solid var(--edge); font-size: 13px; color: var(--ink-3); max-width: 70ch; }\n.aside p { margin: 0 0 6px; }\n.aside ul { margin: 0; padding-left: 18px; }\n.aside li { overflow-wrap: anywhere; }\n\n/* ── Where it stands, and what was decided ────────────────────────────── */\n/* Five numbers a manager reads before the map. Not cards: each is a number on\n the page's paper with the same left bar the \"Needs you\" items carry, so the\n page keeps one way of marking a thing out. The bar is the edge colour until a\n number means trouble — then it borrows the red the key already reads as \"the\n system disagrees\", and nothing else on this strip is coloured. */\n.stands, .dispositions { margin: 0 0 40px; }\n.tiles { list-style: none; margin: 0 0 24px; padding: 0; display: grid; gap: 12px 20px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }\n.tile { padding: 2px 0 2px 12px; border-left: 3px solid var(--edge); display: grid; gap: 1px; align-content: start; }\n.tile-n { font-size: clamp(26px, 3.4vw, 32px); font-weight: 640; letter-spacing: -0.02em; line-height: 1.1; color: var(--ink); font-variant-numeric: tabular-nums; }\n.tile-label { font-size: 14.5px; font-weight: 620; color: var(--ink); }\n.tile-note { font-size: 13px; color: var(--ink-3); }\n.tile.bad { border-left-color: var(--s-regressed); }\n.tile.bad .tile-n { color: var(--s-regressed); }\n.empty-note { margin: 0 0 20px; color: var(--ink-2); max-width: 70ch; }\n\n/* The two level bars. A and B lean green, C amber, D red, \"not decided\" the\n grey of an object nobody has started — the key's own reading of each colour,\n so a level never asks the eye to learn a new palette. The words under each\n bar carry the same numbers: colour is never the only way to read it. */\n.levels { display: grid; gap: 14px; max-width: 640px; }\n.levelbar { margin: 0; }\n.levelbar figcaption { font-size: 13px; font-weight: 600; color: var(--ink-2); margin-bottom: 4px; }\n.levelbar svg { display: block; width: 100%; height: 12px; border-radius: 2px; }\n.levelwords, .legend-line { margin: 4px 0 0; font-size: 13px; color: var(--ink-3); }\n.l-A { fill: var(--s-verified); background: var(--s-verified); }\n.l-B { fill: var(--s-clean); background: var(--s-clean); }\n.l-C { fill: var(--s-claimed); background: var(--s-claimed); }\n.l-D { fill: var(--s-regressed); background: var(--s-regressed); }\n.l-none { fill: var(--s-untouched); background: var(--s-untouched); }\n\n/* The five dispositions, one line. Outlined pills — the same mark as the\n \"measured from a file\" tag — because a disposition is a person's choice,\n not a state, and only states wear colour here. */\n.chips { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }\n.chip { padding: 3px 11px; border: 1px solid var(--edge); border-radius: 999px; font-size: 14px; color: var(--ink-2); background: var(--paper); }\n.chip b { font-weight: 640; color: var(--ink); font-variant-numeric: tabular-nums; }\n.exceptions { max-width: 78ch; font-size: 14px; }\n.exceptions summary { cursor: pointer; color: var(--ink-2); padding: 4px 0; }\n.exceptions summary:hover, .exceptions[open] > summary { color: var(--ink); }\n.exceptions ul { margin: 6px 0 0; padding-left: 18px; }\n.exceptions li { margin-bottom: 4px; overflow-wrap: anywhere; }\n.lanes .quiet-note { margin: 8px 0 0; }\n.k-exception-no-reason { border-left-color: var(--warn); }\n\n/* ── The focus, the estate, and what the plan adds (spec 2026-10-06) ──── */\n/* The focus sits under the one line of truth as a quieter second line: the\n same left bar as a tile, in the edge colour, because it is context for the\n numbers, not a number that means trouble. */\n.focus-line { margin: -10px 0 22px; padding: 2px 0 2px 12px; border-left: 3px solid var(--edge); font-size: 15px; color: var(--ink-2); max-width: 70ch; overflow-wrap: anywhere; }\n/* The estate reads before the team project's own numbers. Same tiles, same\n table, same quiet notes as the rest of the page — nothing new to learn. */\n.estate { margin: 0 0 40px; min-width: 0; }\n.estate h3 { margin: 0 0 8px; font-size: 14px; font-weight: 620; color: var(--ink-2); }\n/* The one tile whose value is words, not a count: set smaller so a sentence\n does not shout as loudly as a number. */\n.tile-text { font-size: 16px; font-weight: 600; line-height: 1.3; color: var(--ink); overflow-wrap: anywhere; }\n.estate-scope { margin: 0 0 20px; font-size: 15px; color: var(--ink-2); }\n.estate-unknowns { margin: 0 0 24px; max-width: 78ch; font-size: 14px; }\n.estate-unknowns ul { margin: 0; padding: 0; list-style: none; }\n.estate-unknowns li { margin-bottom: 6px; padding-left: 12px; border-left: 3px solid var(--warn); overflow-wrap: anywhere; }\n.estate-unknowns p { margin: 0; }\n/* A retire nobody has confirmed yet is words only — the amber the key already\n reads that way. The other plan items are housekeeping for the lead. */\n.tile.retire-candidates { border-left-color: var(--s-claimed); }\n.k-retire-candidate { border-left-color: var(--s-claimed); }\n.k-out-of-scope-no-reason, .k-excel-clashes { border-left-color: var(--warn); }\n/* Next names up to three objects; let them wrap rather than widen the table. */\ntd.next { white-space: normal; min-width: 14ch; }\n\n/* ── Progress over time, and the plan (spec 2026-10-06 §9 items 5, 8, 9) ── */\n/* The chart borrows the key's own colours: decided blue, claimed amber,\n proven green. Level A is not a state, so it is the quiet ink, dotted. Each\n line also has its own dash, and the legend says the numbers in words, so a\n printer that drops colour or an eye that cannot tell them apart still reads\n it. Ruled like the level bars, not carded. */\n.progress, .plan { margin: 0 0 40px; min-width: 0; }\n.progress h3, .plan h3 { margin: 20px 0 8px; font-size: 14px; font-weight: 620; color: var(--ink-2); }\n.progress-chart { margin: 0; max-width: 720px; }\n.progress-chart svg { display: block; width: 100%; height: auto; }\n.progress-chart .grid { stroke: var(--edge); stroke-width: 1; }\n.progress-chart .axis { fill: var(--ink-3); font-size: 11px; font-family: var(--font); }\n.pl { fill: none; stroke-width: 2.25; stroke-linejoin: round; stroke-linecap: round; }\n.pl-decided, .pd-decided { stroke: var(--s-decided); }\n.pl-claimed, .pd-claimed { stroke: var(--s-claimed); }\n.pl-proven, .pd-proven { stroke: var(--s-verified); }\n.pl-a, .pd-a { stroke: var(--ink-3); }\n.pl-decided { stroke-dasharray: 6 4; }\n.pl-claimed { stroke-dasharray: 2 3; }\n.pl-a { stroke-dasharray: 1 4; stroke-width: 2.5; }\n.pd { fill: var(--paper); stroke-width: 2; }\n.chart-legend { list-style: none; margin: 8px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 13px; color: var(--ink-2); }\n.key { display: inline-block; width: 18px; height: 0; margin: 0 6px 0 0; vertical-align: middle; border-top: 3px solid var(--ink-3); }\n.key-decided { border-top: 3px dashed var(--s-decided); }\n.key-claimed { border-top: 3px dotted var(--s-claimed); }\n.key-proven { border-top-color: var(--s-verified); }\n.key-a { border-top: 3px dotted var(--ink-3); }\n/* Done % per developer and per focus: a track, a fill, the number in words. */\n.pct-bars { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; max-width: 720px; }\n.pct-bars li { display: grid; grid-template-columns: minmax(10ch, 16rem) minmax(80px, 1fr) auto; gap: 4px 12px; align-items: center; font-size: 14px; }\n.pct-label { overflow-wrap: anywhere; color: var(--ink); }\n.pct-bar { display: block; height: 10px; border-radius: 2px; background: var(--s-untouched); overflow: hidden; }\n.pct-fill { display: block; height: 100%; background: var(--s-verified); }\n.pct-fill.f-focus { background: var(--s-clean); }\n.pct-n { font-size: 13px; color: var(--ink-3); font-variant-numeric: tabular-nums; white-space: nowrap; }\n@media (max-width: 560px) { .pct-bars li { grid-template-columns: 1fr auto; } .pct-bar { grid-column: 1 / -1; grid-row: 2; } }\n.w-0{width:0%} .w-5{width:5%} .w-10{width:10%} .w-15{width:15%} .w-20{width:20%} .w-25{width:25%} .w-30{width:30%} .w-35{width:35%} .w-40{width:40%} .w-45{width:45%} .w-50{width:50%} .w-55{width:55%} .w-60{width:60%} .w-65{width:65%} .w-70{width:70%} .w-75{width:75%} .w-80{width:80%} .w-85{width:85%} .w-90{width:90%} .w-95{width:95%} .w-100{width:100%}\n.plan-weights { margin: 0 0 12px; font-size: 14.5px; color: var(--ink-2); max-width: 78ch; }\n.focus-stack { margin: 0; padding-left: 22px; max-width: 78ch; font-size: 14px; }\n.focus-stack li { margin-bottom: 6px; overflow-wrap: anywhere; }\ntd.what { white-space: normal; min-width: 18ch; overflow-wrap: anywhere; }\n/* The detail panel's longer evidence: two small lists, the replaced decisions quieter. */\n.detail h4 { margin: 14px 0 6px; font-size: 13.5px; font-weight: 620; color: var(--ink-2); }\n.detail .history { margin: 0 0 8px; padding-left: 18px; font-size: 14px; }\n.detail .history li { margin-bottom: 4px; overflow-wrap: anywhere; }\n.detail .history li.superseded { color: var(--ink-3); }\n\n/* ── The one report (status file, step 2) ─────────────────────────────── */\n/* One page a manager reads in thirty seconds, every lane on it. The coverage\n line is the one ruled band on the page: it says how much of the project the\n check has seen, and it comes before any number, so no count can be read\n without it. Everything else is ink on paper; the levels share one colour,\n because a level is a fact, not a grade. No peach: nothing here is a button. */\n.rp-coverage { margin: 0 0 22px; padding: 10px 0 10px; border-top: 2px solid var(--ink); border-bottom: 1px solid var(--edge); max-width: 78ch; }\n.rp-coverage-line { margin: 0; font-size: 15.5px; font-weight: 600; line-height: 1.45; color: var(--ink); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }\n.rp-trend { margin: 4px 0 0; font-size: 13.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; }\n.rp-headline { margin: 0 0 8px; font-size: clamp(21px, 3vw, 28px); font-weight: 600; letter-spacing: -0.015em; line-height: 1.2; max-width: 34ch; }\n.rp-scope { margin: 0 0 24px; font-size: 15px; color: var(--ink-2); max-width: 70ch; }\n.rp-numbers { list-style: none; margin: 0 0 32px; padding: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); border-top: 1px solid var(--edge); border-bottom: 1px solid var(--edge); }\n.rp-numbers li { display: grid; align-content: start; gap: 2px; padding: 14px 14px 14px 0; }\n.rp-numbers li + li { padding-left: 14px; border-left: 1px solid var(--edge); }\n.rp-n { font-size: clamp(28px, 3.6vw, 36px); font-weight: 640; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }\n.rp-label { margin-top: 6px; font-size: 14.5px; font-weight: 620; line-height: 1.25; }\n.rp-sub { font-size: 12.5px; color: var(--ink-3); line-height: 1.35; }\n.rp-band { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 32px 40px; margin: 0 0 32px; align-items: start; }\n.rp-lvs { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--edge); }\n.rp-lvs li { display: grid; grid-template-columns: minmax(7ch, 9.5rem) 4ch minmax(0, 1fr); gap: 8px; align-items: baseline; padding: 6px 0; border-bottom: 1px solid var(--edge); }\n.rp-lv { font-weight: 620; }\n.rp-lv-n { text-align: right; font-weight: 620; font-variant-numeric: tabular-nums; }\n.rp-lv-key { font-size: 13px; color: var(--ink-3); line-height: 1.35; }\n.rp-target { margin: 12px 0 0; padding-left: 12px; border-left: 3px solid var(--ink); font-size: 14.5px; max-width: 60ch; }\n.rp-note { margin: 6px 0 0; font-size: 12.5px; color: var(--ink-3); max-width: 60ch; }\n.rp-decide { padding: 14px 18px; border: 1px solid var(--edge); border-left: 3px solid var(--ink); border-radius: 4px; background: var(--paper-2); min-width: 0; }\n.rp-decide ol, .rp-changed ul, .rp-kept-list, .rp-rebuilds ul { list-style: none; margin: 0; padding: 0; }\n.rp-decide li, .rp-changed li, .rp-kept-list li, .rp-rebuilds li { padding: 7px 0; border-top: 1px solid var(--edge); overflow-wrap: anywhere; }\n.rp-decide li:first-child { border-top: 0; padding-top: 0; }\n.rp-decide p, .rp-kept-list p, .rp-changed p, .rp-rebuilds p { margin: 0; font-size: 14.5px; }\n.rp-who, .rp-why { color: var(--ink-3); font-size: 13.5px !important; }\n.rp-tail { margin: 10px 0 0; font-size: 13px; color: var(--ink-3); max-width: 78ch; }\n.rp-changed { margin: 0 0 40px; max-width: 78ch; }\n.rp-detail { margin: 0 0 32px; padding-top: 28px; border-top: 1px solid var(--ink-3); min-width: 0; }\n.rp-devs tbody th { font-weight: 620; color: var(--ink); font-size: 14px; }\n.rp-devs td, .rp-devs th { font-variant-numeric: tabular-nums; }\n.rp-devs td.rp-pk { white-space: normal; min-width: 14ch; font-family: var(--mono); font-size: 12.5px; }\n.rp-kept, .rp-rebuilds { margin: 0 0 32px; max-width: 78ch; }\n.rp-key { color: var(--ink-3); }\n@media (max-width: 720px) {\n .rp-band { grid-template-columns: minmax(0, 1fr); }\n .rp-numbers { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .rp-numbers li, .rp-numbers li + li { padding: 12px 12px 12px 0; border-left: 0; border-top: 1px solid var(--edge); }\n .rp-numbers li:nth-child(even) { padding-left: 12px; border-left: 1px solid var(--edge); }\n .rp-numbers li:nth-child(-n+2) { border-top: 0; }\n .rp-lvs li { grid-template-columns: minmax(7ch, 8rem) 4ch minmax(0, 1fr); }\n}\n@media print {\n .rp-coverage, .rp-decide, .rp-target { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n .rp-first { break-after: page; }\n .rp-detail { border-top: 0; padding-top: 0; }\n .rp-numbers, .rp-band, .rp-decide, .rp-devs tr, .rp-kept-list li { break-inside: avoid; }\n}\n\n/* ── Paper ────────────────────────────────────────────────────────────── */\n/* Paper is white, whatever the machine is set to. A manager on a dark-mode\n laptop who prints this — or prints the status file they were mailed, which\n carries this stylesheet inside it — would otherwise get white ink on white\n paper, and the map is the whole message. Every token the dark theme\n overrides is put back here to its light value; a test reads both lists and\n fails if one of them gains a token the other does not. */\n@media print {\n :root {\n --paper: oklch(0.97 0.006 80); --paper-2: oklch(0.94 0.008 80); --ink: oklch(0.20 0.012 40);\n --ink-2: oklch(0.34 0.012 40); --ink-3: oklch(0.48 0.010 40); --edge: oklch(0.88 0.010 80);\n --s-untouched: oklch(0.84 0.006 80); --s-retired: oklch(0.93 0.004 80);\n --paper-3: oklch(0.91 0.010 80); --ink-4: oklch(0.62 0.008 40); --edge-2: oklch(0.82 0.012 80);\n --peach-soft: oklch(0.92 0.05 35); --peach-wash: oklch(0.97 0.02 35); --dgm-node: #ffffff;\n }\n}\n\n@media print {\n .actions, .detail { display: none; }\n #app { max-width: none; padding: 0; }\n .pair { grid-template-columns: 1fr; }\n .cell { cursor: default; }\n /* The map is the message. A printer that drops the colour prints nothing.\n `.notice` is on this list for the status file: on paper it is the line that\n says the picture may be incomplete, and a caveat with its mark stripped off\n is a caveat somebody skims past. */\n .cell, .swatch, .need, .notice, .unmeasured, .tag, .diagram, .tile, .lvl, .chip, .focus-line, .estate-unknowns li, .pct-bar, .pct-fill, .pl, .key { print-color-adjust: exact; -webkit-print-color-adjust: exact; }\n footer, .needs, .lanes, .runs, .stands, .dispositions, .estate-unknowns, .progress-chart, .pct-bars { break-inside: avoid; }\n /* A printed list of exceptions that stays folded is a list nobody can read. */\n .exceptions:not([open]) > :not(summary) { display: block; }\n .exceptions::details-content { content-visibility: visible; }\n}\n";
@@ -7,6 +7,10 @@ export const COPIES = [
7
7
  ['../viewer/src/lib/register-model.js', 'src/register/status-file/register-model.ts'],
8
8
  ['../viewer/src/lib/register-parts.js', 'src/register/status-file/register-parts.ts'],
9
9
  ['../viewer/src/lib/register-status-file.js', 'src/register/status-file/register-status-file.ts'],
10
+ ['../viewer/src/lib/estate-parts.js', 'src/register/status-file/estate-parts.ts'],
11
+ ['../viewer/src/lib/progress-chart.js', 'src/register/status-file/progress-chart.ts'],
12
+ ['../viewer/src/lib/plan-parts.js', 'src/register/status-file/plan-parts.ts'],
13
+ ['../viewer/src/lib/report-model.js', 'src/register/status-file/report-model.ts'],
10
14
  ['../viewer/src/team/team.css', 'src/register/status-file/team-css.ts'],
11
15
  ];
12
16
  export const HEADER = (from) => [
@@ -1,17 +1,38 @@
1
- import { buildStatusHtml } from './status-file/register-status-file.js';
2
- import { needsYou as needsYouUntyped, packageBlocks, summarize } from './status-file/register-model.js';
1
+ /**
2
+ * The status file, from Node — the same document the team page saves, built by
3
+ * the viewer's own code (copied into ./status-file/ and pinned by a test).
4
+ */
5
+ import fs from 'node:fs';
6
+ import { buildStatusHtml as buildStatusHtmlUntyped } from './status-file/register-status-file.js';
7
+ import { needsYou as needsYouUntyped } from './status-file/register-model.js';
3
8
  import { TEAM_CSS } from './status-file/team-css.js';
9
+ import { asOfFromStatusHtml, reportView as reportViewUntyped } from './status-file/report-model.js';
4
10
  // The copy is plain JavaScript under `// @ts-nocheck`, so TypeScript infers
5
11
  // `needsYou`'s options from its `= {}` default and loses `now`. Say the shape
6
12
  // here rather than edit the generated copy.
7
13
  const needsYou = needsYouUntyped;
14
+ // The same for the page's own options: their `= []` / `= null` defaults read as "always empty".
15
+ const buildStatusHtml = buildStatusHtmlUntyped;
16
+ /** The one report's model, typed for the Node callers (team status, the workbook). */
17
+ export const reportViewFor = reportViewUntyped;
8
18
  export function statusHtmlFor(view) {
9
- const { register, unreadable, asOf } = view;
19
+ const { register, unreadable, asOf, ...rest } = view;
10
20
  return buildStatusHtml({
11
- ...view,
12
- summary: summarize(register, { unreadable }),
21
+ ...rest, register, unreadable, asOf,
13
22
  needs: needsYou(register, { now: asOf, unreadable, surface: 'file' }),
14
- blocks: packageBlocks(register),
15
23
  css: TEAM_CSS,
16
24
  });
17
25
  }
26
+ /**
27
+ * The as-of stamp of a status page already on disk, read BEFORE it is
28
+ * overwritten: the "changed since the last report" block starts from it. Null
29
+ * when there is no page, or one from 1.1.31 without the tag (review F10).
30
+ */
31
+ export function previousAsOfIn(file) {
32
+ try {
33
+ return asOfFromStatusHtml(fs.readFileSync(file, 'utf8'));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
@@ -17,6 +17,7 @@ import fs from 'node:fs';
17
17
  import os from 'node:os';
18
18
  import path from 'node:path';
19
19
  import { cspeachRoot } from '../config/paths.js';
20
+ import { writeWhole } from './write-whole.js';
20
21
  export const TEAM_FILE = 'cspeach-team.json';
21
22
  export const BASELINE_FILE = path.join('baseline', 'objects.json');
22
23
  export const LANES_FILE = 'lanes.json';
@@ -103,8 +104,7 @@ export function writeProjectVariant(projectDir, variant) {
103
104
  throw new RegisterError(`"${variant}" is not an ATC check variant name.`);
104
105
  const file = path.join(projectDir, TEAM_FILE);
105
106
  const next = { ...readProjectFile(projectDir), atcVariant: variant };
106
- fs.writeFileSync(`${file}.tmp`, `${JSON.stringify(next, null, 2)}\n`);
107
- fs.renameSync(`${file}.tmp`, file);
107
+ writeWhole(file, `${JSON.stringify(next, null, 2)}\n`);
108
108
  }
109
109
  /**
110
110
  * One read, one sentence. `existsSync` then `readFileSync` answers only the
@@ -156,8 +156,9 @@ export function readLanes(projectDir) {
156
156
  const l = readJson(file);
157
157
  return Array.isArray(l?.lanes) ? l.lanes : [];
158
158
  }
159
+ /** Whole, then renamed (spec §3.7): a crash mid-write leaves the old lanes.json as it was. */
159
160
  export function writeLanes(projectDir, projectId, lanes) {
160
- fs.writeFileSync(path.join(projectDir, LANES_FILE), `${JSON.stringify({ kind: 'lanes', formatVersion: 1, projectId, lanes }, null, 2)}\n`);
161
+ writeWhole(path.join(projectDir, LANES_FILE), `${JSON.stringify({ kind: 'lanes', formatVersion: 1, projectId, lanes }, null, 2)}\n`);
161
162
  }
162
163
  /**
163
164
  * A record is a short list of object names. Anything this big is a dump, a log
@@ -178,7 +179,7 @@ function readRecord(file) {
178
179
  return { ok: false };
179
180
  }
180
181
  }
181
- /** Every record-shaped JSON under a folder: writers/<id>/records, rulings, baseline/measurement-*.json. */
182
+ /** Every record-shaped JSON under a folder: writers/<id>/records, rulings, plan, baseline/measurement-*.json, baseline/evidence-*.json. */
182
183
  export function collectRecords(root) {
183
184
  const records = [];
184
185
  let unreadable = 0;
@@ -213,9 +214,12 @@ export function collectRecords(root) {
213
214
  for (const w of writers)
214
215
  readDir(path.join(root, 'writers', w, 'records'));
215
216
  readDir(path.join(root, 'rulings'));
217
+ // The lead's plan records (spec 2026-10-06 §6.1): written only by the lead, read like any other.
218
+ readDir(path.join(root, 'plan'));
216
219
  try {
217
220
  for (const name of fs.readdirSync(path.join(root, 'baseline'))) {
218
- if (!/^measurement-.*\.json$/i.test(name))
221
+ // Measurement zero and the inventory's evidence (evidence-0.json).
222
+ if (!/^(measurement|evidence)-.*\.json$/i.test(name))
219
223
  continue;
220
224
  // Measurement zero lands here, written by the lead and then synced: the
221
225
  // same hazards as any other record, read the same way.
@@ -225,3 +229,38 @@ export function collectRecords(root) {
225
229
  catch { /* no baseline folder here (an outbox has none) */ }
226
230
  return { records, unreadable, files };
227
231
  }
232
+ export const DISCOVERY_DIR = 'discovery';
233
+ const ESTATE_FILE = /^estate-(\d{8}T\d{6}Z(?:-\d{1,4})?)\.json$/;
234
+ /** Newest first: by the time, then by the `-n` a second discovery of the same second carries (-10 after -2). */
235
+ const newestStampFirst = (a, b) => {
236
+ const [ta = '', na = '1'] = a.split('-');
237
+ const [tb = '', nb = '1'] = b.split('-');
238
+ return ta !== tb ? (ta < tb ? 1 : -1) : Number(nb) - Number(na);
239
+ };
240
+ /**
241
+ * The newest discovery in a folder (spec 2026-10-06 §5.2): `estate-<stamp>.json`
242
+ * and the `objects-<stamp>.json` of the same stamp. Older discoveries stay on
243
+ * disk as history and are never read here. A newest file that cannot be read
244
+ * (half synced) gives way to the one before it; `objects` is null when its
245
+ * file is missing or unreadable. Neither file is a record, and both are handed
246
+ * on as they are: the readers downstream read them the strict way.
247
+ */
248
+ export function readEstate(dir) {
249
+ const folder = path.join(dir, DISCOVERY_DIR);
250
+ let names = [];
251
+ try {
252
+ names = fs.readdirSync(folder);
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ const stamps = names.map((n) => ESTATE_FILE.exec(n)?.[1]).filter((s) => !!s).sort(newestStampFirst);
258
+ for (const stamp of stamps) {
259
+ const estate = readRecord(path.join(folder, `estate-${stamp}.json`));
260
+ if (!estate.ok || !estate.value || typeof estate.value !== 'object')
261
+ continue;
262
+ const objects = readRecord(path.join(folder, `objects-${stamp}.json`));
263
+ return { estate: estate.value, objects: objects.ok ? objects.value : null, stamp };
264
+ }
265
+ return null;
266
+ }
@@ -0,0 +1,65 @@
1
+ import { VARIANT_NAME } from './store.js';
2
+ import { shownUpTo } from './atc-file.js';
3
+ /*
4
+ * SCICHKV_HD — the SCI check-variant header. COLUMNS VERIFIED live on S4H,
5
+ * 2026-10-08 (`__tests__/fixtures/step2/scichkv-hd-columns.json`):
6
+ * CHECKVNAME holds the variant name; CIUSER is the owner — blank for a global
7
+ * variant, a user name for that user's private one. CHECKVID is NOT the owner:
8
+ * it is the variant's own number (00465 for ABAP_CLOUD_READINESS), never
9
+ * blank, so reading it as the owner would make every variant private.
10
+ */
11
+ const NAME_COLUMN = 'CHECKVNAME';
12
+ const OWNER_COLUMN = 'CIUSER';
13
+ /**
14
+ * True: the owner column (CIUSER) was seen on a live system (2026-10-08), so
15
+ * CSPeach says "global" or "<USER>'s private variant", not only "found".
16
+ */
17
+ export const PRIVATE_VARIANT_COLUMN_VERIFIED = true;
18
+ /**
19
+ * Whether the variant exists on the system: global, private to one user, not
20
+ * found, or 'unreadable' when the table could not be read. A name that is not
21
+ * a variant name is never put into a statement: it is not found.
22
+ */
23
+ export async function lookupVariant(sql, name,
24
+ /** Tests only: read (or not) the owner column. */
25
+ opts = {}) {
26
+ const wanted = name.trim().toUpperCase();
27
+ if (!VARIANT_NAME.test(wanted))
28
+ return { found: false };
29
+ let rows;
30
+ try {
31
+ ({ rows } = await sql(`SELECT ${NAME_COLUMN}, ${OWNER_COLUMN} FROM SCICHKV_HD WHERE ${NAME_COLUMN} = '${wanted}'`, 50));
32
+ }
33
+ catch {
34
+ return 'unreadable';
35
+ }
36
+ const mine = (Array.isArray(rows) ? rows : []).filter((r) => String(r?.[NAME_COLUMN] ?? '').trim().toUpperCase() === wanted);
37
+ if (!mine.length)
38
+ return { found: false };
39
+ if (!(opts.ownerColumnVerified ?? PRIVATE_VARIANT_COLUMN_VERIFIED))
40
+ return { found: true };
41
+ if (mine.some((r) => !String(r?.[OWNER_COLUMN] ?? '').trim()))
42
+ return { found: true, global: true };
43
+ // Read from the system, shown on a card: cleaned for the screen, at most 40 characters.
44
+ return { found: true, privateTo: shownUpTo(String(mine[0][OWNER_COLUMN]).trim().toUpperCase(), 40) };
45
+ }
46
+ /** The card's title. */
47
+ export const VARIANT_TITLE = (name) => `Checked with: ${name}`;
48
+ /** What the variant is, in one sentence. Never "SAP-recommended". */
49
+ export function variantWhat(name) {
50
+ if (name === 'ABAP_CLOUD_READINESS')
51
+ return "SAP's ABAP Cloud readiness check.";
52
+ if (name === 'S4HANA_READINESS')
53
+ return "SAP's S/4HANA readiness check.";
54
+ return 'A check variant on your system.';
55
+ }
56
+ /** What the lookup found, in one sentence (the card's second fact). */
57
+ export function variantLookupLine(name, sid, look) {
58
+ if (look === 'unreadable')
59
+ return `CSPeach could not confirm the variant exists on ${sid}.`;
60
+ if (!look.found)
61
+ return `CSPeach could not find the variant ${name} on ${sid}. Use it anyway?`;
62
+ if (look.privateTo)
63
+ return `This is ${look.privateTo}'s private variant; a global one is safer for a team.`;
64
+ return look.global ? `Found on ${sid} as a global variant.` : `Found on ${sid}.`;
65
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The two workbooks (spec §6.2, §6.3), built from the sheet model in
3
- * register-core — the same functions the team page prints — so a number in
2
+ * The two workbooks (spec §6.2, §6.3; step 2 §3.3), built from the sheet model in
3
+ * register-core and the report model — the same functions the team page prints — so a number in
4
4
  * Excel and the same number on the page cannot differ.
5
5
  *
6
6
  * Regenerated, never read back: an edit typed into a workbook changes nothing.
@@ -8,10 +8,15 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
- import { developerTable, levelTable, measurementTable, overlapNote, packageTable, summaryRows, utcWords, workingListRows, WORKING_LIST_COLUMNS, } from '@cspeach/register-core';
11
+ import { utcWords, workingListRows, WORKING_LIST_COLUMNS } from '@cspeach/register-core';
12
12
  import { registerFor } from './picture.js';
13
+ import { BUSINESS_COLUMNS, HISTORY_COLUMNS, OBJECTS_COLUMNS, businessSheetRows, historySheetRows, objectsSheetRows, summarySheetRows, } from './status-file/report-model.js';
14
+ import { reportViewFor } from './status-html.js';
13
15
  import { safeFileStem, writeXlsxFile } from './xlsx.js';
14
16
  const LIST_WIDTHS = [28, 6, 16, 6, 8, 22, 48, 26, 10, 10, 20, 18, 20, 22];
17
+ const PLAN_WIDTHS = [28, 6, 16, 22, 9, 9, 12, 40, 3, 10, 18, 20, 7, 22, 20, 10];
18
+ /** The Summary (or, in a 1.1.31 workbook, Read me) line `plan import` reads the export stamp from. */
19
+ export const EXPORT_STAMP = 'Export stamp: ';
15
20
  const LEVEL_LINE = 'Levels: A uses only released APIs. B calls public classic APIs. C uses SAP-internal objects. D modifies SAP standard.';
16
21
  export const workingListFileName = (name) => `${safeFileStem(name)} - working list.xlsx`;
17
22
  export const reportFileNames = (projectName) => ({
@@ -32,42 +37,23 @@ export function workingListSheets(register, laneIds, who, asOf, folderName) {
32
37
  ]),
33
38
  ];
34
39
  }
35
- const table = (t) => [t.header, ...t.rows];
36
- /** The D sentence: the demo's words only when they are true of every active object (claimed ≠ proven). */
37
- function dSentence(register) {
38
- const b = register.totals.byLevel;
39
- const everyoneJudged = b.decided.none === 0 && b.now.none === 0;
40
- return everyoneJudged && b.decided.D === 0 && b.now.D === 0
41
- ? 'D means an object that modifies SAP standard; none of the objects in these packages does.'
42
- : 'D means an object that modifies SAP standard; no object here has been judged D.';
43
- }
44
- export function combinedSheets(register, asOf, folderName) {
45
- const note = overlapNote(register);
46
- const summary = [
47
- ...summaryRows(register),
48
- [], ...table(levelTable(register)),
49
- [], ...table(packageTable(register)),
50
- [], ...table(developerTable(register)),
51
- ...(note ? [[note]] : []),
52
- ];
53
- const lanes = register.lanes.map((l) => ({
54
- name: l.owner, header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, [l.id]), widths: LIST_WIDTHS,
55
- }));
56
- const runs = measurementTable(register);
40
+ /**
41
+ * The combined workbook: four sheets (step 2, the one report) — Summary ·
42
+ * Objects · For the business · History — built from the same report model the
43
+ * status page prints, so a number in Excel and on the page cannot differ.
44
+ * Objects carries the Plan sheet's columns, so `plan import` reads it; the
45
+ * export stamp sits on the Summary sheet.
46
+ */
47
+ export function combinedSheets(register, asOf, folderName, project) {
48
+ const view = reportViewFor({ project, register, asOf });
49
+ const summary = summarySheetRows(view, { madeFor: `Made by CSPeach on ${utcWords(asOf)}, from the team project folder "${folderName}".` });
50
+ const stampAt = summary.findIndex((r) => r[0] === 'As of') + 1;
51
+ summary.splice(stampAt, 0, [`${EXPORT_STAMP}${asOf}`, '']);
57
52
  return [
58
- { name: 'Summary', header: ['Summary', 'Count'], rows: summary, widths: [34, 14, 12, 12, 12, 12, 14, 12, 22], filter: false },
59
- { name: 'All objects', header: [...WORKING_LIST_COLUMNS], rows: workingListRows(register, null), widths: LIST_WIDTHS },
60
- ...lanes,
61
- { name: 'Measurements', header: runs.header, rows: runs.rows, widths: [20, 20, 26, 22, 10, 14, 24] },
62
- readMe([
63
- `Made by CSPeach on ${utcWords(asOf)}, from the team project folder "${folderName}".`,
64
- 'The numbers are the same ones the team page shows for this folder.',
65
- 'This file is made again by cspeach team report. Changes typed here are not read back.',
66
- 'Proven means a check run said so. A claimed fix is not counted as proven.',
67
- 'A level moves only when a check proves the fix.',
68
- LEVEL_LINE,
69
- dSentence(register),
70
- ]),
53
+ { name: 'Summary', header: ['Summary', 'Count'], rows: summary, widths: [64, 18, 10, 10, 12, 18, 8, 10, 10, 12], filter: false },
54
+ { name: 'Objects', header: [...OBJECTS_COLUMNS], rows: objectsSheetRows(register, view.coverage.variant), widths: [...PLAN_WIDTHS, 10, 40, 20, 24] },
55
+ { name: 'For the business', header: [...BUSINESS_COLUMNS], rows: businessSheetRows(register), widths: [28, 6, 16, 20, 24, 40, 30, 12, 9, 22, 12] },
56
+ { name: 'History', header: [...HISTORY_COLUMNS], rows: historySheetRows(register), widths: [20, 20, 40, 28, 40] },
71
57
  ];
72
58
  }
73
59
  export const HELD = new Set(['EBUSY', 'EPERM', 'EACCES', 'EISDIR', 'ENOTEMPTY']);
@@ -81,7 +67,7 @@ export function regenerateWorkingList(joined, who, now = new Date()) {
81
67
  const { register } = registerFor(joined.projectDir, joined.outboxDir);
82
68
  const mine = register.lanes.filter((l) => sameName(l.owner, who)).map((l) => l.id);
83
69
  if (!mine.length)
84
- return { file: null, line: null, ownsLane: false };
70
+ return { file: null, line: null, ownsLane: false, kind: 'none' };
85
71
  const name = workingListFileName(who);
86
72
  const file = path.join(joined.outboxDir, 'writers', joined.writerId, name);
87
73
  try {
@@ -90,14 +76,14 @@ export function regenerateWorkingList(joined, who, now = new Date()) {
90
76
  catch (e) {
91
77
  const code = e?.code ?? '';
92
78
  if (HELD.has(code))
93
- return { file: null, line: ' ! Your working list is open in another program, so it was not updated. Close it and run: cspeach team sheet', ownsLane: true };
79
+ return { file: null, line: ' ! Your working list is open in another program, so it was not updated. Close it and run: cspeach team sheet', ownsLane: true, kind: 'held' };
94
80
  throw e;
95
81
  }
96
- return { file, line: ` Your working list is updated: ${name}`, ownsLane: true };
82
+ return { file, line: ` Your working list is updated: ${name}`, ownsLane: true, kind: 'updated' };
97
83
  }
98
84
  catch {
99
85
  // Fixed words: the error text can carry full folder paths, which never go on screen.
100
- return { file: null, line: ' ! Your working list could not be updated: the team project folder could not be read. Run cspeach team status for the reason.', ownsLane: true };
86
+ return { file: null, line: ' ! Your working list could not be updated: the team project folder could not be read. Run cspeach team status for the reason.', ownsLane: true, kind: 'unreadable' };
101
87
  }
102
88
  }
103
89
  /** True when `file` sits where a working list goes; used by tests and by `sheet`. */
@@ -5,12 +5,40 @@
5
5
  */
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
+ /** OneDrive or a virus scanner holding the file for a moment: EPERM / EBUSY, gone within milliseconds. */
9
+ const HELD = new Set(['EPERM', 'EBUSY']);
10
+ const RENAME_TRIES = 3;
11
+ const RENAME_WAIT_MS = 200;
12
+ /** Rename, tried up to 3 times 200 ms apart when the file is held; any other error at once. */
13
+ function renameHeld(from, to) {
14
+ for (let attempt = 1;; attempt++) {
15
+ try {
16
+ fs.renameSync(from, to);
17
+ return;
18
+ }
19
+ catch (e) {
20
+ if (attempt >= RENAME_TRIES || !HELD.has(e?.code ?? ''))
21
+ throw e;
22
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_WAIT_MS);
23
+ }
24
+ }
25
+ }
8
26
  export function writeWhole(file, data) {
9
27
  fs.mkdirSync(path.dirname(file), { recursive: true });
10
28
  const tmp = `${file}.tmp`;
11
- fs.writeFileSync(tmp, data);
12
29
  try {
13
- fs.renameSync(tmp, file);
30
+ fs.writeFileSync(tmp, data);
31
+ }
32
+ catch (e) {
33
+ // Half a .tmp is no use to anyone and a sync tool would carry it to the whole team.
34
+ try {
35
+ fs.unlinkSync(tmp);
36
+ }
37
+ catch { /* nothing was created */ }
38
+ throw e;
39
+ }
40
+ try {
41
+ renameHeld(tmp, file);
14
42
  }
15
43
  catch (e) {
16
44
  try {
@@ -2,7 +2,8 @@
2
2
  * A minimal .xlsx writer (spec §6.1): an .xlsx is a zip of a few XML parts.
3
3
  *
4
4
  * Text cells are inline strings (`t="inlineStr"`), which Excel never evaluates
5
- * as formulas, so `=SUM(` in a reason stays text. Every cell goes through XML
5
+ * as formulas, so `=SUM(` in a reason stays text; a text cell that starts with
6
+ * = + - or @ also carries the quote-prefix style, so it stays text after an edit. Every cell goes through XML
6
7
  * escaping, and characters XML 1.0 cannot hold are dropped. One bold header
7
8
  * style, column widths, a frozen header row and an autofilter. Entry times are
8
9
  * pinned, so the same sheets give the same bytes.
@@ -20,6 +21,10 @@ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[
20
21
  const NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
21
22
  const REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
22
23
  const HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n';
24
+ /** Cells Excel could read as a formula: =, +, - or @ first. */
25
+ const FORMULA_LEAD = /^[=+\-@]/;
26
+ /** The cell style with `quotePrefix` (the third xf in STYLES). */
27
+ const QUOTED = 2;
23
28
  export function xmlText(v) {
24
29
  // Strip lone surrogates again after the cut: the cut can split a pair.
25
30
  return v.replace(NOT_XML, '').replace(LONE_SURROGATE, '').slice(0, MAX_CELL).replace(LONE_SURROGATE, '')
@@ -37,7 +42,11 @@ function cellXml(v, ref, style) {
37
42
  const s = style ? ` s="${style}"` : '';
38
43
  if (typeof v === 'number')
39
44
  return Number.isFinite(v) ? `<c r="${ref}"${s}><v>${v}</v></c>` : '';
40
- return `<c r="${ref}"${s} t="inlineStr"><is><t xml:space="preserve">${xmlText(String(v))}</t></is></c>`;
45
+ const text = String(v);
46
+ // Text that starts like a formula gets Excel's own quote-prefix style: shown as
47
+ // typed, and kept text even when someone edits the cell in Excel and presses Enter.
48
+ const t = !style && FORMULA_LEAD.test(text) ? ` s="${QUOTED}"` : s;
49
+ return `<c r="${ref}"${t} t="inlineStr"><is><t xml:space="preserve">${xmlText(text)}</t></is></c>`;
41
50
  }
42
51
  function sheetXml(sheet) {
43
52
  const width = Math.max(1, sheet.header.length, ...sheet.rows.map((r) => r.length));
@@ -56,7 +65,8 @@ const STYLES = `${HEAD}<styleSheet xmlns="${NS}">`
56
65
  + '<fills count="2"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill></fills>'
57
66
  + '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>'
58
67
  + '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>'
59
- + '<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>'
68
+ + '<cellXfs count="3"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/><xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>'
69
+ + '<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0" quotePrefix="1"/></cellXfs>'
60
70
  + '<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>';
61
71
  export function buildXlsx(sheets) {
62
72
  const taken = new Set();