@svelterm/core 0.23.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.24.0 — 2026-07-05
4
+
5
+ Motion timing completeness: transitions and keyframes behave per spec.
6
+
7
+ ### Added
8
+
9
+ - **Per-property transitions** — `transition: color 150ms linear, width
10
+ 400ms ease` runs each property on its own duration and timing;
11
+ longhand lists pair cyclically per spec. (Previously the first
12
+ duration and one timing function applied to everything.)
13
+ - **Interrupted transitions continue** — a retargeted transition starts
14
+ from its current blended value instead of jumping back to the previous
15
+ target (reversals no longer flash).
16
+ - **Per-keyframe `animation-timing-function`** — a timing function
17
+ declared inside a keyframe applies from that stop to the next.
18
+ - **Keyframe `var()`/`light-dark()` re-resolution** — scheme flips and
19
+ custom-property changes retarget a running animation in place, without
20
+ restarting it.
21
+
3
22
  ## 0.23.0 — 2026-07-05
4
23
 
5
24
  Positioning: `relative` offsets apply, and `sticky` arrives.
@@ -1,6 +1,6 @@
1
1
  import type { KeyframeStop } from './parser.js';
2
2
  import { type ResolvedStyle } from './compute.js';
3
- import type { Easing } from './easing.js';
3
+ import { type Easing } from './easing.js';
4
4
  /**
5
5
  * Runs a CSS animation by applying keyframe properties at the current
6
6
  * time. Colours interpolate in RGB space between stops; properties that
@@ -1,5 +1,6 @@
1
1
  import { applyDeclaration } from './compute.js';
2
2
  import { resolveColor } from './color.js';
3
+ import { parseEasing } from './easing.js';
3
4
  import { lerpColor, lerpNumber } from './interpolate.js';
4
5
  import { parseCellLength } from './values.js';
5
6
  /** Properties whose animation only needs repaint; anything else re-layouts. */
@@ -37,7 +38,11 @@ export class AnimationRunner {
37
38
  const progress = this.getProgress(elapsedMs);
38
39
  const segment = this.segmentAt(progress);
39
40
  const { from, to } = segment;
40
- const localT = this.easing(segment.localT);
41
+ // A timing function declared inside a keyframe applies from that
42
+ // stop to the next, overriding the element-level easing (per CSS).
43
+ const override = from.declarations.find(d => d.property === 'animation-timing-function');
44
+ const easing = override ? (parseEasing(override.value) ?? this.easing) : this.easing;
45
+ const localT = easing(segment.localT);
41
46
  // Hold the earlier stop's values, then interpolate toward the next
42
47
  for (const decl of from.declarations) {
43
48
  applyAnimatedProperty(style, decl);
@@ -87,6 +92,9 @@ export class AnimationRunner {
87
92
  }
88
93
  }
89
94
  function applyAnimatedProperty(style, decl) {
95
+ // Keyframe-level timing functions steer the runner, not the style
96
+ if (decl.property === 'animation-timing-function')
97
+ return;
90
98
  applyDeclaration(style, decl.property, decl.value);
91
99
  }
92
100
  function applyInterpolatedProperty(style, from, to, t) {
@@ -53,6 +53,15 @@ export interface ResolvedStyle {
53
53
  transitionProperty: string | null;
54
54
  transitionDuration: number;
55
55
  transitionTimingFunction: string;
56
+ /** Longhand lists, paired cyclically per spec into `transitions`. */
57
+ transitionDurations: number[];
58
+ transitionTimings: string[];
59
+ /** Per-property transition config, resolved after cascade. */
60
+ transitions: Array<{
61
+ property: string;
62
+ duration: number;
63
+ timing: string;
64
+ }>;
56
65
  borderStyle: 'none' | 'single' | 'double' | 'rounded' | 'heavy' | 'ascii' | 'eighth-cell-inner' | 'eighth-cell-outer' | 'half-cell-inner' | 'half-cell-outer' | 'full-cell';
57
66
  borderCorner: 'none' | 'h' | 'v';
58
67
  borderColor: string;
@@ -88,3 +97,12 @@ export declare function resolveStyles(root: TermNode, stylesheet: CSSStyleSheet,
88
97
  export declare function filterByMedia(stylesheet: CSSStyleSheet, context: MediaContext): CSSStyleSheet;
89
98
  export declare function resolveNode(node: TermNode, stylesheet: CSSStyleSheet, styles: Map<number, ResolvedStyle>, variables: Map<number, Map<string, string>>, scheme?: 'dark' | 'light'): void;
90
99
  export declare function applyDeclaration(style: ResolvedStyle, property: string, value: string, scheme?: 'dark' | 'light'): void;
100
+ /**
101
+ * Pair the transition lists per spec: durations/timings repeat
102
+ * cyclically to cover every listed property.
103
+ */
104
+ export declare function pairTransitions(style: ResolvedStyle): Array<{
105
+ property: string;
106
+ duration: number;
107
+ timing: string;
108
+ }>;
@@ -56,6 +56,8 @@ export function defaultStyle(tag) {
56
56
  animationTimingFunction: 'ease',
57
57
  transitionProperty: null, transitionDuration: 0,
58
58
  transitionTimingFunction: 'ease',
59
+ transitionDurations: [], transitionTimings: [],
60
+ transitions: [],
59
61
  borderStyle: 'none', borderColor: 'default', borderCorner: 'none',
60
62
  borderTop: true, borderRight: true, borderBottom: true, borderLeft: true,
61
63
  boxSizing: 'border-box',
@@ -272,6 +274,8 @@ function computeStyle(node, stylesheet, vars, parentStyle, scheme = 'dark') {
272
274
  applyDeclaration(style, decl.property, resolveVar(decl.value, vars), scheme);
273
275
  }
274
276
  }
277
+ // Pair transition longhand lists now the cascade is complete
278
+ style.transitions = pairTransitions(style);
275
279
  return style;
276
280
  }
277
281
  function parseInlineStyle(text) {
@@ -563,10 +567,12 @@ export function applyDeclaration(style, property, value, scheme = 'dark') {
563
567
  style.transitionProperty = value === 'none' ? null : value;
564
568
  break;
565
569
  case 'transition-duration':
566
- style.transitionDuration = parseDuration(value);
570
+ style.transitionDurations = value.split(',').map(v => parseDuration(v.trim()));
571
+ style.transitionDuration = style.transitionDurations[0] ?? 0;
567
572
  break;
568
573
  case 'transition-timing-function':
569
- style.transitionTimingFunction = value;
574
+ style.transitionTimings = splitTimingList(value);
575
+ style.transitionTimingFunction = style.transitionTimings[0] ?? 'ease';
570
576
  break;
571
577
  case 'animation-name':
572
578
  style.animationName = value === 'none' ? null : value;
@@ -806,35 +812,85 @@ function parseAnimationShorthand(style, value) {
806
812
  }
807
813
  }
808
814
  }
809
- /** Parse `transition: <property> <duration> [...]`, comma-separated groups. */
815
+ /** Split a timing-function list on commas, keeping function args intact. */
816
+ function splitTimingList(value) {
817
+ const out = [];
818
+ let depth = 0;
819
+ let current = '';
820
+ for (const ch of value) {
821
+ if (ch === '(')
822
+ depth++;
823
+ if (ch === ')')
824
+ depth--;
825
+ if (ch === ',' && depth === 0) {
826
+ out.push(current.trim());
827
+ current = '';
828
+ }
829
+ else
830
+ current += ch;
831
+ }
832
+ if (current.trim())
833
+ out.push(current.trim());
834
+ return out;
835
+ }
836
+ /**
837
+ * Parse `transition: <property> <duration> <timing> [...]` — each
838
+ * comma-separated group configures one property (or `all`).
839
+ */
810
840
  function parseTransitionShorthand(style, value) {
811
841
  const trimmed = value.trim();
812
842
  if (trimmed === 'none') {
813
843
  style.transitionProperty = null;
814
844
  style.transitionDuration = 0;
845
+ style.transitionDurations = [];
846
+ style.transitionTimings = [];
815
847
  return;
816
848
  }
817
- const { easing, rest } = extractEasingFunction(trimmed);
818
- if (easing)
819
- style.transitionTimingFunction = easing;
820
849
  const properties = [];
821
- let duration = 0;
822
- for (const group of rest.split(',')) {
823
- for (const token of group.trim().split(/\s+/)) {
850
+ const durations = [];
851
+ const timings = [];
852
+ for (const groupRaw of splitTimingList(trimmed)) {
853
+ const { easing, rest } = extractEasingFunction(groupRaw);
854
+ let property = 'all';
855
+ let duration = 0;
856
+ let timing = easing ?? 'ease';
857
+ for (const token of rest.trim().split(/\s+/)) {
824
858
  if (/^\d*\.?\d+(ms|s)$/.test(token)) {
825
859
  if (duration === 0)
826
860
  duration = parseDuration(token);
827
861
  }
828
862
  else if (TIMING_KEYWORDS.has(token)) {
829
- style.transitionTimingFunction = token;
863
+ timing = token;
830
864
  }
831
865
  else if (token) {
832
- properties.push(token);
866
+ property = token;
833
867
  }
834
868
  }
869
+ properties.push(property);
870
+ durations.push(duration);
871
+ timings.push(timing);
835
872
  }
836
873
  style.transitionProperty = properties.length > 0 ? properties.join(',') : 'all';
837
- style.transitionDuration = duration;
874
+ style.transitionDurations = durations;
875
+ style.transitionTimings = timings;
876
+ style.transitionDuration = durations[0] ?? 0;
877
+ style.transitionTimingFunction = timings[0] ?? 'ease';
878
+ }
879
+ /**
880
+ * Pair the transition lists per spec: durations/timings repeat
881
+ * cyclically to cover every listed property.
882
+ */
883
+ export function pairTransitions(style) {
884
+ if (!style.transitionProperty)
885
+ return [];
886
+ const properties = style.transitionProperty.split(',').map(p => p.trim()).filter(Boolean);
887
+ const durations = style.transitionDurations.length > 0 ? style.transitionDurations : [style.transitionDuration];
888
+ const timings = style.transitionTimings.length > 0 ? style.transitionTimings : [style.transitionTimingFunction];
889
+ return properties.map((property, i) => ({
890
+ property,
891
+ duration: durations[i % durations.length] ?? 0,
892
+ timing: timings[i % timings.length] ?? 'ease',
893
+ }));
838
894
  }
839
895
  function parseDuration(value) {
840
896
  if (value.endsWith('ms'))
@@ -12,6 +12,7 @@ export type { KeyframeResolution } from '../css/animation.js';
12
12
  */
13
13
  export declare class AnimationClock {
14
14
  private active;
15
+ /** Per-property transition runners, keyed `nodeId:property`. */
15
16
  private transitions;
16
17
  /** Last-seen target values per transitioned node, as CSS property → value. */
17
18
  private transitionTargets;
@@ -51,7 +52,14 @@ export declare class AnimationClock {
51
52
  stop(): void;
52
53
  private discover;
53
54
  private discoverTransitions;
54
- /** Snapshot the node's target values; start a transition on any change. */
55
+ /**
56
+ * Snapshot the node's target values; start a per-property transition
57
+ * runner on any change, each with its own duration and timing. An
58
+ * interrupted transition continues from its current blended value
59
+ * rather than restarting from the previous target.
60
+ */
55
61
  private trackTransitionTargets;
62
+ /** Evaluate an in-flight transition's value for one property, now. */
63
+ private currentValue;
56
64
  private updateTimer;
57
65
  }
@@ -6,17 +6,6 @@ import { systemClock, clockFromNow } from './clock.js';
6
6
  function easingFor(value) {
7
7
  return parseEasing(value) ?? (t => t);
8
8
  }
9
- /** Parse a transition-property value into the tracked-property filter. */
10
- function transitionedProperties(value) {
11
- const names = new Set();
12
- for (const raw of value.split(',')) {
13
- const name = raw.trim();
14
- if (name === 'all')
15
- return { all: true, names };
16
- names.add(name === 'background' ? 'background-color' : name);
17
- }
18
- return { all: false, names };
19
- }
20
9
  function cellValue(value) {
21
10
  return typeof value === 'number' && value >= 0 ? `${value}cell` : null;
22
11
  }
@@ -50,6 +39,7 @@ const FRAME_INTERVAL_MS = 33;
50
39
  */
51
40
  export class AnimationClock {
52
41
  active = new Map();
42
+ /** Per-property transition runners, keyed `nodeId:property`. */
53
43
  transitions = new Map();
54
44
  /** Last-seen target values per transitioned node, as CSS property → value. */
55
45
  transitionTargets = new Map();
@@ -71,8 +61,13 @@ export class AnimationClock {
71
61
  }
72
62
  /** Whether this node's animation needs re-layout each frame (vs repaint only). */
73
63
  touchesLayout(node) {
74
- return (this.active.get(node.id)?.runner.touchesLayout
75
- || this.transitions.get(node.id)?.runner.touchesLayout) ?? false;
64
+ if (this.active.get(node.id)?.runner.touchesLayout)
65
+ return true;
66
+ for (const anim of this.transitions.values()) {
67
+ if (anim.node.id === node.id && anim.runner.touchesLayout)
68
+ return true;
69
+ }
70
+ return false;
76
71
  }
77
72
  /**
78
73
  * Track transitioned elements and start a one-shot transition when a
@@ -87,7 +82,10 @@ export class AnimationClock {
87
82
  for (const id of this.transitionTargets.keys()) {
88
83
  if (!seen.has(id)) {
89
84
  this.transitionTargets.delete(id);
90
- this.transitions.delete(id);
85
+ for (const key of this.transitions.keys()) {
86
+ if (key.startsWith(`${id}:`))
87
+ this.transitions.delete(key);
88
+ }
91
89
  }
92
90
  }
93
91
  this.updateTimer();
@@ -116,15 +114,15 @@ export class AnimationClock {
116
114
  return dirty;
117
115
  }
118
116
  applyEntries(entries, styles, dirty) {
119
- for (const [id, anim] of entries) {
120
- const style = styles.get(id);
117
+ for (const [key, anim] of entries) {
118
+ const style = styles.get(anim.node.id);
121
119
  if (!style)
122
120
  continue;
123
121
  const elapsed = this.now() - anim.start;
124
122
  anim.runner.apply(style, elapsed);
125
123
  dirty.push({ node: anim.node, touchesLayout: anim.runner.touchesLayout });
126
124
  if (anim.runner.isFinished(elapsed))
127
- entries.delete(id);
125
+ entries.delete(key);
128
126
  }
129
127
  }
130
128
  stop() {
@@ -140,18 +138,27 @@ export class AnimationClock {
140
138
  const stops = name ? keyframes.get(name) : undefined;
141
139
  if (style && name && stops && style.animationDuration > 0) {
142
140
  const existing = this.active.get(node.id);
141
+ const resolved = resolution
142
+ ? resolveKeyframeStops(stops, resolution, node.id)
143
+ : stops;
144
+ const resolvedKey = JSON.stringify(resolved);
143
145
  if (!existing || existing.name !== name || existing.duration !== style.animationDuration) {
144
- const resolved = resolution
145
- ? resolveKeyframeStops(stops, resolution, node.id)
146
- : stops;
147
146
  this.active.set(node.id, {
148
147
  node,
149
148
  runner: new AnimationRunner(resolved, style.animationDuration, style.animationIterationCount, easingFor(style.animationTimingFunction)),
150
149
  name,
151
150
  duration: style.animationDuration,
152
151
  start: this.now(),
152
+ resolvedKey,
153
153
  });
154
154
  }
155
+ else if (existing.resolvedKey !== undefined && existing.resolvedKey !== resolvedKey) {
156
+ // var()/light-dark() re-resolved to new values (scheme
157
+ // flip, custom property change): retarget the runner
158
+ // without restarting — the original start time holds.
159
+ existing.runner = new AnimationRunner(resolved, style.animationDuration, style.animationIterationCount, easingFor(style.animationTimingFunction));
160
+ existing.resolvedKey = resolvedKey;
161
+ }
155
162
  seen.add(node.id);
156
163
  }
157
164
  }
@@ -162,7 +169,7 @@ export class AnimationClock {
162
169
  const subtreeResolved = parentResolved || (resolvedIds?.has(node.id) ?? false);
163
170
  if (node.nodeType === 'element') {
164
171
  const style = styles.get(node.id);
165
- if (style?.transitionProperty && style.transitionDuration > 0) {
172
+ if (style && style.transitions.some(t => t.duration > 0)) {
166
173
  seen.add(node.id);
167
174
  if (subtreeResolved)
168
175
  this.trackTransitionTargets(node, style);
@@ -172,12 +179,20 @@ export class AnimationClock {
172
179
  this.discoverTransitions(child, styles, subtreeResolved, resolvedIds, seen);
173
180
  }
174
181
  }
175
- /** Snapshot the node's target values; start a transition on any change. */
182
+ /**
183
+ * Snapshot the node's target values; start a per-property transition
184
+ * runner on any change, each with its own duration and timing. An
185
+ * interrupted transition continues from its current blended value
186
+ * rather than restarting from the previous target.
187
+ */
176
188
  trackTransitionTargets(node, style) {
177
- const included = transitionedProperties(style.transitionProperty);
189
+ const configFor = (css) => style.transitions.find(t => t.property === css
190
+ || (t.property === 'background' && css === 'background-color'))
191
+ ?? style.transitions.find(t => t.property === 'all');
178
192
  const targets = {};
179
193
  for (const prop of TRANSITIONABLE) {
180
- if (!included.all && !included.names.has(prop.css))
194
+ const config = configFor(prop.css);
195
+ if (!config || config.duration <= 0)
181
196
  continue;
182
197
  const value = prop.read(style);
183
198
  if (value !== null)
@@ -187,28 +202,38 @@ export class AnimationClock {
187
202
  this.transitionTargets.set(node.id, targets);
188
203
  if (!previous)
189
204
  return; // first sight — the initial style never transitions
190
- const fromDecls = [];
191
- const toDecls = [];
192
205
  for (const [property, target] of Object.entries(targets)) {
193
206
  const before = previous[property];
194
- if (before !== undefined && before !== target) {
195
- fromDecls.push({ property, value: before });
196
- toDecls.push({ property, value: target });
207
+ if (before === undefined || before === target)
208
+ continue;
209
+ const config = configFor(property);
210
+ const key = `${node.id}:${property}`;
211
+ // Interrupted mid-flight? Continue from the current value.
212
+ let from = before;
213
+ const inFlight = this.transitions.get(key);
214
+ if (inFlight) {
215
+ const current = this.currentValue(inFlight, style, property);
216
+ if (current !== null)
217
+ from = current;
197
218
  }
219
+ const stops = [
220
+ { offset: 0, declarations: [{ property, value: from }] },
221
+ { offset: 1, declarations: [{ property, value: target }] },
222
+ ];
223
+ this.transitions.set(key, {
224
+ node,
225
+ runner: new AnimationRunner(stops, config.duration, 1, easingFor(config.timing)),
226
+ name: property,
227
+ duration: config.duration,
228
+ start: this.now(),
229
+ });
198
230
  }
199
- if (fromDecls.length === 0)
200
- return;
201
- const stops = [
202
- { offset: 0, declarations: fromDecls },
203
- { offset: 1, declarations: toDecls },
204
- ];
205
- this.transitions.set(node.id, {
206
- node,
207
- runner: new AnimationRunner(stops, style.transitionDuration, 1, easingFor(style.transitionTimingFunction)),
208
- name: '',
209
- duration: style.transitionDuration,
210
- start: this.now(),
211
- });
231
+ }
232
+ /** Evaluate an in-flight transition's value for one property, now. */
233
+ currentValue(anim, base, property) {
234
+ const scratch = { ...base };
235
+ anim.runner.apply(scratch, this.now() - anim.start);
236
+ return TRANSITIONABLE.find(p => p.css === property)?.read(scratch) ?? null;
212
237
  }
213
238
  updateTimer() {
214
239
  if (this.activeCount > 0 && this.timer === null) {
package/docs/motion.md CHANGED
@@ -66,19 +66,20 @@ inline style updates, a `:checked` rule starts applying:
66
66
  `ms`/`s`. The same interpolation rules as animations apply. The initial
67
67
  style never transitions.
68
68
 
69
+ Transitions are configured per property, per spec: comma-separated
70
+ shorthand groups (`transition: color 150ms linear, width 400ms ease`)
71
+ or longhand lists paired cyclically. An interrupted transition
72
+ continues from its current blended value. A timing function declared
73
+ *inside a keyframe* applies from that stop to the next, overriding the
74
+ element's. Keyframe `var()`/`light-dark()` values re-resolve when the
75
+ scheme or custom properties change mid-animation, retargeting without
76
+ restarting.
77
+
69
78
  ## Deviations from browsers
70
79
 
71
- - One duration and one timing function apply to all listed transition
72
- properties (per-property lists aren't split).
73
- - An interrupted transition restarts from its previous target value, not
74
- the current blended value.
75
80
  - `opacity` doesn't interpolate (it applies discretely mid-animation) —
76
81
  animate colour toward the background for a smooth fade.
77
- - Keyframe `var()`/`light-dark()` resolution happens once when the
78
- animation starts; changing a custom property doesn't retarget a
79
- running animation.
80
- - Per-keyframe `animation-timing-function` overrides are ignored — the
81
- element's timing function applies to every segment.
82
+ - `transition-delay` / `animation-delay` are not implemented.
82
83
 
83
84
  ## Reduced motion
84
85
 
package/docs/reference.md CHANGED
@@ -176,7 +176,7 @@ adaptations. Lengths are cells (`cell`/`ch`, `%`, or `calc()`).
176
176
  | [Tables](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_table) | `border-collapse`, `border-spacing`, `caption-side`, `table-layout`, `empty-cells`, `vertical-align` (`baseline` ≈ `top`) |
177
177
  | Borders | `border`/`border-style`/`border-color`/`border-corner` + per-side toggles (terminal values above) |
178
178
  | [Animation](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_animations) | `animation` shorthand, `animation-name/-duration/-iteration-count` (incl. `infinite`)/`-timing-function`, `@keyframes` (from/to/percentages, values resolve `var()`/`light-dark()`) |
179
- | [Transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions) | `transition` shorthand, `transition-property` (list or `all`), `transition-duration`, `transition-timing-function` |
179
+ | [Transitions](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions) | `transition` shorthand with per-property comma groups; `transition-property`/`-duration`/`-timing-function` longhand lists paired per spec; interruptions continue from the current value |
180
180
  | [Easing](https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function) | `linear`, `ease` (default), `ease-in`, `ease-out`, `ease-in-out`, `cubic-bezier()`, `steps()`, `step-start`, `step-end` |
181
181
 
182
182
  ### Animation & transition semantics on the grid
@@ -185,12 +185,14 @@ Colours interpolate in RGB at ~30fps; single cell/ch lengths interpolate
185
185
  to whole cells (movement steps cell by cell); every other supported
186
186
  property applies discretely, switching at the segment midpoint (the CSS
187
187
  rule for non-interpolable values). Layout-affecting animations re-flow
188
- each frame. Easing applies per keyframe segment; non-interpolable values
189
- switch when eased progress crosses the midpoint. Deviations: one duration
190
- and one timing function for all listed transition properties, interrupted
191
- transitions restart from the previous target value, keyframe
192
- `var()`/`light-dark()` resolves once at animation start, and per-keyframe
193
- `animation-timing-function` overrides are ignored.
188
+ each frame. Easing applies per keyframe segment (a timing function
189
+ declared inside a keyframe overrides the element's for that segment);
190
+ non-interpolable values switch when eased progress crosses the midpoint.
191
+ Transitions run per property with their own duration/timing;
192
+ interruptions continue from the current blended value. Keyframe
193
+ `var()`/`light-dark()` re-resolves on scheme/custom-property changes
194
+ without restarting the animation. Deviation: no `transition-delay` /
195
+ `animation-delay`.
194
196
 
195
197
  ## Values, functions and at-rules
196
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelterm/core",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Svelte 5 components rendered to the terminal with real CSS",
5
5
  "type": "module",
6
6
  "exports": {