From 497ff7f33d1da621aa3a621818fa020c74f3436f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:32:54 +0000 Subject: [PATCH 01/14] chore: release v0.6.0 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f7734e..8590350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Release entries below are generated by `release-changelog.yml` when a ## [Unreleased] +## [0.6.0] - 2026-07-04 + +- #56 — feat(avoid): curve-aware detection and per-control-point belly routing (@dancj) + ## [0.5.2] - 2026-06-22 - #52 — fix(demo): make demo page mobile-responsive (@dancj) diff --git a/package.json b/package.json index 02df9f8..cc6105b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scroll-arrows", - "version": "0.5.2", + "version": "0.6.0", "description": "Hand-drawn arrows that draw themselves between two elements as you scroll. Roughness goes from clean straight lines to scratchy and curvy.", "type": "module", "sideEffects": false, From 502af1488357375f13de5ba34b7bbcaacadef9b4 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:52:24 -0400 Subject: [PATCH 02/14] docs(plan): shaft/arrowhead overlap fix plan (#59) --- ...6-07-06-001-fix-shaft-head-overlap-plan.md | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md diff --git a/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md b/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md new file mode 100644 index 0000000..36f00f6 --- /dev/null +++ b/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md @@ -0,0 +1,128 @@ +--- +title: "fix: Stop shaft overlapping the arrowhead" +date: 2026-07-06 +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +product_contract_source: ce-plan-bootstrap +origin: "GitHub issue #59" +--- + +# fix: Stop shaft overlapping the arrowhead + +## Summary + +The shaft path and the arrowhead share the same endpoint, so the line (plus rough.js wobble) visibly crosses the arrowhead's V instead of meeting it at the base. Fix: when a head is drawn at an end, shorten the shaft by the head's base depth — pulling the endpoint back along the arrival tangent (curve route) or trimming the first/last leg (elbow route) — while keeping the arrowhead itself anchored at the true socket point. Both ends, with clamps so very short arrows never invert. + +**Branch/PR convention:** branch from `origin/staging`; PR targets `staging`. + +--- + +## Problem Frame + +In `src/scroll-arrow.ts` `render()`, `buildPath(local, …)` / `buildElbowPath(local)` end exactly at `local.end`, and the end head is `arrowHeadPath(local.end, dir, size)` — tip at the same point. The head's base sits `size * cos(spread)` behind the tip (`spread = π/7` in `src/geometry.ts` `arrowHeadPath`), so the shaft runs ~0.9 × headSize past where it should stop. Same at the start when `head` is `'start'` or `'both'`. + +## Requirements + +- **R1**: When `head` includes `'end'`, the shaft terminates at the arrowhead's base (tip minus base depth along the aim direction), not at the tip. +- **R2**: Same treatment for the start point when `head` includes `'start'`. +- **R3**: The arrowhead stays anchored at the true socket point — the tip still touches the target edge. +- **R4**: `head: 'none'` (and the non-headed end of `'start'`/`'end'`) renders exactly as today. +- **R5**: Both `route: 'curve'` (default, incl. obstacle avoidance) and `route: 'elbow'` get the inset. +- **R6**: Very short arrows degrade gracefully — insets never invert or collapse the shaft. +- **R7**: Draw-on animation ordering unchanged: head strokes still begin after the (now slightly shorter) line finishes. + +--- + +## Key Technical Decisions + +- **KTD1 — Inset lives in geometry as a pure function (curve route).** Add `insetEndpoints(ep, startInset, endInset): Endpoints` to `src/geometry.ts`. It moves `start`/`end` back along `startTangent(ep)`/`endTangent(ep)` (i.e. `p - tangent * inset`; note `endTangent` is `-endNormal` for side sockets, so this equals `end + endNormal * inset` — same formula, two spellings) and preserves the normals so `cubicControls` still bows the curve off the socket edges. Pure and unit-testable; `render()` just calls it. +- **KTD2 — Base depth derived from the shared spread constant.** Inset = `headSize * cos(π/7)` (~0.9 × headSize). Hoist the `spread` constant in `geometry.ts` so `arrowHeadPath` and the inset computation share one source of truth; no new public option (issue allows a fixed fraction). +- **KTD3 — Short-arrow clamp (curve route).** If `startInset + endInset` exceeds half the start→end chord length, scale both proportionally so the shaft keeps at least half the chord. Prevents inverted/degenerate paths for tiny arrows (R6). The chord comparison is exact only for facing-collinear sockets (elsewhere it is strictly conservative — it may shrink insets more than needed, which is safe); test it in that configuration. +- **KTD4 — Curve route gets inset endpoints; elbow route trims its legs.** For the curve route, `routeBellies` and `buildPath` receive the inset endpoints, so obstacle detection runs against the curve actually rendered. The elbow route must NOT receive inset endpoints — `buildElbowPath` recomputes corners from its endpoints, so a moved endpoint shifts the mid-rail by inset/2 and can invert the final leg entirely (e.g. start `(0,0)` right-socket → end `(200,5)` top-socket: inset end `(200,-7.6)` makes the last leg travel away from the target while the head points at it). Instead, build the elbow from the true endpoints and trim the first/last leg along its own axis by the inset, clamping each trim to that leg's length. This per-leg trim also handles center-socket elbows correctly (the trim follows the actual leg direction, not a tangent guess). +- **KTD5 — Head aim vs shaft arrival for center sockets (curve route).** Heads are aimed with tangents computed from the original `local` endpoints. For side sockets the tangents are the socket normals and are unaffected by insetting. For a zero-normal (center) socket the tangent falls back to the chord direction, so insetting the far endpoint rotates the shaft's true arrival by ~`atan(inset/chord)` relative to the head aim — a few degrees at typical sizes. Accepted: center sockets are already a fallback path. + +## High-Level Technical Design + +``` +render(): + local = shifted endpoints (true socket points) + si/ei = per-end insets from opts.head (headSize * cos(π/7), or 0) + dirs = endTangent(local)/startTangent(local) + d = route === 'elbow' + ? buildElbowPath(local, si, ei) // true endpoints; first/last leg trimmed + : buildPath(insetEndpoints(local, si, ei), curvature, ...routeBellies(...)) + heads = arrowHeadPath(local.end, dirs.end, size) // still at true tip + arrowHeadPath(local.start, dirs.start, size) +``` + +Geometry of the end inset (side socket, curve route): travel arrives along `-endNormal`, so the shaft's new endpoint is `end + endNormal * inset` — back along the arrival path, exactly at the head's base midpoint. Elbow route: corners stay computed from true endpoints; only the first/last straight leg is shortened by the trim. + +--- + +## Implementation Units + +### U1. Geometry: `insetEndpoints`, elbow leg trims, shared spread constant + +**Goal:** Pure inset/trim primitives with clamping. +**Requirements:** R1, R2, R5, R6 (geometry half), KTD1–KTD4. +**Dependencies:** none. +**Files:** `src/geometry.ts`, `test/geometry.test.ts`. +**Approach:** Hoist `spread = Math.PI / 7` to a module constant used by `arrowHeadPath`; export `headBaseInset(size)` (or equivalent) returning `size * Math.cos(spread)`. Add `insetEndpoints(ep, startInset, endInset)` (curve route) that computes tangents via existing `startTangent`/`endTangent`, applies the proportional clamp from KTD3, and returns a new `Endpoints` with moved points and original normals. Extend `buildElbowPath(ep, startTrim = 0, endTrim = 0)` (elbow route) to shorten the first/last straight leg along its own axis after corners are computed from the true endpoints, clamping each trim to its leg's length (KTD4). Follow existing pure-function style in `geometry.ts`. +**Patterns to follow:** `routeBellies` / `samplePath` doc-comment style; existing `Endpoints` shape; `buildElbowPath` branch structure. +**Test scenarios:** +- End inset, side socket: `insetEndpoints(ep, 0, k)` moves `end` by `+endNormal * k`, leaves `start` and normals untouched. +- Start inset, side socket: `start` moves by `+startNormal * k`. +- Both insets applied independently. +- Zero insets/trims: output identical to today (identity for `head: 'none'`). +- Center sockets (zero normals): inset falls back along the chord direction (matches `endTangent` fallback). +- Short chord clamp (facing-collinear sockets, where the comparison is exact): chord length 10, insets 12+12 → both scale so shaft length stays ≥ half the chord; endpoints never cross. +- Elbow end trim: corners unchanged (mid-rail/corner coordinates identical to untrimmed output); only the final leg's endpoint moves back along that leg by the trim. +- Elbow inversion guard: start `(0,0)` right-socket → end `(200,5)` top-socket with trim 12.6 (final leg length 5) — trim clamps to the leg length; the final leg never extends past its corner. +- Elbow same-axis sockets: mid-rail `midY`/`midX` identical with and without trims. +**Verification:** `vitest` geometry suite green; new cases cover each scenario. + +### U2. Wire inset into `render()` for both routes + +**Goal:** Shaft stops at the head base in the rendered SVG; heads stay at true sockets. +**Requirements:** R1–R5, R7, KTD4. +**Dependencies:** U1. +**Files:** `src/scroll-arrow.ts`, `test/scroll-arrow.test.ts`. +**Approach:** In `render()`, after computing `local`, derive per-end insets from `this.opts.head` and `headSize`. Curve route: build `shaft = insetEndpoints(local, si, ei)` and pass `shaft` to `routeBellies` + `buildPath`. Elbow route: pass the true `local` plus the insets as leg trims — `buildElbowPath(local, si, ei)`. Keep `arrowHeadPath(local.end, endTangent(local), size)` and the start-head call on `local` unchanged. `lineD` (label measurement) naturally uses the shortened path. No changes to `dashOffsets` / segment ordering. +**Patterns to follow:** existing `render()` structure; `shift()` local-coords idiom. +**Test scenarios:** +- Covers R1: `head: 'end'` — the line path's `d` terminates at the socket point pulled back by `headSize * cos(π/7)` along the end normal; head path still contains the true tip coordinates. +- Covers R2: `head: 'both'` — start of line `d` inset along the start normal; both heads at true sockets. +- Covers R4: `head: 'none'` — line `d` endpoints equal the socket points exactly (byte-compare against pre-change expectation). +- Covers R5: `route: 'elbow'` with `head: 'end'` — final `L` coordinate pulled back along the final leg's axis; corner coordinates unchanged from the untrimmed elbow. +- Covers R7: segments still ordered line-then-head; existing `draw.test.ts` ordering tests pass unchanged. +- Label still renders on a labelled arrow (shortened `lineD` doesn't break `renderLabel`). +**Verification:** full `vitest` suite green; visual spot-check in demo page shows shaft meeting head base cleanly. + +--- + +## Scope Boundaries + +**In scope:** shaft/head junction geometry for curve and elbow routes, both ends. + +**Out of scope (true non-goals):** rough.js wobble/overshoot tuning; changing arrowhead shape or spread. + +### Deferred to Follow-Up Work +- Solid/filled arrowhead type — tracked as issue #60. +- Head-aim vs shaft-arrival divergence for center-socket curve arrows (KTD5) — accepted, revisit only if visible in practice. + +## Risks & Dependencies + +- **Snapshot-ish tests:** existing tests asserting exact path `d` strings for headed arrows will need updating — expected, not a regression. +- **rough.js overshoot:** wobble can still poke a stroke slightly past the shaft end; issue accepts this (the fix removes the systematic full-tip overlap). + +## Verification Contract + +- `npx vitest run` green (geometry, scroll-arrow, draw, reduced-motion suites). +- Lint/typecheck clean (`eslint`, `tsc`). + +## Definition of Done + +- R1–R7 hold, demonstrated by the U1/U2 test scenarios. +- All existing tests pass (updated only where they encoded the old overlapping endpoint). +- No public API change. From 37ccffa7e94951cfbc46ff7dd61008a6430a451c Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:54:29 -0400 Subject: [PATCH 03/14] feat(geometry): endpoint insets and elbow leg trims for head clearance insetEndpoints pulls curve endpoints back along their tangents by an arrowhead's base depth (headBaseInset = size * cos(spread)), scaling both insets down on tiny chords so endpoints never cross. buildElbowPath gains per-leg trims computed after the corners, so the mid-rail stays put and a trim can never invert a short final leg. Groundwork for #59. --- src/geometry.ts | 68 +++++++++++++++++++- test/geometry.test.ts | 146 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/src/geometry.ts b/src/geometry.ts index 4419448..921c5f5 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -210,8 +210,18 @@ export function samplePath( * bracket); perpendicular sockets get a single L-corner. Center sockets (no * normal) fall back to the dominant delta axis. rough.js then sketches the * straight segments for the hand-drawn look. + * + * `startTrim`/`endTrim` shorten the first/last leg along its own axis (issue + * #59: stop the shaft at an arrowhead's base). Corners are computed from the + * true endpoints first, so trimming never moves the mid-rail; each trim is + * clamped to its leg's length so the leg stops at its corner instead of + * inverting past it. */ -export function buildElbowPath(ep: Endpoints): string { +export function buildElbowPath( + ep: Endpoints, + startTrim = 0, + endTrim = 0, +): string { const { start: s, end: e, startNormal: sn, endNormal: en } = ep; const dx = e.x - s.x; const dy = e.y - s.y; @@ -236,6 +246,9 @@ export function buildElbowPath(ep: Endpoints): string { pts = [s, { x: e.x, y: s.y }, e]; } + if (startTrim > 0) trimLeg(pts, 0, 1, startTrim); + if (endTrim > 0) trimLeg(pts, pts.length - 1, pts.length - 2, endTrim); + return ( `M ${r(pts[0]!.x)} ${r(pts[0]!.y)}` + pts @@ -245,6 +258,16 @@ export function buildElbowPath(ep: Endpoints): string { ); } +/** Move pts[i] toward pts[j] by `by`, clamped to that leg's length. */ +function trimLeg(pts: Point[], i: number, j: number, by: number): void { + const a = pts[i]!; + const b = pts[j]!; + const len = Math.hypot(b.x - a.x, b.y - a.y); + if (len === 0) return; + const k = Math.min(by, len) / len; + pts[i] = { x: a.x + (b.x - a.x) * k, y: a.y + (b.y - a.y) * k }; +} + /** Axis-aligned box, in the same coordinate space as the endpoints. */ export interface Box { left: number; @@ -350,10 +373,19 @@ export function routeBellies( return { b1, b2 }; } +/** Half-angle of the arrowhead's V. Shared with headBaseInset so the shaft + * inset lands exactly on the head's base. */ +const HEAD_SPREAD = Math.PI / 7; + +/** Depth of the arrowhead's base behind its tip, along the aim direction. */ +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 { const a = Math.atan2(dir.y, dir.x); - const spread = Math.PI / 7; + const spread = HEAD_SPREAD; const p1 = { x: tip.x - size * Math.cos(a - spread), y: tip.y - size * Math.sin(a - spread), @@ -365,6 +397,38 @@ export function arrowHeadPath(tip: Point, dir: Point, size: number): string { return `M ${r(p1.x)} ${r(p1.y)} L ${r(tip.x)} ${r(tip.y)} L ${r(p2.x)} ${r(p2.y)}`; } +/** + * Pull the endpoints back along their tangents so the shaft terminates at an + * arrowhead's base instead of running into its tip (issue #59). The moved + * point is `p - tangent * inset` — for a side socket that is `p + normal * + * inset`, back along the arrival/departure path. Normals are preserved so + * cubicControls still bows the curve off the socket edges. When the summed + * insets would eat more than half the chord (tiny arrows), both scale down + * proportionally so the shaft keeps at least half the chord and the endpoints + * never cross. Curve route only — the elbow route trims its legs instead + * (see buildElbowPath). + */ +export function insetEndpoints( + ep: Endpoints, + startInset: number, + endInset: number, +): Endpoints { + const total = startInset + endInset; + if (total <= 0) return ep; + const chord = Math.hypot(ep.end.x - ep.start.x, ep.end.y - ep.start.y); + const scale = total > chord / 2 ? chord / 2 / total : 1; + const st = startTangent(ep); + const et = endTangent(ep); + const si = startInset * scale; + const ei = endInset * scale; + return { + start: { x: ep.start.x - st.x * si, y: ep.start.y - st.y * si }, + end: { x: ep.end.x - et.x * ei, y: ep.end.y - et.y * ei }, + startNormal: ep.startNormal, + endNormal: ep.endNormal, + }; +} + /** Tangent direction of the cubic at its end, for aiming the arrowhead. */ export function endTangent(ep: Endpoints): Point { // Curve arrives along the inverse of the end normal; good enough and stable. diff --git a/test/geometry.test.ts b/test/geometry.test.ts index 588ef20..fa2979f 100644 --- a/test/geometry.test.ts +++ b/test/geometry.test.ts @@ -10,6 +10,9 @@ import { routeBellies, samplePath, isDegenerateRect, + insetEndpoints, + headBaseInset, + type Endpoints, type DocRect, type Box, } from '../src/geometry'; @@ -405,3 +408,146 @@ function controlX(d: string): number { const cIdx = parts.indexOf('C'); return Number(parts[cIdx + 1]); } + +describe('headBaseInset', () => { + it('is the head base depth: size * cos(spread)', () => { + expect(headBaseInset(14)).toBeCloseTo(14 * Math.cos(Math.PI / 7), 6); + }); +}); + +describe('insetEndpoints', () => { + const facing = (): Endpoints => ({ + start: { x: 100, y: 50 }, + end: { x: 300, y: 50 }, + startNormal: { x: 1, y: 0 }, + endNormal: { x: -1, y: 0 }, + }); + + it('moves the end back along the arrival path (+endNormal)', () => { + const out = insetEndpoints(facing(), 0, 10); + expect(out.end).toEqual({ x: 290, y: 50 }); + expect(out.start).toEqual({ x: 100, y: 50 }); + expect(out.startNormal).toEqual({ x: 1, y: 0 }); + expect(out.endNormal).toEqual({ x: -1, y: 0 }); + }); + + it('moves the start forward along the departure path (+startNormal)', () => { + const out = insetEndpoints(facing(), 10, 0); + expect(out.start).toEqual({ x: 110, y: 50 }); + expect(out.end).toEqual({ x: 300, y: 50 }); + }); + + it('applies both insets independently', () => { + const out = insetEndpoints(facing(), 5, 10); + expect(out.start).toEqual({ x: 105, y: 50 }); + expect(out.end).toEqual({ x: 290, y: 50 }); + }); + + it('is the identity for zero insets (head: none)', () => { + const ep = facing(); + const out = insetEndpoints(ep, 0, 0); + expect(out.start).toEqual(ep.start); + expect(out.end).toEqual(ep.end); + }); + + it('falls back along the chord for center sockets (zero normals)', () => { + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 100, y: 0 }, + startNormal: { x: 0, y: 0 }, + endNormal: { x: 0, y: 0 }, + }; + const out = insetEndpoints(ep, 10, 10); + expect(out.start.x).toBeCloseTo(10, 6); + expect(out.end.x).toBeCloseTo(90, 6); + }); + + it('clamps on a short facing-collinear chord so the shaft keeps half of it', () => { + // Chord 10, insets 12+12: scale to 5 total; endpoints never cross. + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 10, y: 0 }, + startNormal: { x: 1, y: 0 }, + endNormal: { x: -1, y: 0 }, + }; + const out = insetEndpoints(ep, 12, 12); + expect(out.start.x).toBeCloseTo(2.5, 6); + expect(out.end.x).toBeCloseTo(7.5, 6); + expect(out.end.x - out.start.x).toBeCloseTo(5, 6); // ≥ half the chord + }); +}); + +describe('buildElbowPath leg trims', () => { + const lastPoint = (d: string): { x: number; y: number } => { + const parts = d.trim().split(/\s+/); + return { + x: Number(parts[parts.length - 2]), + y: Number(parts[parts.length - 1]), + }; + }; + + it('trims only the final leg; corners stay put', () => { + // Horizontal exit, vertical arrival: corner over the end. + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 200, y: 50 }, + startNormal: { x: 1, y: 0 }, + endNormal: { x: 0, y: -1 }, + }; + const plain = buildElbowPath(ep); + const trimmed = buildElbowPath(ep, 0, 10); + expect(plain).toContain('L 200 0'); // corner + expect(trimmed).toContain('L 200 0'); // corner unchanged + expect(lastPoint(trimmed)).toEqual({ x: 200, y: 40 }); + }); + + it('clamps the trim to the leg length so the leg never inverts past its corner', () => { + // Final leg is only 5px (end nearly level with the start rail). + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 200, y: 5 }, + startNormal: { x: 1, y: 0 }, + endNormal: { x: 0, y: -1 }, + }; + const trimmed = buildElbowPath(ep, 0, headBaseInset(14)); + expect(lastPoint(trimmed)).toEqual({ x: 200, y: 0 }); // stops at the corner + }); + + it('trims the first leg toward its corner', () => { + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 200, y: 50 }, + startNormal: { x: 1, y: 0 }, + endNormal: { x: 0, y: -1 }, + }; + const trimmed = buildElbowPath(ep, 10, 0); + expect(trimmed.startsWith('M 10 0')).toBe(true); + }); + + it('leaves the mid-rail of a same-axis elbow untouched', () => { + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 200, y: 300 }, + startNormal: { x: 0, y: 1 }, + endNormal: { x: 0, y: -1 }, + }; + const plain = buildElbowPath(ep); + const trimmed = buildElbowPath(ep, 10, 10); + expect(plain).toContain('L 0 150'); + expect(plain).toContain('L 200 150'); + expect(trimmed).toContain('L 0 150'); // mid-rail identical + expect(trimmed).toContain('L 200 150'); + expect(trimmed.startsWith('M 0 10')).toBe(true); + expect(lastPoint(trimmed)).toEqual({ x: 200, y: 290 }); + }); + + it('is unchanged for zero trims', () => { + const ep: Endpoints = { + start: { x: 0, y: 0 }, + end: { x: 200, y: 300 }, + startNormal: { x: 0, y: 1 }, + endNormal: { x: 0, y: -1 }, + }; + expect(buildElbowPath(ep, 0, 0)).toBe(buildElbowPath(ep)); + }); +}); From c797111a6abc3d9c8bee69500da8a51f4f2bc374 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:57:30 -0400 Subject: [PATCH 04/14] fix(render): stop the shaft at the arrowhead base, not its tip (#59) When a head is drawn, the curve route feeds inset endpoints to routing and path build (so avoid detection matches the rendered shaft) and the elbow route trims its first/last leg. Heads stay anchored at the true socket points, so tips still touch the target edge. Draw-on ordering unchanged. --- src/scroll-arrow.ts | 24 +++++++--- test/scroll-arrow.test.ts | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts index 57a6e85..876ae07 100644 --- a/src/scroll-arrow.ts +++ b/src/scroll-arrow.ts @@ -8,6 +8,8 @@ import { buildPath, buildElbowPath, arrowHeadPath, + headBaseInset, + insetEndpoints, endTangent, startTangent, unitNormal, @@ -249,12 +251,22 @@ export class ScrollArrow { this.opts.anchorEnds ?? true, ); + // Stop the shaft at each drawn head's base instead of its tip (#59). The + // heads themselves stay anchored at the true socket points below. + const head = this.opts.head; + const size = this.opts.headSize; + const inset = headBaseInset(size); + const startInset = head === 'start' || head === 'both' ? inset : 0; + const endInset = head === 'end' || head === 'both' ? inset : 0; + let d: string; if (this.opts.route === 'elbow') { // Orthogonal connector: ignores obstacle avoidance and curvature. - d = buildElbowPath(local); + d = buildElbowPath(local, startInset, endInset); } else { - // Route around any obstacles, then build the curve. + // Route around any obstacles, then build the curve — both against the + // inset endpoints, so detection matches the rendered shaft. + const shaft = insetEndpoints(local, startInset, endInset); const obstacles: Box[] = this.resolveAvoid().map((el) => { const dr = docRect(el); return { @@ -265,19 +277,17 @@ export class ScrollArrow { }; }); const { b1, b2 } = routeBellies( - local, + shaft, curvature, obstacles, this.opts.avoidPadding ?? 14, ); - d = buildPath(local, curvature, b1, b2); + d = buildPath(shaft, curvature, b1, b2); } this.lineD = d; this.appendDrawable(this.rc.path(d, roughOpts), 'line'); - // Arrowheads. - const head = this.opts.head; - const size = this.opts.headSize; + // Arrowheads, anchored at the true socket points. if (head === 'end' || head === 'both') { const dir = endTangent(local); this.appendDrawable( diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 8a9b409..013a62a 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -166,3 +166,95 @@ describe('ScrollArrow avoid routing', () => { expect(routed).toBe(plain); }); }); + +describe('ScrollArrow shaft/head junction (#59)', () => { + // Same jsdom SVG-geometry stub as the avoid-routing suite above, plus the + // label-measurement calls (getPointAtLength/getBBox). rough.js is + // deterministic for a fixed seed, so DOM `d` strings witness geometry. + type SvgStubs = { + getTotalLength?: unknown; + getPointAtLength?: unknown; + getBBox?: unknown; + }; + beforeEach(() => { + const proto = SVGElement.prototype as SvgStubs; + proto.getTotalLength = () => 100; + proto.getPointAtLength = (l: number) => ({ x: l, y: 20 }); + proto.getBBox = () => ({ x: 0, y: 0, width: 20, height: 10 }); + }); + afterEach(() => { + const proto = SVGElement.prototype as SvgStubs; + delete proto.getTotalLength; + delete proto.getPointAtLength; + delete proto.getBBox; + }); + + function pathDs(opts: ConstructorParameters[0]): string[] { + new ScrollArrow(opts); + const ds = [...document.querySelectorAll('svg path')].map( + (p) => p.getAttribute('d') ?? '', + ); + document.querySelectorAll('svg').forEach((s) => s.remove()); + return ds; + } + 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]; + const withHead = pathDs({ ...a, seed: 7, head: 'end' })[0]; + expect(noHead).toBeTruthy(); + expect(withHead).toBeTruthy(); + expect(withHead).not.toBe(noHead); + }); + + it('leaves the shaft untouched when no head is drawn (R4)', () => { + // headSize can only reach the line through the inset; with head:'none' + // the shaft must not depend on it. + const a = anchors(); + const small = pathDs({ ...a, seed: 7, head: 'none', headSize: 14 })[0]; + const large = pathDs({ ...a, seed: 7, head: 'none', headSize: 28 })[0]; + expect(small).toBe(large); + }); + + it('scales the shaft inset with headSize', () => { + const a = anchors(); + const small = pathDs({ ...a, seed: 7, head: 'end', headSize: 14 })[0]; + const large = pathDs({ ...a, seed: 7, head: 'end', headSize: 28 })[0]; + expect(small).not.toBe(large); + }); + + it('insets the start too for head: both (R2)', () => { + const a = anchors(); + const endOnly = pathDs({ ...a, seed: 7, head: 'end' })[0]; + const both = pathDs({ ...a, seed: 7, head: 'both' })[0]; + expect(both).not.toBe(endOnly); + }); + + it('keeps the arrowhead anchored at the true socket point (R3)', () => { + // anchorEnds (preserveVertices) keeps exact vertices, so the head path + // must still pass through the true end socket (300, 20) even though the + // shaft now stops short of it. + const a = anchors(); + const ds = pathDs({ ...a, seed: 7, head: 'end' }); + const headD = ds[ds.length - 1]!; + expect(headD).toMatch(/300[ ,]+20/); + }); + + 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]; + const withHead = pathDs({ ...a, seed: 7, route: 'elbow', head: 'end' })[0]; + expect(withHead).not.toBe(noHead); + }); + + it('still renders a label on a headed arrow', () => { + const a = anchors(); + new ScrollArrow({ ...a, seed: 7, head: 'both', label: 'hi' }); + expect(document.querySelector('svg text')?.textContent).toBe('hi'); + document.querySelectorAll('svg').forEach((s) => s.remove()); + }); +}); From 4f53ce141f674c78ce573a42c7f84a3418e726c4 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:15:07 -0400 Subject: [PATCH 05/14] fix(review): format plan doc with prettier --- .../2026-07-06-001-fix-shaft-head-overlap-plan.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md b/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md index 36f00f6..d857ad2 100644 --- a/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md +++ b/docs/plans/2026-07-06-001-fix-shaft-head-overlap-plan.md @@ -1,11 +1,11 @@ --- -title: "fix: Stop shaft overlapping the arrowhead" +title: 'fix: Stop shaft overlapping the arrowhead' date: 2026-07-06 artifact_contract: ce-unified-plan/v1 artifact_readiness: implementation-ready execution: code product_contract_source: ce-plan-bootstrap -origin: "GitHub issue #59" +origin: 'GitHub issue #59' --- # fix: Stop shaft overlapping the arrowhead @@ -71,6 +71,7 @@ Geometry of the end inset (side socket, curve route): travel arrives along `-end **Approach:** Hoist `spread = Math.PI / 7` to a module constant used by `arrowHeadPath`; export `headBaseInset(size)` (or equivalent) returning `size * Math.cos(spread)`. Add `insetEndpoints(ep, startInset, endInset)` (curve route) that computes tangents via existing `startTangent`/`endTangent`, applies the proportional clamp from KTD3, and returns a new `Endpoints` with moved points and original normals. Extend `buildElbowPath(ep, startTrim = 0, endTrim = 0)` (elbow route) to shorten the first/last straight leg along its own axis after corners are computed from the true endpoints, clamping each trim to its leg's length (KTD4). Follow existing pure-function style in `geometry.ts`. **Patterns to follow:** `routeBellies` / `samplePath` doc-comment style; existing `Endpoints` shape; `buildElbowPath` branch structure. **Test scenarios:** + - End inset, side socket: `insetEndpoints(ep, 0, k)` moves `end` by `+endNormal * k`, leaves `start` and normals untouched. - Start inset, side socket: `start` moves by `+startNormal * k`. - Both insets applied independently. @@ -80,7 +81,7 @@ Geometry of the end inset (side socket, curve route): travel arrives along `-end - Elbow end trim: corners unchanged (mid-rail/corner coordinates identical to untrimmed output); only the final leg's endpoint moves back along that leg by the trim. - Elbow inversion guard: start `(0,0)` right-socket → end `(200,5)` top-socket with trim 12.6 (final leg length 5) — trim clamps to the leg length; the final leg never extends past its corner. - Elbow same-axis sockets: mid-rail `midY`/`midX` identical with and without trims. -**Verification:** `vitest` geometry suite green; new cases cover each scenario. + **Verification:** `vitest` geometry suite green; new cases cover each scenario. ### U2. Wire inset into `render()` for both routes @@ -91,13 +92,14 @@ Geometry of the end inset (side socket, curve route): travel arrives along `-end **Approach:** In `render()`, after computing `local`, derive per-end insets from `this.opts.head` and `headSize`. Curve route: build `shaft = insetEndpoints(local, si, ei)` and pass `shaft` to `routeBellies` + `buildPath`. Elbow route: pass the true `local` plus the insets as leg trims — `buildElbowPath(local, si, ei)`. Keep `arrowHeadPath(local.end, endTangent(local), size)` and the start-head call on `local` unchanged. `lineD` (label measurement) naturally uses the shortened path. No changes to `dashOffsets` / segment ordering. **Patterns to follow:** existing `render()` structure; `shift()` local-coords idiom. **Test scenarios:** + - Covers R1: `head: 'end'` — the line path's `d` terminates at the socket point pulled back by `headSize * cos(π/7)` along the end normal; head path still contains the true tip coordinates. - Covers R2: `head: 'both'` — start of line `d` inset along the start normal; both heads at true sockets. - Covers R4: `head: 'none'` — line `d` endpoints equal the socket points exactly (byte-compare against pre-change expectation). - Covers R5: `route: 'elbow'` with `head: 'end'` — final `L` coordinate pulled back along the final leg's axis; corner coordinates unchanged from the untrimmed elbow. - Covers R7: segments still ordered line-then-head; existing `draw.test.ts` ordering tests pass unchanged. - Label still renders on a labelled arrow (shortened `lineD` doesn't break `renderLabel`). -**Verification:** full `vitest` suite green; visual spot-check in demo page shows shaft meeting head base cleanly. + **Verification:** full `vitest` suite green; visual spot-check in demo page shows shaft meeting head base cleanly. --- @@ -108,6 +110,7 @@ Geometry of the end inset (side socket, curve route): travel arrives along `-end **Out of scope (true non-goals):** rough.js wobble/overshoot tuning; changing arrowhead shape or spread. ### Deferred to Follow-Up Work + - Solid/filled arrowhead type — tracked as issue #60. - Head-aim vs shaft-arrival divergence for center-socket curve arrows (KTD5) — accepted, revisit only if visible in practice. From c1ecb711b82746681ffe7d02337dc6e481fa7395 Mon Sep 17 00:00:00 2001 From: dancj <7661275+dancj@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:03 -0400 Subject: [PATCH 06/14] refactor(review): simplify inset wiring and dedupe test helper Math.min for the clamp scale, hoisted hasStartHead/hasEndHead so the shaft inset and head drawing can't desync, and one shared pathDs test helper. Behavior unchanged; suite stays green. --- src/geometry.ts | 2 +- src/scroll-arrow.ts | 10 ++++++---- test/scroll-arrow.test.ts | 26 ++++++++++++-------------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/geometry.ts b/src/geometry.ts index 921c5f5..f0cbf00 100644 --- a/src/geometry.ts +++ b/src/geometry.ts @@ -416,7 +416,7 @@ export function insetEndpoints( const total = startInset + endInset; if (total <= 0) return ep; const chord = Math.hypot(ep.end.x - ep.start.x, ep.end.y - ep.start.y); - const scale = total > chord / 2 ? chord / 2 / total : 1; + const scale = Math.min(1, chord / 2 / total); const st = startTangent(ep); const et = endTangent(ep); const si = startInset * scale; diff --git a/src/scroll-arrow.ts b/src/scroll-arrow.ts index 876ae07..7a76d20 100644 --- a/src/scroll-arrow.ts +++ b/src/scroll-arrow.ts @@ -255,9 +255,11 @@ export class ScrollArrow { // heads themselves stay anchored at the true socket points below. const head = this.opts.head; const size = this.opts.headSize; + const hasStartHead = head === 'start' || head === 'both'; + const hasEndHead = head === 'end' || head === 'both'; const inset = headBaseInset(size); - const startInset = head === 'start' || head === 'both' ? inset : 0; - const endInset = head === 'end' || head === 'both' ? inset : 0; + const startInset = hasStartHead ? inset : 0; + const endInset = hasEndHead ? inset : 0; let d: string; if (this.opts.route === 'elbow') { @@ -288,14 +290,14 @@ export class ScrollArrow { this.appendDrawable(this.rc.path(d, roughOpts), 'line'); // Arrowheads, anchored at the true socket points. - if (head === 'end' || head === 'both') { + if (hasEndHead) { const dir = endTangent(local); this.appendDrawable( this.rc.path(arrowHeadPath(local.end, dir, size), roughOpts), 'head', ); } - if (head === 'start' || head === 'both') { + if (hasStartHead) { const dir = startTangent(local); this.appendDrawable( this.rc.path(arrowHeadPath(local.start, dir, size), roughOpts), diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts index 013a62a..b5f6202 100644 --- a/test/scroll-arrow.test.ts +++ b/test/scroll-arrow.test.ts @@ -24,6 +24,16 @@ 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[] { + new ScrollArrow(opts); + const ds = [...document.querySelectorAll('svg path')].map( + (p) => p.getAttribute('d') ?? '', + ); + document.querySelectorAll('svg').forEach((s) => s.remove()); + return ds; +} + beforeEach(() => { ioInstances = []; // @ts-expect-error -- minimal stub for jsdom @@ -129,12 +139,8 @@ describe('ScrollArrow avoid routing', () => { .getTotalLength; }); - function lineD(opts: ConstructorParameters[0]): string { - new ScrollArrow(opts); - const d = document.querySelector('svg path')?.getAttribute('d') ?? ''; - document.querySelectorAll('svg').forEach((s) => s.remove()); - return d; - } + const lineD = (opts: ConstructorParameters[0]): string => + pathDs(opts)[0] ?? ''; it('bends the curve when an avoided obstacle blocks it', () => { const start = boxed({ left: 0, top: 0, width: 100, height: 40 }); @@ -189,14 +195,6 @@ describe('ScrollArrow shaft/head junction (#59)', () => { delete proto.getBBox; }); - function pathDs(opts: ConstructorParameters[0]): string[] { - new ScrollArrow(opts); - const ds = [...document.querySelectorAll('svg path')].map( - (p) => p.getAttribute('d') ?? '', - ); - document.querySelectorAll('svg').forEach((s) => s.remove()); - return ds; - } const anchors = () => ({ start: boxed({ left: 0, top: 0, width: 100, height: 40 }), end: boxed({ left: 300, top: 0, width: 100, height: 40 }), 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 07/14] 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 08/14] 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 09/14] 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 10/14] 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 11/14] 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 12/14] 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 13/14] 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 14/14] 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);