@shardworks/astrolabe-apparatus 0.1.280 → 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/astrolabe-apparatus",
3
- "version": "0.1.280",
3
+ "version": "0.1.281",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,18 +20,18 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "zod": "4.3.6",
23
- "@shardworks/tools-apparatus": "0.1.280",
24
- "@shardworks/clerk-apparatus": "0.1.280",
25
- "@shardworks/spider-apparatus": "0.1.280",
26
- "@shardworks/animator-apparatus": "0.1.280",
27
- "@shardworks/fabricator-apparatus": "0.1.280",
28
- "@shardworks/stacks-apparatus": "0.1.280",
29
- "@shardworks/loom-apparatus": "0.1.280",
30
- "@shardworks/clockworks-apparatus": "0.1.280"
23
+ "@shardworks/stacks-apparatus": "0.1.281",
24
+ "@shardworks/clerk-apparatus": "0.1.281",
25
+ "@shardworks/tools-apparatus": "0.1.281",
26
+ "@shardworks/spider-apparatus": "0.1.281",
27
+ "@shardworks/animator-apparatus": "0.1.281",
28
+ "@shardworks/clockworks-apparatus": "0.1.281",
29
+ "@shardworks/fabricator-apparatus": "0.1.281",
30
+ "@shardworks/loom-apparatus": "0.1.281"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "25.5.0",
34
- "@shardworks/nexus-core": "0.1.280"
34
+ "@shardworks/nexus-core": "0.1.281"
35
35
  },
36
36
  "files": [
37
37
  "dist",
@@ -16,34 +16,78 @@
16
16
  var writTitleLookup = {};
17
17
 
18
18
  // ── URL handling ───────────────────────────────────────────────────────
19
+ //
20
+ // All deep-linkable view state for this page rides on
21
+ // `window.NexusUrl` — the shared helper auto-injected by oculus's
22
+ // chrome pass. The earlier inline `currentUrlParams` / `updateUrl`
23
+ // copies are gone (commission moix23w5).
24
+ //
25
+ // URL keys:
26
+ // ?status= reading | analyzing | reviewing | writing |
27
+ // completed | failed
28
+ // Matches the server-side ?status= sent to
29
+ // /api/plan/list (D8). Default '' (All).
30
+ // ?plan=ID Detail deep-link; pushes (D5).
31
+ //
32
+ // Detail-tab state (inventory / scope / decisions / observations /
33
+ // spec) is deliberately NOT URL-tracked (D13 narrow reading). The
34
+ // astrolabe.test.js suite asserts no `?tab=` key is ever written;
35
+ // do not introduce one.
36
+
37
+ var STATUS_VALUES = ['', 'reading', 'analyzing', 'reviewing', 'writing', 'completed', 'failed'];
38
+
39
+ function showUrlError(msg) {
40
+ var el = document.getElementById('url-error-banner');
41
+ if (!el) return;
42
+ var line = document.createElement('div');
43
+ line.textContent = msg;
44
+ el.appendChild(line);
45
+ el.style.display = 'block';
46
+ }
19
47
 
20
- /**
21
- * Read the current querystring as a `URLSearchParams`. Live snapshot
22
- * read at call time, never cached.
23
- */
24
- function currentUrlParams() {
25
- return new URLSearchParams(window.location.search);
48
+ function clearUrlErrors() {
49
+ var el = document.getElementById('url-error-banner');
50
+ if (!el) return;
51
+ el.innerHTML = '';
52
+ el.style.display = 'none';
53
+ }
54
+
55
+ /** Persist the list-page status filter to the URL (replace, D5). */
56
+ function writeStatusFilterToUrl() {
57
+ window.NexusUrl.update({
58
+ status: currentStatusFilter === '' ? null : currentStatusFilter,
59
+ });
26
60
  }
27
61
 
28
62
  /**
29
- * Apply the given key/value changes to the current querystring and
30
- * `pushState` the result. Null/undefined/empty value deletes the key.
31
- * Mirrors Ratchet's `updateUrl` (D9). The plan param shape (?plan=ID)
32
- * is preserved verbatim — operators have URLs in the wild already
33
- * (D10).
63
+ * Read URL state into module-level variables. Validates ?status=
64
+ * against STATUS_VALUES; unknowns surface a fail-loud banner per D6
65
+ * without applying. Returns the deep-link plan id, if any.
34
66
  */
35
- function updateUrl(changes) {
36
- var params = currentUrlParams();
37
- var keys = Object.keys(changes);
38
- for (var i = 0; i < keys.length; i++) {
39
- var key = keys[i];
40
- var value = changes[key];
41
- if (value === null || value === undefined || value === '') params.delete(key);
42
- else params.set(key, value);
67
+ function readUrlState() {
68
+ clearUrlErrors();
69
+ var params = window.NexusUrl.read();
70
+
71
+ var status = params.get('status');
72
+ if (status !== null) {
73
+ if (STATUS_VALUES.indexOf(status) !== -1) {
74
+ currentStatusFilter = status;
75
+ } else {
76
+ showUrlError('Unknown plan status "' + status + '". Expected one of: reading, analyzing, reviewing, writing, completed, failed.');
77
+ }
78
+ }
79
+
80
+ return params.get('plan');
81
+ }
82
+
83
+ /** Sync the status-filter button row to the current filter state. */
84
+ function syncStatusFilterUiFromState() {
85
+ var btns = document.querySelectorAll('#status-filters .filter-btn');
86
+ for (var i = 0; i < btns.length; i++) {
87
+ var match = btns[i].getAttribute('data-status') === currentStatusFilter;
88
+ if (match) btns[i].classList.add('active-filter');
89
+ else btns[i].classList.remove('active-filter');
43
90
  }
44
- var qs = params.toString();
45
- var next = window.location.pathname + (qs ? '?' + qs : '');
46
- window.history.pushState({}, '', next);
47
91
  }
48
92
 
49
93
  // ── Utility ────────────────────────────────────────────────────────────
@@ -205,6 +249,7 @@
205
249
  filterBtns[j].classList.remove('active-filter');
206
250
  }
207
251
  btn.classList.add('active-filter');
252
+ writeStatusFilterToUrl();
208
253
  fetchPlans(true);
209
254
  });
210
255
  })(filterBtns[fi]);
@@ -363,7 +408,7 @@
363
408
  // ?plan=ID for free. The popstate-driven path passes
364
409
  // skipUrlPush=true to avoid double-pushing the URL the browser
365
410
  // already updated.
366
- if (!skipUrlPush) updateUrl({ plan: plan.id });
411
+ if (!skipUrlPush) window.NexusUrl.update({ plan: plan.id }, { push: true });
367
412
 
368
413
  detailTitle.textContent = 'Plan: ' + plan.id;
369
414
 
@@ -674,7 +719,7 @@
674
719
  // D11: push a clean URL — the operator's Forward button still does
675
720
  // what they expect, and we never pop history because they may have
676
721
  // arrived directly at ?plan=ID with no prior list-view entry.
677
- if (!skipUrlPush) updateUrl({ plan: null });
722
+ if (!skipUrlPush) window.NexusUrl.update({ plan: null }, { push: true });
678
723
  }
679
724
 
680
725
  // ── Deep Link ──────────────────────────────────────────────────────────
@@ -693,7 +738,10 @@
693
738
  */
694
739
  function handleDeepLink(opts) {
695
740
  var fetchOnEmpty = !(opts && opts.fetchOnEmpty === false);
696
- var planId = currentUrlParams().get('plan');
741
+ // Restore the status filter alongside the deep-link id so the
742
+ // filtered view round-trips through refresh and Back/Forward.
743
+ var planId = readUrlState();
744
+ syncStatusFilterUiFromState();
697
745
 
698
746
  if (planId) {
699
747
  fetch('/api/plan/show?planId=' + encodeURIComponent(planId))
@@ -713,9 +761,10 @@
713
761
  } else if (fetchOnEmpty) {
714
762
  fetchPlans(true);
715
763
  } else {
716
- // popstate to a no-?plan URL: just return to the list view
717
- // without re-fetching.
764
+ // popstate to a no-?plan URL: refresh the list with the
765
+ // restored filter and switch back to list view.
718
766
  backToList({ skipUrlPush: true });
767
+ fetchPlans(true);
719
768
  }
720
769
  }
721
770
 
@@ -712,41 +712,50 @@ describe('astrolabe.js cost-panel rig lookup uses rig-for-writ', () => {
712
712
  // ── Deep-link URL state (?plan=ID) ──────────────────────────────────────
713
713
 
714
714
  describe('astrolabe.js — deep-link URL state', () => {
715
- it('exposes currentUrlParams + updateUrl helpers (D9 Ratchet pattern)', () => {
715
+ it('routes URL reads/writes through window.NexusUrl (no inline helpers)', () => {
716
+ // Inline currentUrlParams / updateUrl are gone (commission moix23w5).
717
+ assert.ok(
718
+ !/function\s+currentUrlParams\s*\(/.test(astrolabeJs),
719
+ 'inline currentUrlParams must not be redeclared',
720
+ );
721
+ assert.ok(
722
+ !/function\s+updateUrl\s*\(/.test(astrolabeJs),
723
+ 'inline updateUrl must not be redeclared',
724
+ );
716
725
  assert.match(
717
726
  astrolabeJs,
718
- /function currentUrlParams\(\)\s*\{[\s\S]*?new URLSearchParams\(window\.location\.search\)/,
719
- 'currentUrlParams reads window.location.search live',
727
+ /window\.NexusUrl\.read\(\)/,
728
+ 'astrolabe.js should read URL state via window.NexusUrl.read',
720
729
  );
721
730
  assert.match(
722
731
  astrolabeJs,
723
- /function updateUrl\(changes\)\s*\{[\s\S]*?window\.history\.pushState/,
724
- 'updateUrl pushes via history.pushState',
732
+ /window\.NexusUrl\.update\(/,
733
+ 'astrolabe.js should write URL state via window.NexusUrl.update',
725
734
  );
726
735
  });
727
736
 
728
- it('showPlanDetail pushes ?plan=ID via the central updateUrl call (D12)', () => {
737
+ it('showPlanDetail pushes ?plan=ID via NexusUrl with push: true (D12)', () => {
729
738
  const block = astrolabeJs.match(
730
739
  /function showPlanDetail\(plan(?:, opts)?\)[\s\S]*?(?=\n {2}function )/,
731
740
  );
732
741
  assert.ok(block, 'should find showPlanDetail body');
733
742
  assert.match(
734
743
  block[0],
735
- /updateUrl\(\{\s*plan:\s*plan\.id\s*\}\)/,
744
+ /window\.NexusUrl\.update\(\{\s*plan:\s*plan\.id\s*\}\s*,\s*\{\s*push:\s*true\s*\}\)/,
736
745
  'showPlanDetail should push ?plan=<plan.id> when not skipUrlPush',
737
746
  );
738
747
  assert.match(block[0], /skipUrlPush/, 'showPlanDetail accepts a skipUrlPush opt');
739
748
  });
740
749
 
741
- it('backToList clears ?plan via updateUrl({plan: null}) — never pops history (D11)', () => {
750
+ it('backToList clears ?plan via NexusUrl with push: true — never pops history (D11)', () => {
742
751
  const block = astrolabeJs.match(
743
752
  /function backToList\((?:opts)?\)[\s\S]*?(?=\n {2}\/\/)/,
744
753
  );
745
754
  assert.ok(block, 'should find backToList body');
746
755
  assert.match(
747
756
  block[0],
748
- /updateUrl\(\{\s*plan:\s*null\s*\}\)/,
749
- 'backToList should push a clean URL via updateUrl({plan: null})',
757
+ /window\.NexusUrl\.update\(\{\s*plan:\s*null\s*\}\s*,\s*\{\s*push:\s*true\s*\}\)/,
758
+ 'backToList should push a clean URL via NexusUrl.update with plan: null',
750
759
  );
751
760
  assert.ok(
752
761
  !/window\.history\.back\s*\(/.test(block[0]),
@@ -796,7 +805,7 @@ describe('astrolabe.js — deep-link URL state', () => {
796
805
  );
797
806
  assert.ok(block, 'should find renderPlanDetailNotFound body');
798
807
  assert.ok(
799
- !/updateUrl/.test(block[0]),
808
+ !/NexusUrl\.update/.test(block[0]),
800
809
  'renderPlanDetailNotFound must not rewrite the URL',
801
810
  );
802
811
  assert.match(
@@ -805,4 +814,52 @@ describe('astrolabe.js — deep-link URL state', () => {
805
814
  'renderPlanDetailNotFound surfaces a "not found" message',
806
815
  );
807
816
  });
817
+
818
+ it('list-page status filter writes through NexusUrl.update (replace, no push: true)', () => {
819
+ const writer = astrolabeJs.match(
820
+ /function writeStatusFilterToUrl\(\)[\s\S]*?(?=\n {2}function )/,
821
+ );
822
+ assert.ok(writer, 'writeStatusFilterToUrl should be defined');
823
+ assert.match(
824
+ writer[0],
825
+ /window\.NexusUrl\.update\(\{\s*status:[\s\S]*?\}\s*\)/,
826
+ 'status filter must call NexusUrl.update without { push: true }',
827
+ );
828
+ assert.ok(
829
+ !/push:\s*true/.test(writer[0]),
830
+ 'status filter writes must use replaceState (D5 default)',
831
+ );
832
+ });
833
+
834
+ it('readUrlState validates ?status= against STATUS_VALUES and surfaces fail-loud errors (D6)', () => {
835
+ const reader = astrolabeJs.match(
836
+ /function readUrlState\(\)[\s\S]*?(?=\n {2}function )/,
837
+ );
838
+ assert.ok(reader, 'readUrlState should be defined');
839
+ assert.match(
840
+ reader[0],
841
+ /STATUS_VALUES\.indexOf/,
842
+ 'readUrlState must validate ?status= against STATUS_VALUES',
843
+ );
844
+ assert.match(
845
+ reader[0],
846
+ /showUrlError\(/,
847
+ 'readUrlState must surface invalid values via showUrlError',
848
+ );
849
+ });
850
+
851
+ it('detail-tab state stays non-URL-tracked (D13 narrow reading)', () => {
852
+ // Astrolabe's detail tabs (inventory / scope / decisions /
853
+ // observations / spec) are deliberately NOT URL-tracked. Filter-
854
+ // shaped only — the tab affordance remains client-only state.
855
+ assert.ok(
856
+ !/NexusUrl\.update\(\{\s*tab:/.test(astrolabeJs),
857
+ 'detail-tab state must remain client-only (D13)',
858
+ );
859
+ // Defensive — also forbid the legacy updateUrl shape.
860
+ assert.ok(
861
+ !/updateUrl\(\{\s*tab:/.test(astrolabeJs),
862
+ 'detail-tab state must remain client-only (D13) — legacy form too',
863
+ );
864
+ });
808
865
  });
@@ -8,6 +8,14 @@
8
8
  <body>
9
9
  <main style="padding: 24px;">
10
10
 
11
+ <!--
12
+ URL-state validation banner (D6 fail-loud). Shown when the page is
13
+ loaded with an invalid filter value (e.g. ?status=bogus). The banner
14
+ appends one line per invalid value; pages do not silently fall back
15
+ to a default.
16
+ -->
17
+ <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>
18
+
11
19
  <!-- ── List View ──────────────────────────────────────────────────── -->
12
20
  <div id="plan-list-view">
13
21
  <h2>Plans</h2>