@bongos/core 1.19.505 → 1.19.506

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.505",
6
- "core_contract": "1.19.505",
7
- "source_commit": "3ab4e5ad43e20da1374af1e120d51911fd799680",
5
+ "core_version": "1.19.506",
6
+ "core_contract": "1.19.506",
7
+ "source_commit": "0c5102c78cf4acf0c5ab19a0f34ab43caa0a53d0",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-03T17:32:35.678Z",
9
+ "built_at": "2026-09-03T17:56:16.813Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 427,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1944,
14
+ "functional_verbatim": 1945,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2395,
20
- "tree_sha256": "1bc57733b6267db17eb8b78650effda973d902370c46f3be51c0764e1858de00",
19
+ "file_count": 2396,
20
+ "tree_sha256": "d0ec8df4b7c843085a5692c55b908977f2bbc25023e21dc62e246e62cfdfbd0d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1727,12 +1727,12 @@
1727
1727
  {
1728
1728
  "path": "docs/copy-inventory.md",
1729
1729
  "mode": "0000644",
1730
- "sha256": "276273f5edb4e920ce7ffdf33707b3e08c259ab22a45427ebde2f4f1bfdc2831"
1730
+ "sha256": "6f3476adddccabea94bb3bb8af804ec1e6dfc1710a9e3f76fb4ba062b44e3234"
1731
1731
  },
1732
1732
  {
1733
1733
  "path": "docs/copy-registry.json",
1734
1734
  "mode": "0000644",
1735
- "sha256": "b6bf1dcc9d8b2bbef68d5ff7b8f2e2b9a3e5d2d504562c21cacd0e3b9a0e9905"
1735
+ "sha256": "d6f13f1ab6d579e195b3240511d68181aabd64591933f1911fc0009fbdf875d8"
1736
1736
  },
1737
1737
  {
1738
1738
  "path": "docs/design/apex-pass-2-direction.md",
@@ -2577,7 +2577,7 @@
2577
2577
  {
2578
2578
  "path": "docs/module-api-changelog.md",
2579
2579
  "mode": "0000644",
2580
- "sha256": "6e57f6714292cf1ab9df6ec0c481390848d08ee35f399719ec5833d2699ed8bd"
2580
+ "sha256": "130aad5d413e749405b9f5104259cdfb9dfde539ae35326649c64e79c5ff6604"
2581
2581
  },
2582
2582
  {
2583
2583
  "path": "docs/modules-contract.md",
@@ -4997,12 +4997,12 @@
4997
4997
  {
4998
4998
  "path": "modules/hall-ui/public/sessions.html",
4999
4999
  "mode": "0000644",
5000
- "sha256": "cb5a4b2ece4f73cc2ccc29577cd5c724314260b8e7cf6790981ee06866fd4145"
5000
+ "sha256": "160985637f34bbf424e9333af158b260aad268e4a1dd9f22cf4da789283746b2"
5001
5001
  },
5002
5002
  {
5003
5003
  "path": "modules/hall-ui/public/sessions.js",
5004
5004
  "mode": "0000644",
5005
- "sha256": "6cc6ea120acd76bc42901adaa57f89f98f87eadfc2088ce4f6ccaa20106347ce"
5005
+ "sha256": "9470e5816bcb5aa0a77856413df9e20e5d5aa1a1948a01d41810037e8355e7f9"
5006
5006
  },
5007
5007
  {
5008
5008
  "path": "modules/hall-ui/public/sessions.states.json",
@@ -7227,12 +7227,12 @@
7227
7227
  {
7228
7228
  "path": "package-lock.json",
7229
7229
  "mode": "0000644",
7230
- "sha256": "5b46ca655ee12c47c36c6d2e05baea5d10bdf139404a931c041225e7e0343426"
7230
+ "sha256": "7a6dd18246bdbc4fbf57c52d54246a17ce2f5fb144bb256a6a163804175a8be3"
7231
7231
  },
7232
7232
  {
7233
7233
  "path": "package.json",
7234
7234
  "mode": "0000644",
7235
- "sha256": "54330892dd305c453d6e4f8b18051ba2116277c101651f912da36c07b15e314c"
7235
+ "sha256": "26b904a0eb436c1b3654cac79c075a39ccd61bd9db7d3eab8c96f43507fd2686"
7236
7236
  },
7237
7237
  {
7238
7238
  "path": "public-docs/index.html",
@@ -8902,7 +8902,7 @@
8902
8902
  {
8903
8903
  "path": "src/module-api.js",
8904
8904
  "mode": "0000644",
8905
- "sha256": "da3ceeaa7c239b0ec879e2724b32c30d3efae1ef2440d5d8defa0a729f18c69d"
8905
+ "sha256": "818b1e08d902b65dedaf1d670b920a12f8eedbc3191051c4977675ec34a4e8d6"
8906
8906
  },
8907
8907
  {
8908
8908
  "path": "src/module-loader/catalog.js",
@@ -10282,7 +10282,7 @@
10282
10282
  {
10283
10283
  "path": "tests/hall_audit.mjs",
10284
10284
  "mode": "0000644",
10285
- "sha256": "f6b972549bd19663760d9a65512e622799ad833613b5d4cfa365fa331997f052"
10285
+ "sha256": "7df5602bb92b44186613870501ecb4e959c9288f8d103cfc8075a3a24d51d840"
10286
10286
  },
10287
10287
  {
10288
10288
  "path": "tests/hall_board_world.mjs",
@@ -11524,6 +11524,11 @@
11524
11524
  "mode": "0000644",
11525
11525
  "sha256": "a630ac72981f230e8aa6609152765c13e8928ac329cb0ff3fe615371f31208d2"
11526
11526
  },
11527
+ {
11528
+ "path": "tests/sessions_kit_pager.mjs",
11529
+ "mode": "0000644",
11530
+ "sha256": "ce4a6f81b17821ed34710f2e3f616eeb51655973b3c9b2537b74f7c371ef0719"
11531
+ },
11527
11532
  {
11528
11533
  "path": "tests/setup_device_flow_fallback.mjs",
11529
11534
  "mode": "0000644",
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **1626 strings** across 3 surfaces · 1416 distinct pieces of copy · 1626 certain, 0 heuristic · 150 shown in more than one place.
7
+ **1623 strings** across 3 surfaces · 1412 distinct pieces of copy · 1623 certain, 0 heuristic · 151 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 73 | 1183 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 73 | 1180 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 98 |
15
15
  | `landing` | `modules/public-landing/public` | 5 | 345 |
16
16
 
@@ -76,9 +76,9 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
76
76
  | `f6bb75b08e98` | 2x | Back to {…} | modules/hall-ui/public/task.js:188, modules/hall-ui/public/task.js:197 |
77
77
  | `60b248791dc3` | 2x | Builders — {{worldName}} | modules/hall-ui/public/roster.html:6, modules/hall-ui/public/roster.html:6 |
78
78
  | `563d29ed86d8` | 2x | Bulk actions | modules/hall-ui/public/goals.html:172, modules/hall-ui/public/work.html:129 |
79
- | … | | _110 more in the JSON registry_ | |
79
+ | … | | _111 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1183 strings
81
+ ## `builders-hall` — 1180 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -284,8 +284,8 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
284
284
  | `589a4cfc5442` | certain | Could not load idea {…}. | `modules/hall-ui/public/ideas.js:629` |
285
285
  | `192c893fc64e` | certain | Could not load modules. | `modules/hall-ui/public/modules.js:318` |
286
286
  | `8a4b590fa1a0` | certain | Could not load requests. | `modules/hall-ui/public/collab.js:173` |
287
- | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:187` |
288
- | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:226` |
287
+ | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:243` |
288
+ | `7775fec0fa85` | certain | Could not load sessions. | `modules/hall-ui/public/sessions.js:281` |
289
289
  | `b2147cc90e36` | certain | Could not load the idea template. Reload, or file from the terminal with capture.js. | `modules/hall-ui/public/ideas.js:611` |
290
290
  | `7604af13c40e` | certain | Could not load the profile. | `modules/hall-ui/public/profile.js:759` |
291
291
  | `8b69e445592a` | certain | Could not load the question labels; showing the answers as stored. | `modules/hall-ui/public/idea.js:244` |
@@ -330,7 +330,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
330
330
  | `b1602d01f4bb` | certain | Could not unblock: {…} | `modules/hall-ui/public/harbor.js:187` |
331
331
  | `3db890b074a7` | certain | Could not update visibility just now. | `modules/hall-ui/public/goals-render.js:1043` |
332
332
  | `112d12b24879` | certain | Could not verify your access. Go back and try again. | `modules/hall-ui/public/watch.js:1010` |
333
- | `6bd74fd20311` | certain | Could not verify your access. Try again. | `modules/hall-ui/public/sessions.js:225` |
333
+ | `6bd74fd20311` | certain | Could not verify your access. Try again. | `modules/hall-ui/public/sessions.js:280` |
334
334
  | `278e8281c82f` | certain | Could not vote: {…} | `modules/hall-ui/public/government.js:990` |
335
335
  | `a48e9f098392` | certain | Could not {…} just now. | `modules/hall-ui/public/goals-render.js:1029` |
336
336
  | `cb09ee26014f` | certain | Could not {…}: {…} | `modules/hall-ui/public/government.js:321` |
@@ -559,7 +559,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
559
559
  | `9af4b7a3a36f` | certain | Module truth | `modules/hall-ui/public/atlas.html:48` |
560
560
  | `319e9db996ea` | certain | Modules — {{worldName}} | `modules/hall-ui/public/modules.html:6` |
561
561
  | `319e9db996ea` | certain | Modules — {{worldName}} | `modules/hall-ui/public/modules.html:6` |
562
- | `1ae18e58fead` | certain | Most recent first. Each load of this page is itself recorded in the audit log. | `modules/hall-ui/public/sessions.js:160` |
562
+ | `1ae18e58fead` | certain | Most recent first. Each load of this page is itself recorded in the audit log. | `modules/hall-ui/public/sessions.js:196` |
563
563
  | `f14a91b3f625` | certain | Most recent first. Each row is one recorded work session; every id on it opens its record. | `modules/hall-ui/public/sessions.html:56` |
564
564
  | `e52ae74f0052` | certain | Move to goal | `modules/hall-ui/public/task.js:510` |
565
565
  | `ae84ebe1d735` | certain | Name cannot be blank. | `modules/hall-ui/public/settings.js:831` |
@@ -618,7 +618,6 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
618
618
  | `ad9a17a8a5eb` | certain | Nothing shipped yet. | `modules/hall-ui/public/work.js:1016` |
619
619
  | `f326632f2013` | certain | Nothing to submit — every module here ships with the core. | `modules/hall-ui/public/modules.js:258` |
620
620
  | `9d25ba1d2f17` | certain | Off voice — says it in the wrong register | `modules/hall-ui/public/copy-desk.html:89` |
621
- | `55bc72820efc` | certain | Older → | `modules/hall-ui/public/sessions.html:70` |
622
621
  | `b89ad49770e7` | certain | On Windows, run | `modules/hall-ui/public/hall-render.js:811` |
623
622
  | `11a1905c76b2` | certain | On the board | `modules/hall-ui/public/hall-render.js:556` |
624
623
  | `ea1f179dd878` | certain | On this page | `modules/hall-ui/public/drachmae.html:51` |
@@ -650,7 +649,6 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
650
649
  | `695ffff13730` | certain | Over time | `modules/hall-ui/public/profile.html:72` |
651
650
  | `5ded44da0cc5` | certain | Override requests | `modules/hall-ui/public/watch.html:139` |
652
651
  | `15a49bf15007` | certain | Page {…} of {…} | `modules/hall-ui/public/hall-kit.js:109` |
653
- | `d1cd74b39c2e` | certain | Page {…} of {…} · {…}–{…} of {…} | `modules/hall-ui/public/sessions.js:143` |
654
652
  | `8595b0aa76a0` | certain | Pair Claude Desktop | `modules/hall-ui/public/settings.html:242` |
655
653
  | `cf68a394cfa5` | certain | Paste command copied. | `modules/hall-ui/public/settings.js:996` |
656
654
  | `a495649d1f82` | certain | Paste your key first. | `modules/hall-ui/public/settings.js:1117` |
@@ -774,7 +772,6 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
774
772
  | `ae43a132699b` | certain | Selectable today | `modules/hall-ui/public/government.js:721` |
775
773
  | `d14e8e9acaff` | certain | Session ended. | `modules/hall-ui/public/settings-sessions.js:81` |
776
774
  | `55e0a34bafed` | certain | Session log{…} | `modules/hall-ui/public/task.js:447` |
777
- | `3f8f6c62adb8` | certain | Session pages | `modules/hall-ui/public/sessions.html:67` |
778
775
  | `cccb362bd6da` | certain | Sessions — {{worldName}} | `modules/hall-ui/public/sessions.html:6` |
779
776
  | `cccb362bd6da` | certain | Sessions — {{worldName}} | `modules/hall-ui/public/sessions.html:6` |
780
777
  | `b823d9a37a35` | certain | Set up key storage | `modules/hall-ui/public/settings.js:1069` |
@@ -878,7 +875,8 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
878
875
  | `38ce5d68c182` | certain | The roster | `modules/hall-ui/public/roster.html:36` |
879
876
  | `dd6a926b8137` | certain | The same sentence, in several places. Sometimes that is right and the answer is to dismiss it; sometimes it is | `modules/hall-ui/public/copy-desk.html:57` |
880
877
  | `cd4ceb6e7e48` | certain | The seeded plan | `modules/hall-ui/public/roadmap.js:194` |
881
- | `d01d36a596b0` | certain | The session log could not be loaded. Try again. | `modules/hall-ui/public/sessions.js:186` |
878
+ | `d01d36a596b0` | certain | The session log could not be loaded. Try again. | `modules/hall-ui/public/sessions.js:242` |
879
+ | `d01d36a596b0` | certain | The session log could not be loaded. Try again. | `modules/hall-ui/public/sessions.js:294` |
882
880
  | `e48a9b77d91b` | certain | The session log opens from a higher rank. | `modules/hall-ui/public/sessions.html:41` |
883
881
  | `af664a644b13` | certain | The seven government structures, for getting a team up to speed before deciding when to flip a switch. | `modules/hall-ui/public/government.js:721` |
884
882
  | `6360a5988f26` | certain | The shape and health of the codebase. | `modules/hall-ui/public/watch.html:112` |
@@ -1205,7 +1203,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1205
1203
  | `275f3565c78b` | certain | {…} reset to its default {…} permissions. | `modules/hall-ui/public/government.js:304` |
1206
1204
  | `33afba17ee0f` | certain | {…} seeded | `modules/hall-ui/public/roadmap.js:194` |
1207
1205
  | `732b4c935e36` | certain | {…} sessions | `modules/hall-ui/public/task.js:446` |
1208
- | `3d76dce19c4a` | certain | {…} session{…} across {…} builder{…}. | `modules/hall-ui/public/sessions.js:158` |
1206
+ | `3d76dce19c4a` | certain | {…} session{…} across {…} builder{…}. | `modules/hall-ui/public/sessions.js:194` |
1209
1207
  | `e08fa22622f0` | certain | {…} shipped, {…} confirmed, {…} completed, {…} remaining of {…} | `modules/hall-ui/public/roadmap.js:74` |
1210
1208
  | `e08fa22622f0` | certain | {…} shipped, {…} confirmed, {…} completed, {…} remaining of {…} | `modules/hall-ui/public/roadmap.js:273` |
1211
1209
  | `ad27ae9f5433` | certain | {…} tasks · {…} | `modules/hall-ui/public/roadmap.js:390` |
@@ -1253,7 +1251,6 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1253
1251
  | `7926e30955d2` | certain | ← Back to goal | `modules/hall-ui/public/goals.html:184` |
1254
1252
  | `7926e30955d2` | certain | ← Back to goal | `modules/hall-ui/public/goals.html:195` |
1255
1253
  | `7926e30955d2` | certain | ← Back to goal | `modules/hall-ui/public/goals.html:207` |
1256
- | `1e7cf7b1e64e` | certain | ← Newer | `modules/hall-ui/public/sessions.html:68` |
1257
1254
  | `2180e0ecf138` | certain | → no idea # | `modules/hall-ui/public/task-detail.js:484` |
1258
1255
  | `d5d76f1c0dfe` | certain | ○ reserved | `modules/hall-ui/public/diagrams.html:44` |
1259
1256
  | `97b0773e72a5` | certain | ● LIVE | `modules/hall-ui/public/diagrams.html:42` |
@@ -3,17 +3,17 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1626,
7
- "unique_ids": 1416,
8
- "certain": 1626,
6
+ "strings": 1623,
7
+ "unique_ids": 1412,
8
+ "certain": 1623,
9
9
  "heuristic": 0,
10
- "duplicate_ids": 150
10
+ "duplicate_ids": 151
11
11
  },
12
12
  "surfaces": {
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 73,
16
- "strings": 1183
16
+ "strings": 1180
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -604,8 +604,8 @@
604
604
  "text": "Could not load sessions.",
605
605
  "occurrences": 2,
606
606
  "at": [
607
- "modules/hall-ui/public/sessions.js:187",
608
- "modules/hall-ui/public/sessions.js:226"
607
+ "modules/hall-ui/public/sessions.js:243",
608
+ "modules/hall-ui/public/sessions.js:281"
609
609
  ]
610
610
  },
611
611
  {
@@ -1268,6 +1268,16 @@
1268
1268
  "modules/hall-ui/public/primer.html:89"
1269
1269
  ]
1270
1270
  },
1271
+ {
1272
+ "id": "d01d36a596b0",
1273
+ "surface": "builders-hall",
1274
+ "text": "The session log could not be loaded. Try again.",
1275
+ "occurrences": 2,
1276
+ "at": [
1277
+ "modules/hall-ui/public/sessions.js:242",
1278
+ "modules/hall-ui/public/sessions.js:294"
1279
+ ]
1280
+ },
1271
1281
  {
1272
1282
  "id": "e9a366866022",
1273
1283
  "surface": "builders-hall",
@@ -3423,7 +3433,7 @@
3423
3433
  "surface": "builders-hall",
3424
3434
  "text": "Could not load sessions.",
3425
3435
  "file": "modules/hall-ui/public/sessions.js",
3426
- "line": 187,
3436
+ "line": 243,
3427
3437
  "origin": "js-notice",
3428
3438
  "confidence": "certain"
3429
3439
  },
@@ -3432,7 +3442,7 @@
3432
3442
  "surface": "builders-hall",
3433
3443
  "text": "Could not load sessions.",
3434
3444
  "file": "modules/hall-ui/public/sessions.js",
3435
- "line": 226,
3445
+ "line": 281,
3436
3446
  "origin": "js-notice",
3437
3447
  "confidence": "certain"
3438
3448
  },
@@ -3837,7 +3847,7 @@
3837
3847
  "surface": "builders-hall",
3838
3848
  "text": "Could not verify your access. Try again.",
3839
3849
  "file": "modules/hall-ui/public/sessions.js",
3840
- "line": 225,
3850
+ "line": 280,
3841
3851
  "origin": "js-text-assign",
3842
3852
  "confidence": "certain"
3843
3853
  },
@@ -5898,7 +5908,7 @@
5898
5908
  "surface": "builders-hall",
5899
5909
  "text": "Most recent first. Each load of this page is itself recorded in the audit log.",
5900
5910
  "file": "modules/hall-ui/public/sessions.js",
5901
- "line": 160,
5911
+ "line": 196,
5902
5912
  "origin": "js-text-assign",
5903
5913
  "confidence": "certain"
5904
5914
  },
@@ -6424,15 +6434,6 @@
6424
6434
  "origin": "html-text",
6425
6435
  "confidence": "certain"
6426
6436
  },
6427
- {
6428
- "id": "55bc72820efc",
6429
- "surface": "builders-hall",
6430
- "text": "Older →",
6431
- "file": "modules/hall-ui/public/sessions.html",
6432
- "line": 70,
6433
- "origin": "html-text",
6434
- "confidence": "certain"
6435
- },
6436
6437
  {
6437
6438
  "id": "b89ad49770e7",
6438
6439
  "surface": "builders-hall",
@@ -6712,15 +6713,6 @@
6712
6713
  "origin": "js-text-assign",
6713
6714
  "confidence": "certain"
6714
6715
  },
6715
- {
6716
- "id": "d1cd74b39c2e",
6717
- "surface": "builders-hall",
6718
- "text": "Page {…} of {…} · {…}–{…} of {…}",
6719
- "file": "modules/hall-ui/public/sessions.js",
6720
- "line": 143,
6721
- "origin": "js-text-assign",
6722
- "confidence": "certain"
6723
- },
6724
6716
  {
6725
6717
  "id": "8595b0aa76a0",
6726
6718
  "surface": "builders-hall",
@@ -7828,15 +7820,6 @@
7828
7820
  "origin": "js-markup",
7829
7821
  "confidence": "certain"
7830
7822
  },
7831
- {
7832
- "id": "3f8f6c62adb8",
7833
- "surface": "builders-hall",
7834
- "text": "Session pages",
7835
- "file": "modules/hall-ui/public/sessions.html",
7836
- "line": 67,
7837
- "origin": "html-attr:aria-label",
7838
- "confidence": "certain"
7839
- },
7840
7823
  {
7841
7824
  "id": "cccb362bd6da",
7842
7825
  "surface": "builders-hall",
@@ -8769,7 +8752,16 @@
8769
8752
  "surface": "builders-hall",
8770
8753
  "text": "The session log could not be loaded. Try again.",
8771
8754
  "file": "modules/hall-ui/public/sessions.js",
8772
- "line": 186,
8755
+ "line": 242,
8756
+ "origin": "js-text-assign",
8757
+ "confidence": "certain"
8758
+ },
8759
+ {
8760
+ "id": "d01d36a596b0",
8761
+ "surface": "builders-hall",
8762
+ "text": "The session log could not be loaded. Try again.",
8763
+ "file": "modules/hall-ui/public/sessions.js",
8764
+ "line": 294,
8773
8765
  "origin": "js-text-assign",
8774
8766
  "confidence": "certain"
8775
8767
  },
@@ -11712,7 +11704,7 @@
11712
11704
  "surface": "builders-hall",
11713
11705
  "text": "{…} session{…} across {…} builder{…}.",
11714
11706
  "file": "modules/hall-ui/public/sessions.js",
11715
- "line": 158,
11707
+ "line": 194,
11716
11708
  "origin": "js-text-assign",
11717
11709
  "confidence": "certain"
11718
11710
  },
@@ -12139,15 +12131,6 @@
12139
12131
  "origin": "html-text",
12140
12132
  "confidence": "certain"
12141
12133
  },
12142
- {
12143
- "id": "1e7cf7b1e64e",
12144
- "surface": "builders-hall",
12145
- "text": "← Newer",
12146
- "file": "modules/hall-ui/public/sessions.html",
12147
- "line": 68,
12148
- "origin": "html-text",
12149
- "confidence": "certain"
12150
- },
12151
12134
  {
12152
12135
  "id": "2180e0ecf138",
12153
12136
  "surface": "builders-hall",
@@ -1447,5 +1447,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1447
1447
  landed since 1.19.503 with no explicit bump. run 33784387586. (task 1002620)
1448
1448
  1.19.505 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1449
1449
  landed since 1.19.504 with no explicit bump. run 33785121177. (task 1002620)
1450
+ 1.19.506 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1451
+ landed since 1.19.505 with no explicit bump. run 33787453336. (task 1002620)
1450
1452
  ---------------------------------------------------------------------------
1451
1453
  ```
@@ -64,11 +64,11 @@
64
64
  </thead>
65
65
  <tbody id="sessions-body"></tbody>
66
66
  </table>
67
- <nav class="pager" id="ledger-pager" hidden aria-label="Session pages">
68
- <button type="button" class="btn-ghost" id="pager-prev">← Newer</button>
69
- <span class="pager__range" id="pager-pos"></span>
70
- <button type="button" class="btn-ghost" id="pager-next">Older →</button>
71
- </nav>
67
+ <!-- The kit stamps the pager bar here (sessions.js, OTBKit.pagerBarHtml).
68
+ makeLedger is deliberately NOT used: it stamps table + pager into one
69
+ mount, and this page keeps them separate so the bar sits below the
70
+ ledger rather than inside whatever scrollport the table is in. -->
71
+ <div id="sessions-pager-mount"></div>
72
72
  <p class="ledger__note" id="ledger-foot"></p>
73
73
  </section>
74
74
  </main>
@@ -88,7 +88,11 @@
88
88
  <!-- shared frontend toolkit (window.OTB) — loaded BEFORE sessions.js so it can pull from it. -->
89
89
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
90
90
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
91
+ <!-- The shared component kit — the pager contract this page now uses instead of
92
+ its own (task 1003279-sibling follow-up of 1003281). Must load BEFORE
93
+ sessions.js, which reads window.OTBKit at boot. -->
94
+ <script src="/builders/hall-kit.js"></script>
91
95
  <script src="/builders/brand-apply.js?v=2026-06-19-r56"></script>
92
- <script src="/builders/sessions.js?v=2026-06-19-r56"></script>
96
+ <script src="/builders/sessions.js?v=2026-08-28-kitpager"></script>
93
97
  </body>
94
98
  </html>
@@ -121,91 +121,146 @@
121
121
  $('#sessions-scroll').hidden = true;
122
122
  }
123
123
 
124
- // ---- paging state --------------------------------------------------------
124
+ // ---- paging --------------------------------------------------------------
125
+ //
126
+ // This page used to hand-roll its own server-offset pager, and hall-ui/CLAUDE.md
127
+ // carried it as the ONE sanctioned exception to "no bespoke pager" (audited task
128
+ // 2179) — because the kit could only slice an array the browser already held, and
129
+ // this corpus cannot be held. Task 1003281 added the server-offset mode to the kit
130
+ // and left the migration as its own task; this is it. The exception is gone, so
131
+ // there is one pager contract in the hall again rather than one-plus-a-footnote.
132
+ //
133
+ // What the reader gains: a per-page control that remembers their choice, and a
134
+ // clamp — if the corpus shrinks under an open page, renderServerPage() says so and
135
+ // we re-ask, instead of showing an empty page under a range that claims rows.
136
+ //
137
+ // The one thing NOT taken from the kit is makeLedger: it stamps table+pager into a
138
+ // single mount, and the CARD is this page's scroll container (hall-ui/CLAUDE.md, the
139
+ // sideways-scroll rule), so a pager stamped into the table's mount would slide with
140
+ // the columns. Keeping it a separate sibling below the ledger is what holds it still.
141
+ // (This page used to carry a .ledger-table-wrap scrollport; task 1003470 removed it
142
+ // because the wrapper broke the shell's sticky th. The separate mount outlived it.)
143
+ // Table markup stays in sessions.html; the PAGER is the kit's.
125
144
 
126
- const PAGE_SIZE = 50;
127
- const state = { offset: 0, total: 0 };
145
+ // Guarded the way every other kit consumer guards it (profile.js): the kit is a
146
+ // separate <script>, so a cache miss or a load-order slip must degrade to a named
147
+ // failure rather than an uncaught throw at parse time that also kills load().
148
+ const kit = window.OTBKit;
149
+ const PAGER_PREFIX = 'sessions';
150
+ // 50 was this page's fixed page size before the per-page control existed; keeping it
151
+ // as the default means a returning reader's first page looks exactly as it did.
152
+ const pager = kit ? kit.makePager(PAGER_PREFIX, 'otb.sessions.perPage', { defaultPerPage: 50 }) : null;
153
+ // `page`/`perPage` here are the last values that actually PAINTED. The kit moves the
154
+ // pager first and calls back second, so a failed fetch has to put it back to these —
155
+ // otherwise the bar goes on claiming a page whose rows never arrived. Keeping both
156
+ // (not just the page) means a failed per-page change rolls back cleanly too.
157
+ const state = { total: 0, shown: 0, page: 1, perPage: pager?.perPage ?? 0 };
128
158
  let loading = false;
159
+ // Two clicks can be in flight at once; only the newest may paint (the profile
160
+ // activity-log idiom, task 1003281).
161
+ let inFlight = 0;
129
162
 
163
+ // Paint the kit's bar, then apply the one thing the kit deliberately does not model:
164
+ // a fetch is in flight, so nothing may be pressed again until it lands. Only the
165
+ // DISABLING is ours — re-enabling the arrows is left to the kit, which knows whether
166
+ // there is a page on either side; re-enabling them here would light up "Prev" on
167
+ // page 1. Returns the kit's clamped flag: true = the page outran a shrunken corpus.
130
168
  function renderPager() {
131
- const pager = $('#ledger-pager');
132
- const total = state.total;
133
- if (total <= PAGE_SIZE) {
134
- // Everything fits on one page — no pager needed.
135
- pager.hidden = true;
136
- return;
169
+ const clamped = kit.renderServerPage(pager, state.total, state.shown);
170
+ const perPage = $(`#${PAGER_PREFIX}-per-page`);
171
+ if (perPage) perPage.disabled = loading;
172
+ if (loading) {
173
+ for (const id of ['prev', 'next']) {
174
+ const el = $(`#${PAGER_PREFIX}-${id}`);
175
+ if (el) el.disabled = true;
176
+ }
137
177
  }
138
- pager.hidden = false;
139
- const page = Math.floor(state.offset / PAGE_SIZE) + 1;
140
- const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
141
- const from = state.offset + 1;
142
- const to = Math.min(state.offset + PAGE_SIZE, total);
143
- $('#pager-pos').textContent = `Page ${fmtInt(page)} of ${fmtInt(pages)} · ${fmtInt(from)}–${fmtInt(to)} of ${fmtInt(total)}`;
144
- $('#pager-prev').disabled = loading || state.offset <= 0;
145
- $('#pager-next').disabled = loading || state.offset + PAGE_SIZE >= total;
178
+ return clamped;
146
179
  }
147
180
 
181
+ // Paint one page. Returns true when the pager clamped — the corpus shrank under an
182
+ // open page, so the caller must re-ask rather than leave a range that lies.
148
183
  function showData(data) {
149
184
  const sessions = Array.isArray(data.sessions) ? data.sessions : [];
150
185
  const totals = data.totals || { total: data.total || sessions.length };
151
186
  state.total = num(totals.total);
187
+ state.shown = sessions.length;
152
188
  $('#gate-scroll').hidden = true;
153
189
  $('#stats-scroll').hidden = false;
154
190
  $('#sessions-scroll').hidden = false;
155
191
  renderStats(totals);
156
192
  renderTable(sessions);
157
- renderPager();
193
+ const clamped = renderPager();
158
194
  $('#ledger-sub').textContent =
159
195
  `${fmtInt(totals.total)} session${num(totals.total) === 1 ? '' : 's'} across ${fmtInt(totals.builders)} builder${num(totals.builders) === 1 ? '' : 's'}.`;
160
196
  $('#ledger-foot').textContent =
161
197
  'Most recent first. Each load of this page is itself recorded in the audit log.';
198
+ return clamped;
162
199
  }
163
200
 
164
201
  // ---- load ----------------------------------------------------------------
165
202
 
166
- // Fetch one page at the current offset. Returns true on success.
167
- async function fetchPage() {
203
+ // Fetch the page the pager currently points at. Returns true on success.
204
+ //
205
+ // `pager.page` has ALREADY moved by the time this runs — wirePager advances it and
206
+ // then calls back — so a failure restores `state.page`/`state.perPage`, the last
207
+ // values that painted. That roll-back is what the old hand-roll did with
208
+ // `state.offset`, and it is the one piece of it worth carrying over.
209
+ async function fetchPage({ reclamps = 0 } = {}) {
210
+ const seq = ++inFlight;
168
211
  loading = true;
169
212
  renderPager();
170
213
  try {
171
214
  const res = await api.request(
172
215
  'GET',
173
- `${API}/sessions/search?order=recent&limit=${PAGE_SIZE}&offset=${state.offset}`
216
+ `${API}/sessions/search?order=recent&limit=${pager.perPage}&offset=${kit.pageOffset(pager)}`
174
217
  );
218
+ // A newer click already went out; painting this answer would show a page nobody
219
+ // is on any more.
220
+ if (seq !== inFlight) return true;
175
221
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return false; }
176
222
  if (res.status === 403) {
177
223
  showSealed('You do not have access to the session log.');
178
224
  return false;
179
225
  }
180
226
  if (!res.ok) throw new Error(`search → ${res.status}`);
181
- const data = res.data;
182
- showData(data);
227
+ const clamped = showData(res.data);
228
+ // The corpus shrank under this page (sessions pruned between loads):
229
+ // renderServerPage pulled the page back into range, so go get the rows that page
230
+ // actually holds rather than leaving a range that disagrees with the table.
231
+ // One re-ask, never a loop: a corpus shrinking on every round-trip would
232
+ // otherwise spin here, and a stale range for one paint is the smaller harm.
233
+ if (clamped && reclamps < 1) { loading = false; return fetchPage({ reclamps: reclamps + 1 }); }
234
+ state.page = pager.page;
235
+ state.perPage = pager.perPage;
183
236
  return true;
184
237
  } catch (err) {
238
+ if (seq !== inFlight) return true;
185
239
  console.error('[sessions] failed to load corpus', err);
240
+ pager.page = state.page;
241
+ pager.perPage = state.perPage;
186
242
  $('#ledger-sub').textContent = 'The session log could not be loaded. Try again.';
187
243
  toast('Could not load sessions.');
188
244
  return false;
189
245
  } finally {
190
- loading = false;
191
- renderPager();
246
+ if (seq === inFlight) {
247
+ loading = false;
248
+ renderPager();
249
+ }
192
250
  }
193
251
  }
194
252
 
195
- async function goTo(offset) {
196
- if (loading) return;
197
- const next = Math.max(0, offset);
198
- if (next === state.offset) return;
199
- const prev = state.offset;
200
- state.offset = next;
201
- const ok = await fetchPage();
202
- if (!ok) state.offset = prev; // roll back on failure so the pager stays honest
203
- else window.scrollTo({ top: 0, behavior: 'smooth' });
204
- }
205
-
206
- function wirePager() {
207
- $('#pager-prev').addEventListener('click', () => goTo(state.offset - PAGE_SIZE));
208
- $('#pager-next').addEventListener('click', () => goTo(state.offset + PAGE_SIZE));
253
+ function mountPager() {
254
+ const mount = $('#sessions-pager-mount');
255
+ if (!mount) return;
256
+ mount.innerHTML = kit.pagerBarHtml(PAGER_PREFIX, { label: 'sessions' });
257
+ // The kit has already updated `pager` by the time this fires; go get the rows that
258
+ // match it. Scrolling up only on success keeps a failed page from yanking the
259
+ // reader away from the table they were still looking at.
260
+ kit.wirePager(pager, async () => {
261
+ const ok = await fetchPage();
262
+ if (ok) window.scrollTo({ top: 0, behavior: 'smooth' });
263
+ });
209
264
  }
210
265
 
211
266
  async function load() {
@@ -234,8 +289,14 @@
234
289
  return;
235
290
  }
236
291
 
292
+ if (!kit) {
293
+ console.error('[sessions] OTBKit did not load — the pager cannot be built');
294
+ $('#ledger-sub').textContent = 'The session log could not be loaded. Try again.';
295
+ return;
296
+ }
297
+
237
298
  // 2. Fetch the first page (Archon-gated + audited server-side).
238
- wirePager();
299
+ mountPager();
239
300
  await fetchPage();
240
301
  }
241
302
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.505",
3
+ "version": "1.19.506",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.505",
9
+ "version": "1.19.506",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.505",
3
+ "version": "1.19.506",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.505'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.506'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -198,6 +198,44 @@ test('the sidebar scrolls its nav list, not the whole column', () => {
198
198
 
199
199
  // ---- 6. every hall list is paged through the kit (task 1002446) -----------
200
200
 
201
+ test('the kit is the ONLY thing in the hall that emits pager markup', () => {
202
+ // hall-ui/CLAUDE.md's "no bespoke pager" rule had one sanctioned exception for two
203
+ // releases — sessions.js, which paged server-side by offset, a thing the kit could
204
+ // not express (audited task 2179). Task 1003281 added `makeLedger({ serverPager })`
205
+ // and left the migration as its own task; it has now landed, so the hall is back to
206
+ // ONE pager contract with no footnote.
207
+ //
208
+ // A rule that only lives in prose is a rule that comes back as an eleventh pager.
209
+ // This is the machine-checkable half: the kit STAMPS every bar at runtime, so any
210
+ // .pager markup written by hand — in a page's HTML or in another script's template —
211
+ // is by construction a second implementation. Adding an exception here is where the
212
+ // conversation happens, exactly like the budgets above.
213
+ const OWNER = 'hall-kit.js';
214
+ const offenders = [];
215
+ for (const f of fs.readdirSync(HALL)) {
216
+ if (!/\.(html|js)$/.test(f) || f === OWNER) continue;
217
+ const src = read(f);
218
+ // `class="pager…"` in a template string or in static markup. The kit's own callers
219
+ // pass a PREFIX and get the markup back; they never write the class themselves.
220
+ if (/class="pager[\s"_-]/.test(src)) offenders.push(f);
221
+ }
222
+ assert.deepEqual(offenders, [],
223
+ `${offenders.join(', ')} writes pager markup by hand — call OTBKit.pagerBarHtml(prefix) `
224
+ + '(or makeLedger, which stamps it for you) instead of a second copy.');
225
+ });
226
+
227
+ test('the session log pages on the kit — the last hand-roll is gone', () => {
228
+ // The specific exception this rule carried, pinned by name so its removal cannot be
229
+ // quietly undone. Behaviour is exercised for real in tests/sessions_kit_pager.mjs;
230
+ // this is the structural half.
231
+ const js = read('sessions.js');
232
+ for (const fn of ['makePager', 'pagerBarHtml', 'wirePager', 'pageOffset', 'renderServerPage']) {
233
+ assert.match(js, new RegExp(`kit\\.${fn}\\(`), `sessions.js must page through the kit's ${fn}`);
234
+ }
235
+ assert.doesNotMatch(js, /const PAGE_SIZE\b/, 'its fixed page size is gone — per-page is the kit\'s now');
236
+ assert.match(read('sessions.html'), /hall-kit\.js/, 'and the page has to load the kit');
237
+ });
238
+
201
239
  test('the peer-recognition feed is paged through OTBKit, not by hand', () => {
202
240
  assert.match(read('hall-widgets.js'), /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
203
241
  const js = read('builders.js');
@@ -0,0 +1,312 @@
1
+ // tests/sessions_kit_pager.mjs — the session log pages on the KIT's contract, not
2
+ // its own (goal 1000072, the migration task 1003281 left behind).
3
+ //
4
+ // WHY THIS FILE EXISTS. `sessions.js` was the hall's one sanctioned hand-rolled
5
+ // pager — audited as an exception at task 2179 because the kit could only slice an
6
+ // array the browser already held, and the session corpus cannot be held. Task 1003281
7
+ // added the server-offset mode to the kit and explicitly deferred the migration:
8
+ // "sessions.js stays as it is; migrating it is its own task." This is that task, and
9
+ // an exception removed without a test is an exception waiting to be re-added.
10
+ //
11
+ // It runs the REAL hall-kit.js and the REAL sessions.js in one vm against a stub DOM
12
+ // and a fake API (the profile_activity.mjs idiom), so what is asserted is the request
13
+ // the page actually issues and the bar a reader actually sees — not a stub of the kit
14
+ // agreeing with a stub of the page.
15
+ //
16
+ // The five behaviours the migration had to keep or gain:
17
+ // 1. it asks the SERVER for one page (the corpus is never pulled into the browser)
18
+ // 2. a page click moves the offset by the reader's per-page, not a fixed 50
19
+ // 3. per-page is a real control now, and it resets to page 1 and persists
20
+ // 4. a failed fetch ROLLS BACK the pager — the bar may never claim a page whose
21
+ // rows never arrived (the one piece of the hand-roll worth keeping)
22
+ // 5. nothing is pressable while a fetch is in flight
23
+ //
24
+ // Run: node tests/sessions_kit_pager.mjs
25
+ import { strict as assert } from 'node:assert';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import vm from 'node:vm';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { makeRunner } from './helpers.mjs';
31
+
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
34
+ const KIT_JS = read('modules/hall-ui/public/hall-kit.js');
35
+ const SESSIONS_JS = read('modules/hall-ui/public/sessions.js');
36
+ const SESSIONS_HTML = read('modules/hall-ui/public/sessions.html');
37
+
38
+ const { test, summary } = makeRunner();
39
+ const tick = () => new Promise((r) => setTimeout(r, 0));
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // the harness: a stub DOM just rich enough for the kit's ids + the page's cells
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function makeEl(id) {
46
+ const listeners = new Map();
47
+ return {
48
+ id,
49
+ innerHTML: '', textContent: '', value: '',
50
+ hidden: false, disabled: false,
51
+ options: [], style: {}, dataset: {},
52
+ classList: { add() {}, remove() {}, toggle() {} },
53
+ addEventListener(ev, fn) {
54
+ if (!listeners.has(ev)) listeners.set(ev, []);
55
+ listeners.get(ev).push(fn);
56
+ },
57
+ fire(ev, arg) { for (const fn of listeners.get(ev) || []) fn(arg); },
58
+ listenerCount(ev) { return (listeners.get(ev) || []).length; },
59
+ };
60
+ }
61
+
62
+ // `rank: 'metic'` is the gate sessions.js applies before it fetches anything.
63
+ function harness({ total = 320, failAfter = null, viewerRank = 'metic', perPagePref = null, dropKit = false } = {}) {
64
+ const els = new Map();
65
+ const getEl = (id) => {
66
+ if (!els.has(id)) els.set(id, makeEl(id));
67
+ return els.get(id);
68
+ };
69
+ const documentObj = {
70
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {} } },
71
+ getElementById: getEl,
72
+ querySelector(sel) { return getEl(String(sel).replace(/^#/, '')); },
73
+ querySelectorAll() { return []; },
74
+ createElement() { return makeEl(); },
75
+ addEventListener() {},
76
+ };
77
+
78
+ const requests = [];
79
+ let calls = 0;
80
+ const rowsFor = (limit, offset) => Array.from(
81
+ { length: Math.max(0, Math.min(limit, total - offset)) },
82
+ (_, i) => ({ session_id: `sid-${offset + i}`, builder_id: 1, github_login: 'jaxri', rank: 'metic', task_ids: [], assistant_turns: 3, total_tokens: 100, tool_errors: 0, duration_seconds: 60, uploaded_at: '2026-08-01T00:00:00Z' })
83
+ );
84
+
85
+ const client = {
86
+ request: async (method, url) => {
87
+ requests.push(url);
88
+ if (url.endsWith('/me')) {
89
+ return { status: 200, ok: true, data: { builder: { id: 1, rank: viewerRank } } };
90
+ }
91
+ if (url.includes('/sessions/search')) {
92
+ calls += 1;
93
+ if (failAfter != null && calls > failAfter) return { status: 500, ok: false, data: null };
94
+ const q = new URLSearchParams(url.split('?')[1] || '');
95
+ const limit = Number(q.get('limit'));
96
+ const offset = Number(q.get('offset'));
97
+ return {
98
+ status: 200,
99
+ ok: true,
100
+ data: { sessions: rowsFor(limit, offset), totals: { total, builders: 3 } },
101
+ };
102
+ }
103
+ return { status: 404, ok: false, data: null };
104
+ },
105
+ };
106
+
107
+ const store = new Map();
108
+ // Seeded BEFORE the scripts run — makePager reads the preference at boot, so this is
109
+ // the only way to model a returning reader.
110
+ if (perPagePref != null) store.set('otb.sessions.perPage', String(perPagePref));
111
+ const windowObj = {
112
+ BongosClient: { createClient: () => client },
113
+ location: { assign() {} },
114
+ localStorage: {
115
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
116
+ setItem: (k, v) => store.set(k, String(v)),
117
+ },
118
+ scrollTo() {},
119
+ addEventListener() {},
120
+ OTB: {
121
+ escapeHtml: (s) => String(s == null ? '' : s),
122
+ $: (sel) => documentObj.querySelector(sel),
123
+ num: (n) => Number(n) || 0,
124
+ fmtInt: (n) => String(Number(n) || 0),
125
+ fmtCompact: (n) => String(Number(n) || 0),
126
+ fmtDate: (iso) => String(iso ?? '').slice(0, 10),
127
+ rankLabel: (r) => String(r ?? ''),
128
+ toast() {},
129
+ },
130
+ };
131
+
132
+ const sandbox = {
133
+ window: windowObj, document: documentObj, location: windowObj.location,
134
+ console: { error() {}, warn() {}, log() {} },
135
+ setTimeout, clearTimeout, URLSearchParams,
136
+ };
137
+ sandbox.globalThis = sandbox;
138
+ vm.createContext(sandbox);
139
+ // The real kit first — that is the point: the page is held to the shipped contract.
140
+ vm.runInContext(KIT_JS, sandbox, { filename: 'hall-kit.js' });
141
+ if (dropKit) delete sandbox.window.OTBKit;
142
+ vm.runInContext(SESSIONS_JS, sandbox, { filename: 'sessions.js' });
143
+
144
+ const searches = () => requests.filter((u) => u.includes('/sessions/search'));
145
+ const q = (n) => {
146
+ const list = searches();
147
+ const url = list[n < 0 ? list.length + n : n];
148
+ return Object.fromEntries(new URLSearchParams(url.split('?')[1]));
149
+ };
150
+ return { els, getEl, searches, q, store, kit: sandbox.window.OTBKit };
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // 1. the page markup no longer carries a pager of its own
155
+ // ---------------------------------------------------------------------------
156
+
157
+ await test('sessions.html carries a mount, not a hand-rolled pager', () => {
158
+ assert.ok(!/id="ledger-pager"/.test(SESSIONS_HTML), 'the bespoke <nav class="pager"> is gone');
159
+ assert.ok(!/id="pager-(prev|next|pos)"/.test(SESSIONS_HTML), 'its bespoke button ids are gone');
160
+ assert.match(SESSIONS_HTML, /id="sessions-pager-mount"/);
161
+ assert.match(SESSIONS_HTML, /hall-kit\.js/, 'the kit must load on this page now');
162
+ // The kit must be loaded BEFORE the page script that reads window.OTBKit at boot.
163
+ assert.ok(SESSIONS_HTML.indexOf('hall-kit.js') < SESSIONS_HTML.indexOf('sessions.js?'),
164
+ 'hall-kit.js must come before sessions.js');
165
+ // The pager mount stays OUTSIDE the horizontal scrollport, or it slides away with
166
+ // the columns on a narrow screen.
167
+ const wrapEnd = SESSIONS_HTML.indexOf('</div>', SESSIONS_HTML.indexOf('ledger-table-wrap'));
168
+ assert.ok(SESSIONS_HTML.indexOf('sessions-pager-mount') > wrapEnd,
169
+ 'the pager mount must not be inside .ledger-table-wrap');
170
+ });
171
+
172
+ await test('sessions.js hand-rolls no pager state any more', () => {
173
+ assert.ok(!/const PAGE_SIZE\b/.test(SESSIONS_JS), 'the fixed page size is gone');
174
+ assert.ok(!/state\.offset/.test(SESSIONS_JS.replace(/^\s*\/\/.*$/gm, '')),
175
+ 'the hand-rolled offset cursor is gone (comments may still explain it)');
176
+ for (const fn of ['makePager', 'pagerBarHtml', 'wirePager', 'pageOffset', 'renderServerPage']) {
177
+ assert.ok(SESSIONS_JS.includes(`kit.${fn}`), `sessions.js must use the kit's ${fn}`);
178
+ }
179
+ });
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // 2. what the page actually does, executed
183
+ // ---------------------------------------------------------------------------
184
+
185
+ await test('the first load asks the SERVER for one page — the corpus is never held', async () => {
186
+ const h = harness({ total: 320 });
187
+ await tick(); await tick();
188
+ assert.equal(h.searches().length, 1);
189
+ const p = h.q(0);
190
+ assert.equal(p.offset, '0');
191
+ assert.equal(p.limit, '50', 'the page keeps its historical 50 as the default per-page');
192
+ assert.equal(p.order, 'recent');
193
+ });
194
+
195
+ await test('Next moves the offset by the reader\'s per-page, and the bar says where they are', async () => {
196
+ const h = harness({ total: 320 });
197
+ await tick(); await tick();
198
+ h.getEl('sessions-next').fire('click');
199
+ await tick(); await tick();
200
+ assert.equal(h.searches().length, 2);
201
+ assert.equal(h.q(-1).offset, '50');
202
+ assert.equal(h.getEl('sessions-page').textContent, 'Page 2 of 7');
203
+ assert.equal(h.getEl('sessions-range').textContent, 'Showing 51–100 of 320');
204
+ assert.equal(h.getEl('sessions-pager').hidden, false);
205
+ });
206
+
207
+ await test('per-page is a real control now: it re-asks, resets to page 1, and persists', async () => {
208
+ const h = harness({ total: 320 });
209
+ await tick(); await tick();
210
+ h.getEl('sessions-next').fire('click');
211
+ await tick(); await tick();
212
+ assert.equal(h.q(-1).offset, '50', 'sanity: on page 2');
213
+
214
+ const sel = h.getEl('sessions-per-page');
215
+ sel.fire('change', { target: { value: '100' } });
216
+ await tick(); await tick();
217
+ const p = h.q(-1);
218
+ assert.equal(p.limit, '100', 'the new page size reaches the server');
219
+ assert.equal(p.offset, '0', 'a per-page change returns to page 1');
220
+ assert.equal(h.store.get('otb.sessions.perPage'), '100', 'the choice is remembered');
221
+ assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 4');
222
+ });
223
+
224
+ await test('a remembered per-page is used on the very first request of the next visit', async () => {
225
+ // The point of persisting it: a returning reader does not have to set it again, and
226
+ // does not first get a page at the default size and then a second one at theirs.
227
+ const h = harness({ total: 320, perPagePref: 25 });
228
+ await tick(); await tick();
229
+ assert.equal(h.searches().length, 1, 'one request, not a default page then a corrected one');
230
+ assert.equal(h.q(0).limit, '25');
231
+ assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 13');
232
+ });
233
+
234
+ await test('a junk stored preference falls back to the default rather than to nothing', async () => {
235
+ // localStorage is reader-writable and survives across releases; a value the option
236
+ // list no longer offers must not become the page size.
237
+ const h = harness({ total: 320, perPagePref: 'lots' });
238
+ await tick(); await tick();
239
+ assert.equal(h.q(0).limit, '50');
240
+ });
241
+
242
+ await test('a failed page ROLLS BACK — the bar never claims a page whose rows never came', async () => {
243
+ // failAfter: 1 → the first load succeeds, every page click after it 500s.
244
+ const h = harness({ total: 320, failAfter: 1 });
245
+ await tick(); await tick();
246
+ assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 7');
247
+
248
+ h.getEl('sessions-next').fire('click');
249
+ await tick(); await tick();
250
+ assert.equal(h.searches().length, 2, 'it did try');
251
+ assert.equal(h.getEl('sessions-page').textContent, 'Page 1 of 7',
252
+ 'the failed page 2 must not be left showing in the bar');
253
+ assert.equal(h.getEl('sessions-range').textContent, 'Showing 1–50 of 320',
254
+ 'and the range must still describe the rows that are actually in the table');
255
+ });
256
+
257
+ await test('a failed per-page change rolls the SIZE back too', async () => {
258
+ const h = harness({ total: 320, failAfter: 1 });
259
+ await tick(); await tick();
260
+ h.getEl('sessions-per-page').fire('change', { target: { value: '10' } });
261
+ await tick(); await tick();
262
+ h.getEl('sessions-next').fire('click');
263
+ await tick(); await tick();
264
+ // Back on the 50-row page 1, the only page whose rows ever arrived, so Next asks
265
+ // for offset 50 and not offset 10.
266
+ assert.equal(h.q(-1).limit, '50', 'the rolled-back size is what the next request uses');
267
+ });
268
+
269
+ await test('nothing is pressable while a fetch is in flight', async () => {
270
+ const h = harness({ total: 320 });
271
+ await tick(); await tick();
272
+ assert.equal(h.getEl('sessions-per-page').disabled, false, 'idle: the control is live');
273
+ assert.equal(h.getEl('sessions-next').disabled, false);
274
+ h.getEl('sessions-next').fire('click');
275
+ // deliberately not awaited — this is mid-flight
276
+ assert.equal(h.getEl('sessions-next').disabled, true, 'in flight: Next is dead');
277
+ assert.equal(h.getEl('sessions-prev').disabled, true, 'in flight: Prev is dead');
278
+ assert.equal(h.getEl('sessions-per-page').disabled, true, 'in flight: per-page is dead');
279
+ await tick(); await tick();
280
+ assert.equal(h.getEl('sessions-per-page').disabled, false, 'landed: live again');
281
+ });
282
+
283
+ await test('Prev stays dead on page 1 — the kit decides that, not the page', async () => {
284
+ const h = harness({ total: 320 });
285
+ await tick(); await tick();
286
+ assert.equal(h.getEl('sessions-prev').disabled, true, 'page 1 has nothing before it');
287
+ assert.equal(h.getEl('sessions-next').disabled, false);
288
+ });
289
+
290
+ await test('a corpus that fits one page shows no bar at all', async () => {
291
+ const h = harness({ total: 8 });
292
+ await tick(); await tick();
293
+ assert.equal(h.getEl('sessions-pager').hidden, true);
294
+ });
295
+
296
+ await test('a missing kit degrades to a named failure, not a blank page', async () => {
297
+ // The kit is a separate <script>; a cache miss must not throw at parse time and take
298
+ // the whole page — including the rank gate — down with it.
299
+ const h = harness({ total: 320, dropKit: true });
300
+ await tick(); await tick();
301
+ assert.equal(h.searches().length, 0, 'no corpus request without a pager to place it');
302
+ assert.match(h.getEl('ledger-sub').textContent, /could not be loaded/);
303
+ });
304
+
305
+ await test('the rank gate still runs before any corpus request', async () => {
306
+ const h = harness({ total: 320, viewerRank: 'xenos' });
307
+ await tick(); await tick();
308
+ assert.equal(h.searches().length, 0, 'a xenos must not even ask');
309
+ assert.equal(h.getEl('gate-scroll').hidden, false, 'they get the sealed notice');
310
+ });
311
+
312
+ summary();