@profpowell/border-wc 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ProfPowell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # `<border-wc>`
2
+
3
+ [![CI](https://github.com/ProfPowell/border-wc/actions/workflows/ci.yml/badge.svg)](https://github.com/ProfPowell/border-wc/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
+
6
+ **[Live demo →](https://profpowell.github.io/border-wc/)**
7
+
8
+ A light-DOM web component for high-touch border effects — squiggle, draw-on,
9
+ sparks — the kind of decorative borders that pure CSS can't pull off. It pairs
10
+ with vanilla-breeze's CSS-tier `data-border-effect` (spin / pulse / march): use
11
+ the CSS tier for cheap, always-on motion, and reach for `<border-wc>` when you
12
+ need SVG/canvas-driven effects. It reads your design tokens (CSS custom
13
+ properties) so borders stay on-brand without extra config.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @profpowell/border-wc
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```html
24
+ <script type="module" src="https://unpkg.com/@profpowell/border-wc"></script>
25
+ <border-wc effect="squiggle" color="var(--ink)" animate>
26
+ <blockquote>The shape around the thing is the thing.</blockquote>
27
+ </border-wc>
28
+ ```
29
+
30
+ ## Attributes
31
+
32
+ | Attribute | Description |
33
+ | ----------- | ---------------------------------------------------------------------------------------------------- |
34
+ | `effect` | Border effect: `squiggle`, `draw`, or `sparks`. |
35
+ | `color` | Stroke/particle color (any CSS color; defaults to `currentColor`). |
36
+ | `thickness` | Stroke width in px. |
37
+ | `speed` | Animation duration in ms. |
38
+ | `radius` | Corner radius in px (falls back to the host's computed `border-radius`). |
39
+ | `animate` | Boolean; when present, plays the entrance/loop animation. |
40
+ | `mode` | Effect-specific placement mode (e.g. `center`). |
41
+ | `motion` | `auto` \| `reduce` \| `force` — overrides `prefers-reduced-motion`: `reduce` forces static, `force` forces animation, `auto` (default) honors the media query. |
42
+
43
+ Each attribute can also be set via a matching `--border-wc-*` CSS custom
44
+ property (e.g. `--border-wc-color`), which takes precedence over the attribute.
45
+
46
+ ## Attribute binder (no wrapper)
47
+
48
+ Opt in once and annotate any element — no `<border-wc>` wrapper needed:
49
+
50
+ ```html
51
+ <script type="module" src="https://unpkg.com/@profpowell/border-wc/attr"></script>
52
+ <article data-border-effect="squiggle">…</article>
53
+ ```
54
+
55
+ The binder applies the **extreme** effects (`squiggle`, `draw`, `sparks`) directly
56
+ to the element and watches the DOM for added/changed/removed nodes. **Base** values
57
+ (`spin`, `pulse`, `march`, …) are owned by vanilla-breeze's CSS and ignored here.
58
+ Params come from `--border-wc-*` custom properties (same knobs as the component).
59
+
60
+ The module auto-scans on import. For programmatic control it also exports
61
+ `bindBorderEffects(root = document)` (scan a subtree on demand) and `stopWatching()`
62
+ (stop observing future DOM changes).
63
+
64
+ Part of the "Decorated Layers" family alongside
65
+ [vanilla-breeze](https://github.com/ProfPowell) and `bg-wc`.
66
+
67
+ ## License
68
+
69
+ MIT
@@ -0,0 +1,58 @@
1
+ import { s as styleHost, E as EFFECTS, b as reducedMotion, r as readParams } from "./registry-FHKtTUGp.js";
2
+ class BorderWC extends HTMLElement {
3
+ static get observedAttributes() {
4
+ return ["effect", "color", "thickness", "speed", "radius", "animate", "mode", "motion"];
5
+ }
6
+ #cleanup = null;
7
+ #token = 0;
8
+ get effect() {
9
+ return this.getAttribute("effect");
10
+ }
11
+ set effect(v) {
12
+ v == null ? this.removeAttribute("effect") : this.setAttribute("effect", String(v));
13
+ }
14
+ connectedCallback() {
15
+ styleHost(this);
16
+ this.#apply();
17
+ }
18
+ disconnectedCallback() {
19
+ this.#teardown();
20
+ }
21
+ attributeChangedCallback() {
22
+ if (this.isConnected) this.#apply();
23
+ }
24
+ refresh() {
25
+ this.#apply();
26
+ }
27
+ #teardown() {
28
+ try {
29
+ this.#cleanup?.();
30
+ } catch {
31
+ }
32
+ this.#cleanup = null;
33
+ }
34
+ async #apply() {
35
+ this.#teardown();
36
+ const name = this.effect;
37
+ if (!name || !EFFECTS[name]) return;
38
+ const token = ++this.#token;
39
+ let create;
40
+ try {
41
+ create = await EFFECTS[name]();
42
+ } catch {
43
+ return;
44
+ }
45
+ if (token !== this.#token || !this.isConnected) return;
46
+ const params = { ...readParams(this), reduce: reducedMotion(this) };
47
+ try {
48
+ this.#cleanup = create(this, params) || null;
49
+ this.dispatchEvent(new CustomEvent("border-wc:effect-applied", { detail: { effect: name } }));
50
+ } catch (err) {
51
+ this.dispatchEvent(new CustomEvent("border-wc:error", { detail: { error: err } }));
52
+ }
53
+ }
54
+ }
55
+ if (!customElements.get("border-wc")) customElements.define("border-wc", BorderWC);
56
+ export {
57
+ BorderWC
58
+ };
@@ -0,0 +1,89 @@
1
+ import { E as EFFECTS, s as styleHost, b as reducedMotion, r as readParams, a as EXTREME } from "./registry-FHKtTUGp.js";
2
+ const bound = /* @__PURE__ */ new WeakMap();
3
+ let observer = null;
4
+ function extremeValue(el) {
5
+ const raw = el.getAttribute("data-border-effect");
6
+ if (!raw) return null;
7
+ for (const v of raw.trim().split(/\s+/)) {
8
+ if (EXTREME.includes(v)) return v;
9
+ }
10
+ return null;
11
+ }
12
+ function teardown(el) {
13
+ const prev = bound.get(el);
14
+ if (!prev) return;
15
+ try {
16
+ prev.cleanup?.();
17
+ } catch {
18
+ }
19
+ bound.delete(el);
20
+ }
21
+ async function applyTo(el) {
22
+ const value = extremeValue(el);
23
+ const prev = bound.get(el);
24
+ if (prev && prev.value === value) return;
25
+ teardown(el);
26
+ if (!value) return;
27
+ const token = {};
28
+ bound.set(el, { value, cleanup: null, token });
29
+ let create;
30
+ try {
31
+ create = await EFFECTS[value]();
32
+ } catch {
33
+ if (bound.get(el)?.token === token) bound.delete(el);
34
+ return;
35
+ }
36
+ const cur = bound.get(el);
37
+ if (!cur || cur.token !== token || !el.isConnected) return;
38
+ styleHost(el);
39
+ const params = { ...readParams(el), reduce: reducedMotion(el) };
40
+ try {
41
+ cur.cleanup = create(el, params) || null;
42
+ } catch {
43
+ }
44
+ }
45
+ function bindBorderEffects(root = document) {
46
+ if (root.nodeType === 1 && root.hasAttribute("data-border-effect")) applyTo(root);
47
+ root.querySelectorAll?.("[data-border-effect]").forEach(applyTo);
48
+ }
49
+ function stopWatching() {
50
+ observer?.disconnect();
51
+ observer = null;
52
+ }
53
+ function eachAnnotated(node, fn) {
54
+ if (node.nodeType !== 1) return;
55
+ if (node.hasAttribute("data-border-effect")) fn(node);
56
+ node.querySelectorAll?.("[data-border-effect]").forEach(fn);
57
+ }
58
+ function startWatching() {
59
+ if (observer) return;
60
+ observer = new MutationObserver((records) => {
61
+ for (const rec of records) {
62
+ if (rec.type === "attributes") {
63
+ applyTo(rec.target);
64
+ } else {
65
+ rec.addedNodes.forEach((n) => eachAnnotated(n, applyTo));
66
+ rec.removedNodes.forEach((n) => eachAnnotated(n, teardown));
67
+ }
68
+ }
69
+ });
70
+ observer.observe(document.documentElement, {
71
+ childList: true,
72
+ subtree: true,
73
+ attributes: true,
74
+ attributeFilter: ["data-border-effect"]
75
+ });
76
+ }
77
+ function init() {
78
+ bindBorderEffects();
79
+ startWatching();
80
+ }
81
+ if (document.readyState === "loading") {
82
+ document.addEventListener("DOMContentLoaded", init, { once: true });
83
+ } else {
84
+ init();
85
+ }
86
+ export {
87
+ bindBorderEffects,
88
+ stopWatching
89
+ };
@@ -0,0 +1,69 @@
1
+ import { r as roundedRectPath, a as roundedRectPerimeter } from "./perimeter-B9myracn.js";
2
+ import { c as resolveRadius } from "./registry-FHKtTUGp.js";
3
+ const SVGNS = "http://www.w3.org/2000/svg";
4
+ function createDraw(host, params) {
5
+ const svg = document.createElementNS(SVGNS, "svg");
6
+ svg.setAttribute("data-border-wc", "draw");
7
+ Object.assign(svg.style, {
8
+ position: "absolute",
9
+ inset: "0",
10
+ overflow: "visible",
11
+ pointerEvents: "none"
12
+ });
13
+ svg.setAttribute("width", "100%");
14
+ svg.setAttribute("height", "100%");
15
+ const path = document.createElementNS(SVGNS, "path");
16
+ path.setAttribute("fill", "none");
17
+ path.setAttribute("stroke", params.color);
18
+ path.setAttribute("stroke-width", String(params.thickness));
19
+ svg.appendChild(path);
20
+ host.appendChild(svg);
21
+ let raf = 0;
22
+ let currentOnEnd = null;
23
+ const render = (animateIn) => {
24
+ if (currentOnEnd) {
25
+ path.removeEventListener("transitionend", currentOnEnd);
26
+ currentOnEnd = null;
27
+ }
28
+ const rect = host.getBoundingClientRect();
29
+ const inset = params.thickness / 2;
30
+ const radius = resolveRadius(host, params);
31
+ path.setAttribute(
32
+ "d",
33
+ roundedRectPath({ width: rect.width, height: rect.height, radius, inset })
34
+ );
35
+ const len = roundedRectPerimeter({ width: rect.width, height: rect.height, radius, inset }) || 1;
36
+ path.style.transition = "none";
37
+ path.style.strokeDasharray = String(len);
38
+ if (!animateIn || params.reduce) {
39
+ path.style.strokeDashoffset = "0";
40
+ host.dispatchEvent(new CustomEvent("border-wc:draw-complete", { detail: {} }));
41
+ return;
42
+ }
43
+ path.style.strokeDashoffset = String(len);
44
+ raf = requestAnimationFrame(() => {
45
+ path.style.transition = `stroke-dashoffset ${params.speed}ms linear`;
46
+ path.style.strokeDashoffset = "0";
47
+ });
48
+ const onEnd = (e) => {
49
+ if (e.propertyName !== "stroke-dashoffset") return;
50
+ host.dispatchEvent(new CustomEvent("border-wc:draw-complete", { detail: {} }));
51
+ path.removeEventListener("transitionend", onEnd);
52
+ if (currentOnEnd === onEnd) currentOnEnd = null;
53
+ };
54
+ currentOnEnd = onEnd;
55
+ path.addEventListener("transitionend", onEnd);
56
+ };
57
+ render(true);
58
+ const ro = new ResizeObserver(() => render(false));
59
+ ro.observe(host);
60
+ return () => {
61
+ cancelAnimationFrame(raf);
62
+ if (currentOnEnd) path.removeEventListener("transitionend", currentOnEnd);
63
+ ro.disconnect();
64
+ svg.remove();
65
+ };
66
+ }
67
+ export {
68
+ createDraw
69
+ };
@@ -0,0 +1,169 @@
1
+ const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
2
+ const n = (v) => (Math.round(v * 1e3) / 1e3).toString();
3
+ const deg = (d) => d * Math.PI / 180;
4
+ function tracePath({ start, segments }) {
5
+ if (!segments.length) return "";
6
+ let out = `M${n(start[0])} ${n(start[1])}`;
7
+ segments.forEach((s, i) => {
8
+ if (s.len <= 0 && s.kind !== "line") return;
9
+ if (i === segments.length - 1 && s.kind === "line") {
10
+ const end = s.at(1);
11
+ if (Math.abs(end[0] - start[0]) < 1e-6 && Math.abs(end[1] - start[1]) < 1e-6) return;
12
+ }
13
+ out += s.d;
14
+ });
15
+ return out + "Z";
16
+ }
17
+ function traceLength({ segments }) {
18
+ return segments.reduce((acc, s) => acc + s.len, 0);
19
+ }
20
+ function traceSampler({ start, segments }) {
21
+ const total = segments.reduce((acc, s) => acc + s.len, 0);
22
+ if (total <= 0) return () => start.slice();
23
+ return (t) => {
24
+ let d = (t % 1 + 1) % 1 * total;
25
+ for (const s of segments) {
26
+ if (s.len <= 0) continue;
27
+ if (d <= s.len) return s.at(d / s.len);
28
+ d -= s.len;
29
+ }
30
+ for (let i = segments.length - 1; i >= 0; i--)
31
+ if (segments[i].len > 0) return segments[i].at(1);
32
+ return start.slice();
33
+ };
34
+ }
35
+ function lineSeg(p0, p1) {
36
+ const [x0, y0] = p0;
37
+ const [x1, y1] = p1;
38
+ const len = Math.hypot(x1 - x0, y1 - y0);
39
+ let d;
40
+ if (y0 === y1) d = `H${n(x1)}`;
41
+ else if (x0 === x1) d = `V${n(y1)}`;
42
+ else d = `L${n(x1)} ${n(y1)}`;
43
+ return { kind: "line", len, at: (u) => [x0 + (x1 - x0) * u, y0 + (y1 - y0) * u], d };
44
+ }
45
+ function flattenedWalker(poly) {
46
+ const cum = [0];
47
+ for (let i = 1; i < poly.length; i++) {
48
+ cum.push(cum[i - 1] + Math.hypot(poly[i][0] - poly[i - 1][0], poly[i][1] - poly[i - 1][1]));
49
+ }
50
+ const len = cum[cum.length - 1];
51
+ const at = (u) => {
52
+ if (len <= 0) return poly[0].slice();
53
+ const target = clamp(u, 0, 1) * len;
54
+ let i = 1;
55
+ while (i < cum.length && cum[i] < target) i++;
56
+ if (i >= cum.length) return poly[poly.length - 1].slice();
57
+ const seg = cum[i] - cum[i - 1] || 1;
58
+ const f = (target - cum[i - 1]) / seg;
59
+ return [
60
+ poly[i - 1][0] + (poly[i][0] - poly[i - 1][0]) * f,
61
+ poly[i - 1][1] + (poly[i][1] - poly[i - 1][1]) * f
62
+ ];
63
+ };
64
+ return { len, at };
65
+ }
66
+ function cornerArcSeg({ cx, cy, rx, ry, a0deg, end }) {
67
+ const pt = (adeg) => [cx + rx * Math.cos(deg(adeg)), cy + ry * Math.sin(deg(adeg))];
68
+ const d = `A${n(rx)} ${n(ry)} 0 0 1 ${n(end[0])} ${n(end[1])}`;
69
+ if (rx === ry) {
70
+ const r = rx;
71
+ return { kind: "arc", len: Math.PI / 2 * r, at: (u) => pt(a0deg + 90 * u), d };
72
+ }
73
+ const N = 24;
74
+ const poly = [];
75
+ for (let i = 0; i <= N; i++) poly.push(pt(a0deg + 90 * (i / N)));
76
+ return { kind: "arc", ...flattenedWalker(poly), d };
77
+ }
78
+ function roundedRectShape({ width, height, corners, inset = 0 }) {
79
+ const ox = inset;
80
+ const oy = inset;
81
+ const w = Math.max(0, width - 2 * inset);
82
+ const h = Math.max(0, height - 2 * inset);
83
+ if (w <= 0 || h <= 0) return { start: [ox, oy], segments: [] };
84
+ let c = corners.map(([rx, ry]) => [Math.max(0, rx - inset), Math.max(0, ry - inset)]);
85
+ const ratio = (avail, sum) => sum > 0 ? avail / sum : Infinity;
86
+ const f = Math.min(
87
+ 1,
88
+ ratio(w, c[0][0] + c[1][0]),
89
+ // top: rxTL + rxTR
90
+ ratio(w, c[3][0] + c[2][0]),
91
+ // bottom: rxBL + rxBR
92
+ ratio(h, c[0][1] + c[3][1]),
93
+ // left: ryTL + ryBL
94
+ ratio(h, c[1][1] + c[2][1])
95
+ // right: ryTR + ryBR
96
+ );
97
+ if (f < 1) c = c.map(([rx, ry]) => [rx * f, ry * f]);
98
+ c = c.map(([rx, ry]) => rx > 0 && ry > 0 ? [rx, ry] : [0, 0]);
99
+ const [[rxTL, ryTL], [rxTR, ryTR], [rxBR, ryBR], [rxBL, ryBL]] = c;
100
+ const start = [ox + rxTL, oy];
101
+ const segments = [];
102
+ segments.push(lineSeg([ox + rxTL, oy], [ox + w - rxTR, oy]));
103
+ if (rxTR > 0 && ryTR > 0)
104
+ segments.push(
105
+ cornerArcSeg({
106
+ cx: ox + w - rxTR,
107
+ cy: oy + ryTR,
108
+ rx: rxTR,
109
+ ry: ryTR,
110
+ a0deg: -90,
111
+ end: [ox + w, oy + ryTR]
112
+ })
113
+ );
114
+ segments.push(lineSeg([ox + w, oy + ryTR], [ox + w, oy + h - ryBR]));
115
+ if (rxBR > 0 && ryBR > 0)
116
+ segments.push(
117
+ cornerArcSeg({
118
+ cx: ox + w - rxBR,
119
+ cy: oy + h - ryBR,
120
+ rx: rxBR,
121
+ ry: ryBR,
122
+ a0deg: 0,
123
+ end: [ox + w - rxBR, oy + h]
124
+ })
125
+ );
126
+ segments.push(lineSeg([ox + w - rxBR, oy + h], [ox + rxBL, oy + h]));
127
+ if (rxBL > 0 && ryBL > 0)
128
+ segments.push(
129
+ cornerArcSeg({
130
+ cx: ox + rxBL,
131
+ cy: oy + h - ryBL,
132
+ rx: rxBL,
133
+ ry: ryBL,
134
+ a0deg: 90,
135
+ end: [ox, oy + h - ryBL]
136
+ })
137
+ );
138
+ segments.push(lineSeg([ox, oy + h - ryBL], [ox, oy + ryTL]));
139
+ if (rxTL > 0 && ryTL > 0)
140
+ segments.push(
141
+ cornerArcSeg({
142
+ cx: ox + rxTL,
143
+ cy: oy + ryTL,
144
+ rx: rxTL,
145
+ ry: ryTL,
146
+ a0deg: 180,
147
+ end: [ox + rxTL, oy]
148
+ })
149
+ );
150
+ return { start, segments };
151
+ }
152
+ function uniformCorners({ width, height, radius = 0, inset = 0 }) {
153
+ const c = [radius, radius];
154
+ return { width, height, inset, corners: [c, c, c, c] };
155
+ }
156
+ function roundedRectPath(dims) {
157
+ return tracePath(roundedRectShape(uniformCorners(dims)));
158
+ }
159
+ function roundedRectPerimeter(dims) {
160
+ return traceLength(roundedRectShape(uniformCorners(dims)));
161
+ }
162
+ function roundedRectSampler(dims) {
163
+ return traceSampler(roundedRectShape(uniformCorners(dims)));
164
+ }
165
+ export {
166
+ roundedRectPerimeter as a,
167
+ roundedRectSampler as b,
168
+ roundedRectPath as r
169
+ };
@@ -0,0 +1,48 @@
1
+ function readParams(host) {
2
+ const cs = getComputedStyle(host);
3
+ const cssVar = (n) => cs.getPropertyValue(n).trim();
4
+ const pick = (varName, attr, def, parse = (x) => x) => {
5
+ const v = cssVar(varName);
6
+ if (v) return parse(v);
7
+ const a = host.getAttribute(attr);
8
+ return a != null ? parse(a) : def;
9
+ };
10
+ return {
11
+ color: pick("--border-wc-color", "color", "currentColor"),
12
+ thickness: pick("--border-wc-thickness", "thickness", 2, parseFloat),
13
+ speed: pick("--border-wc-speed", "speed", 1e3, parseFloat),
14
+ radius: pick("--border-wc-radius", "radius", null, parseFloat),
15
+ animate: host.hasAttribute("animate"),
16
+ mode: host.getAttribute("mode") || "center"
17
+ };
18
+ }
19
+ function reducedMotion(host) {
20
+ const m = host.getAttribute("motion");
21
+ if (m === "reduce") return true;
22
+ if (m === "force") return false;
23
+ return matchMedia("(prefers-reduced-motion: reduce)").matches;
24
+ }
25
+ function resolveRadius(host, params) {
26
+ if (Number.isFinite(params.radius)) return params.radius;
27
+ const r = parseFloat(getComputedStyle(host).borderTopLeftRadius);
28
+ return Number.isFinite(r) ? r : 0;
29
+ }
30
+ const EFFECTS = {
31
+ draw: () => import("./draw-CKnXGdb7.js").then((m) => m.createDraw),
32
+ squiggle: () => import("./squiggle-CJaR8E7M.js").then((m) => m.createSquiggle),
33
+ sparks: () => import("./sparks-CGHc4C2Y.js").then((m) => m.createSparks)
34
+ };
35
+ const EXTREME = Object.keys(EFFECTS);
36
+ function styleHost(host) {
37
+ const cs = getComputedStyle(host);
38
+ if (cs.position === "static") host.style.position = "relative";
39
+ if (cs.display === "inline") host.style.display = "block";
40
+ }
41
+ export {
42
+ EFFECTS as E,
43
+ EXTREME as a,
44
+ reducedMotion as b,
45
+ resolveRadius as c,
46
+ readParams as r,
47
+ styleHost as s
48
+ };
@@ -0,0 +1,85 @@
1
+ import { b as roundedRectSampler } from "./perimeter-B9myracn.js";
2
+ import { c as resolveRadius } from "./registry-FHKtTUGp.js";
3
+ let ctx;
4
+ const cache = /* @__PURE__ */ new Map();
5
+ function toRGBA(css) {
6
+ if (!css) return "rgba(0,0,0,0)";
7
+ const key = String(css).trim();
8
+ if (cache.has(key)) return cache.get(key);
9
+ if (!ctx) ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
10
+ ctx.clearRect(0, 0, 1, 1);
11
+ ctx.fillStyle = "#000";
12
+ ctx.fillStyle = key;
13
+ ctx.fillRect(0, 0, 1, 1);
14
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
15
+ const out = a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(3)})`;
16
+ cache.set(key, out);
17
+ return out;
18
+ }
19
+ function createSparks(host, params) {
20
+ const canvas = document.createElement("canvas");
21
+ canvas.setAttribute("data-border-wc", "sparks");
22
+ Object.assign(canvas.style, {
23
+ position: "absolute",
24
+ inset: "0",
25
+ width: "100%",
26
+ height: "100%",
27
+ pointerEvents: "none"
28
+ });
29
+ host.appendChild(canvas);
30
+ const ctx2 = canvas.getContext("2d");
31
+ const color = toRGBA(
32
+ params.color === "currentColor" ? getComputedStyle(host).color : params.color
33
+ );
34
+ let sampler = () => [0, 0];
35
+ let dpr = 1;
36
+ const fit = () => {
37
+ const rect = host.getBoundingClientRect();
38
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
39
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
40
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
41
+ sampler = roundedRectSampler({
42
+ width: rect.width,
43
+ height: rect.height,
44
+ radius: resolveRadius(host, params),
45
+ inset: params.thickness / 2
46
+ });
47
+ };
48
+ fit();
49
+ const N = 24;
50
+ const parts = Array.from({ length: N }, (_, i) => ({ t: i / N, v: 0.02 + Math.random() * 0.02 }));
51
+ let raf = 0;
52
+ const frame = () => {
53
+ ctx2.clearRect(0, 0, canvas.width, canvas.height);
54
+ ctx2.fillStyle = color;
55
+ const dt = params.reduce ? 0 : 1;
56
+ for (const p of parts) {
57
+ p.t = (p.t + p.v * 0.016 * dt) % 1;
58
+ const [x, y] = sampler(p.t);
59
+ ctx2.beginPath();
60
+ ctx2.arc(x * dpr, y * dpr, params.thickness * dpr, 0, Math.PI * 2);
61
+ ctx2.fill();
62
+ }
63
+ raf = params.reduce ? 0 : requestAnimationFrame(frame);
64
+ };
65
+ frame();
66
+ const io = new IntersectionObserver(([e]) => {
67
+ if (e.isIntersecting && !raf && !params.reduce) raf = requestAnimationFrame(frame);
68
+ else if (!e.isIntersecting && raf) {
69
+ cancelAnimationFrame(raf);
70
+ raf = 0;
71
+ }
72
+ });
73
+ io.observe(host);
74
+ const ro = new ResizeObserver(fit);
75
+ ro.observe(host);
76
+ return () => {
77
+ cancelAnimationFrame(raf);
78
+ io.disconnect();
79
+ ro.disconnect();
80
+ canvas.remove();
81
+ };
82
+ }
83
+ export {
84
+ createSparks
85
+ };
@@ -0,0 +1,62 @@
1
+ import { r as roundedRectPath } from "./perimeter-B9myracn.js";
2
+ import { c as resolveRadius } from "./registry-FHKtTUGp.js";
3
+ const SVGNS = "http://www.w3.org/2000/svg";
4
+ let uid = 0;
5
+ function createSquiggle(host, params) {
6
+ const id = `bw-sq-${++uid}`;
7
+ const svg = document.createElementNS(SVGNS, "svg");
8
+ svg.setAttribute("data-border-wc", "squiggle");
9
+ Object.assign(svg.style, {
10
+ position: "absolute",
11
+ inset: "0",
12
+ overflow: "visible",
13
+ pointerEvents: "none"
14
+ });
15
+ svg.setAttribute("width", "100%");
16
+ svg.setAttribute("height", "100%");
17
+ svg.innerHTML = `<defs><filter id="${id}" x="-20%" y="-20%" width="140%" height="140%"><feTurbulence type="fractalNoise" baseFrequency="0.02" numOctaves="2" seed="1" result="n"/><feDisplacementMap in="SourceGraphic" in2="n" scale="6" xChannelSelector="R" yChannelSelector="G"/></filter></defs>`;
18
+ const path = document.createElementNS(SVGNS, "path");
19
+ path.setAttribute("fill", "none");
20
+ path.setAttribute("stroke", params.color);
21
+ path.setAttribute("stroke-width", String(params.thickness));
22
+ path.setAttribute("filter", `url(#${id})`);
23
+ svg.appendChild(path);
24
+ host.appendChild(svg);
25
+ const turb = svg.querySelector("feTurbulence");
26
+ const fit = () => {
27
+ const rect = host.getBoundingClientRect();
28
+ path.setAttribute(
29
+ "d",
30
+ roundedRectPath({
31
+ width: rect.width,
32
+ height: rect.height,
33
+ radius: resolveRadius(host, params),
34
+ inset: params.thickness / 2
35
+ })
36
+ );
37
+ };
38
+ fit();
39
+ let raf = 0;
40
+ if (params.animate && !params.reduce) {
41
+ let seed = 1;
42
+ let last = 0;
43
+ const loop = (now) => {
44
+ if (now - last > params.speed / 8) {
45
+ turb.setAttribute("seed", String(seed = (seed + 1) % 100));
46
+ last = now;
47
+ }
48
+ raf = requestAnimationFrame(loop);
49
+ };
50
+ raf = requestAnimationFrame(loop);
51
+ }
52
+ const ro = new ResizeObserver(fit);
53
+ ro.observe(host);
54
+ return () => {
55
+ cancelAnimationFrame(raf);
56
+ ro.disconnect();
57
+ svg.remove();
58
+ };
59
+ }
60
+ export {
61
+ createSquiggle
62
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@profpowell/border-wc",
3
+ "version": "0.1.0",
4
+ "description": "Light-DOM web component for high-touch border effects (squiggle, draw, sparks) the platform under-serves.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/border-wc.js",
8
+ "module": "dist/border-wc.js",
9
+ "exports": {
10
+ ".": "./dist/border-wc.js",
11
+ "./attr": "./dist/data-border-effect.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "sideEffects": [
19
+ "dist/border-wc.js",
20
+ "dist/data-border-effect.js"
21
+ ],
22
+ "customElements": "custom-elements.json",
23
+ "keywords": [
24
+ "web-component",
25
+ "custom-element",
26
+ "border",
27
+ "svg",
28
+ "canvas",
29
+ "vanilla-breeze"
30
+ ],
31
+ "author": "ProfPowell",
32
+ "scripts": {
33
+ "dev": "vite",
34
+ "build": "vite build",
35
+ "preview": "vite preview",
36
+ "test": "playwright test",
37
+ "test:ui": "playwright test --ui",
38
+ "lint": "eslint src/",
39
+ "lint:fix": "eslint src/ --fix",
40
+ "format": "prettier --write \"src/**/*.js\"",
41
+ "format:check": "prettier --check \"src/**/*.js\"",
42
+ "analyze": "cem analyze",
43
+ "prepublishOnly": "npm run build && npm run analyze"
44
+ },
45
+ "devDependencies": {
46
+ "@custom-elements-manifest/analyzer": "^0.11.0",
47
+ "@eslint/js": "^10.0.1",
48
+ "@playwright/test": "^1.60.0",
49
+ "eslint": "^10.4.0",
50
+ "prettier": "^3.8.3",
51
+ "vite": "^7.3.3"
52
+ }
53
+ }