@shardworks/spider-apparatus 0.1.279 → 0.1.281

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardworks/spider-apparatus",
3
- "version": "0.1.279",
3
+ "version": "0.1.281",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,17 +22,17 @@
22
22
  "hono": "^4.7.11",
23
23
  "yaml": "^2.0.0",
24
24
  "zod": "4.3.6",
25
- "@shardworks/fabricator-apparatus": "0.1.279",
26
- "@shardworks/tools-apparatus": "0.1.279",
27
- "@shardworks/clerk-apparatus": "0.1.279",
28
- "@shardworks/codexes-apparatus": "0.1.279",
29
- "@shardworks/stacks-apparatus": "0.1.279",
30
- "@shardworks/loom-apparatus": "0.1.279",
31
- "@shardworks/animator-apparatus": "0.1.279"
25
+ "@shardworks/fabricator-apparatus": "0.1.281",
26
+ "@shardworks/stacks-apparatus": "0.1.281",
27
+ "@shardworks/tools-apparatus": "0.1.281",
28
+ "@shardworks/animator-apparatus": "0.1.281",
29
+ "@shardworks/codexes-apparatus": "0.1.281",
30
+ "@shardworks/loom-apparatus": "0.1.281",
31
+ "@shardworks/clerk-apparatus": "0.1.281"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "25.5.0",
35
- "@shardworks/nexus-core": "0.1.279"
35
+ "@shardworks/nexus-core": "0.1.281"
36
36
  },
37
37
  "files": [
38
38
  "dist",
@@ -316,16 +316,25 @@ describe('feedback.js tag filter toolbar', () => {
316
316
  // ── Deep-link URL state (?feedback=ID) ──────────────────────────────────
317
317
 
318
318
  describe('feedback.js — deep-link URL state', () => {
319
- it('exposes currentUrlParams + updateUrl helpers (D9 Ratchet pattern)', () => {
319
+ it('routes URL reads/writes through window.NexusUrl (no inline helpers)', () => {
320
+ // Inline currentUrlParams / updateUrl are gone (commission moix23w5).
321
+ assert.ok(
322
+ !/function\s+currentUrlParams\s*\(/.test(feedbackJs),
323
+ 'inline currentUrlParams must not be redeclared',
324
+ );
325
+ assert.ok(
326
+ !/function\s+updateUrl\s*\(/.test(feedbackJs),
327
+ 'inline updateUrl must not be redeclared',
328
+ );
320
329
  assert.match(
321
330
  feedbackJs,
322
- /function currentUrlParams\(\)\s*\{[\s\S]*?new URLSearchParams\(window\.location\.search\)/,
323
- 'currentUrlParams reads window.location.search live',
331
+ /window\.NexusUrl\.read\(\)/,
332
+ 'feedback.js should read URL state via window.NexusUrl.read',
324
333
  );
325
334
  assert.match(
326
335
  feedbackJs,
327
- /function updateUrl\(changes\)\s*\{[\s\S]*?window\.history\.pushState/,
328
- 'updateUrl pushes via history.pushState',
336
+ /window\.NexusUrl\.update\(/,
337
+ 'feedback.js should write URL state via window.NexusUrl.update',
329
338
  );
330
339
  });
331
340
 
@@ -336,21 +345,29 @@ describe('feedback.js — deep-link URL state', () => {
336
345
  assert.ok(block, 'should find showDetail body');
337
346
  assert.match(
338
347
  block[0],
339
- /updateUrl\(\{\s*feedback:\s*currentRequest\.id\s*\}\)/,
340
- 'showDetail pushes ?feedback=<currentRequest.id> keyed on the id, not the index',
348
+ /window\.NexusUrl\.update\(\{\s*feedback:\s*currentRequest\.id\s*\}\s*,\s*\{\s*push:\s*true\s*\}\)/,
349
+ 'showDetail pushes ?feedback=<currentRequest.id> via NexusUrl.update with push: true',
341
350
  );
342
351
  assert.match(block[0], /skipUrlPush/, 'showDetail accepts a skipUrlPush opt');
343
352
  });
344
353
 
345
- it('navigateToList clears ?feedback via updateUrl({feedback: null}) never pops history (D11)', () => {
354
+ it('navigateToList clears ?feedback (and ?tag=) via NexusUrl with push: true (D11/D12)', () => {
346
355
  const block = feedbackJs.match(
347
356
  /function navigateToList\((?:opts)?\)[\s\S]*?fetchList\(\);[\s\S]*?startPoll\(\);\s*\}/,
348
357
  );
349
358
  assert.ok(block, 'should find navigateToList body');
350
359
  assert.match(
351
360
  block[0],
352
- /updateUrl\(\{\s*feedback:\s*null\s*\}\)/,
353
- 'navigateToList should push a clean URL via updateUrl({feedback: null})',
361
+ /window\.NexusUrl\.update\(\{[^}]*feedback:\s*null[^}]*\}\s*,\s*\{\s*push:\s*true\s*\}\)/,
362
+ 'navigateToList must push a clean URL with feedback: null',
363
+ );
364
+ // D12 — closing the detail also clears any per-detail ?tag= keys
365
+ // via the omit-defaults rule. The same NexusUrl.update call drops
366
+ // both keys at once.
367
+ assert.match(
368
+ block[0],
369
+ /tag:\s*null/,
370
+ 'navigateToList must also drop ?tag= keys when the detail closes (D12)',
354
371
  );
355
372
  assert.ok(
356
373
  !/window\.history\.back\s*\(/.test(block[0]),
@@ -370,8 +387,8 @@ describe('feedback.js — deep-link URL state', () => {
370
387
  assert.ok(block, 'should find popstate handler body');
371
388
  assert.match(
372
389
  block[0],
373
- /currentUrlParams\(\)\.get\(['"]feedback['"]\)/,
374
- 'popstate handler reads ?feedback from the new URL',
390
+ /readUrlState\(\)/,
391
+ 'popstate handler reads URL state via the central readUrlState helper',
375
392
  );
376
393
  assert.match(
377
394
  block[0],
@@ -380,11 +397,11 @@ describe('feedback.js — deep-link URL state', () => {
380
397
  );
381
398
  });
382
399
 
383
- it('init reads ?feedback=ID and resolves it via showDetailById', () => {
400
+ it('init reads URL state and resolves the deep-link via showDetailById', () => {
384
401
  assert.match(
385
402
  feedbackJs,
386
- /var initialFeedbackId\s*=\s*currentUrlParams\(\)\.get\(['"]feedback['"]\)/,
387
- 'init reads ?feedback from the URL',
403
+ /var initialFeedbackId\s*=\s*readUrlState\(\)/,
404
+ 'init calls readUrlState before fetching the list',
388
405
  );
389
406
  assert.match(
390
407
  feedbackJs,
@@ -399,7 +416,7 @@ describe('feedback.js — deep-link URL state', () => {
399
416
  );
400
417
  assert.ok(block, 'should find renderFeedbackNotFound body');
401
418
  assert.ok(
402
- !/updateUrl/.test(block[0]),
419
+ !/NexusUrl\.update/.test(block[0]),
403
420
  'renderFeedbackNotFound must not rewrite the URL',
404
421
  );
405
422
  assert.match(
@@ -408,4 +425,63 @@ describe('feedback.js — deep-link URL state', () => {
408
425
  'renderFeedbackNotFound surfaces a "not found" message',
409
426
  );
410
427
  });
428
+
429
+ it('list-page status filter writes through NexusUrl.update (replace, no push: true)', () => {
430
+ const writer = feedbackJs.match(
431
+ /function writeStatusFilterToUrl\(\)[\s\S]*?(?=\n function )/,
432
+ );
433
+ assert.ok(writer, 'writeStatusFilterToUrl should be defined');
434
+ assert.match(
435
+ writer[0],
436
+ /window\.NexusUrl\.update\(\{\s*status:[\s\S]*?\}\s*\)/,
437
+ 'status filter must call NexusUrl.update without { push: true }',
438
+ );
439
+ assert.ok(
440
+ !/push:\s*true/.test(writer[0]),
441
+ 'status filter writes must use replaceState (D5 default)',
442
+ );
443
+ });
444
+
445
+ it('per-detail tag filter writes ?tag= via repeated keys (D12 + D3)', () => {
446
+ const writer = feedbackJs.match(
447
+ /function writeTagFilterToUrl\(\)[\s\S]*?(?=\n function )/,
448
+ );
449
+ assert.ok(writer, 'writeTagFilterToUrl should be defined');
450
+ assert.match(
451
+ writer[0],
452
+ /Object\.keys\(activeTagFilters\)/,
453
+ 'tag filter writer reads from activeTagFilters',
454
+ );
455
+ assert.match(
456
+ writer[0],
457
+ /window\.NexusUrl\.update\(\{\s*tag:[\s\S]*?\}\s*\)/,
458
+ 'tag filter writer calls NexusUrl.update with the tag key (no push: true)',
459
+ );
460
+ assert.ok(
461
+ !/push:\s*true/.test(writer[0]),
462
+ 'tag filter changes must use replaceState (D5 default)',
463
+ );
464
+ });
465
+
466
+ it('readUrlState validates ?status= against STATUS_VALUES and surfaces fail-loud errors (D6)', () => {
467
+ const reader = feedbackJs.match(
468
+ /function readUrlState\(\)[\s\S]*?(?=\n function )/,
469
+ );
470
+ assert.ok(reader, 'readUrlState should be defined');
471
+ assert.match(
472
+ reader[0],
473
+ /STATUS_VALUES\.indexOf/,
474
+ 'readUrlState must validate ?status= against STATUS_VALUES',
475
+ );
476
+ assert.match(
477
+ reader[0],
478
+ /showUrlError\(/,
479
+ 'readUrlState must surface invalid values via showUrlError',
480
+ );
481
+ assert.match(
482
+ reader[0],
483
+ /params\.getAll\(['"]tag['"]\)/,
484
+ 'readUrlState must read ?tag= as a repeated-key array',
485
+ );
486
+ });
411
487
  });
@@ -35,35 +35,80 @@
35
35
  var successToast = document.getElementById('success-toast');
36
36
 
37
37
  // ── URL handling ───────────────────────────────────────────────────────
38
+ //
39
+ // All deep-linkable view state for this page rides on
40
+ // `window.NexusUrl` — the shared helper auto-injected by oculus's
41
+ // chrome pass. The earlier inline `currentUrlParams` / `updateUrl`
42
+ // copies are gone (commission moix23w5).
43
+ //
44
+ // URL keys:
45
+ // ?status= pending | completed | rejected
46
+ // List-page status filter. Default 'pending'.
47
+ // ?feedback=ID Detail deep-link; pushes (D5).
48
+ // ?tag=A&tag=B Per-detail tag filter (D12). Repeated keys; clears
49
+ // itself when the detail closes via the omit-defaults
50
+ // rule.
51
+
52
+ var STATUS_VALUES = ['pending', 'completed', 'rejected'];
53
+ var DEFAULT_STATUS = 'pending';
54
+
55
+ function showUrlError(msg) {
56
+ var el = document.getElementById('url-error-banner');
57
+ if (!el) return;
58
+ var line = document.createElement('div');
59
+ line.textContent = msg;
60
+ el.appendChild(line);
61
+ el.style.display = 'block';
62
+ }
63
+
64
+ function clearUrlErrors() {
65
+ var el = document.getElementById('url-error-banner');
66
+ if (!el) return;
67
+ el.innerHTML = '';
68
+ el.style.display = 'none';
69
+ }
70
+
71
+ /** Persist the list-page status filter to the URL (replace, D5). */
72
+ function writeStatusFilterToUrl() {
73
+ var status = statusFilterEl ? statusFilterEl.value : DEFAULT_STATUS;
74
+ window.NexusUrl.update({
75
+ status: status === DEFAULT_STATUS ? null : status,
76
+ });
77
+ }
38
78
 
39
- /**
40
- * Read the current querystring as a `URLSearchParams`. Live snapshot.
41
- */
42
- function currentUrlParams() {
43
- return new URLSearchParams(window.location.search);
79
+ /** Persist the per-detail tag filter to the URL (replace, D5). */
80
+ function writeTagFilterToUrl() {
81
+ var keys = Object.keys(activeTagFilters);
82
+ window.NexusUrl.update({
83
+ tag: keys.length === 0 ? null : keys,
84
+ });
44
85
  }
45
86
 
46
87
  /**
47
- * Apply the given key/value changes to the current querystring and
48
- * `pushState` the result. Null/undefined/empty value deletes the key.
49
- * Mirrors Ratchet's `updateUrl` (D9). The feedback param shape is
50
- * `?feedback=ID` (D10) — keyed on the request id (req.id), which is
51
- * stable across the list reorderings the 12 s polling loop can
52
- * trigger. The legacy index-based showDetail path now translates
53
- * index → id at the click site.
88
+ * Read URL state into the page. Validates the status filter and any
89
+ * tag values; unknowns fail loud (D6). Returns the deep-link
90
+ * feedback id, if any.
54
91
  */
55
- function updateUrl(changes) {
56
- var params = currentUrlParams();
57
- var keys = Object.keys(changes);
58
- for (var i = 0; i < keys.length; i++) {
59
- var key = keys[i];
60
- var value = changes[key];
61
- if (value === null || value === undefined || value === '') params.delete(key);
62
- else params.set(key, value);
92
+ function readUrlState() {
93
+ clearUrlErrors();
94
+ var params = window.NexusUrl.read();
95
+
96
+ var status = params.get('status');
97
+ if (status !== null) {
98
+ if (STATUS_VALUES.indexOf(status) !== -1) {
99
+ if (statusFilterEl) statusFilterEl.value = status;
100
+ } else {
101
+ showUrlError('Unknown feedback status "' + status + '". Expected one of: ' + STATUS_VALUES.join(', ') + '.');
102
+ }
103
+ }
104
+
105
+ var tags = params.getAll('tag');
106
+ activeTagFilters = {};
107
+ for (var i = 0; i < tags.length; i++) {
108
+ activeTagFilters[tags[i]] = true;
63
109
  }
64
- var qs = params.toString();
65
- var next = window.location.pathname + (qs ? '?' + qs : '');
66
- window.history.pushState({}, '', next);
110
+
111
+ return params.get('feedback');
67
112
  }
68
113
 
69
114
  // ── Helpers ────────────────────────────────────────────────────────────
@@ -180,8 +225,9 @@
180
225
 
181
226
  // Centralised URL push (D12) — keyed on the request id so the URL
182
227
  // survives list reorderings between the 12 s polls. Translation
183
- // index → id happens here, not at the click site.
184
- if (!skipUrlPush) updateUrl({ feedback: currentRequest.id });
228
+ // index → id happens here, not at the click site. Detail open is
229
+ // a navigation event (push: true).
230
+ if (!skipUrlPush) window.NexusUrl.update({ feedback: currentRequest.id }, { push: true });
185
231
 
186
232
  // Initialize local answers from server state
187
233
  localAnswers = {};
@@ -231,7 +277,7 @@
231
277
  })
232
278
  .then(function (req) {
233
279
  if (!req || !req.id) {
234
- if (!skipUrlPush) updateUrl({ feedback: id });
280
+ if (!skipUrlPush) window.NexusUrl.update({ feedback: id }, { push: true });
235
281
  renderFeedbackNotFound(id);
236
282
  return;
237
283
  }
@@ -242,7 +288,7 @@
242
288
  showDetail(requests.length - 1, { skipUrlPush: skipUrlPush });
243
289
  })
244
290
  .catch(function () {
245
- if (!skipUrlPush) updateUrl({ feedback: id });
291
+ if (!skipUrlPush) window.NexusUrl.update({ feedback: id }, { push: true });
246
292
  renderFeedbackNotFound(id);
247
293
  });
248
294
  }
@@ -332,6 +378,7 @@
332
378
  activeTagFilters[tag] = true;
333
379
  e.target.classList.add('active');
334
380
  }
381
+ writeTagFilterToUrl();
335
382
  applyTagFilters();
336
383
  } else if (e.target.matches('.tag-filter-clear')) {
337
384
  activeTagFilters = {};
@@ -339,6 +386,7 @@
339
386
  for (var j = 0; j < btns.length; j++) {
340
387
  btns[j].classList.remove('active');
341
388
  }
389
+ writeTagFilterToUrl();
342
390
  applyTagFilters();
343
391
  }
344
392
  });
@@ -604,8 +652,9 @@
604
652
  listView.style.display = '';
605
653
  // D11: push a clean URL so deep-link entries survive the Back
606
654
  // button. Never pop history — the operator may have arrived
607
- // directly at ?feedback=ID.
608
- if (!skipUrlPush) updateUrl({ feedback: null });
655
+ // directly at ?feedback=ID. Closing the detail also drops the
656
+ // per-detail tag filter via the omit-defaults rule (D12).
657
+ if (!skipUrlPush) window.NexusUrl.update({ feedback: null, tag: null }, { push: true });
609
658
  fetchList();
610
659
  startPoll();
611
660
  }
@@ -683,6 +732,7 @@
683
732
  });
684
733
 
685
734
  statusFilterEl.addEventListener('change', function () {
735
+ writeStatusFilterToUrl();
686
736
  requests = [];
687
737
  renderList();
688
738
  fetchList();
@@ -853,11 +903,12 @@
853
903
 
854
904
  // ── Browser navigation (popstate) ──────────────────────────────────────
855
905
 
856
- // Read ?feedback=ID from the new URL and either re-open the matching
857
- // detail (skipUrlPush=true) or return to the list. Pairs with the
858
- // central push inside showDetail (D11/D12).
906
+ // Restore the full URL state on Back / Forward — ?status=,
907
+ // ?feedback=, and ?tag= each round-trip independently. The
908
+ // popstate-driven path uses skipUrlPush so it never re-pushes the
909
+ // URL the browser already updated.
859
910
  window.addEventListener('popstate', function () {
860
- var feedbackId = currentUrlParams().get('feedback');
911
+ var feedbackId = readUrlState();
861
912
  if (feedbackId) {
862
913
  showDetailById(feedbackId, { skipUrlPush: true });
863
914
  } else {
@@ -866,17 +917,19 @@
866
917
  });
867
918
 
868
919
  // ── Init ───────────────────────────────────────────────────────────────
869
-
920
+ //
921
+ // Read URL state on first paint so the status filter, tag filter,
922
+ // and ?feedback= deep-link survive refresh and copy-paste. The list
923
+ // fetch already reads statusFilterEl.value, so updating that input
924
+ // before fetchList() is enough to apply the URL-restored status.
925
+ var initialFeedbackId = readUrlState();
870
926
  fetchList();
871
927
  startPoll();
872
928
 
873
929
  // Deep-link: ?feedback=ID — open that request's detail after the
874
- // first list fetch lands. The list-fetch is async so we wait for it
875
- // by polling `requests.length` once via a microtask; on a miss, the
876
- // /api/input/request-show fallback inside showDetailById handles it.
877
- // A missing/deleted/mistyped id renders a "not found" state without
878
- // rewriting the URL (D16).
879
- var initialFeedbackId = currentUrlParams().get('feedback');
930
+ // first list fetch lands. On a miss, the /api/input/request-show
931
+ // fallback inside showDetailById handles it. A missing/deleted/
932
+ // mistyped id renders a "not found" state without rewriting the URL.
880
933
  if (initialFeedbackId) {
881
934
  showDetailById(initialFeedbackId, { skipUrlPush: true });
882
935
  }
@@ -10,6 +10,14 @@
10
10
  <main style="padding: 24px;">
11
11
  <h1>Feedback</h1>
12
12
 
13
+ <!--
14
+ URL-state validation banner (D6 fail-loud). Shown when the page is
15
+ loaded with an invalid filter value (e.g. ?status=bogus). The banner
16
+ appends one line per invalid value; pages do not silently fall back
17
+ to a default.
18
+ -->
19
+ <div id="url-error-banner" class="error-msg" style="display:none;margin-bottom:12px;padding:0.6rem 0.8rem;background:var(--bg2,#1a1b26);border:1px solid var(--red,#f7768e);border-radius:6px;font-size:0.9rem;color:var(--red,#f7768e)"></div>
20
+
13
21
  <!-- List view -->
14
22
  <div id="list-view">
15
23
  <div class="toolbar">
@@ -24,6 +24,14 @@
24
24
 
25
25
  <h1>Spider</h1>
26
26
 
27
+ <!--
28
+ URL-state validation banner (D6 fail-loud). Shown when the page is
29
+ loaded with an invalid filter value (e.g. ?status=bogus). The banner
30
+ appends one line per invalid value; pages do not silently fall back
31
+ to a default.
32
+ -->
33
+ <div id="url-error-banner" class="error-msg" style="display:none;margin-bottom:12px;padding:0.6rem 0.8rem;background:var(--bg2,#1a1b26);border:1px solid var(--red,#f7768e);border-radius:6px;font-size:0.9rem;color:var(--red,#f7768e)"></div>
34
+
27
35
  <div class="tab-bar">
28
36
  <button class="tab active" data-tab="rigs">Rigs</button>
29
37
  <button class="tab" data-tab="config">Config</button>
@@ -1917,33 +1917,37 @@ describe('spider.js engine-detail attempt-history details', () => {
1917
1917
  // ── Deep-link URL state (?rig=ID) ──────────────────────────────────────
1918
1918
 
1919
1919
  describe('spider.js — deep-link URL state', () => {
1920
- it('exposes currentUrlParams + updateUrl helpers (D9 Ratchet pattern)', () => {
1921
- assert.match(
1922
- spiderJs,
1923
- /function currentUrlParams\(\)\s*\{[\s\S]*?new URLSearchParams\(window\.location\.search\)/,
1924
- 'currentUrlParams reads window.location.search live',
1920
+ it('routes URL reads/writes through window.NexusUrl (no inline helpers)', () => {
1921
+ // Inline currentUrlParams / updateUrl are gone (commission moix23w5).
1922
+ assert.ok(
1923
+ !/function\s+currentUrlParams\s*\(/.test(spiderJs),
1924
+ 'inline currentUrlParams must not be redeclared',
1925
+ );
1926
+ assert.ok(
1927
+ !/function\s+updateUrl\s*\(/.test(spiderJs),
1928
+ 'inline updateUrl must not be redeclared',
1925
1929
  );
1926
1930
  assert.match(
1927
1931
  spiderJs,
1928
- /function updateUrl\(changes\)\s*\{[\s\S]*?window\.history\.pushState/,
1929
- 'updateUrl pushes via history.pushState',
1932
+ /window\.NexusUrl\.read\(\)/,
1933
+ 'spider.js should read URL state via window.NexusUrl.read',
1930
1934
  );
1931
1935
  assert.match(
1932
1936
  spiderJs,
1933
- /function updateUrl\(changes\)[\s\S]*?params\.delete\(key\)/,
1934
- 'updateUrl deletes the key when value is null/undefined/empty',
1937
+ /window\.NexusUrl\.update\(/,
1938
+ 'spider.js should write URL state via window.NexusUrl.update',
1935
1939
  );
1936
1940
  });
1937
1941
 
1938
- it('showRigDetail pushes ?rig=ID via the central updateUrl call (D12)', () => {
1942
+ it('showRigDetail pushes ?rig=ID via NexusUrl with push: true (D12)', () => {
1939
1943
  const block = spiderJs.match(
1940
1944
  /function showRigDetail\(rig(?:, opts)?\)[\s\S]*?(?=\n function )/,
1941
1945
  );
1942
1946
  assert.ok(block, 'should find showRigDetail body');
1943
1947
  assert.match(
1944
1948
  block[0],
1945
- /updateUrl\(\{\s*rig:\s*rig\.id\s*\}\)/,
1946
- 'showRigDetail should push ?rig=<rig.id> when not skipUrlPush',
1949
+ /window\.NexusUrl\.update\(\{\s*rig:\s*rig\.id\s*\}\s*,\s*\{\s*push:\s*true\s*\}\)/,
1950
+ 'showRigDetail must push ?rig=<rig.id> when not skipUrlPush',
1947
1951
  );
1948
1952
  assert.match(
1949
1953
  block[0],
@@ -1952,15 +1956,15 @@ describe('spider.js — deep-link URL state', () => {
1952
1956
  );
1953
1957
  });
1954
1958
 
1955
- it('backToList clears ?rig via updateUrl({rig: null}) — never history.back (D11)', () => {
1959
+ it('backToList clears ?rig via NexusUrl with push: true — never history.back (D11)', () => {
1956
1960
  const block = spiderJs.match(
1957
1961
  /function backToList\((?:opts)?\)[\s\S]*?(?=\n function )/,
1958
1962
  );
1959
1963
  assert.ok(block, 'should find backToList body');
1960
1964
  assert.match(
1961
1965
  block[0],
1962
- /updateUrl\(\{\s*rig:\s*null\s*\}\)/,
1963
- 'backToList should push a clean URL via updateUrl({rig: null})',
1966
+ /window\.NexusUrl\.update\(\{\s*rig:\s*null\s*\}\s*,\s*\{\s*push:\s*true\s*\}\)/,
1967
+ 'backToList must push a clean URL via NexusUrl.update({rig: null}, {push: true})',
1964
1968
  );
1965
1969
  assert.ok(
1966
1970
  !/history\.back\(/.test(block[0]),
@@ -1980,8 +1984,8 @@ describe('spider.js — deep-link URL state', () => {
1980
1984
  assert.ok(block, 'should find popstate handler body');
1981
1985
  assert.match(
1982
1986
  block[0],
1983
- /currentUrlParams\(\)\.get\(['"]rig['"]\)/,
1984
- 'popstate handler reads ?rig from the new URL',
1987
+ /readUrlState\(\)/,
1988
+ 'popstate handler should restore filter state via readUrlState',
1985
1989
  );
1986
1990
  assert.match(
1987
1991
  block[0],
@@ -1990,15 +1994,15 @@ describe('spider.js — deep-link URL state', () => {
1990
1994
  );
1991
1995
  });
1992
1996
 
1993
- it('init reads ?rig=ID and opens the matching detail after the rig list lands', () => {
1997
+ it('init reads URL state and opens the deep-linked rig after the list lands', () => {
1994
1998
  assert.match(
1995
1999
  spiderJs,
1996
- /currentUrlParams\(\)\.get\(['"]rig['"]\)/,
1997
- 'init reads ?rig from the URL',
2000
+ /var\s+initialState\s*=\s*readUrlState\(\)/,
2001
+ 'init should call readUrlState before fetching rigs',
1998
2002
  );
1999
2003
  assert.match(
2000
2004
  spiderJs,
2001
- /showRigDetailById\(\s*initialRigId\s*,\s*\{\s*skipUrlPush:\s*true\s*\}\s*\)/,
2005
+ /showRigDetailById\(\s*initialState\.rigId\s*,\s*\{\s*skipUrlPush:\s*true\s*\}\s*\)/,
2002
2006
  'init opens the detail via showRigDetailById with skipUrlPush=true',
2003
2007
  );
2004
2008
  });
@@ -2008,10 +2012,10 @@ describe('spider.js — deep-link URL state', () => {
2008
2012
  /function renderRigDetailNotFound\([\s\S]*?(?=\n function )/,
2009
2013
  );
2010
2014
  assert.ok(block, 'should find renderRigDetailNotFound body');
2011
- // The function itself must not call updateUrl — the URL is left
2012
- // alone so the operator can recover by editing the address bar.
2015
+ // The function itself must not call NexusUrl.update — the URL is
2016
+ // left alone so the operator can recover by editing the address bar.
2013
2017
  assert.ok(
2014
- !/updateUrl/.test(block[0]),
2018
+ !/NexusUrl\.update/.test(block[0]),
2015
2019
  'renderRigDetailNotFound must not rewrite the URL',
2016
2020
  );
2017
2021
  assert.match(
@@ -2022,14 +2026,76 @@ describe('spider.js — deep-link URL state', () => {
2022
2026
  });
2023
2027
 
2024
2028
  it('does not push a tab=… or engine=… URL param (D13/D14 explicit out-of-scope)', () => {
2029
+ // Filter-shaped only (D13 narrow reading): the rigs page must
2030
+ // never write ?tab= or ?engine= to the URL. These assertions stay
2031
+ // in place verbatim from the pre-migration test suite.
2025
2032
  assert.ok(
2026
- !/updateUrl\(\{\s*tab:/.test(spiderJs),
2033
+ !/NexusUrl\.update\(\{\s*tab:/.test(spiderJs),
2027
2034
  'tab state must remain client-only (D14)',
2028
2035
  );
2029
2036
  assert.ok(
2030
- !/updateUrl\(\{\s*engine:/.test(spiderJs),
2037
+ !/NexusUrl\.update\(\{\s*engine:/.test(spiderJs),
2031
2038
  'engine selection must remain client state (D13)',
2032
2039
  );
2040
+ // Defensive — also forbid the legacy updateUrl shape (which the
2041
+ // page no longer carries, but which a future regression could
2042
+ // reintroduce).
2043
+ assert.ok(
2044
+ !/updateUrl\(\{\s*tab:/.test(spiderJs),
2045
+ 'tab state must remain client-only (D14) — legacy form too',
2046
+ );
2047
+ assert.ok(
2048
+ !/updateUrl\(\{\s*engine:/.test(spiderJs),
2049
+ 'engine selection must remain client state (D13) — legacy form too',
2050
+ );
2051
+ });
2052
+
2053
+ it('rigs filter state writes through NexusUrl.update (replace, no push: true)', () => {
2054
+ const writer = spiderJs.match(
2055
+ /function writeRigFiltersToUrl\(\)[\s\S]*?(?=\n function )/,
2056
+ );
2057
+ assert.ok(writer, 'writeRigFiltersToUrl should be defined');
2058
+ assert.match(
2059
+ writer[0],
2060
+ /window\.NexusUrl\.update\(\{[\s\S]*?status:[\s\S]*?\}\s*\)/,
2061
+ 'rig filter writer must call NexusUrl.update without { push: true }',
2062
+ );
2063
+ assert.ok(
2064
+ !/push:\s*true/.test(writer[0]),
2065
+ 'rig filter writes must use replaceState (D5 default) — no push: true',
2066
+ );
2067
+ // Each filter key the rigs page tracks must appear in the writer.
2068
+ for (const key of ['status', "'writ-filter'", 'from', 'to', 'sort', 'dir']) {
2069
+ assert.match(
2070
+ writer[0],
2071
+ new RegExp(`${key.replace(/'/g, "\\'")}\\s*:`),
2072
+ `rig filter writer must include the ${key} key`,
2073
+ );
2074
+ }
2075
+ });
2076
+
2077
+ it('readUrlState validates filter values and surfaces fail-loud errors (D6)', () => {
2078
+ const reader = spiderJs.match(
2079
+ /function readUrlState\(\)[\s\S]*?(?=\n function )/,
2080
+ );
2081
+ assert.ok(reader, 'readUrlState should be defined');
2082
+ // The reader must call showUrlError when validation fails.
2083
+ assert.match(
2084
+ reader[0],
2085
+ /showUrlError\(/,
2086
+ 'readUrlState must surface validation errors via showUrlError',
2087
+ );
2088
+ // It must validate ?status= against the known status set.
2089
+ assert.match(
2090
+ reader[0],
2091
+ /STATUS_VALUES\.indexOf/,
2092
+ 'readUrlState must validate ?status= against STATUS_VALUES',
2093
+ );
2094
+ assert.match(
2095
+ reader[0],
2096
+ /SORT_FIELDS\.indexOf/,
2097
+ 'readUrlState must validate ?sort= against SORT_FIELDS',
2098
+ );
2033
2099
  });
2034
2100
  });
2035
2101
 
@@ -77,33 +77,115 @@
77
77
  }
78
78
 
79
79
  // ── URL handling ───────────────────────────────────────────────────────
80
+ //
81
+ // All deep-linkable view state for this page rides on
82
+ // `window.NexusUrl` — the shared helper auto-injected by oculus's
83
+ // chrome pass. The earlier inline `currentUrlParams` / `updateUrl`
84
+ // copies are gone (commission moix23w5).
85
+ //
86
+ // URL keys for the rigs tab:
87
+ // ?status= running | completed | failed | cancelled
88
+ // (matches the server-side ?status= already sent
89
+ // to /api/rig/list per D8). Default '' (All).
90
+ // ?writ-filter= Substring filter against rig.writTitle / writId.
91
+ // Default ''.
92
+ // ?from= ISO date (yyyy-mm-dd). Inclusive lower bound.
93
+ // ?to= ISO date. Inclusive upper bound (compared with
94
+ // T23:59:59 suffix).
95
+ // ?sort= status | id | writId | createdAt
96
+ // Default 'createdAt'; omitted when default.
97
+ // ?dir= asc | desc. Default 'desc'.
98
+ // ?rig=ID Detail deep-link; pushes (D5).
99
+ //
100
+ // Tab state (?tab=) and engine selection (?engine=) are deliberately
101
+ // NOT URL-tracked (D13 — narrow reading). The spider-ui tests assert
102
+ // those keys never appear; do not introduce them.
103
+
104
+ var SORT_FIELDS = ['status', 'id', 'writId', 'createdAt'];
105
+ var SORT_DIRS = ['asc', 'desc'];
106
+ var STATUS_VALUES = ['', 'running', 'completed', 'failed', 'cancelled'];
107
+
108
+ /** Append a fail-loud message to the URL-error banner (D6). */
109
+ function showUrlError(msg) {
110
+ var el = document.getElementById('url-error-banner');
111
+ if (!el) return;
112
+ var line = document.createElement('div');
113
+ line.textContent = msg;
114
+ el.appendChild(line);
115
+ el.style.display = 'block';
116
+ }
80
117
 
81
- /**
82
- * Read the current querystring as a `URLSearchParams`. Live snapshot —
83
- * read at call time, never cached, so reasoning stays local.
84
- */
85
- function currentUrlParams() {
86
- return new URLSearchParams(window.location.search);
118
+ function clearUrlErrors() {
119
+ var el = document.getElementById('url-error-banner');
120
+ if (!el) return;
121
+ el.innerHTML = '';
122
+ el.style.display = 'none';
123
+ }
124
+
125
+ /** Persist the current rigs-list filter state to the URL (replace, D5). */
126
+ function writeRigFiltersToUrl() {
127
+ var writFilter = (document.getElementById('writ-filter') || {}).value || '';
128
+ var dateFrom = (document.getElementById('date-from') || {}).value || '';
129
+ var dateTo = (document.getElementById('date-to') || {}).value || '';
130
+ window.NexusUrl.update({
131
+ status: currentStatusFilter === '' ? null : currentStatusFilter,
132
+ 'writ-filter': writFilter === '' ? null : writFilter,
133
+ from: dateFrom === '' ? null : dateFrom,
134
+ to: dateTo === '' ? null : dateTo,
135
+ sort: sortField === 'createdAt' ? null : sortField,
136
+ dir: sortDir === 'desc' ? null : sortDir,
137
+ });
87
138
  }
88
139
 
89
140
  /**
90
- * Apply the given key/value changes to the current querystring and
91
- * `pushState` the result. Null/undefined/empty value deletes the key.
92
- * Mirrors Ratchet's `updateUrl` (D9). Tab state and engine selection
93
- * are deliberately NOT round-tripped through this helper (D13/D14).
141
+ * Read the URL filter state into module-level variables and return
142
+ * the deep-link rig id (if any). Validates each value against its
143
+ * known set; unknowns surface a fail-loud banner per D6 without
144
+ * applying the value.
94
145
  */
95
- function updateUrl(changes) {
96
- var params = currentUrlParams();
97
- var keys = Object.keys(changes);
98
- for (var i = 0; i < keys.length; i++) {
99
- var key = keys[i];
100
- var value = changes[key];
101
- if (value === null || value === undefined || value === '') params.delete(key);
102
- else params.set(key, value);
103
- }
104
- var qs = params.toString();
105
- var next = window.location.pathname + (qs ? '?' + qs : '');
106
- window.history.pushState({}, '', next);
146
+ function readUrlState() {
147
+ clearUrlErrors();
148
+ var params = window.NexusUrl.read();
149
+
150
+ var status = params.get('status');
151
+ if (status !== null) {
152
+ if (STATUS_VALUES.indexOf(status) !== -1) {
153
+ currentStatusFilter = status;
154
+ } else {
155
+ showUrlError('Unknown rig status "' + status + '". Expected one of: running, completed, failed, cancelled.');
156
+ }
157
+ }
158
+
159
+ var sort = params.get('sort');
160
+ if (sort !== null) {
161
+ if (SORT_FIELDS.indexOf(sort) !== -1) sortField = sort;
162
+ else showUrlError('Unknown sort column "' + sort + '". Expected one of: ' + SORT_FIELDS.join(', ') + '.');
163
+ }
164
+ var dir = params.get('dir');
165
+ if (dir !== null) {
166
+ if (SORT_DIRS.indexOf(dir) !== -1) sortDir = dir;
167
+ else showUrlError('Unknown sort direction "' + dir + '". Expected "asc" or "desc".');
168
+ }
169
+
170
+ return {
171
+ rigId: params.get('rig'),
172
+ writFilter: params.get('writ-filter'),
173
+ dateFrom: params.get('from'),
174
+ dateTo: params.get('to'),
175
+ };
176
+ }
177
+
178
+ /** Sync the toolbar UI to current filter state. */
179
+ function syncRigFilterUiFromState() {
180
+ var statusEl = document.getElementById('status-filter');
181
+ if (statusEl) statusEl.value = currentStatusFilter;
182
+ var params = window.NexusUrl.read();
183
+ var writEl = document.getElementById('writ-filter');
184
+ if (writEl) writEl.value = params.get('writ-filter') || '';
185
+ var fromEl = document.getElementById('date-from');
186
+ if (fromEl) fromEl.value = params.get('from') || '';
187
+ var toEl = document.getElementById('date-to');
188
+ if (toEl) toEl.value = params.get('to') || '';
107
189
  }
108
190
 
109
191
  // ── Utility ────────────────────────────────────────────────────────────
@@ -918,12 +1000,12 @@
918
1000
  if (rig && rig.id) {
919
1001
  showRigDetail(rig, { skipUrlPush: skipUrlPush });
920
1002
  } else {
921
- if (!skipUrlPush) updateUrl({ rig: id });
1003
+ if (!skipUrlPush) window.NexusUrl.update({ rig: id }, { push: true });
922
1004
  renderRigDetailNotFound(id);
923
1005
  }
924
1006
  })
925
1007
  .catch(function () {
926
- if (!skipUrlPush) updateUrl({ rig: id });
1008
+ if (!skipUrlPush) window.NexusUrl.update({ rig: id }, { push: true });
927
1009
  renderRigDetailNotFound(id);
928
1010
  });
929
1011
  }
@@ -937,8 +1019,8 @@
937
1019
  // (writ-title anchor, rig-id anchor, future entry points, deep-link
938
1020
  // init) emits ?rig=ID for free. The popstate-driven path passes
939
1021
  // skipUrlPush=true to avoid double-pushing the URL the browser
940
- // already updated.
941
- if (!skipUrlPush) updateUrl({ rig: rig.id });
1022
+ // already updated. Detail open is a navigation event (push: true).
1023
+ if (!skipUrlPush) window.NexusUrl.update({ rig: rig.id }, { push: true });
942
1024
 
943
1025
  // Reset the session-log surface BEFORE any render (T7): hides the
944
1026
  // section, clears the textarea, and nulls transcript state.
@@ -1618,7 +1700,7 @@
1618
1700
  // doing what they expect. We deliberately push instead of popping
1619
1701
  // history — the operator may have arrived directly at ?rig=ID with
1620
1702
  // no prior list-view entry to pop back to.
1621
- if (!skipUrlPush) updateUrl({ rig: null });
1703
+ if (!skipUrlPush) window.NexusUrl.update({ rig: null }, { push: true });
1622
1704
  }
1623
1705
 
1624
1706
  // ── Config tab ─────────────────────────────────────────────────────────
@@ -1893,6 +1975,8 @@
1893
1975
  var statusFilter = document.getElementById('status-filter');
1894
1976
  if (statusFilter) {
1895
1977
  statusFilter.addEventListener('change', function () {
1978
+ currentStatusFilter = statusFilter.value;
1979
+ writeRigFiltersToUrl();
1896
1980
  fetchRigs(statusFilter.value);
1897
1981
  });
1898
1982
  }
@@ -1901,6 +1985,7 @@
1901
1985
  var writFilter = document.getElementById('writ-filter');
1902
1986
  if (writFilter) {
1903
1987
  writFilter.addEventListener('input', function () {
1988
+ writeRigFiltersToUrl();
1904
1989
  renderRigList();
1905
1990
  });
1906
1991
  }
@@ -1909,10 +1994,16 @@
1909
1994
  var dateFrom = document.getElementById('date-from');
1910
1995
  var dateTo = document.getElementById('date-to');
1911
1996
  if (dateFrom) {
1912
- dateFrom.addEventListener('change', function () { renderRigList(); });
1997
+ dateFrom.addEventListener('change', function () {
1998
+ writeRigFiltersToUrl();
1999
+ renderRigList();
2000
+ });
1913
2001
  }
1914
2002
  if (dateTo) {
1915
- dateTo.addEventListener('change', function () { renderRigList(); });
2003
+ dateTo.addEventListener('change', function () {
2004
+ writeRigFiltersToUrl();
2005
+ renderRigList();
2006
+ });
1916
2007
  }
1917
2008
 
1918
2009
  // Refresh button
@@ -1937,6 +2028,7 @@
1937
2028
  sortField = field;
1938
2029
  sortDir = 'asc';
1939
2030
  }
2031
+ writeRigFiltersToUrl();
1940
2032
  renderRigList();
1941
2033
  });
1942
2034
  })(headers[k]);
@@ -1948,29 +2040,33 @@
1948
2040
  backBtn.addEventListener('click', backToList);
1949
2041
  }
1950
2042
 
1951
- // Browser navigation (Back / Forward) — read ?rig=ID from the new
1952
- // URL and either open the matching detail (skipUrlPush=true so we
1953
- // don't push the URL the browser already updated) or return to the
1954
- // list. Pairs with the central push inside showRigDetail (D11/D12).
2043
+ // Browser navigation (Back / Forward) — restore the FULL filter
2044
+ // state and the ?rig= deep-link. Pairs with the central push
2045
+ // inside showRigDetail (D11/D12). The popstate-driven path uses
2046
+ // skipUrlPush so it never re-pushes the URL the browser already
2047
+ // updated.
1955
2048
  window.addEventListener('popstate', function () {
1956
- var rigId = currentUrlParams().get('rig');
1957
- if (rigId) {
1958
- showRigDetailById(rigId, { skipUrlPush: true });
2049
+ var state = readUrlState();
2050
+ syncRigFilterUiFromState();
2051
+ if (state.rigId) {
2052
+ showRigDetailById(state.rigId, { skipUrlPush: true });
1959
2053
  } else {
1960
2054
  backToList({ skipUrlPush: true });
2055
+ // Refresh the list with the restored filter set.
2056
+ fetchRigs(currentStatusFilter);
1961
2057
  }
1962
2058
  });
1963
2059
 
1964
- // Initial load. Deep-link: ?rig=ID. The init path waits for the
1965
- // first rig list to land (so showRigDetailById can find the live
1966
- // rig in `rigs`) before opening the detail. A missing/deleted id
1967
- // falls through to renderRigDetailNotFound (D16) — the URL is
1968
- // preserved.
1969
- var initialRigId = currentUrlParams().get('rig');
1970
- fetchRigs('', {
2060
+ // Initial load. Read the URL filter state and ?rig= deep-link
2061
+ // BEFORE fetching the rig list so the server query already carries
2062
+ // the correct ?status= filter. A missing/deleted ?rig= id falls
2063
+ // through to renderRigDetailNotFound (D16) — the URL is preserved.
2064
+ var initialState = readUrlState();
2065
+ syncRigFilterUiFromState();
2066
+ fetchRigs(currentStatusFilter, {
1971
2067
  onLoaded: function () {
1972
- if (initialRigId) {
1973
- showRigDetailById(initialRigId, { skipUrlPush: true });
2068
+ if (initialState.rigId) {
2069
+ showRigDetailById(initialState.rigId, { skipUrlPush: true });
1974
2070
  }
1975
2071
  },
1976
2072
  });