@ax-design/typed-om 0.2.0 → 0.3.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/README.md CHANGED
@@ -54,10 +54,24 @@ binding.get('--my-color'); // [255, 0, 0, 1]
54
54
  binding.get('--my-mode'); // 'soft'
55
55
  ```
56
56
 
57
- Values in common forms (`12px`, `50%`, `calc(50% + 4px)`, `rgb()`, `#hex`, keywords) are parsed directly. Anything else (`calc(1em + 2px)`, `red`) goes through a hidden probe element, so the browser does the math.
57
+ Values in common forms (`12px`, `50%`, `calc(50% + 4px)`, `rgb()`, `#hex`, `200ms`, `0.2s`, keywords) are parsed directly. Anything else (`calc(1em + 2px)`, `red`) goes through a hidden probe element, so the browser does the math.
58
58
 
59
59
  `register: true` registers the property with `CSS.registerProperty` where it exists, so your transitions on it interpolate. Where it does not exist, everything still works and transitions jump to the end value.
60
60
 
61
+ ### Times
62
+
63
+ A `time` property holds a CSS `<time>` and reads as a number of milliseconds, whichever unit the author wrote:
64
+
65
+ ```javascript
66
+ const schema = new StyleSchema([
67
+ { name: '--my-delay', syntax: { type: 'time' }, initial: '250ms', inherits: true, register: true },
68
+ ]);
69
+ // --my-delay: 0.4s
70
+ binding.get('--my-delay'); // 400
71
+ ```
72
+
73
+ A bare number is not a time, as in CSS: `--my-delay: 400` keeps the initial value. `calc()` and other forms go through the probe like the other types.
74
+
61
75
  ### Watching changes
62
76
 
63
77
  ```javascript
@@ -80,3 +94,5 @@ A property can be skipped when another property makes it irrelevant, which saves
80
94
  ## Browser support
81
95
 
82
96
  Typed OM works in browsers released since 2016 that support custom properties and Shadow DOM. Keyword values cannot be CSS-wide keywords (`initial`, `inherit` and so on), and changes of a keyword to `none` are not seen by the detector.
97
+
98
+ The detector sees changes of a `time` property only where `CSS.registerProperty` exists (a time can only transition as a registered property). Elsewhere `read()` still gives the right value: read again when you know it may have changed.
@@ -1,4 +1,16 @@
1
1
  import type { PropertySpec } from './schema.js';
2
+ export interface KeywordMirror {
3
+ name: string;
4
+ property: string;
5
+ syntax: string;
6
+ initial: string;
7
+ }
8
+ export declare function keywordMirrors(schema: PropertySpec[]): KeywordMirror[];
9
+ export interface TimeMirror {
10
+ name: string;
11
+ property: string;
12
+ }
13
+ export declare function timeMirrors(schema: PropertySpec[]): TimeMirror[];
2
14
  export interface DetectorTemplate {
3
15
  css: string;
4
16
  elementCount: number;
package/dist/detector.js CHANGED
@@ -4,7 +4,10 @@
4
4
  // - numeric values: folded into up to four weighted calc() hashes, placed in the x and y of
5
5
  // transform-origin and perspective-origin (neither triggers layout), 1ms transitions;
6
6
  // - colors: one real color property each, 1ms transitions;
7
- // - keywords: an animation-name list with shadow-scoped empty @keyframes per allowed value.
7
+ // - keywords: an animation-name list with shadow-scoped empty @keyframes per allowed value;
8
+ // - times: one registered <time> mirror each, 1ms transitions. A time cannot enter the numeric
9
+ // hash (calc() multiplies a time by numbers only), and only a registered custom property
10
+ // interpolates a time, so times are observed where CSS.registerProperty exists.
8
11
  // Any transition or animation start marks the host as changed.
9
12
  const HASH_SLOTS = 4;
10
13
  const COLOR_PROPERTIES = [
@@ -30,13 +33,50 @@ function chunk(items, size) {
30
33
  function weight(i) {
31
34
  return (1 + i * 0.0173).toFixed(4);
32
35
  }
36
+ // Numeric specs that may hold a keyword (e.g. "<length> | auto") would poison the numeric
37
+ // calc() hash when the computed value is that keyword: calc(auto * 1.0000) is invalid and
38
+ // invalidates the whole transform-origin / perspective-origin declaration. Give each such spec
39
+ // a registered mirror with a pure numeric syntax: "auto" is rejected at computed-value time,
40
+ // so the mirror falls back to its zero initial and the hash stays legal. Value<->keyword jumps
41
+ // still move the hash (number -> 0), so changes keep firing.
42
+ export function keywordMirrors(schema) {
43
+ const out = [];
44
+ let i = 0;
45
+ for (const spec of schema) {
46
+ const t = spec.syntax.type;
47
+ if (t !== 'length' && t !== 'length-percentage' && t !== 'number')
48
+ continue;
49
+ if (!spec.syntax.keywords || !spec.syntax.keywords.length)
50
+ continue;
51
+ out.push({
52
+ name: `--${PREFIX}-kw-m${i++}`,
53
+ property: spec.name,
54
+ syntax: t === 'number' ? '<number>' : t === 'length' ? '<length>' : '<length-percentage>',
55
+ initial: t === 'number' ? '0' : '0px',
56
+ });
57
+ }
58
+ return out;
59
+ }
60
+ // One registered <time> mirror per time spec, named by its position among the time specs. The
61
+ // definition is the same for every schema (<time>, not inherited, 0s), so schemas share the names.
62
+ export function timeMirrors(schema) {
63
+ return schema.filter((spec) => spec.syntax.type === 'time').map((spec, i) => ({ name: `--${PREFIX}-t${i}`, property: spec.name }));
64
+ }
33
65
  // `registered` lists the properties registered by name. A registered non-inherited property
34
66
  // does not reach the detector element inside the shadow root, so the host mirrors it into an
35
67
  // unregistered (inherited) property. The slot resets the mirrors so author content does not
36
- // inherit them.
68
+ // inherit them. Time specs are observed only when their time mirror is in `registered`.
37
69
  export function buildDetector(schema, registered = new Set()) {
38
70
  const mirrors = [];
71
+ const kwMirrors = [];
72
+ const kwName = {};
73
+ for (const m of keywordMirrors(schema)) {
74
+ kwName[m.property] = m.name;
75
+ kwMirrors.push(`${m.name}: var(${m.property});`);
76
+ }
39
77
  const source = (spec) => {
78
+ if (kwName[spec.name])
79
+ return kwName[spec.name];
40
80
  if (!registered.has(spec.name) || spec.inherits)
41
81
  return spec.name;
42
82
  const mirror = `--${PREFIX}-m${mirrors.length}`;
@@ -52,7 +92,11 @@ export function buildDetector(schema, registered = new Set()) {
52
92
  const groups = chunk(numeric, Math.ceil(numeric.length / HASH_SLOTS));
53
93
  const hashes = groups.map((group) => `calc(${group
54
94
  .map((spec, i) => {
55
- const v = `var(${source(spec)}, ${spec.initial})`;
95
+ const src = source(spec);
96
+ // The registered mirror always resolves to a number (keyword -> zero), so a
97
+ // keyword-capable spec's fallback is zero rather than its (keyword) initial.
98
+ const fallback = kwName[spec.name] ? (spec.syntax.type === 'number' ? '0' : '0px') : spec.initial;
99
+ const v = `var(${src}, ${fallback})`;
56
100
  return spec.syntax.type === 'number' ? `${v} * ${weight(i)}px` : `${v} * ${weight(i)}`;
57
101
  })
58
102
  .join(' + ')})`);
@@ -75,6 +119,13 @@ export function buildDetector(schema, registered = new Set()) {
75
119
  e.transitions.push(`${COLOR_PROPERTIES[i]} 1ms`);
76
120
  });
77
121
  });
122
+ for (const m of timeMirrors(schema)) {
123
+ if (!registered.has(m.name))
124
+ continue;
125
+ const spec = schema.filter((s) => s.name === m.property)[0];
126
+ elements[0].decls.push(`${m.name}: var(${source(spec)}, ${spec.initial});`);
127
+ elements[0].transitions.push(`${m.name} 1ms`);
128
+ }
78
129
  const rules = [];
79
130
  const keywords = schema.filter((s) => s.syntax.type === 'keyword');
80
131
  if (keywords.length) {
@@ -94,9 +145,10 @@ export function buildDetector(schema, registered = new Set()) {
94
145
  const transition = e.transitions.length ? ` transition: ${e.transitions.join(', ')};` : '';
95
146
  rules.push(`.${PREFIX}-d${i} { ${e.decls.join(' ')}${transition} }`);
96
147
  });
97
- if (mirrors.length) {
98
- rules.push(`:host { ${mirrors.join(' ')} }`);
99
- rules.push(`slot { ${mirrors.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
148
+ if (mirrors.length || kwMirrors.length) {
149
+ const all = [...mirrors, ...kwMirrors];
150
+ rules.push(`:host { ${all.join(' ')} }`);
151
+ rules.push(`slot { ${all.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
100
152
  }
101
153
  rules.push(`.${PREFIX}-c { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; visibility: hidden; pointer-events: none; contain: strict; }`, `.${PREFIX}-c > div { position: absolute; left: 0; top: 0; width: 1px; height: 1px; }`);
102
154
  return { css: rules.join('\n'), elementCount: elements.length };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { StyleBinding, StyleSchema } from './binding.js';
2
2
  export { attachDetector, buildDetector } from './detector.js';
3
3
  export type { DetectorHandle, DetectorTemplate } from './detector.js';
4
- export { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber } from './parse.js';
5
- export { probeColor, probeLengthPercentage, probeNumber } from './probe.js';
4
+ export { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber, parseTime } from './parse.js';
5
+ export { probeColor, probeLengthPercentage, probeNumber, probeTime } from './probe.js';
6
6
  export { StyleReader } from './reader.js';
7
7
  export type { ReadStats } from './reader.js';
8
8
  export { canRegister, registerProperties } from './register.js';
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { StyleBinding, StyleSchema } from './binding.js';
2
2
  export { attachDetector, buildDetector } from './detector.js';
3
- export { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber } from './parse.js';
4
- export { probeColor, probeLengthPercentage, probeNumber } from './probe.js';
3
+ export { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber, parseTime } from './parse.js';
4
+ export { probeColor, probeLengthPercentage, probeNumber, probeTime } from './probe.js';
5
5
  export { StyleReader } from './reader.js';
6
6
  export { canRegister, registerProperties } from './register.js';
7
7
  export { cssSyntax, validateSchema } from './schema.js';
package/dist/parse.d.ts CHANGED
@@ -2,5 +2,6 @@ import type { LengthPercentage, Rgba } from './schema.js';
2
2
  export declare function parseNumber(text: string): number | null;
3
3
  export declare function parseLengthPercentage(text: string): LengthPercentage | null;
4
4
  export declare function parseLength(text: string): number | null;
5
+ export declare function parseTime(text: string): number | null;
5
6
  export declare function parseKeyword(text: string, values: string[]): string | null;
6
7
  export declare function parseColor(text: string): Rgba | null;
package/dist/parse.js CHANGED
@@ -30,6 +30,16 @@ export function parseLength(text) {
30
30
  const m = LENGTH.exec(text);
31
31
  return m && m[2] === 'px' ? parseFloat(m[1]) : null;
32
32
  }
33
+ const TIME = new RegExp(`^(${NUMBER})(ms|s)$`);
34
+ // A <time> in ms. Engines serialize computed times in seconds ("0.05s"); an unregistered value
35
+ // comes back as written, in either unit. A bare number is not a time, as in CSS.
36
+ export function parseTime(text) {
37
+ const m = TIME.exec(text);
38
+ if (!m)
39
+ return null;
40
+ const v = parseFloat(m[1]);
41
+ return m[2] === 's' ? v * 1000 : v;
42
+ }
33
43
  export function parseKeyword(text, values) {
34
44
  return values.indexOf(text) >= 0 ? text : null;
35
45
  }
package/dist/probe.d.ts CHANGED
@@ -2,3 +2,4 @@ import type { LengthPercentage, Rgba } from './schema.js';
2
2
  export declare function probeLengthPercentage(parent: Node, name: string): LengthPercentage;
3
3
  export declare function probeNumber(parent: Node, name: string): number;
4
4
  export declare function probeColor(parent: Node, name: string): Rgba | null;
5
+ export declare function probeTime(parent: Node, name: string): number | null;
package/dist/probe.js CHANGED
@@ -1,4 +1,4 @@
1
- import { parseColor } from './parse.js';
1
+ import { parseColor, parseTime } from './parse.js';
2
2
  // Slow path for values the fast parsers cannot read (unregistered `calc(1em + 2px)`, named
3
3
  // colors, other units). One probe per document is moved into the shadow root that needs it,
4
4
  // so it inherits the host's custom properties, read, and removed again.
@@ -38,6 +38,7 @@ function withProbe(parent, read) {
38
38
  probe.narrow.style.marginLeft = '';
39
39
  probe.wide.style.marginLeft = '';
40
40
  probe.narrow.style.color = '';
41
+ probe.narrow.style.transitionDelay = '';
41
42
  }
42
43
  }
43
44
  export function probeLengthPercentage(parent, name) {
@@ -64,3 +65,14 @@ export function probeColor(parent, name) {
64
65
  return text === sentinel ? null : parseColor(text);
65
66
  });
66
67
  }
68
+ // A time in ms, or null when the value is not a valid time. transition-delay is not inherited,
69
+ // so an invalid value would read as its initial 0s, the same as a real 0s. The second entry of
70
+ // the list tells them apart: an invalid var() invalidates the whole declaration, which leaves
71
+ // one entry instead of two. Negative delays are valid, so negative times read back too.
72
+ export function probeTime(parent, name) {
73
+ return withProbe(parent, (p) => {
74
+ p.narrow.style.transitionDelay = `var(${name}), 0s`;
75
+ const list = getComputedStyle(p.narrow).transitionDelay.split(',');
76
+ return list.length === 2 ? parseTime(list[0].trim()) : null;
77
+ });
78
+ }
package/dist/reader.js CHANGED
@@ -1,5 +1,5 @@
1
- import { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber } from './parse.js';
2
- import { probeColor, probeLengthPercentage, probeNumber } from './probe.js';
1
+ import { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber, parseTime } from './parse.js';
2
+ import { probeColor, probeLengthPercentage, probeNumber, probeTime } from './probe.js';
3
3
  // Reads the computed values of a schema from one element. Fast path: literal parsing of
4
4
  // getComputedStyle strings. Slow path: the shared probe, inserted into `probeParent`, which
5
5
  // must be inside the element's shadow root (or otherwise inherit its custom properties).
@@ -52,6 +52,8 @@ function parseFast(spec, text) {
52
52
  if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0)
53
53
  return text;
54
54
  return parseNumber(text);
55
+ case 'time':
56
+ return parseTime(text);
55
57
  case 'color':
56
58
  if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0)
57
59
  return text;
@@ -68,6 +70,8 @@ function parseSlow(spec, parent) {
68
70
  return probeLengthPercentage(parent, spec.name);
69
71
  case 'number':
70
72
  return probeNumber(parent, spec.name);
73
+ case 'time':
74
+ return probeTime(parent, spec.name);
71
75
  case 'color':
72
76
  return probeColor(parent, spec.name);
73
77
  case 'keyword':
package/dist/register.js CHANGED
@@ -1,10 +1,12 @@
1
+ import { keywordMirrors, timeMirrors } from './detector.js';
1
2
  import { cssSyntax } from './schema.js';
2
3
  export function canRegister() {
3
4
  return typeof CSS !== 'undefined' && typeof CSS.registerProperty === 'function';
4
5
  }
5
6
  // Registers the properties that ask for it. Returns the names that are registered after the
6
7
  // call, including ones another copy of the package registered before (first registration
7
- // wins, so definitions must never change between versions).
8
+ // wins, so definitions must never change between versions), and the time mirrors of the
9
+ // detector (see timeMirrors).
8
10
  export function registerProperties(schema) {
9
11
  const registered = new Set();
10
12
  if (!canRegister())
@@ -25,5 +27,33 @@ export function registerProperties(schema) {
25
27
  throw error;
26
28
  }
27
29
  }
30
+ // Numeric specs that may hold a keyword get a registered mirror with a pure numeric syntax,
31
+ // so the keyword is rejected at computed-value time and the numeric hash stays legal. The
32
+ // mirror is internal (never read by the reader) and its definition is derived from the
33
+ // permanent schema contract, so a re-registration by another copy is the same definition.
34
+ for (const m of keywordMirrors(schema)) {
35
+ try {
36
+ css.registerProperty({ name: m.name, syntax: m.syntax, inherits: true, initialValue: m.initial });
37
+ }
38
+ catch (error) {
39
+ // Already registered by an earlier copy with the identical definition.
40
+ if (!error || error.name !== 'InvalidModificationError')
41
+ throw error;
42
+ }
43
+ }
44
+ // Time specs get a registered <time> mirror on the detector element, the only way to make a
45
+ // time transition (and so be observed). Same definition for every schema, like the above.
46
+ for (const m of timeMirrors(schema)) {
47
+ try {
48
+ css.registerProperty({ name: m.name, syntax: '<time>', inherits: false, initialValue: '0s' });
49
+ registered.add(m.name);
50
+ }
51
+ catch (error) {
52
+ if (error && error.name === 'InvalidModificationError')
53
+ registered.add(m.name);
54
+ else
55
+ throw error;
56
+ }
57
+ }
28
58
  return registered;
29
59
  }
package/dist/schema.d.ts CHANGED
@@ -7,6 +7,8 @@ export type PropertySyntax = {
7
7
  } | {
8
8
  type: 'number';
9
9
  keywords?: string[];
10
+ } | {
11
+ type: 'time';
10
12
  } | {
11
13
  type: 'color';
12
14
  keywords?: string[];
package/dist/schema.js CHANGED
@@ -20,7 +20,7 @@ export function validateSchema(schema) {
20
20
  if (syntax.type === 'keyword') {
21
21
  checkKeywords(spec.name, syntax.values);
22
22
  }
23
- else if (syntax.type !== 'color' && syntax.keywords) {
23
+ else if (syntax.type !== 'color' && syntax.type !== 'time' && syntax.keywords) {
24
24
  // Numeric types may hold a keyword instead of a value ("<length> | auto");
25
25
  // validate them the same way as the keyword type. Color keywords stay unvalidated.
26
26
  checkKeywords(spec.name, syntax.keywords);
@@ -35,6 +35,8 @@ export function cssSyntax(syntax) {
35
35
  return syntax.keywords && syntax.keywords.length ? `<length-percentage> | ${syntax.keywords.join(' | ')}` : '<length-percentage>';
36
36
  case 'number':
37
37
  return syntax.keywords && syntax.keywords.length ? `<number> | ${syntax.keywords.join(' | ')}` : '<number>';
38
+ case 'time':
39
+ return '<time>';
38
40
  case 'color':
39
41
  return syntax.keywords && syntax.keywords.length ? `<color> | ${syntax.keywords.join(' | ')}` : '<color>';
40
42
  case 'keyword':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ax-design/typed-om",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Typed reads and change detection for CSS custom properties, for browsers with or without CSS Typed OM and @property.",
5
5
  "keywords": [
6
6
  "css",
package/src/detector.ts CHANGED
@@ -6,7 +6,10 @@ import type { PropertySpec } from './schema.js';
6
6
  // - numeric values: folded into up to four weighted calc() hashes, placed in the x and y of
7
7
  // transform-origin and perspective-origin (neither triggers layout), 1ms transitions;
8
8
  // - colors: one real color property each, 1ms transitions;
9
- // - keywords: an animation-name list with shadow-scoped empty @keyframes per allowed value.
9
+ // - keywords: an animation-name list with shadow-scoped empty @keyframes per allowed value;
10
+ // - times: one registered <time> mirror each, 1ms transitions. A time cannot enter the numeric
11
+ // hash (calc() multiplies a time by numbers only), and only a registered custom property
12
+ // interpolates a time, so times are observed where CSS.registerProperty exists.
10
13
  // Any transition or animation start marks the host as changed.
11
14
 
12
15
  const HASH_SLOTS = 4;
@@ -35,6 +38,53 @@ function weight(i: number): string {
35
38
  return (1 + i * 0.0173).toFixed(4);
36
39
  }
37
40
 
41
+ export interface KeywordMirror {
42
+ // Name of the registered mirror property, e.g. "--ax-typed-om-kw-m0".
43
+ name: string;
44
+ // The source custom property the mirror reflects.
45
+ property: string;
46
+ // Pure numeric syntax (no keywords) the mirror is registered with.
47
+ syntax: string;
48
+ // Zero initial value for that syntax.
49
+ initial: string;
50
+ }
51
+
52
+ // Numeric specs that may hold a keyword (e.g. "<length> | auto") would poison the numeric
53
+ // calc() hash when the computed value is that keyword: calc(auto * 1.0000) is invalid and
54
+ // invalidates the whole transform-origin / perspective-origin declaration. Give each such spec
55
+ // a registered mirror with a pure numeric syntax: "auto" is rejected at computed-value time,
56
+ // so the mirror falls back to its zero initial and the hash stays legal. Value<->keyword jumps
57
+ // still move the hash (number -> 0), so changes keep firing.
58
+ export function keywordMirrors(schema: PropertySpec[]): KeywordMirror[] {
59
+ const out: KeywordMirror[] = [];
60
+ let i = 0;
61
+ for (const spec of schema) {
62
+ const t = spec.syntax.type;
63
+ if (t !== 'length' && t !== 'length-percentage' && t !== 'number') continue;
64
+ if (!spec.syntax.keywords || !spec.syntax.keywords.length) continue;
65
+ out.push({
66
+ name: `--${PREFIX}-kw-m${i++}`,
67
+ property: spec.name,
68
+ syntax: t === 'number' ? '<number>' : t === 'length' ? '<length>' : '<length-percentage>',
69
+ initial: t === 'number' ? '0' : '0px',
70
+ });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export interface TimeMirror {
76
+ // Name of the registered <time> mirror on the detector element, e.g. "--ax-typed-om-t0".
77
+ name: string;
78
+ // The source custom property the mirror reflects.
79
+ property: string;
80
+ }
81
+
82
+ // One registered <time> mirror per time spec, named by its position among the time specs. The
83
+ // definition is the same for every schema (<time>, not inherited, 0s), so schemas share the names.
84
+ export function timeMirrors(schema: PropertySpec[]): TimeMirror[] {
85
+ return schema.filter((spec) => spec.syntax.type === 'time').map((spec, i) => ({ name: `--${PREFIX}-t${i}`, property: spec.name }));
86
+ }
87
+
38
88
  export interface DetectorTemplate {
39
89
  css: string;
40
90
  elementCount: number;
@@ -43,10 +93,17 @@ export interface DetectorTemplate {
43
93
  // `registered` lists the properties registered by name. A registered non-inherited property
44
94
  // does not reach the detector element inside the shadow root, so the host mirrors it into an
45
95
  // unregistered (inherited) property. The slot resets the mirrors so author content does not
46
- // inherit them.
96
+ // inherit them. Time specs are observed only when their time mirror is in `registered`.
47
97
  export function buildDetector(schema: PropertySpec[], registered: Set<string> = new Set()): DetectorTemplate {
48
98
  const mirrors: string[] = [];
99
+ const kwMirrors: string[] = [];
100
+ const kwName: { [name: string]: string } = {};
101
+ for (const m of keywordMirrors(schema)) {
102
+ kwName[m.property] = m.name;
103
+ kwMirrors.push(`${m.name}: var(${m.property});`);
104
+ }
49
105
  const source = (spec: PropertySpec) => {
106
+ if (kwName[spec.name]) return kwName[spec.name];
50
107
  if (!registered.has(spec.name) || spec.inherits) return spec.name;
51
108
  const mirror = `--${PREFIX}-m${mirrors.length}`;
52
109
  mirrors.push(`${mirror}: var(${spec.name});`);
@@ -64,7 +121,11 @@ export function buildDetector(schema: PropertySpec[], registered: Set<string> =
64
121
  const hashes = groups.map((group) =>
65
122
  `calc(${group
66
123
  .map((spec, i) => {
67
- const v = `var(${source(spec)}, ${spec.initial})`;
124
+ const src = source(spec);
125
+ // The registered mirror always resolves to a number (keyword -> zero), so a
126
+ // keyword-capable spec's fallback is zero rather than its (keyword) initial.
127
+ const fallback = kwName[spec.name] ? (spec.syntax.type === 'number' ? '0' : '0px') : spec.initial;
128
+ const v = `var(${src}, ${fallback})`;
68
129
  return spec.syntax.type === 'number' ? `${v} * ${weight(i)}px` : `${v} * ${weight(i)}`;
69
130
  })
70
131
  .join(' + ')})`
@@ -89,6 +150,13 @@ export function buildDetector(schema: PropertySpec[], registered: Set<string> =
89
150
  });
90
151
  });
91
152
 
153
+ for (const m of timeMirrors(schema)) {
154
+ if (!registered.has(m.name)) continue;
155
+ const spec = schema.filter((s) => s.name === m.property)[0];
156
+ elements[0].decls.push(`${m.name}: var(${source(spec)}, ${spec.initial});`);
157
+ elements[0].transitions.push(`${m.name} 1ms`);
158
+ }
159
+
92
160
  const rules: string[] = [];
93
161
  const keywords = schema.filter((s) => s.syntax.type === 'keyword');
94
162
  if (keywords.length) {
@@ -106,9 +174,10 @@ export function buildDetector(schema: PropertySpec[], registered: Set<string> =
106
174
  const transition = e.transitions.length ? ` transition: ${e.transitions.join(', ')};` : '';
107
175
  rules.push(`.${PREFIX}-d${i} { ${e.decls.join(' ')}${transition} }`);
108
176
  });
109
- if (mirrors.length) {
110
- rules.push(`:host { ${mirrors.join(' ')} }`);
111
- rules.push(`slot { ${mirrors.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
177
+ if (mirrors.length || kwMirrors.length) {
178
+ const all = [...mirrors, ...kwMirrors];
179
+ rules.push(`:host { ${all.join(' ')} }`);
180
+ rules.push(`slot { ${all.map((m) => `${m.slice(0, m.indexOf(':'))}: initial;`).join(' ')} }`);
112
181
  }
113
182
  rules.push(
114
183
  `.${PREFIX}-c { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: hidden; visibility: hidden; pointer-events: none; contain: strict; }`,
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { StyleBinding, StyleSchema } from './binding.js';
2
2
  export { attachDetector, buildDetector } from './detector.js';
3
3
  export type { DetectorHandle, DetectorTemplate } from './detector.js';
4
- export { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber } from './parse.js';
5
- export { probeColor, probeLengthPercentage, probeNumber } from './probe.js';
4
+ export { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber, parseTime } from './parse.js';
5
+ export { probeColor, probeLengthPercentage, probeNumber, probeTime } from './probe.js';
6
6
  export { StyleReader } from './reader.js';
7
7
  export type { ReadStats } from './reader.js';
8
8
  export { canRegister, registerProperties } from './register.js';
package/src/parse.ts CHANGED
@@ -35,6 +35,17 @@ export function parseLength(text: string): number | null {
35
35
  return m && m[2] === 'px' ? parseFloat(m[1]) : null;
36
36
  }
37
37
 
38
+ const TIME = new RegExp(`^(${NUMBER})(ms|s)$`);
39
+
40
+ // A <time> in ms. Engines serialize computed times in seconds ("0.05s"); an unregistered value
41
+ // comes back as written, in either unit. A bare number is not a time, as in CSS.
42
+ export function parseTime(text: string): number | null {
43
+ const m = TIME.exec(text);
44
+ if (!m) return null;
45
+ const v = parseFloat(m[1]);
46
+ return m[2] === 's' ? v * 1000 : v;
47
+ }
48
+
38
49
  export function parseKeyword(text: string, values: string[]): string | null {
39
50
  return values.indexOf(text) >= 0 ? text : null;
40
51
  }
package/src/probe.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { parseColor } from './parse.js';
1
+ import { parseColor, parseTime } from './parse.js';
2
2
  import type { LengthPercentage, Rgba } from './schema.js';
3
3
 
4
4
  // Slow path for values the fast parsers cannot read (unregistered `calc(1em + 2px)`, named
@@ -48,6 +48,7 @@ function withProbe<T>(parent: Node, read: (p: Probe) => T): T {
48
48
  probe.narrow.style.marginLeft = '';
49
49
  probe.wide.style.marginLeft = '';
50
50
  probe.narrow.style.color = '';
51
+ probe.narrow.style.transitionDelay = '';
51
52
  }
52
53
  }
53
54
 
@@ -77,3 +78,15 @@ export function probeColor(parent: Node, name: string): Rgba | null {
77
78
  return text === sentinel ? null : parseColor(text);
78
79
  });
79
80
  }
81
+
82
+ // A time in ms, or null when the value is not a valid time. transition-delay is not inherited,
83
+ // so an invalid value would read as its initial 0s, the same as a real 0s. The second entry of
84
+ // the list tells them apart: an invalid var() invalidates the whole declaration, which leaves
85
+ // one entry instead of two. Negative delays are valid, so negative times read back too.
86
+ export function probeTime(parent: Node, name: string): number | null {
87
+ return withProbe(parent, (p) => {
88
+ p.narrow.style.transitionDelay = `var(${name}), 0s`;
89
+ const list = getComputedStyle(p.narrow).transitionDelay.split(',');
90
+ return list.length === 2 ? parseTime(list[0].trim()) : null;
91
+ });
92
+ }
package/src/reader.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber } from './parse.js';
2
- import { probeColor, probeLengthPercentage, probeNumber } from './probe.js';
1
+ import { parseColor, parseKeyword, parseLength, parseLengthPercentage, parseNumber, parseTime } from './parse.js';
2
+ import { probeColor, probeLengthPercentage, probeNumber, probeTime } from './probe.js';
3
3
  import type { PropertySpec, PropertyValue } from './schema.js';
4
4
 
5
5
  export interface ReadStats {
@@ -62,6 +62,8 @@ function parseFast(spec: PropertySpec, text: string): PropertyValue {
62
62
  case 'number':
63
63
  if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0) return text;
64
64
  return parseNumber(text);
65
+ case 'time':
66
+ return parseTime(text);
65
67
  case 'color':
66
68
  if (spec.syntax.keywords && spec.syntax.keywords.indexOf(text) >= 0) return text;
67
69
  return parseColor(text);
@@ -78,6 +80,8 @@ function parseSlow(spec: PropertySpec, parent: Node): PropertyValue {
78
80
  return probeLengthPercentage(parent, spec.name);
79
81
  case 'number':
80
82
  return probeNumber(parent, spec.name);
83
+ case 'time':
84
+ return probeTime(parent, spec.name);
81
85
  case 'color':
82
86
  return probeColor(parent, spec.name);
83
87
  case 'keyword':
package/src/register.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { keywordMirrors, timeMirrors } from './detector.js';
1
2
  import { cssSyntax } from './schema.js';
2
3
  import type { PropertySpec } from './schema.js';
3
4
 
@@ -16,7 +17,8 @@ export function canRegister(): boolean {
16
17
 
17
18
  // Registers the properties that ask for it. Returns the names that are registered after the
18
19
  // call, including ones another copy of the package registered before (first registration
19
- // wins, so definitions must never change between versions).
20
+ // wins, so definitions must never change between versions), and the time mirrors of the
21
+ // detector (see timeMirrors).
20
22
  export function registerProperties(schema: PropertySpec[]): Set<string> {
21
23
  const registered = new Set<string>();
22
24
  if (!canRegister()) return registered;
@@ -32,5 +34,28 @@ export function registerProperties(schema: PropertySpec[]): Set<string> {
32
34
  else throw error;
33
35
  }
34
36
  }
37
+ // Numeric specs that may hold a keyword get a registered mirror with a pure numeric syntax,
38
+ // so the keyword is rejected at computed-value time and the numeric hash stays legal. The
39
+ // mirror is internal (never read by the reader) and its definition is derived from the
40
+ // permanent schema contract, so a re-registration by another copy is the same definition.
41
+ for (const m of keywordMirrors(schema)) {
42
+ try {
43
+ css.registerProperty!({ name: m.name, syntax: m.syntax, inherits: true, initialValue: m.initial });
44
+ } catch (error) {
45
+ // Already registered by an earlier copy with the identical definition.
46
+ if (!error || (error as Error).name !== 'InvalidModificationError') throw error;
47
+ }
48
+ }
49
+ // Time specs get a registered <time> mirror on the detector element, the only way to make a
50
+ // time transition (and so be observed). Same definition for every schema, like the above.
51
+ for (const m of timeMirrors(schema)) {
52
+ try {
53
+ css.registerProperty!({ name: m.name, syntax: '<time>', inherits: false, initialValue: '0s' });
54
+ registered.add(m.name);
55
+ } catch (error) {
56
+ if (error && (error as Error).name === 'InvalidModificationError') registered.add(m.name);
57
+ else throw error;
58
+ }
59
+ }
35
60
  return registered;
36
61
  }
package/src/schema.ts CHANGED
@@ -3,6 +3,8 @@ export type PropertySyntax =
3
3
  | { type: 'length'; keywords?: string[] }
4
4
  | { type: 'length-percentage'; keywords?: string[] }
5
5
  | { type: 'number'; keywords?: string[] }
6
+ // A CSS <time>, read as a number of milliseconds ("0.2s" and "200ms" are both 200).
7
+ | { type: 'time' }
6
8
  // `keywords`: extra identifiers the property may hold instead of a color (returned as strings).
7
9
  | { type: 'color'; keywords?: string[] }
8
10
  // Allowed values. CSS-wide keywords are rejected. `none` is allowed for existing APIs, but
@@ -55,7 +57,7 @@ export function validateSchema(schema: PropertySpec[]): void {
55
57
  const syntax = spec.syntax;
56
58
  if (syntax.type === 'keyword') {
57
59
  checkKeywords(spec.name, syntax.values);
58
- } else if (syntax.type !== 'color' && syntax.keywords) {
60
+ } else if (syntax.type !== 'color' && syntax.type !== 'time' && syntax.keywords) {
59
61
  // Numeric types may hold a keyword instead of a value ("<length> | auto");
60
62
  // validate them the same way as the keyword type. Color keywords stay unvalidated.
61
63
  checkKeywords(spec.name, syntax.keywords);
@@ -71,6 +73,8 @@ export function cssSyntax(syntax: PropertySyntax): string {
71
73
  return syntax.keywords && syntax.keywords.length ? `<length-percentage> | ${syntax.keywords.join(' | ')}` : '<length-percentage>';
72
74
  case 'number':
73
75
  return syntax.keywords && syntax.keywords.length ? `<number> | ${syntax.keywords.join(' | ')}` : '<number>';
76
+ case 'time':
77
+ return '<time>';
74
78
  case 'color':
75
79
  return syntax.keywords && syntax.keywords.length ? `<color> | ${syntax.keywords.join(' | ')}` : '<color>';
76
80
  case 'keyword':