@svelterm/core 0.24.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,72 @@
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
+
3
70
  ## 0.24.0 — 2026-07-05
4
71
 
5
72
  Motion timing completeness: transitions and keyframes behave per spec.
@@ -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,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;
@@ -95,7 +99,7 @@ export interface ResolvedStyle {
95
99
  export declare function defaultStyle(tag?: string): ResolvedStyle;
96
100
  export declare function resolveStyles(root: TermNode, stylesheet: CSSStyleSheet, media?: MediaContext, availWidth?: number, availHeight?: number): Map<number, ResolvedStyle>;
97
101
  export declare function filterByMedia(stylesheet: CSSStyleSheet, context: MediaContext): CSSStyleSheet;
98
- 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;
99
103
  export declare function applyDeclaration(style: ResolvedStyle, property: string, value: string, scheme?: 'dark' | 'light'): void;
100
104
  /**
101
105
  * Pair the transition lists per spec: durations/timings repeat
@@ -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,7 +52,9 @@ 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,
@@ -206,7 +209,7 @@ function evaluateSupports(condition) {
206
209
  const property = condition.substring(0, colonIdx).trim();
207
210
  return SUPPORTED_PROPERTIES.has(property);
208
211
  }
209
- export function resolveNode(node, stylesheet, styles, variables, scheme = 'dark') {
212
+ export function resolveNode(node, stylesheet, styles, variables, scheme = 'dark', counters = new CounterContext()) {
210
213
  if (node.nodeType === 'element') {
211
214
  const vars = variables.get(node.id) ?? new Map();
212
215
  const parentStyle = node.parent ? styles.get(node.parent.id) : undefined;
@@ -214,14 +217,20 @@ export function resolveNode(node, stylesheet, styles, variables, scheme = 'dark'
214
217
  styles.set(node.id, resolved);
215
218
  node.cache.resolvedStyle = resolved;
216
219
  node.cache.classAttr = node.attributes.get('class') ?? '';
217
- 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);
218
223
  }
219
224
  for (const child of node.children) {
220
- resolveNode(child, stylesheet, styles, variables, scheme);
225
+ resolveNode(child, stylesheet, styles, variables, scheme, counters);
221
226
  }
222
227
  }
223
228
  function computeStyle(node, stylesheet, vars, parentStyle, scheme = 'dark') {
224
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;
225
234
  // Collect all matching declarations with specificity
226
235
  const scored = [];
227
236
  let order = 0;
@@ -292,6 +301,7 @@ function parseInlineStyle(text) {
292
301
  return result;
293
302
  }
294
303
  const INHERITABLE_PROPERTIES = new Set([
304
+ 'border-collapse',
295
305
  'color', 'font-weight', 'font-style', 'text-decoration',
296
306
  'white-space', 'word-break', 'text-align', 'visibility', 'opacity',
297
307
  ]);
@@ -317,6 +327,9 @@ function applyInherit(style, property, parentStyle) {
317
327
  case 'white-space':
318
328
  style.whiteSpace = parentStyle.whiteSpace;
319
329
  break;
330
+ case 'border-collapse':
331
+ style.borderCollapse = parentStyle.borderCollapse;
332
+ break;
320
333
  case 'word-break':
321
334
  style.wordBreak = parentStyle.wordBreak;
322
335
  break;
@@ -557,6 +570,15 @@ export function applyDeclaration(style, property, value, scheme = 'dark') {
557
570
  case 'grid-area':
558
571
  parseGridArea(style, value);
559
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;
560
582
  case 'animation':
561
583
  parseAnimationShorthand(style, value);
562
584
  break;
@@ -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;
@@ -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/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,10 +176,10 @@ 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
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` |
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.24.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": {