@inkra/react 0.10.0 → 0.11.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
@@ -487,16 +487,37 @@ shrinking values and sign changes expand or collapse the affected columns.
487
487
  Grouping, signs, currency and compact units remain formatted text, not digit
488
488
  reels. `rollDirection="auto"` rolls up for increasing values and down for
489
489
  decreasing values; `up` or `down` fixes the direction. `duration` and `easing`
490
- apply to both modes. The default `animation="count"` retains value interpolation.
490
+ apply to all modes. The default `animation="count"` retains value interpolation.
491
491
  `renderValue` takes precedence over the built-in reels. Changing the formatting
492
492
  or animation mode settles immediately, as do disabled/reduced motion, invalid
493
493
  durations, and transitions with more than 128 formatted tokens. Exact strings
494
- and bigint values retain their original precision in either mode.
494
+ and bigint values retain their original precision in every mode.
495
495
 
496
496
  ```tsx
497
497
  <NumberTicker value={amount} animation="roll" rollDirection="auto" duration={650} />
498
498
  ```
499
499
 
500
+ Use `animation="morph"` to preserve digit identities during exact nonzero,
501
+ same-sign multiplication or division by a power of ten. For example, `1 → 100`
502
+ moves the existing `1` left and reveals `00` from the right; `12.34 → 123.4`
503
+ preserves all four digits while the decimal moves. The displayed significand and
504
+ decimal shift must agree with the exact values after formatting. Other changes,
505
+ including carries, sign changes, unit changes and exponent notation, use
506
+ place-aligned vertical rolling. This mode does not search for arbitrary matching
507
+ digits or reorder repeated glyphs.
508
+
509
+ Glyph widths stay fixed: motion uses position, clipping and a smooth opacity
510
+ fade rather than stretching text. Rapid updates continue from visible positions
511
+ and opacity. Keep the caller's container right-aligned to hold the numeric right
512
+ edge fixed as its width changes, and allow enough space for the formatted value;
513
+ the animation is clipped within its number area. The default duration is 420ms.
514
+
515
+ ```tsx
516
+ <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
517
+ <NumberTicker value={amount} animation="morph" duration={420} />
518
+ </div>
519
+ ```
520
+
500
521
  Set `compact` to follow `locale` (Chinese units for `zh`, English units otherwise),
501
522
  or use `compact="en"` / `compact="zh"` to select a unit table explicitly.
502
523
  English uses this library's short-scale suffix convention: K, M, B, T, Qa, Qi,
@@ -570,7 +591,10 @@ technology; decorative affixes are not announced twice.
570
591
 
571
592
  `Log` renders caller-ordered records with stable ids, optional level filters and
572
593
  pause/follow controls. It follows only append updates while the viewport remains
573
- near the bottom. The named scroll region supports Home/End and intentionally is
594
+ near the bottom. When scrolled away or paused, a localized icon button floats
595
+ at the viewport's bottom end corner without adding a footer row. Clicking it
596
+ returns focus to the scroll region and resumes following or jumps to the latest
597
+ record. The named scroll region supports Home/End and intentionally is
574
598
  not a live region, so a long stream is not repeatedly announced. Log does not
575
599
  provide persistence, virtualization or transport.
576
600
 
package/dist/log/index.js CHANGED
@@ -159,10 +159,11 @@ export const Log = forwardRef(function Log({ 'aria-label': ariaLabel, className,
159
159
  return _jsxs("li", { className: "inkra-log__item", "data-has-icon": item.icon != null || undefined, "data-level": item.level, "data-tone": tone, children: [index > 0 ? _jsx(PencilLine, { className: "inkra-log__item-divider", seed: `inkra-log-item-${item.id}-divider` }) : null, item.icon == null ? null : _jsx("span", { "aria-hidden": "true", className: "inkra-log__icon", children: item.icon }), _jsxs("div", { className: "inkra-log__record", children: [item.time == null && item.level == null ? null : _jsxs("div", { className: "inkra-log__meta", children: [item.time == null ? null : item.dateTime
160
160
  ? _jsx("time", { dateTime: item.dateTime, children: renderText(item.time) })
161
161
  : _jsx("span", { children: renderText(item.time) }), item.level == null ? null : _jsx("span", { className: "inkra-log__level", children: renderText(definition?.label ?? item.level) })] }), _jsx("div", { className: "inkra-log__content", children: renderText(item.content) })] })] }, item.id);
162
- }) }) : _jsx("div", { className: "inkra-log__empty", children: renderText(isEmpty ? (empty ?? labels.empty) : (noMatches ?? labels.noMatches)) }) })] }), showLatest ? _jsxs("div", { className: "inkra-log__latest", children: [_jsx(PencilLine, { className: "inkra-log__latest-divider", seed: "inkra-log-latest-divider" }), _jsx(Button, { size: "sm", startIcon: _jsx(PencilIcon, { paths: followPaths, size: 18, sourceSize: 24 }), variant: "secondary", onClick: () => {
163
- if (resolvedPaused)
164
- changePaused(false);
165
- else
166
- scrollToLatest();
167
- }, children: resolvedPaused ? labels.resume : labels.latest })] }) : null] }));
162
+ }) }) : _jsx("div", { className: "inkra-log__empty", children: renderText(isEmpty ? (empty ?? labels.empty) : (noMatches ?? labels.noMatches)) }) }), showLatest ? _jsx("div", { className: "inkra-log__latest", children: _jsx(Button, { "aria-label": resolvedPaused ? labels.resume : labels.latest, "data-tooltip": resolvedPaused ? labels.resume : labels.latest, title: resolvedPaused ? labels.resume : labels.latest, size: "icon-sm", variant: "secondary", onClick: () => {
163
+ viewportRef.current?.focus({ preventScroll: true });
164
+ if (resolvedPaused)
165
+ changePaused(false);
166
+ else
167
+ scrollToLatest();
168
+ }, children: _jsx(PencilIcon, { paths: followPaths, size: 20, sourceSize: 24 }) }) }) : null] })] }));
168
169
  });
@@ -0,0 +1,7 @@
1
+ import type { MorphMotion } from './morph-motion.js';
2
+ import type { RollingToken } from './rolling.js';
3
+ export declare function MorphValue({ tokens, motion, progress }: {
4
+ tokens: RollingToken[];
5
+ motion: MorphMotion | null;
6
+ progress: number;
7
+ }): import("react").JSX.Element;
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { PencilText } from '../PencilText.js';
3
+ import { sampleMorphGlyph } from './morph-motion.js';
4
+ export function MorphValue({ tokens, motion, progress }) {
5
+ return _jsx("span", { className: "inkra-number-ticker__morph", dir: "ltr", "data-transition": motion?.kind ?? 'idle', style: motion ? { width: `${progress >= 1 ? motion.targetWidth : motion.width}ch` } : undefined, children: motion ? motion.glyphs.filter(glyph => progress < 1 || glyph.target).map(glyph => {
6
+ const state = sampleMorphGlyph(glyph, progress);
7
+ return _jsx("span", { className: "inkra-number-ticker__morph-glyph", "data-token-id": glyph.id, "data-place": glyph.referenceKey, "data-digit": glyph.digit || undefined, "data-face": glyph.target ? 'to' : 'from', style: { position: 'absolute', left: '100%', width: `${glyph.width}ch`,
8
+ transform: `translate(${state.x / glyph.width * 100}%, ${state.y * 100}%)`, opacity: state.opacity }, children: _jsx(PencilText, { seed: `ticker-${glyph.id}-${glyph.text}`, children: glyph.text }) }, glyph.id);
9
+ }) : tokens.map(token => _jsx("span", { className: "inkra-number-ticker__morph-glyph", "data-token-id": `settled-${token.key}`, "data-place": token.key, "data-digit": token.digit || undefined, "data-face": "to", style: token.digit ? { width: '1ch' } : undefined, children: _jsx(PencilText, { seed: `ticker-settled-${token.key}-${token.text}`, children: token.text }) }, `settled-${token.key}`)) });
10
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ComponentPropsWithoutRef, ReactNode } from 'react';
2
2
  import type { NumberTickerCompact, NumberTickerCompactOptions, NumberTickerFormatOptions, NumberTickerLocale, NumberTickerValue } from './model.js';
3
- export type NumberTickerAnimation = 'count' | 'roll';
3
+ export type NumberTickerAnimation = 'count' | 'roll' | 'morph';
4
4
  export type NumberTickerRollDirection = 'auto' | 'up' | 'down';
5
5
  export interface NumberTickerRenderDetails {
6
6
  animating: boolean;
@@ -7,6 +7,9 @@ import { canAnimateTickerValue, createNumberFormatter, createTickerFormatter, ea
7
7
  import { ExactDecimal } from './precision.js';
8
8
  import { RollingValue } from './RollingValue.js';
9
9
  import { createRollingMotion, rollingTokens, sameRollingTokens } from './rolling.js';
10
+ import { MorphValue } from './MorphValue.js';
11
+ import { morphTokenMapping } from './morph.js';
12
+ import { createMorphMotion, measureMorphWidths } from './morph-motion.js';
10
13
  function usePrefersReducedMotion() {
11
14
  const [reduced, setReduced] = useState(false);
12
15
  useBrowserLayoutEffect(() => {
@@ -41,6 +44,8 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
41
44
  const [roll, setRoll] = useState(null);
42
45
  const [rollProgress, setRollProgress] = useState(1);
43
46
  const rollFrameRef = useRef(null);
47
+ const [morph, setMorph] = useState(null);
48
+ const morphFrameRef = useRef(null);
44
49
  const reducedMotion = usePrefersReducedMotion();
45
50
  const localeKey = Array.isArray(locale) ? locale.join('\u0000') : locale;
46
51
  const formatOptionsKey = JSON.stringify(formatOptions ?? {});
@@ -51,12 +56,18 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
51
56
  const announcementFormatter = useMemo(() => createNumberFormatter(locale, compact ? { ...formatOptions, notation: 'standard' } : formatOptions), [localeKey, formatOptionsKey, compact]);
52
57
  useImperativeHandle(forwardedRef, () => rootRef.current);
53
58
  const rollRequested = animation === 'roll' && !renderValue;
54
- const finalTokens = useMemo(() => rollRequested ? rollingTokens(formatter.formatToParts(value)) : [], [formatter, value, rollRequested]);
59
+ const morphRequested = animation === 'morph' && !renderValue;
60
+ const finalParts = useMemo(() => rollRequested || morphRequested ? formatter.formatToParts(value) : [], [formatter, value, rollRequested, morphRequested]);
61
+ const finalTokens = useMemo(() => rollingTokens(finalParts), [finalParts]);
55
62
  // Keep exact large values readable without creating thousands of painted reels.
56
63
  const useRoll = rollRequested && finalTokens.length <= 128;
64
+ const useMorph = morphRequested && finalTokens.length <= 128;
65
+ const useTokens = useRoll || useMorph;
57
66
  const settledTokensRef = useRef(finalTokens);
67
+ const morphPartsRef = useRef(finalParts);
58
68
  const previousFormatterRef = useRef(formatter);
59
69
  const previousUseRollRef = useRef(useRoll);
70
+ const previousAnimationRef = useRef(animation);
60
71
  const columns = useMemo(() => roll?.columns ?? createRollingMotion(finalTokens, finalTokens, 'up'), [roll, finalTokens]);
61
72
  useBrowserLayoutEffect(() => {
62
73
  const previousTarget = targetRef.current;
@@ -87,12 +98,13 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
87
98
  const from = displayedRef.current;
88
99
  const formatChanged = previousFormatterRef.current !== formatter;
89
100
  previousFormatterRef.current = formatter;
90
- const modeChanged = previousUseRollRef.current !== useRoll;
101
+ const modeChanged = previousUseRollRef.current !== useRoll || previousAnimationRef.current !== animation;
91
102
  previousUseRollRef.current = useRoll;
103
+ previousAnimationRef.current = animation;
92
104
  if (!animate || reduceImmediately || formatChanged || modeChanged
93
- || (useRoll && !rollFrameRef.current && sameRollingTokens(settledTokensRef.current, finalTokens))
94
- || (rollRequested && (!useRoll || settledTokensRef.current.length > 128))
95
- || !canAnimateTickerValue(from, value, duration)) {
105
+ || (useTokens && !rollFrameRef.current && (!morphFrameRef.current || morphFrameRef.current.progress >= 1) && sameRollingTokens(settledTokensRef.current, finalTokens))
106
+ || ((rollRequested || morphRequested) && (!useTokens || settledTokensRef.current.length > 128))
107
+ || (!canAnimateTickerValue(from, value, duration) && !(useMorph && canAnimateTickerValue(previousTarget, value, duration)))) {
96
108
  displayedRef.current = value;
97
109
  setDisplayedValue(value);
98
110
  setAnnouncedValue(value);
@@ -101,9 +113,44 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
101
113
  setRollProgress(1);
102
114
  settledTokensRef.current = finalTokens;
103
115
  rollFrameRef.current = null;
116
+ setMorph(null);
117
+ morphFrameRef.current = null;
118
+ morphPartsRef.current = finalParts;
104
119
  return undefined;
105
120
  }
106
- if (useRoll) {
121
+ if (useMorph) {
122
+ const direction = rollDirection === 'auto'
123
+ ? (new ExactDecimal(String(value)).gt(String(previousTarget)) ? 'up' : 'down') : rollDirection;
124
+ const previousTokens = morphFrameRef.current?.motion.tokens ?? settledTokensRef.current;
125
+ const zeroPart = formatter.formatToParts(0).find(part => part.type === 'integer')?.value ?? '0';
126
+ const zero = Array.from(zeroPart).slice(-1)[0] ?? '0';
127
+ const mapping = morphTokenMapping(morphPartsRef.current, finalParts, previousTarget, value, zero);
128
+ const motion = createMorphMotion(previousTokens, finalTokens, mapping, direction, measureMorphWidths(rootRef.current, [...previousTokens, ...finalTokens]), morphFrameRef.current ?? undefined);
129
+ // Bound interrupted layers as well as formatted target token counts.
130
+ if (motion.glyphs.length > 512) {
131
+ displayedRef.current = value;
132
+ setDisplayedValue(value);
133
+ setAnnouncedValue(value);
134
+ setAnimating(false);
135
+ setMorph(null);
136
+ morphFrameRef.current = null;
137
+ morphPartsRef.current = finalParts;
138
+ settledTokensRef.current = finalTokens;
139
+ setRoll(null);
140
+ rollFrameRef.current = null;
141
+ setRollProgress(1);
142
+ return undefined;
143
+ }
144
+ setMorph(motion);
145
+ morphFrameRef.current = { motion, progress: 0 };
146
+ morphPartsRef.current = finalParts;
147
+ setRoll(null);
148
+ rollFrameRef.current = null;
149
+ setRollProgress(0);
150
+ }
151
+ else if (useRoll) {
152
+ setMorph(null);
153
+ morphFrameRef.current = null;
107
154
  const direction = rollDirection === 'auto'
108
155
  ? (new ExactDecimal(String(value)).gt(String(from)) ? 'up' : 'down') : rollDirection;
109
156
  const nextColumns = createRollingMotion(settledTokensRef.current, finalTokens, direction, rollFrameRef.current ?? undefined);
@@ -114,6 +161,8 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
114
161
  else {
115
162
  setRoll(null);
116
163
  rollFrameRef.current = null;
164
+ setMorph(null);
165
+ morphFrameRef.current = null;
117
166
  }
118
167
  let startTime = null;
119
168
  setAnimating(true);
@@ -125,10 +174,12 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
125
174
  const bounded = Number.isFinite(eased) ? Math.min(1, Math.max(0, eased)) : progress;
126
175
  const next = interpolateTickerValue(from, value, bounded);
127
176
  displayedRef.current = next;
128
- if (useRoll) {
177
+ if (useTokens) {
129
178
  setRollProgress(bounded);
130
179
  if (rollFrameRef.current)
131
180
  rollFrameRef.current.progress = bounded;
181
+ if (morphFrameRef.current)
182
+ morphFrameRef.current.progress = bounded;
132
183
  }
133
184
  else
134
185
  setDisplayedValue(next);
@@ -145,6 +196,8 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
145
196
  setRollProgress(1);
146
197
  settledTokensRef.current = finalTokens;
147
198
  rollFrameRef.current = null;
199
+ if (morphFrameRef.current)
200
+ morphFrameRef.current.progress = 1;
148
201
  }
149
202
  };
150
203
  frameRef.current = requestAnimationFrame(tick);
@@ -154,7 +207,7 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
154
207
  frameRef.current = null;
155
208
  }
156
209
  };
157
- }, [animate, deltaDuration, duration, easing, reducedMotion, showDelta, value, useRoll, rollRequested, rollDirection, formatter]);
210
+ }, [animate, deltaDuration, duration, easing, reducedMotion, showDelta, value, useRoll, useMorph, useTokens, rollRequested, morphRequested, rollDirection, formatter, animation]);
158
211
  useEffect(() => () => {
159
212
  if (frameRef.current !== null)
160
213
  cancelAnimationFrame(frameRef.current);
@@ -168,9 +221,10 @@ export const NumberTicker = forwardRef(function NumberTicker({ animate = true, a
168
221
  .filter(Boolean).join(' ');
169
222
  const accessibleLabel = getValueLabel?.(announcedValue, announcedFormattedValue) ?? defaultAccessibleLabel;
170
223
  const valueContent = renderValue?.(displayedValue, { animating, formattedValue })
171
- ?? (useRoll ? _jsx(RollingValue, { columns: columns, progress: roll ? rollProgress : 1, direction: roll?.direction ?? 'up' }) : animating
172
- ? _jsx("span", { className: "inkra-number-ticker__native-value", children: formattedValue })
173
- : _jsx(PencilText, { seed: `number-ticker-${finalFormattedValue}`, children: finalFormattedValue }));
224
+ ?? (useMorph ? _jsx(MorphValue, { tokens: finalTokens, motion: morph, progress: morph ? rollProgress : 1 })
225
+ : useRoll ? _jsx(RollingValue, { columns: columns, progress: roll ? rollProgress : 1, direction: roll?.direction ?? 'up' }) : animating
226
+ ? _jsx("span", { className: "inkra-number-ticker__native-value", children: formattedValue })
227
+ : _jsx(PencilText, { seed: `number-ticker-${finalFormattedValue}`, children: finalFormattedValue }));
174
228
  const deltaContent = delta === null ? null : (() => {
175
229
  const formattedDelta = formatDerivedTickerDelta(deltaFormatter, delta);
176
230
  const details = {
@@ -0,0 +1,46 @@
1
+ import type { RollingToken } from './rolling.js';
2
+ export interface MorphGlyph {
3
+ id: string;
4
+ referenceKey: string;
5
+ text: string;
6
+ digit: boolean;
7
+ width: number;
8
+ target: boolean;
9
+ fromX: number;
10
+ toX: number;
11
+ fromY: number;
12
+ toY: number;
13
+ fromOpacity: number;
14
+ toOpacity: number;
15
+ }
16
+ export interface MorphMotion {
17
+ glyphs: MorphGlyph[];
18
+ tokens: RollingToken[];
19
+ width: number;
20
+ targetWidth: number;
21
+ kind: 'shift' | 'roll';
22
+ }
23
+ export interface MorphFrame {
24
+ motion: MorphMotion;
25
+ progress: number;
26
+ }
27
+ export type MorphWidths = (token: RollingToken) => number;
28
+ export declare function measureMorphWidths(root: HTMLElement | null, tokens: RollingToken[]): MorphWidths;
29
+ export declare function sampleMorphGlyph(glyph: MorphGlyph, progress: number): {
30
+ x: number;
31
+ y: number;
32
+ opacity: number;
33
+ id: string;
34
+ referenceKey: string;
35
+ text: string;
36
+ digit: boolean;
37
+ width: number;
38
+ target: boolean;
39
+ fromX: number;
40
+ toX: number;
41
+ fromY: number;
42
+ toY: number;
43
+ fromOpacity: number;
44
+ toOpacity: number;
45
+ };
46
+ export declare function createMorphMotion(from: RollingToken[], to: RollingToken[], mapping: Array<[number, number]> | null, direction: 'up' | 'down', widths: MorphWidths, previous?: MorphFrame): MorphMotion;
@@ -0,0 +1,121 @@
1
+ import { rollMix } from './rolling.js';
2
+ let nextGlyph = 0;
3
+ export function measureMorphWidths(root, tokens) {
4
+ if (!root || typeof document === 'undefined')
5
+ return token => token.digit ? 1 : Math.max(.25, Array.from(token.text).length);
6
+ const style = getComputedStyle(root);
7
+ const probe = document.createElement('span');
8
+ Object.assign(probe.style, { position: 'fixed', visibility: 'hidden', pointerEvents: 'none',
9
+ font: style.font, fontVariantNumeric: 'tabular-nums lining-nums', fontKerning: 'none',
10
+ fontVariantLigatures: 'none', letterSpacing: '0', whiteSpace: 'pre', width: '1ch', display: 'inline-block' });
11
+ document.body.append(probe);
12
+ const ch = probe.getBoundingClientRect().width || 1;
13
+ probe.style.width = 'auto';
14
+ const widths = new Map();
15
+ // Measurement happens once when a target changes, never in the frame loop.
16
+ const measure = (token) => {
17
+ if (token.digit)
18
+ return 1;
19
+ if (!widths.has(token.text)) {
20
+ probe.textContent = token.text;
21
+ widths.set(token.text, Math.max(.05, probe.getBoundingClientRect().width / ch));
22
+ }
23
+ return widths.get(token.text);
24
+ };
25
+ // The returned callback cannot retain a live measuring node.
26
+ tokens.forEach(measure);
27
+ const result = token => token.digit ? 1 : widths.get(token.text) ?? Math.max(.25, Array.from(token.text).length);
28
+ probe.remove();
29
+ return result;
30
+ }
31
+ function layout(tokens, widths) {
32
+ const total = tokens.reduce((sum, token) => sum + widths(token), 0);
33
+ let x = -total;
34
+ return { width: total, items: tokens.map(token => {
35
+ const width = widths(token);
36
+ const item = { ...token, x, width };
37
+ x += width;
38
+ return item;
39
+ }) };
40
+ }
41
+ export function sampleMorphGlyph(glyph, progress) {
42
+ const fade = progress * progress * (3 - 2 * progress);
43
+ return { ...glyph, x: rollMix(glyph.fromX, glyph.toX, progress), y: rollMix(glyph.fromY, glyph.toY, progress),
44
+ opacity: rollMix(glyph.fromOpacity, glyph.toOpacity, fade) };
45
+ }
46
+ export function createMorphMotion(from, to, mapping, direction, widths, previous) {
47
+ const before = layout(from, widths), after = layout(to, widths);
48
+ const source = previous ? previous.motion.glyphs.map(glyph => sampleMorphGlyph(glyph, previous.progress))
49
+ .filter(glyph => glyph.opacity > .001 && Math.abs(glyph.y) < 1.001)
50
+ : before.items.map(token => ({ id: `settled-${token.key}`, referenceKey: token.key, text: token.text,
51
+ digit: token.digit, width: token.width, target: true, x: token.x, y: 0, opacity: 1 }));
52
+ const used = new Set();
53
+ const glyphs = [];
54
+ const sign = direction === 'up' ? -1 : 1;
55
+ const pairs = mapping ?? from.flatMap((token, index) => {
56
+ const next = to.findIndex(item => item.key === token.key);
57
+ return next < 0 ? [] : [[index, next]];
58
+ });
59
+ const oldIndexes = new Map(pairs.map(([left, right]) => [right, left]));
60
+ const findSource = (index) => source.filter(item => item.referenceKey === from[index]?.key)
61
+ .sort((left, right) => right.opacity - left.opacity);
62
+ const decimalEnd = (items) => {
63
+ const integers = items.filter(item => item.key.startsWith('integer-'));
64
+ const integer = integers[integers.length - 1];
65
+ return integer ? integer.x + integer.width : 0;
66
+ };
67
+ for (let index = 0; index < to.length; index++) {
68
+ const token = to[index], target = after.items[index];
69
+ const oldIndex = oldIndexes.get(index);
70
+ const candidates = oldIndex === undefined ? [] : findSource(oldIndex);
71
+ const existing = candidates.find(item => item.text === token.text && !used.has(item.id));
72
+ if (existing) {
73
+ used.add(existing.id);
74
+ glyphs.push({ id: existing.id, referenceKey: token.key, text: token.text, digit: token.digit,
75
+ width: existing.width, target: true, fromX: existing.x, toX: target.x,
76
+ fromY: existing.y, toY: 0, fromOpacity: existing.opacity, toOpacity: 1 });
77
+ continue;
78
+ }
79
+ let x = candidates[0]?.x ?? before.items[oldIndex ?? -1]?.x ?? target.x;
80
+ let y = token.digit && !mapping ? -sign : 0;
81
+ if (mapping) {
82
+ if (token.key.startsWith('decimal-'))
83
+ x = decimalEnd(before.items);
84
+ else if (token.digit) {
85
+ const anchor = pairs.filter(([left, right]) => right < index && from[left].digit)
86
+ .sort((left, right) => right[1] - left[1])[0];
87
+ if (anchor) {
88
+ const visible = findSource(anchor[0])[0];
89
+ x = (visible?.x ?? before.items[anchor[0]].x) + target.x - after.items[anchor[1]].x;
90
+ }
91
+ }
92
+ }
93
+ glyphs.push({ id: `morph-${nextGlyph++}`, referenceKey: token.key, text: token.text,
94
+ digit: token.digit, width: target.width, target: true, fromX: x, toX: target.x,
95
+ fromY: y, toY: 0, fromOpacity: mapping || !token.digit ? 0 : 1, toOpacity: 1 });
96
+ }
97
+ for (const item of source) {
98
+ if (used.has(item.id))
99
+ continue;
100
+ let x = item.x;
101
+ let y = item.digit && !mapping ? sign : 0;
102
+ if (mapping) {
103
+ if (item.referenceKey.startsWith('decimal-'))
104
+ x = decimalEnd(after.items);
105
+ else {
106
+ const oldIndex = from.findIndex(token => token.key === item.referenceKey);
107
+ const anchor = pairs.filter(([left]) => left < oldIndex && from[left].digit)
108
+ .sort((left, right) => right[0] - left[0])[0];
109
+ if (anchor) {
110
+ const visible = findSource(anchor[0])[0];
111
+ x += after.items[anchor[1]].x - (visible?.x ?? before.items[anchor[0]].x);
112
+ }
113
+ }
114
+ }
115
+ glyphs.push({ id: item.id, referenceKey: item.referenceKey, text: item.text, digit: item.digit,
116
+ width: item.width, target: false, fromX: item.x, toX: x, fromY: item.y, toY: y,
117
+ fromOpacity: item.opacity, toOpacity: mapping || !item.digit ? 0 : 1 });
118
+ }
119
+ return { glyphs, tokens: to, width: Math.max(before.width, after.width, previous && previous.progress < 1 ? previous.motion.width : 0),
120
+ targetWidth: after.width, kind: mapping ? 'shift' : 'roll' };
121
+ }
@@ -0,0 +1,7 @@
1
+ import type { NumberTickerValue } from './precision.js';
2
+ /**
3
+ * Match an exact decimal shift, rather than guessing identity from equal glyphs.
4
+ * Pair indexes address rollingTokens(parts), including unchanged typed symbols.
5
+ * A null result leaves the transition to the existing place-aligned reels.
6
+ */
7
+ export declare function morphTokenMapping(fromParts: Intl.NumberFormatPart[], toParts: Intl.NumberFormatPart[], fromValue: NumberTickerValue, toValue: NumberTickerValue, zeroGlyph?: string): Array<[number, number]> | null;
@@ -0,0 +1,78 @@
1
+ import { ExactDecimal, tickerDecimal } from './precision.js';
2
+ import { rollingTokens } from './rolling.js';
3
+ const numericParts = new Set(['integer', 'fraction', 'decimal', 'group']);
4
+ const exponentParts = new Set(['exponentSeparator', 'exponentMinusSign', 'exponentInteger']);
5
+ function displayDigits(parts, zeroGlyph) {
6
+ const tokens = rollingTokens(parts);
7
+ const indexes = tokens.flatMap((token, index) => token.digit ? [index] : []);
8
+ const text = indexes.map(index => tokens[index].text);
9
+ const first = text.findIndex(digit => digit !== zeroGlyph);
10
+ if (first < 0)
11
+ return null;
12
+ let last = text.length - 1;
13
+ while (text[last] === zeroGlyph)
14
+ last--;
15
+ const integerLength = parts.filter(part => part.type === 'integer')
16
+ .reduce((length, part) => length + Array.from(part.value).length, 0);
17
+ return { indexes, text, first, last, exponent: integerLength - first - 1 };
18
+ }
19
+ /**
20
+ * Match an exact decimal shift, rather than guessing identity from equal glyphs.
21
+ * Pair indexes address rollingTokens(parts), including unchanged typed symbols.
22
+ * A null result leaves the transition to the existing place-aligned reels.
23
+ */
24
+ export function morphTokenMapping(fromParts, toParts, fromValue, toValue, zeroGlyph = '0') {
25
+ const from = tickerDecimal(fromValue);
26
+ const to = tickerDecimal(toValue);
27
+ if (!from.isFinite() || !to.isFinite() || from.isZero() || to.isZero()
28
+ || from.isNegative() !== to.isNegative())
29
+ return null;
30
+ const shift = to.e - from.e;
31
+ if (shift === 0 || !from.times(new ExactDecimal(10).pow(shift)).eq(to))
32
+ return null;
33
+ // Unit/sign/literal changes are separate semantic transitions. Exponents have
34
+ // their own digit domain and must never be mistaken for mantissa digits.
35
+ if ([...fromParts, ...toParts].some(part => exponentParts.has(part.type)))
36
+ return null;
37
+ const symbols = (parts) => parts.filter(part => !numericParts.has(part.type));
38
+ const fromSymbols = symbols(fromParts);
39
+ const toSymbols = symbols(toParts);
40
+ if (fromSymbols.length !== toSymbols.length || fromSymbols.some((part, index) => {
41
+ const next = toSymbols[index];
42
+ return part.type !== next.type || part.value !== next.value;
43
+ }))
44
+ return null;
45
+ const before = displayDigits(fromParts, zeroGlyph);
46
+ const after = displayDigits(toParts, zeroGlyph);
47
+ if (!before || !after || after.exponent - before.exponent !== shift)
48
+ return null;
49
+ const core = before.text.slice(before.first, before.last + 1);
50
+ const nextCore = after.text.slice(after.first, after.last + 1);
51
+ // Rounding may change the visible significand even for an exact raw shift.
52
+ if (core.length !== nextCore.length || core.some((digit, index) => digit !== nextCore[index]))
53
+ return null;
54
+ const mapping = [];
55
+ for (let index = 0; index < core.length; index++) {
56
+ mapping.push([before.indexes[before.first + index], after.indexes[after.first + index]]);
57
+ }
58
+ // Edge zeros retain their ordinal distance from the significant core. This
59
+ // preserves 10 as the first 10 in 1000 without matching arbitrary repeats.
60
+ for (let offset = 1; offset <= Math.min(before.first, after.first); offset++) {
61
+ mapping.push([before.indexes[before.first - offset], after.indexes[after.first - offset]]);
62
+ }
63
+ const trailing = Math.min(before.text.length - before.last - 1, after.text.length - after.last - 1);
64
+ for (let offset = 1; offset <= trailing; offset++) {
65
+ mapping.push([before.indexes[before.last + offset], after.indexes[after.last + offset]]);
66
+ }
67
+ const fromTokens = rollingTokens(fromParts);
68
+ const toTokens = rollingTokens(toParts);
69
+ const targetSymbols = new Map(toTokens.flatMap((token, index) => token.digit ? [] : [[token.key, { token, index }]]));
70
+ fromTokens.forEach((token, index) => {
71
+ if (token.digit)
72
+ return;
73
+ const next = targetSymbols.get(token.key);
74
+ if (next && next.token.text === token.text)
75
+ mapping.push([index, next.index]);
76
+ });
77
+ return mapping.sort((left, right) => left[0] - right[0]);
78
+ }
@@ -13,8 +13,6 @@
13
13
  }
14
14
 
15
15
  .inkra-log[data-toolbar="true"] { grid-template-rows: auto minmax(var(--inkra-touch-target), 1fr); }
16
- .inkra-log:has(> .inkra-log__latest) { grid-template-rows: minmax(var(--inkra-touch-target), 1fr) auto; }
17
- .inkra-log[data-toolbar="true"]:has(> .inkra-log__latest) { grid-template-rows: auto minmax(var(--inkra-touch-target), 1fr) auto; }
18
16
 
19
17
  .inkra-log__toolbar {
20
18
  align-items: center;
@@ -158,21 +156,15 @@
158
156
  }
159
157
 
160
158
  .inkra-log__latest {
161
- align-items: center;
162
159
  display: flex;
163
- justify-content: flex-end;
164
- max-inline-size: 100%;
165
- min-inline-size: 0;
166
- padding: var(--inkra-space-2) var(--inkra-space-3);
167
- position: relative;
160
+ inset-block-end: clamp(0px, calc(100% - var(--inkra-touch-target)), var(--inkra-space-2));
161
+ inset-inline-end: var(--inkra-space-3);
162
+ position: absolute;
168
163
  z-index: 2;
169
164
  }
170
165
 
171
- .inkra-log__latest-divider {
172
- color: var(--inkra-border);
173
- inset-block-start: -4px;
174
- inset-inline: 0;
175
- position: absolute;
166
+ .inkra-log__latest .inkra-button__surface {
167
+ background: var(--inkra-paper);
176
168
  }
177
169
 
178
170
  @container (max-width: 36rem) {
@@ -188,11 +180,6 @@
188
180
  .inkra-log__follow-control .inkra-button__content,
189
181
  .inkra-log__follow-control .inkra-button__label,
190
182
  .inkra-log__follow-control .inkra-pencil-text__source { min-inline-size: 0; white-space: normal; overflow-wrap: anywhere; }
191
- .inkra-log__latest .inkra-button { inline-size: 100%; }
192
- .inkra-log__latest .inkra-button__surface { height: auto; inline-size: 100%; max-inline-size: 100%; min-height: var(--inkra-button-visual-height); padding-block: var(--inkra-space-2); }
193
- .inkra-log__latest .inkra-button__content,
194
- .inkra-log__latest .inkra-button__label,
195
- .inkra-log__latest .inkra-pencil-text__source { min-inline-size: 0; white-space: normal; overflow-wrap: anywhere; }
196
183
  }
197
184
 
198
185
  @media (forced-colors: active) {
@@ -66,7 +66,48 @@
66
66
  flex: none;
67
67
  }
68
68
 
69
+ .inkra-number-ticker[data-animation="morph"] { min-inline-size: 0; }
70
+
71
+ .inkra-number-ticker[data-animation="morph"] .inkra-number-ticker__value {
72
+ display: block;
73
+ max-inline-size: 100%;
74
+ overflow: hidden;
75
+ }
76
+
77
+ .inkra-number-ticker__morph {
78
+ position: relative;
79
+ display: inline-flex;
80
+ flex: none;
81
+ align-items: baseline;
82
+ justify-content: flex-end;
83
+ max-inline-size: 100%;
84
+ height: 1lh;
85
+ line-height: var(--inkra-line-ui, 1.45);
86
+ overflow: hidden;
87
+ contain: paint;
88
+ unicode-bidi: isolate;
89
+ }
90
+
91
+ .inkra-number-ticker__morph-glyph {
92
+ display: inline-flex;
93
+ flex: none;
94
+ justify-content: center;
95
+ align-items: center;
96
+ height: 1lh;
97
+ white-space: pre;
98
+ }
99
+
100
+ .inkra-number-ticker__morph-glyph > .inkra-pencil-text { flex: none; }
101
+
69
102
  @media (prefers-reduced-motion: reduce), print {
103
+ .inkra-number-ticker__morph { width: auto !important; }
104
+ .inkra-number-ticker__morph-glyph[data-face="from"] { display: none; }
105
+ .inkra-number-ticker__morph-glyph[data-face="to"] {
106
+ position: relative !important;
107
+ left: auto !important;
108
+ transform: none !important;
109
+ opacity: 1 !important;
110
+ }
70
111
  .inkra-number-ticker__column { grid-template-columns: 1fr !important; }
71
112
  .inkra-number-ticker__column[data-digit] { width: 1ch !important; }
72
113
  .inkra-number-ticker__column[data-departing] { display: none; }
package/dist/styles.css CHANGED
@@ -4445,8 +4445,6 @@
4445
4445
  }
4446
4446
 
4447
4447
  .inkra-log[data-toolbar="true"] { grid-template-rows: auto minmax(var(--inkra-touch-target), 1fr); }
4448
- .inkra-log:has(> .inkra-log__latest) { grid-template-rows: minmax(var(--inkra-touch-target), 1fr) auto; }
4449
- .inkra-log[data-toolbar="true"]:has(> .inkra-log__latest) { grid-template-rows: auto minmax(var(--inkra-touch-target), 1fr) auto; }
4450
4448
 
4451
4449
  .inkra-log__toolbar {
4452
4450
  align-items: center;
@@ -4590,21 +4588,15 @@
4590
4588
  }
4591
4589
 
4592
4590
  .inkra-log__latest {
4593
- align-items: center;
4594
4591
  display: flex;
4595
- justify-content: flex-end;
4596
- max-inline-size: 100%;
4597
- min-inline-size: 0;
4598
- padding: var(--inkra-space-2) var(--inkra-space-3);
4599
- position: relative;
4592
+ inset-block-end: clamp(0px, calc(100% - var(--inkra-touch-target)), var(--inkra-space-2));
4593
+ inset-inline-end: var(--inkra-space-3);
4594
+ position: absolute;
4600
4595
  z-index: 2;
4601
4596
  }
4602
4597
 
4603
- .inkra-log__latest-divider {
4604
- color: var(--inkra-border);
4605
- inset-block-start: -4px;
4606
- inset-inline: 0;
4607
- position: absolute;
4598
+ .inkra-log__latest .inkra-button__surface {
4599
+ background: var(--inkra-paper);
4608
4600
  }
4609
4601
 
4610
4602
  @container (max-width: 36rem) {
@@ -4620,11 +4612,6 @@
4620
4612
  .inkra-log__follow-control .inkra-button__content,
4621
4613
  .inkra-log__follow-control .inkra-button__label,
4622
4614
  .inkra-log__follow-control .inkra-pencil-text__source { min-inline-size: 0; white-space: normal; overflow-wrap: anywhere; }
4623
- .inkra-log__latest .inkra-button { inline-size: 100%; }
4624
- .inkra-log__latest .inkra-button__surface { height: auto; inline-size: 100%; max-inline-size: 100%; min-height: var(--inkra-button-visual-height); padding-block: var(--inkra-space-2); }
4625
- .inkra-log__latest .inkra-button__content,
4626
- .inkra-log__latest .inkra-button__label,
4627
- .inkra-log__latest .inkra-pencil-text__source { min-inline-size: 0; white-space: normal; overflow-wrap: anywhere; }
4628
4615
  }
4629
4616
 
4630
4617
  @media (forced-colors: active) {
@@ -4892,7 +4879,48 @@
4892
4879
  flex: none;
4893
4880
  }
4894
4881
 
4882
+ .inkra-number-ticker[data-animation="morph"] { min-inline-size: 0; }
4883
+
4884
+ .inkra-number-ticker[data-animation="morph"] .inkra-number-ticker__value {
4885
+ display: block;
4886
+ max-inline-size: 100%;
4887
+ overflow: hidden;
4888
+ }
4889
+
4890
+ .inkra-number-ticker__morph {
4891
+ position: relative;
4892
+ display: inline-flex;
4893
+ flex: none;
4894
+ align-items: baseline;
4895
+ justify-content: flex-end;
4896
+ max-inline-size: 100%;
4897
+ height: 1lh;
4898
+ line-height: var(--inkra-line-ui, 1.45);
4899
+ overflow: hidden;
4900
+ contain: paint;
4901
+ unicode-bidi: isolate;
4902
+ }
4903
+
4904
+ .inkra-number-ticker__morph-glyph {
4905
+ display: inline-flex;
4906
+ flex: none;
4907
+ justify-content: center;
4908
+ align-items: center;
4909
+ height: 1lh;
4910
+ white-space: pre;
4911
+ }
4912
+
4913
+ .inkra-number-ticker__morph-glyph > .inkra-pencil-text { flex: none; }
4914
+
4895
4915
  @media (prefers-reduced-motion: reduce), print {
4916
+ .inkra-number-ticker__morph { width: auto !important; }
4917
+ .inkra-number-ticker__morph-glyph[data-face="from"] { display: none; }
4918
+ .inkra-number-ticker__morph-glyph[data-face="to"] {
4919
+ position: relative !important;
4920
+ left: auto !important;
4921
+ transform: none !important;
4922
+ opacity: 1 !important;
4923
+ }
4896
4924
  .inkra-number-ticker__column { grid-template-columns: 1fr !important; }
4897
4925
  .inkra-number-ticker__column[data-digit] { width: 1ch !important; }
4898
4926
  .inkra-number-ticker__column[data-departing] { display: none; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inkra/react",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Hand-drawn React controls with native semantics and deterministic decoration.",
5
5
  "type": "module",
6
6
  "license": "MIT",