@svelterm/core 0.23.0 → 0.27.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,91 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.27.0 — 2026-07-05
4
+
5
+ Form control parity: password, maxlength, readonly.
6
+
7
+ ### Added
8
+
9
+ - **`input type="password"`** — the value paints as `•` bullets; layout,
10
+ scrolling, and cursor behave as for text inputs and the real value
11
+ stays in `value`.
12
+ - **`maxlength`** on `input`/`textarea` — caps typing and paste at the
13
+ limit (counted in code units, as in HTML). An over-long initial value
14
+ is kept and can be edited down, matching browsers.
15
+ - **`readonly`** on `input`/`textarea` — blocks all edits (typing,
16
+ deletion, Ctrl+U/K, paste) while the caret still moves; the control
17
+ remains focusable.
18
+
19
+ ### Fixed
20
+
21
+ - `input` events now fire only when the value changes — caret-movement
22
+ keys (arrows, Home/End) no longer dispatch spurious `input` events.
23
+
24
+ ## 0.26.0 — 2026-07-05
25
+
26
+ Sibling border collapse becomes explicit.
27
+
28
+ ### Changed (breaking)
29
+
30
+ - **Sibling border collapse is now opt-in.** Adjacent bordered siblings
31
+ (stacked blocks, flex items, grid items) previously always shared a
32
+ border line with junction glyphs (`├` `┬` `┼`). Now that happens only
33
+ under `border-collapse: collapse` — an extension of the CSS table
34
+ property to all boxes. It inherits per spec, so opt in once on the
35
+ container or app-wide:
36
+
37
+ ```css
38
+ :root { border-collapse: collapse; }
39
+ ```
40
+
41
+ Without it, sibling frames render separately, matching browsers.
42
+ `border-collapse: separate` on a child opts it back out.
43
+
44
+ ### Added
45
+
46
+ - `border-collapse` now inherits (CSS 2.2 §17.6) — previously it was
47
+ only read from the table element itself.
48
+
49
+ ## 0.25.0 — 2026-07-05
50
+
51
+ Grid and generated-content completeness.
52
+
53
+ ### Added
54
+
55
+ - **`grid-auto-flow: column`** — auto-placed items fill down each
56
+ column, wrapping to a new (implicit) column after the explicit row
57
+ count; implicit columns take the last explicit column's width.
58
+ - **`minmax()` redistribution** — a `fr` track clamped to its
59
+ `minmax()` minimum leaves the distribution pool and the freed space
60
+ re-splits among the remaining `fr` tracks (previously other tracks
61
+ kept their naive share, overflowing the container).
62
+ - **`counter()` in `content:`** — `counter-reset` and
63
+ `counter-increment` (with optional amounts) resolve in document
64
+ order; flat namespace, no `counters()` nesting.
65
+ - **Pseudo-elements in table-internal boxes** — `::before`/`::after`
66
+ now render on rows, row groups, and table boxes per CSS
67
+ anonymous-box rules (a row pseudo becomes a leading anonymous cell);
68
+ previously they were silently dropped.
69
+
70
+ ## 0.24.0 — 2026-07-05
71
+
72
+ Motion timing completeness: transitions and keyframes behave per spec.
73
+
74
+ ### Added
75
+
76
+ - **Per-property transitions** — `transition: color 150ms linear, width
77
+ 400ms ease` runs each property on its own duration and timing;
78
+ longhand lists pair cyclically per spec. (Previously the first
79
+ duration and one timing function applied to everything.)
80
+ - **Interrupted transitions continue** — a retargeted transition starts
81
+ from its current blended value instead of jumping back to the previous
82
+ target (reversals no longer flash).
83
+ - **Per-keyframe `animation-timing-function`** — a timing function
84
+ declared inside a keyframe applies from that stop to the next.
85
+ - **Keyframe `var()`/`light-dark()` re-resolution** — scheme flips and
86
+ custom-property changes retarget a running animation in place, without
87
+ restarting it.
88
+
3
89
  ## 0.23.0 — 2026-07-05
4
90
 
5
91
  Positioning: `relative` offsets apply, and `sticky` arrives.
@@ -2,6 +2,10 @@ import type { KeyEvent } from '../input/keyboard.js';
2
2
  export declare class TextBuffer {
3
3
  private _text;
4
4
  private _cursor;
5
+ /** Insertion cap in code units, as HTML maxlength counts them. */
6
+ maxLength: number | null;
7
+ /** Blocks all mutation while leaving caret movement live. */
8
+ readOnly: boolean;
5
9
  constructor(initial?: string);
6
10
  get text(): string;
7
11
  set text(value: string);
@@ -2,6 +2,10 @@ import { nextGraphemeBoundary, prevGraphemeBoundary } from '../layout/unicode.js
2
2
  export class TextBuffer {
3
3
  _text;
4
4
  _cursor;
5
+ /** Insertion cap in code units, as HTML maxlength counts them. */
6
+ maxLength = null;
7
+ /** Blocks all mutation while leaving caret movement live. */
8
+ readOnly = false;
5
9
  constructor(initial = '') {
6
10
  this._text = initial;
7
11
  this._cursor = initial.length;
@@ -13,16 +17,28 @@ export class TextBuffer {
13
17
  this._cursor = Math.max(0, Math.min(value, this._text.length));
14
18
  }
15
19
  insert(chars) {
20
+ if (this.readOnly)
21
+ return;
22
+ if (this.maxLength !== null) {
23
+ const room = Math.max(0, this.maxLength - this._text.length);
24
+ chars = chars.slice(0, room);
25
+ }
26
+ if (chars.length === 0)
27
+ return;
16
28
  this._text = this._text.substring(0, this._cursor) + chars + this._text.substring(this._cursor);
17
29
  this._cursor += chars.length;
18
30
  }
19
31
  delete() {
32
+ if (this.readOnly)
33
+ return;
20
34
  if (this._cursor >= this._text.length)
21
35
  return;
22
36
  const end = nextGraphemeBoundary(this._text, this._cursor);
23
37
  this._text = this._text.substring(0, this._cursor) + this._text.substring(end);
24
38
  }
25
39
  backspace() {
40
+ if (this.readOnly)
41
+ return;
26
42
  if (this._cursor <= 0)
27
43
  return;
28
44
  const start = prevGraphemeBoundary(this._text, this._cursor);
@@ -34,10 +50,14 @@ export class TextBuffer {
34
50
  home() { this._cursor = 0; }
35
51
  end() { this._cursor = this._text.length; }
36
52
  clearToStart() {
53
+ if (this.readOnly)
54
+ return;
37
55
  this._text = this._text.substring(this._cursor);
38
56
  this._cursor = 0;
39
57
  }
40
58
  clearToEnd() {
59
+ if (this.readOnly)
60
+ return;
41
61
  this._text = this._text.substring(0, this._cursor);
42
62
  }
43
63
  handleKey(key) {
@@ -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) {
@@ -1,5 +1,6 @@
1
1
  import { TermNode } from '../renderer/node.js';
2
2
  import { NodeMap } from '../utils/node-map.js';
3
+ import { CounterContext } from './counters.js';
3
4
  export type StyleMap = NodeMap<ResolvedStyle>;
4
5
  import { CSSStyleSheet } from './parser.js';
5
6
  import { type MediaContext } from './media.js';
@@ -45,6 +46,9 @@ export interface ResolvedStyle {
45
46
  gridRowEnd: number | null;
46
47
  gridRowSpan: number | null;
47
48
  gridTemplateAreas: string | null;
49
+ gridAutoFlow: 'row' | 'column';
50
+ counterReset: string | null;
51
+ counterIncrement: string | null;
48
52
  gridArea: string | null;
49
53
  animationName: string | null;
50
54
  animationDuration: number;
@@ -53,6 +57,15 @@ export interface ResolvedStyle {
53
57
  transitionProperty: string | null;
54
58
  transitionDuration: number;
55
59
  transitionTimingFunction: string;
60
+ /** Longhand lists, paired cyclically per spec into `transitions`. */
61
+ transitionDurations: number[];
62
+ transitionTimings: string[];
63
+ /** Per-property transition config, resolved after cascade. */
64
+ transitions: Array<{
65
+ property: string;
66
+ duration: number;
67
+ timing: string;
68
+ }>;
56
69
  borderStyle: 'none' | 'single' | 'double' | 'rounded' | 'heavy' | 'ascii' | 'eighth-cell-inner' | 'eighth-cell-outer' | 'half-cell-inner' | 'half-cell-outer' | 'full-cell';
57
70
  borderCorner: 'none' | 'h' | 'v';
58
71
  borderColor: string;
@@ -86,5 +99,14 @@ export interface ResolvedStyle {
86
99
  export declare function defaultStyle(tag?: string): ResolvedStyle;
87
100
  export declare function resolveStyles(root: TermNode, stylesheet: CSSStyleSheet, media?: MediaContext, availWidth?: number, availHeight?: number): Map<number, ResolvedStyle>;
88
101
  export declare function filterByMedia(stylesheet: CSSStyleSheet, context: MediaContext): CSSStyleSheet;
89
- export declare function resolveNode(node: TermNode, stylesheet: CSSStyleSheet, styles: Map<number, ResolvedStyle>, variables: Map<number, Map<string, string>>, scheme?: 'dark' | 'light'): void;
102
+ export declare function resolveNode(node: TermNode, stylesheet: CSSStyleSheet, styles: Map<number, ResolvedStyle>, variables: Map<number, Map<string, string>>, scheme?: 'dark' | 'light', counters?: CounterContext): void;
90
103
  export declare function applyDeclaration(style: ResolvedStyle, property: string, value: string, scheme?: 'dark' | 'light'): void;
104
+ /**
105
+ * Pair the transition lists per spec: durations/timings repeat
106
+ * cyclically to cover every listed property.
107
+ */
108
+ export declare function pairTransitions(style: ResolvedStyle): Array<{
109
+ property: string;
110
+ duration: number;
111
+ timing: string;
112
+ }>;
@@ -1,5 +1,6 @@
1
1
  import { NodeMap } from '../utils/node-map.js';
2
2
  import { resolvePseudoElements } from './pseudo-elements.js';
3
+ import { CounterContext } from './counters.js';
3
4
  import { matchesSelector } from './selector.js';
4
5
  import { resolveColor, expandLightDark } from './color.js';
5
6
  import { parseCellValue, parseSizeValue, parseJustify, parseAlign } from './values.js';
@@ -51,11 +52,15 @@ export function defaultStyle(tag) {
51
52
  gridTemplateColumns: null, gridTemplateRows: null,
52
53
  gridColumnStart: null, gridColumnEnd: null, gridColumnSpan: null,
53
54
  gridRowStart: null, gridRowEnd: null, gridRowSpan: null,
54
- gridTemplateAreas: null, gridArea: null,
55
+ gridTemplateAreas: null,
56
+ gridAutoFlow: 'row',
57
+ counterReset: null, counterIncrement: null, gridArea: null,
55
58
  animationName: null, animationDuration: 0, animationIterationCount: 1,
56
59
  animationTimingFunction: 'ease',
57
60
  transitionProperty: null, transitionDuration: 0,
58
61
  transitionTimingFunction: 'ease',
62
+ transitionDurations: [], transitionTimings: [],
63
+ transitions: [],
59
64
  borderStyle: 'none', borderColor: 'default', borderCorner: 'none',
60
65
  borderTop: true, borderRight: true, borderBottom: true, borderLeft: true,
61
66
  boxSizing: 'border-box',
@@ -204,7 +209,7 @@ function evaluateSupports(condition) {
204
209
  const property = condition.substring(0, colonIdx).trim();
205
210
  return SUPPORTED_PROPERTIES.has(property);
206
211
  }
207
- export function resolveNode(node, stylesheet, styles, variables, scheme = 'dark') {
212
+ export function resolveNode(node, stylesheet, styles, variables, scheme = 'dark', counters = new CounterContext()) {
208
213
  if (node.nodeType === 'element') {
209
214
  const vars = variables.get(node.id) ?? new Map();
210
215
  const parentStyle = node.parent ? styles.get(node.parent.id) : undefined;
@@ -212,14 +217,20 @@ export function resolveNode(node, stylesheet, styles, variables, scheme = 'dark'
212
217
  styles.set(node.id, resolved);
213
218
  node.cache.resolvedStyle = resolved;
214
219
  node.cache.classAttr = node.attributes.get('class') ?? '';
215
- resolvePseudoElements(node, stylesheet, styles, vars, scheme);
220
+ // Counter effects apply in document order, before pseudo content
221
+ counters.enter(resolved);
222
+ resolvePseudoElements(node, stylesheet, styles, vars, scheme, counters);
216
223
  }
217
224
  for (const child of node.children) {
218
- resolveNode(child, stylesheet, styles, variables, scheme);
225
+ resolveNode(child, stylesheet, styles, variables, scheme, counters);
219
226
  }
220
227
  }
221
228
  function computeStyle(node, stylesheet, vars, parentStyle, scheme = 'dark') {
222
229
  const style = defaultStyle(node.tag);
230
+ // border-collapse is an inherited property (CSS 2.2 §17.6), so opting a
231
+ // container in flows down to the siblings the collapse applies between.
232
+ if (parentStyle)
233
+ style.borderCollapse = parentStyle.borderCollapse;
223
234
  // Collect all matching declarations with specificity
224
235
  const scored = [];
225
236
  let order = 0;
@@ -272,6 +283,8 @@ function computeStyle(node, stylesheet, vars, parentStyle, scheme = 'dark') {
272
283
  applyDeclaration(style, decl.property, resolveVar(decl.value, vars), scheme);
273
284
  }
274
285
  }
286
+ // Pair transition longhand lists now the cascade is complete
287
+ style.transitions = pairTransitions(style);
275
288
  return style;
276
289
  }
277
290
  function parseInlineStyle(text) {
@@ -288,6 +301,7 @@ function parseInlineStyle(text) {
288
301
  return result;
289
302
  }
290
303
  const INHERITABLE_PROPERTIES = new Set([
304
+ 'border-collapse',
291
305
  'color', 'font-weight', 'font-style', 'text-decoration',
292
306
  'white-space', 'word-break', 'text-align', 'visibility', 'opacity',
293
307
  ]);
@@ -313,6 +327,9 @@ function applyInherit(style, property, parentStyle) {
313
327
  case 'white-space':
314
328
  style.whiteSpace = parentStyle.whiteSpace;
315
329
  break;
330
+ case 'border-collapse':
331
+ style.borderCollapse = parentStyle.borderCollapse;
332
+ break;
316
333
  case 'word-break':
317
334
  style.wordBreak = parentStyle.wordBreak;
318
335
  break;
@@ -553,6 +570,15 @@ export function applyDeclaration(style, property, value, scheme = 'dark') {
553
570
  case 'grid-area':
554
571
  parseGridArea(style, value);
555
572
  break;
573
+ case 'grid-auto-flow':
574
+ style.gridAutoFlow = value.startsWith('column') ? 'column' : 'row';
575
+ break;
576
+ case 'counter-reset':
577
+ style.counterReset = value === 'none' ? null : value;
578
+ break;
579
+ case 'counter-increment':
580
+ style.counterIncrement = value === 'none' ? null : value;
581
+ break;
556
582
  case 'animation':
557
583
  parseAnimationShorthand(style, value);
558
584
  break;
@@ -563,10 +589,12 @@ export function applyDeclaration(style, property, value, scheme = 'dark') {
563
589
  style.transitionProperty = value === 'none' ? null : value;
564
590
  break;
565
591
  case 'transition-duration':
566
- style.transitionDuration = parseDuration(value);
592
+ style.transitionDurations = value.split(',').map(v => parseDuration(v.trim()));
593
+ style.transitionDuration = style.transitionDurations[0] ?? 0;
567
594
  break;
568
595
  case 'transition-timing-function':
569
- style.transitionTimingFunction = value;
596
+ style.transitionTimings = splitTimingList(value);
597
+ style.transitionTimingFunction = style.transitionTimings[0] ?? 'ease';
570
598
  break;
571
599
  case 'animation-name':
572
600
  style.animationName = value === 'none' ? null : value;
@@ -806,35 +834,85 @@ function parseAnimationShorthand(style, value) {
806
834
  }
807
835
  }
808
836
  }
809
- /** Parse `transition: <property> <duration> [...]`, comma-separated groups. */
837
+ /** Split a timing-function list on commas, keeping function args intact. */
838
+ function splitTimingList(value) {
839
+ const out = [];
840
+ let depth = 0;
841
+ let current = '';
842
+ for (const ch of value) {
843
+ if (ch === '(')
844
+ depth++;
845
+ if (ch === ')')
846
+ depth--;
847
+ if (ch === ',' && depth === 0) {
848
+ out.push(current.trim());
849
+ current = '';
850
+ }
851
+ else
852
+ current += ch;
853
+ }
854
+ if (current.trim())
855
+ out.push(current.trim());
856
+ return out;
857
+ }
858
+ /**
859
+ * Parse `transition: <property> <duration> <timing> [...]` — each
860
+ * comma-separated group configures one property (or `all`).
861
+ */
810
862
  function parseTransitionShorthand(style, value) {
811
863
  const trimmed = value.trim();
812
864
  if (trimmed === 'none') {
813
865
  style.transitionProperty = null;
814
866
  style.transitionDuration = 0;
867
+ style.transitionDurations = [];
868
+ style.transitionTimings = [];
815
869
  return;
816
870
  }
817
- const { easing, rest } = extractEasingFunction(trimmed);
818
- if (easing)
819
- style.transitionTimingFunction = easing;
820
871
  const properties = [];
821
- let duration = 0;
822
- for (const group of rest.split(',')) {
823
- for (const token of group.trim().split(/\s+/)) {
872
+ const durations = [];
873
+ const timings = [];
874
+ for (const groupRaw of splitTimingList(trimmed)) {
875
+ const { easing, rest } = extractEasingFunction(groupRaw);
876
+ let property = 'all';
877
+ let duration = 0;
878
+ let timing = easing ?? 'ease';
879
+ for (const token of rest.trim().split(/\s+/)) {
824
880
  if (/^\d*\.?\d+(ms|s)$/.test(token)) {
825
881
  if (duration === 0)
826
882
  duration = parseDuration(token);
827
883
  }
828
884
  else if (TIMING_KEYWORDS.has(token)) {
829
- style.transitionTimingFunction = token;
885
+ timing = token;
830
886
  }
831
887
  else if (token) {
832
- properties.push(token);
888
+ property = token;
833
889
  }
834
890
  }
891
+ properties.push(property);
892
+ durations.push(duration);
893
+ timings.push(timing);
835
894
  }
836
895
  style.transitionProperty = properties.length > 0 ? properties.join(',') : 'all';
837
- style.transitionDuration = duration;
896
+ style.transitionDurations = durations;
897
+ style.transitionTimings = timings;
898
+ style.transitionDuration = durations[0] ?? 0;
899
+ style.transitionTimingFunction = timings[0] ?? 'ease';
900
+ }
901
+ /**
902
+ * Pair the transition lists per spec: durations/timings repeat
903
+ * cyclically to cover every listed property.
904
+ */
905
+ export function pairTransitions(style) {
906
+ if (!style.transitionProperty)
907
+ return [];
908
+ const properties = style.transitionProperty.split(',').map(p => p.trim()).filter(Boolean);
909
+ const durations = style.transitionDurations.length > 0 ? style.transitionDurations : [style.transitionDuration];
910
+ const timings = style.transitionTimings.length > 0 ? style.transitionTimings : [style.transitionTimingFunction];
911
+ return properties.map((property, i) => ({
912
+ property,
913
+ duration: durations[i % durations.length] ?? 0,
914
+ timing: timings[i % timings.length] ?? 'ease',
915
+ }));
838
916
  }
839
917
  function parseDuration(value) {
840
918
  if (value.endsWith('ms'))
@@ -0,0 +1,13 @@
1
+ /**
2
+ * CSS counters for `content: counter(name)` — threaded through the
3
+ * style-resolution walk, which visits elements in document order.
4
+ * Flat namespace (no per-scope nesting or `counters()` joining yet);
5
+ * incremental restyles reuse the values from the last full resolve.
6
+ */
7
+ import type { ResolvedStyle } from './compute.js';
8
+ export declare class CounterContext {
9
+ private values;
10
+ /** Apply an element's counter-reset then counter-increment. */
11
+ enter(style: ResolvedStyle): void;
12
+ value(name: string): number;
13
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * CSS counters for `content: counter(name)` — threaded through the
3
+ * style-resolution walk, which visits elements in document order.
4
+ * Flat namespace (no per-scope nesting or `counters()` joining yet);
5
+ * incremental restyles reuse the values from the last full resolve.
6
+ */
7
+ export class CounterContext {
8
+ values = new Map();
9
+ /** Apply an element's counter-reset then counter-increment. */
10
+ enter(style) {
11
+ if (style.counterReset) {
12
+ for (const { name, amount } of parseCounterList(style.counterReset, 0)) {
13
+ this.values.set(name, amount);
14
+ }
15
+ }
16
+ if (style.counterIncrement) {
17
+ for (const { name, amount } of parseCounterList(style.counterIncrement, 1)) {
18
+ this.values.set(name, (this.values.get(name) ?? 0) + amount);
19
+ }
20
+ }
21
+ }
22
+ value(name) {
23
+ return this.values.get(name) ?? 0;
24
+ }
25
+ }
26
+ /** Parse `name [amount] name [amount] …` with a per-property default. */
27
+ function parseCounterList(value, defaultAmount) {
28
+ const out = [];
29
+ const tokens = value.trim().split(/\s+/);
30
+ for (let i = 0; i < tokens.length; i++) {
31
+ const name = tokens[i];
32
+ const next = tokens[i + 1];
33
+ if (next !== undefined && /^-?\d+$/.test(next)) {
34
+ out.push({ name, amount: parseInt(next, 10) });
35
+ i++;
36
+ }
37
+ else {
38
+ out.push({ name, amount: defaultAmount });
39
+ }
40
+ }
41
+ return out;
42
+ }
@@ -8,7 +8,7 @@ const LAYOUT_PROPERTIES = [
8
8
  'width', 'height', 'minWidth', 'minHeight', 'maxWidth', 'maxHeight',
9
9
  'borderStyle', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
10
10
  'position', 'top', 'right', 'bottom', 'left',
11
- 'overflow', 'whiteSpace',
11
+ 'overflow', 'whiteSpace', 'borderCollapse',
12
12
  ];
13
13
  /**
14
14
  * Incremental style resolution — re-resolves dirty nodes and their
@@ -1,9 +1,10 @@
1
1
  import { TermNode } from '../renderer/node.js';
2
2
  import { CSSStyleSheet } from './parser.js';
3
3
  import { type ResolvedStyle } from './compute.js';
4
+ import type { CounterContext } from './counters.js';
4
5
  /**
5
6
  * Resolve ::before/::after for one element: build the pseudo's style from
6
7
  * matching rules, materialise (or drop) its synthetic box on the node, and
7
8
  * record the style under the synthetic node's id.
8
9
  */
9
- export declare function resolvePseudoElements(node: TermNode, stylesheet: CSSStyleSheet, styles: Map<number, ResolvedStyle>, vars: Map<string, string>, scheme: 'dark' | 'light'): void;
10
+ export declare function resolvePseudoElements(node: TermNode, stylesheet: CSSStyleSheet, styles: Map<number, ResolvedStyle>, vars: Map<string, string>, scheme: 'dark' | 'light', counters?: CounterContext): void;
@@ -8,13 +8,13 @@ import { defaultStyle, applyDeclaration } from './compute.js';
8
8
  * matching rules, materialise (or drop) its synthetic box on the node, and
9
9
  * record the style under the synthetic node's id.
10
10
  */
11
- export function resolvePseudoElements(node, stylesheet, styles, vars, scheme) {
12
- node.pseudoBefore = syncPseudo(node, node.pseudoBefore, 'before', stylesheet, styles, vars, scheme);
13
- node.pseudoAfter = syncPseudo(node, node.pseudoAfter, 'after', stylesheet, styles, vars, scheme);
11
+ export function resolvePseudoElements(node, stylesheet, styles, vars, scheme, counters) {
12
+ node.pseudoBefore = syncPseudo(node, node.pseudoBefore, 'before', stylesheet, styles, vars, scheme, counters);
13
+ node.pseudoAfter = syncPseudo(node, node.pseudoAfter, 'after', stylesheet, styles, vars, scheme, counters);
14
14
  }
15
- function syncPseudo(host, existing, which, stylesheet, styles, vars, scheme) {
15
+ function syncPseudo(host, existing, which, stylesheet, styles, vars, scheme, counters) {
16
16
  const declarations = collectPseudoDeclarations(host, which, stylesheet, vars);
17
- const content = resolveContent(declarations, host);
17
+ const content = resolveContent(declarations, host, counters);
18
18
  if (content === null) {
19
19
  if (existing)
20
20
  styles.delete(existing.id);
@@ -65,22 +65,24 @@ function collectPseudoDeclarations(host, which, stylesheet, vars) {
65
65
  * The winning `content` value rendered to text, or null when the pseudo
66
66
  * generates no box (no content declaration, `none`/`normal`, or empty).
67
67
  */
68
- function resolveContent(declarations, host) {
68
+ function resolveContent(declarations, host, counters) {
69
69
  const winner = declarations.filter(d => d.property === 'content').pop();
70
70
  if (!winner)
71
71
  return null;
72
- const text = parseContentValue(winner.value, host);
72
+ const text = parseContentValue(winner.value, host, counters);
73
73
  return text === '' ? null : text;
74
74
  }
75
- const CONTENT_TOKEN = /"([^"]*)"|'([^']*)'|attr\(\s*([^)\s]+)\s*\)/g;
76
- /** content: a space-separated sequence of quoted strings and attr() lookups. */
77
- function parseContentValue(value, host) {
75
+ const CONTENT_TOKEN = /"([^"]*)"|'([^']*)'|attr\(\s*([^)\s]+)\s*\)|counter\(\s*([a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)/g;
76
+ /** content: quoted strings, attr() lookups, and counter() values. */
77
+ function parseContentValue(value, host, counters) {
78
78
  const trimmed = value.trim();
79
79
  if (trimmed === 'none' || trimmed === 'normal')
80
80
  return '';
81
81
  let text = '';
82
82
  for (const match of trimmed.matchAll(CONTENT_TOKEN)) {
83
- if (match[3] !== undefined)
83
+ if (match[4] !== undefined)
84
+ text += String(counters?.value(match[4]) ?? 0);
85
+ else if (match[3] !== undefined)
84
86
  text += host.attributes.get(match[3]) ?? '';
85
87
  else
86
88
  text += match[1] ?? match[2] ?? '';
package/dist/src/index.js CHANGED
@@ -27,6 +27,7 @@ import { toggleDetails } from './input/details.js';
27
27
  import { cycleSelect } from './input/select.js';
28
28
  import { labelledControl } from './input/label.js';
29
29
  import { TextBuffer } from './components/text-buffer.js';
30
+ import { syncEditConstraints } from './input/edit-constraints.js';
30
31
  import { StdinRouter, matchOSC11, parseOSC11Scheme } from './terminal/stdin-router.js';
31
32
  import { detectCapabilities, matchCPR, parseCPRRow } from './terminal/capabilities.js';
32
33
  import { copyToClipboard } from './terminal/clipboard.js';
@@ -490,6 +491,8 @@ export function run(AppComponent, options) {
490
491
  if (focused && (focused.tag === 'input' || focused.tag === 'textarea') && !isCheckableInput(focused)) {
491
492
  if (!focused.textBuffer)
492
493
  focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
494
+ syncEditConstraints(focused);
495
+ const oldValue = focused.textBuffer.text;
493
496
  if (focused.textBuffer.handleKey(key)) {
494
497
  const newValue = focused.textBuffer.text;
495
498
  focused.attributes.set('value', newValue);
@@ -498,7 +501,10 @@ export function run(AppComponent, options) {
498
501
  ctx.onSetText(textChild, newValue);
499
502
  // Enqueue the input element itself for repaint (cursor may have moved)
500
503
  ctx.queue.enqueuePaintOnly(focused);
501
- dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
504
+ // input fires on value change, not caret movement (per spec)
505
+ if (newValue !== oldValue) {
506
+ dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
507
+ }
502
508
  scheduleRender();
503
509
  return;
504
510
  }
@@ -528,13 +534,17 @@ export function run(AppComponent, options) {
528
534
  if (focused && (focused.tag === 'input' || focused.tag === 'textarea')) {
529
535
  if (!focused.textBuffer)
530
536
  focused.textBuffer = new TextBuffer(focused.attributes.get('value') ?? '');
537
+ syncEditConstraints(focused);
538
+ const oldValue = focused.textBuffer.text;
531
539
  focused.textBuffer.insert(text);
532
540
  const newValue = focused.textBuffer.text;
533
541
  focused.attributes.set('value', newValue);
534
542
  const textChild = focused.children.find(c => c.nodeType === 'text');
535
543
  if (textChild)
536
544
  ctx.onSetText(textChild, newValue);
537
- dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
545
+ if (newValue !== oldValue) {
546
+ dispatchEvent(focused, 'input', { value: newValue, cursor: focused.textBuffer.cursor });
547
+ }
538
548
  scheduleRender();
539
549
  }
540
550
  else {
@@ -0,0 +1,7 @@
1
+ import { TermNode } from '../renderer/node.js';
2
+ /**
3
+ * Mirror the element's maxlength/readonly attributes onto its TextBuffer
4
+ * before each edit, so attribute changes made after focus apply
5
+ * immediately (Svelte removes boolean attributes when false).
6
+ */
7
+ export declare function syncEditConstraints(node: TermNode): void;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Mirror the element's maxlength/readonly attributes onto its TextBuffer
3
+ * before each edit, so attribute changes made after focus apply
4
+ * immediately (Svelte removes boolean attributes when false).
5
+ */
6
+ export function syncEditConstraints(node) {
7
+ if (!node.textBuffer)
8
+ return;
9
+ const max = node.attributes.get('maxlength');
10
+ const parsed = max !== undefined ? parseInt(max, 10) : NaN;
11
+ node.textBuffer.maxLength = Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
12
+ const readonly = node.attributes.get('readonly');
13
+ node.textBuffer.readOnly = readonly !== undefined && readonly !== 'false';
14
+ }
@@ -7,13 +7,17 @@ import { imageIntrinsicSize } from '../render/image.js';
7
7
  import { resolveSize, constrain } from './size.js';
8
8
  import { parseCellLength } from '../css/values.js';
9
9
  /**
10
- * Check if two adjacent siblings both have borders on their shared edge.
11
- * Returns true if the gap between them should be reduced by 1 to account
12
- * for the visual spacing inherent in box-drawing border characters.
10
+ * Check if two adjacent siblings should share a single border line on
11
+ * their common edge (overlapping by 1 cell so the strokes merge into
12
+ * junction glyphs). Opt-in: both siblings must be under border-collapse:
13
+ * collapse — an inherited property, so setting it on the container (or
14
+ * :root) is enough — and both must have a border on the shared edge.
13
15
  */
14
16
  function shouldAdjustBorderGap(prevStyle, nextStyle, direction) {
15
17
  if (!prevStyle || !nextStyle)
16
18
  return false;
19
+ if (prevStyle.borderCollapse !== 'collapse' || nextStyle.borderCollapse !== 'collapse')
20
+ return false;
17
21
  if (prevStyle.borderStyle === 'none' || nextStyle.borderStyle === 'none')
18
22
  return false;
19
23
  if (direction === 'vertical') {
@@ -526,7 +530,7 @@ function isRowLevelDisplay(display) {
526
530
  || display === 'table-column-group';
527
531
  }
528
532
  function cellsOfRow(trNode, styles) {
529
- return trNode.children.filter(c => isCellContent(c, styles));
533
+ return childrenWithPseudos(trNode).filter(c => isCellContent(c, styles));
530
534
  }
531
535
  /**
532
536
  * Group the children of a table or row-group into rows. Explicit table-rows
@@ -574,15 +578,15 @@ function collectTableRows(children, styles) {
574
578
  const display = child.nodeType === 'element' ? styles.get(child.id)?.display : undefined;
575
579
  if (display === 'table-header-group') {
576
580
  flushStray();
577
- headerRows.push(...groupIntoRows(child.children, styles));
581
+ headerRows.push(...groupIntoRows(childrenWithPseudos(child), styles));
578
582
  }
579
583
  else if (display === 'table-footer-group') {
580
584
  flushStray();
581
- footerRows.push(...groupIntoRows(child.children, styles));
585
+ footerRows.push(...groupIntoRows(childrenWithPseudos(child), styles));
582
586
  }
583
587
  else if (display === 'table-row-group') {
584
588
  flushStray();
585
- bodyRows.push(...groupIntoRows(child.children, styles));
589
+ bodyRows.push(...groupIntoRows(childrenWithPseudos(child), styles));
586
590
  }
587
591
  else {
588
592
  // table-rows and stray cell content accumulate; captions/columns
@@ -774,7 +778,7 @@ function placeRows(rows, grid, styles, boxes, x, startY, colWidths, tableWidth,
774
778
  return rowY - startY - (rows.length > 0 ? gaps.row : 0);
775
779
  }
776
780
  function layoutTable(node, styles, boxes, x, y, availW, availH) {
777
- return layoutTableChildren(node.children, styles.get(node.id), styles, boxes, x, y, availW, availH);
781
+ return layoutTableChildren(childrenWithPseudos(node), styles.get(node.id), styles, boxes, x, y, availW, availH);
778
782
  }
779
783
  /**
780
784
  * Table layout over a list of children. Called with a table element's
@@ -829,6 +833,10 @@ function layoutGrid(node, styles, boxes, x, y, availW, availH, style) {
829
833
  }
830
834
  const rowHeights = parseGridTemplate(style.gridTemplateRows ?? '', availH);
831
835
  const numCols = colWidths.length || 1;
836
+ const columnFlow = style.gridAutoFlow === 'column';
837
+ // Column flow wraps at the explicit row count; implicit columns take
838
+ // the last explicit column's width.
839
+ const numRows = rowHeights.length || 1;
832
840
  const gap = style.gap ?? 0;
833
841
  // Pre-compute border-adjusted gaps for grid children
834
842
  let hGap = gap;
@@ -850,7 +858,12 @@ function layoutGrid(node, styles, boxes, x, y, availW, availH, style) {
850
858
  for (const child of children) {
851
859
  const childStyle = styles.get(child.id);
852
860
  const area = childStyle?.gridArea ? areas?.byName.get(childStyle.gridArea) : undefined;
853
- const placed = resolveGridPlacement(childStyle, area, cursor, numCols);
861
+ const placed = columnFlow
862
+ ? resolveGridPlacementColumn(childStyle, area, cursor, numRows)
863
+ : resolveGridPlacement(childStyle, area, cursor, numCols);
864
+ while (columnFlow && placed.col >= colWidths.length && colWidths.length > 0) {
865
+ colWidths.push(colWidths[colWidths.length - 1]);
866
+ }
854
867
  const colW = trackSpanSize(colWidths, placed.col, placed.span, hGap);
855
868
  // Measure content height with unconstrained available height
856
869
  const size = layoutNode(child, styles, boxes, 0, 0, colW, availH);
@@ -884,6 +897,30 @@ function layoutGrid(node, styles, boxes, x, y, availW, availH, style) {
884
897
  const totalHeight = totalRows === 0 ? 0 : trackOffset(trackHeights, totalRows, vGap) - vGap;
885
898
  return { width: maxWidth, height: totalHeight };
886
899
  }
900
+ /**
901
+ * grid-auto-flow: column — auto-placed items fill down each column,
902
+ * wrapping to a new (implicit) column after the explicit row count.
903
+ * Explicit line placements behave as in row flow.
904
+ */
905
+ function resolveGridPlacementColumn(childStyle, area, cursor, numRows) {
906
+ if (area) {
907
+ return {
908
+ col: area.colStart, span: area.colEnd - area.colStart,
909
+ row: area.rowStart, rowSpan: area.rowEnd - area.rowStart,
910
+ };
911
+ }
912
+ if (cursor.row >= numRows) {
913
+ cursor.row = 0;
914
+ cursor.col++;
915
+ }
916
+ const col = childStyle?.gridColumnStart != null ? childStyle.gridColumnStart - 1 : cursor.col;
917
+ const row = childStyle?.gridRowStart != null ? childStyle.gridRowStart - 1 : cursor.row;
918
+ const span = childStyle?.gridColumnSpan ?? 1;
919
+ const rowSpan = childStyle?.gridRowSpan ?? 1;
920
+ cursor.col = col;
921
+ cursor.row = row + rowSpan;
922
+ return { col, span, row, rowSpan };
923
+ }
887
924
  /**
888
925
  * Where one grid item lands: a named area wins outright; otherwise
889
926
  * explicit lines/spans combine with the auto-flow cursor, which only
@@ -1053,12 +1090,34 @@ function resolveTrackSizes(parts, availSize) {
1053
1090
  sizes.push(0);
1054
1091
  }
1055
1092
  }
1056
- // Distribute remaining space to fr units, honouring minmax() minimums
1093
+ // Distribute remaining space to fr units. minmax() minimums are
1094
+ // honoured with redistribution: a track clamped to its minimum leaves
1095
+ // the pool, and the freed space re-splits among the rest (iterate
1096
+ // until no new clamps).
1057
1097
  if (frParts.length > 0) {
1058
- const totalFr = frParts.reduce((sum, p) => sum + p.fr, 0);
1059
- const remaining = Math.max(0, availSize - fixedTotal);
1060
- for (const { index, fr, min } of frParts) {
1061
- sizes[index] = Math.max(min, Math.floor(remaining * fr / totalFr));
1098
+ let pool = Math.max(0, availSize - fixedTotal);
1099
+ let flexible = [...frParts];
1100
+ const clamped = new Set();
1101
+ while (true) {
1102
+ const totalFr = flexible.reduce((sum, p) => sum + p.fr, 0);
1103
+ let reclamped = false;
1104
+ for (const part of flexible) {
1105
+ const share = totalFr > 0 ? Math.floor(pool * part.fr / totalFr) : 0;
1106
+ if (share < part.min) {
1107
+ sizes[part.index] = part.min;
1108
+ pool -= part.min;
1109
+ clamped.add(part.index);
1110
+ reclamped = true;
1111
+ }
1112
+ }
1113
+ flexible = flexible.filter(p => !clamped.has(p.index));
1114
+ if (!reclamped) {
1115
+ const finalFr = flexible.reduce((sum, p) => sum + p.fr, 0);
1116
+ for (const part of flexible) {
1117
+ sizes[part.index] = finalFr > 0 ? Math.max(part.min, Math.floor(Math.max(0, pool) * part.fr / finalFr)) : part.min;
1118
+ }
1119
+ break;
1120
+ }
1062
1121
  }
1063
1122
  }
1064
1123
  return sizes;
@@ -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) {
@@ -350,7 +350,11 @@ function paintCheckable(node, buffer, box, visuals, clip) {
350
350
  }
351
351
  }
352
352
  function paintInput(node, buffer, box, visuals, clip) {
353
- const value = node.attributes.get('value') ?? '';
353
+ const rawValue = node.attributes.get('value') ?? '';
354
+ // Password values paint as bullets; layout and scrolling use the
355
+ // masked string, which has the same length as the real value.
356
+ const value = node.attributes.get('type') === 'password'
357
+ ? '•'.repeat(rawValue.length) : rawValue;
354
358
  const isFocused = node.attributes.has('data-focused');
355
359
  const cursor = node.textBuffer?.cursor ?? value.length;
356
360
  const style = node.cache.resolvedStyle;
package/docs/elements.md CHANGED
@@ -37,8 +37,9 @@ unprevented, the `href` opens in the local browser.
37
37
 
38
38
  | Control | Rendering | Interaction |
39
39
  |---|---|---|
40
- | `input` (text) | one-row editor on the grid | readline-style editing with a real cursor; `input` events carry `{ value, cursor }` |
41
- | `textarea` | multi-line editor | as above |
40
+ | `input` (text) | one-row editor on the grid | readline-style editing with a real cursor; `input` events carry `{ value, cursor }`; `maxlength` caps typing and paste; `readonly` blocks edits but keeps caret movement |
41
+ | `input type="password"` | as text, value painted as `•` bullets | editing as text inputs; the real value stays in `value` |
42
+ | `textarea` | multi-line editor | as text inputs, including `maxlength`/`readonly` |
42
43
  | `input type="checkbox"` | `[x]` / `[ ]` (3×1) | `Space` or click toggles; `change`/`input` carry `{ checked, value }` |
43
44
  | `input type="radio"` | `(•)` / `( )` | selecting unchecks same-`name` radios across the tree; never untoggles itself |
44
45
  | `select` + `option`/`optgroup` | selected label + `▾`, sized to the longest option | popup-less cycling: `ArrowUp`/`ArrowDown` move with wraparound, `Space`/`Enter`/click advance; `change` carries `{ value }` |
package/docs/layout.md CHANGED
@@ -74,9 +74,13 @@ support:
74
74
  .nav { grid-area: nav; }
75
75
  ```
76
76
 
77
- Deviations: auto-flow is row-based (`grid-auto-flow: column` is not
78
- implemented); `minmax()` minimums on `fr` tracks are enforced without
79
- redistribution; spanning content doesn't stretch individual tracks.
77
+ `grid-auto-flow: column` fills down each column, wrapping to a new
78
+ (implicit) column after the explicit row count; implicit columns take
79
+ the last explicit column's width. `minmax()` minimums on `fr` tracks
80
+ redistribute — a track clamped to its minimum leaves the pool and the
81
+ freed space re-splits among the remaining `fr` tracks.
82
+
83
+ Deviations: spanning content doesn't stretch individual tracks.
80
84
 
81
85
  ## Tables
82
86
 
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
@@ -130,7 +130,8 @@ block/inline box per its display default.
130
130
  | `strong`/`b`, `em`/`i`, `u`, `s`/`del`, `mark`, `kbd`, `abbr`, `samp`, `var` | text attributes (bold/italic/underline/strikethrough/colour) | — |
131
131
  | `a` | underlined, focusable; Enter/click opens `href` in the local browser | [`<a>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) |
132
132
  | `table` and friends | full table layout: colspan/rowspan, header/footer groups, caption, `colgroup`/`col` width hints, collapse/separate borders, `empty-cells` | [`<table>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table) |
133
- | `input` (text) | single-line editor with cursor, `value`, `input` events | [`<input>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) |
133
+ | `input` (text) | single-line editor with cursor, `value`, `input` events; `maxlength`, `readonly` | [`<input>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) |
134
+ | `input type="password"` | value masked as `•` bullets; editing as text | [password](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/password) |
134
135
  | `input type="checkbox" / "radio"` | glyph toggles; `checked` attribute/property; `change`+`input` events | [checkbox](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox), [radio](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio) |
135
136
  | `textarea` | multi-line editing | [`<textarea>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) |
136
137
  | `button` | focusable, centred text, `click` on Enter/click | [`<button>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button) |
@@ -153,10 +154,14 @@ All standard matching semantics. Reference: [MDN selectors](https://developer.mo
153
154
  `:nth-of-type()`, `:nth-last-of-type()` (full An+B), `:not()`, `:is()`,
154
155
  `:where()`, `:checked`, `:disabled`, `:enabled`
155
156
  - Pseudo-elements: `::before`, `::after` (single-colon legacy accepted)
156
- with `content:` strings, `attr(x)`, space-separated concatenation, and
157
- `none`/`""`. `counter()` is not supported. Pseudo boxes are inline and
158
- invisible to `:empty`/`:nth-*`. Known gap: pseudo-elements don't render
159
- inside table-internal boxes.
157
+ with `content:` strings, `attr(x)`, `counter(name)` (with
158
+ `counter-reset` / `counter-increment`, including explicit amounts),
159
+ space-separated concatenation, and `none`/`""`. Counters use a flat
160
+ namespace — no per-scope nesting or `counters()` joining — and update
161
+ on full style resolution, so an incremental restyle can serve stale
162
+ numbers until the next full pass. Pseudo boxes are inline and invisible
163
+ to `:empty`/`:nth-*`. In table-internal boxes they render per §17.2.1:
164
+ a pseudo on a row or table box becomes an anonymous cell/row.
160
165
  - Specificity, source order, and inline-`style` precedence follow the
161
166
  [cascade](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascade/Cascade).
162
167
 
@@ -171,12 +176,12 @@ adaptations. Lengths are cells (`cell`/`ch`, `%`, or `calc()`).
171
176
  | [Box model](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_box_model) | `width`, `height`, `min/max-width`, `min/max-height`, `padding(-*)`, `margin(-*)` (incl. `auto` centring and margin collapse), `box-sizing`, `overflow` (`hidden`/`scroll`/`auto` with real scrolling + fading scrollbars) |
172
177
  | [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 |
173
178
  | [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` |
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 |
179
+ | [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`, `grid-auto-flow: row \| column` (column flow wraps at the explicit row count; implicit columns take the last explicit column's width). Fractional `minmax()` minimums redistribute: a track clamped to its minimum leaves the pool and the freed space re-splits among the rest |
175
180
  | [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) |
176
181
  | [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
- | Borders | `border`/`border-style`/`border-color`/`border-corner` + per-side toggles (terminal values above) |
182
+ | Borders | `border`/`border-style`/`border-color`/`border-corner` + per-side toggles (terminal values above). `border-collapse: collapse` on a container (inherited — `:root` works) makes adjacent bordered siblings in block flow, flex, and grid share a single border line with junction glyphs (`├` `┬` `┼`) — a cell-grid extension of the table property |
178
183
  | [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` |
184
+ | [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
185
  | [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
186
 
182
187
  ### Animation & transition semantics on the grid
@@ -185,12 +190,14 @@ Colours interpolate in RGB at ~30fps; single cell/ch lengths interpolate
185
190
  to whole cells (movement steps cell by cell); every other supported
186
191
  property applies discretely, switching at the segment midpoint (the CSS
187
192
  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.
193
+ each frame. Easing applies per keyframe segment (a timing function
194
+ declared inside a keyframe overrides the element's for that segment);
195
+ non-interpolable values switch when eased progress crosses the midpoint.
196
+ Transitions run per property with their own duration/timing;
197
+ interruptions continue from the current blended value. Keyframe
198
+ `var()`/`light-dark()` re-resolves on scheme/custom-property changes
199
+ without restarting the animation. Deviation: no `transition-delay` /
200
+ `animation-delay`.
194
201
 
195
202
  ## Values, functions and at-rules
196
203
 
package/docs/selectors.md CHANGED
@@ -64,13 +64,28 @@ a[href$=".pdf"]::after { content: " [pdf]"; color: red; }
64
64
  ```
65
65
 
66
66
  `content:` supports quoted strings, `attr(x)` against the host element,
67
- space-separated concatenation, and `none`/`""` (no box). `counter()` is
68
- not supported. Pseudo boxes are invisible to `:empty` and `:nth-*`, and
69
- style like inline elements (they inherit the host's visual attributes
70
- unless the pseudo rule overrides them).
67
+ `counter(name)`, space-separated concatenation, and `none`/`""` (no
68
+ box). Pseudo boxes are invisible to `:empty` and `:nth-*`, and style
69
+ like inline elements (they inherit the host's visual attributes unless
70
+ the pseudo rule overrides them). In table-internal boxes they follow
71
+ CSS anonymous-box rules: a `::before` on a row renders as a leading
72
+ anonymous cell.
71
73
 
72
- Known gap: pseudo-elements don't render inside table-internal layout
73
- boxes.
74
+ ### Counters
75
+
76
+ `counter-reset` and `counter-increment` work with optional amounts:
77
+
78
+ ```css
79
+ .doc { counter-reset: sec; }
80
+ .section { counter-increment: sec; }
81
+ .section::before { content: counter(sec) ". "; }
82
+ ```
83
+
84
+ Counters resolve in document order with a flat namespace — nested
85
+ elements share the same counter rather than creating a scoped one, and
86
+ `counters()` (the nested-join form) is not supported. Values update on
87
+ full style resolution, so an incremental restyle can briefly show stale
88
+ numbers.
74
89
 
75
90
  ## What re-resolves when
76
91
 
@@ -61,10 +61,17 @@ Browser values (`solid`, `dashed`…) are ignored.
61
61
  - `border-corner: h | v | none` biases which line wins at corners.
62
62
  - `border-color` is standard, including `currentColor`.
63
63
  - Tables support `border-collapse: collapse` with shared grid lines.
64
+ - `border-collapse: collapse` extends beyond tables: on any container
65
+ (it inherits, so `:root` opts the whole app in), adjacent bordered
66
+ siblings — stacked blocks, flex items, grid items — share a single
67
+ border line, merging into junction glyphs (`├` `┬` `┼`) in the border's
68
+ family. Without it, sibling frames stay separate as in browsers.
69
+ `border-collapse: separate` on a child opts it back out.
64
70
 
65
71
  ```css
66
72
  .panel { border: rounded; border-color: cyan; }
67
73
  .rule { border-top: true; border-style: single; } /* horizontal rule */
74
+ .list { border-collapse: collapse; } /* children share dividers */
68
75
  ```
69
76
 
70
77
  ## Colour
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svelterm/core",
3
- "version": "0.23.0",
3
+ "version": "0.27.0",
4
4
  "description": "Svelte 5 components rendered to the terminal with real CSS",
5
5
  "type": "module",
6
6
  "exports": {