@@ -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);
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 d448d87..c804c6d 100644
--- a/src/draw.ts
+++ b/src/draw.ts
@@ -38,39 +38,73 @@ 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;
}
-/**
- * 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) {
+ 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 };
}
-export function dashOffsets(segs: DrawSegment[], eased: number): number[] {
- const { lineLen, headLen } = lengths(segs);
+/**
+ * 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, 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);
- return segs.map((seg) => {
- if (seg.kind === 'line') return seg.len * (1 - lp);
+ // 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) => {
+ 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;
});
}
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/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..8a95741 100644
--- a/src/scroll-arrow.ts
+++ b/src/scroll-arrow.ts
@@ -28,10 +28,11 @@ import {
import { getOverlay, overlayOrigin, createGroup, createSvgEl } from './overlay';
import { mapRoughness, deriveSeed } from './roughness';
import {
- dashOffsets,
+ segmentFractions,
lineProgress,
labelOpacity,
resolveLabelAt,
+ type DrawSegment,
} from './draw';
interface ResolvedRefs {
@@ -67,11 +68,9 @@ 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';
- }[] = [];
+ 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. */
private lineEl: SVGPathElement | null = null;
/**
@@ -289,18 +288,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',
);
}
@@ -311,8 +316,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;
@@ -386,14 +396,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 });
});
}
@@ -404,9 +419,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/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/draw.test.ts b/test/draw.test.ts
index a9f9af1..abb3e1b 100644
--- a/test/draw.test.ts
+++ b/test/draw.test.ts
@@ -1,12 +1,16 @@
import { describe, it, expect } from 'vitest';
import {
- dashOffsets,
+ segmentFractions,
lineProgress,
labelOpacity,
resolveLabelAt,
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' },
@@ -67,6 +71,61 @@ 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);
+ });
+});
+
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/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', () => {
diff --git a/test/scroll-arrow.test.ts b/test/scroll-arrow.test.ts
index b5f6202..c8dbcad 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];
@@ -242,6 +252,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];
@@ -256,3 +296,115 @@ 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;
+ });
+
+ 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('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("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({
+ ...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');
+ });
+});