@shardworks/clerk-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/clerk-apparatus",
3
- "version": "0.1.280",
3
+ "version": "0.1.281",
4
4
  "license": "ISC",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,12 +24,12 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "zod": "4.3.6",
27
- "@shardworks/tools-apparatus": "0.1.280",
28
- "@shardworks/stacks-apparatus": "0.1.280"
27
+ "@shardworks/stacks-apparatus": "0.1.281",
28
+ "@shardworks/tools-apparatus": "0.1.281"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "25.5.0",
32
- "@shardworks/nexus-core": "0.1.280"
32
+ "@shardworks/nexus-core": "0.1.281"
33
33
  },
34
34
  "files": [
35
35
  "dist",
@@ -77,6 +77,14 @@
77
77
  <h1>Writs</h1>
78
78
 
79
79
  <div id="writ-list-view">
80
+ <!--
81
+ URL-state validation banner. Surfaced when the page is loaded with a
82
+ URL filter value that does not match any known classification, type,
83
+ sort column, or sort direction (D6 fail-loud). Pages must not
84
+ silently fall back to a default — the operator copy-pasting a URL
85
+ needs honest feedback so they can recover.
86
+ -->
87
+ <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>
80
88
  <div class="card" style="margin-bottom: 16px;">
81
89
  <div class="toolbar" id="toolbar">
82
90
  <button class="btn btn--primary" id="btn-new-writ">New Writ</button>
@@ -221,33 +229,188 @@
221
229
  const DEPTH_CAP = 8;
222
230
 
223
231
  // ── URL handling ───────────────────────────────────────────────────
232
+ //
233
+ // All deep-linkable view state for this page rides on
234
+ // `window.NexusUrl` — the shared helper auto-injected by oculus's
235
+ // chrome pass. The earlier inline `currentUrlParams` / `updateUrl`
236
+ // copies are gone (commission moix23w5).
237
+ //
238
+ // URL keys, all client-side except `?writ=`, `?classification=`,
239
+ // and `?type=` which are also accepted by /api/writ/* endpoints:
240
+ // ?classification= one of '' | 'initial' | 'active' | 'terminal'.
241
+ // Default '' (All); omitted when default (D4).
242
+ // ?type=A&type=B Repeated keys (D3). Omitted when every known
243
+ // type is selected (the default). Subset → emit
244
+ // each currently-selected type. The empty-set
245
+ // edge case collapses to default on round-trip
246
+ // (operators don't share empty list states).
247
+ // ?q= Search text. Omitted when empty.
248
+ // ?sort= One of phase | title | type | id | createdAt.
249
+ // Default 'createdAt'; omitted when default.
250
+ // ?dir= 'asc' | 'desc'. Default 'desc'; omitted when
251
+ // default.
252
+ // ?children= 'true' | 'false' (D7). Default true; omitted
253
+ // when default.
254
+ // ?cancelled= 'true' | 'false'. Default false; omitted when
255
+ // default.
256
+ // ?writ=ID Detail deep-link; opening pushes (D5).
257
+
258
+ const SORT_COLS = new Set(['phase', 'title', 'type', 'id', 'createdAt']);
259
+ const SORT_DIRS = new Set(['asc', 'desc']);
260
+ const CLASSIFICATIONS = new Set(['', 'initial', 'active', 'terminal']);
261
+
262
+ /** Append a fail-loud message to the URL-error banner (D6). */
263
+ function showUrlError(msg) {
264
+ const el = document.getElementById('url-error-banner');
265
+ if (!el) return;
266
+ // Append rather than replace so multiple invalid params each get
267
+ // their own line — operators see every recovery hint at once.
268
+ const line = document.createElement('div');
269
+ line.textContent = msg;
270
+ el.appendChild(line);
271
+ el.style.display = 'block';
272
+ }
273
+
274
+ /** Clear the URL-error banner — used before a fresh validation pass. */
275
+ function clearUrlErrors() {
276
+ const el = document.getElementById('url-error-banner');
277
+ if (!el) return;
278
+ el.innerHTML = '';
279
+ el.style.display = 'none';
280
+ }
281
+
282
+ /**
283
+ * Apply current filter state to the URL via NexusUrl.update. Always
284
+ * a replace (D5 default) — filter changes do not push history
285
+ * entries. When a filter is at its declared default the
286
+ * corresponding key is omitted (D4). Type[] is omitted when every
287
+ * known type is selected (the operator-default).
288
+ */
289
+ function writeFiltersToUrl() {
290
+ const allKnown = knownTypes.length > 0 && knownTypes.every(t => currentType.has(t));
291
+ const noneSelected = currentType.size === 0;
292
+ const typeArray = (allKnown || noneSelected) ? null : [...currentType];
293
+ window.NexusUrl.update({
294
+ classification: currentClassification === '' ? null : currentClassification,
295
+ type: typeArray,
296
+ q: searchText === '' ? null : searchText,
297
+ sort: sortCol === 'createdAt' ? null : sortCol,
298
+ dir: sortDir === 'desc' ? null : sortDir,
299
+ children: showChildren === true ? null : false,
300
+ cancelled: showCancelled === false ? null : true,
301
+ });
302
+ }
224
303
 
225
304
  /**
226
- * Read the current querystring as a `URLSearchParams`. A live snapshot —
227
- * always read at call time, never cached, so reasoning stays local.
305
+ * Read the URL filter state into module-level variables. Validates
306
+ * each value against its known set and surfaces an error banner
307
+ * for any unknown value (D6 fail-loud). Returns the writ id, if
308
+ * any, so the caller can route into the detail view.
309
+ *
310
+ * Must be called after loadWritTypes() so knownTypes is populated
311
+ * and the type[] validation can fire.
228
312
  */
229
- function currentUrlParams() {
230
- return new URLSearchParams(window.location.search);
313
+ function readFiltersFromUrl() {
314
+ clearUrlErrors();
315
+ const params = window.NexusUrl.read();
316
+
317
+ // ── classification ────────────────────────────────────────────
318
+ const classification = params.get('classification');
319
+ if (classification !== null) {
320
+ if (CLASSIFICATIONS.has(classification)) {
321
+ currentClassification = classification;
322
+ } else {
323
+ showUrlError(`Unknown classification "${classification}". Expected one of: initial, active, terminal.`);
324
+ }
325
+ }
326
+
327
+ // ── type[] ─────────────────────────────────────────────────────
328
+ const typeValues = params.getAll('type');
329
+ if (typeValues.length > 0) {
330
+ const knownSet = new Set(knownTypes);
331
+ const unknown = typeValues.filter(t => !knownSet.has(t));
332
+ if (unknown.length > 0) {
333
+ for (const u of unknown) {
334
+ showUrlError(`Unknown writ type "${u}". The current type registry does not declare this name.`);
335
+ }
336
+ } else {
337
+ currentType = new Set(typeValues);
338
+ }
339
+ }
340
+ // Otherwise leave currentType at its default (set by buildTypeFilterBar to all known types).
341
+
342
+ // ── search text ────────────────────────────────────────────────
343
+ const q = params.get('q');
344
+ if (q !== null) searchText = q;
345
+
346
+ // ── sort ───────────────────────────────────────────────────────
347
+ const sort = params.get('sort');
348
+ if (sort !== null) {
349
+ if (SORT_COLS.has(sort)) {
350
+ sortCol = sort;
351
+ } else {
352
+ showUrlError(`Unknown sort column "${sort}". Expected one of: ${[...SORT_COLS].join(', ')}.`);
353
+ }
354
+ }
355
+ const dir = params.get('dir');
356
+ if (dir !== null) {
357
+ if (SORT_DIRS.has(dir)) {
358
+ sortDir = dir;
359
+ } else {
360
+ showUrlError(`Unknown sort direction "${dir}". Expected "asc" or "desc".`);
361
+ }
362
+ }
363
+
364
+ // ── boolean toggles ───────────────────────────────────────────
365
+ const childrenRaw = params.get('children');
366
+ if (childrenRaw !== null) {
367
+ if (childrenRaw === 'true') showChildren = true;
368
+ else if (childrenRaw === 'false') showChildren = false;
369
+ else showUrlError(`Invalid children value "${childrenRaw}". Expected "true" or "false".`);
370
+ }
371
+ const cancelledRaw = params.get('cancelled');
372
+ if (cancelledRaw !== null) {
373
+ if (cancelledRaw === 'true') showCancelled = true;
374
+ else if (cancelledRaw === 'false') showCancelled = false;
375
+ else showUrlError(`Invalid cancelled value "${cancelledRaw}". Expected "true" or "false".`);
376
+ }
377
+
378
+ return params.get('writ');
231
379
  }
232
380
 
233
381
  /**
234
- * Apply the given key/value changes to the current querystring and
235
- * `pushState` the result, preserving any unrelated params already
236
- * present. A null/undefined/empty value deletes the key. Mirrors
237
- * Ratchet's `updateUrl` (D9). Always pushes — `back-to-list` does
238
- * `updateUrl({writ: null})` so the operator's Back button returns to
239
- * whatever page they came from rather than collapsing inside the
240
- * page's own history (D11).
382
+ * Sync the toolbar UI to current filter state. Called after URL
383
+ * restore (init / popstate) so visible button fills, the search
384
+ * input, and the type-filter bar match what the URL describes.
241
385
  */
242
- function updateUrl(changes) {
243
- const params = currentUrlParams();
244
- for (const [key, value] of Object.entries(changes)) {
245
- if (value === null || value === undefined || value === '') params.delete(key);
246
- else params.set(key, value);
247
- }
248
- const qs = params.toString();
249
- const next = window.location.pathname + (qs ? '?' + qs : '');
250
- window.history.pushState({}, '', next);
386
+ function syncFilterUiFromState() {
387
+ // Classification buttons
388
+ document.querySelectorAll('.filter-btn[data-classification]').forEach(btn => {
389
+ btn.classList.toggle('active-filter', btn.dataset.classification === currentClassification);
390
+ });
391
+
392
+ // Type-filter pills
393
+ const bar = document.getElementById('type-filter-bar');
394
+ if (bar) {
395
+ const allBtn = bar.querySelector('.type-filter-btn[data-type=""]');
396
+ const typeBtns = [...bar.querySelectorAll('.type-filter-btn:not([data-type=""])')];
397
+ const allTypeNames = typeBtns.map(b => b.dataset.type);
398
+ const allActive = allTypeNames.length > 0 && allTypeNames.every(t => currentType.has(t));
399
+ if (allBtn) allBtn.classList.toggle('active-filter', allActive);
400
+ typeBtns.forEach(btn => {
401
+ btn.classList.toggle('active-filter', currentType.has(btn.dataset.type));
402
+ });
403
+ }
404
+
405
+ // Search input
406
+ const searchInput = document.getElementById('search-input');
407
+ if (searchInput && searchInput.value !== searchText) searchInput.value = searchText;
408
+
409
+ // Toggles
410
+ const childrenBtn = document.getElementById('btn-toggle-children');
411
+ if (childrenBtn) childrenBtn.classList.toggle('active-filter', showChildren);
412
+ const cancelledBtn = document.getElementById('btn-toggle-cancelled');
413
+ if (cancelledBtn) cancelledBtn.classList.toggle('active-filter', showCancelled);
251
414
  }
252
415
 
253
416
  // ── Helpers ────────────────────────────────────────────────────────
@@ -1451,7 +1614,7 @@
1451
1614
  writ = await api('GET', '/api/writ/show?id=' + encodeURIComponent(id));
1452
1615
  } catch (e) {
1453
1616
  console.error('Failed to load writ detail:', e);
1454
- if (!skipUrlPush) updateUrl({ writ: id });
1617
+ if (!skipUrlPush) window.NexusUrl.update({ writ: id }, { push: true });
1455
1618
  renderDetailNotFound(id);
1456
1619
  return;
1457
1620
  }
@@ -1472,8 +1635,9 @@
1472
1635
 
1473
1636
  // Centralised URL push — every callsite into showWritDetail emits
1474
1637
  // ?writ=ID for free, except the popstate-driven path which would
1475
- // otherwise re-push the current URL.
1476
- if (!skipUrlPush) updateUrl({ writ: id });
1638
+ // otherwise re-push the current URL. Detail open is a navigation
1639
+ // event (pushes), per the D5 push-vs-replace split.
1640
+ if (!skipUrlPush) window.NexusUrl.update({ writ: id }, { push: true });
1477
1641
 
1478
1642
  // Toggle views
1479
1643
  document.getElementById('writ-list-view').style.display = 'none';
@@ -1505,15 +1669,15 @@
1505
1669
  }
1506
1670
 
1507
1671
  /** Switch back from the detail view to the list view. Centralised so
1508
- * the URL clear (`updateUrl({writ: null})`) and the DOM toggle stay
1509
- * paired. Per D11 we never call `history.back()` — clicking "Back to
1510
- * list" pushes a clean URL so the operator's forward button keeps
1511
- * doing what they expect. */
1672
+ * the URL clear (`{ writ: null }`) and the DOM toggle stay paired.
1673
+ * Per D11 we never call `history.back()` — clicking "Back to list"
1674
+ * pushes a clean URL so the operator's forward button keeps doing
1675
+ * what they expect. */
1512
1676
  function showWritList(opts) {
1513
1677
  const skipUrlPush = !!(opts && opts.skipUrlPush);
1514
1678
  document.getElementById('writ-detail-view').style.display = 'none';
1515
1679
  document.getElementById('writ-list-view').style.display = '';
1516
- if (!skipUrlPush) updateUrl({ writ: null });
1680
+ if (!skipUrlPush) window.NexusUrl.update({ writ: null }, { push: true });
1517
1681
  }
1518
1682
 
1519
1683
  // ── Data loading ────────────────────────────────────────────────────
@@ -1848,6 +2012,7 @@
1848
2012
  document.querySelectorAll('.filter-btn[data-classification]').forEach(btn => {
1849
2013
  btn.classList.toggle('active-filter', btn.dataset.classification === classification);
1850
2014
  });
2015
+ writeFiltersToUrl();
1851
2016
  loadWrits(true);
1852
2017
  }
1853
2018
 
@@ -1884,6 +2049,7 @@
1884
2049
  btn.classList.toggle('active-filter', currentType.has(btn.dataset.type));
1885
2050
  });
1886
2051
 
2052
+ writeFiltersToUrl();
1887
2053
  loadWrits(true);
1888
2054
  }
1889
2055
 
@@ -1901,16 +2067,17 @@
1901
2067
  document.getElementById('btn-refresh').addEventListener('click', () => loadWrits(true));
1902
2068
 
1903
2069
  document.getElementById('back-btn').addEventListener('click', () => {
1904
- // D11: push a clean URL via showWritList → updateUrl, never call
1905
- // history.back(). Survives deep-link entries (the operator may have
1906
- // arrived directly at ?writ=ID with no prior list-view entry to
1907
- // pop back to), and keeps Forward navigation predictable.
2070
+ // D11: push a clean URL via showWritList → NexusUrl.update, never
2071
+ // call history.back(). Survives deep-link entries (the operator
2072
+ // may have arrived directly at ?writ=ID with no prior list-view
2073
+ // entry to pop back to), and keeps Forward navigation predictable.
1908
2074
  showWritList();
1909
2075
  });
1910
2076
 
1911
2077
  document.getElementById('btn-toggle-children').addEventListener('click', () => {
1912
2078
  showChildren = !showChildren;
1913
2079
  document.getElementById('btn-toggle-children').classList.toggle('active-filter', showChildren);
2080
+ writeFiltersToUrl();
1914
2081
  renderTable();
1915
2082
  });
1916
2083
 
@@ -1920,6 +2087,7 @@
1920
2087
  // cancelled rows when the toggle is off.
1921
2088
  showCancelled = !showCancelled;
1922
2089
  document.getElementById('btn-toggle-cancelled').classList.toggle('active-filter', showCancelled);
2090
+ writeFiltersToUrl();
1923
2091
  renderTable();
1924
2092
  });
1925
2093
 
@@ -1937,6 +2105,7 @@
1937
2105
 
1938
2106
  document.getElementById('search-input').addEventListener('input', e => {
1939
2107
  searchText = e.target.value;
2108
+ writeFiltersToUrl();
1940
2109
  renderTable();
1941
2110
  });
1942
2111
 
@@ -1949,6 +2118,7 @@
1949
2118
  sortCol = col;
1950
2119
  sortDir = 'asc';
1951
2120
  }
2121
+ writeFiltersToUrl();
1952
2122
  renderTable();
1953
2123
  });
1954
2124
  });
@@ -1957,18 +2127,25 @@
1957
2127
 
1958
2128
  /**
1959
2129
  * Reflect browser navigation (Back / Forward) into the page's view
1960
- * state. The popstate handler reads `?writ=` from the new URL and
1961
- * either shows the matching detail (without re-pushing the URL,
1962
- * which the browser has already done) or returns to the list. This
1963
- * pairs with the central push inside showWritDetail so the
1964
- * round-trip stays balanced (D11/D12).
2130
+ * state. The popstate handler now restores the full filter state in
2131
+ * addition to the detail-id state Back from a deep-linked
2132
+ * filtered URL must reproduce the original filtered view, not just
2133
+ * its detail open/close axis (commission moix23w5).
2134
+ *
2135
+ * The browser has already updated the URL by the time popstate
2136
+ * fires, so showWritDetail / showWritList run with skipUrlPush; the
2137
+ * filter restore reads from `window.location.search` directly via
2138
+ * readFiltersFromUrl and never writes back.
1965
2139
  */
1966
2140
  window.addEventListener('popstate', () => {
1967
- const writId = currentUrlParams().get('writ');
2141
+ const writId = readFiltersFromUrl();
2142
+ syncFilterUiFromState();
1968
2143
  if (writId) {
1969
2144
  showWritDetail(writId, { skipUrlPush: true });
1970
2145
  } else {
1971
2146
  showWritList({ skipUrlPush: true });
2147
+ // Refresh the list with the restored filter state.
2148
+ loadWrits(true);
1972
2149
  }
1973
2150
  });
1974
2151
 
@@ -1976,12 +2153,19 @@
1976
2153
  loadCodexes();
1977
2154
  loadLinkKinds();
1978
2155
 
1979
- // Deep-link: ?writ=ID
2156
+ // Deep-link: ?writ=ID and filter state
1980
2157
  (async function () {
1981
- // Load writ types first so currentType default is set before fetching writs
2158
+ // Load writ types first so currentType default is set and known
2159
+ // type names are populated — the URL-restore step needs them to
2160
+ // validate ?type= against the registry.
1982
2161
  await loadWritTypes();
1983
2162
 
1984
- var writId = currentUrlParams().get('writ');
2163
+ // Read URL filter state AFTER buildTypeFilterBar (inside
2164
+ // loadWritTypes) so default-fill does not clobber URL-restored
2165
+ // selections. readFiltersFromUrl validates each value and routes
2166
+ // unknowns to the fail-loud banner per D6.
2167
+ var writId = readFiltersFromUrl();
2168
+ syncFilterUiFromState();
1985
2169
 
1986
2170
  await loadWrits(true);
1987
2171
 
@@ -1405,17 +1405,16 @@ describe('Deep descendant rendering in detail view', () => {
1405
1405
  });
1406
1406
  });
1407
1407
 
1408
- describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)', () => {
1409
- // Mirrors the URL helpers in index.html. The page IIFE reads from
1410
- // `window.location.search` and pushes via `window.history.pushState`;
1411
- // here we test the shape of those interactions by injecting a fake
1412
- // window. Keeping these tests beside the rest of the page logic locks
1413
- // in the contract that:
1414
- // - showWritDetail pushes ?writ=ID
1415
- // - showWritList clears ?writ
1416
- // - skipUrlPush suppresses the push (the popstate-driven path)
1417
- // - the popstate handler reads ?writ from the new URL and routes
1418
- // into showWritDetail (skipUrlPush) or showWritList (skipUrlPush)
1408
+ describe('URL deep-link contract (window.NexusUrl + filter state + popstate cycle)', () => {
1409
+ // The page now relies on the shared `window.NexusUrl` helper that
1410
+ // oculus auto-injects. The inline `currentUrlParams` / `updateUrl`
1411
+ // copies were removed in commission moix23w5; the source of truth
1412
+ // for these tests is `packages/plugins/oculus/src/static/nexus-url.js`.
1413
+ //
1414
+ // We model the page's contract directly: detail open pushes a new
1415
+ // history entry, filter changes replace, and the popstate-driven
1416
+ // path uses skipUrlPush so it never re-pushes.
1417
+
1419
1418
  function makeWindow(initialSearch) {
1420
1419
  const w = {
1421
1420
  location: {
@@ -1424,10 +1423,15 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
1424
1423
  },
1425
1424
  history: {
1426
1425
  pushed: [],
1427
- pushState(state, _title, url) {
1426
+ replaced: [],
1427
+ pushState(_state, _title, url) {
1428
1428
  this.pushed.push(url);
1429
- // Reflect the push back into location so subsequent reads
1430
- // see the new querystring matching real browser behaviour.
1429
+ const idx = url.indexOf('?');
1430
+ w.location.search = idx === -1 ? '' : url.slice(idx);
1431
+ w.location.pathname = idx === -1 ? url : url.slice(0, idx);
1432
+ },
1433
+ replaceState(_state, _title, url) {
1434
+ this.replaced.push(url);
1431
1435
  const idx = url.indexOf('?');
1432
1436
  w.location.search = idx === -1 ? '' : url.slice(idx);
1433
1437
  w.location.pathname = idx === -1 ? url : url.slice(0, idx);
@@ -1435,62 +1439,107 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
1435
1439
  },
1436
1440
  _popstate: null,
1437
1441
  addEventListener(name, fn) { if (name === 'popstate') this._popstate = fn; },
1442
+ removeEventListener() {},
1438
1443
  };
1439
1444
  return w;
1440
1445
  }
1441
1446
 
1442
- function makeUrlHelpers(w) {
1443
- function currentUrlParams() {
1444
- return new URLSearchParams(w.location.search);
1447
+ /** Mount NexusUrl into a fake window by inlining its IIFE shape. */
1448
+ function mountNexusUrl(w) {
1449
+ function read() { return new URLSearchParams(w.location.search); }
1450
+ function isEmpty(v) {
1451
+ if (v === null || v === undefined) return true;
1452
+ if (Array.isArray(v)) return v.length === 0;
1453
+ if (v === '') return true;
1454
+ return false;
1445
1455
  }
1446
- function updateUrl(changes) {
1447
- const params = currentUrlParams();
1448
- for (const [key, value] of Object.entries(changes)) {
1449
- if (value === null || value === undefined || value === '') params.delete(key);
1450
- else params.set(key, value);
1456
+ function applyChange(p, k, v) {
1457
+ if (isEmpty(v)) { p.delete(k); return; }
1458
+ if (Array.isArray(v)) {
1459
+ p.delete(k);
1460
+ for (const x of v) {
1461
+ if (x === null || x === undefined || x === '') continue;
1462
+ p.append(k, String(x));
1463
+ }
1464
+ return;
1451
1465
  }
1452
- const qs = params.toString();
1453
- const next = w.location.pathname + (qs ? '?' + qs : '');
1454
- w.history.pushState({}, '', next);
1466
+ if (typeof v === 'boolean') { p.set(k, v ? 'true' : 'false'); return; }
1467
+ p.set(k, String(v));
1455
1468
  }
1456
- return { currentUrlParams, updateUrl };
1469
+ w.NexusUrl = {
1470
+ read,
1471
+ update(changes, opts) {
1472
+ const p = read();
1473
+ for (const k of Object.keys(changes ?? {})) applyChange(p, k, changes[k]);
1474
+ const qs = p.toString();
1475
+ const next = w.location.pathname + (qs ? '?' + qs : '');
1476
+ if (opts && opts.push) w.history.pushState({}, '', next);
1477
+ else w.history.replaceState({}, '', next);
1478
+ return p;
1479
+ },
1480
+ };
1457
1481
  }
1458
1482
 
1459
- it('updateUrl({writ: id}) pushes ?writ=ID onto a clean URL', () => {
1483
+ it('detail open pushes ?writ=ID (push: true)', () => {
1460
1484
  const w = makeWindow();
1461
- const { updateUrl } = makeUrlHelpers(w);
1462
- updateUrl({ writ: 'w-abc' });
1485
+ mountNexusUrl(w);
1486
+ w.NexusUrl.update({ writ: 'w-abc' }, { push: true });
1463
1487
  assert.deepEqual(w.history.pushed, ['/pages/writs/?writ=w-abc']);
1488
+ assert.equal(w.history.replaced.length, 0);
1464
1489
  });
1465
1490
 
1466
- it('updateUrl({writ: null}) clears the param while preserving others', () => {
1491
+ it('back-to-list pushes a clean URL (writ: null clears the key)', () => {
1467
1492
  const w = makeWindow('?writ=w-old&keep=me');
1468
- const { updateUrl } = makeUrlHelpers(w);
1469
- updateUrl({ writ: null });
1470
- assert.equal(w.history.pushed.length, 1);
1471
- assert.equal(w.history.pushed[0], '/pages/writs/?keep=me');
1493
+ mountNexusUrl(w);
1494
+ w.NexusUrl.update({ writ: null }, { push: true });
1495
+ assert.deepEqual(w.history.pushed, ['/pages/writs/?keep=me']);
1496
+ });
1497
+
1498
+ it('filter changes replace, never push (D5)', () => {
1499
+ const w = makeWindow();
1500
+ mountNexusUrl(w);
1501
+ w.NexusUrl.update({ classification: 'active' });
1502
+ w.NexusUrl.update({ q: 'hello' });
1503
+ w.NexusUrl.update({ children: false });
1504
+ assert.equal(w.history.pushed.length, 0, 'filter changes must not push');
1505
+ assert.equal(w.history.replaced.length, 3);
1506
+ assert.equal(w.location.search, '?classification=active&q=hello&children=false');
1507
+ });
1508
+
1509
+ it('omit-defaults clears keys when set to default (D4)', () => {
1510
+ const w = makeWindow('?classification=active&q=hello&children=false');
1511
+ mountNexusUrl(w);
1512
+ w.NexusUrl.update({ classification: null, q: null, children: null });
1513
+ assert.equal(w.location.search, '');
1514
+ });
1515
+
1516
+ it('repeated-keys array encoding for type filter (D3)', () => {
1517
+ const w = makeWindow();
1518
+ mountNexusUrl(w);
1519
+ w.NexusUrl.update({ type: ['mandate', 'bug'] });
1520
+ const params = new URLSearchParams(w.location.search);
1521
+ assert.deepEqual(params.getAll('type'), ['mandate', 'bug']);
1472
1522
  });
1473
1523
 
1474
- it('updateUrl percent-encodes ids that include special characters', () => {
1524
+ it('boolean encoding uses true/false strings (D7)', () => {
1475
1525
  const w = makeWindow();
1476
- const { updateUrl } = makeUrlHelpers(w);
1477
- updateUrl({ writ: 'w with spaces' });
1478
- // URLSearchParams encodes spaces as `+`; either form is valid in a
1479
- // querystring, but the regex below pins what URLSearchParams
1480
- // actually emits so wording drift breaks the suite.
1481
- assert.equal(w.history.pushed[0], '/pages/writs/?writ=w+with+spaces');
1526
+ mountNexusUrl(w);
1527
+ w.NexusUrl.update({ children: false, cancelled: true });
1528
+ const params = new URLSearchParams(w.location.search);
1529
+ assert.equal(params.get('children'), 'false');
1530
+ assert.equal(params.get('cancelled'), 'true');
1482
1531
  });
1483
1532
 
1484
1533
  it('a full select → back-to-list → select cycle pushes the right URL sequence', () => {
1485
1534
  const w = makeWindow();
1486
- const { updateUrl } = makeUrlHelpers(w);
1535
+ mountNexusUrl(w);
1487
1536
 
1488
1537
  // Operator clicks into a writ → showWritDetail pushes ?writ=
1489
- updateUrl({ writ: 'w-1' });
1538
+ w.NexusUrl.update({ writ: 'w-1' }, { push: true });
1490
1539
  // Back to list → showWritList clears ?writ=
1491
- updateUrl({ writ: null });
1540
+ w.NexusUrl.update({ writ: null }, { push: true });
1492
1541
  // Operator opens another writ
1493
- updateUrl({ writ: 'w-2' });
1542
+ w.NexusUrl.update({ writ: 'w-2' }, { push: true });
1494
1543
 
1495
1544
  assert.deepEqual(w.history.pushed, [
1496
1545
  '/pages/writs/?writ=w-1',
@@ -1501,18 +1550,13 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
1501
1550
 
1502
1551
  it('popstate-driven path does not push (the browser already updated the URL)', () => {
1503
1552
  const w = makeWindow('?writ=w-1');
1504
- const { currentUrlParams, updateUrl } = makeUrlHelpers(w);
1553
+ mountNexusUrl(w);
1505
1554
 
1506
- // Simulate the popstate dispatch: the page reads ?writ from the
1507
- // current URL and calls showWritDetail with skipUrlPush=true. We
1508
- // model showWritDetail's URL-push branch here as "if not
1509
- // skipUrlPush, updateUrl({writ: id})" — see the index.html
1510
- // implementation. The expected behaviour is zero pushes.
1511
1555
  function simulateShowWritDetail(id, skipUrlPush) {
1512
- if (!skipUrlPush) updateUrl({ writ: id });
1556
+ if (!skipUrlPush) w.NexusUrl.update({ writ: id }, { push: true });
1513
1557
  }
1514
1558
  function simulateShowWritList(skipUrlPush) {
1515
- if (!skipUrlPush) updateUrl({ writ: null });
1559
+ if (!skipUrlPush) w.NexusUrl.update({ writ: null }, { push: true });
1516
1560
  }
1517
1561
 
1518
1562
  // Forward to detail (programmatic, normal) — pushes once.
@@ -1533,25 +1577,33 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
1533
1577
  assert.equal(w.history.pushed[1], '/pages/writs/');
1534
1578
  });
1535
1579
 
1536
- it('popstate handler reads ?writ from the new URL (round-trip simulation)', () => {
1580
+ it('popstate handler reads ?writ from the new URL and restores filter state', () => {
1537
1581
  const w = makeWindow();
1538
- const { currentUrlParams, updateUrl } = makeUrlHelpers(w);
1582
+ mountNexusUrl(w);
1539
1583
 
1540
- // Track which view the page would be showing.
1584
+ // Track which view the page would be showing AND the filter state.
1541
1585
  let view = 'list';
1542
1586
  let detailId = null;
1587
+ let appliedClassification = '';
1588
+ let appliedSearch = '';
1543
1589
  function showWritDetail(id, opts) {
1544
1590
  view = 'detail';
1545
1591
  detailId = id;
1546
- if (!(opts && opts.skipUrlPush)) updateUrl({ writ: id });
1592
+ if (!(opts && opts.skipUrlPush)) w.NexusUrl.update({ writ: id }, { push: true });
1547
1593
  }
1548
1594
  function showWritList(opts) {
1549
1595
  view = 'list';
1550
1596
  detailId = null;
1551
- if (!(opts && opts.skipUrlPush)) updateUrl({ writ: null });
1597
+ if (!(opts && opts.skipUrlPush)) w.NexusUrl.update({ writ: null }, { push: true });
1598
+ }
1599
+ function readFilters() {
1600
+ const p = w.NexusUrl.read();
1601
+ appliedClassification = p.get('classification') ?? '';
1602
+ appliedSearch = p.get('q') ?? '';
1603
+ return p.get('writ');
1552
1604
  }
1553
1605
  function popstate() {
1554
- const id = currentUrlParams().get('writ');
1606
+ const id = readFilters();
1555
1607
  if (id) showWritDetail(id, { skipUrlPush: true });
1556
1608
  else showWritList({ skipUrlPush: true });
1557
1609
  }
@@ -1563,21 +1615,158 @@ describe('URL deep-link helpers (currentUrlParams + updateUrl + popstate cycle)'
1563
1615
  assert.equal(detailId, 'w-1');
1564
1616
  assert.equal(w.location.search, '?writ=w-1');
1565
1617
 
1566
- // Browser Back URL would revert; simulate the browser flipping
1567
- // the URL state back to the prior entry, then dispatching popstate.
1568
- w.location.search = '';
1618
+ // Browser Back to a filtered list view (?classification=active&q=foo)
1619
+ w.location.search = '?classification=active&q=foo';
1569
1620
  w._popstate({});
1570
1621
  assert.equal(view, 'list', 'popstate to list-view');
1571
1622
  assert.equal(detailId, null);
1623
+ assert.equal(appliedClassification, 'active', 'classification filter restored from URL');
1624
+ assert.equal(appliedSearch, 'foo', 'search text restored from URL');
1572
1625
  // No additional pushes from the popstate-driven render.
1573
1626
  assert.equal(w.history.pushed.length, 1);
1627
+ });
1628
+ });
1574
1629
 
1575
- // Browser Forward URL flips forward; popstate fires again.
1576
- w.location.search = '?writ=w-1';
1577
- w._popstate({});
1578
- assert.equal(view, 'detail');
1579
- assert.equal(detailId, 'w-1');
1580
- assert.equal(w.history.pushed.length, 1, 'forward popstate still does not push');
1630
+ describe('Clerk writs URL state filter restore from initial URL', () => {
1631
+ // Mirrors the page's readFiltersFromUrl logic. Asserts:
1632
+ // (a) changing a filter writes the expected URL key with the
1633
+ // expected value (covered by the URL-helper tests above),
1634
+ // (b) initial load with the URL key present applies the filter,
1635
+ // (c) popstate restoration re-applies the filter (covered above).
1636
+ // This block focuses on the (b) case for each filter key.
1637
+
1638
+ /**
1639
+ * Distilled mirror of readFiltersFromUrl in index.html. Returns the
1640
+ * filter state derived from a query string. Validation errors (D6
1641
+ * fail-loud) are returned as a `errors` list — the page surfaces
1642
+ * them in a top-of-page banner, but here we just assert the shape.
1643
+ */
1644
+ function readFiltersFromUrl(search, knownTypes) {
1645
+ const SORT_COLS = new Set(['phase', 'title', 'type', 'id', 'createdAt']);
1646
+ const SORT_DIRS = new Set(['asc', 'desc']);
1647
+ const CLASSIFICATIONS = new Set(['', 'initial', 'active', 'terminal']);
1648
+ const params = new URLSearchParams(search);
1649
+
1650
+ const errors = [];
1651
+ let classification = '';
1652
+ let currentType = new Set(knownTypes);
1653
+ let q = '';
1654
+ let sortCol = 'createdAt';
1655
+ let sortDir = 'desc';
1656
+ let showChildren = true;
1657
+ let showCancelled = false;
1658
+
1659
+ const c = params.get('classification');
1660
+ if (c !== null) {
1661
+ if (CLASSIFICATIONS.has(c)) classification = c;
1662
+ else errors.push(`classification: "${c}"`);
1663
+ }
1664
+ const types = params.getAll('type');
1665
+ if (types.length > 0) {
1666
+ const known = new Set(knownTypes);
1667
+ const unknown = types.filter(t => !known.has(t));
1668
+ if (unknown.length > 0) errors.push(...unknown.map(u => `type: "${u}"`));
1669
+ else currentType = new Set(types);
1670
+ }
1671
+ const queryText = params.get('q');
1672
+ if (queryText !== null) q = queryText;
1673
+ const sort = params.get('sort');
1674
+ if (sort !== null) {
1675
+ if (SORT_COLS.has(sort)) sortCol = sort;
1676
+ else errors.push(`sort: "${sort}"`);
1677
+ }
1678
+ const dir = params.get('dir');
1679
+ if (dir !== null) {
1680
+ if (SORT_DIRS.has(dir)) sortDir = dir;
1681
+ else errors.push(`dir: "${dir}"`);
1682
+ }
1683
+ const ch = params.get('children');
1684
+ if (ch !== null) {
1685
+ if (ch === 'true') showChildren = true;
1686
+ else if (ch === 'false') showChildren = false;
1687
+ else errors.push(`children: "${ch}"`);
1688
+ }
1689
+ const cn = params.get('cancelled');
1690
+ if (cn !== null) {
1691
+ if (cn === 'true') showCancelled = true;
1692
+ else if (cn === 'false') showCancelled = false;
1693
+ else errors.push(`cancelled: "${cn}"`);
1694
+ }
1695
+
1696
+ return { classification, currentType, q, sortCol, sortDir, showChildren, showCancelled, errors };
1697
+ }
1698
+
1699
+ it('initial load with ?classification=active applies the classification filter', () => {
1700
+ const s = readFiltersFromUrl('?classification=active', ['mandate', 'bug']);
1701
+ assert.equal(s.classification, 'active');
1702
+ assert.equal(s.errors.length, 0);
1703
+ });
1704
+
1705
+ it('initial load with ?type=mandate&type=bug applies the type filter', () => {
1706
+ const s = readFiltersFromUrl('?type=mandate&type=bug', ['mandate', 'bug', 'task']);
1707
+ assert.deepEqual(s.currentType, new Set(['mandate', 'bug']));
1708
+ assert.equal(s.errors.length, 0);
1709
+ });
1710
+
1711
+ it('initial load with ?q=hello applies the search text', () => {
1712
+ const s = readFiltersFromUrl('?q=hello', ['mandate']);
1713
+ assert.equal(s.q, 'hello');
1714
+ });
1715
+
1716
+ it('initial load with ?sort=title&dir=asc applies sort col + direction', () => {
1717
+ const s = readFiltersFromUrl('?sort=title&dir=asc', ['mandate']);
1718
+ assert.equal(s.sortCol, 'title');
1719
+ assert.equal(s.sortDir, 'asc');
1720
+ });
1721
+
1722
+ it('initial load with ?children=false applies the children toggle', () => {
1723
+ const s = readFiltersFromUrl('?children=false', ['mandate']);
1724
+ assert.equal(s.showChildren, false);
1725
+ });
1726
+
1727
+ it('initial load with ?cancelled=true applies the cancelled toggle', () => {
1728
+ const s = readFiltersFromUrl('?cancelled=true', ['mandate']);
1729
+ assert.equal(s.showCancelled, true);
1730
+ });
1731
+
1732
+ it('absent keys leave each filter at its declared default (D4)', () => {
1733
+ const s = readFiltersFromUrl('', ['mandate', 'bug']);
1734
+ assert.equal(s.classification, '');
1735
+ assert.deepEqual(s.currentType, new Set(['mandate', 'bug']));
1736
+ assert.equal(s.q, '');
1737
+ assert.equal(s.sortCol, 'createdAt');
1738
+ assert.equal(s.sortDir, 'desc');
1739
+ assert.equal(s.showChildren, true);
1740
+ assert.equal(s.showCancelled, false);
1741
+ assert.equal(s.errors.length, 0);
1742
+ });
1743
+
1744
+ it('fail-loud — invalid classification surfaces an error and does not fall back silently (D6)', () => {
1745
+ const s = readFiltersFromUrl('?classification=bogus', ['mandate']);
1746
+ assert.equal(s.errors.length, 1);
1747
+ assert.match(s.errors[0], /classification.*bogus/);
1748
+ // The default value stays at '' — but the error must be surfaced.
1749
+ assert.equal(s.classification, '');
1750
+ });
1751
+
1752
+ it('fail-loud — unknown type surfaces an error', () => {
1753
+ const s = readFiltersFromUrl('?type=bogus', ['mandate']);
1754
+ assert.equal(s.errors.length, 1);
1755
+ assert.match(s.errors[0], /type.*bogus/);
1756
+ });
1757
+
1758
+ it('fail-loud — invalid sort column / direction', () => {
1759
+ const s = readFiltersFromUrl('?sort=banana&dir=sideways', ['mandate']);
1760
+ assert.equal(s.errors.length, 2);
1761
+ assert.ok(s.errors.some(e => /sort.*banana/.test(e)));
1762
+ assert.ok(s.errors.some(e => /dir.*sideways/.test(e)));
1763
+ });
1764
+
1765
+ it('fail-loud — invalid boolean values', () => {
1766
+ const s = readFiltersFromUrl('?children=maybe&cancelled=perhaps', ['mandate']);
1767
+ assert.equal(s.errors.length, 2);
1768
+ assert.ok(s.errors.some(e => /children.*maybe/.test(e)));
1769
+ assert.ok(s.errors.some(e => /cancelled.*perhaps/.test(e)));
1581
1770
  });
1582
1771
  });
1583
1772