cursedbelt-core 1.0.0
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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// One-time, idempotent preference migration. The IA overhaul re-groups the old
|
|
2
|
+
// 8-hub layout into 6 domains; this remaps the user's saved nav/hub customizations
|
|
3
|
+
// (order, hidden, per-row colors) onto the new ids so nothing resets. The *mapping*
|
|
4
|
+
// (old key → new domain id) is app-supplied data (only the app knows its old hubs);
|
|
5
|
+
// the runner is the framework's. Idempotent via a version stamp — it runs on every
|
|
6
|
+
// cold load but no-ops once `version` is current, so colors/hidden the user set
|
|
7
|
+
// AFTER migrating are never clobbered.
|
|
8
|
+
/** Bump when the migration logic or the prefs shape changes. */
|
|
9
|
+
export const CURRENT_NAV_PREFS_VERSION = 1;
|
|
10
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
11
|
+
/** Merge two tile-pref blocks (union hidden, concat+dedupe order, later colors win). */
|
|
12
|
+
function mergeTilePrefs(a, b) {
|
|
13
|
+
return {
|
|
14
|
+
order: uniq([...(a?.order ?? []), ...(b.order ?? [])]),
|
|
15
|
+
hidden: uniq([...(a?.hidden ?? []), ...(b.hidden ?? [])]),
|
|
16
|
+
colors: { ...(a?.colors ?? {}), ...(b.colors ?? {}) },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Migrate legacy nav prefs onto the new model. Idempotent: returns `old` untouched
|
|
21
|
+
* when it's already at {@link CURRENT_NAV_PREFS_VERSION}. Otherwise remaps via
|
|
22
|
+
* `options.keyMap`, drops domain-level keys whose target isn't a real domain in
|
|
23
|
+
* `model`, merges consolidated hubs, and stamps the version.
|
|
24
|
+
*/
|
|
25
|
+
export function migrateNavPreferences(old, model, options = {}) {
|
|
26
|
+
// Already migrated → no-op (don't reset user choices made post-migration).
|
|
27
|
+
if (old && typeof old.version === 'number' && old.version >= CURRENT_NAV_PREFS_VERSION) {
|
|
28
|
+
return old;
|
|
29
|
+
}
|
|
30
|
+
const keyMap = options.keyMap ?? {};
|
|
31
|
+
const remap = (k) => keyMap[k] ?? k;
|
|
32
|
+
const domainIds = new Set((model?.domains ?? []).map((d) => d.id));
|
|
33
|
+
const legacy = (old ?? {});
|
|
34
|
+
// Domain order: remap each old route id → domain id, dedupe, keep only real domains.
|
|
35
|
+
const order = uniq((legacy.nav?.order ?? []).map(remap)).filter((id) => domainIds.has(id));
|
|
36
|
+
// Hidden domains: remap, dedupe, keep only real domains.
|
|
37
|
+
const hidden = uniq((legacy.hiddenNavRoutes ?? []).map(remap)).filter((id) => domainIds.has(id));
|
|
38
|
+
// Domain colors: remap keys, keep only real domains (last write wins on collision).
|
|
39
|
+
const colors = {};
|
|
40
|
+
for (const [k, v] of Object.entries(legacy.nav?.colors ?? {})) {
|
|
41
|
+
const id = remap(k);
|
|
42
|
+
if (domainIds.has(id))
|
|
43
|
+
colors[id] = v;
|
|
44
|
+
}
|
|
45
|
+
// Hub tile prefs: remap hub key → domain id, merging when two hubs consolidate.
|
|
46
|
+
const hubs = {};
|
|
47
|
+
for (const [oldHub, prefs] of Object.entries(legacy.hubs ?? {})) {
|
|
48
|
+
const id = remap(oldHub);
|
|
49
|
+
if (!domainIds.has(id))
|
|
50
|
+
continue; // a hub with no surviving domain home is dropped
|
|
51
|
+
hubs[id] = mergeTilePrefs(hubs[id], prefs);
|
|
52
|
+
}
|
|
53
|
+
const result = { version: CURRENT_NAV_PREFS_VERSION };
|
|
54
|
+
if (order.length)
|
|
55
|
+
result.order = order;
|
|
56
|
+
if (hidden.length)
|
|
57
|
+
result.hidden = hidden;
|
|
58
|
+
if (Object.keys(colors).length)
|
|
59
|
+
result.colors = colors;
|
|
60
|
+
if (Object.keys(hubs).length)
|
|
61
|
+
result.hubs = hubs;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visibility / eligibility gate carried by a domain or item. The model only stores
|
|
3
|
+
* the *flags*; the consuming app injects a {@link GateFn} that decides what they
|
|
4
|
+
* mean (is the user authed, do they hold the role, is the GET-only "ctgr" mode on).
|
|
5
|
+
* Gates are inherited: an item inside a gated-out domain never surfaces.
|
|
6
|
+
*/
|
|
7
|
+
export interface NavGate {
|
|
8
|
+
/** Only visible when signed in. */
|
|
9
|
+
requiresAuth?: boolean;
|
|
10
|
+
/** Only visible when signed out (public/marketing routes). */
|
|
11
|
+
requiresNoAuth?: boolean;
|
|
12
|
+
/** Visible only to users holding ONE of these roles (e.g. `['admin','owner']`). */
|
|
13
|
+
requiredRoles?: string[];
|
|
14
|
+
/** Hidden while the GET-only ("ctgr") session mode is active (write-heavy tools). */
|
|
15
|
+
hiddenInCtgr?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A navigable leaf. `path` references a route (a `routeMeta` path in ca); use a
|
|
19
|
+
* `#tab=…` suffix for an in-page tab target (`/tools#tab=curl`) — the selectors
|
|
20
|
+
* strip the hash for route matching but keep it for navigation.
|
|
21
|
+
*/
|
|
22
|
+
export interface NavItem {
|
|
23
|
+
/** Stable unique key — the React key AND the key prefs (order/hide/color) store by. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Route destination. `#tab=…` addresses an in-page tab. */
|
|
26
|
+
path: string;
|
|
27
|
+
/** Display label (side nav row, hub tile title, breadcrumb leaf, search hit). */
|
|
28
|
+
label: string;
|
|
29
|
+
/** Icon *name* from the app's icon set (resolved to a node by the React layer). */
|
|
30
|
+
icon?: string;
|
|
31
|
+
/** Short description — hub-tile subtitle + search snippet. */
|
|
32
|
+
description?: string;
|
|
33
|
+
/** Auth / role / ctgr gate (see {@link NavGate}). */
|
|
34
|
+
gate?: NavGate;
|
|
35
|
+
/**
|
|
36
|
+
* Registered for coverage + deep-link reachability, but **never surfaced** in the
|
|
37
|
+
* side nav, hub tiles, search, or palette. For routes reachable only by a shared
|
|
38
|
+
* link, a notification, or a WS invite (Shared Space, `/watch/:type/:id`, auth
|
|
39
|
+
* routes, `/set-initial-password`, admin deep-links). Use deliberately — it is not
|
|
40
|
+
* a way to dodge the coverage test for a real, discoverable feature.
|
|
41
|
+
*/
|
|
42
|
+
deepLinkOnly?: true;
|
|
43
|
+
/** Extra match terms for search / palette (synonyms, old names, abbreviations). */
|
|
44
|
+
keywords?: string[];
|
|
45
|
+
}
|
|
46
|
+
/** A labeled cluster of items inside a domain (a sub-section of a hub landing). */
|
|
47
|
+
export interface NavGroup {
|
|
48
|
+
/** Stable unique key (within the model). */
|
|
49
|
+
id: string;
|
|
50
|
+
/** Section heading shown on the hub landing; omit-able visually when there's one. */
|
|
51
|
+
label: string;
|
|
52
|
+
items: NavItem[];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A top-level **place** — the browsable backbone (the UX north-star's small fixed
|
|
56
|
+
* place-set: Today / Library / People / Calendar / Play + a role-gated Operate; see
|
|
57
|
+
* `react/components/SHELL.md`). It is itself navigable (its `path` is the place
|
|
58
|
+
* landing) and owns the groups of items shown there. The shell renders these as the
|
|
59
|
+
* slim desktop place rail (`SideNav`) and the mobile capture-forward `PlaceBar`.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* A place that exists in the IA but CANNOT do its job on the host currently
|
|
63
|
+
* serving the page.
|
|
64
|
+
*
|
|
65
|
+
* The worked example is the station's "Vault" row: the prod mirror offers it
|
|
66
|
+
* identically to the local one, and clicking it there reaches a machine that does
|
|
67
|
+
* not hold the data. The owner's ruling (`owner-uis-are-one-ui`, 2026-08-13) is
|
|
68
|
+
* that the answer is never a second UI and never a hidden item:
|
|
69
|
+
*
|
|
70
|
+
* *"Keeping two versions of UIs … is not a good long-term approach. I'd rather
|
|
71
|
+
* a button that literally can't work in prod just be disabled but in the same
|
|
72
|
+
* UI and with a message about why when I hover."*
|
|
73
|
+
*
|
|
74
|
+
* A hidden row teaches the reader the feature does not exist; a disabled row with
|
|
75
|
+
* a reason teaches them where to go. `UX_STANDARDS.md` §8.6 states it as a rule
|
|
76
|
+
* and no component carried it until unit 11.
|
|
77
|
+
*
|
|
78
|
+
* 🔴 "Too dangerous to expose" is NOT a host limit — Cloudflare Access already
|
|
79
|
+
* enforces owner-only at the edge. Only three things qualify: the destination
|
|
80
|
+
* points at the Mac, the bytes are not on this host, or it drives local
|
|
81
|
+
* hardware/the desktop.
|
|
82
|
+
*/
|
|
83
|
+
export interface NavHostLimit {
|
|
84
|
+
/** Why it cannot work here, in a sentence the owner would write. Shown on hover. */
|
|
85
|
+
reason: string;
|
|
86
|
+
/** Where it DOES work, if anywhere — "Open it on the Mac at localhost:4747". */
|
|
87
|
+
worksAt?: string;
|
|
88
|
+
/** Still let it be clicked (it degrades rather than failing). Default `false`. */
|
|
89
|
+
navigable?: boolean;
|
|
90
|
+
}
|
|
91
|
+
export interface NavDomain {
|
|
92
|
+
/** Stable unique key — prefs (order/hide/color) for the side-nav rail store by it. */
|
|
93
|
+
id: string;
|
|
94
|
+
/** Display label (side-nav row + hub landing title + breadcrumb root). */
|
|
95
|
+
label: string;
|
|
96
|
+
/** Icon *name* (required — domains always show an icon in the rail). */
|
|
97
|
+
icon: string;
|
|
98
|
+
/** The hub landing page path. */
|
|
99
|
+
path: string;
|
|
100
|
+
/** Domain-wide gate — inherited by every group/item under it. */
|
|
101
|
+
gate?: NavGate;
|
|
102
|
+
/**
|
|
103
|
+
* Short description — the hub-tile subtitle and the search/palette snippet, the
|
|
104
|
+
* same role it plays on a {@link NavItem}.
|
|
105
|
+
*/
|
|
106
|
+
description?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Extra match terms for search / palette (synonyms, old names, abbreviations) —
|
|
109
|
+
* again the same as on a {@link NavItem}.
|
|
110
|
+
*
|
|
111
|
+
* Both of these exist because a domain IS a destination: `deriveSearchCatalogue`
|
|
112
|
+
* emits one catalogue entry per domain, so a rail of leaf-shaped domains (an app
|
|
113
|
+
* whose places have no sub-items) had no way to be found by synonym while an
|
|
114
|
+
* otherwise identical rail of items did. That asymmetry was in the data model,
|
|
115
|
+
* not in the ranker.
|
|
116
|
+
*/
|
|
117
|
+
keywords?: string[];
|
|
118
|
+
/**
|
|
119
|
+
* Optional rail section label. When any domain carries one, `SideNav` renders the
|
|
120
|
+
* rail as labeled, collapsible groups instead of one flat list — the fix for the
|
|
121
|
+
* "wall of 20+ flat items" rail (owner UX teardown 2026-07-15 §2). Domains without
|
|
122
|
+
* a section render first, ungrouped. Section order follows first appearance.
|
|
123
|
+
*/
|
|
124
|
+
section?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Mark this place as belonging in the mobile `PlaceBar`'s **thumb zone** — the
|
|
127
|
+
* flat row of tabs a phone shows, as opposed to the "More" bottom-sheet behind it.
|
|
128
|
+
* When ANY domain in the model is pinned, the `PlaceBar` fills the thumb zone from
|
|
129
|
+
* the pinned set (in declaration order, up to its `max` budget) and overflows the
|
|
130
|
+
* *unpinned* places to More — so the important destinations survive onto a phone
|
|
131
|
+
* regardless of where they sit in the rail order.
|
|
132
|
+
*
|
|
133
|
+
* The default (no domain pinned) is the historical behavior: the first `max - 1`
|
|
134
|
+
* places by declaration order ride the bar and the rest go to More. Reach for a pin
|
|
135
|
+
* when an app has more places than fit AND the ones that matter on a phone are not
|
|
136
|
+
* the first few — e.g. an Inbox declared after Media/Prompts (owner UX report
|
|
137
|
+
* 2026-08-04: the Inbox fell into More, and it is a place the owner opens).
|
|
138
|
+
*
|
|
139
|
+
* `SideNav` (the desktop rail) ignores this — the rail shows every place.
|
|
140
|
+
*/
|
|
141
|
+
mobilePinned?: boolean;
|
|
142
|
+
/** Declare that this place cannot do its job on THIS host. It stays visible and
|
|
143
|
+
* says why on hover, rather than vanishing. @see NavHostLimit */
|
|
144
|
+
hostLimit?: NavHostLimit;
|
|
145
|
+
groups: NavGroup[];
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The complete IA tree. `domains` are the browsable backbone; `ungrouped` items are
|
|
149
|
+
* registered + searchable (and reachable by palette) but live under no domain hub —
|
|
150
|
+
* use it for cross-cutting destinations (Settings, Profile) or for `deepLinkOnly`
|
|
151
|
+
* routes that need coverage without a hub home.
|
|
152
|
+
*/
|
|
153
|
+
export interface NavigationModel {
|
|
154
|
+
domains: NavDomain[];
|
|
155
|
+
ungrouped?: NavItem[];
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Injected by the app: decides whether a gated node is visible *right now* (auth
|
|
159
|
+
* state, the user's roles, the ctgr GET-only mode). Pure — the selectors call it for
|
|
160
|
+
* every domain/item, so it must be cheap and side-effect-free. An undefined gate
|
|
161
|
+
* (no restrictions) is always visible.
|
|
162
|
+
*/
|
|
163
|
+
export type GateFn = (gate?: NavGate) => boolean;
|
|
164
|
+
/** The trivial gate — everything is visible. Default for selectors + unguarded apps. */
|
|
165
|
+
export declare const allowAllGate: GateFn;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The single navigation source of truth — a declarative Information-Architecture
|
|
2
|
+
// tree (Domain → Group → Item) every nav surface derives from: the side nav, hub
|
|
3
|
+
// landing tiles, breadcrumbs, the universal-search catalogue, and the command
|
|
4
|
+
// palette. Authoring a feature into the model is the *only* way to surface it, so
|
|
5
|
+
// the historical "four hand-maintained lists drift out of sync → features get
|
|
6
|
+
// buried" bug is structurally impossible (the coverage test enforces it in CI).
|
|
7
|
+
//
|
|
8
|
+
// This module is **React-free and routing-free** (it ships from `cursedbelt-core/navigation`):
|
|
9
|
+
// items carry icon *names* (strings) and *paths* (strings), never components or
|
|
10
|
+
// `<Link>`s, so the same model is consumed by client components, server tests, and
|
|
11
|
+
// the coverage test alike. The React layer (`cursedbelt/react`) resolves icon names
|
|
12
|
+
// to nodes and paths to navigation.
|
|
13
|
+
/** The trivial gate — everything is visible. Default for selectors + unguarded apps. */
|
|
14
|
+
export const allowAllGate = () => true;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type GateFn, type NavDomain, type NavGroup, type NavItem, type NavigationModel } from './navigationModel';
|
|
2
|
+
/**
|
|
3
|
+
* Normalize a path for route *matching* — drop the query string, the hash (the
|
|
4
|
+
* `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
|
|
5
|
+
* variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
|
|
6
|
+
* matches `/lists`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function normalizeNavPath(path: string): string;
|
|
9
|
+
/** A flattened, gated catalogue entry — a leaf plus where it lives (for grouping). */
|
|
10
|
+
export interface SearchCatalogueEntry extends NavItem {
|
|
11
|
+
/** Owning domain id (absent for ungrouped items). */
|
|
12
|
+
domainId?: string;
|
|
13
|
+
/** Owning domain label — groups search results + is itself matched. */
|
|
14
|
+
domainLabel?: string;
|
|
15
|
+
/** Owning group id (absent for domain landings + ungrouped items). */
|
|
16
|
+
groupId?: string;
|
|
17
|
+
/** Owning group label. */
|
|
18
|
+
groupLabel?: string;
|
|
19
|
+
}
|
|
20
|
+
/** One link in a breadcrumb trail — a coerced domain or item (always navigable). */
|
|
21
|
+
export interface NavCrumb {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
path: string;
|
|
25
|
+
icon?: string;
|
|
26
|
+
}
|
|
27
|
+
/** The ordered domains visible under `gate` — the side-nav rail backbone. */
|
|
28
|
+
export declare function deriveSideNavDomains(model: NavigationModel, gate?: GateFn): NavDomain[];
|
|
29
|
+
/**
|
|
30
|
+
* Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened across its
|
|
31
|
+
* groups — for a simple HubTileGrid. Returns `[]` if the domain itself is gated out.
|
|
32
|
+
*/
|
|
33
|
+
export declare function deriveDomainItems(domain: NavDomain, gate?: GateFn): NavItem[];
|
|
34
|
+
/**
|
|
35
|
+
* The visible groups of `domain` (each with its visible items; empty groups
|
|
36
|
+
* dropped) — for a *grouped* hub landing that shows section headings. Returns `[]`
|
|
37
|
+
* if the domain is gated out.
|
|
38
|
+
*/
|
|
39
|
+
export declare function deriveDomainGroups(domain: NavDomain, gate?: GateFn): NavGroup[];
|
|
40
|
+
/** Look up a domain by id (gated). */
|
|
41
|
+
export declare function deriveDomain(model: NavigationModel, domainId: string, gate?: GateFn): NavDomain | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* The ancestor chain for `path` — for Breadcrumbs. A domain landing yields
|
|
44
|
+
* `[domain]`; an item yields `[domain, item]`; an ungrouped item yields `[item]`.
|
|
45
|
+
* Matches the raw path first (so a `#tab=…` variant resolves to its own leaf), then
|
|
46
|
+
* falls back to the normalized base path. Returns `[]` when nothing matches.
|
|
47
|
+
*/
|
|
48
|
+
export declare function deriveBreadcrumbTrail(model: NavigationModel, path: string, gate?: GateFn): NavCrumb[];
|
|
49
|
+
/**
|
|
50
|
+
* The flat list of searchable destinations — every visible (gated,
|
|
51
|
+
* non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
|
|
52
|
+
* results can be grouped. Drives HeaderSearch's instant pages layer *and* the
|
|
53
|
+
* command palette; replaces every hand-listed search catalogue (so a feature is
|
|
54
|
+
* findable the moment it's registered).
|
|
55
|
+
*/
|
|
56
|
+
export declare function deriveSearchCatalogue(model: NavigationModel, gate?: GateFn): SearchCatalogueEntry[];
|
|
57
|
+
/**
|
|
58
|
+
* Every path registered in the model (domain landings + all items + ungrouped),
|
|
59
|
+
* INCLUDING `deepLinkOnly` items — that's the point: a `deepLinkOnly` route is still
|
|
60
|
+
* "registered", so it satisfies coverage. De-duplicated. Consumed by the coverage
|
|
61
|
+
* test ({@link createCoverageTest}).
|
|
62
|
+
*/
|
|
63
|
+
export declare function deriveAllPaths(model: NavigationModel): string[];
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Pure derivations off the {@link NavigationModel}. Every nav surface (side nav,
|
|
2
|
+
// hub tiles, breadcrumbs, search catalogue, palette, the coverage test) reads the
|
|
3
|
+
// model exclusively through these — there is no second list to keep in sync. They
|
|
4
|
+
// import nothing from React, so they run identically in the client, in a server
|
|
5
|
+
// test, and in the CI coverage check.
|
|
6
|
+
import { allowAllGate, } from './navigationModel';
|
|
7
|
+
/**
|
|
8
|
+
* Normalize a path for route *matching* — drop the query string, the hash (the
|
|
9
|
+
* `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
|
|
10
|
+
* variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
|
|
11
|
+
* matches `/lists`.
|
|
12
|
+
*/
|
|
13
|
+
export function normalizeNavPath(path) {
|
|
14
|
+
// Null-safe: a nav selector must never throw (a breadcrumb derivation crashing
|
|
15
|
+
// white-screens the whole page). Coerce a missing/non-string path to ''.
|
|
16
|
+
const s = typeof path === 'string' ? path : '';
|
|
17
|
+
let p = (s.split('#')[0] ?? '').split('?')[0]?.trim() ?? '';
|
|
18
|
+
if (p.length > 1 && p.endsWith('/'))
|
|
19
|
+
p = p.slice(0, -1);
|
|
20
|
+
return p || '/';
|
|
21
|
+
}
|
|
22
|
+
const domainCrumb = (d) => ({
|
|
23
|
+
id: d.id,
|
|
24
|
+
label: d.label,
|
|
25
|
+
path: d.path,
|
|
26
|
+
icon: d.icon,
|
|
27
|
+
});
|
|
28
|
+
const itemCrumb = (it) => ({
|
|
29
|
+
id: it.id,
|
|
30
|
+
label: it.label,
|
|
31
|
+
path: it.path,
|
|
32
|
+
icon: it.icon,
|
|
33
|
+
});
|
|
34
|
+
/** The ordered domains visible under `gate` — the side-nav rail backbone. */
|
|
35
|
+
export function deriveSideNavDomains(model, gate = allowAllGate) {
|
|
36
|
+
return (model?.domains ?? []).filter((d) => gate(d.gate));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened across its
|
|
40
|
+
* groups — for a simple HubTileGrid. Returns `[]` if the domain itself is gated out.
|
|
41
|
+
*/
|
|
42
|
+
export function deriveDomainItems(domain, gate = allowAllGate) {
|
|
43
|
+
if (!gate(domain.gate))
|
|
44
|
+
return [];
|
|
45
|
+
return domain.groups.flatMap((g) => g.items).filter((it) => !it.deepLinkOnly && gate(it.gate));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The visible groups of `domain` (each with its visible items; empty groups
|
|
49
|
+
* dropped) — for a *grouped* hub landing that shows section headings. Returns `[]`
|
|
50
|
+
* if the domain is gated out.
|
|
51
|
+
*/
|
|
52
|
+
export function deriveDomainGroups(domain, gate = allowAllGate) {
|
|
53
|
+
if (!gate(domain.gate))
|
|
54
|
+
return [];
|
|
55
|
+
return domain.groups
|
|
56
|
+
.map((g) => ({ ...g, items: g.items.filter((it) => !it.deepLinkOnly && gate(it.gate)) }))
|
|
57
|
+
.filter((g) => g.items.length > 0);
|
|
58
|
+
}
|
|
59
|
+
/** Look up a domain by id (gated). */
|
|
60
|
+
export function deriveDomain(model, domainId, gate = allowAllGate) {
|
|
61
|
+
const d = (model?.domains ?? []).find((x) => x.id === domainId);
|
|
62
|
+
return d && gate(d.gate) ? d : undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The ancestor chain for `path` — for Breadcrumbs. A domain landing yields
|
|
66
|
+
* `[domain]`; an item yields `[domain, item]`; an ungrouped item yields `[item]`.
|
|
67
|
+
* Matches the raw path first (so a `#tab=…` variant resolves to its own leaf), then
|
|
68
|
+
* falls back to the normalized base path. Returns `[]` when nothing matches.
|
|
69
|
+
*/
|
|
70
|
+
export function deriveBreadcrumbTrail(model, path, gate = allowAllGate) {
|
|
71
|
+
// Null-safe (see normalizeNavPath): a consumer that hasn't wired the model/path yet
|
|
72
|
+
// must get an empty trail, never a crash — a breadcrumb derivation throwing
|
|
73
|
+
// white-screens the whole page. Degrade gracefully on a missing model or path.
|
|
74
|
+
if (!model || !Array.isArray(model?.domains ?? []))
|
|
75
|
+
return [];
|
|
76
|
+
const safe = typeof path === 'string' ? path : '';
|
|
77
|
+
const raw = safe.trim();
|
|
78
|
+
const target = normalizeNavPath(safe);
|
|
79
|
+
// Two passes so an EXACT path wins over a merely-normalized one: `/tools#tab=curl`
|
|
80
|
+
// must resolve to the Curl tab leaf, not be shadowed by the `/tools` domain landing
|
|
81
|
+
// (which also normalizes to `/tools`). Within a pass, the domain landing is checked
|
|
82
|
+
// before its items so the bare `/tools` route yields `[Tools]`, not a tab leaf.
|
|
83
|
+
const find = (match) => {
|
|
84
|
+
for (const domain of model?.domains ?? []) {
|
|
85
|
+
if (!gate(domain.gate))
|
|
86
|
+
continue;
|
|
87
|
+
if (match(domain.path))
|
|
88
|
+
return [domainCrumb(domain)];
|
|
89
|
+
for (const group of domain.groups) {
|
|
90
|
+
for (const item of group.items) {
|
|
91
|
+
if (gate(item.gate) && match(item.path))
|
|
92
|
+
return [domainCrumb(domain), itemCrumb(item)];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const item of model.ungrouped ?? []) {
|
|
97
|
+
if (gate(item.gate) && match(item.path))
|
|
98
|
+
return [itemCrumb(item)];
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
};
|
|
102
|
+
return find((p) => p === raw) ?? find((p) => normalizeNavPath(p) === target) ?? [];
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The flat list of searchable destinations — every visible (gated,
|
|
106
|
+
* non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
|
|
107
|
+
* results can be grouped. Drives HeaderSearch's instant pages layer *and* the
|
|
108
|
+
* command palette; replaces every hand-listed search catalogue (so a feature is
|
|
109
|
+
* findable the moment it's registered).
|
|
110
|
+
*/
|
|
111
|
+
export function deriveSearchCatalogue(model, gate = allowAllGate) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const domain of model?.domains ?? []) {
|
|
114
|
+
if (!gate(domain.gate))
|
|
115
|
+
continue;
|
|
116
|
+
out.push({
|
|
117
|
+
id: domain.id,
|
|
118
|
+
path: domain.path,
|
|
119
|
+
label: domain.label,
|
|
120
|
+
icon: domain.icon,
|
|
121
|
+
// A domain landing is a destination like any other, so it carries the same
|
|
122
|
+
// search metadata an item does — without these, an app whose places are
|
|
123
|
+
// leaf-shaped domains could only ever be found by exact label.
|
|
124
|
+
...(domain.description === undefined ? {} : { description: domain.description }),
|
|
125
|
+
...(domain.keywords === undefined ? {} : { keywords: domain.keywords }),
|
|
126
|
+
domainId: domain.id,
|
|
127
|
+
domainLabel: domain.label,
|
|
128
|
+
});
|
|
129
|
+
for (const group of domain.groups) {
|
|
130
|
+
for (const item of group.items) {
|
|
131
|
+
if (item.deepLinkOnly || !gate(item.gate))
|
|
132
|
+
continue;
|
|
133
|
+
out.push({
|
|
134
|
+
...item,
|
|
135
|
+
domainId: domain.id,
|
|
136
|
+
domainLabel: domain.label,
|
|
137
|
+
groupId: group.id,
|
|
138
|
+
groupLabel: group.label,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
for (const item of model.ungrouped ?? []) {
|
|
144
|
+
if (item.deepLinkOnly || !gate(item.gate))
|
|
145
|
+
continue;
|
|
146
|
+
out.push({ ...item });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Every path registered in the model (domain landings + all items + ungrouped),
|
|
152
|
+
* INCLUDING `deepLinkOnly` items — that's the point: a `deepLinkOnly` route is still
|
|
153
|
+
* "registered", so it satisfies coverage. De-duplicated. Consumed by the coverage
|
|
154
|
+
* test ({@link createCoverageTest}).
|
|
155
|
+
*/
|
|
156
|
+
export function deriveAllPaths(model) {
|
|
157
|
+
const paths = new Set();
|
|
158
|
+
for (const domain of model?.domains ?? []) {
|
|
159
|
+
paths.add(domain.path);
|
|
160
|
+
for (const group of domain.groups)
|
|
161
|
+
for (const item of group.items)
|
|
162
|
+
paths.add(item.path);
|
|
163
|
+
}
|
|
164
|
+
for (const item of model.ungrouped ?? [])
|
|
165
|
+
paths.add(item.path);
|
|
166
|
+
return [...paths];
|
|
167
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SearchCatalogueEntry } from './navigationSelectors';
|
|
2
|
+
/** A query hit with its computed relevance score (higher = better). */
|
|
3
|
+
export interface CatalogueHit extends SearchCatalogueEntry {
|
|
4
|
+
score: number;
|
|
5
|
+
}
|
|
6
|
+
/** Hits bucketed under a domain (or under "" for ungrouped), preserving rank order. */
|
|
7
|
+
export interface CatalogueResultGroup {
|
|
8
|
+
/** Domain id, or `''` for ungrouped destinations. */
|
|
9
|
+
key: string;
|
|
10
|
+
/** Domain label, or `'Pages'` for ungrouped. */
|
|
11
|
+
label: string;
|
|
12
|
+
items: CatalogueHit[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Filter + rank a catalogue by `query`. Empty/blank query → `[]` (the instant layer
|
|
16
|
+
* shows nothing until the user types). Stable: equal scores keep catalogue order.
|
|
17
|
+
*/
|
|
18
|
+
export declare function rankCatalogue(catalogue: SearchCatalogueEntry[], query: string): CatalogueHit[];
|
|
19
|
+
/**
|
|
20
|
+
* Rank + bucket hits by owning domain (ungrouped under a "Pages" group), capping the
|
|
21
|
+
* total to `limit`. Groups appear in first-hit order; within a group, by rank.
|
|
22
|
+
*/
|
|
23
|
+
export declare function groupCatalogueHits(catalogue: SearchCatalogueEntry[], query: string, limit?: number): CatalogueResultGroup[];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Pure matcher + grouper for the instant client-side "pages" search layer. Ranks a
|
|
2
|
+
// derived catalogue ({@link deriveSearchCatalogue}) against a query and buckets the
|
|
3
|
+
// hits by their owning domain. Kept pure + framework-free so it's unit-testable and
|
|
4
|
+
// shared by HeaderSearch and (task 14) the command palette. The palette upgrades the
|
|
5
|
+
// *ranking* to uFuzzy; this is the dependency-free baseline both can fall back to.
|
|
6
|
+
const norm = (s) => s.toLowerCase().trim();
|
|
7
|
+
/**
|
|
8
|
+
* Score one entry against a normalized query. Returns `0` for no match. Ranking
|
|
9
|
+
* favors, in order: exact label, label prefix, label word-boundary, label
|
|
10
|
+
* substring, then keyword / group / domain / path / description hits — so "env"
|
|
11
|
+
* surfaces "Env Sets" above a page that merely mentions environments.
|
|
12
|
+
*/
|
|
13
|
+
function scoreEntry(entry, q) {
|
|
14
|
+
const label = norm(entry.label);
|
|
15
|
+
if (label === q)
|
|
16
|
+
return 100;
|
|
17
|
+
if (label.startsWith(q))
|
|
18
|
+
return 80;
|
|
19
|
+
// word-boundary: a query that starts any word in the label ("col" → "Format **Col**…")
|
|
20
|
+
if (label.split(/[\s/&·-]+/).some((w) => w.startsWith(q)))
|
|
21
|
+
return 65;
|
|
22
|
+
if (label.includes(q))
|
|
23
|
+
return 50;
|
|
24
|
+
for (const kw of entry.keywords ?? []) {
|
|
25
|
+
const k = norm(kw);
|
|
26
|
+
if (k === q || k.startsWith(q))
|
|
27
|
+
return 45;
|
|
28
|
+
if (k.includes(q))
|
|
29
|
+
return 35;
|
|
30
|
+
}
|
|
31
|
+
if (entry.groupLabel && norm(entry.groupLabel).includes(q))
|
|
32
|
+
return 25;
|
|
33
|
+
if (entry.domainLabel && norm(entry.domainLabel).includes(q))
|
|
34
|
+
return 20;
|
|
35
|
+
if (norm(entry.path).includes(q))
|
|
36
|
+
return 15;
|
|
37
|
+
if (entry.description && norm(entry.description).includes(q))
|
|
38
|
+
return 10;
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Filter + rank a catalogue by `query`. Empty/blank query → `[]` (the instant layer
|
|
43
|
+
* shows nothing until the user types). Stable: equal scores keep catalogue order.
|
|
44
|
+
*/
|
|
45
|
+
export function rankCatalogue(catalogue, query) {
|
|
46
|
+
const q = norm(query);
|
|
47
|
+
if (!q)
|
|
48
|
+
return [];
|
|
49
|
+
const scored = [];
|
|
50
|
+
for (const entry of catalogue) {
|
|
51
|
+
const score = scoreEntry(entry, q);
|
|
52
|
+
if (score > 0)
|
|
53
|
+
scored.push({ ...entry, score });
|
|
54
|
+
}
|
|
55
|
+
// Stable sort by score desc — Array.prototype.sort is stable in modern engines, so
|
|
56
|
+
// equal scores retain catalogue (authoring) order.
|
|
57
|
+
return scored.sort((a, b) => b.score - a.score);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Rank + bucket hits by owning domain (ungrouped under a "Pages" group), capping the
|
|
61
|
+
* total to `limit`. Groups appear in first-hit order; within a group, by rank.
|
|
62
|
+
*/
|
|
63
|
+
export function groupCatalogueHits(catalogue, query, limit = 20) {
|
|
64
|
+
const hits = rankCatalogue(catalogue, query).slice(0, limit);
|
|
65
|
+
const groups = new Map();
|
|
66
|
+
for (const hit of hits) {
|
|
67
|
+
const key = hit.domainId ?? '';
|
|
68
|
+
let bucket = groups.get(key);
|
|
69
|
+
if (!bucket) {
|
|
70
|
+
bucket = { key, label: hit.domainLabel ?? 'Pages', items: [] };
|
|
71
|
+
groups.set(key, bucket);
|
|
72
|
+
}
|
|
73
|
+
bucket.items.push(hit);
|
|
74
|
+
}
|
|
75
|
+
return [...groups.values()];
|
|
76
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnect discipline for every persistent client connection (03 §6.5, J5-2).
|
|
3
|
+
*
|
|
4
|
+
* A server restart or a laptop resume wakes every client at once. With a fixed
|
|
5
|
+
* retry interval they all reconnect in the same millisecond and the app
|
|
6
|
+
* self-DDoSes its own Hono listener — the more clients, the harder the stampede.
|
|
7
|
+
*
|
|
8
|
+
* So: exponential backoff with FULL jitter — `delay = random(0, min(cap, base ·
|
|
9
|
+
* 2^attempt))`. Full jitter (rather than "±10%") is what actually spreads a
|
|
10
|
+
* synchronized wake across the window. A stable connection resets the attempt
|
|
11
|
+
* counter, so a flaky link does not inherit yesterday's backoff.
|
|
12
|
+
*/
|
|
13
|
+
export interface ReconnectScheduleOptions {
|
|
14
|
+
baseMs?: number;
|
|
15
|
+
capMs?: number;
|
|
16
|
+
/** Connected for this long ⇒ the next drop starts from attempt 0 again. */
|
|
17
|
+
stableResetMs?: number;
|
|
18
|
+
/** Injected for tests; defaults to Math.random. */
|
|
19
|
+
random?: () => number;
|
|
20
|
+
}
|
|
21
|
+
export declare const RECONNECT_BASE_MS = 500;
|
|
22
|
+
export declare const RECONNECT_CAP_MS = 30000;
|
|
23
|
+
export declare const RECONNECT_STABLE_RESET_MS = 60000;
|
|
24
|
+
export interface ReconnectSchedule {
|
|
25
|
+
/** Delay for the next attempt, and advance the attempt counter. */
|
|
26
|
+
nextDelayMs(): number;
|
|
27
|
+
/** Call when a connection has been up; resets the counter once it has been stable. */
|
|
28
|
+
onConnected(uptimeMs: number): void;
|
|
29
|
+
/**
|
|
30
|
+
* Drop the accumulated backoff outright — for a DELIBERATE re-open (a
|
|
31
|
+
* credential change), which is not a failure and must not inherit the penalty
|
|
32
|
+
* the last outage earned. Never call this on a retry path: that is exactly how
|
|
33
|
+
* a fixed interval creeps back in.
|
|
34
|
+
*/
|
|
35
|
+
reset(): void;
|
|
36
|
+
attempt(): number;
|
|
37
|
+
/** The deterministic ceiling for the current attempt — what the jitter is drawn from. */
|
|
38
|
+
currentCeilingMs(): number;
|
|
39
|
+
}
|
|
40
|
+
export declare function createReconnectSchedule(options?: ReconnectScheduleOptions): ReconnectSchedule;
|
|
41
|
+
/**
|
|
42
|
+
* Did a set of observed retry timestamps actually spread? Used by V7.11: clients
|
|
43
|
+
* clustered inside one 50ms bucket beyond chance means someone reintroduced a
|
|
44
|
+
* fixed interval.
|
|
45
|
+
*/
|
|
46
|
+
export declare function retriesAreJittered(timestampsMs: readonly number[], bucketMs?: number): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const RECONNECT_BASE_MS = 500;
|
|
2
|
+
export const RECONNECT_CAP_MS = 30_000;
|
|
3
|
+
export const RECONNECT_STABLE_RESET_MS = 60_000;
|
|
4
|
+
export function createReconnectSchedule(options = {}) {
|
|
5
|
+
const base = options.baseMs ?? RECONNECT_BASE_MS;
|
|
6
|
+
const cap = options.capMs ?? RECONNECT_CAP_MS;
|
|
7
|
+
const stableReset = options.stableResetMs ?? RECONNECT_STABLE_RESET_MS;
|
|
8
|
+
const random = options.random ?? Math.random;
|
|
9
|
+
let attempt = 0;
|
|
10
|
+
const ceiling = (n) => Math.min(cap, base * 2 ** n);
|
|
11
|
+
return {
|
|
12
|
+
nextDelayMs() {
|
|
13
|
+
const delay = random() * ceiling(attempt);
|
|
14
|
+
attempt += 1;
|
|
15
|
+
return delay;
|
|
16
|
+
},
|
|
17
|
+
onConnected(uptimeMs) {
|
|
18
|
+
if (uptimeMs >= stableReset)
|
|
19
|
+
attempt = 0;
|
|
20
|
+
},
|
|
21
|
+
reset() {
|
|
22
|
+
attempt = 0;
|
|
23
|
+
},
|
|
24
|
+
attempt: () => attempt,
|
|
25
|
+
currentCeilingMs: () => ceiling(attempt),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Did a set of observed retry timestamps actually spread? Used by V7.11: clients
|
|
30
|
+
* clustered inside one 50ms bucket beyond chance means someone reintroduced a
|
|
31
|
+
* fixed interval.
|
|
32
|
+
*/
|
|
33
|
+
export function retriesAreJittered(timestampsMs, bucketMs = 50) {
|
|
34
|
+
if (timestampsMs.length < 2)
|
|
35
|
+
return true;
|
|
36
|
+
const buckets = new Set(timestampsMs.map((t) => Math.floor(t / bucketMs)));
|
|
37
|
+
return buckets.size > 1;
|
|
38
|
+
}
|