@flighthq/tokens 0.5.1-edge.685.f72fc7f

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.md ADDED
@@ -0,0 +1,9 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2013-2026 Joshua Granick and other contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # @flighthq/tokens
2
+
3
+ Explicit mode resolution and substitution for flight document scene tokens
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @flighthq/tokens
9
+ ```
10
+
11
+ Import the supported application-facing API from `@flighthq/tokens`. The `@flighthq/tokens/contract` export is the wider package-to-package contract used to compose Flight itself.
12
+
13
+ This package is part of the locked-version Flight SDK graph. Applications may instead install and import `@flighthq/sdk` when package-level tree shaking is sufficient.
14
+
15
+ - [Flight project](https://github.com/flighthq/flight)
16
+ - [Source for @flighthq/tokens@0.5.1-edge.685.f72fc7f](https://github.com/flighthq/flight/tree/f72fc7feaa9653d950f39c7d2fe6365f48e82e51/packages/tokens)
17
+ - [License](https://github.com/flighthq/flight/blob/f72fc7feaa9653d950f39c7d2fe6365f48e82e51/LICENSE.md)
@@ -0,0 +1,3 @@
1
+ export * from './flightDocumentSceneTokens';
2
+ export * from './substituteFlightDocumentSceneTokens';
3
+ //# sourceMappingURL=contract.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.d.ts","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC"}
@@ -0,0 +1,3 @@
1
+ export * from './flightDocumentSceneTokens';
2
+ export * from './substituteFlightDocumentSceneTokens';
3
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../src/contract.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { FlightDocumentRefusalExplanation, FlightDocumentScene, FlightDocumentTokenResolution, FlightDocumentTokenResolverRegistry } from '@flighthq/types/contract';
2
+ export declare function createFlightDocumentTokenResolverRegistry(): FlightDocumentTokenResolverRegistry;
3
+ export declare function explainFlightDocumentSceneTokenResolution(scene: Readonly<FlightDocumentScene>, mode: string, resolvers?: Readonly<FlightDocumentTokenResolverRegistry>): FlightDocumentRefusalExplanation | null;
4
+ export declare function resolveFlightDocumentSceneTokens(scene: Readonly<FlightDocumentScene>, mode: string, resolvers?: Readonly<FlightDocumentTokenResolverRegistry>): FlightDocumentTokenResolution | null;
5
+ //# sourceMappingURL=flightDocumentSceneTokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flightDocumentSceneTokens.d.ts","sourceRoot":"","sources":["../src/flightDocumentSceneTokens.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,gCAAgC,EAChC,mBAAmB,EAEnB,6BAA6B,EAE7B,mCAAmC,EAEpC,MAAM,0BAA0B,CAAC;AAalC,wBAAgB,yCAAyC,IAAI,mCAAmC,CAO/F;AAED,wBAAgB,yCAAyC,CACvD,KAAK,EAAE,QAAQ,CAAC,mBAAmB,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,QAAQ,CAAC,mCAAmC,CAAC,GACxD,gCAAgC,GAAG,IAAI,CAEzC;AAED,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,QAAQ,CAAC,mBAAmB,CAAC,EACpC,IAAI,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,QAAQ,CAAC,mCAAmC,CAAC,GACxD,6BAA6B,GAAG,IAAI,CAEtC"}
@@ -0,0 +1,130 @@
1
+ import { createKeyedTable, getRegistryTableEntry, withRegistryTableEntry } from '@flighthq/registry/contract';
2
+ import { FlightDocumentRefusalReason } from '@flighthq/types/contract';
3
+ import { INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE, isFlightDocumentTokenReference, readFlightDocumentTokenReferenceKey, substituteFlightDocumentTokenValue, } from './flightDocumentTokenReference';
4
+ // The value kinds the SDK itself describes. A caller composes vendor kinds onto the returned table
5
+ // with withRegistryTableEntry; nothing is registered at module scope, so a caller that never asks for
6
+ // the built-ins never pays for them.
7
+ export function createFlightDocumentTokenResolverRegistry() {
8
+ let resolvers = createKeyedTable(TOKEN_RESOLVER_REGISTRY_ID, TOKEN_RESOLVER_MISS_POLICY);
9
+ resolvers = withRegistryTableEntry(resolvers, 'Boolean', resolveBooleanTokenValue);
10
+ resolvers = withRegistryTableEntry(resolvers, 'Color', resolveColorTokenValue);
11
+ resolvers = withRegistryTableEntry(resolvers, 'Number', resolveNumberTokenValue);
12
+ resolvers = withRegistryTableEntry(resolvers, 'String', resolveStringTokenValue);
13
+ return { resolvers };
14
+ }
15
+ export function explainFlightDocumentSceneTokenResolution(scene, mode, resolvers) {
16
+ return readSceneTokens(scene, mode, resolvers).refusal;
17
+ }
18
+ export function resolveFlightDocumentSceneTokens(scene, mode, resolvers) {
19
+ return readSceneTokens(scene, mode, resolvers).resolution;
20
+ }
21
+ // One pass serves both exported seams so a refusal and a null can never disagree about the same input.
22
+ function readSceneTokens(scene, mode, resolvers) {
23
+ const rows = new Map();
24
+ for (let index = 0; index < scene.tokens.length; index++) {
25
+ const token = scene.tokens[index];
26
+ rows.set(token.key, { index, token });
27
+ }
28
+ const state = { mode, resolvers, resolved: new Map(), rows, visiting: new Set() };
29
+ const values = {};
30
+ for (const token of scene.tokens) {
31
+ const resolved = resolveTokenKey(token.key, state);
32
+ if (resolved === INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE)
33
+ return { refusal: state.refusal ?? null, resolution: null };
34
+ values[token.key] = resolved;
35
+ }
36
+ return { refusal: null, resolution: { mode, values } };
37
+ }
38
+ // Resolves one token to its concrete value for the requested mode, following an alias chain through
39
+ // the same mode. Memoized so a diamond of aliases costs one resolution per token, and guarded by a
40
+ // visiting set so a cycle is named instead of recursing until the stack ends.
41
+ function resolveTokenKey(key, state, referencedFrom = '') {
42
+ const memo = state.resolved.get(key);
43
+ if (memo !== undefined)
44
+ return memo;
45
+ const row = state.rows.get(key);
46
+ if (row === undefined) {
47
+ return refuse(state, FlightDocumentRefusalReason.TokenUnresolved, referencedFrom, key, null, null);
48
+ }
49
+ const path = `tokens[${row.index}]`;
50
+ if (state.visiting.has(key)) {
51
+ return refuse(state, FlightDocumentRefusalReason.TokenReferenceCycle, path, key, null, row.token.kind);
52
+ }
53
+ const resolver = state.resolvers === undefined ? null : getRegistryTableEntry(state.resolvers.resolvers, row.token.kind);
54
+ if (resolver === null) {
55
+ const kindPath = `${path}.kind`;
56
+ return refuse(state, FlightDocumentRefusalReason.TokenResolverUnregistered, kindPath, key, null, row.token.kind);
57
+ }
58
+ const mode = row.token.values[state.mode] !== undefined ? state.mode : DEFAULT_MODE;
59
+ const authored = row.token.values[mode];
60
+ if (authored === undefined) {
61
+ return refuse(state, FlightDocumentRefusalReason.TokenModeUnresolved, path, key, state.mode, row.token.kind);
62
+ }
63
+ const modePath = `${path}.${mode}`;
64
+ state.visiting.add(key);
65
+ const substituted = isFlightDocumentTokenReference(authored)
66
+ ? resolveAlias(authored, row.token, modePath, state)
67
+ : substituteFlightDocumentTokenValue(authored, modePath, (key, at) => resolveTokenKey(key, state, at), (at) => void refuse(state, FlightDocumentRefusalReason.TokenReferenceInvalid, at, null, null, null));
68
+ state.visiting.delete(key);
69
+ if (substituted === INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE)
70
+ return INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE;
71
+ const value = resolver(substituted, row.token);
72
+ if (value === null) {
73
+ return refuse(state, FlightDocumentRefusalReason.TokenValueInvalid, modePath, key, mode, row.token.kind);
74
+ }
75
+ state.resolved.set(key, value);
76
+ return value;
77
+ }
78
+ // A whole-value reference is an ALIAS: it names another token of the same semantic type, so the kinds
79
+ // must agree. A reference nested inside a composite value is an ordinary value substitution and
80
+ // carries no such claim — the field it fills is not the token's own type.
81
+ function resolveAlias(authored, token, path, state) {
82
+ const target = readFlightDocumentTokenReferenceKey(authored);
83
+ if (target === null) {
84
+ return refuse(state, FlightDocumentRefusalReason.TokenReferenceInvalid, path, token.key, null, token.kind);
85
+ }
86
+ const targetRow = state.rows.get(target);
87
+ if (targetRow !== undefined && targetRow.token.kind !== token.kind) {
88
+ const kindPath = `tokens[${targetRow.index}].kind`;
89
+ return refuse(state, FlightDocumentRefusalReason.TokenKindMismatch, kindPath, target, null, targetRow.token.kind);
90
+ }
91
+ return resolveTokenKey(target, state, path);
92
+ }
93
+ function refuse(state, reason, path, tokenKey, mode, kind) {
94
+ state.refusal ??= {
95
+ actual: null,
96
+ column: null,
97
+ kind,
98
+ limit: null,
99
+ line: null,
100
+ mode,
101
+ offset: null,
102
+ path,
103
+ reason,
104
+ resourceKey: null,
105
+ tokenKey,
106
+ version: null,
107
+ };
108
+ return INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE;
109
+ }
110
+ function resolveBooleanTokenValue(value) {
111
+ return typeof value === 'boolean' ? value : null;
112
+ }
113
+ // Packed sRGB RGBA, matching the SDK-wide colour convention: one unsigned 32-bit integer, never a
114
+ // float and never a separate alpha.
115
+ function resolveColorTokenValue(value) {
116
+ if (typeof value !== 'number' || !Number.isInteger(value))
117
+ return null;
118
+ return value >= 0 && value <= 0xffffffff ? value : null;
119
+ }
120
+ function resolveNumberTokenValue(value) {
121
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
122
+ }
123
+ function resolveStringTokenValue(value) {
124
+ return typeof value === 'string' ? value : null;
125
+ }
126
+ const DEFAULT_MODE = 'default';
127
+ // A miss is a named refusal, never a substituted value: an unregistered kind must not resolve.
128
+ const TOKEN_RESOLVER_MISS_POLICY = 'Unregistered';
129
+ const TOKEN_RESOLVER_REGISTRY_ID = 'flight-document-token-resolvers';
130
+ //# sourceMappingURL=flightDocumentSceneTokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flightDocumentSceneTokens.js","sourceRoot":"","sources":["../src/flightDocumentSceneTokens.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import type { FlightDocumentValue } from '@flighthq/types/contract';
2
+ export declare function isFlightDocumentTokenReference(value: FlightDocumentValue): value is string;
3
+ export declare function readFlightDocumentTokenReferenceKey(value: string): string | null;
4
+ export declare function substituteFlightDocumentTokenValue(value: FlightDocumentValue, path: string, lookup: (key: string, path: string) => FlightDocumentValue | typeof INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE, refuseMalformed: (path: string) => void): FlightDocumentValue | typeof INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE;
5
+ export declare const INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE: unique symbol;
6
+ //# sourceMappingURL=flightDocumentTokenReference.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flightDocumentTokenReference.d.ts","sourceRoot":"","sources":["../src/flightDocumentTokenReference.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAKpE,wBAAgB,8BAA8B,CAAC,KAAK,EAAE,mBAAmB,GAAG,KAAK,IAAI,MAAM,CAE1F;AAID,wBAAgB,mCAAmC,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAGhF;AAKD,wBAAgB,kCAAkC,CAChD,KAAK,EAAE,mBAAmB,EAC1B,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,mBAAmB,GAAG,OAAO,mCAAmC,EACvG,eAAe,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GACtC,mBAAmB,GAAG,OAAO,mCAAmC,CA4BlE;AAGD,eAAO,MAAM,mCAAmC,eAAgD,CAAC"}
@@ -0,0 +1,57 @@
1
+ // One statement of what a reference IS, shared by token-value resolution and node-field substitution.
2
+ // Two copies would drift, and the copy that drifted would be whichever a later change forgot — the
3
+ // same hazard the shape-command schema ruling names.
4
+ export function isFlightDocumentTokenReference(value) {
5
+ return typeof value === 'string' && value.startsWith(REFERENCE_SIGIL) && !value.startsWith(REFERENCE_ESCAPE);
6
+ }
7
+ // The token key a reference names, or null when the scalar is a malformed reference. A caller must
8
+ // have established that the value IS a reference; a plain string never reaches here.
9
+ export function readFlightDocumentTokenReferenceKey(value) {
10
+ const key = value.slice(REFERENCE_SIGIL.length);
11
+ return FLIGHT_DOCUMENT_TOKEN_KEY_PATTERN.test(key) ? key : null;
12
+ }
13
+ // Walks a value tree and replaces every reference through `lookup`, which owns both the mapping from
14
+ // key to value and the refusal it records when there is none. Returns the sentinel rather than null
15
+ // because null is itself a representable document value, so it cannot mean failure here.
16
+ export function substituteFlightDocumentTokenValue(value, path, lookup, refuseMalformed) {
17
+ if (typeof value === 'string') {
18
+ if (value.startsWith(REFERENCE_ESCAPE))
19
+ return value.slice(REFERENCE_SIGIL.length);
20
+ if (!isFlightDocumentTokenReference(value))
21
+ return value;
22
+ const key = readFlightDocumentTokenReferenceKey(value);
23
+ if (key === null) {
24
+ refuseMalformed(path);
25
+ return INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE;
26
+ }
27
+ return lookup(key, path);
28
+ }
29
+ if (Array.isArray(value)) {
30
+ const out = [];
31
+ for (let index = 0; index < value.length; index++) {
32
+ const item = substituteFlightDocumentTokenValue(value[index], `${path}[${index}]`, lookup, refuseMalformed);
33
+ if (item === INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE)
34
+ return item;
35
+ out.push(item);
36
+ }
37
+ return out;
38
+ }
39
+ if (value === null || typeof value !== 'object')
40
+ return value;
41
+ const out = {};
42
+ for (const key of Object.keys(value)) {
43
+ const item = substituteFlightDocumentTokenValue(value[key], `${path}.${key}`, lookup, refuseMalformed);
44
+ if (item === INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE)
45
+ return item;
46
+ out[key] = item;
47
+ }
48
+ return out;
49
+ }
50
+ // A reference that could not be resolved. Distinct from null, which a document may legitimately hold.
51
+ export const INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE = Symbol('invalid-flight-document-token-value');
52
+ // Token keys and mode names share the codec's plain-scalar shape, so an authored key never needs
53
+ // quoting and a reference never needs escaping beyond its sigil.
54
+ const FLIGHT_DOCUMENT_TOKEN_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
55
+ const REFERENCE_ESCAPE = '$$';
56
+ const REFERENCE_SIGIL = '$';
57
+ //# sourceMappingURL=flightDocumentTokenReference.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flightDocumentTokenReference.js","sourceRoot":"","sources":["../src/flightDocumentTokenReference.ts"],"names":[],"mappings":"AAEA,sGAAsG;AACtG,mGAAmG;AACnG,qDAAqD;AACrD,MAAM,UAAU,8BAA8B,CAAC,KAA0B;IACvE,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,UAAU,CAAC,eAAe,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAC;AAC/G,CAAC;AAED,mGAAmG;AACnG,qFAAqF;AACrF,MAAM,UAAU,mCAAmC,CAAC,KAAa;IAC/D,MAAM,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;IAChD,OAAO,iCAAiC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;AAClE,CAAC;AAED,qGAAqG;AACrG,oGAAoG;AACpG,yFAAyF;AACzF,MAAM,UAAU,kCAAkC,CAChD,KAA0B,EAC1B,IAAY,EACZ,MAAuG,EACvG,eAAuC;IAEvC,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,IAAI,KAAK,CAAC,UAAU,CAAC,gBAAgB,CAAC;YAAE,OAAO,KAAK,CAAC,KAAK,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC;QACnF,IAAI,CAAC,8BAA8B,CAAC,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QACzD,MAAM,GAAG,GAAG,mCAAmC,CAAC,KAAK,CAAC,CAAC;QACvD,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACjB,eAAe,CAAC,IAAI,CAAC,CAAC;YACtB,OAAO,mCAAmC,CAAC;QAC7C,CAAC;QACD,OAAO,MAAM,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC3B,CAAC;IACD,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QACzB,MAAM,GAAG,GAA0B,EAAE,CAAC;QACtC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;YAClD,MAAM,IAAI,GAAG,kCAAkC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,IAAI,IAAI,KAAK,GAAG,EAAE,MAAM,EAAE,eAAe,CAAC,CAAC;YAC5G,IAAI,IAAI,KAAK,mCAAmC;gBAAE,OAAO,IAAI,CAAC;YAC9D,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjB,CAAC;QACD,OAAO,GAAG,CAAC;IACb,CAAC;IACD,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC9D,MAAM,GAAG,GAAwC,EAAE,CAAC;IACpD,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,GAAG,kCAAkC,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,IAAI,GAAG,EAAE,EAAE,MAAM,EAAE,eAAe,CAAC,CAAC;QACvG,IAAI,IAAI,KAAK,mCAAmC;YAAE,OAAO,IAAI,CAAC;QAC9D,GAAG,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC;IAClB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,sGAAsG;AACtG,MAAM,CAAC,MAAM,mCAAmC,GAAG,MAAM,CAAC,qCAAqC,CAAC,CAAC;AAEjG,iGAAiG;AACjG,iEAAiE;AACjE,MAAM,iCAAiC,GAAG,4BAA4B,CAAC;AACvE,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAC9B,MAAM,eAAe,GAAG,GAAG,CAAC"}
@@ -0,0 +1,3 @@
1
+ export * from './flightDocumentSceneTokens';
2
+ export * from './substituteFlightDocumentSceneTokens';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './flightDocumentSceneTokens';
2
+ export * from './substituteFlightDocumentSceneTokens';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,uCAAuC,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { FlightDocumentRefusalExplanation, FlightDocumentScene, FlightDocumentTokenResolution } from '@flighthq/types/contract';
2
+ export declare function explainFlightDocumentSceneTokenSubstitution(scene: Readonly<FlightDocumentScene>, resolution: Readonly<FlightDocumentTokenResolution>): FlightDocumentRefusalExplanation | null;
3
+ export declare function substituteFlightDocumentSceneTokens<T extends Readonly<FlightDocumentScene>>(scene: T, resolution: Readonly<FlightDocumentTokenResolution>): T | null;
4
+ //# sourceMappingURL=substituteFlightDocumentSceneTokens.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"substituteFlightDocumentSceneTokens.d.ts","sourceRoot":"","sources":["../src/substituteFlightDocumentSceneTokens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,gCAAgC,EAChC,mBAAmB,EACnB,6BAA6B,EAE9B,MAAM,0BAA0B,CAAC;AAQlC,wBAAgB,2CAA2C,CACzD,KAAK,EAAE,QAAQ,CAAC,mBAAmB,CAAC,EACpC,UAAU,EAAE,QAAQ,CAAC,6BAA6B,CAAC,GAClD,gCAAgC,GAAG,IAAI,CAEzC;AAOD,wBAAgB,mCAAmC,CAAC,CAAC,SAAS,QAAQ,CAAC,mBAAmB,CAAC,EACzF,KAAK,EAAE,CAAC,EACR,UAAU,EAAE,QAAQ,CAAC,6BAA6B,CAAC,GAClD,CAAC,GAAG,IAAI,CAOV"}
@@ -0,0 +1,72 @@
1
+ import { FlightDocumentRefusalReason } from '@flighthq/types/contract';
2
+ import { INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE, substituteFlightDocumentTokenValue, } from './flightDocumentTokenReference';
3
+ export function explainFlightDocumentSceneTokenSubstitution(scene, resolution) {
4
+ return readSubstitution(scene, resolution).refusal;
5
+ }
6
+ // Pure: the input scene is never touched and a new entry is returned for later materialization. The
7
+ // dimension is preserved through the type parameter so a caller does not re-narrow what it passed in.
8
+ // The readonly intent sits on the CONSTRAINT rather than on the parameter: Readonly<T> is homomorphic
9
+ // and distributes, so a caller holding the FlightDocumentScene union — which is what document.scenes[i]
10
+ // is — could not have inferred T at all.
11
+ export function substituteFlightDocumentSceneTokens(scene, resolution) {
12
+ const substituted = readSubstitution(scene, resolution);
13
+ if (substituted.scene === null)
14
+ return null;
15
+ // The spread carries every scene-level section (layouts and tokens, plus backgroundColor or cameras
16
+ // and lights) unchanged, and only `scene` is replaced; T is preserved by construction rather than by
17
+ // narrowing, which no assertion in the language can express for a generic spread.
18
+ return { ...scene, scene: substituted.scene };
19
+ }
20
+ function readSubstitution(scene, resolution) {
21
+ const state = { resolution };
22
+ const root = substituteNode(scene.scene, 'scene', state);
23
+ if (root === null)
24
+ return { refusal: state.refusal ?? null, scene: null };
25
+ return { refusal: null, scene: root };
26
+ }
27
+ function substituteNode(node, path, state) {
28
+ const fields = {};
29
+ for (const name of Object.keys(node.fields)) {
30
+ const fieldPath = `${path}.fields.${name}`;
31
+ const value = substituteFlightDocumentTokenValue(node.fields[name], fieldPath, (key, at) => lookup(key, at, state), (at) => refuse(state, FlightDocumentRefusalReason.TokenReferenceInvalid, at, null));
32
+ if (value === INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE)
33
+ return null;
34
+ fields[name] = value;
35
+ }
36
+ const children = [];
37
+ for (let index = 0; index < node.children.length; index++) {
38
+ const child = substituteNode(node.children[index], `${path}.children[${index}]`, state);
39
+ if (child === null)
40
+ return null;
41
+ children.push(child);
42
+ }
43
+ return { children, fields, kind: node.kind };
44
+ }
45
+ // A reference the resolution does not cover is a named refusal, never a `$…` string left in a field a
46
+ // renderer will read as a number. Silent pass-through would make the file say one thing and the render
47
+ // show another, with nothing reporting the difference.
48
+ function lookup(key, path, state) {
49
+ const value = state.resolution.values[key];
50
+ if (value === undefined) {
51
+ refuse(state, FlightDocumentRefusalReason.TokenUnresolved, path, key);
52
+ return INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE;
53
+ }
54
+ return value;
55
+ }
56
+ function refuse(state, reason, path, tokenKey) {
57
+ state.refusal ??= {
58
+ actual: null,
59
+ column: null,
60
+ kind: null,
61
+ limit: null,
62
+ line: null,
63
+ mode: state.resolution.mode,
64
+ offset: null,
65
+ path,
66
+ reason,
67
+ resourceKey: null,
68
+ tokenKey,
69
+ version: null,
70
+ };
71
+ }
72
+ //# sourceMappingURL=substituteFlightDocumentSceneTokens.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"substituteFlightDocumentSceneTokens.js","sourceRoot":"","sources":["../src/substituteFlightDocumentSceneTokens.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@flighthq/tokens",
3
+ "version": "0.5.1-edge.685.f72fc7f",
4
+ "author": "Joshua Granick and other contributors",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/flighthq/flight.git",
9
+ "directory": "packages/tokens"
10
+ },
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./contract": {
20
+ "types": "./dist/contract.d.ts",
21
+ "default": "./dist/contract.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "src/**/*.test.ts",
27
+ "!dist/**/*.test.js",
28
+ "!dist/**/*.test.d.ts",
29
+ "!dist/**/*.test.js.map",
30
+ "!dist/**/*.test.d.ts.map"
31
+ ],
32
+ "scripts": {
33
+ "build": "tsc -b",
34
+ "clean": "tsc -b --clean",
35
+ "test": "vitest run --config vitest.config.ts",
36
+ "test:watch": "vitest --watch --config vitest.config.ts",
37
+ "prepack": "npm run clean && npm run clean:dist && npm run build",
38
+ "clean:dist": "tsx ../../scripts/clean-package-dist.ts"
39
+ },
40
+ "dependencies": {
41
+ "@flighthq/registry": "0.5.1-edge.685.f72fc7f",
42
+ "@flighthq/types": "0.5.1-edge.685.f72fc7f"
43
+ },
44
+ "devDependencies": {
45
+ "@flighthq/node": "*",
46
+ "@flighthq/scene-document": "*",
47
+ "@flighthq/scene2d": "*",
48
+ "typescript": "^5.3.0"
49
+ },
50
+ "description": "Explicit mode resolution and substitution for flight document scene tokens",
51
+ "sideEffects": false
52
+ }
@@ -0,0 +1,125 @@
1
+ import { getNodeChildren } from '@flighthq/node/contract';
2
+ import { createKeyedTable, withRegistryTableEntry } from '@flighthq/registry/contract';
3
+ import { createFlightDocumentScene2DMaterialization, parseFlightDocumentText } from '@flighthq/scene-document/contract';
4
+ import { createDisplayObject, createSprite } from '@flighthq/scene2d/contract';
5
+ import type {
6
+ FlightDocument,
7
+ FlightDocumentFields,
8
+ FlightDocumentNodeSchema,
9
+ FlightDocumentScene2D,
10
+ FlightDocumentSchemaRegistry,
11
+ Node2D,
12
+ NodeAny,
13
+ } from '@flighthq/types/contract';
14
+ import { DisplayObjectKind, SpriteKind } from '@flighthq/types/contract';
15
+
16
+ import {
17
+ createFlightDocumentTokenResolverRegistry,
18
+ resolveFlightDocumentSceneTokens,
19
+ } from './flightDocumentSceneTokens';
20
+ import { substituteFlightDocumentSceneTokens } from './substituteFlightDocumentSceneTokens';
21
+
22
+ // The one flow no single package can cover: authored text, through resolution and substitution, into
23
+ // a materialized scene. It is the serialization round-trip carve-out in the testing conventions, not a
24
+ // generic integration bucket — each stage's own behavior is unit-tested where it lives.
25
+ describe('flightDocumentSceneTokenPipeline', () => {
26
+ it('carries an authored reference through to the materialized node for the requested mode', () => {
27
+ const document = parseAuthoredDocument();
28
+ const substituted = substituteScene(document, 'dark');
29
+ const materialization = createFlightDocumentScene2DMaterialization(
30
+ { ...document, scenes: [substituted] },
31
+ createTestSchemas(),
32
+ );
33
+ const child = getNodeChildren(materialization?.scene.root as NodeAny)[0] as Node2D;
34
+ // 0.25 and 40 are the dark variants written in the fixture text below, restated here rather than
35
+ // read back out of the document: sourcing them from the parsed tokens would pass for any mapping.
36
+ expect(child.alpha).toBe(0.25);
37
+ expect(child.x).toBe(40);
38
+ });
39
+
40
+ it('produces the other mode values from the same authored document', () => {
41
+ const document = parseAuthoredDocument();
42
+ const substituted = substituteScene(document, 'light');
43
+ const materialization = createFlightDocumentScene2DMaterialization(
44
+ { ...document, scenes: [substituted] },
45
+ createTestSchemas(),
46
+ );
47
+ const child = getNodeChildren(materialization?.scene.root as NodeAny)[0] as Node2D;
48
+ expect(child.alpha).toBe(1);
49
+ expect(child.x).toBe(40);
50
+ });
51
+
52
+ it('refuses the whole flow when a referenced token is not declared', () => {
53
+ const document = parseFlightDocumentText(
54
+ 'flight: 1\ndefaultScene: 0\nscenes:\n' +
55
+ ' - kind: Scene2D\n scene:\n kind: DisplayObject\n children:\n' +
56
+ ' - kind: Sprite\n alpha: "$opacity.absent"\n',
57
+ );
58
+ if (document === null) throw new Error('expected the fixture to parse');
59
+ const scene = document.scenes[0];
60
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
61
+ if (resolution === null) throw new Error('expected an empty token table to resolve');
62
+ expect(substituteFlightDocumentSceneTokens(scene, resolution)).toBeNull();
63
+ });
64
+ });
65
+
66
+ function parseAuthoredDocument(): FlightDocument {
67
+ const document = parseFlightDocumentText(
68
+ 'flight: 1\ndefaultScene: 0\nscenes:\n' +
69
+ ' - kind: Scene2D\n' +
70
+ ' scene:\n kind: DisplayObject\n children:\n' +
71
+ ' - kind: Sprite\n alpha: "$opacity.panel"\n x: "$space.inset"\n' +
72
+ ' tokens:\n' +
73
+ ' - kind: Number\n key: opacity.panel\n dark: 0.25\n light: 1\n' +
74
+ ' - kind: Number\n key: space.inset\n default: 40\n',
75
+ );
76
+ if (document === null) throw new Error('expected the authored token fixture to parse');
77
+ return document;
78
+ }
79
+
80
+ function substituteScene(document: Readonly<FlightDocument>, mode: string): FlightDocumentScene2D {
81
+ const scene = document.scenes[0];
82
+ if (scene.kind !== 'Scene2D') throw new Error('expected a 2D fixture entry');
83
+ const resolution = resolveFlightDocumentSceneTokens(scene, mode, createFlightDocumentTokenResolverRegistry());
84
+ if (resolution === null) throw new Error('expected the fixture token table to resolve');
85
+ const substituted = substituteFlightDocumentSceneTokens(scene, resolution);
86
+ if (substituted === null) throw new Error('expected every authored reference to substitute');
87
+ return substituted;
88
+ }
89
+
90
+ function createTestSchemas(): FlightDocumentSchemaRegistry {
91
+ const displayObjectSchema: FlightDocumentNodeSchema = {
92
+ createNode: (fields: Readonly<FlightDocumentFields>) => applyFields(createDisplayObject(), fields),
93
+ fields: [],
94
+ kind: DisplayObjectKind,
95
+ writeNodeFields: () => true,
96
+ };
97
+ const spriteSchema: FlightDocumentNodeSchema = {
98
+ createNode: (fields: Readonly<FlightDocumentFields>) => applyFields(createSprite(), fields),
99
+ fields: [
100
+ { defaultValue: 1, name: 'alpha', required: false, validate: (value) => typeof value === 'number' },
101
+ { defaultValue: 0, name: 'x', required: false, validate: (value) => typeof value === 'number' },
102
+ ],
103
+ kind: SpriteKind,
104
+ writeNodeFields: () => true,
105
+ };
106
+ let nodeSchemas = createKeyedTable<FlightDocumentNodeSchema>('flight-document.node', 'none');
107
+ nodeSchemas = withRegistryTableEntry(nodeSchemas, DisplayObjectKind, displayObjectSchema);
108
+ nodeSchemas = withRegistryTableEntry(nodeSchemas, SpriteKind, spriteSchema);
109
+ return {
110
+ interactiveStateExtensionSchemas: createKeyedTable('flight-document.interactive-state-extension', 'none'),
111
+ interactiveStateTransitionSchemas: createKeyedTable('flight-document.interactive-state-transition', 'none'),
112
+ nodeSchemas,
113
+ resourceSchemas: createKeyedTable('flight-document.resource', 'none'),
114
+ shapeCommandSchemas: createKeyedTable('flight-document.shape-command', 'none'),
115
+ };
116
+ }
117
+
118
+ // Deliberately assigns only when the field is already a number: a reference that failed to substitute
119
+ // arrives as a string, and this fixture must leave the default in place rather than coerce it, so a
120
+ // broken substitution shows up as a failed assertion instead of NaN.
121
+ function applyFields(target: Node2D, fields: Readonly<FlightDocumentFields>): Node2D {
122
+ if (typeof fields['alpha'] === 'number') target.alpha = fields['alpha'];
123
+ if (typeof fields['x'] === 'number') target.x = fields['x'];
124
+ return target;
125
+ }
@@ -0,0 +1,235 @@
1
+ import { withRegistryTableEntry } from '@flighthq/registry/contract';
2
+ import type {
3
+ FlightDocumentScene2D,
4
+ FlightDocumentToken,
5
+ FlightDocumentTokenResolverRegistry,
6
+ } from '@flighthq/types/contract';
7
+ import { FlightDocumentRefusalReason } from '@flighthq/types/contract';
8
+
9
+ import {
10
+ createFlightDocumentTokenResolverRegistry,
11
+ explainFlightDocumentSceneTokenResolution,
12
+ resolveFlightDocumentSceneTokens,
13
+ } from './flightDocumentSceneTokens';
14
+
15
+ describe('createFlightDocumentTokenResolverRegistry', () => {
16
+ it('admits each built-in kind only for the values that kind describes', () => {
17
+ const registry = createFlightDocumentTokenResolverRegistry();
18
+ expect(resolveOne(registry, token('Color', { default: 0x3366ccff }))).toEqual({ 'a.token': 0x3366ccff });
19
+ expect(resolveOne(registry, token('Color', { default: -1 }))).toBeNull();
20
+ expect(resolveOne(registry, token('Color', { default: 1.5 }))).toBeNull();
21
+ expect(resolveOne(registry, token('Color', { default: 0x1_0000_0000 }))).toBeNull();
22
+ expect(resolveOne(registry, token('Color', { default: 'blue' }))).toBeNull();
23
+ expect(resolveOne(registry, token('Number', { default: 1.5 }))).toEqual({ 'a.token': 1.5 });
24
+ expect(resolveOne(registry, token('Number', { default: 'wide' }))).toBeNull();
25
+ expect(resolveOne(registry, token('Boolean', { default: true }))).toEqual({ 'a.token': true });
26
+ expect(resolveOne(registry, token('Boolean', { default: 1 }))).toBeNull();
27
+ expect(resolveOne(registry, token('String', { default: 'Inter' }))).toEqual({ 'a.token': 'Inter' });
28
+ expect(resolveOne(registry, token('String', { default: 12 }))).toBeNull();
29
+ });
30
+
31
+ it('stays open to a vendor kind the SDK never registered', () => {
32
+ const registry = createFlightDocumentTokenResolverRegistry();
33
+ const extended: FlightDocumentTokenResolverRegistry = {
34
+ resolvers: withResolver(registry, 'acme.Duration', (value) => (typeof value === 'number' ? value : null)),
35
+ };
36
+ expect(resolveOne(extended, token('acme.Duration', { default: 150 }))).toEqual({ 'a.token': 150 });
37
+ });
38
+ });
39
+
40
+ describe('explainFlightDocumentSceneTokenResolution', () => {
41
+ it('names an unregistered kind against the token that carries it', () => {
42
+ const scene = sceneWith([token('acme.Shadow', { default: 1 })]);
43
+ const explanation = explainFlightDocumentSceneTokenResolution(
44
+ scene,
45
+ 'dark',
46
+ createFlightDocumentTokenResolverRegistry(),
47
+ );
48
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenResolverUnregistered);
49
+ expect(explanation?.kind).toBe('acme.Shadow');
50
+ expect(explanation?.tokenKey).toBe('a.token');
51
+ expect(explanation?.path).toBe('tokens[0].kind');
52
+ });
53
+
54
+ it('names a value the kind refused, with the mode it was authored under', () => {
55
+ const scene = sceneWith([token('Color', { dark: 'midnight' })]);
56
+ const explanation = explainFlightDocumentSceneTokenResolution(
57
+ scene,
58
+ 'dark',
59
+ createFlightDocumentTokenResolverRegistry(),
60
+ );
61
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenValueInvalid);
62
+ expect(explanation?.mode).toBe('dark');
63
+ expect(explanation?.path).toBe('tokens[0].dark');
64
+ });
65
+
66
+ it('names a token that declares neither the requested mode nor a default', () => {
67
+ const scene = sceneWith([token('Color', { light: 0xffffffff })]);
68
+ const explanation = explainFlightDocumentSceneTokenResolution(
69
+ scene,
70
+ 'dark',
71
+ createFlightDocumentTokenResolverRegistry(),
72
+ );
73
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenModeUnresolved);
74
+ expect(explanation?.mode).toBe('dark');
75
+ expect(explanation?.tokenKey).toBe('a.token');
76
+ });
77
+
78
+ it('names an alias whose target no token declares', () => {
79
+ const scene = sceneWith([{ key: 'color.card', kind: 'Color', values: { default: '$color.absent' } }]);
80
+ const explanation = explainFlightDocumentSceneTokenResolution(
81
+ scene,
82
+ 'dark',
83
+ createFlightDocumentTokenResolverRegistry(),
84
+ );
85
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenUnresolved);
86
+ expect(explanation?.tokenKey).toBe('color.absent');
87
+ });
88
+
89
+ it('names an alias cycle instead of following it forever', () => {
90
+ const scene = sceneWith([
91
+ { key: 'a', kind: 'Color', values: { default: '$b' } },
92
+ { key: 'b', kind: 'Color', values: { default: '$a' } },
93
+ ]);
94
+ const explanation = explainFlightDocumentSceneTokenResolution(
95
+ scene,
96
+ 'dark',
97
+ createFlightDocumentTokenResolverRegistry(),
98
+ );
99
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenReferenceCycle);
100
+ });
101
+
102
+ it('names a self-referential token as a cycle', () => {
103
+ const scene = sceneWith([{ key: 'a', kind: 'Color', values: { default: '$a' } }]);
104
+ const explanation = explainFlightDocumentSceneTokenResolution(
105
+ scene,
106
+ 'dark',
107
+ createFlightDocumentTokenResolverRegistry(),
108
+ );
109
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenReferenceCycle);
110
+ expect(explanation?.tokenKey).toBe('a');
111
+ });
112
+
113
+ it('names an alias whose target is a different kind', () => {
114
+ const scene = sceneWith([
115
+ { key: 'color.card', kind: 'Color', values: { default: '$space.gutter' } },
116
+ { key: 'space.gutter', kind: 'Number', values: { default: 8 } },
117
+ ]);
118
+ const explanation = explainFlightDocumentSceneTokenResolution(
119
+ scene,
120
+ 'dark',
121
+ createFlightDocumentTokenResolverRegistry(),
122
+ );
123
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenKindMismatch);
124
+ expect(explanation?.tokenKey).toBe('space.gutter');
125
+ });
126
+
127
+ it('names a malformed reference rather than treating it as an ordinary string', () => {
128
+ const scene = sceneWith([{ key: 'a', kind: 'String', values: { default: '$9lives' } }]);
129
+ const explanation = explainFlightDocumentSceneTokenResolution(
130
+ scene,
131
+ 'dark',
132
+ createFlightDocumentTokenResolverRegistry(),
133
+ );
134
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenReferenceInvalid);
135
+ });
136
+
137
+ it('reports nothing when every token resolves', () => {
138
+ const scene = sceneWith([token('Color', { default: 0x3366ccff })]);
139
+ expect(
140
+ explainFlightDocumentSceneTokenResolution(scene, 'dark', createFlightDocumentTokenResolverRegistry()),
141
+ ).toBeNull();
142
+ });
143
+ });
144
+
145
+ describe('resolveFlightDocumentSceneTokens', () => {
146
+ it('prefers the requested mode over the default', () => {
147
+ const scene = sceneWith([token('Color', { dark: 0x1a1a1aff, default: 0x808080ff, light: 0xffffffff })]);
148
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
149
+ expect(resolution).toEqual({ mode: 'dark', values: { 'a.token': 0x1a1a1aff } });
150
+ });
151
+
152
+ it('falls back to the default mode when the requested one is absent', () => {
153
+ const scene = sceneWith([token('Color', { default: 0x808080ff, light: 0xffffffff })]);
154
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
155
+ expect(resolution?.values).toEqual({ 'a.token': 0x808080ff });
156
+ });
157
+
158
+ it('resolves an alias inside the requested mode rather than through the target default', () => {
159
+ const scene = sceneWith([
160
+ { key: 'color.card', kind: 'Color', values: { default: '$color.background' } },
161
+ { key: 'color.background', kind: 'Color', values: { dark: 0x1a1a1aff, light: 0xffffffff } },
162
+ ]);
163
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
164
+ expect(resolution?.values['color.card']).toBe(0x1a1a1aff);
165
+ expect(
166
+ resolveFlightDocumentSceneTokens(scene, 'light', createFlightDocumentTokenResolverRegistry())?.values[
167
+ 'color.card'
168
+ ],
169
+ ).toBe(0xffffffff);
170
+ });
171
+
172
+ it('follows an alias chain to its concrete value', () => {
173
+ const scene = sceneWith([
174
+ { key: 'a', kind: 'Color', values: { default: '$b' } },
175
+ { key: 'b', kind: 'Color', values: { default: '$c' } },
176
+ { key: 'c', kind: 'Color', values: { default: 0x3366ccff } },
177
+ ]);
178
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
179
+ expect(resolution?.values).toEqual({ a: 0x3366ccff, b: 0x3366ccff, c: 0x3366ccff });
180
+ });
181
+
182
+ it('resolves an escaped literal dollar sign instead of looking it up', () => {
183
+ const scene = sceneWith([{ key: 'price.label', kind: 'String', values: { default: '$$5.00' } }]);
184
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
185
+ expect(resolution?.values['price.label']).toBe('$5.00');
186
+ });
187
+
188
+ it('resolves an empty token section to an empty table rather than refusing', () => {
189
+ const resolution = resolveFlightDocumentSceneTokens(
190
+ sceneWith([]),
191
+ 'dark',
192
+ createFlightDocumentTokenResolverRegistry(),
193
+ );
194
+ expect(resolution).toEqual({ mode: 'dark', values: {} });
195
+ });
196
+
197
+ it('returns null wherever the explain seam reports a refusal', () => {
198
+ const scene = sceneWith([token('Color', { dark: 'midnight' })]);
199
+ expect(resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry())).toBeNull();
200
+ });
201
+
202
+ it('refuses every kind when no resolver registry is supplied', () => {
203
+ expect(resolveFlightDocumentSceneTokens(sceneWith([token('Color', { default: 1 })]), 'dark')).toBeNull();
204
+ });
205
+ });
206
+
207
+ function resolveOne(
208
+ registry: Readonly<FlightDocumentTokenResolverRegistry>,
209
+ entry: FlightDocumentToken,
210
+ ): Record<string, unknown> | null {
211
+ const resolution = resolveFlightDocumentSceneTokens(sceneWith([entry]), 'dark', registry);
212
+ return resolution === null ? null : { ...resolution.values };
213
+ }
214
+
215
+ function sceneWith(tokens: readonly FlightDocumentToken[]): FlightDocumentScene2D {
216
+ return {
217
+ backgroundColor: null,
218
+ kind: 'Scene2D',
219
+ layouts: [],
220
+ scene: { children: [], fields: {}, kind: 'DisplayObject' },
221
+ tokens: [...tokens],
222
+ };
223
+ }
224
+
225
+ function token(kind: string, values: FlightDocumentToken['values']): FlightDocumentToken {
226
+ return { key: 'a.token', kind, values };
227
+ }
228
+
229
+ function withResolver(
230
+ registry: Readonly<FlightDocumentTokenResolverRegistry>,
231
+ kind: string,
232
+ resolver: (value: unknown) => unknown,
233
+ ): FlightDocumentTokenResolverRegistry['resolvers'] {
234
+ return withRegistryTableEntry(registry.resolvers, kind, resolver as never);
235
+ }
@@ -0,0 +1,86 @@
1
+ import {
2
+ INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE,
3
+ isFlightDocumentTokenReference,
4
+ readFlightDocumentTokenReferenceKey,
5
+ substituteFlightDocumentTokenValue,
6
+ } from './flightDocumentTokenReference';
7
+
8
+ describe('INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE', () => {
9
+ it('is distinct from null, which a document may legitimately hold', () => {
10
+ expect(INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE).not.toBeNull();
11
+ expect(typeof INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE).toBe('symbol');
12
+ });
13
+ });
14
+
15
+ describe('isFlightDocumentTokenReference', () => {
16
+ it('accepts a sigil-prefixed scalar and rejects an escaped or ordinary one', () => {
17
+ expect(isFlightDocumentTokenReference('$color.primary')).toBe(true);
18
+ expect(isFlightDocumentTokenReference('$$5.00')).toBe(false);
19
+ expect(isFlightDocumentTokenReference('color.primary')).toBe(false);
20
+ });
21
+
22
+ it('rejects every non-string value, including one that could carry a reference inside it', () => {
23
+ expect(isFlightDocumentTokenReference(8)).toBe(false);
24
+ expect(isFlightDocumentTokenReference(null)).toBe(false);
25
+ expect(isFlightDocumentTokenReference(['$color.primary'])).toBe(false);
26
+ });
27
+ });
28
+
29
+ describe('readFlightDocumentTokenReferenceKey', () => {
30
+ it('reads a dotted key as one flat name rather than a path', () => {
31
+ expect(readFlightDocumentTokenReferenceKey('$color.primary')).toBe('color.primary');
32
+ expect(readFlightDocumentTokenReferenceKey('$acme.brand.primary')).toBe('acme.brand.primary');
33
+ });
34
+
35
+ it('refuses a key the codec would have had to quote', () => {
36
+ expect(readFlightDocumentTokenReferenceKey('$9lives')).toBeNull();
37
+ expect(readFlightDocumentTokenReferenceKey('$')).toBeNull();
38
+ expect(readFlightDocumentTokenReferenceKey('$has space')).toBeNull();
39
+ });
40
+ });
41
+
42
+ describe('substituteFlightDocumentTokenValue', () => {
43
+ it('replaces references at every depth and leaves other scalars alone', () => {
44
+ const value = substituteFlightDocumentTokenValue(
45
+ { commands: [{ beginFill: { alpha: 1, color: '$color.primary' } }], name: 'card' },
46
+ 'fields',
47
+ (key) => (key === 'color.primary' ? 0x3366ccff : INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE),
48
+ fail,
49
+ );
50
+ expect(value).toEqual({ commands: [{ beginFill: { alpha: 1, color: 0x3366ccff } }], name: 'card' });
51
+ });
52
+
53
+ it('unescapes a doubled sigil without consulting the lookup', () => {
54
+ expect(substituteFlightDocumentTokenValue('$$5.00', 'fields.text', fail as never, fail)).toBe('$5.00');
55
+ });
56
+
57
+ it('reports the path of a malformed reference and stops', () => {
58
+ const paths: string[] = [];
59
+ const value = substituteFlightDocumentTokenValue(
60
+ { commands: [{ beginFill: { color: '$9lives' } }] },
61
+ 'fields',
62
+ () => INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE,
63
+ (path) => paths.push(path),
64
+ );
65
+ expect(value).toBe(INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE);
66
+ expect(paths).toEqual(['fields.commands[0].beginFill.color']);
67
+ });
68
+
69
+ it('propagates a lookup refusal out of a nested array', () => {
70
+ const value = substituteFlightDocumentTokenValue(
71
+ [1, ['$color.absent']],
72
+ 'fields.ratios',
73
+ () => INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE,
74
+ fail,
75
+ );
76
+ expect(value).toBe(INVALID_FLIGHT_DOCUMENT_TOKEN_VALUE);
77
+ });
78
+
79
+ it('returns a null scalar as a value rather than as a failure', () => {
80
+ expect(substituteFlightDocumentTokenValue(null, 'fields.blendMode', fail as never, fail)).toBeNull();
81
+ });
82
+ });
83
+
84
+ function fail(): never {
85
+ throw new Error('the walker consulted a seam this case must never reach');
86
+ }
@@ -0,0 +1,178 @@
1
+ import type {
2
+ FlightDocumentNode,
3
+ FlightDocumentScene,
4
+ FlightDocumentScene2D,
5
+ FlightDocumentScene3D,
6
+ FlightDocumentToken,
7
+ FlightDocumentTokenResolution,
8
+ } from '@flighthq/types/contract';
9
+ import { FlightDocumentRefusalReason } from '@flighthq/types/contract';
10
+
11
+ import {
12
+ createFlightDocumentTokenResolverRegistry,
13
+ resolveFlightDocumentSceneTokens,
14
+ } from './flightDocumentSceneTokens';
15
+ import {
16
+ explainFlightDocumentSceneTokenSubstitution,
17
+ substituteFlightDocumentSceneTokens,
18
+ } from './substituteFlightDocumentSceneTokens';
19
+
20
+ describe('explainFlightDocumentSceneTokenSubstitution', () => {
21
+ it('names a reference no resolved token covers, with the field that carries it', () => {
22
+ const scene = sceneWith({ children: [], fields: { fill: '$color.absent' }, kind: 'Shape' }, [
23
+ { key: 'color.background', kind: 'Color', values: { default: 0xffffffff } },
24
+ ]);
25
+ const explanation = explainFlightDocumentSceneTokenSubstitution(scene, resolutionOf(scene));
26
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenUnresolved);
27
+ expect(explanation?.tokenKey).toBe('color.absent');
28
+ expect(explanation?.path).toBe('scene.fields.fill');
29
+ });
30
+
31
+ it('names a malformed reference rather than passing the raw string through', () => {
32
+ const scene = sceneWith({ children: [], fields: { fill: '$9lives' }, kind: 'Shape' }, []);
33
+ const explanation = explainFlightDocumentSceneTokenSubstitution(scene, resolutionOf(scene));
34
+ expect(explanation?.reason).toBe(FlightDocumentRefusalReason.TokenReferenceInvalid);
35
+ expect(explanation?.path).toBe('scene.fields.fill');
36
+ });
37
+
38
+ it('qualifies the path of a reference nested in a descendant command list', () => {
39
+ const scene = sceneWith(
40
+ {
41
+ children: [
42
+ { children: [], fields: {}, kind: 'Sprite' },
43
+ { children: [], fields: { commands: [{ beginFill: { color: '$color.absent' } }] }, kind: 'Shape' },
44
+ ],
45
+ fields: {},
46
+ kind: 'DisplayObject',
47
+ },
48
+ [],
49
+ );
50
+ const explanation = explainFlightDocumentSceneTokenSubstitution(scene, resolutionOf(scene));
51
+ expect(explanation?.path).toBe('scene.children[1].fields.commands[0].beginFill.color');
52
+ });
53
+
54
+ it('reports nothing when every reference resolves', () => {
55
+ const scene = sceneWith({ children: [], fields: { fill: '$color.background' }, kind: 'Shape' }, [
56
+ { key: 'color.background', kind: 'Color', values: { default: 0xffffffff } },
57
+ ]);
58
+ expect(explainFlightDocumentSceneTokenSubstitution(scene, resolutionOf(scene))).toBeNull();
59
+ });
60
+ });
61
+
62
+ describe('substituteFlightDocumentSceneTokens', () => {
63
+ it('replaces a reference nested inside a command argument map inside an array', () => {
64
+ const scene = sceneWith(
65
+ {
66
+ children: [
67
+ {
68
+ children: [],
69
+ fields: {
70
+ commands: [
71
+ { beginFill: { alpha: 1, color: '$color.background' } },
72
+ { drawRectangle: { height: '$space.gutter', width: 120 } },
73
+ ],
74
+ },
75
+ kind: 'Shape',
76
+ },
77
+ ],
78
+ fields: {},
79
+ kind: 'DisplayObject',
80
+ },
81
+ [
82
+ { key: 'color.background', kind: 'Color', values: { dark: 0x1a1a1aff, light: 0xffffffff } },
83
+ { key: 'space.gutter', kind: 'Number', values: { default: 8 } },
84
+ ],
85
+ );
86
+ const substituted = substituteFlightDocumentSceneTokens(scene, resolutionOf(scene, 'dark'));
87
+ // The expected values are literals, not reads back out of the token rows the substitution used: a
88
+ // test that sourced them from scene.tokens would pass for any mapping, including the wrong one.
89
+ expect(substituted?.scene.children[0].fields['commands']).toEqual([
90
+ { beginFill: { alpha: 1, color: 0x1a1a1aff } },
91
+ { drawRectangle: { height: 8, width: 120 } },
92
+ ]);
93
+ });
94
+
95
+ it('leaves a scene without references structurally equal to its input', () => {
96
+ const scene = sceneWith({ children: [], fields: { x: 12 }, kind: 'Sprite' }, []);
97
+ expect(substituteFlightDocumentSceneTokens(scene, resolutionOf(scene))).toEqual(scene);
98
+ });
99
+
100
+ it('never mutates the scene it was given', () => {
101
+ const scene = sceneWith({ children: [], fields: { fill: '$color.background' }, kind: 'Shape' }, [
102
+ { key: 'color.background', kind: 'Color', values: { default: 0xffffffff } },
103
+ ]);
104
+ substituteFlightDocumentSceneTokens(scene, resolutionOf(scene));
105
+ expect(scene.scene.fields['fill']).toBe('$color.background');
106
+ });
107
+
108
+ it('keeps the token section so the palette still round-trips', () => {
109
+ const scene = sceneWith({ children: [], fields: { fill: '$color.background' }, kind: 'Shape' }, [
110
+ { key: 'color.background', kind: 'Color', values: { default: 0xffffffff } },
111
+ ]);
112
+ expect(substituteFlightDocumentSceneTokens(scene, resolutionOf(scene))?.tokens).toEqual(scene.tokens);
113
+ });
114
+
115
+ it('preserves inert layout metadata while substituting node fields', () => {
116
+ const scene = sceneWith({ children: [], fields: { fill: '$color.background', name: 'root' }, kind: 'Shape' }, [
117
+ { key: 'color.background', kind: 'Color', values: { default: 0xffffffff } },
118
+ ]);
119
+ scene.layouts.push({
120
+ targets: ['root'],
121
+ tree: {
122
+ nodes: [{ containerStyle: { gap: 8 }, itemStyle: null, kind: 'acme.Flow', parentIndex: -1 }],
123
+ },
124
+ });
125
+
126
+ expect(substituteFlightDocumentSceneTokens(scene, resolutionOf(scene))?.layouts).toBe(scene.layouts);
127
+ });
128
+
129
+ it('resolves an escaped dollar sign to a literal instead of looking it up', () => {
130
+ const scene = sceneWith({ children: [], fields: { text: '$$5.00' }, kind: 'TextLabel' }, []);
131
+ expect(substituteFlightDocumentSceneTokens(scene, resolutionOf(scene))?.scene.fields['text']).toBe('$5.00');
132
+ });
133
+
134
+ it('preserves the 3D dimension and its camera and light sections', () => {
135
+ const scene: FlightDocumentScene3D = {
136
+ cameras: [],
137
+ kind: 'Scene3D',
138
+ layouts: [],
139
+ lights: [],
140
+ scene: { children: [], fields: { alpha: '$opacity.dim' }, kind: 'Node3D' },
141
+ tokens: [{ key: 'opacity.dim', kind: 'Number', values: { default: 0.5 } }],
142
+ };
143
+ const resolution = resolveFlightDocumentSceneTokens(scene, 'dark', createFlightDocumentTokenResolverRegistry());
144
+ if (resolution === null) throw new Error('expected the 3D token fixture to resolve');
145
+ const substituted = substituteFlightDocumentSceneTokens(scene, resolution);
146
+ expect(substituted?.kind).toBe('Scene3D');
147
+ expect(substituted?.cameras).toEqual([]);
148
+ expect(substituted?.scene.fields['alpha']).toBe(0.5);
149
+ });
150
+
151
+ it('accepts the scene union a caller reads out of document.scenes without narrowing it first', () => {
152
+ const entries: FlightDocumentScene[] = [
153
+ sceneWith({ children: [], fields: { fill: '$color.background' }, kind: 'Shape' }, [
154
+ { key: 'color.background', kind: 'Color', values: { default: 0xffffffff } },
155
+ ]),
156
+ ];
157
+ const substituted = substituteFlightDocumentSceneTokens(entries[0], resolutionOf(entries[0]));
158
+ expect(substituted?.kind).toBe('Scene2D');
159
+ });
160
+
161
+ it('returns null wherever the explain seam reports a refusal', () => {
162
+ const scene = sceneWith({ children: [], fields: { fill: '$color.absent' }, kind: 'Shape' }, []);
163
+ expect(substituteFlightDocumentSceneTokens(scene, resolutionOf(scene))).toBeNull();
164
+ });
165
+ });
166
+
167
+ function resolutionOf(
168
+ scene: Readonly<FlightDocumentScene2D | FlightDocumentScene3D>,
169
+ mode = 'default',
170
+ ): FlightDocumentTokenResolution {
171
+ const resolution = resolveFlightDocumentSceneTokens(scene, mode, createFlightDocumentTokenResolverRegistry());
172
+ if (resolution === null) throw new Error('expected the fixture token table to resolve');
173
+ return resolution;
174
+ }
175
+
176
+ function sceneWith(scene: FlightDocumentNode, tokens: readonly FlightDocumentToken[]): FlightDocumentScene2D {
177
+ return { backgroundColor: null, kind: 'Scene2D', layouts: [], scene, tokens: [...tokens] };
178
+ }