@scrawl-board/board 0.1.0-beta.2 → 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.d.ts CHANGED
@@ -368,8 +368,13 @@ interface StrokePoint extends BoardPoint {
368
368
  */
369
369
  erase?: number;
370
370
  }
371
- /** Which drawing tool made a stroke; undefined means marker (back-compat). */
372
- type StrokeTool = "marker" | "highlighter";
371
+ /**
372
+ * Which drawing tool made a stroke; undefined means marker (back-compat).
373
+ * `"shape"` (rect/ellipse/line/arrow/polygon/star/heart) renders at its
374
+ * exact configured width with no pressure variance or end taper — a
375
+ * geometric outline, not an expressive ink mark.
376
+ */
377
+ type StrokeTool = "marker" | "highlighter" | "shape";
373
378
  interface Stroke extends Lockable {
374
379
  id: string;
375
380
  color: string;
package/dist/browser.js CHANGED
@@ -1739,7 +1739,15 @@ function changeToOps(change, restoring = false) {
1739
1739
  //#region src/core/shapes/ribbonEdges.ts
1740
1740
  var MIN_WIDTH_FACTOR = .35;
1741
1741
  var END_TAPER = .55;
1742
- function ribbonEdges(points, baseWidth) {
1742
+ /**
1743
+ * @param handDrawn Default `true`: ink's organic feel — width follows
1744
+ * per-point pressure, and both ends taper. Pass `false` for a geometric
1745
+ * shape outline, which has no real pressure signal and isn't a pen stroke
1746
+ * with a lift-off: it renders at the exact `baseWidth`, uniformly, with no
1747
+ * taper at its start/end (which, for a closed shape, is the same point —
1748
+ * tapering it would visibly pinch just that one corner).
1749
+ */
1750
+ function ribbonEdges(points, baseWidth, handDrawn = true) {
1743
1751
  const pts = points.length === 1 ? [points[0], {
1744
1752
  ...points[0],
1745
1753
  x: points[0].x + baseWidth * .05
@@ -1754,8 +1762,8 @@ function ribbonEdges(points, baseWidth) {
1754
1762
  const len = Math.hypot(dx, dy) || 1;
1755
1763
  dx /= len;
1756
1764
  dy /= len;
1757
- let width = baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure);
1758
- if (i === 0 || i === n - 1) width *= END_TAPER;
1765
+ let width = handDrawn ? baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure) : baseWidth;
1766
+ if (handDrawn && (i === 0 || i === n - 1)) width *= END_TAPER;
1759
1767
  const hw = width / 2;
1760
1768
  out[i] = {
1761
1769
  lx: pts[i].x - dy * hw,
@@ -2031,7 +2039,7 @@ function strokeToPaths(stroke) {
2031
2039
  y,
2032
2040
  pressure,
2033
2041
  ...erase > 0 ? { erase } : {}
2034
- })), stroke.baseWidth);
2042
+ })), stroke.baseWidth, stroke.tool !== "shape");
2035
2043
  const transform = stroke.matrix ? ` transform="matrix(${fmt(stroke.matrix[0])} ${fmt(-stroke.matrix[1])} ${fmt(-stroke.matrix[2])} ${fmt(stroke.matrix[3])} ${fmt(stroke.matrix[4])} ${fmt(-stroke.matrix[5])})"` : "";
2036
2044
  const baseOpacity = stroke.tool === "highlighter" ? .4 : 1;
2037
2045
  const paths = [];
@@ -36308,8 +36316,8 @@ var INK_Z = .02;
36308
36316
  var HIGHLIGHT_Z = .016;
36309
36317
  /** Ghosts sit under everything so live strokes always read on top. */
36310
36318
  var GHOST_Z = .012;
36311
- function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36312
- const edges = ribbonEdges(points, baseWidth);
36319
+ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36320
+ const edges = ribbonEdges(points, baseWidth, handDrawn);
36313
36321
  const n = edges.length;
36314
36322
  const positions = new Float32Array(n * 2 * 3);
36315
36323
  const uvs = new Float32Array(n * 2 * 2);
@@ -36354,6 +36362,8 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36354
36362
  //#endregion
36355
36363
  //#region src/renderer/shapes/strokeRenderer.ts
36356
36364
  var strokeZ = (stroke) => stroke.tool === "highlighter" ? HIGHLIGHT_Z : INK_Z;
36365
+ /** Shapes (rect/ellipse/line/arrow/...) are geometric outlines, not pressure-sensitive ink. */
36366
+ var handDrawn = (stroke) => stroke.tool !== "shape";
36357
36367
  /**
36358
36368
  * Derives ink meshes from the document by subscription — the document never
36359
36369
  * knows the renderer exists. Also hosts session-only ghosts (decision Q7)
@@ -36378,12 +36388,12 @@ var StrokeRenderer = class {
36378
36388
  this.ghostGroup.visible = visible;
36379
36389
  }
36380
36390
  /** Leave a smeared residue of an erased span (never persisted). */
36381
- addGhost(points, color, baseWidth) {
36391
+ addGhost(points, color, baseWidth, handDrawn = true) {
36382
36392
  if (points.length < 2) return;
36383
36393
  const mesh = new Mesh(buildRibbonGeometry(points.map((p) => ({
36384
36394
  ...p,
36385
36395
  erase: 0
36386
- })), baseWidth * 1.7, GHOST_Z), this.materials.ghost(color));
36396
+ })), baseWidth * 1.7, GHOST_Z, handDrawn), this.materials.ghost(color));
36387
36397
  this.ghostGroup.add(mesh);
36388
36398
  }
36389
36399
  /**
@@ -36395,7 +36405,7 @@ var StrokeRenderer = class {
36395
36405
  if (this.meshes.has(stroke.id) || this.retiredLive.has(stroke.id) || stroke.points.length < 2) return;
36396
36406
  this.liveUser.set(stroke.id, userId);
36397
36407
  const existing = this.liveMeshes.get(stroke.id);
36398
- const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36408
+ const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36399
36409
  if (existing) {
36400
36410
  existing.geometry.dispose();
36401
36411
  existing.geometry = geometry;
@@ -36455,7 +36465,7 @@ var StrokeRenderer = class {
36455
36465
  const mesh = this.meshes.get(stroke.id);
36456
36466
  if (mesh) {
36457
36467
  mesh.geometry.dispose();
36458
- mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36468
+ mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36459
36469
  }
36460
36470
  }
36461
36471
  for (const stroke of change.transformed) {
@@ -36464,7 +36474,7 @@ var StrokeRenderer = class {
36464
36474
  }
36465
36475
  }
36466
36476
  create(stroke) {
36467
- const mesh = new Mesh(buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke)), stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color));
36477
+ const mesh = new Mesh(buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke)), stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color));
36468
36478
  mesh.matrixAutoUpdate = false;
36469
36479
  syncMatrix(mesh, stroke);
36470
36480
  this.meshes.set(stroke.id, mesh);
@@ -44603,9 +44613,10 @@ function hitStroke(doc, index, p, slop) {
44603
44613
  const scale = m ? avgScale(m) : 1;
44604
44614
  let prev = null;
44605
44615
  let prevWidth = 0;
44616
+ const handDrawn = stroke.tool !== "shape";
44606
44617
  for (const point of stroke.points) {
44607
44618
  const world = m ? apply(m, point) : point;
44608
- const width = stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) * scale;
44619
+ const width = (handDrawn ? stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : stroke.baseWidth) * scale;
44609
44620
  if (prev) {
44610
44621
  const reach = Math.max(width, prevWidth) / 2 + slop;
44611
44622
  if (distToSegmentSq(p, prev, world) <= reach * reach) {
@@ -44739,7 +44750,7 @@ var EraserTool = class {
44739
44750
  ...p,
44740
44751
  ...apply(m, p)
44741
44752
  })) : run.points;
44742
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1));
44753
+ this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44743
44754
  }
44744
44755
  }
44745
44756
  doc.removeStrokes(before.map((s) => s.id));
@@ -44972,10 +44983,7 @@ function rect(a, b, step) {
44972
44983
  a
44973
44984
  ];
44974
44985
  const out = [];
44975
- for (let i = 0; i < corners.length - 1; i++) {
44976
- const edge = sampleSegment(corners[i], corners[i + 1], step);
44977
- out.push(...i === 0 ? edge : edge.slice(1));
44978
- }
44986
+ for (let i = 0; i < corners.length - 1; i++) out.push(...sampleSegment(corners[i], corners[i + 1], step));
44979
44987
  return out;
44980
44988
  }
44981
44989
  function ellipse(a, b, step) {
@@ -45046,10 +45054,7 @@ function polygon(sides, a, b, step, startAngle = -Math.PI / 2) {
45046
45054
  }
45047
45055
  vertices.push(vertices[0]);
45048
45056
  const out = [];
45049
- for (let i = 0; i < vertices.length - 1; i++) {
45050
- const edge = sampleSegment(vertices[i], vertices[i + 1], step);
45051
- out.push(...i === 0 ? edge : edge.slice(1));
45052
- }
45057
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45053
45058
  return out;
45054
45059
  }
45055
45060
  function star(points, a, b, step) {
@@ -45073,10 +45078,7 @@ function star(points, a, b, step) {
45073
45078
  }
45074
45079
  vertices.push(vertices[0]);
45075
45080
  const out = [];
45076
- for (let i = 0; i < vertices.length - 1; i++) {
45077
- const edge = sampleSegment(vertices[i], vertices[i + 1], step);
45078
- out.push(...i === 0 ? edge : edge.slice(1));
45079
- }
45081
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45080
45082
  return out;
45081
45083
  }
45082
45084
  function heart(a, b, step) {
@@ -45153,6 +45155,7 @@ var ShapeTool = class {
45153
45155
  id: crypto.randomUUID(),
45154
45156
  color: this.ctx.inkColor(),
45155
45157
  baseWidth: this.ctx.shapeStrokeWidth(),
45158
+ tool: "shape",
45156
45159
  points
45157
45160
  }));
45158
45161
  this.ctx.clusters.assign(strokes[0]);
@@ -45186,7 +45189,7 @@ var ShapeTool = class {
45186
45189
  this.previews[i].visible = polyline !== void 0;
45187
45190
  if (polyline) {
45188
45191
  this.previews[i].geometry.dispose();
45189
- this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth());
45192
+ this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth(), void 0, false);
45190
45193
  }
45191
45194
  }
45192
45195
  }
package/dist/core.d.ts CHANGED
@@ -425,8 +425,13 @@ interface StrokePoint extends BoardPoint {
425
425
  */
426
426
  erase?: number;
427
427
  }
428
- /** Which drawing tool made a stroke; undefined means marker (back-compat). */
429
- type StrokeTool = "marker" | "highlighter";
428
+ /**
429
+ * Which drawing tool made a stroke; undefined means marker (back-compat).
430
+ * `"shape"` (rect/ellipse/line/arrow/polygon/star/heart) renders at its
431
+ * exact configured width with no pressure variance or end taper — a
432
+ * geometric outline, not an expressive ink mark.
433
+ */
434
+ type StrokeTool = "marker" | "highlighter" | "shape";
430
435
  interface Stroke extends Lockable {
431
436
  id: string;
432
437
  color: string;
@@ -1061,7 +1066,15 @@ interface RibbonEdgePoint {
1061
1066
  /** 1 = intact ink, 0 = fully erased (from the erasure channel). */
1062
1067
  alpha: number;
1063
1068
  }
1064
- declare function ribbonEdges(points: StrokePoint[], baseWidth: number): RibbonEdgePoint[];
1069
+ /**
1070
+ * @param handDrawn Default `true`: ink's organic feel — width follows
1071
+ * per-point pressure, and both ends taper. Pass `false` for a geometric
1072
+ * shape outline, which has no real pressure signal and isn't a pen stroke
1073
+ * with a lift-off: it renders at the exact `baseWidth`, uniformly, with no
1074
+ * taper at its start/end (which, for a closed shape, is the same point —
1075
+ * tapering it would visibly pinch just that one corner).
1076
+ */
1077
+ declare function ribbonEdges(points: StrokePoint[], baseWidth: number, handDrawn?: boolean): RibbonEdgePoint[];
1065
1078
 
1066
1079
  declare class SpatialIndex {
1067
1080
  private readonly doc;
package/dist/core.js CHANGED
@@ -1732,7 +1732,15 @@ function changeToOps(change, restoring = false) {
1732
1732
  //#region src/core/shapes/ribbonEdges.ts
1733
1733
  var MIN_WIDTH_FACTOR = .35;
1734
1734
  var END_TAPER = .55;
1735
- function ribbonEdges(points, baseWidth) {
1735
+ /**
1736
+ * @param handDrawn Default `true`: ink's organic feel — width follows
1737
+ * per-point pressure, and both ends taper. Pass `false` for a geometric
1738
+ * shape outline, which has no real pressure signal and isn't a pen stroke
1739
+ * with a lift-off: it renders at the exact `baseWidth`, uniformly, with no
1740
+ * taper at its start/end (which, for a closed shape, is the same point —
1741
+ * tapering it would visibly pinch just that one corner).
1742
+ */
1743
+ function ribbonEdges(points, baseWidth, handDrawn = true) {
1736
1744
  const pts = points.length === 1 ? [points[0], {
1737
1745
  ...points[0],
1738
1746
  x: points[0].x + baseWidth * .05
@@ -1747,8 +1755,8 @@ function ribbonEdges(points, baseWidth) {
1747
1755
  const len = Math.hypot(dx, dy) || 1;
1748
1756
  dx /= len;
1749
1757
  dy /= len;
1750
- let width = baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure);
1751
- if (i === 0 || i === n - 1) width *= END_TAPER;
1758
+ let width = handDrawn ? baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure) : baseWidth;
1759
+ if (handDrawn && (i === 0 || i === n - 1)) width *= END_TAPER;
1752
1760
  const hw = width / 2;
1753
1761
  out[i] = {
1754
1762
  lx: pts[i].x - dy * hw,
@@ -2061,7 +2069,7 @@ function strokeToPaths(stroke) {
2061
2069
  y,
2062
2070
  pressure,
2063
2071
  ...erase > 0 ? { erase } : {}
2064
- })), stroke.baseWidth);
2072
+ })), stroke.baseWidth, stroke.tool !== "shape");
2065
2073
  const transform = stroke.matrix ? ` transform="matrix(${fmt(stroke.matrix[0])} ${fmt(-stroke.matrix[1])} ${fmt(-stroke.matrix[2])} ${fmt(stroke.matrix[3])} ${fmt(stroke.matrix[4])} ${fmt(-stroke.matrix[5])})"` : "";
2066
2074
  const baseOpacity = stroke.tool === "highlighter" ? .4 : 1;
2067
2075
  const paths = [];
package/dist/index.d.ts CHANGED
@@ -428,8 +428,13 @@ interface StrokePoint extends BoardPoint {
428
428
  */
429
429
  erase?: number;
430
430
  }
431
- /** Which drawing tool made a stroke; undefined means marker (back-compat). */
432
- type StrokeTool = "marker" | "highlighter";
431
+ /**
432
+ * Which drawing tool made a stroke; undefined means marker (back-compat).
433
+ * `"shape"` (rect/ellipse/line/arrow/polygon/star/heart) renders at its
434
+ * exact configured width with no pressure variance or end taper — a
435
+ * geometric outline, not an expressive ink mark.
436
+ */
437
+ type StrokeTool = "marker" | "highlighter" | "shape";
433
438
  interface Stroke extends Lockable {
434
439
  id: string;
435
440
  color: string;
@@ -1064,7 +1069,15 @@ interface RibbonEdgePoint {
1064
1069
  /** 1 = intact ink, 0 = fully erased (from the erasure channel). */
1065
1070
  alpha: number;
1066
1071
  }
1067
- declare function ribbonEdges(points: StrokePoint[], baseWidth: number): RibbonEdgePoint[];
1072
+ /**
1073
+ * @param handDrawn Default `true`: ink's organic feel — width follows
1074
+ * per-point pressure, and both ends taper. Pass `false` for a geometric
1075
+ * shape outline, which has no real pressure signal and isn't a pen stroke
1076
+ * with a lift-off: it renders at the exact `baseWidth`, uniformly, with no
1077
+ * taper at its start/end (which, for a closed shape, is the same point —
1078
+ * tapering it would visibly pinch just that one corner).
1079
+ */
1080
+ declare function ribbonEdges(points: StrokePoint[], baseWidth: number, handDrawn?: boolean): RibbonEdgePoint[];
1068
1081
 
1069
1082
  declare class SpatialIndex {
1070
1083
  private readonly doc;
@@ -1690,6 +1703,13 @@ interface ScrawlTheme {
1690
1703
  textMuted?: string;
1691
1704
  /** Borders and dividers between UI chrome elements. */
1692
1705
  edge?: string;
1706
+ /**
1707
+ * Brand accent color. Drives the active/selected state of toolbar
1708
+ * controls (e.g. the active tool button): its icon/text render in this
1709
+ * color, and its background is automatically derived as a light tint of
1710
+ * it (via `color-mix`) — set this one token and both follow.
1711
+ */
1712
+ primary?: string;
1693
1713
  /** Focus ring color. Checked for contrast against `surface`. */
1694
1714
  focus?: string;
1695
1715
  /** Selection highlight color (e.g. selected list items, not board object selection). */
@@ -1762,6 +1782,19 @@ interface DefaultBoardChromeProps {
1762
1782
  style?: React.CSSProperties;
1763
1783
  regions?: Partial<Record<DefaultUIRegion, boolean>>;
1764
1784
  slots?: DefaultUISlots;
1785
+ /**
1786
+ * Swap in your own icon for a tool's primary toolbar button — pass any
1787
+ * icon from any library (`<Pencil />`, `<Icon icon="..." />`, an inline
1788
+ * `<svg>`, whatever). A tool with no entry here keeps its default text
1789
+ * label, so this is opt-in per tool, not all-or-nothing. Aria labeling
1790
+ * and the keyboard-shortcut tooltip stay text-based regardless, so the
1791
+ * board remains fully accessible even with icon-only buttons.
1792
+ *
1793
+ * Only affects the six primary toolbar buttons (select/marker/
1794
+ * highlighter/eraser/note/text) — the "more tools" overflow menu is a
1795
+ * native `<select>`, which can only render plain text `<option>`s.
1796
+ */
1797
+ icons?: Partial<Record<BuiltInTool, ReactNode>>;
1765
1798
  }
1766
1799
  type DefaultUIRegion = "tools" | "history" | "view" | "style" | "search" | "import" | "export" | "inlineEditing" | "styleShelf";
1767
1800
  /** Props for the toolbar/topBar/stylePanel/contextMenu slots. */
@@ -1791,7 +1824,7 @@ interface DefaultUISlots {
1791
1824
  */
1792
1825
  contextMenu?: ComponentType<BoardSlotProps> | null;
1793
1826
  }
1794
- declare function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots }: DefaultBoardChromeProps): react.JSX.Element;
1827
+ declare function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons }: DefaultBoardChromeProps): react.JSX.Element;
1795
1828
 
1796
1829
  interface InlineEditorsProps {
1797
1830
  controller: BoardController;
@@ -1848,8 +1881,14 @@ interface ScrawlProps extends Omit<CreateBoardControllerOptions, "canvas"> {
1848
1881
  onThemeDiagnostic?: (diagnostic: ScrawlThemeDiagnostic) => void;
1849
1882
  /** Provide null for a headless Board, or an existing canvas to control its identity. */
1850
1883
  canvas?: HTMLCanvasElement | null;
1884
+ /**
1885
+ * Per-tool icon override for the default toolbar's primary buttons —
1886
+ * only applies when you don't supply `children` (i.e. you're using the
1887
+ * SDK's default UI). See DefaultBoardChromeProps.icons.
1888
+ */
1889
+ icons?: Partial<Record<BuiltInTool, ReactNode>>;
1851
1890
  }
1852
- declare function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, ...options }: ScrawlProps): react.JSX.Element;
1891
+ declare function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }: ScrawlProps): react.JSX.Element;
1853
1892
  interface ScrawlCanvasProps {
1854
1893
  element?: HTMLCanvasElement;
1855
1894
  className?: string;
@@ -1864,8 +1903,10 @@ interface ScrawlDefaultUIProps {
1864
1903
  regions?: Partial<Record<DefaultUIRegion, boolean>>;
1865
1904
  /** Replace (component) or hide (null) a coarse region; omit for the SDK default. */
1866
1905
  slots?: DefaultUISlots;
1906
+ /** Per-tool icon override for the primary toolbar buttons — see DefaultBoardChromeProps.icons. */
1907
+ icons?: Partial<Record<BuiltInTool, ReactNode>>;
1867
1908
  }
1868
- declare function ScrawlDefaultUI({ className, style, regions, slots }: ScrawlDefaultUIProps): react.JSX.Element;
1909
+ declare function ScrawlDefaultUI({ className, style, regions, slots, icons }: ScrawlDefaultUIProps): react.JSX.Element;
1869
1910
  declare function ScrawlPortal({ children }: {
1870
1911
  children: ReactNode;
1871
1912
  }): react.ReactPortal | null;
package/dist/index.js CHANGED
@@ -1778,7 +1778,15 @@ function changeToOps(change, restoring = false) {
1778
1778
  //#region src/core/shapes/ribbonEdges.ts
1779
1779
  var MIN_WIDTH_FACTOR = .35;
1780
1780
  var END_TAPER = .55;
1781
- function ribbonEdges(points, baseWidth) {
1781
+ /**
1782
+ * @param handDrawn Default `true`: ink's organic feel — width follows
1783
+ * per-point pressure, and both ends taper. Pass `false` for a geometric
1784
+ * shape outline, which has no real pressure signal and isn't a pen stroke
1785
+ * with a lift-off: it renders at the exact `baseWidth`, uniformly, with no
1786
+ * taper at its start/end (which, for a closed shape, is the same point —
1787
+ * tapering it would visibly pinch just that one corner).
1788
+ */
1789
+ function ribbonEdges(points, baseWidth, handDrawn = true) {
1782
1790
  const pts = points.length === 1 ? [points[0], {
1783
1791
  ...points[0],
1784
1792
  x: points[0].x + baseWidth * .05
@@ -1793,8 +1801,8 @@ function ribbonEdges(points, baseWidth) {
1793
1801
  const len = Math.hypot(dx, dy) || 1;
1794
1802
  dx /= len;
1795
1803
  dy /= len;
1796
- let width = baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure);
1797
- if (i === 0 || i === n - 1) width *= END_TAPER;
1804
+ let width = handDrawn ? baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure) : baseWidth;
1805
+ if (handDrawn && (i === 0 || i === n - 1)) width *= END_TAPER;
1798
1806
  const hw = width / 2;
1799
1807
  out[i] = {
1800
1808
  lx: pts[i].x - dy * hw,
@@ -2107,7 +2115,7 @@ function strokeToPaths(stroke) {
2107
2115
  y,
2108
2116
  pressure,
2109
2117
  ...erase > 0 ? { erase } : {}
2110
- })), stroke.baseWidth);
2118
+ })), stroke.baseWidth, stroke.tool !== "shape");
2111
2119
  const transform = stroke.matrix ? ` transform="matrix(${fmt(stroke.matrix[0])} ${fmt(-stroke.matrix[1])} ${fmt(-stroke.matrix[2])} ${fmt(stroke.matrix[3])} ${fmt(stroke.matrix[4])} ${fmt(-stroke.matrix[5])})"` : "";
2112
2120
  const baseOpacity = stroke.tool === "highlighter" ? .4 : 1;
2113
2121
  const paths = [];
@@ -36388,8 +36396,8 @@ var INK_Z = .02;
36388
36396
  var HIGHLIGHT_Z = .016;
36389
36397
  /** Ghosts sit under everything so live strokes always read on top. */
36390
36398
  var GHOST_Z = .012;
36391
- function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36392
- const edges = ribbonEdges(points, baseWidth);
36399
+ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36400
+ const edges = ribbonEdges(points, baseWidth, handDrawn);
36393
36401
  const n = edges.length;
36394
36402
  const positions = new Float32Array(n * 2 * 3);
36395
36403
  const uvs = new Float32Array(n * 2 * 2);
@@ -36434,6 +36442,8 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36434
36442
  //#endregion
36435
36443
  //#region src/renderer/shapes/strokeRenderer.ts
36436
36444
  var strokeZ = (stroke) => stroke.tool === "highlighter" ? HIGHLIGHT_Z : INK_Z;
36445
+ /** Shapes (rect/ellipse/line/arrow/...) are geometric outlines, not pressure-sensitive ink. */
36446
+ var handDrawn = (stroke) => stroke.tool !== "shape";
36437
36447
  /**
36438
36448
  * Derives ink meshes from the document by subscription — the document never
36439
36449
  * knows the renderer exists. Also hosts session-only ghosts (decision Q7)
@@ -36458,12 +36468,12 @@ var StrokeRenderer = class {
36458
36468
  this.ghostGroup.visible = visible;
36459
36469
  }
36460
36470
  /** Leave a smeared residue of an erased span (never persisted). */
36461
- addGhost(points, color, baseWidth) {
36471
+ addGhost(points, color, baseWidth, handDrawn = true) {
36462
36472
  if (points.length < 2) return;
36463
36473
  const mesh = new Mesh(buildRibbonGeometry(points.map((p) => ({
36464
36474
  ...p,
36465
36475
  erase: 0
36466
- })), baseWidth * 1.7, GHOST_Z), this.materials.ghost(color));
36476
+ })), baseWidth * 1.7, GHOST_Z, handDrawn), this.materials.ghost(color));
36467
36477
  this.ghostGroup.add(mesh);
36468
36478
  }
36469
36479
  /**
@@ -36475,7 +36485,7 @@ var StrokeRenderer = class {
36475
36485
  if (this.meshes.has(stroke.id) || this.retiredLive.has(stroke.id) || stroke.points.length < 2) return;
36476
36486
  this.liveUser.set(stroke.id, userId);
36477
36487
  const existing = this.liveMeshes.get(stroke.id);
36478
- const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36488
+ const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36479
36489
  if (existing) {
36480
36490
  existing.geometry.dispose();
36481
36491
  existing.geometry = geometry;
@@ -36535,7 +36545,7 @@ var StrokeRenderer = class {
36535
36545
  const mesh = this.meshes.get(stroke.id);
36536
36546
  if (mesh) {
36537
36547
  mesh.geometry.dispose();
36538
- mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36548
+ mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36539
36549
  }
36540
36550
  }
36541
36551
  for (const stroke of change.transformed) {
@@ -36544,7 +36554,7 @@ var StrokeRenderer = class {
36544
36554
  }
36545
36555
  }
36546
36556
  create(stroke) {
36547
- const mesh = new Mesh(buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke)), stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color));
36557
+ const mesh = new Mesh(buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke)), stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color));
36548
36558
  mesh.matrixAutoUpdate = false;
36549
36559
  syncMatrix(mesh, stroke);
36550
36560
  this.meshes.set(stroke.id, mesh);
@@ -44683,9 +44693,10 @@ function hitStroke(doc, index, p, slop) {
44683
44693
  const scale = m ? avgScale(m) : 1;
44684
44694
  let prev = null;
44685
44695
  let prevWidth = 0;
44696
+ const handDrawn = stroke.tool !== "shape";
44686
44697
  for (const point of stroke.points) {
44687
44698
  const world = m ? apply(m, point) : point;
44688
- const width = stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) * scale;
44699
+ const width = (handDrawn ? stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : stroke.baseWidth) * scale;
44689
44700
  if (prev) {
44690
44701
  const reach = Math.max(width, prevWidth) / 2 + slop;
44691
44702
  if (distToSegmentSq(p, prev, world) <= reach * reach) {
@@ -44819,7 +44830,7 @@ var EraserTool = class {
44819
44830
  ...p,
44820
44831
  ...apply(m, p)
44821
44832
  })) : run.points;
44822
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1));
44833
+ this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44823
44834
  }
44824
44835
  }
44825
44836
  doc.removeStrokes(before.map((s) => s.id));
@@ -45052,10 +45063,7 @@ function rect(a, b, step) {
45052
45063
  a
45053
45064
  ];
45054
45065
  const out = [];
45055
- for (let i = 0; i < corners.length - 1; i++) {
45056
- const edge = sampleSegment(corners[i], corners[i + 1], step);
45057
- out.push(...i === 0 ? edge : edge.slice(1));
45058
- }
45066
+ for (let i = 0; i < corners.length - 1; i++) out.push(...sampleSegment(corners[i], corners[i + 1], step));
45059
45067
  return out;
45060
45068
  }
45061
45069
  function ellipse(a, b, step) {
@@ -45126,10 +45134,7 @@ function polygon(sides, a, b, step, startAngle = -Math.PI / 2) {
45126
45134
  }
45127
45135
  vertices.push(vertices[0]);
45128
45136
  const out = [];
45129
- for (let i = 0; i < vertices.length - 1; i++) {
45130
- const edge = sampleSegment(vertices[i], vertices[i + 1], step);
45131
- out.push(...i === 0 ? edge : edge.slice(1));
45132
- }
45137
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45133
45138
  return out;
45134
45139
  }
45135
45140
  function star(points, a, b, step) {
@@ -45153,10 +45158,7 @@ function star(points, a, b, step) {
45153
45158
  }
45154
45159
  vertices.push(vertices[0]);
45155
45160
  const out = [];
45156
- for (let i = 0; i < vertices.length - 1; i++) {
45157
- const edge = sampleSegment(vertices[i], vertices[i + 1], step);
45158
- out.push(...i === 0 ? edge : edge.slice(1));
45159
- }
45161
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45160
45162
  return out;
45161
45163
  }
45162
45164
  function heart(a, b, step) {
@@ -45233,6 +45235,7 @@ var ShapeTool = class {
45233
45235
  id: crypto.randomUUID(),
45234
45236
  color: this.ctx.inkColor(),
45235
45237
  baseWidth: this.ctx.shapeStrokeWidth(),
45238
+ tool: "shape",
45236
45239
  points
45237
45240
  }));
45238
45241
  this.ctx.clusters.assign(strokes[0]);
@@ -45266,7 +45269,7 @@ var ShapeTool = class {
45266
45269
  this.previews[i].visible = polyline !== void 0;
45267
45270
  if (polyline) {
45268
45271
  this.previews[i].geometry.dispose();
45269
- this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth());
45272
+ this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth(), void 0, false);
45270
45273
  }
45271
45274
  }
45272
45275
  }
@@ -49427,6 +49430,7 @@ var scrawlThemePresets = Object.freeze({
49427
49430
  text: "#1c1c1a",
49428
49431
  textMuted: "#62625c",
49429
49432
  edge: "#d4d4cc",
49433
+ primary: "#1856a3",
49430
49434
  focus: "#1856a3",
49431
49435
  selection: "#d9e8f7",
49432
49436
  danger: "#a3302b",
@@ -49459,6 +49463,7 @@ var scrawlThemePresets = Object.freeze({
49459
49463
  text: "#f4f4ee",
49460
49464
  textMuted: "#b7b8ae",
49461
49465
  edge: "#484a42",
49466
+ primary: "#9dc8ef",
49462
49467
  focus: "#9dc8ef",
49463
49468
  selection: "#314b63",
49464
49469
  danger: "#ffaaa3",
@@ -49492,6 +49497,7 @@ var variableNames = {
49492
49497
  text: "--scrawl-color-text",
49493
49498
  textMuted: "--scrawl-color-text-muted",
49494
49499
  edge: "--scrawl-color-edge",
49500
+ primary: "--scrawl-color-primary",
49495
49501
  focus: "--scrawl-color-focus",
49496
49502
  selection: "--scrawl-color-selection",
49497
49503
  danger: "--scrawl-color-danger",
@@ -49524,6 +49530,7 @@ var colorTokens = /* @__PURE__ */ new Set([
49524
49530
  "text",
49525
49531
  "textMuted",
49526
49532
  "edge",
49533
+ "primary",
49527
49534
  "focus",
49528
49535
  "selection",
49529
49536
  "danger",
@@ -50165,7 +50172,7 @@ var INKS = [
50165
50172
  ["Forest", "#315d46"],
50166
50173
  ["Oxblood", "#773f3b"]
50167
50174
  ];
50168
- function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots }) {
50175
+ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons }) {
50169
50176
  const [panel, setPanel] = useState(null);
50170
50177
  const [query, setQuery] = useState("");
50171
50178
  const [announcement, setAnnouncement] = useState("Board controls ready");
@@ -50319,7 +50326,10 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50319
50326
  onClick: () => chooseTool(tool),
50320
50327
  title: `${label} (${key})`,
50321
50328
  type: "button",
50322
- children: label
50329
+ children: icons?.[tool] ? /* @__PURE__ */ jsx("span", {
50330
+ "aria-hidden": "true",
50331
+ children: icons[tool]
50332
+ }) : label
50323
50333
  }, tool)), /* @__PURE__ */ jsxs("label", {
50324
50334
  className: "scrawl-board__tool-select",
50325
50335
  children: [/* @__PURE__ */ jsx("span", { children: "More tools" }), /* @__PURE__ */ jsxs("select", {
@@ -50782,7 +50792,7 @@ function ScrawlProvider({ controller, children, preset = "light", theme, portalC
50782
50792
  })
50783
50793
  });
50784
50794
  }
50785
- function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, ...options }) {
50795
+ function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }) {
50786
50796
  const [runtime, setRuntime] = useState(null);
50787
50797
  const runtimeRef = useRef(null);
50788
50798
  const ownedCanvasRef = useRef(null);
@@ -50878,7 +50888,7 @@ function Scrawl({ children, preset, theme, portalContainer, className, style, on
50878
50888
  onThemeDiagnostic,
50879
50889
  className,
50880
50890
  style,
50881
- children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, {})]
50891
+ children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, { icons })]
50882
50892
  });
50883
50893
  }
50884
50894
  function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Scrawl Board" }) {
@@ -50914,10 +50924,11 @@ function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Sc
50914
50924
  style
50915
50925
  });
50916
50926
  }
50917
- function ScrawlDefaultUI({ className, style, regions, slots }) {
50927
+ function ScrawlDefaultUI({ className, style, regions, slots, icons }) {
50918
50928
  return /* @__PURE__ */ jsx(DefaultBoardChrome, {
50919
50929
  className,
50920
50930
  controller: useScrawlController(),
50931
+ icons,
50921
50932
  regions,
50922
50933
  renderPortal: (children) => /* @__PURE__ */ jsx(ScrawlPortal, { children }),
50923
50934
  slots,
package/dist/react.d.ts CHANGED
@@ -371,8 +371,13 @@ interface StrokePoint extends BoardPoint {
371
371
  */
372
372
  erase?: number;
373
373
  }
374
- /** Which drawing tool made a stroke; undefined means marker (back-compat). */
375
- type StrokeTool = "marker" | "highlighter";
374
+ /**
375
+ * Which drawing tool made a stroke; undefined means marker (back-compat).
376
+ * `"shape"` (rect/ellipse/line/arrow/polygon/star/heart) renders at its
377
+ * exact configured width with no pressure variance or end taper — a
378
+ * geometric outline, not an expressive ink mark.
379
+ */
380
+ type StrokeTool = "marker" | "highlighter" | "shape";
376
381
  interface Stroke extends Lockable {
377
382
  id: string;
378
383
  color: string;
@@ -1064,6 +1069,13 @@ interface ScrawlTheme {
1064
1069
  textMuted?: string;
1065
1070
  /** Borders and dividers between UI chrome elements. */
1066
1071
  edge?: string;
1072
+ /**
1073
+ * Brand accent color. Drives the active/selected state of toolbar
1074
+ * controls (e.g. the active tool button): its icon/text render in this
1075
+ * color, and its background is automatically derived as a light tint of
1076
+ * it (via `color-mix`) — set this one token and both follow.
1077
+ */
1078
+ primary?: string;
1067
1079
  /** Focus ring color. Checked for contrast against `surface`. */
1068
1080
  focus?: string;
1069
1081
  /** Selection highlight color (e.g. selected list items, not board object selection). */
@@ -1136,6 +1148,19 @@ interface DefaultBoardChromeProps {
1136
1148
  style?: React.CSSProperties;
1137
1149
  regions?: Partial<Record<DefaultUIRegion, boolean>>;
1138
1150
  slots?: DefaultUISlots;
1151
+ /**
1152
+ * Swap in your own icon for a tool's primary toolbar button — pass any
1153
+ * icon from any library (`<Pencil />`, `<Icon icon="..." />`, an inline
1154
+ * `<svg>`, whatever). A tool with no entry here keeps its default text
1155
+ * label, so this is opt-in per tool, not all-or-nothing. Aria labeling
1156
+ * and the keyboard-shortcut tooltip stay text-based regardless, so the
1157
+ * board remains fully accessible even with icon-only buttons.
1158
+ *
1159
+ * Only affects the six primary toolbar buttons (select/marker/
1160
+ * highlighter/eraser/note/text) — the "more tools" overflow menu is a
1161
+ * native `<select>`, which can only render plain text `<option>`s.
1162
+ */
1163
+ icons?: Partial<Record<BuiltInTool, ReactNode>>;
1139
1164
  }
1140
1165
  type DefaultUIRegion = "tools" | "history" | "view" | "style" | "search" | "import" | "export" | "inlineEditing" | "styleShelf";
1141
1166
  /** Props for the toolbar/topBar/stylePanel/contextMenu slots. */
@@ -1165,7 +1190,7 @@ interface DefaultUISlots {
1165
1190
  */
1166
1191
  contextMenu?: ComponentType<BoardSlotProps> | null;
1167
1192
  }
1168
- declare function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots }: DefaultBoardChromeProps): react.JSX.Element;
1193
+ declare function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons }: DefaultBoardChromeProps): react.JSX.Element;
1169
1194
 
1170
1195
  interface InlineEditorsProps {
1171
1196
  controller: BoardController;
@@ -1222,8 +1247,14 @@ interface ScrawlProps extends Omit<CreateBoardControllerOptions, "canvas"> {
1222
1247
  onThemeDiagnostic?: (diagnostic: ScrawlThemeDiagnostic) => void;
1223
1248
  /** Provide null for a headless Board, or an existing canvas to control its identity. */
1224
1249
  canvas?: HTMLCanvasElement | null;
1250
+ /**
1251
+ * Per-tool icon override for the default toolbar's primary buttons —
1252
+ * only applies when you don't supply `children` (i.e. you're using the
1253
+ * SDK's default UI). See DefaultBoardChromeProps.icons.
1254
+ */
1255
+ icons?: Partial<Record<BuiltInTool, ReactNode>>;
1225
1256
  }
1226
- declare function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, ...options }: ScrawlProps): react.JSX.Element;
1257
+ declare function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }: ScrawlProps): react.JSX.Element;
1227
1258
  interface ScrawlCanvasProps {
1228
1259
  element?: HTMLCanvasElement;
1229
1260
  className?: string;
@@ -1238,8 +1269,10 @@ interface ScrawlDefaultUIProps {
1238
1269
  regions?: Partial<Record<DefaultUIRegion, boolean>>;
1239
1270
  /** Replace (component) or hide (null) a coarse region; omit for the SDK default. */
1240
1271
  slots?: DefaultUISlots;
1272
+ /** Per-tool icon override for the primary toolbar buttons — see DefaultBoardChromeProps.icons. */
1273
+ icons?: Partial<Record<BuiltInTool, ReactNode>>;
1241
1274
  }
1242
- declare function ScrawlDefaultUI({ className, style, regions, slots }: ScrawlDefaultUIProps): react.JSX.Element;
1275
+ declare function ScrawlDefaultUI({ className, style, regions, slots, icons }: ScrawlDefaultUIProps): react.JSX.Element;
1243
1276
  declare function ScrawlPortal({ children }: {
1244
1277
  children: ReactNode;
1245
1278
  }): react.ReactPortal | null;
package/dist/react.js CHANGED
@@ -1748,7 +1748,15 @@ function changeToOps(change, restoring = false) {
1748
1748
  //#region src/core/shapes/ribbonEdges.ts
1749
1749
  var MIN_WIDTH_FACTOR = .35;
1750
1750
  var END_TAPER = .55;
1751
- function ribbonEdges(points, baseWidth) {
1751
+ /**
1752
+ * @param handDrawn Default `true`: ink's organic feel — width follows
1753
+ * per-point pressure, and both ends taper. Pass `false` for a geometric
1754
+ * shape outline, which has no real pressure signal and isn't a pen stroke
1755
+ * with a lift-off: it renders at the exact `baseWidth`, uniformly, with no
1756
+ * taper at its start/end (which, for a closed shape, is the same point —
1757
+ * tapering it would visibly pinch just that one corner).
1758
+ */
1759
+ function ribbonEdges(points, baseWidth, handDrawn = true) {
1752
1760
  const pts = points.length === 1 ? [points[0], {
1753
1761
  ...points[0],
1754
1762
  x: points[0].x + baseWidth * .05
@@ -1763,8 +1771,8 @@ function ribbonEdges(points, baseWidth) {
1763
1771
  const len = Math.hypot(dx, dy) || 1;
1764
1772
  dx /= len;
1765
1773
  dy /= len;
1766
- let width = baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure);
1767
- if (i === 0 || i === n - 1) width *= END_TAPER;
1774
+ let width = handDrawn ? baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * pts[i].pressure) : baseWidth;
1775
+ if (handDrawn && (i === 0 || i === n - 1)) width *= END_TAPER;
1768
1776
  const hw = width / 2;
1769
1777
  out[i] = {
1770
1778
  lx: pts[i].x - dy * hw,
@@ -2073,7 +2081,7 @@ function strokeToPaths(stroke) {
2073
2081
  y,
2074
2082
  pressure,
2075
2083
  ...erase > 0 ? { erase } : {}
2076
- })), stroke.baseWidth);
2084
+ })), stroke.baseWidth, stroke.tool !== "shape");
2077
2085
  const transform = stroke.matrix ? ` transform="matrix(${fmt(stroke.matrix[0])} ${fmt(-stroke.matrix[1])} ${fmt(-stroke.matrix[2])} ${fmt(stroke.matrix[3])} ${fmt(stroke.matrix[4])} ${fmt(-stroke.matrix[5])})"` : "";
2078
2086
  const baseOpacity = stroke.tool === "highlighter" ? .4 : 1;
2079
2087
  const paths = [];
@@ -36350,8 +36358,8 @@ var INK_Z = .02;
36350
36358
  var HIGHLIGHT_Z = .016;
36351
36359
  /** Ghosts sit under everything so live strokes always read on top. */
36352
36360
  var GHOST_Z = .012;
36353
- function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36354
- const edges = ribbonEdges(points, baseWidth);
36361
+ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36362
+ const edges = ribbonEdges(points, baseWidth, handDrawn);
36355
36363
  const n = edges.length;
36356
36364
  const positions = new Float32Array(n * 2 * 3);
36357
36365
  const uvs = new Float32Array(n * 2 * 2);
@@ -36396,6 +36404,8 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36396
36404
  //#endregion
36397
36405
  //#region src/renderer/shapes/strokeRenderer.ts
36398
36406
  var strokeZ = (stroke) => stroke.tool === "highlighter" ? HIGHLIGHT_Z : INK_Z;
36407
+ /** Shapes (rect/ellipse/line/arrow/...) are geometric outlines, not pressure-sensitive ink. */
36408
+ var handDrawn = (stroke) => stroke.tool !== "shape";
36399
36409
  /**
36400
36410
  * Derives ink meshes from the document by subscription — the document never
36401
36411
  * knows the renderer exists. Also hosts session-only ghosts (decision Q7)
@@ -36420,12 +36430,12 @@ var StrokeRenderer = class {
36420
36430
  this.ghostGroup.visible = visible;
36421
36431
  }
36422
36432
  /** Leave a smeared residue of an erased span (never persisted). */
36423
- addGhost(points, color, baseWidth) {
36433
+ addGhost(points, color, baseWidth, handDrawn = true) {
36424
36434
  if (points.length < 2) return;
36425
36435
  const mesh = new Mesh(buildRibbonGeometry(points.map((p) => ({
36426
36436
  ...p,
36427
36437
  erase: 0
36428
- })), baseWidth * 1.7, GHOST_Z), this.materials.ghost(color));
36438
+ })), baseWidth * 1.7, GHOST_Z, handDrawn), this.materials.ghost(color));
36429
36439
  this.ghostGroup.add(mesh);
36430
36440
  }
36431
36441
  /**
@@ -36437,7 +36447,7 @@ var StrokeRenderer = class {
36437
36447
  if (this.meshes.has(stroke.id) || this.retiredLive.has(stroke.id) || stroke.points.length < 2) return;
36438
36448
  this.liveUser.set(stroke.id, userId);
36439
36449
  const existing = this.liveMeshes.get(stroke.id);
36440
- const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36450
+ const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36441
36451
  if (existing) {
36442
36452
  existing.geometry.dispose();
36443
36453
  existing.geometry = geometry;
@@ -36497,7 +36507,7 @@ var StrokeRenderer = class {
36497
36507
  const mesh = this.meshes.get(stroke.id);
36498
36508
  if (mesh) {
36499
36509
  mesh.geometry.dispose();
36500
- mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36510
+ mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36501
36511
  }
36502
36512
  }
36503
36513
  for (const stroke of change.transformed) {
@@ -36506,7 +36516,7 @@ var StrokeRenderer = class {
36506
36516
  }
36507
36517
  }
36508
36518
  create(stroke) {
36509
- const mesh = new Mesh(buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke)), stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color));
36519
+ const mesh = new Mesh(buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke)), stroke.tool === "highlighter" ? this.materials.highlight(stroke.color) : this.materials.get(stroke.color));
36510
36520
  mesh.matrixAutoUpdate = false;
36511
36521
  syncMatrix(mesh, stroke);
36512
36522
  this.meshes.set(stroke.id, mesh);
@@ -44645,9 +44655,10 @@ function hitStroke(doc, index, p, slop) {
44645
44655
  const scale = m ? avgScale(m) : 1;
44646
44656
  let prev = null;
44647
44657
  let prevWidth = 0;
44658
+ const handDrawn = stroke.tool !== "shape";
44648
44659
  for (const point of stroke.points) {
44649
44660
  const world = m ? apply(m, point) : point;
44650
- const width = stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) * scale;
44661
+ const width = (handDrawn ? stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : stroke.baseWidth) * scale;
44651
44662
  if (prev) {
44652
44663
  const reach = Math.max(width, prevWidth) / 2 + slop;
44653
44664
  if (distToSegmentSq(p, prev, world) <= reach * reach) {
@@ -44781,7 +44792,7 @@ var EraserTool = class {
44781
44792
  ...p,
44782
44793
  ...apply(m, p)
44783
44794
  })) : run.points;
44784
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1));
44795
+ this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44785
44796
  }
44786
44797
  }
44787
44798
  doc.removeStrokes(before.map((s) => s.id));
@@ -45014,10 +45025,7 @@ function rect(a, b, step) {
45014
45025
  a
45015
45026
  ];
45016
45027
  const out = [];
45017
- for (let i = 0; i < corners.length - 1; i++) {
45018
- const edge = sampleSegment(corners[i], corners[i + 1], step);
45019
- out.push(...i === 0 ? edge : edge.slice(1));
45020
- }
45028
+ for (let i = 0; i < corners.length - 1; i++) out.push(...sampleSegment(corners[i], corners[i + 1], step));
45021
45029
  return out;
45022
45030
  }
45023
45031
  function ellipse(a, b, step) {
@@ -45088,10 +45096,7 @@ function polygon(sides, a, b, step, startAngle = -Math.PI / 2) {
45088
45096
  }
45089
45097
  vertices.push(vertices[0]);
45090
45098
  const out = [];
45091
- for (let i = 0; i < vertices.length - 1; i++) {
45092
- const edge = sampleSegment(vertices[i], vertices[i + 1], step);
45093
- out.push(...i === 0 ? edge : edge.slice(1));
45094
- }
45099
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45095
45100
  return out;
45096
45101
  }
45097
45102
  function star(points, a, b, step) {
@@ -45115,10 +45120,7 @@ function star(points, a, b, step) {
45115
45120
  }
45116
45121
  vertices.push(vertices[0]);
45117
45122
  const out = [];
45118
- for (let i = 0; i < vertices.length - 1; i++) {
45119
- const edge = sampleSegment(vertices[i], vertices[i + 1], step);
45120
- out.push(...i === 0 ? edge : edge.slice(1));
45121
- }
45123
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45122
45124
  return out;
45123
45125
  }
45124
45126
  function heart(a, b, step) {
@@ -45195,6 +45197,7 @@ var ShapeTool = class {
45195
45197
  id: crypto.randomUUID(),
45196
45198
  color: this.ctx.inkColor(),
45197
45199
  baseWidth: this.ctx.shapeStrokeWidth(),
45200
+ tool: "shape",
45198
45201
  points
45199
45202
  }));
45200
45203
  this.ctx.clusters.assign(strokes[0]);
@@ -45228,7 +45231,7 @@ var ShapeTool = class {
45228
45231
  this.previews[i].visible = polyline !== void 0;
45229
45232
  if (polyline) {
45230
45233
  this.previews[i].geometry.dispose();
45231
- this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth());
45234
+ this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth(), void 0, false);
45232
45235
  }
45233
45236
  }
45234
45237
  }
@@ -49389,6 +49392,7 @@ var scrawlThemePresets = Object.freeze({
49389
49392
  text: "#1c1c1a",
49390
49393
  textMuted: "#62625c",
49391
49394
  edge: "#d4d4cc",
49395
+ primary: "#1856a3",
49392
49396
  focus: "#1856a3",
49393
49397
  selection: "#d9e8f7",
49394
49398
  danger: "#a3302b",
@@ -49421,6 +49425,7 @@ var scrawlThemePresets = Object.freeze({
49421
49425
  text: "#f4f4ee",
49422
49426
  textMuted: "#b7b8ae",
49423
49427
  edge: "#484a42",
49428
+ primary: "#9dc8ef",
49424
49429
  focus: "#9dc8ef",
49425
49430
  selection: "#314b63",
49426
49431
  danger: "#ffaaa3",
@@ -49454,6 +49459,7 @@ var variableNames = {
49454
49459
  text: "--scrawl-color-text",
49455
49460
  textMuted: "--scrawl-color-text-muted",
49456
49461
  edge: "--scrawl-color-edge",
49462
+ primary: "--scrawl-color-primary",
49457
49463
  focus: "--scrawl-color-focus",
49458
49464
  selection: "--scrawl-color-selection",
49459
49465
  danger: "--scrawl-color-danger",
@@ -49486,6 +49492,7 @@ var colorTokens = /* @__PURE__ */ new Set([
49486
49492
  "text",
49487
49493
  "textMuted",
49488
49494
  "edge",
49495
+ "primary",
49489
49496
  "focus",
49490
49497
  "selection",
49491
49498
  "danger",
@@ -50127,7 +50134,7 @@ var INKS = [
50127
50134
  ["Forest", "#315d46"],
50128
50135
  ["Oxblood", "#773f3b"]
50129
50136
  ];
50130
- function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots }) {
50137
+ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons }) {
50131
50138
  const [panel, setPanel] = useState(null);
50132
50139
  const [query, setQuery] = useState("");
50133
50140
  const [announcement, setAnnouncement] = useState("Board controls ready");
@@ -50281,7 +50288,10 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50281
50288
  onClick: () => chooseTool(tool),
50282
50289
  title: `${label} (${key})`,
50283
50290
  type: "button",
50284
- children: label
50291
+ children: icons?.[tool] ? /* @__PURE__ */ jsx("span", {
50292
+ "aria-hidden": "true",
50293
+ children: icons[tool]
50294
+ }) : label
50285
50295
  }, tool)), /* @__PURE__ */ jsxs("label", {
50286
50296
  className: "scrawl-board__tool-select",
50287
50297
  children: [/* @__PURE__ */ jsx("span", { children: "More tools" }), /* @__PURE__ */ jsxs("select", {
@@ -50744,7 +50754,7 @@ function ScrawlProvider({ controller, children, preset = "light", theme, portalC
50744
50754
  })
50745
50755
  });
50746
50756
  }
50747
- function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, ...options }) {
50757
+ function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }) {
50748
50758
  const [runtime, setRuntime] = useState(null);
50749
50759
  const runtimeRef = useRef(null);
50750
50760
  const ownedCanvasRef = useRef(null);
@@ -50840,7 +50850,7 @@ function Scrawl({ children, preset, theme, portalContainer, className, style, on
50840
50850
  onThemeDiagnostic,
50841
50851
  className,
50842
50852
  style,
50843
- children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, {})]
50853
+ children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, { icons })]
50844
50854
  });
50845
50855
  }
50846
50856
  function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Scrawl Board" }) {
@@ -50876,10 +50886,11 @@ function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Sc
50876
50886
  style
50877
50887
  });
50878
50888
  }
50879
- function ScrawlDefaultUI({ className, style, regions, slots }) {
50889
+ function ScrawlDefaultUI({ className, style, regions, slots, icons }) {
50880
50890
  return /* @__PURE__ */ jsx(DefaultBoardChrome, {
50881
50891
  className,
50882
50892
  controller: useScrawlController(),
50893
+ icons,
50883
50894
  regions,
50884
50895
  renderPortal: (children) => /* @__PURE__ */ jsx(ScrawlPortal, { children }),
50885
50896
  slots,
package/dist/styles.css CHANGED
@@ -151,7 +151,8 @@
151
151
 
152
152
  [data-scrawl-root] [data-scrawl-default-ui] button[aria-pressed="true"],
153
153
  [data-scrawl-portal] .scrawl-board__panel button[aria-pressed="true"] {
154
- background: var(--scrawl-color-selection);
154
+ background: color-mix(in srgb, var(--scrawl-color-primary), var(--scrawl-color-surface) 82%);
155
+ color: var(--scrawl-color-primary);
155
156
  font-weight: var(--scrawl-font-weight-strong);
156
157
  }
157
158
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scrawl-board/board",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.3",
4
4
  "description": "The Scrawl Board SDK: an embeddable, collaborative infinite-canvas whiteboard for React and vanilla JS/TS apps.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {