From fdc6ea04f6897a6f537f7dab34e21978b702c0d3 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:14:29 -0400 Subject: [PATCH 1/8] test(render): exercise head:'start' alone at the render level Closes the gap where hasStartHead was only ever true via 'both' (#61): curve start inset, start-head socket anchoring, and elbow startTrim. --- test/scroll-arrow.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index b5f6202..1d7fa79 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -242,6 +242,36 @@ describe('ScrollArrow shaft/head junction (#59)', () => { expect(headD).toMatch(/300[ ,]+20/); }); + it("insets the shaft when only a start head is drawn (#61)", () => { + const a = anchors(); + const noHead = pathDs({ ...a, seed: 7, head: 'none' })[0]; + const withHead = pathDs({ ...a, seed: 7, head: 'start' })[0]; + expect(noHead).toBeTruthy(); + expect(withHead).toBeTruthy(); + expect(withHead).not.toBe(noHead); + }); + + it("keeps the start head anchored at the true start socket (#61)", () => { + // Mirror of R3: with anchorEnds the start head must still pass through the + // true start socket (100, 20) even though the shaft now starts short of it. + const a = anchors(); + const ds = pathDs({ ...a, seed: 7, head: 'start' }); + const headD = ds[ds.length - 1]!; + expect(headD).toMatch(/100[ ,]+20/); + }); + + it("trims the elbow's start leg for head: 'start' (#61)", () => { + const a = anchors(); + const noHead = pathDs({ ...a, seed: 7, route: 'elbow', head: 'none' })[0]; + const withHead = pathDs({ + ...a, + seed: 7, + route: 'elbow', + head: 'start', + })[0]; + expect(withHead).not.toBe(noHead); + }); + it('trims the elbow shaft when an end head is drawn (R5)', () => { const a = anchors(); const noHead = pathDs({ ...a, seed: 7, route: 'elbow', head: 'none' })[0]; From 98f4f27ff56d4372872859c10d1f400673254dad Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:15:24 -0400 Subject: [PATCH 2/8] feat(geometry): optional closed arrowhead path arrowHeadPath(closed=true) appends Z so the head can be filled (#60). --- src/geometry.ts | 13 ++++++++++--- test/geometry.test.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/geometry.ts b/src/geometry.ts index f0cbf00..ac13ec2 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -382,8 +382,14 @@ export function headBaseInset(size: number): number { return size * Math.cos(HEAD_SPREAD); } -/** Two short strokes forming an arrowhead at `tip`, opening along `dir`. */ -export function arrowHeadPath(tip: Point, dir: Point, size: number): string { +/** Two short strokes forming an arrowhead at `tip`, opening along `dir`. + * `closed` appends `Z` for a fillable triangle (solid head style). */ +export function arrowHeadPath( + tip: Point, + dir: Point, + size: number, + closed = false, +): string { const a = Math.atan2(dir.y, dir.x); const spread = HEAD_SPREAD; const p1 = { @@ -394,7 +400,8 @@ export function arrowHeadPath(tip: Point, dir: Point, size: number): string { x: tip.x - size * Math.cos(a + spread), y: tip.y - size * Math.sin(a + spread), }; - return `M ${r(p1.x)} ${r(p1.y)} L ${r(tip.x)} ${r(tip.y)} L ${r(p2.x)} ${r(p2.y)}`; + const d = `M ${r(p1.x)} ${r(p1.y)} L ${r(tip.x)} ${r(tip.y)} L ${r(p2.x)} ${r(p2.y)}`; + return closed ? `${d} Z` : d; } /** diff --git a/test/geometry.test.ts b/test/geometry.test.ts index fa2979f..450978e 100644 --- a/test/geometry.test.ts +++ b/test/geometry.test.ts @@ -191,6 +191,17 @@ describe('arrowHeadPath', () => { const d = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14); expect(d).toMatch(/^M .+ L 100 0 L .+$/); }); + + it('stays open by default (no Z, exact current shape)', () => { + const d = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14); + expect(d).not.toContain('Z'); + }); + + it('closes the triangle when closed=true, same three points', () => { + const open = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14); + const closed = arrowHeadPath({ x: 100, y: 0 }, { x: 1, y: 0 }, 14, true); + expect(closed).toBe(`${open} Z`); + }); }); describe('tangents', () => { From c5da5b0a5f6e3c8a6f0aff71765f57074b509361 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:17:43 -0400 Subject: [PATCH 3/8] feat(render): headStyle option with solid (filled) arrowheads 'solid' closes the head triangle and fills it with the stroke color via rough.js fillStyle 'solid', keeping the hand-drawn look (#60). Default 'line' output is unchanged. Drops the redundant fill='none' clobber in appendDrawable (rough.js already sets it on stroke paths) and tags each segment with its drawable's reveal group for the fade-in that follows. --- src/index.ts | 1 + src/scroll-arrow.ts | 29 ++++++++++--- src/types.ts | 13 ++++++ test/scroll-arrow.test.ts | 85 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index d1e3e48..8dceed8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ export type { ScrollOptions, Socket, ArrowHead, + HeadStyle, ElementRef, LabelPosition, Point, diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts index 7a76d20..59c1d42 100644 --- a/src/scroll-arrow.ts +++ b/src/scroll-arrow.ts @@ -71,7 +71,13 @@ export class ScrollArrow { el: SVGPathElement; len: number; kind: 'line' | 'head'; + /** True for a solid head's fill path — reveals by opacity, not dash. */ + fill?: boolean; + /** Reveal group: all paths from one drawable share it (line, each head). */ + group?: number; }[] = []; + /** Counter handing each appendDrawable call its reveal group id. */ + private groupIds = 0; /** Representative line stroke + label nodes, when a label is set. */ private lineEl: SVGPathElement | null = null; /** @@ -289,18 +295,24 @@ export class ScrollArrow { this.lineD = d; this.appendDrawable(this.rc.path(d, roughOpts), 'line'); - // Arrowheads, anchored at the true socket points. + // Arrowheads, anchored at the true socket points. Solid style closes the + // triangle and fills it with the stroke color (#60); rough.js emits the + // fill path alongside the outline stroke(s). + const solid = this.opts.headStyle === 'solid'; + const headOpts = solid + ? { ...roughOpts, fill: this.stroke, fillStyle: 'solid' } + : roughOpts; if (hasEndHead) { const dir = endTangent(local); this.appendDrawable( - this.rc.path(arrowHeadPath(local.end, dir, size), roughOpts), + this.rc.path(arrowHeadPath(local.end, dir, size, solid), headOpts), 'head', ); } if (hasStartHead) { const dir = startTangent(local); this.appendDrawable( - this.rc.path(arrowHeadPath(local.start, dir, size), roughOpts), + this.rc.path(arrowHeadPath(local.start, dir, size, solid), headOpts), 'head', ); } @@ -386,14 +398,19 @@ export class ScrollArrow { this.labelEl = label; } - /** roughjs returns a of one or more ; collect them in order. */ + /** + * roughjs returns a of one or more ; collect them in order. + * Stroke paths already carry fill="none" from rough.js; a solid head's fill + * path carries the fill color and is marked so it reveals by opacity. + */ private appendDrawable(g: SVGGElement, kind: 'line' | 'head'): void { + const group = this.groupIds++; const paths = g.querySelectorAll('path'); paths.forEach((p) => { const el = p as SVGPathElement; - el.setAttribute('fill', 'none'); + const fill = el.getAttribute('fill') !== 'none' || undefined; this.group.appendChild(el); - this.segments.push({ el, len: 0, kind }); + this.segments.push({ el, len: 0, kind, fill, group }); }); } diff --git a/src/types.ts b/src/types.ts index 992fbdb..b72739c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,13 @@ export type Socket = 'auto' | 'top' | 'bottom' | 'left' | 'right' | 'center'; export type ArrowHead = 'start' | 'end' | 'both' | 'none'; +/** + * Arrowhead rendering style. `'line'` (default) draws the head as two open + * strokes (a V); `'solid'` closes the head into a triangle filled with the + * stroke color, keeping the hand-drawn look. + */ +export type HeadStyle = 'line' | 'solid'; + /** * Line routing style. `curved` (default) is the smooth bezier with single-bend * obstacle avoidance. `elbow` draws right-angle connectors (tree/org-chart @@ -105,6 +112,12 @@ export interface ScrollArrowOptions { head?: ArrowHead; /** Arrowhead length in px. Default 14. */ headSize?: number; + /** + * Arrowhead style: `"line"` (default) for an open V, `"solid"` for a filled + * triangle in the stroke color. Applies to whichever ends `head` selects. + * Solid heads fade in as their outline draws instead of dash-revealing. + */ + headStyle?: HeadStyle; /** Text to place along the line. Omit for no label. */ label?: string; diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 1d7fa79..955efc8 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -286,3 +286,88 @@ describe('ScrollArrow shaft/head junction (#59)', () => { document.querySelectorAll('svg').forEach((s) => s.remove()); }); }); + +describe("ScrollArrow headStyle: 'solid' (#60)", () => { + // Same jsdom SVG-geometry stubs as the #59 suite above. + type SvgStubs = { getTotalLength?: unknown }; + beforeEach(() => { + (SVGElement.prototype as SvgStubs).getTotalLength = () => 100; + }); + afterEach(() => { + delete (SVGElement.prototype as SvgStubs).getTotalLength; + }); + + const anchors = () => ({ + start: boxed({ left: 0, top: 0, width: 100, height: 40 }), + end: boxed({ left: 300, top: 0, width: 100, height: 40 }), + }); + + /** Render and return every path's { d, fill }, then clean up. */ + const paths = ( + opts: ConstructorParameters[0], + ): { d: string; fill: string }[] => { + new ScrollArrow(opts); + const out = [...document.querySelectorAll('svg path')].map((p) => ({ + d: p.getAttribute('d') ?? '', + fill: p.getAttribute('fill') ?? '', + })); + document.querySelectorAll('svg').forEach((s) => s.remove()); + return out; + }; + + it('fills the end head with the stroke color', () => { + const a = anchors(); + const ps = paths({ + ...a, + seed: 7, + head: 'end', + headStyle: 'solid', + stroke: '#123456', + }); + const fills = ps.filter((p) => p.fill !== 'none'); + expect(fills).toHaveLength(1); + expect(fills[0]!.fill).toBe('#123456'); + }); + + it('keeps the solid head anchored at the true socket point', () => { + // The head's outline stroke still passes through the end socket (300, 20). + const a = anchors(); + const ps = paths({ ...a, seed: 7, head: 'end', headStyle: 'solid' }); + const headStroke = ps[ps.length - 1]!; + expect(headStroke.fill).toBe('none'); + expect(headStroke.d).toMatch(/300[ ,]+20/); + }); + + it("renders identically for omitted vs explicit 'line' (R2)", () => { + const a = anchors(); + const dflt = paths({ ...a, seed: 7, head: 'end' }); + const line = paths({ ...a, seed: 7, head: 'end', headStyle: 'line' }); + expect(line).toEqual(dflt); + expect(dflt.every((p) => p.fill === 'none')).toBe(true); + }); + + it("fills both heads for head: 'both'", () => { + const a = anchors(); + const ps = paths({ + ...a, + seed: 7, + head: 'both', + headStyle: 'solid', + stroke: '#123456', + }); + expect(ps.filter((p) => p.fill === '#123456')).toHaveLength(2); + }); + + it("line paths keep fill='none' regardless of headStyle", () => { + const a = anchors(); + const ps = paths({ + ...a, + seed: 7, + head: 'end', + headStyle: 'solid', + stroke: '#123456', + }); + // First path(s) are the line strokes; none of them may pick up the fill. + expect(ps[0]!.fill).toBe('none'); + }); +}); From 53f2e9f775217b1c2e4b38b68a7a6717d67e0232 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:20:06 -0400 Subject: [PATCH 4/8] feat(draw): fade solid head fills in with their outline rough.js emits a head's fill path before its outline strokes, so raw segment order can't drive sequential reveal. segmentFractions gives every segment its revealed fraction: fill paths carry no length in the draw budget and mirror their own head group's stroke fraction, fading in while the outline dash-draws (#60). dashOffsets delegates to it, so stroke timing is unchanged. --- src/draw.ts | 38 +++++++++++++++++++--- src/scroll-arrow.ts | 17 +++++++--- test/draw.test.ts | 66 +++++++++++++++++++++++++++++++++++++++ test/scroll-arrow.test.ts | 19 +++++++++++ 4 files changed, 131 insertions(+), 9 deletions(-) diff --git a/src/draw.ts b/src/draw.ts index d448d87..721d897 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -38,6 +38,10 @@ export function resolveLabelAt( export interface DrawSegment { len: number; kind: SegmentKind; + /** A solid head's fill path: reveals by opacity and contributes no length. */ + fill?: boolean; + /** Reveal group tying a fill path to its own head's stroke paths. */ + group?: number; } /** @@ -52,13 +56,24 @@ function lengths(segs: DrawSegment[]): { lineLen: number; headLen: number } { let lineLen = 0; let headLen = 0; for (const s of segs) { + if (s.fill) continue; // fades with its group; no length of its own if (s.kind === 'line') lineLen = Math.max(lineLen, s.len); else headLen += s.len; } return { lineLen, headLen }; } -export function dashOffsets(segs: DrawSegment[], eased: number): number[] { +/** + * Revealed fraction (0..1) of each segment. Line strokes share one leading + * edge; head strokes reveal sequentially once the line is done. A solid + * head's fill path contributes no length to the budget — its fraction mirrors + * its own head group's stroke reveal, so the fill fades in while the outline + * draws (#60). + */ +export function segmentFractions( + segs: DrawSegment[], + eased: number, +): number[] { const { lineLen, headLen } = lengths(segs); const total = lineLen + headLen || 1; const drawn = clamp01(eased) * total; @@ -66,14 +81,29 @@ export function dashOffsets(segs: DrawSegment[], eased: number): number[] { const lp = lineLen > 0 ? clamp01(drawn / lineLen) : 1; let headDrawn = Math.max(0, drawn - lineLen); - return segs.map((seg) => { - if (seg.kind === 'line') return seg.len * (1 - lp); + const groupLen = new Map(); + const groupShown = new Map(); + const fractions = segs.map((seg) => { + if (seg.fill) return -1; // resolved from its group's strokes below + if (seg.kind === 'line') return lp; const show = Math.max(0, Math.min(seg.len, headDrawn)); headDrawn -= seg.len; - return seg.len - show; + groupLen.set(seg.group, (groupLen.get(seg.group) ?? 0) + seg.len); + groupShown.set(seg.group, (groupShown.get(seg.group) ?? 0) + show); + return seg.len > 0 ? show / seg.len : 1; + }); + return fractions.map((f, i) => { + if (f >= 0) return f; + const len = groupLen.get(segs[i]!.group) ?? 0; + return len > 0 ? clamp01((groupShown.get(segs[i]!.group) ?? 0) / len) : lp; }); } +export function dashOffsets(segs: DrawSegment[], eased: number): number[] { + const fractions = segmentFractions(segs, eased); + return segs.map((seg, i) => seg.len * (1 - fractions[i]!)); +} + /** How far the pen tip has travelled along the line, 0..1. */ export function lineProgress(segs: DrawSegment[], eased: number): number { const { lineLen, headLen } = lengths(segs); diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts index 59c1d42..4181a2a 100644 --- a/src/scroll-arrow.ts +++ b/src/scroll-arrow.ts @@ -28,7 +28,7 @@ import { import { getOverlay, overlayOrigin, createGroup, createSvgEl } from './overlay'; import { mapRoughness, deriveSeed } from './roughness'; import { - dashOffsets, + segmentFractions, lineProgress, labelOpacity, resolveLabelAt, @@ -323,8 +323,13 @@ export class ScrollArrow { let longest = 0; for (const seg of this.segments) { seg.len = seg.el.getTotalLength(); - seg.el.style.strokeDasharray = String(seg.len); - seg.el.style.strokeDashoffset = String(seg.len); + if (seg.fill) { + // A filled head can't dash-reveal; it fades in via opacity instead. + seg.el.style.opacity = '0'; + } else { + seg.el.style.strokeDasharray = String(seg.len); + seg.el.style.strokeDashoffset = String(seg.len); + } if (seg.kind === 'line' && seg.len >= longest) { longest = seg.len; this.lineEl = seg.el; @@ -421,9 +426,11 @@ export class ScrollArrow { */ private applyProgress(): void { const eased = this.opts.easing(clamp01(this.progress)); - const offsets = dashOffsets(this.segments, eased); + const fractions = segmentFractions(this.segments, eased); this.segments.forEach((seg, i) => { - seg.el.style.strokeDashoffset = String(offsets[i]); + const f = fractions[i]!; + if (seg.fill) seg.el.style.opacity = String(f); + else seg.el.style.strokeDashoffset = String(seg.len * (1 - f)); }); if (this.labelEl) { diff --git a/test/draw.test.ts b/test/draw.test.ts index a9f9af1..52bba2b 100644 --- a/test/draw.test.ts +++ b/test/draw.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { dashOffsets, + segmentFractions, lineProgress, labelOpacity, resolveLabelAt, @@ -67,6 +68,71 @@ describe('dashOffsets', () => { }); }); +describe('segmentFractions with a solid head fill (#60)', () => { + // rough.js emits the fill path BEFORE the outline stroke(s) of a head. + const solidHead: DrawSegment[] = [ + { len: 100, kind: 'line', group: 0 }, + { len: 30, kind: 'head', fill: true, group: 1 }, + { len: 20, kind: 'head', group: 1 }, + ]; + + it('keeps the fill hidden while the line is still drawing', () => { + const [, fill] = segmentFractions(solidHead, 0.5); + expect(fill).toBe(0); + }); + + it("fades the fill with its own head group's stroke fraction", () => { + // Budget excludes the fill: total = 100 + 20. At drawn = 110 the head + // outline is halfway — the fill's opacity must ride along at 0.5. + const [line, fill, stroke] = segmentFractions(solidHead, 110 / 120); + expect(line).toBe(1); + expect(stroke).toBeCloseTo(0.5); + expect(fill).toBeCloseTo(0.5); + }); + + it('contributes no length to the draw budget', () => { + const noFill: DrawSegment[] = [ + { len: 100, kind: 'line', group: 0 }, + { len: 20, kind: 'head', group: 1 }, + ]; + // The line completes at the same eased progress with and without the + // fill segment, and mid-draw pacing matches exactly. + expect(segmentFractions(solidHead, 100 / 120)[0]).toBe(1); + expect(segmentFractions(noFill, 100 / 120)[0]).toBe(1); + expect(segmentFractions(solidHead, 0.5)[0]).toBeCloseTo( + segmentFractions(noFill, 0.5)[0]!, + ); + }); + + it('shows everything at progress 1 (reduced motion / static)', () => { + expect(segmentFractions(solidHead, 1)).toEqual([1, 1, 1]); + }); + + it('each solid head fades with its own slot for head: both', () => { + const twoSolidHeads: DrawSegment[] = [ + { len: 100, kind: 'line', group: 0 }, + { len: 15, kind: 'head', fill: true, group: 1 }, + { len: 10, kind: 'head', group: 1 }, + { len: 15, kind: 'head', fill: true, group: 2 }, + { len: 10, kind: 'head', group: 2 }, + ]; + // total = 100 + 10 + 10; drawn = 115 -> first head done, second halfway. + const [, fill1, , fill2] = segmentFractions(twoSolidHeads, 115 / 120); + expect(fill1).toBe(1); + expect(fill2).toBeCloseTo(0.5); + }); + + it('matches dashOffsets exactly for stroke-only segments (R2)', () => { + for (const eased of [0, 0.3, 0.5, 100 / 120, 1]) { + const fr = segmentFractions(withHead, eased); + const offs = dashOffsets(withHead, eased); + withHead.forEach((s, i) => { + expect(s.len * (1 - fr[i]!)).toBeCloseTo(offs[i]!); + }); + } + }); +}); + describe('lineProgress', () => { it('reaches 1 before overall progress (line finishes before head)', () => { // line 100, head 20 -> line done at eased 100/120 diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 955efc8..0679566 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -358,6 +358,25 @@ describe("ScrollArrow headStyle: 'solid' (#60)", () => { expect(ps.filter((p) => p.fill === '#123456')).toHaveLength(2); }); + it('fades the fill in: hidden at progress 0, shown fully drawn', () => { + const a = anchors(); + const arrow = new ScrollArrow({ + ...a, + seed: 7, + head: 'end', + headStyle: 'solid', + stroke: '#123456', + scroll: false, + }); + const fillEl = [...document.querySelectorAll('svg path')].find( + (p) => p.getAttribute('fill') === '#123456', + ) as SVGPathElement; + expect(fillEl.style.opacity).toBe('0'); + arrow.setProgress(1); + expect(fillEl.style.opacity).toBe('1'); + document.querySelectorAll('svg').forEach((s) => s.remove()); + }); + it("line paths keep fill='none' regardless of headStyle", () => { const a = anchors(); const ps = paths({ From 1ff99ff5280dee5b4bf6cf1a34f5c76a3ec027c6 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:21:09 -0400 Subject: [PATCH 5/8] docs: document headStyle option and add implementation plan README gains headStyle next to head/headSize; plan for #60/#61 included. --- README.md | 3 +- ...026-07-06-002-feat-solid-arrowhead-plan.md | 172 ++++++++++++++++++ src/draw.ts | 5 +- test/scroll-arrow.test.ts | 4 +- 4 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md diff --git a/README.md b/README.md index 7bdb470..62af3a6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ const arrow = scrollArrow({ stroke: '#e7e9ee', strokeWidth: 2.5, head: 'end', // "start" | "end" | "both" | "none" + headStyle: 'solid', // "line" (open V, default) | "solid" (filled triangle) }); // later @@ -237,7 +238,7 @@ the runtime arrow mounts in its own overlay and won't clash with your static one Key options: `start`, `end`, `container`, `roughness`, `stroke`, `strokeWidth`, `seed`, `startSocket`, `endSocket`, `startSocketOffset`, `endSocketOffset`, -`curvature`, `route`, `head`, `headSize`, `scroll`, `speed`, `easing`, +`curvature`, `route`, `head`, `headSize`, `headStyle`, `scroll`, `speed`, `easing`, `progress`, `enabled`. Full types ship with the package. `setEnabled(on)` toggles an arrow (and a group) on/off without teardown. diff --git a/docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md b/docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md new file mode 100644 index 0000000..09146a3 --- /dev/null +++ b/docs/plans/2026-07-06-002-feat-solid-arrowhead-plan.md @@ -0,0 +1,172 @@ +--- +title: 'feat: Solid (filled) arrowhead style + start-head render coverage' +date: 2026-07-06 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +product_contract_source: ce-plan-bootstrap +origin: 'GitHub issues #60 and #61' +--- + +# feat: Solid (filled) arrowhead style + start-head render coverage + +## Summary + +Two issues, one branch/PR: + +- **#60** — add `headStyle?: 'line' | 'solid'` (default `'line'`, non-breaking). `'solid'` closes the head path (`… L p2 Z`) and fills it with the stroke color via rough.js `fill` + `fillStyle: 'solid'`, keeping the hand-drawn look. Applies to start and end heads. Filled heads can't dash-reveal, so they fade in during their slot of the draw-on sequence. +- **#61** — render-level tests for `head: 'start'` alone, which today is only exercised via `'both'`; a bug isolated to the `head === 'start'` disjunct in `src/scroll-arrow.ts` would not be caught. + +**Branch/PR convention:** branch from `origin/staging`; PR targets `staging`; closes #60 and #61. + +--- + +## Assumptions + +- Single branch and single PR covering both issues (they share `test/scroll-arrow.test.ts`; #61's coverage also protects the render path #60 modifies). The request said "PR for both" — read as one PR resolving both. +- Fade-in (opacity ramp) is the accepted draw-on treatment for solid heads; no scale animation (issue offers "fade/scale" — fade is simpler and sufficient). +- No demo-page changes; deferred (see Scope Boundaries). + +--- + +## Problem Frame + +`arrowHeadPath()` in `src/geometry.ts` always emits an open V (`M p1 L tip L p2`) and `mapRoughness()` hardcodes `fill: 'none'`. `appendDrawable()` in `src/scroll-arrow.ts` additionally force-sets `fill="none"` on every rough.js output path, and the draw-on animation (`src/draw.ts` `dashOffsets`) reveals every segment by `stroke-dashoffset` — a filled polygon has no stroke length to reveal. So a solid head needs: a closed geometry path, fill-carrying rough options for head drawables only, an `appendDrawable` that preserves rough.js fill paths, and an opacity-based reveal for fill segments. + +Test gap (#61): `test/scroll-arrow.test.ts` exercises `head: 'none' | 'end' | 'both'` at render level; `hasStartHead` is only ever true co-occurring with `hasEndHead`. + +## Requirements + +- **R1**: `headStyle: 'solid'` renders each drawn head as a closed triangle filled with the stroke color, hand-drawn look preserved (rough.js `fillStyle: 'solid'`). +- **R2**: Default (`headStyle` omitted or `'line'`) output is byte-identical to today — no breaking change. +- **R3**: Solid style applies to whichever heads `head` selects (`'start'`, `'end'`, `'both'`). +- **R4**: Draw-on animation: solid heads appear only after the line completes, fading in over their slot; sequencing with multiple heads stays sensible. Reduced motion / `progress: 1` shows them fully. +- **R5**: Heads stay anchored at the true socket points; shaft inset behavior from #59 is unchanged (base inset math is identical for both styles). +- **R6** (#61): Render tests exercise `head: 'start'` alone — curve inset, start-head anchoring, and elbow startTrim wiring. + +--- + +## Key Technical Decisions + +- **KTD1 — Closed head path via a flag on the existing helper.** Extend `arrowHeadPath(tip, dir, size, closed = false)` to append `Z` when closed, rather than a parallel function — the three points are identical. +- **KTD2 — Fill options built per-head at render time.** `mapRoughness` stays as-is (`fill: 'none'` is right for the line). In `render()`, solid heads pass `{ ...roughOpts, fill: this.stroke, fillStyle: 'solid' }` to `rc.path`. No change to the roughness mapping contract. +- **KTD3 — Delete the redundant fill clobber in `appendDrawable`.** rough.js already emits `fill="none"` on every stroke path itself (roughjs `svg.js` `path` case), so the `el.setAttribute('fill', 'none')` line in `appendDrawable` is redundant today. Delete it — byte-identical for line/line-style output, and it automatically preserves the fill `` rough.js emits for solid heads. +- **KTD4 — Fill segments reveal by opacity, tied to their head's stroke slot.** rough.js emits the fill path _before_ the stroke path(s) for filled shapes, so raw segment order cannot drive sequential reveal. Extend the segment model with a fill marker (e.g. `kind: 'head'` + `fill: true`) and group segments per head: a solid head's fill segment contributes **zero** length to the total draw budget, and its `opacity` equals its own head's stroke-segment reveal fraction (0 until that head's outline starts, 1 when it completes). Stroke segments keep dash-offset reveal unchanged; initial state for fill segments is opacity 0 (no dasharray). +- **KTD5 — Reveal is concurrent per head: outline dash-draws while the fill fades over the same slot.** This is the committed visual (the issue's "fade it in once the line completes"). Timing vs line-style heads differs slightly regardless — a closed triangle's outline is longer than the open V — and the fill adds no further length by KTD4; accepted. + +## High-Level Technical Design + +``` +render(): + headOpts = headStyle === 'solid' + ? { ...roughOpts, fill: stroke, fillStyle: 'solid' } + : roughOpts + headD = arrowHeadPath(tip, dir, size, headStyle === 'solid') // + Z when solid + appendDrawable(rc.path(headD, headOpts), 'head') // keeps rough fill paths + +applyProgress(): + fractions = per-segment revealed fraction (line shares leading edge; heads sequential; + fill segments carry zero length and inherit their head's stroke fraction) + stroke segment -> strokeDashoffset = len * (1 - fraction) + fill segment -> style.opacity = fraction of its head group's stroke reveal +``` + +--- + +## Implementation Units + +### U1. Render tests for head:'start' alone (#61) + +**Goal:** Close the `head: 'start'` render-level coverage gap before touching the head code path. +**Requirements:** R6. +**Dependencies:** none. +**Files:** `test/scroll-arrow.test.ts`. +**Approach:** Mirror the existing #59 suite cases using its `anchors()`/`pathDs` helpers and SVG stubs. +**Patterns to follow:** `ScrollArrow shaft/head junction (#59)` describe block. +**Test scenarios:** + +- Curve inset: line `d` for `{seed: 7, head: 'none'}` differs from `{seed: 7, head: 'start'}` (start inset along the start normal). +- Anchoring: with `head: 'start'`, the head path still contains the true start-socket coordinates (`anchors()` start socket resolves to `(100, 20)` — assert like the existing R3 regex). +- Elbow startTrim: `{route: 'elbow', head: 'start'}` line `d` differs from `{route: 'elbow', head: 'none'}`. + **Verification:** `npm test` green; deleting the `head === 'start'` disjunct at `src/scroll-arrow.ts` `hasStartHead` would fail these tests. + +### U2. Closed head path in geometry + +**Goal:** `arrowHeadPath` can emit a closed triangle. +**Requirements:** R1 (geometry half). +**Dependencies:** none. +**Files:** `src/geometry.ts`, `test/geometry.test.ts`. +**Approach:** KTD1 — optional `closed` param appends ` Z`. +**Test scenarios:** + +- `closed: true` output ends with `Z` and shares the same three points as the open form. +- Default/omitted stays exactly the current string (R2 guard at geometry level). + **Verification:** geometry tests green. + +### U3. `headStyle` option and solid-head rendering + +**Goal:** Public `headStyle` option; solid heads render filled. +**Requirements:** R1, R2, R3, R5. +**Dependencies:** U2. +**Files:** `src/types.ts`, `src/scroll-arrow.ts`, `test/scroll-arrow.test.ts`. +**Approach:** Add `HeadStyle` type + `headStyle?: 'line' | 'solid'` to `ScrollArrowOptions` (doc comment per existing style). In `render()`, per KTD2 build head-specific rough options and pass `closed` to `arrowHeadPath`. Fix `appendDrawable` per KTD3. +**Test scenarios:** + +- `headStyle: 'solid', head: 'end'`: at least one head path carries a non-`none` fill equal to the stroke color, and its `d` passes through the true socket coordinates (same regex style as the existing R3 test). Do not assert `Z` in the rendered `d` — rough.js re-emits paths through `opsToPath`, which only produces M/L/C commands; the closing `Z` is observable only at the geometry level (U2). +- Default omitted vs explicit `'line'`: rendered `d` strings and fill attributes identical to a render with no `headStyle` (R2). +- `head: 'both', headStyle: 'solid'`: both heads filled. +- Line paths always keep `fill="none"` regardless of `headStyle`. + **Verification:** `npm test`, `npm run typecheck`, `npm run lint` green. + +### U4. Draw-on reveal for filled heads + +**Goal:** Solid heads fade in during their slot instead of dash-revealing. +**Requirements:** R4. +**Dependencies:** U3. +**Files:** `src/draw.ts`, `src/scroll-arrow.ts`, `test/draw.test.ts`. +**Approach:** KTD4/KTD5 — mark fill segments and group them with their head's stroke segments; compute per-segment revealed fraction in `draw.ts` (pure, unit-testable) with fill segments contributing zero length and inheriting their head group's stroke fraction; `applyProgress` maps fraction → dashoffset for stroke segments, → opacity for fill segments; initial state sets fill-segment opacity 0 and skips dasharray on them. +**Test scenarios (pure, in `test/draw.test.ts`):** + +- Fill segment fraction is 0 while the line is still drawing. +- Fill segment fraction equals its head group's stroke fraction as that head reveals (concurrent outline + fade), reaching 1 when the head completes. +- Fill segment length does not shift the total draw budget (line completes at the same eased progress with and without a fill segment of the same head). +- Fraction is 1 at eased progress 1 (reduced-motion/static case). +- Stroke segments' offsets unchanged from current `dashOffsets` behavior for the same inputs (R2 guard on animation). + **Verification:** `npm test` green; manual demo check deferred to browser-test step of the pipeline. + +### U5. Document the option + +**Goal:** README documents `headStyle`. +**Requirements:** R1 (discoverability). +**Dependencies:** U3. +**Files:** `README.md`. +**Approach:** Add `headStyle` next to the existing `head`/`headSize` entries (options block near line 40 and the options list near line 240). +**Test expectation:** none — docs only. +**Verification:** `npm run format:check` green. + +--- + +## Scope Boundaries + +### Deferred to Follow-Up Work + +- Demo-page toggle showcasing `headStyle: 'solid'`. +- Group-level (`ScrollArrowGroupOptions`) needs nothing: per-arrow options already pass through. + +### Non-goals + +- Other head shapes (dot, bar, diamond). +- Per-end head styles (`startHeadStyle`/`endHeadStyle`) — one knob for both ends, per issue. +- Scale-in animation for solid heads. + +--- + +## Verification Contract + +- `npm test`, `npm run typecheck`, `npm run lint`, `npm run format:check` all green. +- R2 regression guard: renders without `headStyle` produce identical DOM output to pre-change (covered by seed-deterministic `d`-string tests in U2/U3/U4). + +## Definition of Done + +- All five units landed; issues #60 and #61 requirements covered by passing tests. +- PR open against `staging` referencing both issues. diff --git a/src/draw.ts b/src/draw.ts index 721d897..92920f3 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -70,10 +70,7 @@ function lengths(segs: DrawSegment[]): { lineLen: number; headLen: number } { * its own head group's stroke reveal, so the fill fades in while the outline * draws (#60). */ -export function segmentFractions( - segs: DrawSegment[], - eased: number, -): number[] { +export function segmentFractions(segs: DrawSegment[], eased: number): number[] { const { lineLen, headLen } = lengths(segs); const total = lineLen + headLen || 1; const drawn = clamp01(eased) * total; diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 0679566..6cea207 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -242,7 +242,7 @@ describe('ScrollArrow shaft/head junction (#59)', () => { expect(headD).toMatch(/300[ ,]+20/); }); - it("insets the shaft when only a start head is drawn (#61)", () => { + it('insets the shaft when only a start head is drawn (#61)', () => { const a = anchors(); const noHead = pathDs({ ...a, seed: 7, head: 'none' })[0]; const withHead = pathDs({ ...a, seed: 7, head: 'start' })[0]; @@ -251,7 +251,7 @@ describe('ScrollArrow shaft/head junction (#59)', () => { expect(withHead).not.toBe(noHead); }); - it("keeps the start head anchored at the true start socket (#61)", () => { + it('keeps the start head anchored at the true start socket (#61)', () => { // Mirror of R3: with anchorEnds the start head must still pass through the // true start socket (100, 20) even though the shaft now starts short of it. const a = anchors(); From 2b9b5b3ade43ea1fa7678beecc248c0c0e2df667 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:40:44 -0400 Subject: [PATCH 6/8] refactor: dedupe test helpers, drop dead dashOffsets, fast-path fractions Simplify pass: shared anchors()/paths() test helpers (pathDs derives from paths); dashOffsets deleted from src (applyProgress uses segmentFractions directly; its numeric suite now guards segmentFractions via a local offsets helper); segments field typed via DrawSegment; segmentFractions skips group bookkeeping when no fill segment exists (per-frame hot path). --- src/draw.ts | 51 ++++++++++++++++++++++----------------- src/scroll-arrow.ts | 11 ++------- test/draw.test.ts | 15 +++--------- test/scroll-arrow.test.ts | 50 ++++++++++++++++---------------------- 4 files changed, 56 insertions(+), 71 deletions(-) diff --git a/src/draw.ts b/src/draw.ts index 92920f3..c804c6d 100644 --- a/src/draw.ts +++ b/src/draw.ts @@ -44,40 +44,52 @@ export interface DrawSegment { group?: number; } -/** - * Given the line/head segments and an eased progress (0..1), return the - * `stroke-dashoffset` each segment should have. - * - * Line sub-strokes (rough.js emits 1-2 overlapping ones) share a single - * leading edge so they grow together as one pen tip. Arrowhead strokes only - * begin once the line is fully drawn, then reveal sequentially. - */ -function lengths(segs: DrawSegment[]): { lineLen: number; headLen: number } { +function lengths(segs: DrawSegment[]): { + lineLen: number; + headLen: number; + hasFill: boolean; +} { let lineLen = 0; let headLen = 0; + let hasFill = false; for (const s of segs) { - if (s.fill) continue; // fades with its group; no length of its own + if (s.fill) { + hasFill = true; // fades with its group; no length of its own + continue; + } if (s.kind === 'line') lineLen = Math.max(lineLen, s.len); else headLen += s.len; } - return { lineLen, headLen }; + return { lineLen, headLen, hasFill }; } /** - * Revealed fraction (0..1) of each segment. Line strokes share one leading - * edge; head strokes reveal sequentially once the line is done. A solid - * head's fill path contributes no length to the budget — its fraction mirrors - * its own head group's stroke reveal, so the fill fades in while the outline - * draws (#60). + * Revealed fraction (0..1) of each segment (a stroke's dash offset is + * `len * (1 - fraction)`). Line strokes share one leading edge so they grow + * together as one pen tip; head strokes reveal sequentially once the line is + * done. A solid head's fill path contributes no length to the budget — its + * fraction mirrors its own head group's stroke reveal, so the fill fades in + * while the outline draws (#60). */ export function segmentFractions(segs: DrawSegment[], eased: number): number[] { - const { lineLen, headLen } = lengths(segs); + const { lineLen, headLen, hasFill } = lengths(segs); const total = lineLen + headLen || 1; const drawn = clamp01(eased) * total; const lp = lineLen > 0 ? clamp01(drawn / lineLen) : 1; let headDrawn = Math.max(0, drawn - lineLen); + // Fast path for the common line-style case: no fill segments, so no group + // bookkeeping — this runs per animation frame while scrolling. + if (!hasFill) { + return segs.map((seg) => { + if (seg.kind === 'line') return lp; + const show = Math.max(0, Math.min(seg.len, headDrawn)); + headDrawn -= seg.len; + return seg.len > 0 ? show / seg.len : 1; + }); + } + const groupLen = new Map(); const groupShown = new Map(); const fractions = segs.map((seg) => { @@ -96,11 +108,6 @@ export function segmentFractions(segs: DrawSegment[], eased: number): number[] { }); } -export function dashOffsets(segs: DrawSegment[], eased: number): number[] { - const fractions = segmentFractions(segs, eased); - return segs.map((seg, i) => seg.len * (1 - fractions[i]!)); -} - /** How far the pen tip has travelled along the line, 0..1. */ export function lineProgress(segs: DrawSegment[], eased: number): number { const { lineLen, headLen } = lengths(segs); diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts index 4181a2a..8a95741 100644 --- a/src/scroll-arrow.ts +++ b/src/scroll-arrow.ts @@ -32,6 +32,7 @@ import { lineProgress, labelOpacity, resolveLabelAt, + type DrawSegment, } from './draw'; interface ResolvedRefs { @@ -67,15 +68,7 @@ export class ScrollArrow { * Drawable segments. Line strokes (rough.js emits 1-2 overlapping ones) share * a leading edge so they grow as a single pen tip; heads draw after the line. */ - private segments: { - el: SVGPathElement; - len: number; - kind: 'line' | 'head'; - /** True for a solid head's fill path — reveals by opacity, not dash. */ - fill?: boolean; - /** Reveal group: all paths from one drawable share it (line, each head). */ - group?: number; - }[] = []; + private segments: (DrawSegment & { el: SVGPathElement })[] = []; /** Counter handing each appendDrawable call its reveal group id. */ private groupIds = 0; /** Representative line stroke + label nodes, when a label is set. */ diff --git a/test/draw.test.ts b/test/draw.test.ts index 52bba2b..abb3e1b 100644 --- a/test/draw.test.ts +++ b/test/draw.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest'; import { - dashOffsets, segmentFractions, lineProgress, labelOpacity, @@ -8,6 +7,10 @@ import { type DrawSegment, } from '../src/draw'; +/** The dash offset each stroke segment gets in applyProgress: len * (1 - fraction). */ +const dashOffsets = (segs: DrawSegment[], eased: number): number[] => + segmentFractions(segs, eased).map((f, i) => segs[i]!.len * (1 - f)); + const line2: DrawSegment[] = [ { len: 100, kind: 'line' }, { len: 100, kind: 'line' }, @@ -121,16 +124,6 @@ describe('segmentFractions with a solid head fill (#60)', () => { expect(fill1).toBe(1); expect(fill2).toBeCloseTo(0.5); }); - - it('matches dashOffsets exactly for stroke-only segments (R2)', () => { - for (const eased of [0, 0.3, 0.5, 100 / 120, 1]) { - const fr = segmentFractions(withHead, eased); - const offs = dashOffsets(withHead, eased); - withHead.forEach((s, i) => { - expect(s.len * (1 - fr[i]!)).toBeCloseTo(offs[i]!); - }); - } - }); }); describe('lineProgress', () => { diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 6cea207..2b8729a 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -24,16 +24,31 @@ function boxed(rect: Partial): HTMLElement { return el; } -/** Render an arrow, return every path's `d` (line stroke(s) first, then heads). */ -function pathDs(opts: ConstructorParameters[0]): string[] { +/** Render an arrow, return every path's { d, fill } (line stroke(s) first, + * then heads), then clean up. */ +function paths( + opts: ConstructorParameters[0], +): { d: string; fill: string }[] { new ScrollArrow(opts); - const ds = [...document.querySelectorAll('svg path')].map( - (p) => p.getAttribute('d') ?? '', - ); + const out = [...document.querySelectorAll('svg path')].map((p) => ({ + d: p.getAttribute('d') ?? '', + fill: p.getAttribute('fill') ?? '', + })); document.querySelectorAll('svg').forEach((s) => s.remove()); - return ds; + return out; +} + +/** Render an arrow, return every path's `d` (line stroke(s) first, then heads). */ +function pathDs(opts: ConstructorParameters[0]): string[] { + return paths(opts).map((p) => p.d); } +/** Standard side-by-side anchors: start socket (100, 20), end socket (300, 20). */ +const anchors = () => ({ + start: boxed({ left: 0, top: 0, width: 100, height: 40 }), + end: boxed({ left: 300, top: 0, width: 100, height: 40 }), +}); + beforeEach(() => { ioInstances = []; // @ts-expect-error -- minimal stub for jsdom @@ -195,11 +210,6 @@ describe('ScrollArrow shaft/head junction (#59)', () => { delete proto.getBBox; }); - const anchors = () => ({ - start: boxed({ left: 0, top: 0, width: 100, height: 40 }), - end: boxed({ left: 300, top: 0, width: 100, height: 40 }), - }); - it('pulls the shaft back from the tip when an end head is drawn (R1)', () => { const a = anchors(); const noHead = pathDs({ ...a, seed: 7, head: 'none' })[0]; @@ -297,24 +307,6 @@ describe("ScrollArrow headStyle: 'solid' (#60)", () => { delete (SVGElement.prototype as SvgStubs).getTotalLength; }); - const anchors = () => ({ - start: boxed({ left: 0, top: 0, width: 100, height: 40 }), - end: boxed({ left: 300, top: 0, width: 100, height: 40 }), - }); - - /** Render and return every path's { d, fill }, then clean up. */ - const paths = ( - opts: ConstructorParameters[0], - ): { d: string; fill: string }[] => { - new ScrollArrow(opts); - const out = [...document.querySelectorAll('svg path')].map((p) => ({ - d: p.getAttribute('d') ?? '', - fill: p.getAttribute('fill') ?? '', - })); - document.querySelectorAll('svg').forEach((s) => s.remove()); - return out; - }; - it('fills the end head with the stroke color', () => { const a = anchors(); const ps = paths({ From 71fd6f0741891502d2985dc18d6ae4c2e707cfad Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:41:15 -0400 Subject: [PATCH 7/8] fix(review): apply review findings Add integration test proving each solid head fades independently for head:'both' via appendDrawable's real group-id wiring (review finding #1). --- test/scroll-arrow.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 2b8729a..c8dbcad 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -369,6 +369,32 @@ describe("ScrollArrow headStyle: 'solid' (#60)", () => { document.querySelectorAll('svg').forEach((s) => s.remove()); }); + it("fades each solid head independently for head: 'both'", () => { + // Proves appendDrawable's real group ids drive per-head fades: at an + // intermediate progress the end head's fill is fully in while the start + // head's is still fading (heads reveal sequentially after the line). + const a = anchors(); + const arrow = new ScrollArrow({ + ...a, + seed: 7, + head: 'both', + headStyle: 'solid', + stroke: '#123456', + scroll: false, + }); + arrow.setProgress(0.6); + const opacities = [...document.querySelectorAll('svg path')] + .filter((p) => p.getAttribute('fill') === '#123456') + .map((p) => Number((p as SVGPathElement).style.opacity)); + expect(opacities).toHaveLength(2); + const hi = Math.max(...opacities); + const lo = Math.min(...opacities); + expect(hi).toBe(1); + expect(lo).toBeGreaterThan(0); + expect(lo).toBeLessThan(1); + document.querySelectorAll('svg').forEach((s) => s.remove()); + }); + it("line paths keep fill='none' regardless of headStyle", () => { const a = anchors(); const ps = paths({ From 6e12fd772bd9771ef54a8847e21d621ecd093cdd Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Tue, 7 Jul 2026 00:30:58 -0400 Subject: [PATCH 8/8] feat(demo): showcase solid arrowheads New section between obstacle avoidance and the tree reveal: head 'both' with headStyle 'solid' so both filled heads fade in with their outlines. Hardcoded section indexes for the tree and reduced-motion cards shift by one. --- demo/index.html | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/demo/index.html b/demo/index.html index ed01c7a..955b0c4 100644 --- a/demo/index.html +++ b/demo/index.html @@ -282,6 +282,14 @@

Obstacle avoidance

+
+

Solid arrowheads · filled

+
+
open V
+
filled
+
+
+

Staggered group · tree reveal

@@ -415,6 +423,17 @@

Elbow routing · org chart

labelAt: '22%', }, ], + [ + { + start: '#s-b', + end: '#s-a', + roughness: 0.5, + head: 'both', + headStyle: 'solid', + headSize: 18, + label: 'filled', + }, + ], ]; examples.forEach(([opts], i) => { @@ -443,7 +462,7 @@

Elbow routing · org chart

scroll: { target: '.tree' }, }); - const treeSection = document.querySelectorAll('.section')[5]; + const treeSection = document.querySelectorAll('.section')[6]; const groupCard = document.createElement('div'); groupCard.className = 'code'; groupCard.innerHTML = renderGroupCall(); @@ -460,7 +479,7 @@

Elbow routing · org chart

}; scrollArrow({ ...base, ...rmOpts }); - const rmSection = document.querySelectorAll('.section')[6]; + const rmSection = document.querySelectorAll('.section')[7]; const rmCard = document.createElement('div'); rmCard.className = 'code'; rmCard.innerHTML = renderCall(rmOpts);