@aznabee/freehand-ui 0.1.2 → 0.1.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/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
- ```
3
+ # Freehand-ui
12
4
 
13
- ## Basic usage
14
-
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
31
+ ---
50
32
 
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.
33
+ ## Vanilla JavaScript
56
34
 
57
- Override it when you need different layout any extra props go to the DOM node:
35
+ Import from the main entry point and call `doodle()` on any element or CSS selector.
58
36
 
59
- ```jsx
60
- <Doodle as="div" style={{ display: "block" }} className="w-full" note="hi">
61
- <Card />
62
- </Doodle>
63
- ```
64
-
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.
68
-
69
- ### Hook
37
+ ### Quick start
70
38
 
71
- For an element you already hold a ref to, skip the wrapper:
72
-
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
- ```
140
-
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
86
+ ### Broken outlines
147
87
 
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,79 +115,94 @@ 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`.
119
+
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.
121
+
122
+ ### Placement behavior
123
+
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.
127
+
128
+ Nudge a note with `offset`:
182
129
 
183
- The position is a preference, not a command. Notes are measured from their real
184
- glyph metrics and laid out so they never sit on top of the element — if the
185
- preferred side would run off screen, the note flips to a side that fits. The
186
- underline is drawn to the measured text width; set `underline: false` for plain
187
- handwriting.
130
+ ```javascript
131
+ doodle(".cta", {
132
+ note: { text: "start chat", position: "top", offset: { x: 40, y: -12 } },
133
+ });
134
+ ```
135
+
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.
188
137
 
189
- ## Arrows
138
+ ### Arrows
190
139
 
191
140
  ```javascript
192
141
  doodle(".cta", { arrow: true });
193
142
 
194
143
  doodle(".cta", {
195
144
  arrow: {
196
- from: "top-right",
145
+ from: "top-right", // note | any position name
197
146
  to: "center",
198
- style: "curved", // curved | straight | dotted
147
+ style: "curved", // curved | straight | dotted | looped
199
148
  },
200
149
  });
201
150
  ```
202
151
 
203
- When the element also has a `note`, the arrow ignores `from` and launches from
204
- the note itself. `to: "edge"` (the default) lands the tip just *outside* the
205
- frame; `to: "center"` is the one setting that deliberately points into the
206
- element.
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:
157
+
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.
161
+
162
+ ```javascript
163
+ doodle(".cta", {
164
+ note: { text: "start chat", position: "top-right" },
165
+ arrow: { from: "bottom-left" }, // leaves the note's bottom-left corner
166
+ });
167
+ ```
168
+
169
+ **`to`:** `"edge"` (default) lands the tip just outside the frame; `"center"` points into the element.
207
170
 
208
- ## Decorations
171
+ ### Decorations
209
172
 
210
173
  ```javascript
211
174
  doodle(".card", { decorations: true });
212
175
 
213
176
  doodle(".card", {
214
177
  decorations: {
215
- count: 2, // border marks, hard-capped at 2
216
- 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
217
180
  types: ["arcs", "twinkle", "heart"],
218
181
  },
219
182
  });
220
183
  ```
221
184
 
222
- A frame stays readable with **at most two marks around it**, so `count` is
223
- capped at 2 and the default composition is deliberately fixed:
185
+ ![Decoration types - corner accents, stars, handwriting marks, and more](docs/images/decorations.png)
224
186
 
225
- - **one corner accent** `arcs` or `emphasis` hugging a corner, aimed outward
226
- - **one corner star** — `twinkle` or `star` — on the corner farthest from it,
227
- and sitting noticeably further off the border than the accent
228
- - **one handwriting accent** — `heart` or `sparkle` — beside the note, not the
229
- component
187
+ A frame stays readable with **at most two marks around it**. Default composition:
230
188
 
231
- Corner marks follow the *rounded* corner rather than the bounding box, so they
232
- hug a pill's cap as closely as a card's corner, and any mark too large for the
233
- gap it landed in is pushed further out. Everything keeps off the element, off
234
- 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.
235
194
 
236
195
  ### Handwriting accents
237
196
 
238
- `heart` and `sparkle` belong to text: they are drawn at the far end of the
239
- note, on the side away from the element. With no `note` on the element they are
240
- 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.
241
198
 
242
199
  ### Small components
243
200
 
244
- Chips and icon buttons have no corner worth pointing at, so they get a mirrored
245
- pair of two-stroke `emphasis` marks — one either side — instead of corner
246
- marks. This is chosen automatically when the element is under 48px on its short
247
- side or under 130px wide; `style: "sides"` forces it at any size and
248
- `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.
249
202
 
250
- The remaining types `star`, `smiley`, `dots`, `stroke`, `steam` are still
251
- 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.
252
204
 
253
- ## Child selector mode
205
+ ### Child selector mode
254
206
 
255
207
  Decorate multiple children from a single parent overlay:
256
208
 
@@ -260,16 +212,113 @@ doodle("#hero", {
260
212
  });
261
213
  ```
262
214
 
263
- ## Lifecycle
215
+ ### Lifecycle
264
216
 
265
217
  ```javascript
266
218
  const instance = doodle(".card");
267
219
 
268
- instance.update(); // recalculate geometry and redraw
220
+ instance.update(); // recalculate geometry and redraw
269
221
  instance.destroy(); // remove overlay and disconnect observers
270
222
  ```
271
223
 
272
- 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
+ ---
273
322
 
274
323
  ## Examples
275
324
 
@@ -285,16 +334,22 @@ Then open:
285
334
 
286
335
  - http://localhost:5173/examples/basic/
287
336
  - http://localhost:5173/examples/annotations/
337
+ - http://localhost:5173/examples/arrows/
288
338
  - http://localhost:5173/examples/children/
289
339
  - http://localhost:5173/examples/decorations/
340
+ - http://localhost:5173/examples/react/
341
+
342
+ ---
290
343
 
291
344
  ## Design principles
292
345
 
293
- - **Framework agnostic** plain JavaScript at the core; React is an optional entry point
294
- - **SVG based** hand-drawn paths, not CSS borders
295
- - **Non-invasive** `pointer-events: none`, no layout changes
296
- - **Responsive** `ResizeObserver`, scroll listeners, and `requestAnimationFrame`
297
- - **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
+ ---
298
353
 
299
354
  ## License
300
355