@aznabee/freehand-ui 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -1,96 +1,54 @@
1
- # freehand-ui
1
+ ![freehand-ui - make your UI delightfully human](docs/images/hero.png)
2
2
 
3
- Wrap any existing web element with a beautiful, responsive, hand-drawn doodle layer.
4
-
5
- `freehand-ui` adds a subtle SVG overlay — thin white pen strokes, handwritten notes, arrows, and sparse decorations — without changing your HTML layout or intercepting pointer events.
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install @aznabee/freehand-ui
11
- ```
12
-
13
- ## Basic usage
3
+ # Freehand-ui
14
4
 
15
- ```javascript
16
- import { doodle } from "@aznabee/freehand-ui";
5
+ Wrap any existing web element with a beautiful, responsive, hand-drawn doodle layer.
17
6
 
18
- doodle(document.querySelector(".card"));
19
- // or
20
- doodle(".card");
21
- ```
7
+ ## What it does
22
8
 
23
- This immediately draws a hand-drawn border around the element and keeps it aligned as the page resizes, scrolls, or reflows.
9
+ `freehand-ui` adds a subtle SVG overlay on top of your UI - thin pen strokes, handwritten notes, arrows, and small decorations. It does **not** change your HTML layout or block clicks (`pointer-events: none`).
24
10
 
25
- ## React / Next.js
11
+ **Good for:** call-to-action buttons, feature cards, onboarding hints, playful marketing UI.
26
12
 
27
- ```jsx
28
- import Doodle from "@aznabee/freehand-ui/react";
13
+ | Feature | One-liner |
14
+ | --------------- | ------------------------------------------------------------------- |
15
+ | **Border** | Hand-drawn frame that follows the element's shape and border-radius |
16
+ | **Notes** | Caveat handwriting beside the element |
17
+ | **Arrows** | Curved, straight, dotted, or looped pointers into the element |
18
+ | **Decorations** | Corner accents, stars, hearts, and more |
19
+ | **Responsive** | Stays aligned on resize, scroll, and reflow |
29
20
 
30
- <Doodle note="start chat" strokeWidth={2}>
31
- <Button />
32
- </Doodle>;
33
- ```
21
+ **Live on [Aznabee.com](https://aznabee.com)** - see it in production on the real product.
34
22
 
35
- Every option below works as a prop. The component ships `"use client"`, so it
36
- drops straight into the Next.js App Router without a wrapper of your own, and
37
- it renders on the server without warnings.
23
+ ## ![Basic hand-drawn border around a card](docs/images/basic.png)
38
24
 
39
- The doodle is drawn around **the wrapped component as a whole** — children are
40
- never decorated individually. To decorate descendants instead, opt in with
41
- `childSelector`:
25
+ ## Install
42
26
 
43
- ```jsx
44
- <Doodle childSelector=".card">
45
- {items.map((item) => <Card key={item.id} className="card" {...item} />)}
46
- </Doodle>
27
+ ```bash
28
+ npm install @aznabee/freehand-ui
47
29
  ```
48
30
 
49
- ### Wrapper element
50
-
51
- `<Doodle>` renders a `<span style="display:inline-flex">` that shrink-wraps its
52
- child, so the frame hugs your component instead of stretching to the full width
53
- of its container. `inline-flex` specifically: an `inline-block` wrapper adds
54
- baseline leading beneath an inline-level child like a `<button>`, which would
55
- leave the frame visibly taller than the button.
56
-
57
- Override it when you need different layout — any extra props go to the DOM node:
58
-
59
- ```jsx
60
- <Doodle as="div" style={{ display: "block" }} className="w-full" note="hi">
61
- <Card />
62
- </Doodle>
63
- ```
31
+ ---
64
32
 
65
- Border radius is picked up automatically: if the wrapper has none of its own,
66
- it inherits the radius of the child it hugs, so a wrapped pill button is still
67
- drawn as a pill.
33
+ ## Vanilla JavaScript
68
34
 
69
- ### Hook
35
+ Import from the main entry point and call `doodle()` on any element or CSS selector.
70
36
 
71
- For an element you already hold a ref to, skip the wrapper:
37
+ ### Quick start
72
38
 
73
- ```jsx
74
- import { useDoodle } from "@aznabee/freehand-ui/react";
39
+ ```javascript
40
+ import { doodle } from "@aznabee/freehand-ui";
75
41
 
76
- function Card() {
77
- const ref = useRef(null);
78
- useDoodle(ref, { note: "new", decorations: true });
79
- return <div ref={ref}>…</div>;
80
- }
42
+ doodle(document.querySelector(".card"));
43
+ // or
44
+ doodle(".card");
81
45
  ```
82
46
 
83
- ### Notes
47
+ This draws a hand-drawn border around the element and keeps it aligned as the page resizes, scrolls, or reflows.
84
48
 
85
- - Inline object props (`note={{ text: "hi" }}`) are compared by value, so a
86
- re-render does not tear down and redraw the overlay with a new random seed.
87
- - The handwriting font is fetched automatically — see [Handwriting
88
- font](#handwriting-font) to use `next/font` or self-host instead.
89
- - `disabled` skips drawing entirely — useful behind a reduced-motion check or a
90
- feature flag.
91
- - TypeScript definitions ship with the package for both entry points.
49
+ ### Configuration
92
50
 
93
- ## Configuration
51
+ Pass a second argument to `doodle()`:
94
52
 
95
53
  ```javascript
96
54
  doodle(".card", {
@@ -98,75 +56,54 @@ doodle(".card", {
98
56
  color: "#ffffff",
99
57
  strokeWidth: 1.5,
100
58
  roughness: 1.5,
101
- padding: 0, // gap between the element's edge and the frame
102
- radius: 20, // optional override; auto-detected from CSS when omitted
59
+ padding: 0, // gap between the element's edge and the frame
60
+ radius: 20, // optional override; auto-detected from CSS when omitted
103
61
  opacity: 0.9,
104
- addBreaks: false, // lift the pen at random points around the outline
105
- fontFamily: null, // override the handwriting stack
62
+ addBreaks: false, // lift the pen at random points around the outline
63
+ fontFamily: null, // override the handwriting stack
106
64
  autoLoadFont: true,
107
65
  });
108
66
  ```
109
67
 
110
- The frame traces the element's own edge by default. Raise `padding` to stand it
111
- off — `padding: 8` leaves a comfortable margin around a card.
68
+ The frame traces the element's own edge by default. Raise `padding` to stand it off - `padding: 8` leaves a comfortable margin around a card.
112
69
 
113
- ## Handwriting font
70
+ ### Handwriting font
114
71
 
115
- Notes are set in [Caveat](https://fonts.google.com/specimen/Caveat). The library
116
- loads it for you the first time a note is drawn, so the handwriting looks right
117
- in a plain app with no font setup of its own — without it the text falls back to
118
- the generic `cursive` family, which on macOS is a calligraphic serif rather than
119
- anything handwritten.
72
+ Notes are set in [Caveat](https://fonts.google.com/specimen/Caveat). The library loads it the first time a note is drawn, so handwriting looks right in a plain app with no font setup. Without it, text falls back to the generic `cursive` family (on macOS that's a calligraphic serif, not handwriting).
120
73
 
121
- It skips the request when the page already provides the family, and only ever
122
- requests it once. To take over:
74
+ It skips the request when the page already provides the family, and only ever requests it once. To take over:
123
75
 
124
76
  ```javascript
125
77
  // self-hosted, or already loaded elsewhere on the page
126
78
  doodle(".card", { note: "hi", autoLoadFont: false });
127
79
 
128
- // your own family — a next/font CSS variable, for instance
80
+ // your own family
129
81
  doodle(".card", { note: "hi", fontFamily: "var(--font-caveat)" });
130
82
  ```
131
83
 
132
- ```jsx
133
- import { Caveat } from "next/font/google";
134
- const caveat = Caveat({ subsets: ["latin"], variable: "--font-caveat" });
84
+ Setting `autoLoadFont: false` without a `fontFamily` falls back to system handwriting faces (Bradley Hand, Segoe Script, Comic Sans MS). Notes are re-measured when a font finishes loading, so the underline always matches the final glyph widths.
135
85
 
136
- <Doodle note="start chat" fontFamily="var(--font-caveat)" autoLoadFont={false}>
137
- <Button />
138
- </Doodle>;
139
- ```
86
+ ### Broken outlines
140
87
 
141
- Setting `autoLoadFont: false` without a `fontFamily` falls back to whatever
142
- handwriting faces the system has (Bradley Hand, Segoe Script, Comic Sans MS).
143
- Notes are re-measured when a font finishes loading, so the underline always
144
- matches the final glyph widths.
145
-
146
- ## Broken outlines
147
-
148
- `addBreaks` cuts randomly sized gaps into the border, so it reads as a few
149
- confident dashes rather than one closed loop:
88
+ `addBreaks` cuts randomly sized gaps into the border so it reads as a few confident dashes rather than one closed loop:
150
89
 
151
90
  ```javascript
152
91
  doodle(".cta", { addBreaks: true });
153
92
 
154
- doodle(".cta", { addBreaks: 5 }); // five gaps
93
+ doodle(".cta", { addBreaks: 5 }); // five gaps
155
94
 
156
95
  doodle(".cta", {
157
96
  addBreaks: {
158
- count: 4, // omit for 2–5 gaps
159
- min: 6, // shortest gap, px
160
- max: 30, // longest gap, px
97
+ count: 4, // omit for 2–5 gaps
98
+ min: 6, // shortest gap, px
99
+ max: 30, // longest gap, px
161
100
  },
162
101
  });
163
102
  ```
164
103
 
165
- Gaps are spread one per section of the perimeter so they never clump, and their
166
- total is capped at a third of the outline so the frame still reads as a frame.
167
- On small elements the gaps shrink to fit.
104
+ Gaps are spread one per section of the perimeter so they never clump, and their total is capped at a third of the outline so the frame still reads as a frame. On small elements the gaps shrink to fit.
168
105
 
169
- ## Handwritten annotations
106
+ ### Handwritten annotations
170
107
 
171
108
  ```javascript
172
109
  doodle(".cta", {
@@ -178,25 +115,17 @@ doodle(".cta", {
178
115
  });
179
116
  ```
180
117
 
181
- Positions: `top`, `top-right`, `right`, `bottom-right`, `bottom`, `bottom-left`, `left`, `top-left`. Same names apply to `arrow.from`/`arrow.to`.
118
+ **Positions:** `top`, `top-right`, `right`, `bottom-right`, `bottom`, `bottom-left`, `left`, `top-left`. Same names apply to `arrow.from` / `arrow.to`.
182
119
 
183
- Notes are measured from their real glyph metrics and laid out so they never sit
184
- on top of the element. The underline is drawn to the measured text width; set
185
- `underline: false` for plain handwriting.
120
+ Notes are measured from real glyph metrics and laid out so they never sit on top of the element. The underline is drawn to the measured text width; set `underline: false` for plain handwriting.
186
121
 
187
- **The side you ask for is the side you get.** When a note runs past the edge of
188
- the viewport it slides horizontally by exactly the amount it overhangs, so a
189
- narrowing viewport walks it gradually inward instead of snapping it to the
190
- opposite side. The only limit is the element itself: a note level with it stops
191
- once they meet, rather than sliding across it.
122
+ ### Placement behavior
192
123
 
193
- Vertical placement is fixed relative to the element and does not react to
194
- scrolling, so a note stays pinned to what it annotates instead of crawling back
195
- into view as the page moves. A note beside an element that is itself jammed
196
- against the viewport edge has nowhere to slide, so it may sit partly off screen
197
- — use a different `position` or an `offset` there.
124
+ - **The side you ask for is the side you get.** When a note runs past the viewport edge it slides horizontally by exactly the amount it overhangs, so a narrowing viewport walks it gradually inward instead of snapping to the opposite side.
125
+ - **Vertical placement is fixed** relative to the element and does not react to scrolling - a note stays pinned to what it annotates.
126
+ - If an element is jammed against the viewport edge, a note may sit partly off screen - use a different `position` or an `offset` there.
198
127
 
199
- Nudge a note off its computed spot with `offset`:
128
+ Nudge a note with `offset`:
200
129
 
201
130
  ```javascript
202
131
  doodle(".cta", {
@@ -204,12 +133,9 @@ doodle(".cta", {
204
133
  });
205
134
  ```
206
135
 
207
- The offset moves the note from the spot `position` chose, and staying in view is
208
- judged from there — so a large offset slides back into the viewport rather than
209
- carrying the note off screen. Vertical offsets are always applied verbatim,
210
- since vertical placement never reacts to the viewport.
136
+ The offset moves the note from the spot `position` chose. A large horizontal offset slides back into the viewport rather than carrying the note off screen.
211
137
 
212
- ## Arrows
138
+ ### Arrows
213
139
 
214
140
  ```javascript
215
141
  doodle(".cta", { arrow: true });
@@ -218,17 +144,20 @@ doodle(".cta", {
218
144
  arrow: {
219
145
  from: "top-right", // note | any position name
220
146
  to: "center",
221
- style: "curved", // curved | straight | dotted | looped
147
+ style: "curved", // curved | straight | dotted | looped
222
148
  },
223
149
  });
224
150
  ```
225
151
 
226
- `looped` ties a curl into the middle of the sweep — the hand-drawn flourish that
227
- doubles back on itself before reaching the tip.
152
+ ![Arrow styles - curved, straight, dotted, and looped](docs/images/arrows.png)
153
+
154
+ **Styles:** `curved` (default), `straight`, `dotted`, `looped` - `looped` adds a flourish that doubles back before reaching the tip.
155
+
156
+ **`from`** decides where the arrow leaves:
228
157
 
229
- `from` decides where the arrow leaves. **With a note it names a side of the
230
- note** where the pen lifts off the handwriting and the arrow then travels to
231
- the element wherever that is:
158
+ - **With a note** - names a side of the note (where the pen lifts off the handwriting), then travels to the element.
159
+ - **`"note"`** (default) - picks the side of the note facing the element.
160
+ - **Without a note** - `from` names a side of the element itself.
232
161
 
233
162
  ```javascript
234
163
  doodle(".cta", {
@@ -237,62 +166,43 @@ doodle(".cta", {
237
166
  });
238
167
  ```
239
168
 
240
- `"note"` is the default and picks the side of the note facing the element. A
241
- callout always departs from its note, so moving the note with `offset` takes the
242
- arrow with it rather than leaving it stranded by the element.
169
+ **`to`:** `"edge"` (default) lands the tip just outside the frame; `"center"` points into the element.
243
170
 
244
- Without a note there is no handwriting to leave from, so `from` falls back to
245
- naming a side of the element itself.
246
-
247
- `to: "edge"` (the default) lands the tip just *outside* the frame; `to: "center"`
248
- is the one setting that deliberately points into the element.
249
-
250
- ## Decorations
171
+ ### Decorations
251
172
 
252
173
  ```javascript
253
174
  doodle(".card", { decorations: true });
254
175
 
255
176
  doodle(".card", {
256
177
  decorations: {
257
- count: 2, // border marks, hard-capped at 2
258
- style: "corners", // corners | sides omit to pick automatically
178
+ count: 2, // border marks, hard-capped at 2
179
+ style: "corners", // corners | sides - omit to pick automatically
259
180
  types: ["arcs", "twinkle", "heart"],
260
181
  },
261
182
  });
262
183
  ```
263
184
 
264
- A frame stays readable with **at most two marks around it**, so `count` is
265
- capped at 2 and the default composition is deliberately fixed:
185
+ ![Decoration types - corner accents, stars, handwriting marks, and more](docs/images/decorations.png)
266
186
 
267
- - **one corner accent** `arcs` or `emphasis` hugging a corner, aimed outward
268
- - **one corner star** — `twinkle` or `star` — on the corner farthest from it,
269
- and sitting noticeably further off the border than the accent
270
- - **one handwriting accent** — `heart` or `sparkle` — beside the note, not the
271
- component
187
+ A frame stays readable with **at most two marks around it**. Default composition:
272
188
 
273
- Corner marks follow the *rounded* corner rather than the bounding box, so they
274
- hug a pill's cap as closely as a card's corner, and any mark too large for the
275
- gap it landed in is pushed further out. Everything keeps off the element, off
276
- the note, and off the arrow's sweep.
189
+ - **one corner accent** - `arcs` or `emphasis` - hugging a corner, aimed outward
190
+ - **one corner star** - `twinkle` or `star` - on the corner farthest from it
191
+ - **one handwriting accent** - `heart` or `sparkle` - beside the note, not the component
192
+
193
+ Corner marks follow the _rounded_ corner rather than the bounding box. Marks too large for their gap are pushed further out.
277
194
 
278
195
  ### Handwriting accents
279
196
 
280
- `heart` and `sparkle` belong to text: they are drawn at the far end of the
281
- note, on the side away from the element. With no `note` on the element they are
282
- skipped rather than moved to the border.
197
+ `heart` and `sparkle` are drawn at the far end of the note, on the side away from the element. With no `note` they are skipped rather than moved to the border.
283
198
 
284
199
  ### Small components
285
200
 
286
- Chips and icon buttons have no corner worth pointing at, so they get a mirrored
287
- pair of two-stroke `emphasis` marks — one either side — instead of corner
288
- marks. This is chosen automatically when the element is under 48px on its short
289
- side or under 130px wide; `style: "sides"` forces it at any size and
290
- `style: "corners"` opts out.
201
+ Chips and icon buttons get a mirrored pair of two-stroke `emphasis` marks - one either side - instead of corner marks. This is automatic when the element is under 48px on its short side or under 130px wide. `style: "sides"` forces it at any size; `style: "corners"` opts out.
291
202
 
292
- The remaining types `star`, `smiley`, `dots`, `stroke`, `steam` are still
293
- available and fill a corner slot when no accent or star was requested.
203
+ Other types - `star`, `smiley`, `dots`, `stroke`, `steam` - fill a corner slot when no accent or star was requested.
294
204
 
295
- ## Child selector mode
205
+ ### Child selector mode
296
206
 
297
207
  Decorate multiple children from a single parent overlay:
298
208
 
@@ -302,16 +212,113 @@ doodle("#hero", {
302
212
  });
303
213
  ```
304
214
 
305
- ## Lifecycle
215
+ ### Lifecycle
306
216
 
307
217
  ```javascript
308
218
  const instance = doodle(".card");
309
219
 
310
- instance.update(); // recalculate geometry and redraw
220
+ instance.update(); // recalculate geometry and redraw
311
221
  instance.destroy(); // remove overlay and disconnect observers
312
222
  ```
313
223
 
314
- Calling `doodle()` twice on the same element replaces the previous overlay no duplicates.
224
+ Calling `doodle()` twice on the same element replaces the previous overlay - no duplicates.
225
+
226
+ ---
227
+
228
+ ## React / Next.js
229
+
230
+ Import from `@aznabee/freehand-ui/react`. Every option in the [Vanilla JavaScript](#vanilla-javascript) section works as a prop.
231
+
232
+ ### Component
233
+
234
+ ```jsx
235
+ import Doodle from "@aznabee/freehand-ui/react";
236
+
237
+ <Doodle note="start chat" strokeWidth={2}>
238
+ <Button />
239
+ </Doodle>;
240
+ ```
241
+
242
+ Ships `"use client"` - drops into the Next.js App Router without a wrapper, and renders on the server without warnings.
243
+
244
+ The doodle is drawn around **the wrapped component as a whole** - children are never decorated individually. To decorate descendants instead, opt in with `childSelector`:
245
+
246
+ ```jsx
247
+ <Doodle childSelector=".card">
248
+ {items.map((item) => (
249
+ <Card key={item.id} className="card" {...item} />
250
+ ))}
251
+ </Doodle>
252
+ ```
253
+
254
+ ### Configuration
255
+
256
+ Same options as `doodle()`, passed as props:
257
+
258
+ ```jsx
259
+ <Doodle
260
+ border
261
+ color="#ffffff"
262
+ strokeWidth={1.5}
263
+ roughness={1.5}
264
+ padding={0}
265
+ radius={20}
266
+ opacity={0.9}
267
+ addBreaks={false}
268
+ autoLoadFont
269
+ >
270
+ <Card />
271
+ </Doodle>
272
+ ```
273
+
274
+ ### Wrapper element
275
+
276
+ `<Doodle>` renders a `<span style="display:inline-flex">` that shrink-wraps its child, so the frame hugs your component instead of stretching to the full width of its container. `inline-flex` specifically: an `inline-block` wrapper adds baseline leading beneath an inline-level child like a `<button>`, which would leave the frame visibly taller than the button.
277
+
278
+ Override when you need different layout - extra props go to the DOM node:
279
+
280
+ ```jsx
281
+ <Doodle as="div" style={{ display: "block" }} className="w-full" note="hi">
282
+ <Card />
283
+ </Doodle>
284
+ ```
285
+
286
+ Border radius is picked up automatically: if the wrapper has none of its own, it inherits the radius of the child it hugs, so a wrapped pill button is still drawn as a pill.
287
+
288
+ ### Hook
289
+
290
+ For an element you already hold a ref to, skip the wrapper:
291
+
292
+ ```jsx
293
+ import { useDoodle } from "@aznabee/freehand-ui/react";
294
+
295
+ function Card() {
296
+ const ref = useRef(null);
297
+ useDoodle(ref, { note: "new", decorations: true });
298
+ return <div ref={ref}>…</div>;
299
+ }
300
+ ```
301
+
302
+ ### Handwriting font
303
+
304
+ With `next/font`:
305
+
306
+ ```jsx
307
+ import { Caveat } from "next/font/google";
308
+ const caveat = Caveat({ subsets: ["latin"], variable: "--font-caveat" });
309
+
310
+ <Doodle note="start chat" fontFamily="var(--font-caveat)" autoLoadFont={false}>
311
+ <Button />
312
+ </Doodle>;
313
+ ```
314
+
315
+ ### React notes
316
+
317
+ - Inline object props (`note={{ text: "hi" }}`) are compared by value, so a re-render does not tear down and redraw the overlay with a new random seed.
318
+ - `disabled` skips drawing entirely - useful behind a reduced-motion check or a feature flag.
319
+ - TypeScript definitions ship with the package for both entry points.
320
+
321
+ ---
315
322
 
316
323
  ## Examples
317
324
 
@@ -327,16 +334,22 @@ Then open:
327
334
 
328
335
  - http://localhost:5173/examples/basic/
329
336
  - http://localhost:5173/examples/annotations/
337
+ - http://localhost:5173/examples/arrows/
330
338
  - http://localhost:5173/examples/children/
331
339
  - http://localhost:5173/examples/decorations/
340
+ - http://localhost:5173/examples/react/
341
+
342
+ ---
332
343
 
333
344
  ## Design principles
334
345
 
335
- - **Framework agnostic** plain JavaScript at the core; React is an optional entry point
336
- - **SVG based** hand-drawn paths, not CSS borders
337
- - **Non-invasive** `pointer-events: none`, no layout changes
338
- - **Responsive** `ResizeObserver`, scroll listeners, and `requestAnimationFrame`
339
- - **Lightweight** zero runtime dependencies
346
+ - **Framework agnostic** - plain JavaScript at the core; React is an optional entry point
347
+ - **SVG based** - hand-drawn paths, not CSS borders
348
+ - **Non-invasive** - `pointer-events: none`, no layout changes
349
+ - **Responsive** - `ResizeObserver`, scroll listeners, and `requestAnimationFrame`
350
+ - **Lightweight** - zero runtime dependencies
351
+
352
+ ---
340
353
 
341
354
  ## License
342
355
 
@@ -41,7 +41,12 @@ var DEFAULT_OPTIONS = {
41
41
  /** Override the handwriting stack, e.g. a next/font CSS variable. */
42
42
  fontFamily: null,
43
43
  /** Fetch Caveat when the page has not provided it. */
44
- autoLoadFont: true
44
+ autoLoadFont: true,
45
+ /**
46
+ * Overlay stacking order. Omit to mirror the target element's z-index so
47
+ * doodles stay above the annotated element but below modals and other UI.
48
+ */
49
+ zIndex: null
45
50
  };
46
51
  function resolveElement(target) {
47
52
  if (!target) return null;
@@ -134,6 +139,40 @@ function getScrollableAncestors(element) {
134
139
  ancestors.push(window);
135
140
  return ancestors;
136
141
  }
142
+ function resolveOverlayZIndex(element, override) {
143
+ if (override != null) return override;
144
+ const { zIndex, position } = getComputedStyle(element);
145
+ if (zIndex !== "auto") {
146
+ const parsed = Number.parseInt(zIndex, 10);
147
+ if (Number.isFinite(parsed)) return parsed + 1;
148
+ }
149
+ let node = element.parentElement;
150
+ while (node && node !== document.documentElement) {
151
+ const style = getComputedStyle(node);
152
+ if (style.zIndex !== "auto" && style.position !== "static") {
153
+ const parsed = Number.parseInt(style.zIndex, 10);
154
+ if (Number.isFinite(parsed)) return parsed + 1;
155
+ }
156
+ node = node.parentElement;
157
+ }
158
+ return null;
159
+ }
160
+ function insertOverlaySvg(svg, element) {
161
+ const parent = element.parentNode;
162
+ if (!parent) {
163
+ document.body.appendChild(svg);
164
+ return;
165
+ }
166
+ parent.insertBefore(svg, element.nextSibling);
167
+ }
168
+ function syncOverlayStacking(svg, element, zIndexOverride) {
169
+ const zIndex = resolveOverlayZIndex(element, zIndexOverride);
170
+ if (zIndex == null) {
171
+ svg.style.zIndex = "";
172
+ } else {
173
+ svg.style.zIndex = String(zIndex);
174
+ }
175
+ }
137
176
 
138
177
  // src/geometry.js
139
178
  function averageCornerRadius(element) {
@@ -484,21 +523,14 @@ function isBroken(fraction, breaks) {
484
523
  function createBorderBreaks(perimeter, config, random) {
485
524
  if (!config || perimeter <= 0) return [];
486
525
  const settings = config === true ? {} : config;
487
- const count = Math.max(
488
- 1,
489
- Math.round(settings.count ?? 2 + random() * 3)
490
- );
526
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
491
527
  const min = Math.max(2, settings.min ?? 6);
492
528
  const max = Math.max(min, settings.max ?? 30);
493
529
  const slice = perimeter / count;
494
530
  let budget = perimeter * 0.35;
495
531
  const breaks = [];
496
532
  for (let i = 0; i < count; i++) {
497
- const length = Math.min(
498
- min + random() * (max - min),
499
- slice * 0.7,
500
- budget
501
- );
533
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
502
534
  if (length < 2) break;
503
535
  budget -= length;
504
536
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -966,7 +998,6 @@ function createOverlaySvg(left, top, width, height) {
966
998
  svg.style.height = `${height}px`;
967
999
  svg.style.pointerEvents = "none";
968
1000
  svg.style.overflow = "visible";
969
- svg.style.zIndex = "2147483646";
970
1001
  return svg;
971
1002
  }
972
1003
 
@@ -1496,7 +1527,8 @@ var DoodleOverlay = class {
1496
1527
  }
1497
1528
  mount() {
1498
1529
  this.svg = createOverlaySvg(0, 0, 0, 0);
1499
- document.body.appendChild(this.svg);
1530
+ insertOverlaySvg(this.svg, this.element);
1531
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1500
1532
  this.resizeObserver = new ResizeObserver(this.onResize);
1501
1533
  this.resizeObserver.observe(this.element);
1502
1534
  if (this.options.children) {
@@ -1554,6 +1586,7 @@ var DoodleOverlay = class {
1554
1586
  );
1555
1587
  const overlayRect = unionRects(absoluteRects, margin);
1556
1588
  clearSvg(this.svg);
1589
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1557
1590
  this.svg.setAttribute("width", String(overlayRect.width));
1558
1591
  this.svg.setAttribute("height", String(overlayRect.height));
1559
1592
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
@@ -16,7 +16,12 @@ var DEFAULT_OPTIONS = {
16
16
  /** Override the handwriting stack, e.g. a next/font CSS variable. */
17
17
  fontFamily: null,
18
18
  /** Fetch Caveat when the page has not provided it. */
19
- autoLoadFont: true
19
+ autoLoadFont: true,
20
+ /**
21
+ * Overlay stacking order. Omit to mirror the target element's z-index so
22
+ * doodles stay above the annotated element but below modals and other UI.
23
+ */
24
+ zIndex: null
20
25
  };
21
26
  function resolveElement(target) {
22
27
  if (!target) return null;
@@ -109,6 +114,40 @@ function getScrollableAncestors(element) {
109
114
  ancestors.push(window);
110
115
  return ancestors;
111
116
  }
117
+ function resolveOverlayZIndex(element, override) {
118
+ if (override != null) return override;
119
+ const { zIndex, position } = getComputedStyle(element);
120
+ if (zIndex !== "auto") {
121
+ const parsed = Number.parseInt(zIndex, 10);
122
+ if (Number.isFinite(parsed)) return parsed + 1;
123
+ }
124
+ let node = element.parentElement;
125
+ while (node && node !== document.documentElement) {
126
+ const style = getComputedStyle(node);
127
+ if (style.zIndex !== "auto" && style.position !== "static") {
128
+ const parsed = Number.parseInt(style.zIndex, 10);
129
+ if (Number.isFinite(parsed)) return parsed + 1;
130
+ }
131
+ node = node.parentElement;
132
+ }
133
+ return null;
134
+ }
135
+ function insertOverlaySvg(svg, element) {
136
+ const parent = element.parentNode;
137
+ if (!parent) {
138
+ document.body.appendChild(svg);
139
+ return;
140
+ }
141
+ parent.insertBefore(svg, element.nextSibling);
142
+ }
143
+ function syncOverlayStacking(svg, element, zIndexOverride) {
144
+ const zIndex = resolveOverlayZIndex(element, zIndexOverride);
145
+ if (zIndex == null) {
146
+ svg.style.zIndex = "";
147
+ } else {
148
+ svg.style.zIndex = String(zIndex);
149
+ }
150
+ }
112
151
 
113
152
  // src/geometry.js
114
153
  function averageCornerRadius(element) {
@@ -459,21 +498,14 @@ function isBroken(fraction, breaks) {
459
498
  function createBorderBreaks(perimeter, config, random) {
460
499
  if (!config || perimeter <= 0) return [];
461
500
  const settings = config === true ? {} : config;
462
- const count = Math.max(
463
- 1,
464
- Math.round(settings.count ?? 2 + random() * 3)
465
- );
501
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
466
502
  const min = Math.max(2, settings.min ?? 6);
467
503
  const max = Math.max(min, settings.max ?? 30);
468
504
  const slice = perimeter / count;
469
505
  let budget = perimeter * 0.35;
470
506
  const breaks = [];
471
507
  for (let i = 0; i < count; i++) {
472
- const length = Math.min(
473
- min + random() * (max - min),
474
- slice * 0.7,
475
- budget
476
- );
508
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
477
509
  if (length < 2) break;
478
510
  budget -= length;
479
511
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -941,7 +973,6 @@ function createOverlaySvg(left, top, width, height) {
941
973
  svg.style.height = `${height}px`;
942
974
  svg.style.pointerEvents = "none";
943
975
  svg.style.overflow = "visible";
944
- svg.style.zIndex = "2147483646";
945
976
  return svg;
946
977
  }
947
978
 
@@ -1471,7 +1502,8 @@ var DoodleOverlay = class {
1471
1502
  }
1472
1503
  mount() {
1473
1504
  this.svg = createOverlaySvg(0, 0, 0, 0);
1474
- document.body.appendChild(this.svg);
1505
+ insertOverlaySvg(this.svg, this.element);
1506
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1475
1507
  this.resizeObserver = new ResizeObserver(this.onResize);
1476
1508
  this.resizeObserver.observe(this.element);
1477
1509
  if (this.options.children) {
@@ -1529,6 +1561,7 @@ var DoodleOverlay = class {
1529
1561
  );
1530
1562
  const overlayRect = unionRects(absoluteRects, margin);
1531
1563
  clearSvg(this.svg);
1564
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1532
1565
  this.svg.setAttribute("width", String(overlayRect.width));
1533
1566
  this.svg.setAttribute("height", String(overlayRect.height));
1534
1567
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ export type DecorationType =
24
24
  export interface NoteOptions {
25
25
  text: string;
26
26
  /**
27
- * Which side of the element the note sits on. Always honoured a note that
27
+ * Which side of the element the note sits on. Always honoured - a note that
28
28
  * would run off screen slides horizontally to stay in view rather than
29
29
  * moving to another side.
30
30
  */
@@ -41,7 +41,7 @@ export interface NoteOptions {
41
41
 
42
42
  export interface ArrowOptions {
43
43
  /**
44
- * Where the arrow leaves. With a `note` this names a side of *the note*
44
+ * Where the arrow leaves. With a `note` this names a side of *the note* -
45
45
  * the arrow always departs from the handwriting and travels to the element,
46
46
  * however far an `offset` has moved it. `"note"` (the default) picks the side
47
47
  * facing the element. Without a note it names a side of the element instead.
@@ -88,8 +88,8 @@ export interface DoodleOptions {
88
88
  /** Lift the pen at random points around the outline. */
89
89
  addBreaks?: boolean | number | BreakOptions;
90
90
  /**
91
- * Font stack for handwritten notes. Point this at your own family a
92
- * `next/font` CSS variable, for instance to take over from the built-in one.
91
+ * Font stack for handwritten notes. Point this at your own family - a
92
+ * `next/font` CSS variable, for instance - to take over from the built-in one.
93
93
  */
94
94
  fontFamily?: string | null;
95
95
  /**
@@ -99,6 +99,11 @@ export interface DoodleOptions {
99
99
  * handwriting fallbacks.
100
100
  */
101
101
  autoLoadFont?: boolean;
102
+ /**
103
+ * Overlay stacking order. Omit to follow the target element's z-index so
104
+ * doodles stay above the annotated element but below modals and other UI.
105
+ */
106
+ zIndex?: number | null;
102
107
  }
103
108
 
104
109
  export interface DoodleInstance {
@@ -114,5 +119,5 @@ export interface DoodleInstance {
114
119
  */
115
120
  export function doodle(
116
121
  target: string | Element,
117
- options?: DoodleOptions
122
+ options?: DoodleOptions,
118
123
  ): DoodleInstance;
package/dist/react.cjs CHANGED
@@ -46,7 +46,12 @@ var DEFAULT_OPTIONS = {
46
46
  /** Override the handwriting stack, e.g. a next/font CSS variable. */
47
47
  fontFamily: null,
48
48
  /** Fetch Caveat when the page has not provided it. */
49
- autoLoadFont: true
49
+ autoLoadFont: true,
50
+ /**
51
+ * Overlay stacking order. Omit to mirror the target element's z-index so
52
+ * doodles stay above the annotated element but below modals and other UI.
53
+ */
54
+ zIndex: null
50
55
  };
51
56
  function resolveElement(target) {
52
57
  if (!target) return null;
@@ -139,6 +144,40 @@ function getScrollableAncestors(element) {
139
144
  ancestors.push(window);
140
145
  return ancestors;
141
146
  }
147
+ function resolveOverlayZIndex(element, override) {
148
+ if (override != null) return override;
149
+ const { zIndex, position } = getComputedStyle(element);
150
+ if (zIndex !== "auto") {
151
+ const parsed = Number.parseInt(zIndex, 10);
152
+ if (Number.isFinite(parsed)) return parsed + 1;
153
+ }
154
+ let node = element.parentElement;
155
+ while (node && node !== document.documentElement) {
156
+ const style = getComputedStyle(node);
157
+ if (style.zIndex !== "auto" && style.position !== "static") {
158
+ const parsed = Number.parseInt(style.zIndex, 10);
159
+ if (Number.isFinite(parsed)) return parsed + 1;
160
+ }
161
+ node = node.parentElement;
162
+ }
163
+ return null;
164
+ }
165
+ function insertOverlaySvg(svg, element) {
166
+ const parent = element.parentNode;
167
+ if (!parent) {
168
+ document.body.appendChild(svg);
169
+ return;
170
+ }
171
+ parent.insertBefore(svg, element.nextSibling);
172
+ }
173
+ function syncOverlayStacking(svg, element, zIndexOverride) {
174
+ const zIndex = resolveOverlayZIndex(element, zIndexOverride);
175
+ if (zIndex == null) {
176
+ svg.style.zIndex = "";
177
+ } else {
178
+ svg.style.zIndex = String(zIndex);
179
+ }
180
+ }
142
181
 
143
182
  // src/geometry.js
144
183
  function averageCornerRadius(element) {
@@ -489,21 +528,14 @@ function isBroken(fraction, breaks) {
489
528
  function createBorderBreaks(perimeter, config, random) {
490
529
  if (!config || perimeter <= 0) return [];
491
530
  const settings = config === true ? {} : config;
492
- const count = Math.max(
493
- 1,
494
- Math.round(settings.count ?? 2 + random() * 3)
495
- );
531
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
496
532
  const min = Math.max(2, settings.min ?? 6);
497
533
  const max = Math.max(min, settings.max ?? 30);
498
534
  const slice = perimeter / count;
499
535
  let budget = perimeter * 0.35;
500
536
  const breaks = [];
501
537
  for (let i = 0; i < count; i++) {
502
- const length = Math.min(
503
- min + random() * (max - min),
504
- slice * 0.7,
505
- budget
506
- );
538
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
507
539
  if (length < 2) break;
508
540
  budget -= length;
509
541
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -971,7 +1003,6 @@ function createOverlaySvg(left, top, width, height) {
971
1003
  svg.style.height = `${height}px`;
972
1004
  svg.style.pointerEvents = "none";
973
1005
  svg.style.overflow = "visible";
974
- svg.style.zIndex = "2147483646";
975
1006
  return svg;
976
1007
  }
977
1008
 
@@ -1501,7 +1532,8 @@ var DoodleOverlay = class {
1501
1532
  }
1502
1533
  mount() {
1503
1534
  this.svg = createOverlaySvg(0, 0, 0, 0);
1504
- document.body.appendChild(this.svg);
1535
+ insertOverlaySvg(this.svg, this.element);
1536
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1505
1537
  this.resizeObserver = new ResizeObserver(this.onResize);
1506
1538
  this.resizeObserver.observe(this.element);
1507
1539
  if (this.options.children) {
@@ -1559,6 +1591,7 @@ var DoodleOverlay = class {
1559
1591
  );
1560
1592
  const overlayRect = unionRects(absoluteRects, margin);
1561
1593
  clearSvg(this.svg);
1594
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1562
1595
  this.svg.setAttribute("width", String(overlayRect.width));
1563
1596
  this.svg.setAttribute("height", String(overlayRect.height));
1564
1597
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
@@ -1795,7 +1828,7 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1795
1828
  ref: mergeRefs(innerRef, forwardedRef),
1796
1829
  className,
1797
1830
  // inline-flex shrink-wraps the child so the doodle hugs the component
1798
- // rather than a full-width block. Flex specifically inline-block would
1831
+ // rather than a full-width block. Flex specifically - inline-block would
1799
1832
  // add baseline leading under an inline-level child like a <button>,
1800
1833
  // leaving the frame taller than what it wraps. Override via `style`/`as`.
1801
1834
  style: { display: "inline-flex", ...style }
package/dist/react.d.ts CHANGED
@@ -24,7 +24,7 @@ export interface DoodleOwnProps extends DoodleOptionProps {
24
24
  children?: ReactNode;
25
25
  /** Wrapper element to render. Default `"span"`. */
26
26
  as?: ElementType;
27
- /** Skip drawing entirely useful for reduced-motion or feature flags. */
27
+ /** Skip drawing entirely - useful for reduced-motion or feature flags. */
28
28
  disabled?: boolean;
29
29
  /**
30
30
  * Selector for decorating descendants instead of the wrapper as a whole.
@@ -52,7 +52,7 @@ export type DoodleProps<T extends ElementType = "span"> = DoodleOwnProps &
52
52
  * decorated individually unless you opt in with `childSelector`.
53
53
  */
54
54
  declare const Doodle: <T extends ElementType = "span">(
55
- props: DoodleProps<T>
55
+ props: DoodleProps<T>,
56
56
  ) => JSX.Element;
57
57
 
58
58
  export default Doodle;
@@ -62,5 +62,5 @@ export { Doodle };
62
62
  export function useDoodle(
63
63
  ref: RefObject<Element | null>,
64
64
  options?: DoodleOptions,
65
- enabled?: boolean
65
+ enabled?: boolean,
66
66
  ): void;
package/dist/react.js CHANGED
@@ -29,7 +29,12 @@ var DEFAULT_OPTIONS = {
29
29
  /** Override the handwriting stack, e.g. a next/font CSS variable. */
30
30
  fontFamily: null,
31
31
  /** Fetch Caveat when the page has not provided it. */
32
- autoLoadFont: true
32
+ autoLoadFont: true,
33
+ /**
34
+ * Overlay stacking order. Omit to mirror the target element's z-index so
35
+ * doodles stay above the annotated element but below modals and other UI.
36
+ */
37
+ zIndex: null
33
38
  };
34
39
  function resolveElement(target) {
35
40
  if (!target) return null;
@@ -122,6 +127,40 @@ function getScrollableAncestors(element) {
122
127
  ancestors.push(window);
123
128
  return ancestors;
124
129
  }
130
+ function resolveOverlayZIndex(element, override) {
131
+ if (override != null) return override;
132
+ const { zIndex, position } = getComputedStyle(element);
133
+ if (zIndex !== "auto") {
134
+ const parsed = Number.parseInt(zIndex, 10);
135
+ if (Number.isFinite(parsed)) return parsed + 1;
136
+ }
137
+ let node = element.parentElement;
138
+ while (node && node !== document.documentElement) {
139
+ const style = getComputedStyle(node);
140
+ if (style.zIndex !== "auto" && style.position !== "static") {
141
+ const parsed = Number.parseInt(style.zIndex, 10);
142
+ if (Number.isFinite(parsed)) return parsed + 1;
143
+ }
144
+ node = node.parentElement;
145
+ }
146
+ return null;
147
+ }
148
+ function insertOverlaySvg(svg, element) {
149
+ const parent = element.parentNode;
150
+ if (!parent) {
151
+ document.body.appendChild(svg);
152
+ return;
153
+ }
154
+ parent.insertBefore(svg, element.nextSibling);
155
+ }
156
+ function syncOverlayStacking(svg, element, zIndexOverride) {
157
+ const zIndex = resolveOverlayZIndex(element, zIndexOverride);
158
+ if (zIndex == null) {
159
+ svg.style.zIndex = "";
160
+ } else {
161
+ svg.style.zIndex = String(zIndex);
162
+ }
163
+ }
125
164
 
126
165
  // src/geometry.js
127
166
  function averageCornerRadius(element) {
@@ -472,21 +511,14 @@ function isBroken(fraction, breaks) {
472
511
  function createBorderBreaks(perimeter, config, random) {
473
512
  if (!config || perimeter <= 0) return [];
474
513
  const settings = config === true ? {} : config;
475
- const count = Math.max(
476
- 1,
477
- Math.round(settings.count ?? 2 + random() * 3)
478
- );
514
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
479
515
  const min = Math.max(2, settings.min ?? 6);
480
516
  const max = Math.max(min, settings.max ?? 30);
481
517
  const slice = perimeter / count;
482
518
  let budget = perimeter * 0.35;
483
519
  const breaks = [];
484
520
  for (let i = 0; i < count; i++) {
485
- const length = Math.min(
486
- min + random() * (max - min),
487
- slice * 0.7,
488
- budget
489
- );
521
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
490
522
  if (length < 2) break;
491
523
  budget -= length;
492
524
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -954,7 +986,6 @@ function createOverlaySvg(left, top, width, height) {
954
986
  svg.style.height = `${height}px`;
955
987
  svg.style.pointerEvents = "none";
956
988
  svg.style.overflow = "visible";
957
- svg.style.zIndex = "2147483646";
958
989
  return svg;
959
990
  }
960
991
 
@@ -1484,7 +1515,8 @@ var DoodleOverlay = class {
1484
1515
  }
1485
1516
  mount() {
1486
1517
  this.svg = createOverlaySvg(0, 0, 0, 0);
1487
- document.body.appendChild(this.svg);
1518
+ insertOverlaySvg(this.svg, this.element);
1519
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1488
1520
  this.resizeObserver = new ResizeObserver(this.onResize);
1489
1521
  this.resizeObserver.observe(this.element);
1490
1522
  if (this.options.children) {
@@ -1542,6 +1574,7 @@ var DoodleOverlay = class {
1542
1574
  );
1543
1575
  const overlayRect = unionRects(absoluteRects, margin);
1544
1576
  clearSvg(this.svg);
1577
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1545
1578
  this.svg.setAttribute("width", String(overlayRect.width));
1546
1579
  this.svg.setAttribute("height", String(overlayRect.height));
1547
1580
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
@@ -1778,7 +1811,7 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1778
1811
  ref: mergeRefs(innerRef, forwardedRef),
1779
1812
  className,
1780
1813
  // inline-flex shrink-wraps the child so the doodle hugs the component
1781
- // rather than a full-width block. Flex specifically inline-block would
1814
+ // rather than a full-width block. Flex specifically - inline-block would
1782
1815
  // add baseline leading under an inline-level child like a <button>,
1783
1816
  // leaving the frame taller than what it wraps. Override via `style`/`as`.
1784
1817
  style: { display: "inline-flex", ...style }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aznabee/freehand-ui",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Wrap any existing web element with a beautiful, responsive, hand-drawn doodle layer",
5
5
  "type": "module",
6
6
  "main": "./dist/freehand-ui.cjs",