@svelterm/core 0.21.0 → 0.23.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,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0 — 2026-07-05
4
+
5
+ Positioning: `relative` offsets apply, and `sticky` arrives.
6
+
7
+ ### Added
8
+
9
+ - **`position: relative`** — `top/right/bottom/left` now shift the box
10
+ and its descendants visually while flow position and size behave as if
11
+ unshifted, per spec (previously parsed and dropped).
12
+ - **`position: sticky`** (top edge) — inside a scroll container the
13
+ element pins to the container top + `top` once scrolled past, painting
14
+ above in-flow content; descendants move with it. Deviations
15
+ (documented): top-only, no push-out at the containing-block end, and
16
+ hit-testing targets the flow position.
17
+
18
+ ### Fixed
19
+
20
+ - **Opaque backgrounds now cover what's beneath** — an element's
21
+ background fill previously kept stale glyphs from earlier paints, so
22
+ overlapping paints (sticky, absolute) showed old content through the
23
+ background. Fills now clear covered cells.
24
+
25
+ ## 0.22.0 — 2026-07-05
26
+
27
+ ### Added
28
+
29
+ - **`Clock` seam for deterministic animation tests** — the animation
30
+ engine's time source *and* its frame scheduler now go through a
31
+ `Clock` interface. `TestClock` (exported) lets tests set time and
32
+ advance it, firing the frame timer at each tick — so the animation and
33
+ transition frame lifecycle is exact, not `setInterval`/`Date.now`
34
+ dependent. `run()` uses the real `systemClock` by default.
35
+
36
+ ### Fixed
37
+
38
+ - **Docs: `<img>` rendering** — `reference.md` still listed `img` as
39
+ "not rendered", and `elements.md`'s intro contradicted its own Images
40
+ section. Both now describe the half-block + kitty-graphics rendering
41
+ shipped in 0.15.0 / 0.19.0.
42
+
3
43
  ## 0.21.0 — 2026-07-05
4
44
 
5
45
  DevTools polish.
@@ -68,7 +68,7 @@ export interface ResolvedStyle {
68
68
  opacity: number;
69
69
  textAlign: 'left' | 'center' | 'right';
70
70
  textTransform: 'none' | 'uppercase' | 'lowercase' | 'capitalize';
71
- position: 'static' | 'relative' | 'absolute' | 'fixed';
71
+ position: 'static' | 'relative' | 'absolute' | 'fixed' | 'sticky';
72
72
  top: number | null;
73
73
  right: number | null;
74
74
  bottom: number | null;
@@ -639,7 +639,7 @@ export function applyDeclaration(style, property, value, scheme = 'dark') {
639
639
  style.textTransform = 'none';
640
640
  break;
641
641
  case 'position':
642
- if (value === 'relative' || value === 'absolute' || value === 'fixed')
642
+ if (value === 'relative' || value === 'absolute' || value === 'fixed' || value === 'sticky')
643
643
  style.position = value;
644
644
  else
645
645
  style.position = 'static';
@@ -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,19 @@ 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
15
  private transitions;
16
16
  /** Last-seen target values per transitioned node, as CSS property → value. */
17
17
  private transitionTargets;
18
18
  private timer;
19
+ private clock;
19
20
  onFrame?: () => void;
20
- constructor(now?: () => number);
21
+ /**
22
+ * Accepts a Clock (time source + scheduler) or, for convenience and
23
+ * backward compatibility, a bare `() => number` time function.
24
+ */
25
+ constructor(clock?: Clock | (() => number));
26
+ private now;
21
27
  get activeCount(): number;
22
28
  /** Whether this node's animation needs re-layout each frame (vs repaint only). */
23
29
  touchesLayout(node: TermNode): boolean;
@@ -1,6 +1,7 @@
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);
@@ -48,15 +49,22 @@ const FRAME_INTERVAL_MS = 33;
48
49
  * wires `onFrame` to re-apply and repaint.
49
50
  */
50
51
  export class AnimationClock {
51
- now;
52
52
  active = new Map();
53
53
  transitions = new Map();
54
54
  /** Last-seen target values per transitioned node, as CSS property → value. */
55
55
  transitionTargets = new Map();
56
56
  timer = null;
57
+ clock;
57
58
  onFrame;
58
- constructor(now = Date.now) {
59
- this.now = now;
59
+ /**
60
+ * Accepts a Clock (time source + scheduler) or, for convenience and
61
+ * backward compatibility, a bare `() => number` time function.
62
+ */
63
+ constructor(clock = systemClock) {
64
+ this.clock = typeof clock === 'function' ? clockFromNow(clock) : clock;
65
+ }
66
+ now() {
67
+ return this.clock.now();
60
68
  }
61
69
  get activeCount() {
62
70
  return this.active.size + this.transitions.size;
@@ -121,7 +129,7 @@ export class AnimationClock {
121
129
  }
122
130
  stop() {
123
131
  if (this.timer !== null) {
124
- clearInterval(this.timer);
132
+ this.clock.clearInterval(this.timer);
125
133
  this.timer = null;
126
134
  }
127
135
  }
@@ -204,7 +212,7 @@ export class AnimationClock {
204
212
  }
205
213
  updateTimer() {
206
214
  if (this.activeCount > 0 && this.timer === null) {
207
- this.timer = setInterval(() => this.onFrame?.(), FRAME_INTERVAL_MS);
215
+ this.timer = this.clock.setInterval(() => this.onFrame?.(), FRAME_INTERVAL_MS);
208
216
  }
209
217
  else if (this.activeCount === 0) {
210
218
  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/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,7 +172,7 @@ 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()`) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelterm/core",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Svelte 5 components rendered to the terminal with real CSS",
5
5
  "type": "module",
6
6
  "exports": {