react-x11 2.49.0 → 2.50.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/src/styles.js CHANGED
@@ -1,17 +1,39 @@
1
1
  // Style props → yoga setters (layout) and paint metadata. Flat, ink-style
2
2
  // props: <box flexDirection="row" padding={8} backgroundColor="#eee">.
3
- // Numbers are pixels; strings like '50%' / 'auto' pass through to yoga.
3
+ // Lengths are logical pixels — a number, or a string like '12px' that the
4
+ // funnel reads into one — and '50%' / 'auto' pass through to yoga; CSS's
5
+ // shorthands expand into the longhands here (src/shorthands.js).
4
6
  // The engine is ours (`./yoga.js`) — the enum tables below are built at
5
7
  // module scope, which is what that module's synchronous half is for.
6
8
  import { cssColorStraight } from 'ntk/color';
7
9
 
8
- import { parseBoxShadow, parseLinearGradient } from './decorations.js';
10
+ import { isCurrentColor, mentionsCurrentColor } from './currentcolor.js';
11
+ import {
12
+ parseBoxShadow,
13
+ parseLinearGradient,
14
+ shadowSpecs,
15
+ } from './decorations.js';
9
16
  import { OBJECT_FITS } from './nodes/fit.js';
10
17
  import {
11
18
  validateGridValue,
12
19
  GRID_CONTAINER_PROPS,
13
20
  GRID_ITEM_PROPS,
14
21
  } from './grid.js';
22
+ import {
23
+ LENGTH_FORMS,
24
+ LINE_STYLES,
25
+ PROBE,
26
+ SHORTHAND_PROPS,
27
+ SHORTHANDS_OF,
28
+ borderProblem,
29
+ checkShorthand,
30
+ expandStyle,
31
+ isShorthandValue,
32
+ locate,
33
+ needsExpanding,
34
+ readLength,
35
+ readLineHeight,
36
+ } from './shorthands.js';
15
37
  import { Yoga, layoutLoaded } from './yoga.js';
16
38
 
17
39
  const FLEX_DIRECTION = {
@@ -84,28 +106,6 @@ const DISPLAY = {
84
106
  none: Yoga.DISPLAY_NONE,
85
107
  };
86
108
 
87
- /**
88
- * CSS's `flex` shorthand, as the three properties it sets. A number is
89
- * `flex: <grow> 1 0` — "take this share of what is left, from a base size of
90
- * nothing" — which is what `flex: 1` means everywhere it is written; the two
91
- * keywords are CSS's own, `'auto'` for "grow and shrink from my content" and
92
- * `'none'` for "do neither".
93
- *
94
- * It is here rather than in `LAYOUT_APPLIERS` because a shorthand is not a
95
- * yoga property: it expands into three of them before the diff runs, so
96
- * `{ flex: 1, flexBasis: 'auto' }` resolves the way CSS does — the longhand
97
- * after the shorthand wins — and the applier for each still sees a plain
98
- * value changing.
99
- */
100
- const FLEX_SHORTHAND = {
101
- none: { flexGrow: 0, flexShrink: 0, flexBasis: 'auto' },
102
- auto: { flexGrow: 1, flexShrink: 1, flexBasis: 'auto' },
103
- };
104
-
105
- const isFlexShorthand = (v) =>
106
- (typeof v === 'number' && Number.isFinite(v) && v >= 0) ||
107
- (typeof v === 'string' && v in FLEX_SHORTHAND);
108
-
109
109
  const OVERFLOW = {
110
110
  visible: Yoga.OVERFLOW_VISIBLE,
111
111
  hidden: Yoga.OVERFLOW_HIDDEN,
@@ -219,6 +219,22 @@ const LAYOUT_APPLIERS = {
219
219
  borderEndWidth: (n, v) => n.setBorder(Yoga.EDGE_END, v),
220
220
  };
221
221
 
222
+ // Every length a setter is handed goes through the length grammar first, so
223
+ // what yoga is asked to take is what the grammar says a length is — at a
224
+ // commit, in a frame, and in development's check, which asks these same
225
+ // appliers (`layoutRefusal`). Yoga's own string handling is `parseFloat`
226
+ // with a `%` check: it read `'2em'` as two pixels, and its border setter
227
+ // coerced a string to a number, so `'2px'` was NaN. By here the funnel has
228
+ // read every length it could into a number (`resolveComputedStyle`), so a
229
+ // string is a percentage, `'auto'`, or one the grammar refuses — which this
230
+ // throws, naming its unit.
231
+ for (const key of LENGTH_FORMS.keys()) {
232
+ const set = LAYOUT_APPLIERS[key];
233
+ if (set === undefined) continue;
234
+ LAYOUT_APPLIERS[key] = (n, v) =>
235
+ set(n, typeof v === 'string' ? readLength(key, v) : v);
236
+ }
237
+
222
238
  // Props that only affect painting, not geometry.
223
239
  //
224
240
  // `outline*` is here rather than beside `border*` in LAYOUT_APPLIERS for the
@@ -564,13 +580,19 @@ export const STATE_KEYS = [
564
580
  // could set `padding` or `fontSize` would reflow the tree on pointer move,
565
581
  // which is both a jitter bug and the end of the "hover is a repaint, not a
566
582
  // React render" property that makes this worth having at all.
567
- const STATE_PROPS = new Set([...PAINT_PROPS, 'color']);
583
+ // The two shorthands that are paint all the way down — `background` sets a
584
+ // colour and a gradient, `outline` a ring's width and colour — are legal here
585
+ // for the same reason their longhands are. `border` is not: its width is
586
+ // layout.
587
+ const STATE_PROPS = new Set([...PAINT_PROPS, 'color', 'background', 'outline']);
568
588
 
569
589
  const STYLE_PROPS = new Set([
570
590
  ...Object.keys(LAYOUT_APPLIERS),
571
- // the one layout property that is not a yoga property: a shorthand for
572
- // three of them, expanded by `resolveComputedStyle`
573
- 'flex',
591
+ // CSS's shorthands — `flex`, `border` and its sides, `inset`, `outline`,
592
+ // `background`, the logical pairs — which no setter and no paint reads:
593
+ // `resolveComputedStyle` expands them into the longhands first
594
+ // (src/shorthands.js)
595
+ ...SHORTHAND_PROPS,
574
596
  ...PAINT_PROPS,
575
597
  ...TEXT_LAYOUT_PROPS,
576
598
  ...TEXT_PAINT_PROPS,
@@ -859,6 +881,29 @@ const COLOR_PROPS = new Set([
859
881
  'color',
860
882
  ]);
861
883
 
884
+ /** …and the ones a node paints with, as against `color`, the ink itself. */
885
+ const PAINTED_COLOR_PROPS = [...COLOR_PROPS].filter((key) => key !== 'color');
886
+
887
+ /**
888
+ * Whether this style paints anything in the node's ink — a colour that is
889
+ * `currentColor`, a gradient stop that is, or a shadow that names no colour
890
+ * of its own. Such a node repaints when its ink moves, which nothing in its
891
+ * own style says (`NodeCascade._retext`): a `:hover { color }` on the row
892
+ * around it is where the change was written.
893
+ *
894
+ * Asked only once the ink has moved, of a node that has resolved one, so a
895
+ * style swap pays nothing for it.
896
+ */
897
+ export function paintsCurrentColor(style) {
898
+ for (const key of PAINTED_COLOR_PROPS) {
899
+ if (isCurrentColor(style[key])) return true;
900
+ }
901
+ if (mentionsCurrentColor(style.backgroundImage)) return true;
902
+ const shadow = style.boxShadow;
903
+ if (typeof shadow !== 'string' || shadow === 'none') return false;
904
+ return Boolean(shadowSpecs(shadow)?.some((s) => s.color === null));
905
+ }
906
+
862
907
  /**
863
908
  * The style values that are a small language rather than a number — a
864
909
  * gradient, a shadow, and grid's track lists, areas and lines — and
@@ -878,6 +923,65 @@ const COLOR_PROPS = new Set([
878
923
  * (`isColor`), since a theme is not a call site.
879
924
  */
880
925
  function validateValue(key, value, where) {
926
+ // A shorthand — or a longhand written with several values, `padding:
927
+ // '8px 16px'` — is read the way the funnel will expand it, a `$token`
928
+ // standing in for one value of whatever it resolves to. A whole token is
929
+ // one value of a shape nobody knows yet, and is answered by the frame.
930
+ if (isShorthandValue(key, value)) {
931
+ if (isToken(value)) return;
932
+ try {
933
+ checkShorthand(key, value);
934
+ } catch (err) {
935
+ throw locate(err, where);
936
+ }
937
+ return;
938
+ }
939
+ // A length written as a string is read into the number it means before
940
+ // anything below looks at it, so `'12px'` passes where 12 does and `'2em'`
941
+ // is an error naming its unit — yoga's own setters read it as 2.
942
+ if (
943
+ LENGTH_FORMS.has(key) &&
944
+ typeof value === 'string' &&
945
+ value !== '' &&
946
+ !isToken(value) &&
947
+ !mentionsToken(value)
948
+ ) {
949
+ try {
950
+ // relative units resolve against the probe: the question here is
951
+ // whether the value reads, and what it comes to is the node's to say
952
+ value = readLength(key, value, undefined, PROBE);
953
+ } catch (err) {
954
+ throw locate(err, where);
955
+ }
956
+ // a percentage radius is of the box, and read where the corner is drawn
957
+ if (key === 'borderRadius') return;
958
+ }
959
+ if (key === 'lineHeight') {
960
+ if (
961
+ value == null ||
962
+ value === '' ||
963
+ isToken(value) ||
964
+ mentionsToken(value)
965
+ ) {
966
+ return;
967
+ }
968
+ try {
969
+ readLineHeight(value, PROBE);
970
+ } catch (err) {
971
+ throw locate(err, where);
972
+ }
973
+ return;
974
+ }
975
+ if (key === 'borderStyle') {
976
+ if (value == null || LINE_STYLES.has(value) || isToken(value)) return;
977
+ throw new Error(
978
+ `react-x11: invalid borderStyle ${JSON.stringify(value)} in ${where} ` +
979
+ `(expected ${[...LINE_STYLES].map((v) => `'${v}'`).join(' or ')}` +
980
+ (value === 'none' || value === 'hidden'
981
+ ? ' — a box with no border is borderWidth: 0)'
982
+ : ')'),
983
+ );
984
+ }
881
985
  const text = TEXT_VALUE_CHECKS.get(key);
882
986
  if (text !== undefined) {
883
987
  // unset, the way a conditional writes it
@@ -896,10 +1000,13 @@ function validateValue(key, value, where) {
896
1000
  // or `''`, which has always painted nothing
897
1001
  if (value == null || value === '') return;
898
1002
  if (isColor(value) || isToken(value) || mentionsToken(value)) return;
1003
+ // the node's own ink, resolved where it is painted — and on `color`
1004
+ // itself, the ink it inherits, which is what CSS makes of it there
1005
+ if (isCurrentColor(value)) return;
899
1006
  throw new Error(
900
1007
  `react-x11: invalid ${key} ${JSON.stringify(value)} in ${where} ` +
901
- "(expected a CSS colour — '#2980b9', 'tomato', 'rgba(0, 0, 0, 0.5)' " +
902
- "or 'transparent' — or a $token)",
1008
+ "(expected a CSS colour — '#2980b9', 'tomato', 'rgba(0, 0, 0, 0.5)', " +
1009
+ "'transparent' or 'currentColor' — or a $token)",
903
1010
  );
904
1011
  }
905
1012
  if (key === 'letterSpacing') {
@@ -977,14 +1084,12 @@ function validateValue(key, value, where) {
977
1084
  isToken(value) || mentionsToken(value) ? null : layoutRefusal(key, value);
978
1085
  if (refused) {
979
1086
  // an enum's own message lists the choices; yoga's names only itself
980
- if (refused.message.startsWith('react-x11:')) {
981
- refused.message += `\n in ${where}`;
982
- throw refused;
983
- }
1087
+ if (refused.message.startsWith('react-x11:')) throw locate(refused, where);
984
1088
  const auto = layoutRefusal(key, 'auto') === null ? ", or 'auto'" : '';
985
1089
  throw new Error(
986
1090
  `react-x11: invalid ${key} ${JSON.stringify(value)} in ${where} ` +
987
- `(expected a number of pixels or a percentage like '50%'${auto})`,
1091
+ "(expected a number of logical pixels, like 12 or '12px', or a " +
1092
+ `percentage like '50%'${auto})`,
988
1093
  );
989
1094
  }
990
1095
  const grid = GRID_VALUES.has(key);
@@ -1017,7 +1122,97 @@ const isAlgorithm = (v) =>
1017
1122
  (typeof v === 'string' && ALGORITHM_NAME.test(v)) ||
1018
1123
  (isOptionBag(v) && typeof v.name === 'string' && ALGORITHM_NAME.test(v.name));
1019
1124
 
1020
- function validateStyle(style, where) {
1125
+ const CORNERS =
1126
+ 'corners are not rounded one at a time yet; borderRadius rounds all four';
1127
+ const PICTURE =
1128
+ 'a box paints a colour and a gradient; a picture is an <image>, fitted ' +
1129
+ 'with objectFit';
1130
+
1131
+ /**
1132
+ * The CSS and React Native names an app reaches for that are not properties
1133
+ * here, and what to write instead. Each is the property somebody coming from
1134
+ * the web or from React Native writes first — the ones a typo's distance
1135
+ * cannot find, because the right answer is a different word.
1136
+ */
1137
+ const INSTEAD = {
1138
+ font:
1139
+ 'the font shorthand is not supported; write fontSize, fontFamily, ' +
1140
+ 'fontWeight, fontStyle and lineHeight',
1141
+ paddingHorizontal:
1142
+ "that is React Native's name; write paddingInline (start and end), or " +
1143
+ 'paddingLeft and paddingRight',
1144
+ paddingVertical: "that is React Native's name; write paddingBlock",
1145
+ marginHorizontal:
1146
+ "that is React Native's name; write marginInline (start and end), or " +
1147
+ 'marginLeft and marginRight',
1148
+ marginVertical: "that is React Native's name; write marginBlock",
1149
+ insetInline: 'write start and end',
1150
+ insetBlock: 'write top and bottom',
1151
+ borderTopLeftRadius: CORNERS,
1152
+ borderTopRightRadius: CORNERS,
1153
+ borderBottomLeftRadius: CORNERS,
1154
+ borderBottomRightRadius: CORNERS,
1155
+ boxSizing: 'every box is border-box here, so there is nothing to set',
1156
+ whiteSpace: "write textWrap: 'nowrap' to keep text on one line",
1157
+ placeItems: 'write alignItems and justifyItems',
1158
+ placeContent: 'write alignContent and justifyContent',
1159
+ placeSelf: 'write alignSelf and justifySelf',
1160
+ backgroundSize: PICTURE,
1161
+ backgroundPosition: PICTURE,
1162
+ backgroundRepeat: PICTURE,
1163
+ textDecoration: 'text decorations are not supported',
1164
+ };
1165
+
1166
+ /** Edit distance, for "did you mean": two names one or two keystrokes
1167
+ * apart, which is what a typo is. */
1168
+ function editDistance(a, b) {
1169
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
1170
+ for (let i = 1; i <= a.length; i++) {
1171
+ const row = [i];
1172
+ for (let j = 1; j <= b.length; j++) {
1173
+ row[j] = Math.min(
1174
+ prev[j] + 1,
1175
+ row[j - 1] + 1,
1176
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
1177
+ );
1178
+ }
1179
+ prev = row;
1180
+ }
1181
+ return prev[b.length];
1182
+ }
1183
+
1184
+ /**
1185
+ * What to say after "unknown style property": the camelCase spelling of a
1186
+ * kebab-case name, what to write instead of a name with no property here,
1187
+ * or the nearest property names. Empty when none of those finds anything —
1188
+ * a name nothing is near is better left unanswered than guessed at.
1189
+ */
1190
+ function unknownPropertyHint(key) {
1191
+ const camel = key.includes('-')
1192
+ ? key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
1193
+ : key;
1194
+ if (camel !== key && STYLE_PROPS.has(camel)) {
1195
+ return ` — style names are camelCase: ${camel}`;
1196
+ }
1197
+ if (Object.hasOwn(INSTEAD, camel)) return ` — ${INSTEAD[camel]}`;
1198
+ const lower = camel.toLowerCase();
1199
+ const reach = camel.length <= 5 ? 1 : 2;
1200
+ const near = [];
1201
+ for (const name of STYLE_PROPS) {
1202
+ if (Math.abs(name.length - camel.length) > reach) continue;
1203
+ const d =
1204
+ name.toLowerCase() === lower
1205
+ ? 0
1206
+ : editDistance(lower, name.toLowerCase());
1207
+ if (d <= reach) near.push([d, name]);
1208
+ }
1209
+ if (near.length === 0) return '';
1210
+ near.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
1211
+ const names = near.slice(0, 3).map(([, name]) => name);
1212
+ return ` — did you mean ${names.join(' or ')}?`;
1213
+ }
1214
+
1215
+ function validateStyle(style, where, nested = false) {
1021
1216
  for (const key of Object.keys(style)) {
1022
1217
  if (isQuery(key)) {
1023
1218
  if (!parseQuery(key)) {
@@ -1029,7 +1224,7 @@ function validateStyle(style, where) {
1029
1224
  `"@supports ${SUPPORTS_FEATURES.join('" / "@supports ')}")`,
1030
1225
  );
1031
1226
  }
1032
- validateStyle(style[key] ?? {}, `${where} ${key}`);
1227
+ validateStyle(style[key] ?? {}, `${where} ${key}`, true);
1033
1228
  continue;
1034
1229
  }
1035
1230
  if (isState(key)) {
@@ -1054,12 +1249,15 @@ function validateStyle(style, where) {
1054
1249
  continue;
1055
1250
  }
1056
1251
  if (!STYLE_PROPS.has(key)) {
1057
- throw new Error(`react-x11: unknown style property "${key}" in ${where}`);
1252
+ throw new Error(
1253
+ `react-x11: unknown style property "${key}" in ${where}` +
1254
+ unknownPropertyHint(key),
1255
+ );
1058
1256
  }
1059
1257
  // The one style value that describes motion rather than a state, so the
1060
1258
  // one whose mistakes are invisible: a loop with a bad `to` does not draw
1061
1259
  // anything wrong, it simply never moves.
1062
- if (key === 'animation') animationsOf(style, where);
1260
+ if (key === 'animation') animationsOf(style, where, 1, PROBE);
1063
1261
  validateValue(key, style[key], where);
1064
1262
  if (key === 'container' && !isContainerDeclaration(style[key])) {
1065
1263
  throw new Error(
@@ -1090,13 +1288,12 @@ function validateStyle(style, where) {
1090
1288
  'this node reads, like { span: 2 })',
1091
1289
  );
1092
1290
  }
1093
- if (key === 'flex' && !isFlexShorthand(style[key])) {
1094
- throw new Error(
1095
- `react-x11: invalid flex ${JSON.stringify(style[key])} in ${where} ` +
1096
- '(expected a number — flex: 1 is flexGrow: 1, flexShrink: 1, ' +
1097
- "flexBasis: 0 — or 'auto' / 'none')",
1098
- );
1099
- }
1291
+ }
1292
+ // what no single value can be wrong about on its own: two line styles on
1293
+ // one box. Once, on the whole style, which is where its blocks are.
1294
+ if (!nested) {
1295
+ const problem = borderProblem(style);
1296
+ if (problem) throw new Error(`${problem}\n in ${where}`);
1100
1297
  }
1101
1298
  }
1102
1299
 
@@ -1216,6 +1413,11 @@ const NOT_ANIMATABLE = new Set([
1216
1413
  // 2.5, is no grid at all, and a track list is a string
1217
1414
  ...GRID_CONTAINER_PROPS,
1218
1415
  ...GRID_ITEM_PROPS,
1416
+ // A shorthand never reaches a running style — the funnel has expanded it
1417
+ // into longhands, which are what animate — so a loop on one would run a
1418
+ // property nobody reads. A `transition` keyed by one still reaches its
1419
+ // longhands (`transitionFor`).
1420
+ ...SHORTHAND_PROPS,
1219
1421
  ]);
1220
1422
 
1221
1423
  export const isAnimatableProp = (name) =>
@@ -1224,13 +1426,22 @@ export const isAnimatableProp = (name) =>
1224
1426
  /**
1225
1427
  * `transition: 150` — every animatable property that changes, over 150ms.
1226
1428
  * `transition: { backgroundColor: 150 }` — only these, with their own
1227
- * durations. Returns a lookup of prop -> ms, or null.
1429
+ * durations. Returns the duration in ms, or 0.
1430
+ *
1431
+ * A duration keyed by a shorthand is its longhands', the nearest shorthand
1432
+ * first, as in CSS: `transition: { border: 150 }` fades a `borderColor`
1433
+ * that `border: '1px solid …'` set, and `{ padding: 200 }` moves each side a
1434
+ * `padding: '8px 16px'` expanded into. The longhand's own entry wins.
1228
1435
  */
1229
1436
  export function transitionFor(style, prop) {
1230
1437
  const t = style.transition;
1231
1438
  if (t == null || !isAnimatableProp(prop)) return 0;
1232
1439
  if (typeof t === 'number') return t;
1233
- return t[prop] ?? 0;
1440
+ if (t[prop] != null) return t[prop];
1441
+ for (const shorthand of SHORTHANDS_OF.get(prop) ?? []) {
1442
+ if (t[shorthand] != null) return t[shorthand];
1443
+ }
1444
+ return 0;
1234
1445
  }
1235
1446
 
1236
1447
  /**
@@ -1305,6 +1516,22 @@ const parsedAnimations = new WeakMap();
1305
1516
  /** What one property's loop may say. */
1306
1517
  const LOOP_OPTIONS = ['from', 'to', 'duration', 'easing', 'alternate', 'delay'];
1307
1518
 
1519
+ /**
1520
+ * One end of a loop on a length, read the way the style's own value is:
1521
+ * `'12px'` travels as 12. A percentage stays one, since two of them have a
1522
+ * midpoint of their own; a value the grammar refuses is left as written, for
1523
+ * the midpoint check to name.
1524
+ */
1525
+ function loopEnd(prop, value, units) {
1526
+ if (!LENGTH_FORMS.has(prop) || typeof value !== 'string') return value;
1527
+ if (isToken(value) || mentionsToken(value)) return value;
1528
+ try {
1529
+ return readLength(prop, value, undefined, units);
1530
+ } catch {
1531
+ return value;
1532
+ }
1533
+ }
1534
+
1308
1535
  function parseAnimation(spec, where) {
1309
1536
  if (typeof spec !== 'object' || spec === null || Array.isArray(spec)) {
1310
1537
  throw new Error(
@@ -1385,7 +1612,12 @@ function parseAnimation(spec, where) {
1385
1612
  * and both ends checked for a midpoint. Null when there are none, so the
1386
1613
  * common case allocates nothing.
1387
1614
  */
1388
- export function animationsOf(style, where = 'a style', scale = 1) {
1615
+ export function animationsOf(
1616
+ style,
1617
+ where = 'a style',
1618
+ scale = 1,
1619
+ units = null,
1620
+ ) {
1389
1621
  const spec = style.animation;
1390
1622
  if (spec == null) return null;
1391
1623
  let parsed = parsedAnimations.get(spec);
@@ -1395,6 +1627,16 @@ export function animationsOf(style, where = 'a style', scale = 1) {
1395
1627
  }
1396
1628
  if (parsed.length === 0) return null;
1397
1629
  return parsed.map((entry) => {
1630
+ // The declared ends, read the way the style's own value is: `'12px'`
1631
+ // travels as 12, and a relative length — `'2em'`, `'50vw'` — as what
1632
+ // it comes to for this node, which is why it is read here rather than
1633
+ // in the parse the declaration is cached by.
1634
+ if (LENGTH_FORMS.has(entry.prop)) {
1635
+ const from = loopEnd(entry.prop, entry.from, units);
1636
+ const to = loopEnd(entry.prop, entry.to, units);
1637
+ if (from !== entry.from || to !== entry.to)
1638
+ entry = { ...entry, from, to };
1639
+ }
1398
1640
  // The display scale, only where the caller says the style is already in
1399
1641
  // device pixels (`_syncLoops` passes the node's). The declared ends are
1400
1642
  // logical like everything an app writes; a `from` *defaulted* from the
@@ -1407,7 +1649,10 @@ export function animationsOf(style, where = 'a style', scale = 1) {
1407
1649
  ...(typeof entry.to === 'number' && { to: entry.to * scale }),
1408
1650
  };
1409
1651
  }
1410
- const from = entry.from === undefined ? style[entry.prop] : entry.from;
1652
+ const from =
1653
+ entry.from === undefined
1654
+ ? loopEnd(entry.prop, style[entry.prop], units)
1655
+ : entry.from;
1411
1656
  if (from === undefined) {
1412
1657
  throw new Error(
1413
1658
  `react-x11: animation for "${entry.prop}" in ${where} has no "from" ` +
@@ -1420,6 +1665,15 @@ export function animationsOf(style, where = 'a style', scale = 1) {
1420
1665
  // resolves the tokens and comes back through here with real values.
1421
1666
  const resolved = from === entry.from ? entry : { ...entry, from };
1422
1667
  if (unresolvedValue(from) || unresolvedValue(entry.to)) return resolved;
1668
+ if (isCurrentColor(from) || isCurrentColor(entry.to)) {
1669
+ throw new Error(
1670
+ `react-x11: animation for "${entry.prop}" in ${where} runs to or ` +
1671
+ 'from currentColor, which a loop cannot hold still: its ends are ' +
1672
+ 'fixed for as long as it runs, and currentColor is whatever the ink ' +
1673
+ 'is. Name the colour — or loop `color` instead, and the ' +
1674
+ `${entry.prop} written as currentColor follows it every frame.`,
1675
+ );
1676
+ }
1423
1677
  if (interpolate(from, entry.to, 0.5) === null) {
1424
1678
  throw new Error(
1425
1679
  `react-x11: animation for "${entry.prop}" in ${where} has no ` +
@@ -1661,14 +1915,23 @@ export const TRANSITION_CONTROL_POINTS = EASING_CONTROL_POINTS['ease-out'];
1661
1915
  * hoisted style. It is the same substitution either way, so the value that
1662
1916
  * *is* a token keeps its fast path and the value that *mentions* one goes
1663
1917
  * through the regexp.
1918
+ *
1919
+ * A whole token is `$name` and nothing else. A value that only *starts* with
1920
+ * one is a mention — `padding: '$gutter 0'`, `border: '$hairline solid
1921
+ * $border'` — which the shorthands made ordinary; reading it as one token
1922
+ * looked up a theme entry called `gutter 0`.
1664
1923
  */
1665
- const isToken = (v) => typeof v === 'string' && v.charCodeAt(0) === 36; /* $ */
1924
+ const WHOLE_TOKEN = /^\$[A-Za-z_][A-Za-z0-9_-]*$/;
1925
+ const isToken = (v) =>
1926
+ typeof v === 'string' &&
1927
+ v.charCodeAt(0) === 36 /* $ */ &&
1928
+ WHOLE_TOKEN.test(v);
1666
1929
  /** `$name`, anywhere in a string. Deliberately the same grammar as a whole
1667
1930
  * token, so `'$accent'` and `'linear-gradient($accent, #000)'` cannot
1668
1931
  * disagree about what a name is. */
1669
1932
  const TOKEN_IN_VALUE = /\$[A-Za-z_][A-Za-z0-9_-]*/g;
1670
1933
  const mentionsToken = (v) =>
1671
- typeof v === 'string' && v.charCodeAt(0) !== 36 && v.includes('$');
1934
+ typeof v === 'string' && v.includes('$') && !isToken(v);
1672
1935
 
1673
1936
  /** The nested blocks a token can hide in, one level down: a state block or
1674
1937
  * a query block, whose values are style values, and `animation`, whose
@@ -1847,11 +2110,16 @@ function unknownToken(token, theme, where) {
1847
2110
  export { validateStyle };
1848
2111
 
1849
2112
  /**
1850
- * The style the layout is actually run from: the `flex` shorthand expanded,
1851
- * and the defaults `overflow: 'scroll'` implies folded in. Both are things a
2113
+ * The style the layout is actually run from: the shorthands expanded into
2114
+ * their longhands, every length read into a number (src/shorthands.js) — a
2115
+ * relative one against `units`, the node's font sizes and window, which
2116
+ * records what it read so the node can resolve it again when one moves
2117
+ * (nodes/styling.js) — and the defaults `overflow: 'scroll'` implies folded
2118
+ * in. All three are things a
1852
2119
  * style *means* rather than things it says, and doing them here — once, on
1853
- * the resolved object — is what lets everything downstream stay a flat diff
1854
- * of yoga properties.
2120
+ * the resolved object, after the state blocks and the queries have merged
2121
+ * into it — is what lets everything downstream stay a flat diff of yoga
2122
+ * properties, and the display scale multiply numbers only.
1855
2123
  *
1856
2124
  * Returns `style` itself when there is nothing to add, so identity comparisons
1857
2125
  * upstream keep meaning "the style did not change".
@@ -1870,28 +2138,25 @@ export { validateStyle };
1870
2138
  * the window got. Zeroing the basis fixes the whole ancestor chain at once,
1871
2139
  * since the content stops counting towards any of their heights.
1872
2140
  */
1873
- export function resolveComputedStyle(style) {
2141
+ export function resolveComputedStyle(style, units = null) {
2142
+ const expands = needsExpanding(style);
1874
2143
  const scrolls = style.overflow === 'scroll';
1875
- const loops = style.animation == null ? null : animationsOf(style);
1876
- if (style.flex === undefined && !scrolls && !loops) return style;
1877
- const out = {};
1878
- if (style.flex !== undefined) {
1879
- const expansion =
1880
- typeof style.flex === 'number'
1881
- ? { flexGrow: style.flex, flexShrink: 1, flexBasis: 0 }
1882
- : FLEX_SHORTHAND[style.flex];
1883
- if (!expansion) {
1884
- throw new Error(
1885
- `react-x11: invalid flex ${JSON.stringify(style.flex)} ` +
1886
- "(expected a number, 'auto' or 'none')",
1887
- );
1888
- }
1889
- Object.assign(out, expansion);
1890
- }
1891
- // after the expansion, so a longhand written beside the shorthand wins
1892
- for (const key of Object.keys(style)) {
1893
- if (key !== 'flex') out[key] = style[key];
1894
- }
2144
+ if (!expands && !scrolls && style.animation == null) return style;
2145
+ // `units` may be a function that makes the context, which is then made
2146
+ // only for a style that has a length to read or a loop to resolve: a
2147
+ // style written in pixels — nearly every one — allocates none
2148
+ const ctx =
2149
+ typeof units === 'function'
2150
+ ? expands || style.animation != null
2151
+ ? units(style)
2152
+ : null
2153
+ : units;
2154
+ // the loops read the expanded style, so a `from` they default to is the
2155
+ // longhand's value, in pixels, whatever the style wrote it as
2156
+ const out = expands ? expandStyle(style, ctx) : { ...style };
2157
+ const loops =
2158
+ out.animation == null ? null : animationsOf(out, 'a style', 1, ctx);
2159
+ if (!expands && !scrolls && !loops) return style;
1895
2160
  if (scrolls) {
1896
2161
  if (out.minWidth === undefined) out.minWidth = 0;
1897
2162
  if (out.minHeight === undefined) out.minHeight = 0;
@@ -1923,51 +2188,12 @@ export function resolveComputedStyle(style) {
1923
2188
  * factors are ratios, `opacity` and `zIndex` are not lengths, and
1924
2189
  * `boxShadow` is a string whose lengths are scaled where it is parsed
1925
2190
  * (src/decorations.js), because its parse is memoized on the raw string.
2191
+ *
2192
+ * The keys of `LENGTH_FORMS` (src/shorthands.js): a property is scaled
2193
+ * exactly when it is one the length grammar reads, because those are the
2194
+ * same question asked twice — and two lists of it drifted.
1926
2195
  */
1927
- export const SCALED_LENGTH_PROPS = [
1928
- 'width',
1929
- 'height',
1930
- 'minWidth',
1931
- 'minHeight',
1932
- 'maxWidth',
1933
- 'maxHeight',
1934
- 'flexBasis',
1935
- 'top',
1936
- 'right',
1937
- 'bottom',
1938
- 'left',
1939
- 'start',
1940
- 'end',
1941
- 'margin',
1942
- 'marginTop',
1943
- 'marginRight',
1944
- 'marginBottom',
1945
- 'marginLeft',
1946
- 'marginStart',
1947
- 'marginEnd',
1948
- 'padding',
1949
- 'paddingTop',
1950
- 'paddingRight',
1951
- 'paddingBottom',
1952
- 'paddingLeft',
1953
- 'paddingStart',
1954
- 'paddingEnd',
1955
- 'gap',
1956
- 'rowGap',
1957
- 'columnGap',
1958
- 'borderWidth',
1959
- 'borderTopWidth',
1960
- 'borderRightWidth',
1961
- 'borderBottomWidth',
1962
- 'borderLeftWidth',
1963
- 'borderStartWidth',
1964
- 'borderEndWidth',
1965
- 'borderRadius',
1966
- 'outlineWidth',
1967
- 'outlineOffset',
1968
- 'fontSize',
1969
- 'letterSpacing',
1970
- ];
2196
+ export const SCALED_LENGTH_PROPS = Object.freeze([...LENGTH_FORMS.keys()]);
1971
2197
 
1972
2198
  const SCALED_LENGTHS = new Set(SCALED_LENGTH_PROPS);
1973
2199
 
@@ -1979,9 +2205,11 @@ const SCALED_LENGTHS = new Set(SCALED_LENGTH_PROPS);
1979
2205
  * (`_syncStyle` → `resolveComputedStyle` → here → `_retarget`), so yoga,
1980
2206
  * every `this.style.borderRadius ?? 0` at a paint site, and the font size
1981
2207
  * the text stack shapes at are all *already* device pixels and none of them
1982
- * ever multiplies again. Numbers scale; `'50%'`, `'auto'` and every other
1983
- * string mean the same thing at any density and pass through; `hitSlop`
1984
- * scales inside its number-or-per-side shape.
2208
+ * ever multiplies again. Numbers scale — and every length is a number by
2209
+ * now, since `resolveComputedStyle` read `'12px'` into 12, which is what
2210
+ * keeps a px string the size it says at 2x; `'50%'` and `'auto'` mean the
2211
+ * same thing at any density and pass through; `hitSlop` scales inside its
2212
+ * number-or-per-side shape.
1985
2213
  *
1986
2214
  * Never mutates: `flattenStyle` hands back the app's own hoisted object
1987
2215
  * when it can, and scaling it in place would corrupt the next render.
@@ -2344,13 +2572,6 @@ const TEXT_VALUE_CHECKS = new Map([
2344
2572
  'fontFamily',
2345
2573
  [isFontFamily, "a family's name, like 'Inter' or 'monospace'"],
2346
2574
  ],
2347
- [
2348
- 'lineHeight',
2349
- [
2350
- (v) => lineHeightOf(v) !== undefined,
2351
- "a multiple of the font's own line height, like lineHeight: 1.5",
2352
- ],
2353
- ],
2354
2575
  ]);
2355
2576
 
2356
2577
  /** Resolved text style (TextLayout base style) from props + inherited. */
@@ -2488,6 +2709,24 @@ export function resolveBorderColors(style, direction) {
2488
2709
  return sides;
2489
2710
  }
2490
2711
 
2712
+ /**
2713
+ * A box's corner radius in the pixels its rect is in: a number as it is, and
2714
+ * a percentage of the box's **shorter side** — the one circle a corner here
2715
+ * can be, where CSS's would be an ellipse taking each side's share — so
2716
+ * `'50%'` is a circle on a square and a pill on anything longer. Read where
2717
+ * the corner is drawn and where its damage is measured, from the rect the
2718
+ * box has then; every reader of a radius goes through here, since a
2719
+ * percentage reaching a `> 0` test is a square box.
2720
+ */
2721
+ export function radiusOf(style, rect) {
2722
+ const r = style?.borderRadius;
2723
+ if (typeof r === 'number') return r;
2724
+ if (typeof r !== 'string' || !rect || !r.trimEnd().endsWith('%')) return 0;
2725
+ const share = Number.parseFloat(r);
2726
+ if (!Number.isFinite(share)) return 0;
2727
+ return (Math.min(rect.width, rect.height) * share) / 100;
2728
+ }
2729
+
2491
2730
  export function resolveHitSlop(value) {
2492
2731
  if (value == null) return null;
2493
2732
  if (typeof value === 'number') {