From 425f010eaf174698cc7fbfc87063fbfeb4364597 Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Mon, 23 Mar 2026 00:08:59 -0300 Subject: [PATCH 1/3] feat(tab): add H/P text labels to hammer-on and pull-off arcs In the Tab renderer, the arc connecting hammer-on and pull-off notes is now annotated with an "H" (ascending fret = hammer-on) or "P" (descending fret = pull-off) label above the arc midpoint. The label is drawn via an overridden paint() in TabSlurGlyph, reusing the same canvas.fillText path already used for whammy/bend slurText. TieGlyph's coordinate fields (_startX/Y, _endX/Y, _tieHeight, _shouldPaint) are widened from private to protected to allow the subclass to read them during paint. Fixes #2608 --- .../rendering/glyphs/TabBeatContainerGlyph.ts | 7 ++++- .../src/rendering/glyphs/TabSlurGlyph.ts | 8 +++++- .../alphatab/src/rendering/glyphs/TieGlyph.ts | 26 +++++++++++++++++-- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts index 437899ea0..2c5d9fd41 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts @@ -66,12 +66,17 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { } } if (!expanded) { + let slurText: string | undefined = undefined; + if (n.isHammerPullOrigin && n.hammerPullDestination) { + slurText = n.hammerPullDestination.fret >= n.fret ? 'H' : 'P'; + } const effectSlur: TabSlurGlyph = new TabSlurGlyph( `tab.slur.effect.${n.id}`, n, n.effectSlurDestination, false, - false + false, + slurText ); this._effectSlurs.push(effectSlur); this.addTie(effectSlur); diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index 97ba66030..88935e421 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -7,16 +7,22 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection */ export class TabSlurGlyph extends TabTieGlyph { private _forSlide: boolean; + private readonly _slurText?: string; - public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean) { + public constructor(slurEffectId: string, startNote: Note, endNote: Note, forSlide: boolean, forEnd:boolean, slurText?: string) { super(slurEffectId, startNote, endNote, forEnd); this._forSlide = forSlide; + this._slurText = slurText; } public override getTieHeight(startX: number, _startY: number, endX: number, _endY: number): number { return (Math.log(endX - startX + 1) * this.renderer.settings.notation.slurHeight) / 2; } + protected override getSlurText(): string | undefined { + return this._slurText; + } + public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean { // same type required if (this._forSlide !== forSlide) { diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts index 5b96249a0..8a0cf8119 100644 --- a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts @@ -165,6 +165,8 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { return; } + const isDown = this.tieDirection === BeamDirection.Down; + if (this.shouldDrawBendSlur()) { TieGlyph.drawBendSlur( canvas, @@ -172,7 +174,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { cy + this._startY, cx + this._endX, cy + this._endY, - this.tieDirection === BeamDirection.Down, + isDown, this.renderer.smuflMetrics.tieHeight ); } else { @@ -183,11 +185,31 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { cy + this._startY, cx + this._endX, cy + this._endY, - this.tieDirection === BeamDirection.Down, + isDown, this._tieHeight, this.renderer.smuflMetrics.tieMidpointThickness ); } + + const slurText = this.getSlurText(); + if (slurText) { + const midX = cx + (this._startX + this._endX) / 2; + const midY = cy + (this._startY + this._endY) / 2; + const apexOffset = this._tieHeight * 0.75; + const apexY = midY + (isDown ? apexOffset : -apexOffset); + const w = canvas.measureText(slurText).width; + const fontSize = canvas.font.size; + // text above: fontSize already includes descender space below the baseline, + // providing natural padding for capital letters like H/P + const textY = isDown + ? apexY + fontSize * 0.3 + : apexY - fontSize * 1.05; + canvas.fillText(slurText, midX - w / 2, textY); + } + } + + protected getSlurText(): string | undefined { + return undefined; } protected abstract shouldDrawBendSlur(): boolean; From e625a149aa526c265bc36b5037663a3c00401650 Mon Sep 17 00:00:00 2001 From: Rafael Sales Date: Thu, 2 Apr 2026 11:39:29 -0300 Subject: [PATCH 2/3] fix(tab): handle H/P chain and same-beat edge cases - Individual arcs per H/P pair in chains (e.g. 5{h} 7{h} 5 now renders separate H and P arcs instead of one collapsed arc) - Prevent tryExpand from merging slurs with different H/P labels (fixes label loss when multiple H/P on same beat share beam direction) - Guard existing effectSlur blocks to skip H/P notes, keeping legato slide rendering unaffected --- .../rendering/glyphs/TabBeatContainerGlyph.ts | 63 ++++++++++++++++--- .../src/rendering/glyphs/TabSlurGlyph.ts | 6 +- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts index 2c5d9fd41..adba41f6e 100644 --- a/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabBeatContainerGlyph.ts @@ -56,24 +56,22 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { const tapSlur: TabTieGlyph = new TabTieGlyph(`tab.tie.leftHandTap.${n.id}`, n, n, false); this.addTie(tapSlur); } - // start effect slur on first beat - if (n.isEffectSlurOrigin && n.effectSlurDestination) { + // H/P arc start-side: create individual arc per hammer-pull pair + if (n.isHammerPullOrigin && n.hammerPullDestination) { + const dest = n.hammerPullDestination; + const slurText = dest.fret >= n.fret ? 'H' : 'P'; let expanded: boolean = false; for (const slur of this._effectSlurs) { - if (slur.tryExpand(n, n.effectSlurDestination, false, false)) { + if (slur.tryExpand(n, dest, false, false, slurText)) { expanded = true; break; } } if (!expanded) { - let slurText: string | undefined = undefined; - if (n.isHammerPullOrigin && n.hammerPullDestination) { - slurText = n.hammerPullDestination.fret >= n.fret ? 'H' : 'P'; - } const effectSlur: TabSlurGlyph = new TabSlurGlyph( `tab.slur.effect.${n.id}`, n, - n.effectSlurDestination, + dest, false, false, slurText @@ -82,8 +80,53 @@ export class TabBeatContainerGlyph extends BeatContainerGlyph { this.addTie(effectSlur); } } - // end effect slur on last beat - if (n.isEffectSlurDestination && n.effectSlurOrigin) { + // H/P arc end-side: for cross-bar rendering + if (n.isHammerPullDestination && n.hammerPullOrigin) { + const origin = n.hammerPullOrigin; + const slurText = n.fret >= origin.fret ? 'H' : 'P'; + let expanded: boolean = false; + for (const slur of this._effectSlurs) { + if (slur.tryExpand(origin, n, false, true, slurText)) { + expanded = true; + break; + } + } + if (!expanded) { + const effectSlur: TabSlurGlyph = new TabSlurGlyph( + `tab.slur.effect.${origin.id}`, + origin, + n, + false, + true, + slurText + ); + this._effectSlurs.push(effectSlur); + this.addTie(effectSlur); + } + } + // start non-H/P effect slur (e.g. legato slide) + if (n.isEffectSlurOrigin && n.effectSlurDestination && !n.isHammerPullOrigin) { + let expanded: boolean = false; + for (const slur of this._effectSlurs) { + if (slur.tryExpand(n, n.effectSlurDestination, false, false)) { + expanded = true; + break; + } + } + if (!expanded) { + const effectSlur: TabSlurGlyph = new TabSlurGlyph( + `tab.slur.effect.${n.id}`, + n, + n.effectSlurDestination, + false, + false + ); + this._effectSlurs.push(effectSlur); + this.addTie(effectSlur); + } + } + // end non-H/P effect slur + if (n.isEffectSlurDestination && n.effectSlurOrigin && !n.isHammerPullDestination) { let expanded: boolean = false; for (const slur of this._effectSlurs) { if (slur.tryExpand(n.effectSlurOrigin, n, false, true)) { diff --git a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts index 88935e421..4c34d45c7 100644 --- a/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TabSlurGlyph.ts @@ -23,7 +23,11 @@ export class TabSlurGlyph extends TabTieGlyph { return this._slurText; } - public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean): boolean { + public tryExpand(startNote: Note, endNote: Note, forSlide: boolean, forEnd: boolean, slurText?: string): boolean { + // same label required (when provided) + if (slurText !== undefined && this._slurText !== slurText) { + return false; + } // same type required if (this._forSlide !== forSlide) { return false; From 6d996f18a61910c91330f658529cb86bda621852 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 2 May 2026 17:50:46 +0200 Subject: [PATCH 3/3] refactor: overflow and font handling --- packages/alphatab/src/NotationSettings.ts | 7 +- packages/alphatab/src/RenderingResources.ts | 13 ++- .../alphatab/src/rendering/glyphs/TieGlyph.ts | 39 ++++--- .../features/EffectsAndAnnotations.test.ts | 26 ++++- .../playground/src/apps/TestResultsApp.ts | 102 +++++++++++++----- 5 files changed, 145 insertions(+), 42 deletions(-) diff --git a/packages/alphatab/src/NotationSettings.ts b/packages/alphatab/src/NotationSettings.ts index 51514e260..669a00ed5 100644 --- a/packages/alphatab/src/NotationSettings.ts +++ b/packages/alphatab/src/NotationSettings.ts @@ -372,7 +372,12 @@ export enum NotationElement { /** * The slurs shown on bend effects within the score staff. */ - ScoreBendSlur = 55 + ScoreBendSlur = 55, + + /** + * The hammer-on pull-off text shown on slurs. + */ + EffectHammerOnPullOffText = 56, } /** diff --git a/packages/alphatab/src/RenderingResources.ts b/packages/alphatab/src/RenderingResources.ts index df711ecd7..e8ba8edbf 100644 --- a/packages/alphatab/src/RenderingResources.ts +++ b/packages/alphatab/src/RenderingResources.ts @@ -53,7 +53,8 @@ export class RenderingResources { [NotationElement.RepeatCount, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)], [NotationElement.BarNumber, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)], [NotationElement.ScoreBendSlur, new Font(RenderingResources._sansFont, 11, FontStyle.Plain)], - [NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)] + [NotationElement.EffectAlternateEndings, new Font(RenderingResources._serifFont, 15, FontStyle.Plain)], + [NotationElement.EffectHammerOnPullOffText, RenderingResources._effectFont] ]); /** @@ -381,8 +382,16 @@ export class RenderingResources { break; } + return this.getFontForNotationElement(notationElement); + } + + /** + * @internal + * @param element + */ + public getFontForNotationElement(notationElement: NotationElement): Font { return this.elementFonts.has(notationElement) ? this.elementFonts.get(notationElement)! - : RenderingResources.defaultFonts.get(NotationElement.ScoreWords)!; + : RenderingResources.defaultFonts.get(notationElement)!; } } diff --git a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts index 8a0cf8119..fe002bc57 100644 --- a/packages/alphatab/src/rendering/glyphs/TieGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TieGlyph.ts @@ -1,5 +1,6 @@ import type { Note } from '@coderline/alphatab/model/Note'; -import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; +import { NotationElement } from '@coderline/alphatab/NotationSettings'; +import { TextAlign, type ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { type BarRendererBase, NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; @@ -33,6 +34,7 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { private _startX: number = 0; private _startY: number = 0; + private _slurTextY: number = 0; private _endX: number = 0; private _endY: number = 0; private _tieHeight: number = 0; @@ -146,6 +148,25 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { } this._boundingBox = tieBoundingBox; + const slurText = this.getSlurText(); + if (slurText) { + // text will be aligned on the arc with a slight padding + const c = this.renderer.scoreRenderer.canvas!; + const settings = this.renderer.settings; + const res = settings.display.resources; + c.font = res.getFontForNotationElement(NotationElement.EffectHammerOnPullOffText); + const textSize = c.measureText(slurText); + const padding = this.renderer.smuflMetrics.tieMidpointThickness; + + if (this.tieDirection === BeamDirection.Up) { + tieBoundingBox.y -= textSize.height + padding; + this._slurTextY = tieBoundingBox.y; + tieBoundingBox.h += textSize.height + padding; + } else { + this._slurTextY = tieBoundingBox.y + tieBoundingBox.h + padding; + tieBoundingBox.h += textSize.height + padding; + } + } this.height = tieBoundingBox.h; @@ -193,18 +214,12 @@ export abstract class TieGlyph extends Glyph implements ITieGlyph { const slurText = this.getSlurText(); if (slurText) { + const ta = canvas.textAlign; + canvas.textAlign = TextAlign.Center; + canvas.font = this.renderer.resources.getFontForNotationElement(NotationElement.EffectHammerOnPullOffText); const midX = cx + (this._startX + this._endX) / 2; - const midY = cy + (this._startY + this._endY) / 2; - const apexOffset = this._tieHeight * 0.75; - const apexY = midY + (isDown ? apexOffset : -apexOffset); - const w = canvas.measureText(slurText).width; - const fontSize = canvas.font.size; - // text above: fontSize already includes descender space below the baseline, - // providing natural padding for capital letters like H/P - const textY = isDown - ? apexY + fontSize * 0.3 - : apexY - fontSize * 1.05; - canvas.fillText(slurText, midX - w / 2, textY); + canvas.fillText(slurText, midX, cy + this._slurTextY); + canvas.textAlign = ta; } } diff --git a/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts b/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts index d35532d43..2354de30b 100644 --- a/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts +++ b/packages/alphatab/test/visualTests/features/EffectsAndAnnotations.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, it } from 'vitest'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import { LayoutMode } from '@coderline/alphatab/LayoutMode'; import { BeatBarreEffectInfo } from '@coderline/alphatab/rendering/effects/BeatBarreEffectInfo'; import { Settings } from '@coderline/alphatab/Settings'; import { TestPlatform } from 'test/TestPlatform'; import { VisualTestHelper, VisualTestOptions, VisualTestRun } from 'test/visualTests/VisualTestHelper'; +import { describe, expect, it } from 'vitest'; describe('EffectsAndAnnotationsTests', () => { it('markers', async () => { @@ -573,4 +573,28 @@ describe('EffectsAndAnnotationsTests', () => { ); }); }); + + describe('hopo-arcs', () => { + async function test(test: string, tex: string) { + await VisualTestHelper.runVisualTestTex( + tex, + `test-data/visual-tests/effects-and-annotations/hopo-arcs-${test}.png` + ); + } + + it('at1', async () => await test('at1', ':4 5.3{h} 7.3 r r')); + it('at2', async () => await test('at2', ':4 7.3{h} 5.3 r r')); + it('at3', async () => await test('at3', ':4 5.3{h} 7.3 7.3{h} 5.3')); + it('at4', async () => await test('at4', ':4 5.3{h} 7.3 8.4{h} 5.4')); + it('at5', async () => await test('at5', ':4 5.3{h} 7.3{h} 5.3 r')); + it('at6', async () => await test('at6', ':8 5.3{h} 7.3{h} 5.3{h} 7.3 r r r r')); + it('at7', async () => await test('at7', ':4 5.3{sl} 7.3 r r')); + it('at8', async () => await test('at8', ':4 5.3 7.3 5.3 7.3')); + it('at9', async () => await test('at9', ':4 (5.3{h} 5.4) (7.3 7.4) r r')); + it('at10', async () => await test('at10', ':4 (5.3 5.4{h}) (7.3 7.4) r r')); + it('at11', async () => await test('at11', ':4 (5.3{h} 5.4{h}) (7.3 7.4) r r')); + it('at12', async () => await test('at12', ':4 (5.3{h} 7.4{h}) (7.3 5.4) r r')); + it('at13', async () => await test('at13', ':4 (5.3{h} 7.4{h}) (7.3{h} 5.4{h}) (5.3 7.4) r')); + it('at14', async () => await test('at14', ':4 5.3 {h} 7.3{h} 5.3 | 5.4 {h} 7.4{h} 5.4')); + }); }); diff --git a/packages/playground/src/apps/TestResultsApp.ts b/packages/playground/src/apps/TestResultsApp.ts index 545a74c3b..ab83a9b2f 100644 --- a/packages/playground/src/apps/TestResultsApp.ts +++ b/packages/playground/src/apps/TestResultsApp.ts @@ -6,10 +6,17 @@ import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom' injectStyles( 'TestResultsApp', css` + body { + justify-content: flex-start; + } + body > * { + overflow: visible; + } .at-test-results { padding: 1rem; font-family: 'Noto Sans', sans-serif; min-height: 100vh; + max-width: 90vw; } .at-test-results > h1 { margin-top: 0; } .at-test-results-toolbar { margin: 1rem 0; } @@ -26,12 +33,48 @@ injectStyles( margin: 0 0 8px 0; } .at-test-comparer { position: relative; } - .at-test-comparer .slider { + .at-test-comparer .slider-handle { position: absolute; - top: 30px; - right: 0; - left: 0; - width: 100%; + bottom: 0; + width: 40px; + transform: translateX(-50%); + cursor: ew-resize; + z-index: 10; + touch-action: none; + user-select: none; + } + .at-test-comparer .slider-handle::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 2px; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25), 0 0 4px rgba(0, 0, 0, 0.15); + pointer-events: none; + } + .at-test-comparer .slider-handle::after { + content: ''; + position: sticky; + top: calc(50vh - 20px); + display: block; + width: 40px; + height: 40px; + margin-top: var(--knob-margin-top, 0); + background-color: #fff; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 18L3 12l6-6M15 6l6 6-6 6' fill='none' stroke='%23555' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: center; + background-size: 22px; + border-radius: 50%; + border: 1.5px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25), 0 0 0 1px rgba(0, 0, 0, 0.06); + pointer-events: none; + } + .at-test-comparer .slider-handle:hover::after { + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.35), 0 0 0 1.5px rgba(0, 0, 0, 0.12); } .at-test-comparer .expected, .at-test-comparer .actual, @@ -63,9 +106,11 @@ injectStyles( body.hide-accepted .at-test-card.accepted { display: none; } .at-test-controls { - position: absolute; + position: sticky; top: 0; - left: 0; + z-index: 20; + background: #fff; + padding: 6px 0; display: flex; gap: 12px; align-items: center; @@ -199,9 +244,7 @@ export class TestResultsApp implements Mountable { this.listEl.replaceChildren(); this.currentResults = results; if (results.length === 0) { - const banner = parseHtml( - html`
No reported errors on visual tests.
` - ); + const banner = parseHtml(html`
No reported errors on visual tests.
`); this.listEl.appendChild(banner); this.updateRemaining(); return; @@ -216,15 +259,15 @@ export class TestResultsApp implements Mountable { const card = parseHtml(html`
${result.originalFile}
+
+ + +
expected
actual
diff
- -
- - -
+
`); @@ -232,7 +275,7 @@ export class TestResultsApp implements Mountable { const ex = comparer.querySelector('.expected')!; const ac = comparer.querySelector('.actual')!; const df = comparer.querySelector('.diff')!; - const slider = comparer.querySelector('.slider')!; + const handle = comparer.querySelector('.slider-handle')!; const exImg = ex.querySelector('img')!; const acImg = ac.querySelector('img')!; const dfImg = df.querySelector('img')!; @@ -245,26 +288,33 @@ export class TestResultsApp implements Mountable { const width = Math.max(exImg.width, acImg.width); const height = Math.max(exImg.height, acImg.height); - const controlsHeight = 60; comparer.style.width = `${width}px`; - comparer.style.height = `${height + controlsHeight}px`; + comparer.style.height = `${height}px`; ex.style.width = `${width}px`; ex.style.height = `${height}px`; - ex.style.top = `${controlsHeight}px`; ac.style.width = `${width / 2}px`; ac.style.height = `${height}px`; - ac.style.top = `${controlsHeight}px`; df.style.width = `${width}px`; df.style.height = `${height}px`; - df.style.top = `${controlsHeight}px`; - slider.oninput = () => { - ac.style.width = `${width * (1 - slider.valueAsNumber)}px`; - }; - comparer.querySelector('.diff-toggle')!.onchange = e => { + handle.style.left = `${width / 2}px`; + handle.style.setProperty('--knob-margin-top', `${height / 2 - 20}px`); + + handle.addEventListener('pointerdown', e => { + handle.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + handle.addEventListener('pointermove', e => { + if (!e.buttons) { return; } + const rect = comparer.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, width)); + handle.style.left = `${x}px`; + ac.style.width = `${width - x}px`; + }); + card.querySelector('.diff-toggle')!.onchange = e => { df.style.display = (e.target as HTMLInputElement).checked ? 'block' : 'none'; }; - const acceptBtn = comparer.querySelector('.accept')!; + const acceptBtn = card.querySelector('.accept')!; acceptBtn.onclick = async () => { acceptBtn.disabled = true; acceptBtn.textContent = 'Accepting...';