concertina 1.0.0 → 1.1.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/README.md CHANGED
@@ -609,6 +609,32 @@ computedWidth = fixedWidth ?? (maxContentChars × charWidthHint + CELL_H_PADDING
609
609
 
610
610
  `CELL_H_PADDING` is 16 px. A 14 px monospace font uses `charWidthHint: 8`. Widths are resolved once at `INIT` and re-sent with every `WINDOW_UPDATE`.
611
611
 
612
+ ### DOM-traced pitch
613
+
614
+ The worker computes a `rowHeight` from `rowHeightHint` at `INIT` time, but real rows may be taller due to padding, borders, or font metrics that differ from the hint. **Pitch** lets the main thread override the worker's row height with a DOM-measured value.
615
+
616
+ ```tsx
617
+ // Measure actual row height from the DOM (e.g. from a ghost/warmup row)
618
+ const [measuredPitch, setMeasuredPitch] = useState(0);
619
+ const ghostRef = useCallback((el) => {
620
+ if (el) setMeasuredPitch(el.getBoundingClientRect().height);
621
+ }, []);
622
+
623
+ // Push to the store — VirtualChamber and scroll handler read it automatically
624
+ useEffect(() => {
625
+ if (measuredPitch > 0) store.setPitch(measuredPitch);
626
+ }, [measuredPitch, store]);
627
+ ```
628
+
629
+ When `pitch > 0`, VirtualChamber uses it instead of `layout.rowHeight` for:
630
+ - **Spacer height**: `totalRows × pitch` (scrollbar range)
631
+ - **Pool node height**: each pool `<div>` gets `height: pitch`
632
+ - **translateY**: row positioning uses `rowIndex × pitch`
633
+ - **Scroll handler**: `SET_WINDOW` start row = `Math.floor(scrollTop / pitch)`
634
+ - **scrollToRow**: programmatic scroll uses `row × pitch`
635
+
636
+ When `pitch` is `0` (the default), all math falls back to the worker's `layout.rowHeight`. This means existing code that doesn't call `setPitch` continues to work unchanged.
637
+
612
638
  ---
613
639
 
614
640
  ## License
@@ -29,7 +29,8 @@ var INITIAL_STORE_STATE = {
29
29
  window: null,
30
30
  backpressure: { strategy: "NOMINAL", queueDepth: 0, avgRenderMs: asMs(0) },
31
31
  totalRows: 0,
32
- error: null
32
+ error: null,
33
+ pitch: 0
33
34
  };
34
35
 
35
36
  // src/core/data-worker.ts
@@ -74,7 +74,8 @@ var INITIAL_STORE_STATE = {
74
74
  window: null,
75
75
  backpressure: { strategy: "NOMINAL", queueDepth: 0, avgRenderMs: asMs(0) },
76
76
  totalRows: 0,
77
- error: null
77
+ error: null,
78
+ pitch: 0
78
79
  };
79
80
 
80
81
  // src/core/atomic-store.ts
@@ -127,6 +128,10 @@ var AtomicStore = class {
127
128
  setStatus(status, error) {
128
129
  this.merge({ status, error: error ?? this.state.error });
129
130
  }
131
+ /** Set DOM-traced row pitch. 0 resets to Worker-computed rowHeight. */
132
+ setPitch(px) {
133
+ if (this.state.pitch !== px) this.merge({ pitch: px });
134
+ }
130
135
  merge(patch) {
131
136
  const next = { ...this.state, ...patch };
132
137
  this.state = next;
@@ -228,14 +233,16 @@ function useStabilityOrchestrator(options) {
228
233
  const el = containerRef_internal.current;
229
234
  const lay = layoutRef.current;
230
235
  if (!el || !lay || lay.rowHeight === 0) return;
231
- const startRow = asRowIndex(Math.floor(el.scrollTop / lay.rowHeight));
236
+ const p = store.getState().pitch;
237
+ const rh = p > 0 ? p : lay.rowHeight;
238
+ const startRow = asRowIndex(Math.floor(el.scrollTop / rh));
232
239
  const rowCount = lay.viewportRows + overscanRows * 2;
233
240
  workerRef.current?.postMessage({
234
241
  type: "SET_WINDOW",
235
242
  startRow,
236
243
  rowCount
237
244
  });
238
- }, [overscanRows]);
245
+ }, [overscanRows, store]);
239
246
  const containerRef = (0, import_react2.useCallback)(
240
247
  (el) => {
241
248
  if (containerRef_internal.current) {
@@ -322,13 +329,15 @@ function useStabilityOrchestrator(options) {
322
329
  const el = containerRef_internal.current;
323
330
  const lay = layoutRef.current;
324
331
  if (!el || !lay) return;
325
- el.scrollTo({ top: row * lay.rowHeight, behavior: "smooth" });
332
+ const p = store.getState().pitch;
333
+ const rh = p > 0 ? p : lay.rowHeight;
334
+ el.scrollTo({ top: row * rh, behavior: "smooth" });
326
335
  workerRef.current?.postMessage({
327
336
  type: "SET_WINDOW",
328
337
  startRow: asRowIndex(Math.max(0, row - overscanRows)),
329
338
  rowCount: lay.viewportRows + overscanRows * 2
330
339
  });
331
- }, [overscanRows]);
340
+ }, [overscanRows, store]);
332
341
  return (0, import_react2.useMemo)(
333
342
  () => ({ containerRef, store, ingest, scrollToRow }),
334
343
  [containerRef, store, ingest, scrollToRow]
@@ -428,7 +437,7 @@ function buildRowProxy(accessors, schema, localRow) {
428
437
  }
429
438
  };
430
439
  }
431
- function buildPoolAssignments(win, layout, poolSize) {
440
+ function buildPoolAssignments(win, rowHeight, poolSize) {
432
441
  const assignments = [];
433
442
  for (let i = 0; i < win.rowCount; i++) {
434
443
  const rowIndex = win.startRow + i;
@@ -436,7 +445,7 @@ function buildPoolAssignments(win, layout, poolSize) {
436
445
  poolSlot: asPoolSlot(i % poolSize),
437
446
  rowIndex: asRowIndex(rowIndex),
438
447
  localIndex: i,
439
- y: rowIndex * layout.rowHeight
448
+ y: rowIndex * rowHeight
440
449
  });
441
450
  }
442
451
  return assignments;
@@ -445,14 +454,16 @@ var OVERSCAN_ROWS = 3;
445
454
  function VirtualChamber({ store, renderRow, containerRef, className, style }) {
446
455
  const layout = useAtomicSlice(store, (s) => s.layout);
447
456
  const win = useAtomicSlice(store, (s) => s.window);
457
+ const pitch = useAtomicSlice(store, (s) => s.pitch);
448
458
  const accessors = (0, import_react3.useMemo)(
449
459
  () => win ? buildAccessors(win.buffer, win.rowCount) : null,
450
460
  [win]
451
461
  );
452
462
  const poolSize = layout ? layout.viewportRows + OVERSCAN_ROWS * 2 : 0;
463
+ const rh = (pitch && pitch > 0 ? pitch : layout?.rowHeight) ?? 0;
453
464
  const assignments = (0, import_react3.useMemo)(
454
- () => win && layout ? buildPoolAssignments(win, layout, poolSize) : [],
455
- [win, layout, poolSize]
465
+ () => win && rh > 0 ? buildPoolAssignments(win, rh, poolSize) : [],
466
+ [win, rh, poolSize]
456
467
  );
457
468
  if (!layout) return null;
458
469
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
@@ -471,7 +482,7 @@ function VirtualChamber({ store, renderRow, containerRef, className, style }) {
471
482
  "div",
472
483
  {
473
484
  "aria-hidden": true,
474
- style: { height: layout.totalHeight, pointerEvents: "none" }
485
+ style: { height: layout.totalRows * rh, pointerEvents: "none" }
475
486
  }
476
487
  ),
477
488
  accessors !== null && assignments.map(({ poolSlot, rowIndex, localIndex, y }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -484,7 +495,7 @@ function VirtualChamber({ store, renderRow, containerRef, className, style }) {
484
495
  top: 0,
485
496
  left: 0,
486
497
  right: 0,
487
- height: layout.rowHeight,
498
+ height: rh,
488
499
  transform: `translateY(${y}px)`,
489
500
  willChange: "transform",
490
501
  contain: "layout style"
@@ -136,6 +136,8 @@ interface StoreState {
136
136
  readonly backpressure: BackpressureState;
137
137
  readonly totalRows: number;
138
138
  readonly error: string | null;
139
+ /** DOM-traced row pitch in px. 0 = use layout.rowHeight from Worker. */
140
+ readonly pitch: number;
139
141
  }
140
142
 
141
143
  /**
@@ -156,6 +158,8 @@ declare class AtomicStore {
156
158
  dispatch(event: WorkerEvent): void;
157
159
  /** Transition stream lifecycle status. */
158
160
  setStatus(status: StreamStatus, error?: string): void;
161
+ /** Set DOM-traced row pitch. 0 resets to Worker-computed rowHeight. */
162
+ setPitch(px: number): void;
159
163
  private merge;
160
164
  }
161
165
  type EqualityFn<T> = (a: T, b: T) => boolean;
@@ -136,6 +136,8 @@ interface StoreState {
136
136
  readonly backpressure: BackpressureState;
137
137
  readonly totalRows: number;
138
138
  readonly error: string | null;
139
+ /** DOM-traced row pitch in px. 0 = use layout.rowHeight from Worker. */
140
+ readonly pitch: number;
139
141
  }
140
142
 
141
143
  /**
@@ -156,6 +158,8 @@ declare class AtomicStore {
156
158
  dispatch(event: WorkerEvent): void;
157
159
  /** Transition stream lifecycle status. */
158
160
  setStatus(status: StreamStatus, error?: string): void;
161
+ /** Set DOM-traced row pitch. 0 resets to Worker-computed rowHeight. */
162
+ setPitch(px: number): void;
159
163
  private merge;
160
164
  }
161
165
  type EqualityFn<T> = (a: T, b: T) => boolean;
@@ -42,7 +42,8 @@ var INITIAL_STORE_STATE = {
42
42
  window: null,
43
43
  backpressure: { strategy: "NOMINAL", queueDepth: 0, avgRenderMs: asMs(0) },
44
44
  totalRows: 0,
45
- error: null
45
+ error: null,
46
+ pitch: 0
46
47
  };
47
48
 
48
49
  // src/core/atomic-store.ts
@@ -95,6 +96,10 @@ var AtomicStore = class {
95
96
  setStatus(status, error) {
96
97
  this.merge({ status, error: error ?? this.state.error });
97
98
  }
99
+ /** Set DOM-traced row pitch. 0 resets to Worker-computed rowHeight. */
100
+ setPitch(px) {
101
+ if (this.state.pitch !== px) this.merge({ pitch: px });
102
+ }
98
103
  merge(patch) {
99
104
  const next = { ...this.state, ...patch };
100
105
  this.state = next;
@@ -195,14 +200,16 @@ function useStabilityOrchestrator(options) {
195
200
  const el = containerRef_internal.current;
196
201
  const lay = layoutRef.current;
197
202
  if (!el || !lay || lay.rowHeight === 0) return;
198
- const startRow = asRowIndex(Math.floor(el.scrollTop / lay.rowHeight));
203
+ const p = store.getState().pitch;
204
+ const rh = p > 0 ? p : lay.rowHeight;
205
+ const startRow = asRowIndex(Math.floor(el.scrollTop / rh));
199
206
  const rowCount = lay.viewportRows + overscanRows * 2;
200
207
  workerRef.current?.postMessage({
201
208
  type: "SET_WINDOW",
202
209
  startRow,
203
210
  rowCount
204
211
  });
205
- }, [overscanRows]);
212
+ }, [overscanRows, store]);
206
213
  const containerRef = useCallback2(
207
214
  (el) => {
208
215
  if (containerRef_internal.current) {
@@ -289,13 +296,15 @@ function useStabilityOrchestrator(options) {
289
296
  const el = containerRef_internal.current;
290
297
  const lay = layoutRef.current;
291
298
  if (!el || !lay) return;
292
- el.scrollTo({ top: row * lay.rowHeight, behavior: "smooth" });
299
+ const p = store.getState().pitch;
300
+ const rh = p > 0 ? p : lay.rowHeight;
301
+ el.scrollTo({ top: row * rh, behavior: "smooth" });
293
302
  workerRef.current?.postMessage({
294
303
  type: "SET_WINDOW",
295
304
  startRow: asRowIndex(Math.max(0, row - overscanRows)),
296
305
  rowCount: lay.viewportRows + overscanRows * 2
297
306
  });
298
- }, [overscanRows]);
307
+ }, [overscanRows, store]);
299
308
  return useMemo(
300
309
  () => ({ containerRef, store, ingest, scrollToRow }),
301
310
  [containerRef, store, ingest, scrollToRow]
@@ -397,7 +406,7 @@ function buildRowProxy(accessors, schema, localRow) {
397
406
  }
398
407
  };
399
408
  }
400
- function buildPoolAssignments(win, layout, poolSize) {
409
+ function buildPoolAssignments(win, rowHeight, poolSize) {
401
410
  const assignments = [];
402
411
  for (let i = 0; i < win.rowCount; i++) {
403
412
  const rowIndex = win.startRow + i;
@@ -405,7 +414,7 @@ function buildPoolAssignments(win, layout, poolSize) {
405
414
  poolSlot: asPoolSlot(i % poolSize),
406
415
  rowIndex: asRowIndex(rowIndex),
407
416
  localIndex: i,
408
- y: rowIndex * layout.rowHeight
417
+ y: rowIndex * rowHeight
409
418
  });
410
419
  }
411
420
  return assignments;
@@ -414,14 +423,16 @@ var OVERSCAN_ROWS = 3;
414
423
  function VirtualChamber({ store, renderRow, containerRef, className, style }) {
415
424
  const layout = useAtomicSlice(store, (s) => s.layout);
416
425
  const win = useAtomicSlice(store, (s) => s.window);
426
+ const pitch = useAtomicSlice(store, (s) => s.pitch);
417
427
  const accessors = useMemo2(
418
428
  () => win ? buildAccessors(win.buffer, win.rowCount) : null,
419
429
  [win]
420
430
  );
421
431
  const poolSize = layout ? layout.viewportRows + OVERSCAN_ROWS * 2 : 0;
432
+ const rh = (pitch && pitch > 0 ? pitch : layout?.rowHeight) ?? 0;
422
433
  const assignments = useMemo2(
423
- () => win && layout ? buildPoolAssignments(win, layout, poolSize) : [],
424
- [win, layout, poolSize]
434
+ () => win && rh > 0 ? buildPoolAssignments(win, rh, poolSize) : [],
435
+ [win, rh, poolSize]
425
436
  );
426
437
  if (!layout) return null;
427
438
  return /* @__PURE__ */ jsxs(
@@ -440,7 +451,7 @@ function VirtualChamber({ store, renderRow, containerRef, className, style }) {
440
451
  "div",
441
452
  {
442
453
  "aria-hidden": true,
443
- style: { height: layout.totalHeight, pointerEvents: "none" }
454
+ style: { height: layout.totalRows * rh, pointerEvents: "none" }
444
455
  }
445
456
  ),
446
457
  accessors !== null && assignments.map(({ poolSlot, rowIndex, localIndex, y }) => /* @__PURE__ */ jsx(
@@ -453,7 +464,7 @@ function VirtualChamber({ store, renderRow, containerRef, className, style }) {
453
464
  top: 0,
454
465
  left: 0,
455
466
  right: 0,
456
- height: layout.rowHeight,
467
+ height: rh,
457
468
  transform: `translateY(${y}px)`,
458
469
  willChange: "transform",
459
470
  contain: "layout style"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concertina",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "React toolkit for layout stability.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",