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

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 = [];
@@ -35876,12 +35884,16 @@ var FRAGMENT = `
35876
35884
  return max(lineAlpha2.x, lineAlpha2.y);
35877
35885
  }
35878
35886
 
35879
- // Distance from p's nearest grid intersection, in world units. Dot radius
35880
- // matches the configured line width in pixels, same anti-aliasing.
35887
+ // Distance from p's nearest grid intersection, in world units. A line's
35888
+ // width reads as visible even at 1px because it spans the whole screen;
35889
+ // an isolated dot at that same 1px radius gets almost entirely eaten by
35890
+ // anti-aliasing and all but disappears. Scaling the radius up keeps a
35891
+ // dot's on-screen weight comparable to a line drawn with the same
35892
+ // uGridLineWidthPx, rather than using it literally as a radius.
35881
35893
  float gridDotAlpha(vec2 p) {
35882
35894
  vec2 cell = mod(p, uGridSpacing) - uGridSpacing * 0.5;
35883
35895
  float dist = length(cell);
35884
- float radius = uGridLineWidthPx * uWorldPerPixel;
35896
+ float radius = uGridLineWidthPx * uWorldPerPixel * 1.75;
35885
35897
  float aa = uWorldPerPixel;
35886
35898
  return 1.0 - smoothstep(radius - aa * 0.5, radius + aa * 0.5, dist);
35887
35899
  }
@@ -36308,8 +36320,8 @@ var INK_Z = .02;
36308
36320
  var HIGHLIGHT_Z = .016;
36309
36321
  /** Ghosts sit under everything so live strokes always read on top. */
36310
36322
  var GHOST_Z = .012;
36311
- function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36312
- const edges = ribbonEdges(points, baseWidth);
36323
+ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36324
+ const edges = ribbonEdges(points, baseWidth, handDrawn);
36313
36325
  const n = edges.length;
36314
36326
  const positions = new Float32Array(n * 2 * 3);
36315
36327
  const uvs = new Float32Array(n * 2 * 2);
@@ -36354,6 +36366,8 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36354
36366
  //#endregion
36355
36367
  //#region src/renderer/shapes/strokeRenderer.ts
36356
36368
  var strokeZ = (stroke) => stroke.tool === "highlighter" ? HIGHLIGHT_Z : INK_Z;
36369
+ /** Shapes (rect/ellipse/line/arrow/...) are geometric outlines, not pressure-sensitive ink. */
36370
+ var handDrawn = (stroke) => stroke.tool !== "shape";
36357
36371
  /**
36358
36372
  * Derives ink meshes from the document by subscription — the document never
36359
36373
  * knows the renderer exists. Also hosts session-only ghosts (decision Q7)
@@ -36378,12 +36392,12 @@ var StrokeRenderer = class {
36378
36392
  this.ghostGroup.visible = visible;
36379
36393
  }
36380
36394
  /** Leave a smeared residue of an erased span (never persisted). */
36381
- addGhost(points, color, baseWidth) {
36395
+ addGhost(points, color, baseWidth, handDrawn = true) {
36382
36396
  if (points.length < 2) return;
36383
36397
  const mesh = new Mesh(buildRibbonGeometry(points.map((p) => ({
36384
36398
  ...p,
36385
36399
  erase: 0
36386
- })), baseWidth * 1.7, GHOST_Z), this.materials.ghost(color));
36400
+ })), baseWidth * 1.7, GHOST_Z, handDrawn), this.materials.ghost(color));
36387
36401
  this.ghostGroup.add(mesh);
36388
36402
  }
36389
36403
  /**
@@ -36395,7 +36409,7 @@ var StrokeRenderer = class {
36395
36409
  if (this.meshes.has(stroke.id) || this.retiredLive.has(stroke.id) || stroke.points.length < 2) return;
36396
36410
  this.liveUser.set(stroke.id, userId);
36397
36411
  const existing = this.liveMeshes.get(stroke.id);
36398
- const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36412
+ const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36399
36413
  if (existing) {
36400
36414
  existing.geometry.dispose();
36401
36415
  existing.geometry = geometry;
@@ -36455,7 +36469,7 @@ var StrokeRenderer = class {
36455
36469
  const mesh = this.meshes.get(stroke.id);
36456
36470
  if (mesh) {
36457
36471
  mesh.geometry.dispose();
36458
- mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36472
+ mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36459
36473
  }
36460
36474
  }
36461
36475
  for (const stroke of change.transformed) {
@@ -36464,7 +36478,7 @@ var StrokeRenderer = class {
36464
36478
  }
36465
36479
  }
36466
36480
  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));
36481
+ 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
36482
  mesh.matrixAutoUpdate = false;
36469
36483
  syncMatrix(mesh, stroke);
36470
36484
  this.meshes.set(stroke.id, mesh);
@@ -44603,9 +44617,10 @@ function hitStroke(doc, index, p, slop) {
44603
44617
  const scale = m ? avgScale(m) : 1;
44604
44618
  let prev = null;
44605
44619
  let prevWidth = 0;
44620
+ const handDrawn = stroke.tool !== "shape";
44606
44621
  for (const point of stroke.points) {
44607
44622
  const world = m ? apply(m, point) : point;
44608
- const width = stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) * scale;
44623
+ const width = (handDrawn ? stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : stroke.baseWidth) * scale;
44609
44624
  if (prev) {
44610
44625
  const reach = Math.max(width, prevWidth) / 2 + slop;
44611
44626
  if (distToSegmentSq(p, prev, world) <= reach * reach) {
@@ -44739,7 +44754,7 @@ var EraserTool = class {
44739
44754
  ...p,
44740
44755
  ...apply(m, p)
44741
44756
  })) : run.points;
44742
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1));
44757
+ this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44743
44758
  }
44744
44759
  }
44745
44760
  doc.removeStrokes(before.map((s) => s.id));
@@ -44972,10 +44987,7 @@ function rect(a, b, step) {
44972
44987
  a
44973
44988
  ];
44974
44989
  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
- }
44990
+ for (let i = 0; i < corners.length - 1; i++) out.push(...sampleSegment(corners[i], corners[i + 1], step));
44979
44991
  return out;
44980
44992
  }
44981
44993
  function ellipse(a, b, step) {
@@ -45046,10 +45058,7 @@ function polygon(sides, a, b, step, startAngle = -Math.PI / 2) {
45046
45058
  }
45047
45059
  vertices.push(vertices[0]);
45048
45060
  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
- }
45061
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45053
45062
  return out;
45054
45063
  }
45055
45064
  function star(points, a, b, step) {
@@ -45073,10 +45082,7 @@ function star(points, a, b, step) {
45073
45082
  }
45074
45083
  vertices.push(vertices[0]);
45075
45084
  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
- }
45085
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45080
45086
  return out;
45081
45087
  }
45082
45088
  function heart(a, b, step) {
@@ -45153,6 +45159,7 @@ var ShapeTool = class {
45153
45159
  id: crypto.randomUUID(),
45154
45160
  color: this.ctx.inkColor(),
45155
45161
  baseWidth: this.ctx.shapeStrokeWidth(),
45162
+ tool: "shape",
45156
45163
  points
45157
45164
  }));
45158
45165
  this.ctx.clusters.assign(strokes[0]);
@@ -45186,7 +45193,7 @@ var ShapeTool = class {
45186
45193
  this.previews[i].visible = polyline !== void 0;
45187
45194
  if (polyline) {
45188
45195
  this.previews[i].geometry.dispose();
45189
- this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth());
45196
+ this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth(), void 0, false);
45190
45197
  }
45191
45198
  }
45192
45199
  }
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 = [];
@@ -35956,12 +35964,16 @@ var FRAGMENT = `
35956
35964
  return max(lineAlpha2.x, lineAlpha2.y);
35957
35965
  }
35958
35966
 
35959
- // Distance from p's nearest grid intersection, in world units. Dot radius
35960
- // matches the configured line width in pixels, same anti-aliasing.
35967
+ // Distance from p's nearest grid intersection, in world units. A line's
35968
+ // width reads as visible even at 1px because it spans the whole screen;
35969
+ // an isolated dot at that same 1px radius gets almost entirely eaten by
35970
+ // anti-aliasing and all but disappears. Scaling the radius up keeps a
35971
+ // dot's on-screen weight comparable to a line drawn with the same
35972
+ // uGridLineWidthPx, rather than using it literally as a radius.
35961
35973
  float gridDotAlpha(vec2 p) {
35962
35974
  vec2 cell = mod(p, uGridSpacing) - uGridSpacing * 0.5;
35963
35975
  float dist = length(cell);
35964
- float radius = uGridLineWidthPx * uWorldPerPixel;
35976
+ float radius = uGridLineWidthPx * uWorldPerPixel * 1.75;
35965
35977
  float aa = uWorldPerPixel;
35966
35978
  return 1.0 - smoothstep(radius - aa * 0.5, radius + aa * 0.5, dist);
35967
35979
  }
@@ -36388,8 +36400,8 @@ var INK_Z = .02;
36388
36400
  var HIGHLIGHT_Z = .016;
36389
36401
  /** Ghosts sit under everything so live strokes always read on top. */
36390
36402
  var GHOST_Z = .012;
36391
- function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36392
- const edges = ribbonEdges(points, baseWidth);
36403
+ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36404
+ const edges = ribbonEdges(points, baseWidth, handDrawn);
36393
36405
  const n = edges.length;
36394
36406
  const positions = new Float32Array(n * 2 * 3);
36395
36407
  const uvs = new Float32Array(n * 2 * 2);
@@ -36434,6 +36446,8 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36434
36446
  //#endregion
36435
36447
  //#region src/renderer/shapes/strokeRenderer.ts
36436
36448
  var strokeZ = (stroke) => stroke.tool === "highlighter" ? HIGHLIGHT_Z : INK_Z;
36449
+ /** Shapes (rect/ellipse/line/arrow/...) are geometric outlines, not pressure-sensitive ink. */
36450
+ var handDrawn = (stroke) => stroke.tool !== "shape";
36437
36451
  /**
36438
36452
  * Derives ink meshes from the document by subscription — the document never
36439
36453
  * knows the renderer exists. Also hosts session-only ghosts (decision Q7)
@@ -36458,12 +36472,12 @@ var StrokeRenderer = class {
36458
36472
  this.ghostGroup.visible = visible;
36459
36473
  }
36460
36474
  /** Leave a smeared residue of an erased span (never persisted). */
36461
- addGhost(points, color, baseWidth) {
36475
+ addGhost(points, color, baseWidth, handDrawn = true) {
36462
36476
  if (points.length < 2) return;
36463
36477
  const mesh = new Mesh(buildRibbonGeometry(points.map((p) => ({
36464
36478
  ...p,
36465
36479
  erase: 0
36466
- })), baseWidth * 1.7, GHOST_Z), this.materials.ghost(color));
36480
+ })), baseWidth * 1.7, GHOST_Z, handDrawn), this.materials.ghost(color));
36467
36481
  this.ghostGroup.add(mesh);
36468
36482
  }
36469
36483
  /**
@@ -36475,7 +36489,7 @@ var StrokeRenderer = class {
36475
36489
  if (this.meshes.has(stroke.id) || this.retiredLive.has(stroke.id) || stroke.points.length < 2) return;
36476
36490
  this.liveUser.set(stroke.id, userId);
36477
36491
  const existing = this.liveMeshes.get(stroke.id);
36478
- const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36492
+ const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36479
36493
  if (existing) {
36480
36494
  existing.geometry.dispose();
36481
36495
  existing.geometry = geometry;
@@ -36535,7 +36549,7 @@ var StrokeRenderer = class {
36535
36549
  const mesh = this.meshes.get(stroke.id);
36536
36550
  if (mesh) {
36537
36551
  mesh.geometry.dispose();
36538
- mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36552
+ mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36539
36553
  }
36540
36554
  }
36541
36555
  for (const stroke of change.transformed) {
@@ -36544,7 +36558,7 @@ var StrokeRenderer = class {
36544
36558
  }
36545
36559
  }
36546
36560
  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));
36561
+ 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
36562
  mesh.matrixAutoUpdate = false;
36549
36563
  syncMatrix(mesh, stroke);
36550
36564
  this.meshes.set(stroke.id, mesh);
@@ -44683,9 +44697,10 @@ function hitStroke(doc, index, p, slop) {
44683
44697
  const scale = m ? avgScale(m) : 1;
44684
44698
  let prev = null;
44685
44699
  let prevWidth = 0;
44700
+ const handDrawn = stroke.tool !== "shape";
44686
44701
  for (const point of stroke.points) {
44687
44702
  const world = m ? apply(m, point) : point;
44688
- const width = stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) * scale;
44703
+ const width = (handDrawn ? stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : stroke.baseWidth) * scale;
44689
44704
  if (prev) {
44690
44705
  const reach = Math.max(width, prevWidth) / 2 + slop;
44691
44706
  if (distToSegmentSq(p, prev, world) <= reach * reach) {
@@ -44819,7 +44834,7 @@ var EraserTool = class {
44819
44834
  ...p,
44820
44835
  ...apply(m, p)
44821
44836
  })) : run.points;
44822
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1));
44837
+ this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44823
44838
  }
44824
44839
  }
44825
44840
  doc.removeStrokes(before.map((s) => s.id));
@@ -45052,10 +45067,7 @@ function rect(a, b, step) {
45052
45067
  a
45053
45068
  ];
45054
45069
  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
- }
45070
+ for (let i = 0; i < corners.length - 1; i++) out.push(...sampleSegment(corners[i], corners[i + 1], step));
45059
45071
  return out;
45060
45072
  }
45061
45073
  function ellipse(a, b, step) {
@@ -45126,10 +45138,7 @@ function polygon(sides, a, b, step, startAngle = -Math.PI / 2) {
45126
45138
  }
45127
45139
  vertices.push(vertices[0]);
45128
45140
  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
- }
45141
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45133
45142
  return out;
45134
45143
  }
45135
45144
  function star(points, a, b, step) {
@@ -45153,10 +45162,7 @@ function star(points, a, b, step) {
45153
45162
  }
45154
45163
  vertices.push(vertices[0]);
45155
45164
  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
- }
45165
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45160
45166
  return out;
45161
45167
  }
45162
45168
  function heart(a, b, step) {
@@ -45233,6 +45239,7 @@ var ShapeTool = class {
45233
45239
  id: crypto.randomUUID(),
45234
45240
  color: this.ctx.inkColor(),
45235
45241
  baseWidth: this.ctx.shapeStrokeWidth(),
45242
+ tool: "shape",
45236
45243
  points
45237
45244
  }));
45238
45245
  this.ctx.clusters.assign(strokes[0]);
@@ -45266,7 +45273,7 @@ var ShapeTool = class {
45266
45273
  this.previews[i].visible = polyline !== void 0;
45267
45274
  if (polyline) {
45268
45275
  this.previews[i].geometry.dispose();
45269
- this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth());
45276
+ this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth(), void 0, false);
45270
45277
  }
45271
45278
  }
45272
45279
  }
@@ -49427,6 +49434,7 @@ var scrawlThemePresets = Object.freeze({
49427
49434
  text: "#1c1c1a",
49428
49435
  textMuted: "#62625c",
49429
49436
  edge: "#d4d4cc",
49437
+ primary: "#1856a3",
49430
49438
  focus: "#1856a3",
49431
49439
  selection: "#d9e8f7",
49432
49440
  danger: "#a3302b",
@@ -49459,6 +49467,7 @@ var scrawlThemePresets = Object.freeze({
49459
49467
  text: "#f4f4ee",
49460
49468
  textMuted: "#b7b8ae",
49461
49469
  edge: "#484a42",
49470
+ primary: "#9dc8ef",
49462
49471
  focus: "#9dc8ef",
49463
49472
  selection: "#314b63",
49464
49473
  danger: "#ffaaa3",
@@ -49492,6 +49501,7 @@ var variableNames = {
49492
49501
  text: "--scrawl-color-text",
49493
49502
  textMuted: "--scrawl-color-text-muted",
49494
49503
  edge: "--scrawl-color-edge",
49504
+ primary: "--scrawl-color-primary",
49495
49505
  focus: "--scrawl-color-focus",
49496
49506
  selection: "--scrawl-color-selection",
49497
49507
  danger: "--scrawl-color-danger",
@@ -49524,6 +49534,7 @@ var colorTokens = /* @__PURE__ */ new Set([
49524
49534
  "text",
49525
49535
  "textMuted",
49526
49536
  "edge",
49537
+ "primary",
49527
49538
  "focus",
49528
49539
  "selection",
49529
49540
  "danger",
@@ -50165,7 +50176,7 @@ var INKS = [
50165
50176
  ["Forest", "#315d46"],
50166
50177
  ["Oxblood", "#773f3b"]
50167
50178
  ];
50168
- function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots }) {
50179
+ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons }) {
50169
50180
  const [panel, setPanel] = useState(null);
50170
50181
  const [query, setQuery] = useState("");
50171
50182
  const [announcement, setAnnouncement] = useState("Board controls ready");
@@ -50319,7 +50330,10 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50319
50330
  onClick: () => chooseTool(tool),
50320
50331
  title: `${label} (${key})`,
50321
50332
  type: "button",
50322
- children: label
50333
+ children: icons?.[tool] ? /* @__PURE__ */ jsx("span", {
50334
+ "aria-hidden": "true",
50335
+ children: icons[tool]
50336
+ }) : label
50323
50337
  }, tool)), /* @__PURE__ */ jsxs("label", {
50324
50338
  className: "scrawl-board__tool-select",
50325
50339
  children: [/* @__PURE__ */ jsx("span", { children: "More tools" }), /* @__PURE__ */ jsxs("select", {
@@ -50782,7 +50796,7 @@ function ScrawlProvider({ controller, children, preset = "light", theme, portalC
50782
50796
  })
50783
50797
  });
50784
50798
  }
50785
- function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, ...options }) {
50799
+ function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }) {
50786
50800
  const [runtime, setRuntime] = useState(null);
50787
50801
  const runtimeRef = useRef(null);
50788
50802
  const ownedCanvasRef = useRef(null);
@@ -50878,7 +50892,7 @@ function Scrawl({ children, preset, theme, portalContainer, className, style, on
50878
50892
  onThemeDiagnostic,
50879
50893
  className,
50880
50894
  style,
50881
- children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, {})]
50895
+ children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, { icons })]
50882
50896
  });
50883
50897
  }
50884
50898
  function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Scrawl Board" }) {
@@ -50914,10 +50928,11 @@ function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Sc
50914
50928
  style
50915
50929
  });
50916
50930
  }
50917
- function ScrawlDefaultUI({ className, style, regions, slots }) {
50931
+ function ScrawlDefaultUI({ className, style, regions, slots, icons }) {
50918
50932
  return /* @__PURE__ */ jsx(DefaultBoardChrome, {
50919
50933
  className,
50920
50934
  controller: useScrawlController(),
50935
+ icons,
50921
50936
  regions,
50922
50937
  renderPortal: (children) => /* @__PURE__ */ jsx(ScrawlPortal, { children }),
50923
50938
  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 = [];
@@ -35918,12 +35926,16 @@ var FRAGMENT = `
35918
35926
  return max(lineAlpha2.x, lineAlpha2.y);
35919
35927
  }
35920
35928
 
35921
- // Distance from p's nearest grid intersection, in world units. Dot radius
35922
- // matches the configured line width in pixels, same anti-aliasing.
35929
+ // Distance from p's nearest grid intersection, in world units. A line's
35930
+ // width reads as visible even at 1px because it spans the whole screen;
35931
+ // an isolated dot at that same 1px radius gets almost entirely eaten by
35932
+ // anti-aliasing and all but disappears. Scaling the radius up keeps a
35933
+ // dot's on-screen weight comparable to a line drawn with the same
35934
+ // uGridLineWidthPx, rather than using it literally as a radius.
35923
35935
  float gridDotAlpha(vec2 p) {
35924
35936
  vec2 cell = mod(p, uGridSpacing) - uGridSpacing * 0.5;
35925
35937
  float dist = length(cell);
35926
- float radius = uGridLineWidthPx * uWorldPerPixel;
35938
+ float radius = uGridLineWidthPx * uWorldPerPixel * 1.75;
35927
35939
  float aa = uWorldPerPixel;
35928
35940
  return 1.0 - smoothstep(radius - aa * 0.5, radius + aa * 0.5, dist);
35929
35941
  }
@@ -36350,8 +36362,8 @@ var INK_Z = .02;
36350
36362
  var HIGHLIGHT_Z = .016;
36351
36363
  /** Ghosts sit under everything so live strokes always read on top. */
36352
36364
  var GHOST_Z = .012;
36353
- function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36354
- const edges = ribbonEdges(points, baseWidth);
36365
+ function buildRibbonGeometry(points, baseWidth, z = INK_Z, handDrawn = true) {
36366
+ const edges = ribbonEdges(points, baseWidth, handDrawn);
36355
36367
  const n = edges.length;
36356
36368
  const positions = new Float32Array(n * 2 * 3);
36357
36369
  const uvs = new Float32Array(n * 2 * 2);
@@ -36396,6 +36408,8 @@ function buildRibbonGeometry(points, baseWidth, z = INK_Z) {
36396
36408
  //#endregion
36397
36409
  //#region src/renderer/shapes/strokeRenderer.ts
36398
36410
  var strokeZ = (stroke) => stroke.tool === "highlighter" ? HIGHLIGHT_Z : INK_Z;
36411
+ /** Shapes (rect/ellipse/line/arrow/...) are geometric outlines, not pressure-sensitive ink. */
36412
+ var handDrawn = (stroke) => stroke.tool !== "shape";
36399
36413
  /**
36400
36414
  * Derives ink meshes from the document by subscription — the document never
36401
36415
  * knows the renderer exists. Also hosts session-only ghosts (decision Q7)
@@ -36420,12 +36434,12 @@ var StrokeRenderer = class {
36420
36434
  this.ghostGroup.visible = visible;
36421
36435
  }
36422
36436
  /** Leave a smeared residue of an erased span (never persisted). */
36423
- addGhost(points, color, baseWidth) {
36437
+ addGhost(points, color, baseWidth, handDrawn = true) {
36424
36438
  if (points.length < 2) return;
36425
36439
  const mesh = new Mesh(buildRibbonGeometry(points.map((p) => ({
36426
36440
  ...p,
36427
36441
  erase: 0
36428
- })), baseWidth * 1.7, GHOST_Z), this.materials.ghost(color));
36442
+ })), baseWidth * 1.7, GHOST_Z, handDrawn), this.materials.ghost(color));
36429
36443
  this.ghostGroup.add(mesh);
36430
36444
  }
36431
36445
  /**
@@ -36437,7 +36451,7 @@ var StrokeRenderer = class {
36437
36451
  if (this.meshes.has(stroke.id) || this.retiredLive.has(stroke.id) || stroke.points.length < 2) return;
36438
36452
  this.liveUser.set(stroke.id, userId);
36439
36453
  const existing = this.liveMeshes.get(stroke.id);
36440
- const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36454
+ const geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36441
36455
  if (existing) {
36442
36456
  existing.geometry.dispose();
36443
36457
  existing.geometry = geometry;
@@ -36497,7 +36511,7 @@ var StrokeRenderer = class {
36497
36511
  const mesh = this.meshes.get(stroke.id);
36498
36512
  if (mesh) {
36499
36513
  mesh.geometry.dispose();
36500
- mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke));
36514
+ mesh.geometry = buildRibbonGeometry(stroke.points, stroke.baseWidth, strokeZ(stroke), handDrawn(stroke));
36501
36515
  }
36502
36516
  }
36503
36517
  for (const stroke of change.transformed) {
@@ -36506,7 +36520,7 @@ var StrokeRenderer = class {
36506
36520
  }
36507
36521
  }
36508
36522
  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));
36523
+ 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
36524
  mesh.matrixAutoUpdate = false;
36511
36525
  syncMatrix(mesh, stroke);
36512
36526
  this.meshes.set(stroke.id, mesh);
@@ -44645,9 +44659,10 @@ function hitStroke(doc, index, p, slop) {
44645
44659
  const scale = m ? avgScale(m) : 1;
44646
44660
  let prev = null;
44647
44661
  let prevWidth = 0;
44662
+ const handDrawn = stroke.tool !== "shape";
44648
44663
  for (const point of stroke.points) {
44649
44664
  const world = m ? apply(m, point) : point;
44650
- const width = stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) * scale;
44665
+ const width = (handDrawn ? stroke.baseWidth * (MIN_WIDTH_FACTOR + (1 - MIN_WIDTH_FACTOR) * point.pressure) : stroke.baseWidth) * scale;
44651
44666
  if (prev) {
44652
44667
  const reach = Math.max(width, prevWidth) / 2 + slop;
44653
44668
  if (distToSegmentSq(p, prev, world) <= reach * reach) {
@@ -44781,7 +44796,7 @@ var EraserTool = class {
44781
44796
  ...p,
44782
44797
  ...apply(m, p)
44783
44798
  })) : run.points;
44784
- this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1));
44799
+ this.ctx.renderer.addGhost(world, current.color, current.baseWidth * (m ? avgScale(m) : 1), current.tool !== "shape");
44785
44800
  }
44786
44801
  }
44787
44802
  doc.removeStrokes(before.map((s) => s.id));
@@ -45014,10 +45029,7 @@ function rect(a, b, step) {
45014
45029
  a
45015
45030
  ];
45016
45031
  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
- }
45032
+ for (let i = 0; i < corners.length - 1; i++) out.push(...sampleSegment(corners[i], corners[i + 1], step));
45021
45033
  return out;
45022
45034
  }
45023
45035
  function ellipse(a, b, step) {
@@ -45088,10 +45100,7 @@ function polygon(sides, a, b, step, startAngle = -Math.PI / 2) {
45088
45100
  }
45089
45101
  vertices.push(vertices[0]);
45090
45102
  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
- }
45103
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45095
45104
  return out;
45096
45105
  }
45097
45106
  function star(points, a, b, step) {
@@ -45115,10 +45124,7 @@ function star(points, a, b, step) {
45115
45124
  }
45116
45125
  vertices.push(vertices[0]);
45117
45126
  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
- }
45127
+ for (let i = 0; i < vertices.length - 1; i++) out.push(...sampleSegment(vertices[i], vertices[i + 1], step));
45122
45128
  return out;
45123
45129
  }
45124
45130
  function heart(a, b, step) {
@@ -45195,6 +45201,7 @@ var ShapeTool = class {
45195
45201
  id: crypto.randomUUID(),
45196
45202
  color: this.ctx.inkColor(),
45197
45203
  baseWidth: this.ctx.shapeStrokeWidth(),
45204
+ tool: "shape",
45198
45205
  points
45199
45206
  }));
45200
45207
  this.ctx.clusters.assign(strokes[0]);
@@ -45228,7 +45235,7 @@ var ShapeTool = class {
45228
45235
  this.previews[i].visible = polyline !== void 0;
45229
45236
  if (polyline) {
45230
45237
  this.previews[i].geometry.dispose();
45231
- this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth());
45238
+ this.previews[i].geometry = buildRibbonGeometry(polyline, this.ctx.shapeStrokeWidth(), void 0, false);
45232
45239
  }
45233
45240
  }
45234
45241
  }
@@ -49389,6 +49396,7 @@ var scrawlThemePresets = Object.freeze({
49389
49396
  text: "#1c1c1a",
49390
49397
  textMuted: "#62625c",
49391
49398
  edge: "#d4d4cc",
49399
+ primary: "#1856a3",
49392
49400
  focus: "#1856a3",
49393
49401
  selection: "#d9e8f7",
49394
49402
  danger: "#a3302b",
@@ -49421,6 +49429,7 @@ var scrawlThemePresets = Object.freeze({
49421
49429
  text: "#f4f4ee",
49422
49430
  textMuted: "#b7b8ae",
49423
49431
  edge: "#484a42",
49432
+ primary: "#9dc8ef",
49424
49433
  focus: "#9dc8ef",
49425
49434
  selection: "#314b63",
49426
49435
  danger: "#ffaaa3",
@@ -49454,6 +49463,7 @@ var variableNames = {
49454
49463
  text: "--scrawl-color-text",
49455
49464
  textMuted: "--scrawl-color-text-muted",
49456
49465
  edge: "--scrawl-color-edge",
49466
+ primary: "--scrawl-color-primary",
49457
49467
  focus: "--scrawl-color-focus",
49458
49468
  selection: "--scrawl-color-selection",
49459
49469
  danger: "--scrawl-color-danger",
@@ -49486,6 +49496,7 @@ var colorTokens = /* @__PURE__ */ new Set([
49486
49496
  "text",
49487
49497
  "textMuted",
49488
49498
  "edge",
49499
+ "primary",
49489
49500
  "focus",
49490
49501
  "selection",
49491
49502
  "danger",
@@ -50127,7 +50138,7 @@ var INKS = [
50127
50138
  ["Forest", "#315d46"],
50128
50139
  ["Oxblood", "#773f3b"]
50129
50140
  ];
50130
- function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots }) {
50141
+ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, style, regions, slots, icons }) {
50131
50142
  const [panel, setPanel] = useState(null);
50132
50143
  const [query, setQuery] = useState("");
50133
50144
  const [announcement, setAnnouncement] = useState("Board controls ready");
@@ -50281,7 +50292,10 @@ function DefaultBoardChrome({ controller, snapshot, renderPortal, className, sty
50281
50292
  onClick: () => chooseTool(tool),
50282
50293
  title: `${label} (${key})`,
50283
50294
  type: "button",
50284
- children: label
50295
+ children: icons?.[tool] ? /* @__PURE__ */ jsx("span", {
50296
+ "aria-hidden": "true",
50297
+ children: icons[tool]
50298
+ }) : label
50285
50299
  }, tool)), /* @__PURE__ */ jsxs("label", {
50286
50300
  className: "scrawl-board__tool-select",
50287
50301
  children: [/* @__PURE__ */ jsx("span", { children: "More tools" }), /* @__PURE__ */ jsxs("select", {
@@ -50744,7 +50758,7 @@ function ScrawlProvider({ controller, children, preset = "light", theme, portalC
50744
50758
  })
50745
50759
  });
50746
50760
  }
50747
- function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, ...options }) {
50761
+ function Scrawl({ children, preset, theme, portalContainer, className, style, onReady, onError, onThemeDiagnostic, canvas: suppliedCanvas, icons, ...options }) {
50748
50762
  const [runtime, setRuntime] = useState(null);
50749
50763
  const runtimeRef = useRef(null);
50750
50764
  const ownedCanvasRef = useRef(null);
@@ -50840,7 +50854,7 @@ function Scrawl({ children, preset, theme, portalContainer, className, style, on
50840
50854
  onThemeDiagnostic,
50841
50855
  className,
50842
50856
  style,
50843
- children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, {})]
50857
+ children: [runtime.canvas && /* @__PURE__ */ jsx(ScrawlCanvas, { element: runtime.canvas }), children ?? /* @__PURE__ */ jsx(ScrawlDefaultUI, { icons })]
50844
50858
  });
50845
50859
  }
50846
50860
  function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Scrawl Board" }) {
@@ -50876,10 +50890,11 @@ function ScrawlCanvas({ element, className, style, "aria-label": ariaLabel = "Sc
50876
50890
  style
50877
50891
  });
50878
50892
  }
50879
- function ScrawlDefaultUI({ className, style, regions, slots }) {
50893
+ function ScrawlDefaultUI({ className, style, regions, slots, icons }) {
50880
50894
  return /* @__PURE__ */ jsx(DefaultBoardChrome, {
50881
50895
  className,
50882
50896
  controller: useScrawlController(),
50897
+ icons,
50883
50898
  regions,
50884
50899
  renderPortal: (children) => /* @__PURE__ */ jsx(ScrawlPortal, { children }),
50885
50900
  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.4",
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": {