Skip to content

Commit c18caea

Browse files
authored
fix(layout-engine): render underlined tabs flush with text underline (SD-3330) (#3611)
* fix(layout-engine): align underlined tab to text baseline (SD-3330) Underlined tab characters render their underline as a border-bottom on the tab box. The box was the full line height and bottom-aligned, so the border landed ~descent+half-leading below the text-decoration underline of adjacent text, making a continuous underline look broken where text meets tabs. Anchor the underlined tab box to the line-box top and end it at the baseline offset derived from the resolved line metrics (ascent/descent/lineHeight), so the border-bottom sits flush with the text underline. Gated to underlined tabs only; non-underlined tabs keep their previous geometry to avoid any tab-stop or line-layout regression. Covers SD-3347 signature/fill-in line rendering. No DOM measurement is added (SD-2957). Adds a regression test for both the inline and positioned paths. * fix(layout-engine): unify tab underline rendering with text-decoration Refactor the rendering of underlined tabs to use the same text-decoration mechanism as adjacent text, ensuring consistent baseline alignment and weight. This change addresses issues where the underline appeared misaligned due to the previous border-bottom approach. The tests have been updated to reflect these changes, ensuring that both underlined and plain tabs render correctly without unexpected borders. This aligns with the goal of maintaining visual fidelity across text and tab elements (SD-3330). * fix(layout-engine): revert tab underline to border, keep matched weight (SD-3330) The text-decoration approach for inline tab underlines required filling the tab with transparent whitespace for the browser to underline. That filler is selectable content, so selecting a line produced a broken/clipped selection highlight across the tab region, and it complicated the editor underline flow. Revert inline tabs to a border-bottom at the computed baseline offset (no filler, no selectable content, no selection artifacts). Keep the matched weight: the border width and text-decoration-thickness both use the shared font-scaled underlineThicknessPx, so text and tab underlines render at the same integer-px weight. Trade-off: the border position is a formula approximation of the text underline baseline (within ~1px) rather than the browser's exact placement, but it has no interaction side effects. * fix(layout-engine): repaint tab when its underline changes (SD-3330) Applying underline to an already-rendered tab in the editor did not show until an unrelated edit forced a rebuild. Root cause: deriveBlockVersion (the paint cache key the DomPainter compares to decide whether to reuse a fragment) encoded a tab run as just text + "tab", omitting its marks. Toggling underline produced an identical version, so the painter reused the cached, non-underlined fragment. Include the tab's underline (style + color) in its version, matching how text runs already encode their underline. Now a tab mark change invalidates the paint cache and the underline appears immediately. Adds a regression test asserting the version changes when a tab gains/recolors an underline. Pre-existing; reproduced on main. Independent of the tab underline rendering fix. * fix(layout-engine): give tab-only lines the paragraph font height (SD-3330) A line containing only tabs was measured at the 12px default and rendered ~4px shorter than a text or empty line in the same paragraph, so tab fill-in lines sat at a different height than typed text. Two parts: - Adapter (paragraph converter): a bare tab carries no font of its own, so give it the paragraph's resolved default font (mirroring the empty-paragraph run). - Measuring: when a paragraph has no sized text run, fall back to any run that carries a font size/family (e.g. a tab) instead of the 12px default, so the tab's font drives the line height. Add fontFamily/fontSize to the TabRun type (the tab legitimately carries them for line height and underline weight) and drop the matching casts. Tab widths are unaffected. Adds a measuring regression test asserting a tab-only line matches a text line of the same font. Pre-existing; independent of the underline work.
1 parent 51c5f50 commit c18caea

9 files changed

Lines changed: 245 additions & 29 deletions

File tree

packages/layout-engine/contracts/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,13 @@ export type TextRun = RunMarks & {
422422
export type TabRun = RunMarks & {
423423
kind: 'tab';
424424
text: '\t';
425+
/**
426+
* Font of the tab, inherited from the paragraph's resolved run properties. A tab has
427+
* no glyphs, but its font drives the line height (so a tab-only line matches a text
428+
* line) and the underline weight. Optional: not every producer sets it.
429+
*/
430+
fontFamily?: string;
431+
fontSize?: number;
425432
/** Width in pixels (assigned by measurer/resolver). */
426433
width?: number;
427434
tabStops?: TabStop[];

packages/layout-engine/layout-resolved/src/versionSignature.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import { deriveBlockVersion, sourceAnchorSignature } from './versionSignature.js';
3-
import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TextRun } from '@superdoc/contracts';
3+
import type { FlowBlock, ImageBlock, ImageRun, SourceAnchor, TableBlock, TabRun, TextRun } from '@superdoc/contracts';
44

55
describe('sourceAnchorSignature', () => {
66
it('is stable for equivalent source anchors with different object key order', () => {
@@ -67,6 +67,36 @@ describe('deriveBlockVersion - bidi', () => {
6767
});
6868
});
6969

70+
describe('deriveBlockVersion - tab underline', () => {
71+
const makeTabParagraph = (underline?: { style?: string; color?: string }): FlowBlock => ({
72+
kind: 'paragraph',
73+
id: 'p1',
74+
attrs: {},
75+
runs: [{ kind: 'tab', text: '\t', pmStart: 1, pmEnd: 2, ...(underline ? { underline } : {}) } as TabRun],
76+
});
77+
78+
// SD-3330: toggling underline on a tab must change the block version, otherwise the
79+
// DomPainter reuses the cached (non-underlined) fragment and the underline does not
80+
// appear until an unrelated edit forces a rebuild.
81+
it('produces a different version when a tab gains an underline', () => {
82+
const plain = deriveBlockVersion(makeTabParagraph());
83+
const underlined = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' }));
84+
expect(underlined).not.toBe(plain);
85+
});
86+
87+
it('produces a different version when the tab underline color changes', () => {
88+
const black = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' }));
89+
const red = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#FF0000' }));
90+
expect(red).not.toBe(black);
91+
});
92+
93+
it('is stable when the tab underline is identical', () => {
94+
const a = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' }));
95+
const b = deriveBlockVersion(makeTabParagraph({ style: 'single', color: '#000000' }));
96+
expect(a).toBe(b);
97+
});
98+
});
99+
70100
describe('deriveBlockVersion - table image content', () => {
71101
const makeTableWithImage = (image: ImageBlock): TableBlock => ({
72102
kind: 'table',

packages/layout-engine/layout-resolved/src/versionSignature.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,11 @@ export const deriveBlockVersion = (block: FlowBlock): string => {
307307
}
308308

309309
if (run.kind === 'tab') {
310-
return [run.text ?? '', 'tab'].join(',');
310+
// Include the underline (the only mark a tab paints, as a border) so toggling
311+
// underline on a tab changes the block version and the painter repaints it.
312+
// Without this, an underline applied to an already-rendered tab is not shown
313+
// until an unrelated edit forces a rebuild (SD-3330).
314+
return [run.text ?? '', 'tab', run.underline?.style ?? '', run.underline?.color ?? ''].join(',');
311315
}
312316

313317
if (run.kind === 'fieldAnnotation') {

packages/layout-engine/measuring/dom/src/index.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,31 @@ describe('measureBlock', () => {
8383
expect(measure.totalHeight).toBe(measure.lines[0].lineHeight);
8484
});
8585

86+
// SD-3330: a line containing only tabs must be measured at the run's font size,
87+
// not the 12px fallback, so it has the same height as a text line in the same
88+
// paragraph font. Without this, tab-only lines render shorter than text lines.
89+
it('measures a tab-only line at the run font size, not the 12px default', async () => {
90+
const textBlock: FlowBlock = {
91+
kind: 'paragraph',
92+
id: 'text',
93+
runs: [{ text: 'x', fontFamily: 'Arial', fontSize: 16 }],
94+
attrs: {},
95+
};
96+
const tabBlock: FlowBlock = {
97+
kind: 'paragraph',
98+
id: 'tab',
99+
runs: [{ kind: 'tab', text: '\t', fontFamily: 'Arial', fontSize: 16 }],
100+
attrs: {},
101+
};
102+
103+
const textMeasure = expectParagraphMeasure(await measureBlock(textBlock, 1000));
104+
const tabMeasure = expectParagraphMeasure(await measureBlock(tabBlock, 1000));
105+
106+
// The tab-only line height matches the text line (both 16px), not 12px × 1.15.
107+
expect(tabMeasure.lines[0].lineHeight).toBeCloseTo(textMeasure.lines[0].lineHeight, 1);
108+
expect(tabMeasure.lines[0].lineHeight).toBeGreaterThan(16);
109+
});
110+
86111
it('breaks lines when text exceeds maxWidth', async () => {
87112
const block: FlowBlock = {
88113
kind: 'paragraph',

packages/layout-engine/measuring/dom/src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -875,11 +875,28 @@ async function measureParagraphBlock(block: ParagraphBlock, maxWidth: number): P
875875
const firstTextRunWithSize = block.runs.find(
876876
(run): run is TextRun => isTextRun(run) && 'fontSize' in run && run.fontSize != null,
877877
);
878-
const fallbackFontSize = normalizeFontSize(firstTextRunWithSize?.fontSize, DEFAULT_PARAGRAPH_FONT_SIZE);
878+
// Prefer a text run's size, but fall back to any run (e.g. a tab) carrying a font
879+
// size when the paragraph has no sized text run. Otherwise a tab-only line is
880+
// measured at the 12px default and renders shorter than a text or empty line in the
881+
// same paragraph (SD-3330).
882+
const firstRunWithSize =
883+
firstTextRunWithSize ??
884+
block.runs.find(
885+
(run): run is Run & { fontSize: number } =>
886+
typeof (run as { fontSize?: unknown }).fontSize === 'number' && (run as { fontSize: number }).fontSize > 0,
887+
);
888+
const fallbackFontSize = normalizeFontSize(firstRunWithSize?.fontSize, DEFAULT_PARAGRAPH_FONT_SIZE);
879889
const firstTextRunWithFont = block.runs.find(
880890
(run): run is TextRun => isTextRun(run) && typeof run.fontFamily === 'string' && run.fontFamily.trim().length > 0,
881891
);
882-
const fallbackFontFamily = firstTextRunWithFont?.fontFamily ?? DEFAULT_PARAGRAPH_FONT_FAMILY;
892+
const firstRunWithFont =
893+
firstTextRunWithFont ??
894+
block.runs.find(
895+
(run): run is Run & { fontFamily: string } =>
896+
typeof (run as { fontFamily?: unknown }).fontFamily === 'string' &&
897+
(run as { fontFamily: string }).fontFamily.trim().length > 0,
898+
);
899+
const fallbackFontFamily = firstRunWithFont?.fontFamily ?? DEFAULT_PARAGRAPH_FONT_FAMILY;
883900
const normalizedRuns = normalizeRunsForMeasurement(block.runs as Run[], fallbackFontSize, fallbackFontFamily);
884901

885902
const markerInfo: ParagraphMeasure['marker'] | undefined = wordLayout?.marker
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect } from 'vitest';
2+
import type { Line, TabRun } from '@superdoc/contracts';
3+
import { renderInlineTabRun, renderPositionedTabRun } from './tab-run.js';
4+
5+
// A line with leading: lineHeight (24) exceeds ascent (12) + descent (4) by 8px.
6+
// Adjacent text draws its `text-decoration` underline near the baseline, which
7+
// sits at ascent + half-leading = 12 + 4 = 16px from the line-box top — well
8+
// above the line-box bottom at 24px. SD-3330: a tab underline drawn at the
9+
// line-box bottom lands ~8px below the text underline and the combined line
10+
// looks broken. The tab underline must land in the baseline region instead.
11+
const LINE: Line = {
12+
fromRun: 0,
13+
fromChar: 0,
14+
toRun: 0,
15+
toChar: 0,
16+
width: 200,
17+
ascent: 12,
18+
descent: 4,
19+
lineHeight: 24,
20+
};
21+
22+
const underlinedTab = (fontSize?: number): TabRun =>
23+
({
24+
kind: 'tab',
25+
text: '\t',
26+
width: 48,
27+
fontSize,
28+
underline: { style: 'single', color: '#000000' },
29+
}) as TabRun;
30+
31+
const plainTab = (): TabRun => ({ kind: 'tab', text: '\t', width: 48 });
32+
33+
describe('tab underline alignment (SD-3330)', () => {
34+
it('anchors the inline tab underline to the baseline region, not the line-box bottom', () => {
35+
const el = renderInlineTabRun(underlinedTab(), LINE, document, 0);
36+
37+
// Border-bottom (not a selectable text-decoration filler) at the box bottom; the box
38+
// top is pinned to the line-box top and ends at the underline offset, so the border
39+
// lands near the baseline rather than the line-box bottom.
40+
expect(el.style.borderBottom).toContain('solid');
41+
expect(el.style.verticalAlign).toBe('top');
42+
const offset = parseFloat(el.style.height);
43+
expect(offset).toBeGreaterThanOrEqual(LINE.ascent);
44+
expect(offset).toBeLessThan(LINE.lineHeight);
45+
});
46+
47+
it('matches the tab underline weight to the text underline (shared font-scaled thickness)', () => {
48+
const el = renderInlineTabRun(underlinedTab(48), LINE, document, 0);
49+
// 48 / 14 rounds to 3px — the same value applyRunStyles sets on text-decoration-thickness.
50+
expect(parseFloat(el.style.borderBottomWidth)).toBe(3);
51+
});
52+
53+
it('anchors the positioned tab underline to the baseline region, not the line-box bottom', () => {
54+
const { element } = renderPositionedTabRun(underlinedTab(), LINE, document, 0, 0, 0);
55+
56+
expect(element.style.borderBottom).toContain('solid');
57+
expect(element.style.visibility).not.toBe('hidden');
58+
const offset = parseFloat(element.style.height);
59+
expect(offset).toBeGreaterThanOrEqual(LINE.ascent);
60+
expect(offset).toBeLessThan(LINE.lineHeight);
61+
});
62+
63+
it('does not draw a border on a plain (non-underlined) inline tab', () => {
64+
const el = renderInlineTabRun(plainTab(), LINE, document, 0);
65+
expect(el.style.borderBottom).toBe('');
66+
});
67+
68+
it('keeps a plain positioned tab invisible with no border', () => {
69+
const { element } = renderPositionedTabRun(plainTab(), LINE, document, 0, 0, 0);
70+
expect(element.style.visibility).toBe('hidden');
71+
expect(element.style.borderBottom).toBe('');
72+
});
73+
});

packages/layout-engine/painters/dom/src/runs/tab-run.ts

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Line, LineSegment, Run } from '@superdoc/contracts';
2+
import { underlineThicknessPx } from './text-run.js';
23

34
export const renderInlineTabRun = (
45
run: Extract<Run, { kind: 'tab' }>,
@@ -15,10 +16,21 @@ export const renderInlineTabRun = (
1516

1617
tabEl.style.display = 'inline-block';
1718
tabEl.style.width = `${tabWidth}px`;
18-
tabEl.style.height = `${line.lineHeight}px`;
19-
tabEl.style.verticalAlign = 'bottom';
19+
if (run.underline) {
20+
// Underlined tabs render the underline as a border-bottom (the tab has no glyphs to
21+
// carry a text-decoration, and a transparent-filler text-decoration would become
22+
// selectable content and break line selection). A full-height, bottom-aligned box
23+
// would put the border ~descent+half-leading below the text-decoration underline of
24+
// adjacent text and look broken (SD-3330), so the box ends at the computed underline
25+
// offset with its top pinned to the line-box top, landing the border at the baseline.
26+
tabEl.style.height = `${underlineOffsetFromLineTop(line)}px`;
27+
tabEl.style.verticalAlign = 'top';
28+
} else {
29+
tabEl.style.height = `${line.lineHeight}px`;
30+
tabEl.style.verticalAlign = 'bottom';
31+
}
2032

21-
applyTabUnderline(tabEl, run);
33+
applyTabUnderlineBorder(tabEl, run);
2234

2335
if (styleId) {
2436
tabEl.setAttribute('styleid', styleId);
@@ -53,12 +65,17 @@ export const renderPositionedTabRun = (
5365
tabEl.style.left = `${tabStartX + indentOffset}px`;
5466
tabEl.style.top = '0px';
5567
tabEl.style.width = `${actualTabWidth}px`;
56-
tabEl.style.height = `${line.lineHeight}px`;
68+
// Underlined positioned tabs end the box at the text underline offset (not the full
69+
// line height) so the border-bottom aligns with adjacent text underlines (SD-3330).
70+
// Non-underlined positioned tabs keep the full line height (they are hidden below).
71+
// Positioned tabs are absolutely placed, so the baseline-aligned text-decoration path
72+
// used for inline tabs does not apply here; a border at the computed offset is used.
73+
tabEl.style.height = run.underline ? `${underlineOffsetFromLineTop(line)}px` : `${line.lineHeight}px`;
5774
tabEl.style.display = 'inline-block';
5875
tabEl.style.pointerEvents = 'none';
5976
tabEl.style.zIndex = '1';
6077

61-
applyTabUnderline(tabEl, run);
78+
applyTabUnderlineBorder(tabEl, run);
6279
if (!run.underline) {
6380
tabEl.style.visibility = 'hidden';
6481
}
@@ -73,23 +90,38 @@ export const renderPositionedTabRun = (
7390
return { element: tabEl, tabEndX, actualTabWidth };
7491
};
7592

76-
const applyTabUnderline = (tabEl: HTMLElement, run: Extract<Run, { kind: 'tab' }>): void => {
77-
// Apply underline styling if present (common for signature lines)
78-
//
79-
// Signature line use case: In documents with signature lines, tabs are often used
80-
// to create underlined blank spaces where signatures should go. The underline mark
81-
// is inherited from a parent node (e.g., a paragraph with underline formatting) and
82-
// applied to the tab, creating a visible underline even though the tab itself has
83-
// no visible text content.
84-
if (run.underline) {
85-
const underlineStyle = run.underline.style ?? 'single';
86-
// We must use an explicit color instead of currentColor because tab content is
87-
// invisible (no text). If we used currentColor, the underline would inherit the
88-
// text color, which might be transparent or the same as the background, making
89-
// the underline invisible. Using an explicit color (defaulting to black) ensures
90-
// the underline is always visible for signature lines.
91-
const underlineColor = run.underline.color ?? '#000000';
92-
const borderStyle = underlineStyle === 'double' ? 'double' : 'solid';
93-
tabEl.style.borderBottom = `1px ${borderStyle} ${underlineColor}`;
94-
}
93+
/**
94+
* Distance, in pixels from the top of the line box, at which a tab's underline
95+
* (border-bottom) should be drawn so it lines up with the `text-decoration`
96+
* underline of adjacent text runs.
97+
*
98+
* The line box places the baseline at `half-leading + ascent` from its top
99+
* (the remaining `half-leading + descent` sits below). `text-decoration`
100+
* underlines render slightly below the baseline, so we add a small gap that
101+
* scales with font size (capped by the descent). This is geometry derived from
102+
* the resolved line metrics — the painter never measures the DOM (SD-2957).
103+
*/
104+
const underlineOffsetFromLineTop = (line: Line): number => {
105+
const halfLeading = Math.max(0, (line.lineHeight - line.ascent - line.descent) / 2);
106+
const baselineFromTop = halfLeading + line.ascent;
107+
const underlineGap = Math.min(line.descent, line.lineHeight * 0.08);
108+
return baselineFromTop + underlineGap;
109+
};
110+
111+
/**
112+
* Underlined tabs (signature / fill-in lines) draw the underline as a border-bottom. The
113+
* tab has no glyphs to carry a text-decoration, so the weight is matched to adjacent text
114+
* by using the same font-scaled thickness text runs apply via text-decoration-thickness
115+
* (underlineThicknessPx), giving a uniform line across text and tabs (SD-3330). The run
116+
* carries the font size even though the rendered span sets none. An explicit color is used
117+
* (not currentColor) because the tab has no visible text to inherit a color from.
118+
*/
119+
const applyTabUnderlineBorder = (tabEl: HTMLElement, run: Extract<Run, { kind: 'tab' }>): void => {
120+
if (!run.underline) return;
121+
122+
const underlineStyle = run.underline.style ?? 'single';
123+
const underlineColor = run.underline.color ?? '#000000';
124+
const borderStyle = underlineStyle === 'double' ? 'double' : 'solid';
125+
const fontSize = run.fontSize ?? 16;
126+
tabEl.style.borderBottom = `${underlineThicknessPx(fontSize)}px ${borderStyle} ${underlineColor}`;
95127
};

packages/layout-engine/painters/dom/src/runs/text-run.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ import {
1616
const DEFAULT_SUPERSCRIPT_RAISE_RATIO = 0.33;
1717
const DEFAULT_SUBSCRIPT_LOWER_RATIO = 0.14;
1818

19+
/**
20+
* Underline thickness in px, scaled to font size. Shared by text runs
21+
* (`text-decoration-thickness`) and tab underlines (border width) so a run's
22+
* underline renders as a single uniform weight across text and tab characters,
23+
* matching Word, on any display density (SD-3330). The divisor approximates the
24+
* font's natural underline weight (≈ what `text-decoration-thickness: auto`
25+
* produces) while staying deterministic across platforms.
26+
*
27+
* Rounded to an integer px because CSS borders snap to integer device pixels
28+
* while `text-decoration-thickness` keeps fractional values; using an integer
29+
* makes the tab border and the text underline rasterize to the same line weight.
30+
*/
31+
export const underlineThicknessPx = (fontSize: number): number => Math.max(1, Math.round(fontSize / 14));
32+
1933
const hasVerticalPositioning = (run: TextRun): boolean =>
2034
normalizeBaselineShift(run.baselineShift) != null || run.vertAlign === 'superscript' || run.vertAlign === 'subscript';
2135

@@ -100,6 +114,11 @@ export const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false):
100114
decorations.push('underline');
101115
const u = run.underline;
102116
element.style.textDecorationStyle = u.style && u.style !== 'single' ? u.style : 'solid';
117+
// Pin the thickness to an explicit, font-scaled value (instead of `auto`, which
118+
// browsers render at the font's underline weight). Tab underlines reuse the same
119+
// value for their border width, so a run's underline is one uniform weight across
120+
// text and tab characters (SD-3330). See underlineThicknessPx.
121+
element.style.textDecorationThickness = `${underlineThicknessPx(run.fontSize)}px`;
103122
if (u.color) {
104123
element.style.textDecorationColor = u.color;
105124
}

packages/super-editor/src/editors/v1/core/layout-adapter/converters/paragraph.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,10 +834,19 @@ export function paragraphToFlowBlocks({
834834
} else {
835835
const run = inlineConverter(inlineConverterParams);
836836
if (run) {
837-
currentRuns.push(run);
838837
if (node.type === 'tab') {
838+
// A bare tab carries no font of its own, so a tab-only line would be
839+
// measured at the 12px measuring fallback and render shorter than a
840+
// text or empty line in the same paragraph. Give the tab the paragraph's
841+
// resolved default font (mirroring the empty-paragraph run) so its line
842+
// height matches (SD-3330). Explicit run properties from the DOCX still
843+
// win — only fill when absent.
844+
const tabRun = run as { fontSize?: number; fontFamily?: string };
845+
if (tabRun.fontSize == null) tabRun.fontSize = defaultSize;
846+
if (tabRun.fontFamily == null) tabRun.fontFamily = defaultFont;
839847
tabOrdinal += 1;
840848
}
849+
currentRuns.push(run);
841850
}
842851
}
843852
} catch (error) {

0 commit comments

Comments
 (0)