@svelterm/core 0.21.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,64 @@
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
+
22
+ ## 0.23.0 — 2026-07-05
23
+
24
+ Positioning: `relative` offsets apply, and `sticky` arrives.
25
+
26
+ ### Added
27
+
28
+ - **`position: relative`** — `top/right/bottom/left` now shift the box
29
+ and its descendants visually while flow position and size behave as if
30
+ unshifted, per spec (previously parsed and dropped).
31
+ - **`position: sticky`** (top edge) — inside a scroll container the
32
+ element pins to the container top + `top` once scrolled past, painting
33
+ above in-flow content; descendants move with it. Deviations
34
+ (documented): top-only, no push-out at the containing-block end, and
35
+ hit-testing targets the flow position.
36
+
37
+ ### Fixed
38
+
39
+ - **Opaque backgrounds now cover what's beneath** — an element's
40
+ background fill previously kept stale glyphs from earlier paints, so
41
+ overlapping paints (sticky, absolute) showed old content through the
42
+ background. Fills now clear covered cells.
43
+
44
+ ## 0.22.0 — 2026-07-05
45
+
46
+ ### Added
47
+
48
+ - **`Clock` seam for deterministic animation tests** — the animation
49
+ engine's time source *and* its frame scheduler now go through a
50
+ `Clock` interface. `TestClock` (exported) lets tests set time and
51
+ advance it, firing the frame timer at each tick — so the animation and
52
+ transition frame lifecycle is exact, not `setInterval`/`Date.now`
53
+ dependent. `run()` uses the real `systemClock` by default.
54
+
55
+ ### Fixed
56
+
57
+ - **Docs: `<img>` rendering** — `reference.md` still listed `img` as
58
+ "not rendered", and `elements.md`'s intro contradicted its own Images
59
+ section. Both now describe the half-block + kitty-graphics rendering
60
+ shipped in 0.15.0 / 0.19.0.
61
+
3
62
  ## 0.21.0 — 2026-07-05
4
63
 
5
64
  DevTools polish.
@@ -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;
@@ -68,7 +77,7 @@ export interface ResolvedStyle {
68
77
  opacity: number;
69
78
  textAlign: 'left' | 'center' | 'right';
70
79
  textTransform: 'none' | 'uppercase' | 'lowercase' | 'capitalize';
71
- position: 'static' | 'relative' | 'absolute' | 'fixed';
80
+ position: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky';
72
81
  top: number | null;
73
82
  right: number | null;
74
83
  bottom: number | null;
@@ -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;
@@ -639,7 +645,7 @@ export function applyDeclaration(style, property, value, scheme = 'dark') {
639
645
  style.textTransform = 'none';
640
646
  break;
641
647
  case 'position':
642
- if (value === 'relative' || value === 'absolute' || value === 'fixed')
648
+ if (value === 'relative' || value === 'absolute' || value === 'fixed' || value === 'sticky')
643
649
  style.position = value;
644
650
  else
645
651
  style.position = 'static';
@@ -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'))
@@ -78,3 +78,4 @@ export { StdinRouter } from './terminal/stdin-router.js';
78
78
  export { type TerminalIO, ProcessIO, InProcessIO } from './terminal/io.js';
79
79
  export { copyToClipboard, osc52Copy } from './terminal/clipboard.js';
80
80
  export { FrameLog, createFrameLog } from './framelog.js';
81
+ export { type Clock, TestClock, systemClock } from './render/clock.js';
package/dist/src/index.js CHANGED
@@ -1011,3 +1011,4 @@ export { StdinRouter } from './terminal/stdin-router.js';
1011
1011
  export { ProcessIO, InProcessIO } from './terminal/io.js';
1012
1012
  export { copyToClipboard, osc52Copy } from './terminal/clipboard.js';
1013
1013
  export { FrameLog, createFrameLog } from './framelog.js';
1014
+ export { TestClock, systemClock } from './render/clock.js';
@@ -159,6 +159,14 @@ function layoutElement(node, styles, boxes, x, y, availWidth, availHeight) {
159
159
  const absY = y + (style.top ?? 0);
160
160
  return layoutAbsolute(node, styles, boxes, absX, absY, availWidth, availHeight, style);
161
161
  }
162
+ // position: relative — a visual shift of the box and its descendants;
163
+ // flow position and size behave as if unshifted (the return value,
164
+ // which advances the flow, is untouched). left beats right, top beats
165
+ // bottom, per LTR CSS.
166
+ if (style?.position === 'relative') {
167
+ x += style.left ?? -(style.right ?? 0);
168
+ y += style.top ?? -(style.bottom ?? 0);
169
+ }
162
170
  let margin = {
163
171
  top: resolvePadding(style?.marginTop, availWidth),
164
172
  right: resolvePadding(style?.marginRight, availWidth),
@@ -2,6 +2,7 @@ import { TermNode } from '../renderer/node.js';
2
2
  import { type KeyframeResolution } from '../css/animation.js';
3
3
  import type { KeyframeStop } from '../css/parser.js';
4
4
  import type { ResolvedStyle } from '../css/compute.js';
5
+ import { type Clock } from './clock.js';
5
6
  export type { KeyframeResolution } from '../css/animation.js';
6
7
  /**
7
8
  * Drives CSS animations: discovers animated elements after style
@@ -10,14 +11,20 @@ export type { KeyframeResolution } from '../css/animation.js';
10
11
  * wires `onFrame` to re-apply and repaint.
11
12
  */
12
13
  export declare class AnimationClock {
13
- private now;
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;
18
19
  private timer;
20
+ private clock;
19
21
  onFrame?: () => void;
20
- constructor(now?: () => number);
22
+ /**
23
+ * Accepts a Clock (time source + scheduler) or, for convenience and
24
+ * backward compatibility, a bare `() => number` time function.
25
+ */
26
+ constructor(clock?: Clock | (() => number));
27
+ private now;
21
28
  get activeCount(): number;
22
29
  /** Whether this node's animation needs re-layout each frame (vs repaint only). */
23
30
  touchesLayout(node: TermNode): boolean;
@@ -45,7 +52,14 @@ export declare class AnimationClock {
45
52
  stop(): void;
46
53
  private discover;
47
54
  private discoverTransitions;
48
- /** 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
+ */
49
61
  private trackTransitionTargets;
62
+ /** Evaluate an in-flight transition's value for one property, now. */
63
+ private currentValue;
50
64
  private updateTimer;
51
65
  }
@@ -1,21 +1,11 @@
1
1
  import { AnimationRunner } from '../css/animation-runner.js';
2
2
  import { resolveKeyframeStops } from '../css/animation.js';
3
3
  import { parseEasing } from '../css/easing.js';
4
+ import { systemClock, clockFromNow } from './clock.js';
4
5
  /** The runner's easing for a CSS timing-function value; invalid → linear. */
5
6
  function easingFor(value) {
6
7
  return parseEasing(value) ?? (t => t);
7
8
  }
8
- /** Parse a transition-property value into the tracked-property filter. */
9
- function transitionedProperties(value) {
10
- const names = new Set();
11
- for (const raw of value.split(',')) {
12
- const name = raw.trim();
13
- if (name === 'all')
14
- return { all: true, names };
15
- names.add(name === 'background' ? 'background-color' : name);
16
- }
17
- return { all: false, names };
18
- }
19
9
  function cellValue(value) {
20
10
  return typeof value === 'number' && value >= 0 ? `${value}cell` : null;
21
11
  }
@@ -48,23 +38,36 @@ const FRAME_INTERVAL_MS = 33;
48
38
  * wires `onFrame` to re-apply and repaint.
49
39
  */
50
40
  export class AnimationClock {
51
- now;
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();
56
46
  timer = null;
47
+ clock;
57
48
  onFrame;
58
- constructor(now = Date.now) {
59
- this.now = now;
49
+ /**
50
+ * Accepts a Clock (time source + scheduler) or, for convenience and
51
+ * backward compatibility, a bare `() => number` time function.
52
+ */
53
+ constructor(clock = systemClock) {
54
+ this.clock = typeof clock === 'function' ? clockFromNow(clock) : clock;
55
+ }
56
+ now() {
57
+ return this.clock.now();
60
58
  }
61
59
  get activeCount() {
62
60
  return this.active.size + this.transitions.size;
63
61
  }
64
62
  /** Whether this node's animation needs re-layout each frame (vs repaint only). */
65
63
  touchesLayout(node) {
66
- return (this.active.get(node.id)?.runner.touchesLayout
67
- || 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;
68
71
  }
69
72
  /**
70
73
  * Track transitioned elements and start a one-shot transition when a
@@ -79,7 +82,10 @@ export class AnimationClock {
79
82
  for (const id of this.transitionTargets.keys()) {
80
83
  if (!seen.has(id)) {
81
84
  this.transitionTargets.delete(id);
82
- this.transitions.delete(id);
85
+ for (const key of this.transitions.keys()) {
86
+ if (key.startsWith(`${id}:`))
87
+ this.transitions.delete(key);
88
+ }
83
89
  }
84
90
  }
85
91
  this.updateTimer();
@@ -108,20 +114,20 @@ export class AnimationClock {
108
114
  return dirty;
109
115
  }
110
116
  applyEntries(entries, styles, dirty) {
111
- for (const [id, anim] of entries) {
112
- const style = styles.get(id);
117
+ for (const [key, anim] of entries) {
118
+ const style = styles.get(anim.node.id);
113
119
  if (!style)
114
120
  continue;
115
121
  const elapsed = this.now() - anim.start;
116
122
  anim.runner.apply(style, elapsed);
117
123
  dirty.push({ node: anim.node, touchesLayout: anim.runner.touchesLayout });
118
124
  if (anim.runner.isFinished(elapsed))
119
- entries.delete(id);
125
+ entries.delete(key);
120
126
  }
121
127
  }
122
128
  stop() {
123
129
  if (this.timer !== null) {
124
- clearInterval(this.timer);
130
+ this.clock.clearInterval(this.timer);
125
131
  this.timer = null;
126
132
  }
127
133
  }
@@ -132,18 +138,27 @@ export class AnimationClock {
132
138
  const stops = name ? keyframes.get(name) : undefined;
133
139
  if (style && name && stops && style.animationDuration > 0) {
134
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);
135
145
  if (!existing || existing.name !== name || existing.duration !== style.animationDuration) {
136
- const resolved = resolution
137
- ? resolveKeyframeStops(stops, resolution, node.id)
138
- : stops;
139
146
  this.active.set(node.id, {
140
147
  node,
141
148
  runner: new AnimationRunner(resolved, style.animationDuration, style.animationIterationCount, easingFor(style.animationTimingFunction)),
142
149
  name,
143
150
  duration: style.animationDuration,
144
151
  start: this.now(),
152
+ resolvedKey,
145
153
  });
146
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
+ }
147
162
  seen.add(node.id);
148
163
  }
149
164
  }
@@ -154,7 +169,7 @@ export class AnimationClock {
154
169
  const subtreeResolved = parentResolved || (resolvedIds?.has(node.id) ?? false);
155
170
  if (node.nodeType === 'element') {
156
171
  const style = styles.get(node.id);
157
- if (style?.transitionProperty && style.transitionDuration > 0) {
172
+ if (style && style.transitions.some(t => t.duration > 0)) {
158
173
  seen.add(node.id);
159
174
  if (subtreeResolved)
160
175
  this.trackTransitionTargets(node, style);
@@ -164,12 +179,20 @@ export class AnimationClock {
164
179
  this.discoverTransitions(child, styles, subtreeResolved, resolvedIds, seen);
165
180
  }
166
181
  }
167
- /** 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
+ */
168
188
  trackTransitionTargets(node, style) {
169
- 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');
170
192
  const targets = {};
171
193
  for (const prop of TRANSITIONABLE) {
172
- if (!included.all && !included.names.has(prop.css))
194
+ const config = configFor(prop.css);
195
+ if (!config || config.duration <= 0)
173
196
  continue;
174
197
  const value = prop.read(style);
175
198
  if (value !== null)
@@ -179,32 +202,42 @@ export class AnimationClock {
179
202
  this.transitionTargets.set(node.id, targets);
180
203
  if (!previous)
181
204
  return; // first sight — the initial style never transitions
182
- const fromDecls = [];
183
- const toDecls = [];
184
205
  for (const [property, target] of Object.entries(targets)) {
185
206
  const before = previous[property];
186
- if (before !== undefined && before !== target) {
187
- fromDecls.push({ property, value: before });
188
- 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;
189
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
+ });
190
230
  }
191
- if (fromDecls.length === 0)
192
- return;
193
- const stops = [
194
- { offset: 0, declarations: fromDecls },
195
- { offset: 1, declarations: toDecls },
196
- ];
197
- this.transitions.set(node.id, {
198
- node,
199
- runner: new AnimationRunner(stops, style.transitionDuration, 1, easingFor(style.transitionTimingFunction)),
200
- name: '',
201
- duration: style.transitionDuration,
202
- start: this.now(),
203
- });
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;
204
237
  }
205
238
  updateTimer() {
206
239
  if (this.activeCount > 0 && this.timer === null) {
207
- this.timer = setInterval(() => this.onFrame?.(), FRAME_INTERVAL_MS);
240
+ this.timer = this.clock.setInterval(() => this.onFrame?.(), FRAME_INTERVAL_MS);
208
241
  }
209
242
  else if (this.activeCount === 0) {
210
243
  this.stop();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Clock seam for time-driven rendering (animations, transitions, and the
3
+ * scrollbar fade). Abstracts both the time source and the frame
4
+ * scheduler so tests can advance time and fire ticks deterministically
5
+ * instead of depending on `Date.now()` and real `setInterval`.
6
+ */
7
+ export type ClockTimer = unknown;
8
+ export interface Clock {
9
+ now(): number;
10
+ setInterval(fn: () => void, ms: number): ClockTimer;
11
+ clearInterval(timer: ClockTimer): void;
12
+ }
13
+ /** The real clock: wall time and the platform timer. */
14
+ export declare const systemClock: Clock;
15
+ /** Wrap a bare time function in a Clock backed by the real scheduler. */
16
+ export declare function clockFromNow(now: () => number): Clock;
17
+ /**
18
+ * A controllable clock for tests: set the time directly, or `advance`
19
+ * it, which fires any registered intervals at each tick they cross.
20
+ */
21
+ export declare class TestClock implements Clock {
22
+ private time;
23
+ private timers;
24
+ constructor(start?: number);
25
+ now(): number;
26
+ setInterval(fn: () => void, ms: number): ClockTimer;
27
+ clearInterval(timer: ClockTimer): void;
28
+ /** Jump to an absolute time without firing intervals. */
29
+ setTime(time: number): void;
30
+ /** Advance by `ms`, firing each interval at every tick it crosses. */
31
+ advance(ms: number): void;
32
+ /** Timers registered right now (for assertions). */
33
+ get activeTimers(): number;
34
+ private nextTimerBefore;
35
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Clock seam for time-driven rendering (animations, transitions, and the
3
+ * scrollbar fade). Abstracts both the time source and the frame
4
+ * scheduler so tests can advance time and fire ticks deterministically
5
+ * instead of depending on `Date.now()` and real `setInterval`.
6
+ */
7
+ /** The real clock: wall time and the platform timer. */
8
+ export const systemClock = {
9
+ now: () => Date.now(),
10
+ setInterval: (fn, ms) => setInterval(fn, ms),
11
+ clearInterval: (timer) => clearInterval(timer),
12
+ };
13
+ /** Wrap a bare time function in a Clock backed by the real scheduler. */
14
+ export function clockFromNow(now) {
15
+ return { now, setInterval: systemClock.setInterval, clearInterval: systemClock.clearInterval };
16
+ }
17
+ /**
18
+ * A controllable clock for tests: set the time directly, or `advance`
19
+ * it, which fires any registered intervals at each tick they cross.
20
+ */
21
+ export class TestClock {
22
+ time;
23
+ timers = new Set();
24
+ constructor(start = 0) {
25
+ this.time = start;
26
+ }
27
+ now() {
28
+ return this.time;
29
+ }
30
+ setInterval(fn, ms) {
31
+ const timer = { fn, ms, next: this.time + ms };
32
+ this.timers.add(timer);
33
+ return timer;
34
+ }
35
+ clearInterval(timer) {
36
+ this.timers.delete(timer);
37
+ }
38
+ /** Jump to an absolute time without firing intervals. */
39
+ setTime(time) {
40
+ this.time = time;
41
+ }
42
+ /** Advance by `ms`, firing each interval at every tick it crosses. */
43
+ advance(ms) {
44
+ const target = this.time + ms;
45
+ while (true) {
46
+ const due = this.nextTimerBefore(target);
47
+ if (!due)
48
+ break;
49
+ this.time = due.next;
50
+ due.next += due.ms;
51
+ due.fn();
52
+ }
53
+ this.time = target;
54
+ }
55
+ /** Timers registered right now (for assertions). */
56
+ get activeTimers() {
57
+ return this.timers.size;
58
+ }
59
+ nextTimerBefore(target) {
60
+ let earliest = null;
61
+ for (const timer of this.timers) {
62
+ if (timer.next <= target && (!earliest || timer.next < earliest.next))
63
+ earliest = timer;
64
+ }
65
+ return earliest;
66
+ }
67
+ }
@@ -25,7 +25,21 @@ function paintNode(node, buffer, styles, layout, inherited, clip, scroll, damage
25
25
  return;
26
26
  const visuals = resolveVisuals(node, styles, inherited);
27
27
  const rawBox = layout?.get(node.id);
28
- const box = rawBox ? applyScroll(rawBox, scroll) : undefined;
28
+ let box = rawBox ? applyScroll(rawBox, scroll) : undefined;
29
+ // position: sticky (top): when scrolled past inside a clipping
30
+ // container, pin to the container top + offset. Descendants follow
31
+ // via an adjusted child scroll; applied before culling so a stuck
32
+ // element scrolled far past its flow position still paints.
33
+ let stickyDelta = 0;
34
+ if (box && clip && node.nodeType === 'element') {
35
+ const stickyStyle = styles?.get(node.id);
36
+ if (stickyStyle?.position === 'sticky' && stickyStyle.top !== null) {
37
+ const stuckY = Math.max(box.y, clip.y + (stickyStyle.top ?? 0));
38
+ stickyDelta = stuckY - box.y;
39
+ if (stickyDelta > 0)
40
+ box = { ...box, y: stuckY };
41
+ }
42
+ }
29
43
  // Skip nodes entirely outside the damage region
30
44
  if (damageClip && box && !boxesOverlap(box, damageClip))
31
45
  return;
@@ -118,7 +132,18 @@ function paintNode(node, buffer, styles, layout, inherited, clip, scroll, damage
118
132
  childScroll = { x: scroll.x + node.scrollLeft, y: scroll.y + node.scrollTop };
119
133
  }
120
134
  }
121
- for (const child of childrenWithPseudos(node)) {
135
+ // Descendants of a stuck element move with it
136
+ if (stickyDelta > 0)
137
+ childScroll = { x: childScroll.x, y: childScroll.y - stickyDelta };
138
+ // Sticky children paint after in-flow siblings so scrolled content
139
+ // doesn't overpaint a stuck header (positioned elements stack above).
140
+ const kids = childrenWithPseudos(node);
141
+ const hasSticky = kids.some(c => c.nodeType === 'element' && styles?.get(c.id)?.position === 'sticky');
142
+ const ordered = hasSticky
143
+ ? [...kids.filter(c => styles?.get(c.id)?.position !== 'sticky'),
144
+ ...kids.filter(c => styles?.get(c.id)?.position === 'sticky')]
145
+ : kids;
146
+ for (const child of ordered) {
122
147
  paintNode(child, buffer, styles, layout, visuals, childClip, childScroll, damageClip);
123
148
  }
124
149
  // Render scrollbar overlays for scrollable containers
@@ -233,7 +258,11 @@ function fillBackground(buffer, box, visuals, clip, style) {
233
258
  const bg = bgHasAlpha
234
259
  ? blendColor(buffer.getCell(col, row)?.bg ?? 'default', visuals.bg)
235
260
  : visuals.bg;
236
- buffer.setCell(col, row, { bg });
261
+ // An opaque fill covers what's beneath — clear stale glyphs
262
+ // too, so overlapping paints (sticky, absolute) don't show
263
+ // earlier content through the background. The element's own
264
+ // text repaints after this fill.
265
+ buffer.setCell(col, row, { bg, char: ' ' });
237
266
  }
238
267
  }
239
268
  }
package/docs/elements.md CHANGED
@@ -9,9 +9,9 @@ its display default.
9
9
  Headings, paragraphs, lists (`ul`/`ol` with markers), `blockquote`,
10
10
  `pre`, `hr` (a `─` rule), `figure`, `dl`, and the text-level elements
11
11
  (`strong`/`b`, `em`/`i`, `u`, `s`/`del`, `mark`, `code`, `kbd`, `abbr`,
12
- `samp`, `var`) carry a browser-like UA stylesheet in cells. `img`,
13
- `video`, `canvas`, and `iframe` are not rendered (inline images are a
14
- planned, separate feature).
12
+ `samp`, `var`) carry a browser-like UA stylesheet in cells. `img`
13
+ renders (see [Images](#images) below); `video`, `canvas`, and `iframe`
14
+ are not rendered.
15
15
 
16
16
  ## Images
17
17
 
package/docs/layout.md CHANGED
@@ -92,8 +92,14 @@ cells has no baseline distinct from its top).
92
92
 
93
93
  `position: absolute` and `fixed` take elements out of flow and place them
94
94
  by `top`/`right`/`bottom`/`left` with `z-index` stacking. `position:
95
- relative` establishes context but **offsets are not applied** a known
96
- gap. Sub-cell geometry (`transform`, floats) is out of scope; see
95
+ relative` shifts the element (and its descendants) visually while the
96
+ flow behaves as if it hadn't moved, per spec. `position: sticky` pins an
97
+ element to the top of its scroll container once scrolled past
98
+ (`top`-edge only; it doesn't yet push out at the end of its containing
99
+ block, and hit-testing targets the flow position rather than the stuck
100
+ one). A sticky element with a transparent background shows scrolled
101
+ content through it — give it a background. Sub-cell geometry
102
+ (`transform`, floats) is out of scope; see
97
103
  [compatibility](./compatibility.md).
98
104
 
99
105
  ## Sizing behaviours worth knowing
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
@@ -137,7 +137,8 @@ block/inline box per its display default.
137
137
  | `select`/`option`/`optgroup` | cycling control (see above); `change`+`input` with the option value | [`<select>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select) |
138
138
  | `progress`, `meter` | block-glyph bars; `value`/`max` (+`min` for meter); no-value progress renders track only | [`<progress>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/progress), [`<meter>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meter) |
139
139
  | `details`/`summary` | ▶/▼ disclosure, `open` attribute, `toggle` event, focusable summary | [`<details>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) |
140
- | `img`, `video`, `canvas`, `iframe` | **not rendered** (inline images are a planned separate feature) | |
140
+ | `img` | half-block pixels (▀) from PNG file paths / `data:image/png` URIs; real pixels via the kitty graphics protocol where supported; sized by CSS `width`/`height` | [`<img>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) |
141
+ | `video`, `canvas`, `iframe` | not rendered | — |
141
142
 
142
143
  ## CSS selectors
143
144
 
@@ -171,11 +172,11 @@ adaptations. Lengths are cells (`cell`/`ch`, `%`, or `calc()`).
171
172
  | [Display & flow](https://developer.mozilla.org/en-US/docs/Web/CSS/display) | `display: block, inline, inline-block, flex, grid, none, contents`, all table display types |
172
173
  | [Flexbox](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flexible_box_layout) | `flex-direction` (all four), `flex-wrap`, `flex`/`flex-grow`/`flex-shrink`/`flex-basis`, `gap`, `justify-content` (incl. `space-*`), `align-items`, `align-self`, `order` |
173
174
  | [Grid](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_grid_layout) | `grid-template-columns/rows` (`cell`/`ch`/`%`/`fr`, `repeat()`, `minmax()`), `grid-template-areas` + `grid-area` (named and numeric), `grid-column`, `grid-row` (start / start‑end / `span n`), `gap`. Auto-flow is row-based; `grid-auto-flow: column` is not implemented. Fractional `minmax()` minimums are enforced without redistribution |
174
- | [Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/position) | `position: static/absolute/fixed` with `top/right/bottom/left`, `z-index`. `position: relative` establishes context but offsets are **not** applied |
175
+ | [Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/position) | `position: static/relative/absolute/fixed/sticky` with `top/right/bottom/left`, `z-index`. Relative offsets shift visually without moving flow; sticky is top-edge only inside scroll containers (no push-out at the containing block end) |
175
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`) |
176
177
  | Borders | `border`/`border-style`/`border-color`/`border-corner` + per-side toggles (terminal values above) |
177
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()`) |
178
- | [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 |
179
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` |
180
181
 
181
182
  ### Animation & transition semantics on the grid
@@ -184,12 +185,14 @@ Colours interpolate in RGB at ~30fps; single cell/ch lengths interpolate
184
185
  to whole cells (movement steps cell by cell); every other supported
185
186
  property applies discretely, switching at the segment midpoint (the CSS
186
187
  rule for non-interpolable values). Layout-affecting animations re-flow
187
- each frame. Easing applies per keyframe segment; non-interpolable values
188
- switch when eased progress crosses the midpoint. Deviations: one duration
189
- and one timing function for all listed transition properties, interrupted
190
- transitions restart from the previous target value, keyframe
191
- `var()`/`light-dark()` resolves once at animation start, and per-keyframe
192
- `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`.
193
196
 
194
197
  ## Values, functions and at-rules
195
198
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelterm/core",
3
- "version": "0.21.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": {