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,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed token set — the compile-time vocabulary every wire is drawn from
|
|
3
|
+
* (04 §2 V3). A `PortSpec` may only reference a `TokenDef` obtained from this
|
|
4
|
+
* registry; anything hand-declared fails V3 `token-not-registered`, which is
|
|
5
|
+
* what makes "compatible" a decidable property instead of a naming convention.
|
|
6
|
+
*
|
|
7
|
+
* Compatibility is **lattice membership**, not string equality: a wire connects
|
|
8
|
+
* iff `produced ⊑ consumed` under the reflexive-transitive closure of
|
|
9
|
+
* `subtypeOf`. String equality is retired — it forced every producer of a
|
|
10
|
+
* narrower thing (a photo feed) to lie about its type to reach a consumer of
|
|
11
|
+
* the wider thing (a media grid).
|
|
12
|
+
*
|
|
13
|
+
* Staging plugins extend the set at design time through `x-<pluginId>:*` ids
|
|
14
|
+
* (01 §5.2). Three laws keep the extension mechanism from corrupting the core
|
|
15
|
+
* vocabulary, all enforced here and reported by V3:
|
|
16
|
+
* - namespace: an extension id must be `x-<pluginId>:<name>`;
|
|
17
|
+
* - collision: an extension may never redeclare a base id;
|
|
18
|
+
* - inversion: a core token may never declare an extension as its supertype
|
|
19
|
+
* (the core would stop compiling the moment the plugin is deleted).
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
export { MARSHALLER_WORKER_OFFLOAD_BYTES, requiresWorkerOffload, } from '../wire/marshalling';
|
|
23
|
+
const TOKEN_ID_RE = /^[a-z][a-z0-9-]*:[a-z0-9-]+$/;
|
|
24
|
+
const EXTENSION_ID_RE = /^x-[a-z0-9-]+:[a-z0-9-]+$/;
|
|
25
|
+
export const isExtensionTokenId = (id) => id.startsWith('x-');
|
|
26
|
+
/** `x-habits:mark` ⇒ `habits`; `media:videos` ⇒ `media`. */
|
|
27
|
+
export const namespaceOf = (id) => id.slice(0, id.indexOf(':'));
|
|
28
|
+
/** `x-habits:mark` ⇒ `plugin-habits`. */
|
|
29
|
+
export const stagingPluginOf = (id) => `plugin-${namespaceOf(id).slice('x-'.length)}`;
|
|
30
|
+
/**
|
|
31
|
+
* Normalized structural description of a token payload. Two schemas that were
|
|
32
|
+
* authored independently in two repos still compare, which is the whole point of
|
|
33
|
+
* the J4-D fallback: identity is the fast path, structure is the truth.
|
|
34
|
+
*/
|
|
35
|
+
export function describeSchema(schema) {
|
|
36
|
+
const described = z.toJSONSchema(schema, { io: 'output', unrepresentable: 'any' });
|
|
37
|
+
const { $schema: _ignored, ...rest } = described;
|
|
38
|
+
return rest;
|
|
39
|
+
}
|
|
40
|
+
const stableStringify = (value) => {
|
|
41
|
+
if (value === null || typeof value !== 'object')
|
|
42
|
+
return JSON.stringify(value) ?? 'null';
|
|
43
|
+
if (Array.isArray(value))
|
|
44
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
45
|
+
const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
46
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(',')}}`;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Compare a producer's payload shape against the consumer's canonical shape.
|
|
50
|
+
*
|
|
51
|
+
* `backward-compatible-extension` means the producer satisfies every requirement
|
|
52
|
+
* the consumer states AND everything it adds is optional — a consumer written
|
|
53
|
+
* against the older shape still validates every payload the newer producer
|
|
54
|
+
* emits. Anything else is drift: same token id, different contract, silent
|
|
55
|
+
* runtime breakage (the `media:video-ref` `{id}` vs `{id,name}` class).
|
|
56
|
+
*/
|
|
57
|
+
export function compareSchemaShapes(producer, consumer) {
|
|
58
|
+
return compareNodes(producer, consumer);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The comparison recurses, because the interesting skew is never at the top
|
|
62
|
+
* level: a producer that added an optional `caption` to each item of
|
|
63
|
+
* `{ items: [...] }` differs only three levels down, and a shallow compare would
|
|
64
|
+
* call that drift and block a build for no reason.
|
|
65
|
+
*/
|
|
66
|
+
function compareNodes(producer, consumer) {
|
|
67
|
+
if (stableStringify(producer) === stableStringify(consumer))
|
|
68
|
+
return 'identical';
|
|
69
|
+
if (typeof producer !== 'object' || producer === null || typeof consumer !== 'object' || consumer === null)
|
|
70
|
+
return 'divergent';
|
|
71
|
+
const producerNode = producer;
|
|
72
|
+
const consumerNode = consumer;
|
|
73
|
+
if (producerNode.type === 'array' && consumerNode.type === 'array')
|
|
74
|
+
return compareNodes(producerNode.items, consumerNode.items);
|
|
75
|
+
const producerProps = (producerNode.properties ?? null);
|
|
76
|
+
const consumerProps = (consumerNode.properties ?? null);
|
|
77
|
+
if (producerNode.type !== 'object' || consumerNode.type !== 'object' || !producerProps || !consumerProps)
|
|
78
|
+
return 'divergent';
|
|
79
|
+
const producerRequired = new Set(producerNode.required ?? []);
|
|
80
|
+
const consumerRequired = new Set(consumerNode.required ?? []);
|
|
81
|
+
const widen = (current, next) => {
|
|
82
|
+
if (current === 'divergent' || next === 'divergent')
|
|
83
|
+
return 'divergent';
|
|
84
|
+
if (current === 'backward-compatible-extension' || next === 'backward-compatible-extension')
|
|
85
|
+
return 'backward-compatible-extension';
|
|
86
|
+
return 'identical';
|
|
87
|
+
};
|
|
88
|
+
let verdict = 'identical';
|
|
89
|
+
for (const [key, consumerProp] of Object.entries(consumerProps)) {
|
|
90
|
+
const producerProp = producerProps[key];
|
|
91
|
+
if (producerProp === undefined)
|
|
92
|
+
return 'divergent'; // a field the consumer needs and the producer never emits
|
|
93
|
+
if (consumerRequired.has(key) && !producerRequired.has(key))
|
|
94
|
+
return 'divergent'; // may arrive absent
|
|
95
|
+
verdict = widen(verdict, compareNodes(producerProp, consumerProp));
|
|
96
|
+
if (verdict === 'divergent')
|
|
97
|
+
return 'divergent';
|
|
98
|
+
}
|
|
99
|
+
for (const key of Object.keys(producerProps)) {
|
|
100
|
+
if (key in consumerProps)
|
|
101
|
+
continue;
|
|
102
|
+
// A required extra field is NOT backward compatible: a consumer that echoes
|
|
103
|
+
// the payload back through the same token would fail the producer's schema.
|
|
104
|
+
if (producerRequired.has(key))
|
|
105
|
+
return 'divergent';
|
|
106
|
+
verdict = widen(verdict, 'backward-compatible-extension');
|
|
107
|
+
}
|
|
108
|
+
return verdict;
|
|
109
|
+
}
|
|
110
|
+
export class TokenRegistry {
|
|
111
|
+
tokens = new Map();
|
|
112
|
+
marshallers = new Map();
|
|
113
|
+
shapes = new Map();
|
|
114
|
+
constructor(base = []) {
|
|
115
|
+
const issues = this.registerAll(base, { origin: 'base' });
|
|
116
|
+
if (issues.length > 0) {
|
|
117
|
+
// Base tokens are authored code, not user data — a broken base registry is a
|
|
118
|
+
// programming error that must never reach a diagnostic list.
|
|
119
|
+
throw new Error(`Invalid base token registry: ${issues.map((i) => `${i.code} (${i.tokenId})`).join(', ')}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Register harvested staging extensions. Returns issues rather than throwing:
|
|
124
|
+
* these ids come from files on disk, so they are data and belong in the
|
|
125
|
+
* diagnostic list (V3), not in a crash.
|
|
126
|
+
*/
|
|
127
|
+
registerAll(defs, opts = { origin: 'staging' }) {
|
|
128
|
+
const issues = [];
|
|
129
|
+
const accepted = [];
|
|
130
|
+
for (const def of defs) {
|
|
131
|
+
const path = opts.path;
|
|
132
|
+
// Collision is checked BEFORE the namespace laws: an extension that
|
|
133
|
+
// redeclares `media:videos` is a collision, and saying "bad namespace"
|
|
134
|
+
// would send the author to fix the wrong thing.
|
|
135
|
+
if (this.tokens.has(def.id)) {
|
|
136
|
+
issues.push({
|
|
137
|
+
code: 'token-collision',
|
|
138
|
+
tokenId: def.id,
|
|
139
|
+
path,
|
|
140
|
+
message: `Token '${def.id}' is already declared by the base registry — a staging extension may extend the vocabulary, never redefine it.`,
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (!TOKEN_ID_RE.test(def.id)) {
|
|
145
|
+
issues.push({
|
|
146
|
+
code: 'token-namespace-violation',
|
|
147
|
+
tokenId: def.id,
|
|
148
|
+
path,
|
|
149
|
+
message: `Token id '${def.id}' is not '<namespace>:<name>' in kebab-case.`,
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (opts.origin === 'staging' && !EXTENSION_ID_RE.test(def.id)) {
|
|
154
|
+
issues.push({
|
|
155
|
+
code: 'token-namespace-violation',
|
|
156
|
+
tokenId: def.id,
|
|
157
|
+
path,
|
|
158
|
+
message: `Staging token '${def.id}' must be namespaced 'x-<pluginId>:<name>'.`,
|
|
159
|
+
});
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (opts.origin === 'base' && isExtensionTokenId(def.id)) {
|
|
163
|
+
issues.push({
|
|
164
|
+
code: 'token-namespace-violation',
|
|
165
|
+
tokenId: def.id,
|
|
166
|
+
path,
|
|
167
|
+
message: `Base token '${def.id}' may not use the reserved staging 'x-' namespace.`,
|
|
168
|
+
});
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const sampleCheck = def.schema.safeParse(def.sample);
|
|
172
|
+
if (!sampleCheck.success) {
|
|
173
|
+
issues.push({
|
|
174
|
+
code: 'token-sample-invalid',
|
|
175
|
+
tokenId: def.id,
|
|
176
|
+
path,
|
|
177
|
+
message: `Token '${def.id}' sample payload does not satisfy its own schema: ${sampleCheck.error.issues[0]?.message ?? 'invalid'}.`,
|
|
178
|
+
});
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
this.tokens.set(def.id, def);
|
|
182
|
+
this.shapes.set(def.id, describeSchema(def.schema));
|
|
183
|
+
accepted.push(def);
|
|
184
|
+
}
|
|
185
|
+
// Lattice edges are validated after the batch lands: a staging catalog may
|
|
186
|
+
// declare a child before its parent within the same file.
|
|
187
|
+
for (const def of accepted) {
|
|
188
|
+
const parentId = def.subtypeOf;
|
|
189
|
+
if (parentId === undefined)
|
|
190
|
+
continue;
|
|
191
|
+
const parent = this.tokens.get(parentId);
|
|
192
|
+
if (!parent) {
|
|
193
|
+
issues.push({
|
|
194
|
+
code: 'token-supertype-missing',
|
|
195
|
+
tokenId: def.id,
|
|
196
|
+
path: opts.path,
|
|
197
|
+
message: `Token '${def.id}' declares supertype '${parentId}', which is not registered.`,
|
|
198
|
+
});
|
|
199
|
+
this.tokens.delete(def.id);
|
|
200
|
+
this.shapes.delete(def.id);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (!isExtensionTokenId(def.id) && isExtensionTokenId(parentId)) {
|
|
204
|
+
issues.push({
|
|
205
|
+
code: 'subtype-inversion',
|
|
206
|
+
tokenId: def.id,
|
|
207
|
+
path: opts.path,
|
|
208
|
+
message: `Core token '${def.id}' declares staging extension '${parentId}' as its supertype — the core would stop compiling when the plugin is removed.`,
|
|
209
|
+
});
|
|
210
|
+
this.tokens.delete(def.id);
|
|
211
|
+
this.shapes.delete(def.id);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (this.hasSubtypeCycle(def.id)) {
|
|
215
|
+
issues.push({
|
|
216
|
+
code: 'token-subtype-cycle',
|
|
217
|
+
tokenId: def.id,
|
|
218
|
+
path: opts.path,
|
|
219
|
+
message: `Token '${def.id}' participates in a subtypeOf cycle.`,
|
|
220
|
+
});
|
|
221
|
+
this.tokens.delete(def.id);
|
|
222
|
+
this.shapes.delete(def.id);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return issues;
|
|
226
|
+
}
|
|
227
|
+
registerMarshaller(marshaller) {
|
|
228
|
+
this.marshallers.set(marshaller.tokenId, marshaller);
|
|
229
|
+
}
|
|
230
|
+
get(id) {
|
|
231
|
+
return this.tokens.get(id);
|
|
232
|
+
}
|
|
233
|
+
has(id) {
|
|
234
|
+
return this.tokens.has(id);
|
|
235
|
+
}
|
|
236
|
+
ids() {
|
|
237
|
+
return [...this.tokens.keys()].sort();
|
|
238
|
+
}
|
|
239
|
+
marshallerFor(tokenId) {
|
|
240
|
+
return this.marshallers.get(tokenId);
|
|
241
|
+
}
|
|
242
|
+
shapeOf(tokenId) {
|
|
243
|
+
return this.shapes.get(tokenId);
|
|
244
|
+
}
|
|
245
|
+
/** The reflexive-transitive closure of `subtypeOf`: is `childId ⊑ parentId`? */
|
|
246
|
+
isSubtype(childId, parentId) {
|
|
247
|
+
let cursor = childId;
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
while (cursor !== undefined) {
|
|
250
|
+
if (cursor === parentId)
|
|
251
|
+
return true;
|
|
252
|
+
if (seen.has(cursor))
|
|
253
|
+
return false;
|
|
254
|
+
seen.add(cursor);
|
|
255
|
+
cursor = this.tokens.get(cursor)?.subtypeOf;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
/** Every ancestor of a token, nearest first — used by V3 diagnostics. */
|
|
260
|
+
ancestorsOf(tokenId) {
|
|
261
|
+
const chain = [];
|
|
262
|
+
const seen = new Set();
|
|
263
|
+
let cursor = this.tokens.get(tokenId)?.subtypeOf;
|
|
264
|
+
while (cursor !== undefined && !seen.has(cursor)) {
|
|
265
|
+
chain.push(cursor);
|
|
266
|
+
seen.add(cursor);
|
|
267
|
+
cursor = this.tokens.get(cursor)?.subtypeOf;
|
|
268
|
+
}
|
|
269
|
+
return chain;
|
|
270
|
+
}
|
|
271
|
+
canConnect(producedId, consumedId) {
|
|
272
|
+
const produced = this.tokens.get(producedId);
|
|
273
|
+
const consumed = this.tokens.get(consumedId);
|
|
274
|
+
if (!produced)
|
|
275
|
+
return { ok: false, reason: 'unknown-produced', message: `Token '${producedId}' is not registered.` };
|
|
276
|
+
if (!consumed)
|
|
277
|
+
return { ok: false, reason: 'unknown-consumed', message: `Token '${consumedId}' is not registered.` };
|
|
278
|
+
if (produced.mode !== consumed.mode)
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
reason: 'mode-mismatch',
|
|
282
|
+
message: `'${producedId}' is a ${produced.mode} port and '${consumedId}' is a ${consumed.mode} port.`,
|
|
283
|
+
};
|
|
284
|
+
if (!this.isSubtype(producedId, consumedId))
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
reason: 'not-a-subtype',
|
|
288
|
+
message: `'${producedId}' is not '${consumedId}' or a subtype of it (lattice: ${[producedId, ...this.ancestorsOf(producedId)].join(' ⊑ ')}).`,
|
|
289
|
+
};
|
|
290
|
+
return { ok: true };
|
|
291
|
+
}
|
|
292
|
+
hasSubtypeCycle(startId) {
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
let cursor = startId;
|
|
295
|
+
while (cursor !== undefined) {
|
|
296
|
+
if (seen.has(cursor))
|
|
297
|
+
return true;
|
|
298
|
+
seen.add(cursor);
|
|
299
|
+
cursor = this.tokens.get(cursor)?.subtypeOf;
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/* ---------------------------------------------------------------------------
|
|
305
|
+
* The base registry. Every id here is part of the app's public wire vocabulary;
|
|
306
|
+
* removing one is a breaking change that goes through the deprecation flow
|
|
307
|
+
* (02 §3.3 step 3), never a delete.
|
|
308
|
+
* ------------------------------------------------------------------------ */
|
|
309
|
+
const MediaItemSchema = z.object({
|
|
310
|
+
id: z.string(),
|
|
311
|
+
kind: z.enum(['video', 'photo', 'file', 'audio']),
|
|
312
|
+
});
|
|
313
|
+
const VideoItemSchema = z.object({
|
|
314
|
+
id: z.string(),
|
|
315
|
+
kind: z.enum(['video', 'photo', 'file', 'audio']),
|
|
316
|
+
streamUrl: z.string().optional(),
|
|
317
|
+
hlsUrl: z.string().optional(),
|
|
318
|
+
hasHls: z.boolean().optional(),
|
|
319
|
+
});
|
|
320
|
+
export const BASE_TOKENS = [
|
|
321
|
+
{
|
|
322
|
+
id: 'media:items',
|
|
323
|
+
mode: 'value',
|
|
324
|
+
description: 'Any addressable media collection — the lattice root every media feed narrows.',
|
|
325
|
+
schema: z.object({ items: z.array(MediaItemSchema) }),
|
|
326
|
+
sample: { items: [{ id: 'itm_1', kind: 'file' }] },
|
|
327
|
+
},
|
|
328
|
+
{
|
|
329
|
+
id: 'media:videos',
|
|
330
|
+
mode: 'value',
|
|
331
|
+
subtypeOf: 'media:items',
|
|
332
|
+
description: 'Video collection with per-item playback URLs; degrades to progressive when hls is off.',
|
|
333
|
+
schema: z.object({ items: z.array(VideoItemSchema) }),
|
|
334
|
+
sample: { items: [{ id: 'vid_1', kind: 'video', streamUrl: '/api/asset/stream/vid_1' }] },
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
id: 'media:photos',
|
|
338
|
+
mode: 'value',
|
|
339
|
+
subtypeOf: 'media:items',
|
|
340
|
+
description: 'Photo collection.',
|
|
341
|
+
schema: z.object({ items: z.array(MediaItemSchema) }),
|
|
342
|
+
sample: { items: [{ id: 'pho_1', kind: 'photo' }] },
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
id: 'media:video-ref',
|
|
346
|
+
mode: 'value',
|
|
347
|
+
description: 'A single video selection handed between a gallery and an editor.',
|
|
348
|
+
schema: z.object({ id: z.string(), name: z.string().optional() }),
|
|
349
|
+
sample: { id: 'vid_1' },
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
id: 'asset:file-ref',
|
|
353
|
+
mode: 'value',
|
|
354
|
+
description: 'A single stored file reference.',
|
|
355
|
+
schema: z.object({ id: z.string(), mime: z.string().optional() }),
|
|
356
|
+
sample: { id: 'file_1' },
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
id: 'text:content',
|
|
360
|
+
mode: 'value',
|
|
361
|
+
description: 'Plain text body — diff panes, note editors, transcript views.',
|
|
362
|
+
schema: z.object({ text: z.string() }),
|
|
363
|
+
sample: { text: 'hello' },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
id: 'signal:refresh',
|
|
367
|
+
mode: 'signal',
|
|
368
|
+
description: 'Scoped invalidation pulse. Fan-in target: every emission must carry a scope (03 §6.1).',
|
|
369
|
+
schema: z.object({ scope: z.string().optional() }),
|
|
370
|
+
sample: { scope: 'videos' },
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: 'session:private-mode',
|
|
374
|
+
mode: 'value',
|
|
375
|
+
description: 'GuardEngine session assertion. Advisory on the client; every route re-verifies (02 §4.2).',
|
|
376
|
+
schema: z.object({
|
|
377
|
+
unlocked: z.boolean(),
|
|
378
|
+
token: z.string().nullable(),
|
|
379
|
+
claims: z.unknown().nullable(),
|
|
380
|
+
}),
|
|
381
|
+
sample: { unlocked: false, token: null, claims: null },
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: 'nav:path',
|
|
385
|
+
mode: 'value',
|
|
386
|
+
description: 'Current client route path — replaces the deleted Breadcrumbs plugin.',
|
|
387
|
+
schema: z.object({ path: z.string() }),
|
|
388
|
+
sample: { path: '/' },
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
id: 'knowledge:conversation-ref',
|
|
392
|
+
mode: 'value',
|
|
393
|
+
description: 'A RAG conversation reference; deep-link entity for /chat/:conversationId.',
|
|
394
|
+
schema: z.object({ id: z.string() }),
|
|
395
|
+
sample: { id: 'conv_1' },
|
|
396
|
+
},
|
|
397
|
+
{
|
|
398
|
+
id: 'knowledge:note-ref',
|
|
399
|
+
mode: 'value',
|
|
400
|
+
description: 'A note reference; deep-link entity for note routes.',
|
|
401
|
+
schema: z.object({ id: z.string() }),
|
|
402
|
+
sample: { id: 'note_1' },
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
id: 'knowledge:records',
|
|
406
|
+
mode: 'value',
|
|
407
|
+
external: true,
|
|
408
|
+
description: 'Imported record rows (CSV/sheet/webhook) — external traffic, marshaller-gated (02 §7.1).',
|
|
409
|
+
schema: z.object({ rows: z.array(z.record(z.string(), z.unknown())) }),
|
|
410
|
+
sample: { rows: [{ id: '1' }] },
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
id: 'calendar:reminder',
|
|
414
|
+
mode: 'signal',
|
|
415
|
+
description: 'A due reminder pulse consumed by the notifications inbox.',
|
|
416
|
+
schema: z.object({ scope: z.string().optional(), at: z.number() }),
|
|
417
|
+
sample: { at: 0 },
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: 'stopwatch:toggled',
|
|
421
|
+
mode: 'signal',
|
|
422
|
+
description: 'Stopwatch widget start/stop pulse.',
|
|
423
|
+
schema: z.object({ running: z.boolean() }),
|
|
424
|
+
sample: { running: false },
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
id: 'dev:job-status',
|
|
428
|
+
mode: 'value',
|
|
429
|
+
description: 'JobBroker progress for a deploy/pipeline/promotion job.',
|
|
430
|
+
schema: z.object({
|
|
431
|
+
jobId: z.string(),
|
|
432
|
+
state: z.enum(['queued', 'running', 'done', 'failed']),
|
|
433
|
+
rev: z.number().optional(),
|
|
434
|
+
}),
|
|
435
|
+
sample: { jobId: 'job_1', state: 'queued' },
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: 'nes:rom-url',
|
|
439
|
+
mode: 'value',
|
|
440
|
+
deprecated: { replacedBy: 'asset:file-ref', since: 'core-refactor' },
|
|
441
|
+
description: 'DEPRECATED — the NES toy was pruned from the core catalog (02 §1.6).',
|
|
442
|
+
schema: z.object({ url: z.string() }),
|
|
443
|
+
sample: { url: 'about:blank' },
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
/** Fresh registry per compilation — the compiler never shares mutable state between runs. */
|
|
447
|
+
export const createTokenRegistry = () => new TokenRegistry(BASE_TOKENS);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI-scale core (framework-free) — the pure model + the document/localStorage store
|
|
3
|
+
* behind the app-wide "text/UI size" accessibility control. `initUiScale()` applies
|
|
4
|
+
* the saved size on app boot (before React mounts → no flash); the React control +
|
|
5
|
+
* hook live in `cursedbelt/react`.
|
|
6
|
+
*/
|
|
7
|
+
export { clampUiScale, DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE, nearestUiScaleLevel, stepUiScale, UI_SCALE_LEVELS, UI_SCALE_STEP, type UiScaleLevel, type UiScaleLevelId, uiScaleToFontSize, } from './scale';
|
|
8
|
+
export { applyUiScaleToDocument, createUiScaleStore, getUiScale, initUiScale, setUiScale, subscribeUiScale, UI_SCALE_STORAGE_KEY, type UiScaleStorage, type UiScaleStore, type UiScaleStoreOptions, uiScaleStore, } from './uiScaleStore';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI-scale core (framework-free) — the pure model + the document/localStorage store
|
|
3
|
+
* behind the app-wide "text/UI size" accessibility control. `initUiScale()` applies
|
|
4
|
+
* the saved size on app boot (before React mounts → no flash); the React control +
|
|
5
|
+
* hook live in `cursedbelt/react`.
|
|
6
|
+
*/
|
|
7
|
+
export { clampUiScale, DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE, nearestUiScaleLevel, stepUiScale, UI_SCALE_LEVELS, UI_SCALE_STEP, uiScaleToFontSize, } from './scale';
|
|
8
|
+
export { applyUiScaleToDocument, createUiScaleStore, getUiScale, initUiScale, setUiScale, subscribeUiScale, UI_SCALE_STORAGE_KEY, uiScaleStore, } from './uiScaleStore';
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI scale — the pure model behind an app-wide "text/UI size" accessibility
|
|
3
|
+
* control. A single number multiplies the document's root font-size, so every
|
|
4
|
+
* **rem-based** Tailwind utility (text, spacing, gaps, rem-sized icons) grows or
|
|
5
|
+
* shrinks together. Because the whole UI scales proportionally — like browser
|
|
6
|
+
* zoom — relative spacing, alignment, and source order are preserved by
|
|
7
|
+
* construction: there's no per-element override that could reflow things into an
|
|
8
|
+
* overlap or reorder a label below its section. (Anything sized in raw px won't
|
|
9
|
+
* scale; cwip's components are rem-first so they do.)
|
|
10
|
+
*
|
|
11
|
+
* This file is framework- and DOM-free so it's trivially testable; the React hook
|
|
12
|
+
* + the store that actually touches `document`/`localStorage` build on it.
|
|
13
|
+
*/
|
|
14
|
+
export type UiScaleLevelId = 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
|
|
15
|
+
export interface UiScaleLevel {
|
|
16
|
+
readonly id: UiScaleLevelId;
|
|
17
|
+
readonly label: string;
|
|
18
|
+
/** Root-font-size multiplier (1 = the browser/app default). */
|
|
19
|
+
readonly scale: number;
|
|
20
|
+
}
|
|
21
|
+
/** Named presets for a simple picker. Continuous values in between are valid too. */
|
|
22
|
+
export declare const UI_SCALE_LEVELS: readonly UiScaleLevel[];
|
|
23
|
+
export declare const MIN_UI_SCALE = 0.8;
|
|
24
|
+
export declare const MAX_UI_SCALE = 2;
|
|
25
|
+
export declare const DEFAULT_UI_SCALE = 1;
|
|
26
|
+
/** One press of the +/- stepper. */
|
|
27
|
+
export declare const UI_SCALE_STEP = 0.1;
|
|
28
|
+
/** Clamp to the supported range, rounded to 3 decimals; non-finite → default. */
|
|
29
|
+
export declare function clampUiScale(n: number): number;
|
|
30
|
+
/** Nudge a scale up (+1) or down (-1) by one step, clamped. */
|
|
31
|
+
export declare function stepUiScale(scale: number, dir: 1 | -1): number;
|
|
32
|
+
/** The preset closest to a (continuous) scale — drives a picker's active state. */
|
|
33
|
+
export declare function nearestUiScaleLevel(scale: number): UiScaleLevel;
|
|
34
|
+
/** The CSS `font-size` value to set on the root, e.g. 1.15 → "115%". Percent (not
|
|
35
|
+
* px) so it composes with the user's own browser base-font preference. */
|
|
36
|
+
export declare function uiScaleToFontSize(scale: number): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI scale — the pure model behind an app-wide "text/UI size" accessibility
|
|
3
|
+
* control. A single number multiplies the document's root font-size, so every
|
|
4
|
+
* **rem-based** Tailwind utility (text, spacing, gaps, rem-sized icons) grows or
|
|
5
|
+
* shrinks together. Because the whole UI scales proportionally — like browser
|
|
6
|
+
* zoom — relative spacing, alignment, and source order are preserved by
|
|
7
|
+
* construction: there's no per-element override that could reflow things into an
|
|
8
|
+
* overlap or reorder a label below its section. (Anything sized in raw px won't
|
|
9
|
+
* scale; cwip's components are rem-first so they do.)
|
|
10
|
+
*
|
|
11
|
+
* This file is framework- and DOM-free so it's trivially testable; the React hook
|
|
12
|
+
* + the store that actually touches `document`/`localStorage` build on it.
|
|
13
|
+
*/
|
|
14
|
+
/** Named presets for a simple picker. Continuous values in between are valid too. */
|
|
15
|
+
export const UI_SCALE_LEVELS = [
|
|
16
|
+
{ id: 'sm', label: 'Small', scale: 0.9 },
|
|
17
|
+
{ id: 'md', label: 'Default', scale: 1 },
|
|
18
|
+
{ id: 'lg', label: 'Large', scale: 1.15 },
|
|
19
|
+
{ id: 'xl', label: 'Larger', scale: 1.3 },
|
|
20
|
+
{ id: 'xxl', label: 'Largest', scale: 1.5 },
|
|
21
|
+
];
|
|
22
|
+
export const MIN_UI_SCALE = 0.8;
|
|
23
|
+
export const MAX_UI_SCALE = 2;
|
|
24
|
+
export const DEFAULT_UI_SCALE = 1;
|
|
25
|
+
/** One press of the +/- stepper. */
|
|
26
|
+
export const UI_SCALE_STEP = 0.1;
|
|
27
|
+
/** Clamp to the supported range, rounded to 3 decimals; non-finite → default. */
|
|
28
|
+
export function clampUiScale(n) {
|
|
29
|
+
if (!Number.isFinite(n))
|
|
30
|
+
return DEFAULT_UI_SCALE;
|
|
31
|
+
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(n * 1000) / 1000));
|
|
32
|
+
}
|
|
33
|
+
/** Nudge a scale up (+1) or down (-1) by one step, clamped. */
|
|
34
|
+
export function stepUiScale(scale, dir) {
|
|
35
|
+
return clampUiScale(scale + dir * UI_SCALE_STEP);
|
|
36
|
+
}
|
|
37
|
+
/** The preset closest to a (continuous) scale — drives a picker's active state. */
|
|
38
|
+
export function nearestUiScaleLevel(scale) {
|
|
39
|
+
const s = clampUiScale(scale);
|
|
40
|
+
return UI_SCALE_LEVELS.reduce((best, level) => Math.abs(level.scale - s) < Math.abs(best.scale - s) ? level : best);
|
|
41
|
+
}
|
|
42
|
+
/** The CSS `font-size` value to set on the root, e.g. 1.15 → "115%". Percent (not
|
|
43
|
+
* px) so it composes with the user's own browser base-font preference. */
|
|
44
|
+
export function uiScaleToFontSize(scale) {
|
|
45
|
+
const pct = Math.round(clampUiScale(scale) * 1000) / 10; // 1.15 → 115, 0.9 → 90
|
|
46
|
+
return `${pct}%`;
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI-scale store: holds the current scale, persists it, applies it to the
|
|
3
|
+
* document root, and notifies subscribers. Split from the React hook so it works
|
|
4
|
+
* before React mounts (call {@link initUiScale} early — like a theme init — to
|
|
5
|
+
* apply the saved size with no flash) and so its logic is testable without a DOM.
|
|
6
|
+
*
|
|
7
|
+
* Side-effects (storage + applying to the page) are injected, so a test drives a
|
|
8
|
+
* real store with fakes; the exported default singleton binds them to
|
|
9
|
+
* `localStorage` + the `<html>` element.
|
|
10
|
+
*/
|
|
11
|
+
/** The slice of the Web Storage API the store needs (so a test can fake it). */
|
|
12
|
+
export interface UiScaleStorage {
|
|
13
|
+
getItem(key: string): string | null;
|
|
14
|
+
setItem(key: string, value: string): void;
|
|
15
|
+
}
|
|
16
|
+
export interface UiScaleStore {
|
|
17
|
+
/** Current scale (lazily hydrated from storage on first read). */
|
|
18
|
+
get(): number;
|
|
19
|
+
/** Set, clamp, persist, apply, and notify. */
|
|
20
|
+
set(scale: number): void;
|
|
21
|
+
/** Nudge up (+1) / down (-1) one step. */
|
|
22
|
+
step(dir: 1 | -1): void;
|
|
23
|
+
/** Back to the default size. */
|
|
24
|
+
reset(): void;
|
|
25
|
+
/** Re-apply the persisted scale to the page (call once on app boot). */
|
|
26
|
+
init(): void;
|
|
27
|
+
subscribe(listener: () => void): () => void;
|
|
28
|
+
}
|
|
29
|
+
export interface UiScaleStoreOptions {
|
|
30
|
+
storage?: UiScaleStorage | null;
|
|
31
|
+
/** Apply a scale to the page. Default writes the root font-size + a CSS var. */
|
|
32
|
+
apply?: (scale: number) => void;
|
|
33
|
+
/** localStorage key. */
|
|
34
|
+
key?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare const UI_SCALE_STORAGE_KEY = "cwip.uiScale";
|
|
37
|
+
export declare function createUiScaleStore(options?: UiScaleStoreOptions): UiScaleStore;
|
|
38
|
+
/** Apply a scale to the document root: scale the rem base + expose a `--cwip-ui-scale`
|
|
39
|
+
* var (for the rare px that wants `calc(... * var(--cwip-ui-scale))`). SSR-safe. */
|
|
40
|
+
export declare function applyUiScaleToDocument(scale: number): void;
|
|
41
|
+
/** The app-wide singleton bound to localStorage + the page. */
|
|
42
|
+
export declare const uiScaleStore: UiScaleStore;
|
|
43
|
+
export declare const getUiScale: () => number;
|
|
44
|
+
export declare const setUiScale: (scale: number) => void;
|
|
45
|
+
export declare const subscribeUiScale: (listener: () => void) => (() => void);
|
|
46
|
+
/** Apply the saved UI size on app boot, before React mounts (no flash). */
|
|
47
|
+
export declare const initUiScale: () => void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI-scale store: holds the current scale, persists it, applies it to the
|
|
3
|
+
* document root, and notifies subscribers. Split from the React hook so it works
|
|
4
|
+
* before React mounts (call {@link initUiScale} early — like a theme init — to
|
|
5
|
+
* apply the saved size with no flash) and so its logic is testable without a DOM.
|
|
6
|
+
*
|
|
7
|
+
* Side-effects (storage + applying to the page) are injected, so a test drives a
|
|
8
|
+
* real store with fakes; the exported default singleton binds them to
|
|
9
|
+
* `localStorage` + the `<html>` element.
|
|
10
|
+
*/
|
|
11
|
+
import { clampUiScale, DEFAULT_UI_SCALE, stepUiScale, uiScaleToFontSize } from './scale';
|
|
12
|
+
export const UI_SCALE_STORAGE_KEY = 'cwip.uiScale';
|
|
13
|
+
export function createUiScaleStore(options = {}) {
|
|
14
|
+
const { storage = null, apply, key = UI_SCALE_STORAGE_KEY } = options;
|
|
15
|
+
const listeners = new Set();
|
|
16
|
+
let current; // undefined until first hydrate
|
|
17
|
+
const hydrate = () => {
|
|
18
|
+
if (current !== undefined)
|
|
19
|
+
return current;
|
|
20
|
+
const raw = storage?.getItem(key);
|
|
21
|
+
current = raw != null ? clampUiScale(Number.parseFloat(raw)) : DEFAULT_UI_SCALE;
|
|
22
|
+
return current;
|
|
23
|
+
};
|
|
24
|
+
const commit = (next) => {
|
|
25
|
+
const clamped = clampUiScale(next);
|
|
26
|
+
current = clamped;
|
|
27
|
+
storage?.setItem(key, String(clamped));
|
|
28
|
+
apply?.(clamped);
|
|
29
|
+
for (const l of listeners)
|
|
30
|
+
l();
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
get: hydrate,
|
|
34
|
+
set: commit,
|
|
35
|
+
step: (dir) => commit(stepUiScale(hydrate(), dir)),
|
|
36
|
+
reset: () => commit(DEFAULT_UI_SCALE),
|
|
37
|
+
init: () => apply?.(hydrate()),
|
|
38
|
+
subscribe(listener) {
|
|
39
|
+
listeners.add(listener);
|
|
40
|
+
return () => listeners.delete(listener);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Apply a scale to the document root: scale the rem base + expose a `--cwip-ui-scale`
|
|
45
|
+
* var (for the rare px that wants `calc(... * var(--cwip-ui-scale))`). SSR-safe. */
|
|
46
|
+
export function applyUiScaleToDocument(scale) {
|
|
47
|
+
if (typeof document === 'undefined')
|
|
48
|
+
return;
|
|
49
|
+
const root = document.documentElement;
|
|
50
|
+
root.style.fontSize = uiScaleToFontSize(scale);
|
|
51
|
+
root.style.setProperty('--cwip-ui-scale', String(clampUiScale(scale)));
|
|
52
|
+
}
|
|
53
|
+
const defaultStorage = typeof localStorage !== 'undefined' ? localStorage : null;
|
|
54
|
+
/** The app-wide singleton bound to localStorage + the page. */
|
|
55
|
+
export const uiScaleStore = createUiScaleStore({
|
|
56
|
+
storage: defaultStorage,
|
|
57
|
+
apply: applyUiScaleToDocument,
|
|
58
|
+
});
|
|
59
|
+
export const getUiScale = () => uiScaleStore.get();
|
|
60
|
+
export const setUiScale = (scale) => uiScaleStore.set(scale);
|
|
61
|
+
export const subscribeUiScale = (listener) => uiScaleStore.subscribe(listener);
|
|
62
|
+
/** Apply the saved UI size on app boot, before React mounts (no flash). */
|
|
63
|
+
export const initUiScale = () => uiScaleStore.init();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|