material-inspired-component-library 7.0.2 → 8.0.1

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.
Files changed (104) hide show
  1. package/.claude/settings.local.json +14 -0
  2. package/CLAUDE.md +53 -0
  3. package/README.md +6 -0
  4. package/components/accordion/README.md +6 -3
  5. package/components/alert/index.scss +5 -0
  6. package/components/appbar/index.scss +12 -0
  7. package/components/badge/index.scss +2 -0
  8. package/components/bottomsheet/index.scss +9 -0
  9. package/components/button/index.scss +33 -6
  10. package/components/card/README.md +4 -0
  11. package/components/card/index.scss +182 -150
  12. package/components/checkbox/index.scss +28 -6
  13. package/components/datepicker/index.scss +13 -0
  14. package/components/datepicker/index.ts +9 -9
  15. package/components/dialog/index.scss +21 -6
  16. package/components/iconbutton/index.scss +28 -6
  17. package/components/list/README.md +191 -32
  18. package/components/list/index.scss +281 -190
  19. package/components/list/index.ts +100 -100
  20. package/components/menu/README.md +199 -10
  21. package/components/menu/index.scss +242 -47
  22. package/components/menu/index.ts +74 -37
  23. package/components/navigationrail/index.scss +91 -68
  24. package/components/progressindicator/README.md +88 -0
  25. package/components/progressindicator/index.scss +225 -0
  26. package/components/progressindicator/index.ts +77 -0
  27. package/components/radio/index.scss +24 -6
  28. package/components/select/README.md +42 -5
  29. package/components/select/index.scss +45 -79
  30. package/components/shape/README.md +103 -0
  31. package/components/shape/_paths.generated.scss +64 -0
  32. package/components/shape/index.scss +66 -0
  33. package/components/shape/master.scss +28 -0
  34. package/components/sidesheet/index.scss +11 -0
  35. package/components/slider/index.scss +13 -0
  36. package/components/snackbar/index.scss +12 -0
  37. package/components/stepper/index.scss +3 -5
  38. package/components/switch/index.scss +9 -0
  39. package/components/textfield/index.scss +10 -1
  40. package/components/textfield/index.ts +2 -2
  41. package/components/timepicker/index.scss +16 -0
  42. package/dist/alert.css +1 -1
  43. package/dist/appbar.css +1 -1
  44. package/dist/badge.css +1 -1
  45. package/dist/bottomsheet.css +1 -1
  46. package/dist/button.css +1 -1
  47. package/dist/card.css +1 -1
  48. package/dist/checkbox.css +1 -1
  49. package/dist/components/list/index.d.ts +2 -2
  50. package/dist/components/progressindicator/index.d.ts +6 -0
  51. package/dist/datepicker.css +1 -1
  52. package/dist/dialog.css +1 -1
  53. package/dist/divider.css +1 -1
  54. package/dist/foundations/form/index.js +1 -0
  55. package/dist/foundations.css +1 -1
  56. package/dist/iconbutton.css +1 -1
  57. package/dist/layout.css +1 -1
  58. package/dist/list.css +1 -1
  59. package/dist/menu.css +1 -1
  60. package/dist/micl.css +1 -1
  61. package/dist/micl.js +1 -1
  62. package/dist/navigationrail.css +1 -1
  63. package/dist/progressindicator.css +1 -0
  64. package/dist/progressindicator.js +1 -0
  65. package/dist/radio.css +1 -1
  66. package/dist/select.css +1 -1
  67. package/dist/shape.css +1 -0
  68. package/dist/shape.js +1 -0
  69. package/dist/sidesheet.css +1 -1
  70. package/dist/slider.css +1 -1
  71. package/dist/snackbar.css +1 -1
  72. package/dist/stepper.css +1 -1
  73. package/dist/switch.css +1 -1
  74. package/dist/textfield.css +1 -1
  75. package/dist/timepicker.css +1 -1
  76. package/docs/accordion.html +24 -24
  77. package/docs/bottomsheet.html +1 -4
  78. package/docs/datepicker.html +21 -21
  79. package/docs/dialog.html +1 -1
  80. package/docs/index.html +5 -4
  81. package/docs/list.html +38 -22
  82. package/docs/menu.html +246 -41
  83. package/docs/micl.css +1 -1
  84. package/docs/micl.js +1 -1
  85. package/docs/progressindicator.html +288 -0
  86. package/docs/select.html +68 -19
  87. package/docs/shape.css +1 -0
  88. package/docs/shape.js +1 -0
  89. package/docs/shapes.html +150 -0
  90. package/foundations/index.scss +0 -1
  91. package/foundations/layout/README.md +1 -1
  92. package/foundations/layout/index.scss +3 -0
  93. package/micl.ts +8 -1
  94. package/package.json +6 -4
  95. package/styles/README.md +90 -12
  96. package/styles/elevation.scss +46 -13
  97. package/styles/motion.scss +51 -47
  98. package/styles/shapes.scss +41 -26
  99. package/styles/statelayer.scss +93 -36
  100. package/styles/typography.scss +120 -322
  101. package/styles.scss +10 -6
  102. package/tools/shapes/check.mjs +42 -0
  103. package/tools/shapes/generate.mjs +834 -0
  104. package/webpack.config.js +16 -1
@@ -0,0 +1,834 @@
1
+ //
2
+ // Copyright © 2025 Hermana AS
3
+ //
4
+ // Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ // of this software and associated documentation files (the "Software"), to deal
6
+ // in the Software without restriction, including without limitation the rights
7
+ // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ // copies of the Software, and to permit persons to whom the Software is
9
+ // furnished to do so, subject to the following conditions:
10
+ //
11
+ // The above copyright notice and this permission notice shall be included in all
12
+ // copies or substantial portions of the Software.
13
+ //
14
+ // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ // SOFTWARE.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ //
27
+ // Shape-path generator. Run via `npm run gen:shapes`. Writes
28
+ // components/shape/_paths.generated.scss with one map entry per shape; the
29
+ // SCSS partial then looks up the path strings without doing any math at
30
+ // compile time.
31
+ //
32
+ // This file is a faithful port of the Sass math that previously lived in
33
+ // styles/shapes.scss / components/shape/index.scss. Numeric output is
34
+ // formatted to match Sass's default $number-precision: 10 with trailing
35
+ // zeros stripped, so the emitted `d` strings are byte-identical to the
36
+ // Sass-rendered ones.
37
+ //
38
+
39
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
+ const targetPath = path.resolve(__dirname, '../../components/shape/_paths.generated.scss');
41
+
42
+ // Sass's default precision is 10 decimal places. Match that, then strip
43
+ // trailing zeros so e.g. 50.0000000000 → 50, 3.1400000000 → 3.14.
44
+ function toCss(n) {
45
+ if (Number.isInteger(n)) return String(n);
46
+ let s = n.toFixed(10);
47
+ s = s.replace(/(\.\d*?)0+$/, '$1');
48
+ s = s.replace(/\.$/, '');
49
+ // Sass renders -0 as 0 — match that to avoid spurious diffs.
50
+ if (s === '-0') return '0';
51
+ return s;
52
+ }
53
+
54
+ function segmentsLength(segments) {
55
+ let t = 0;
56
+ for (const s of segments) t += s.len;
57
+ return t;
58
+ }
59
+
60
+ function pointAt(segments, total, t) {
61
+ const target = t * total;
62
+ let acc = 0;
63
+ for (const s of segments) {
64
+ if (acc + s.len >= target) {
65
+ const local = s.len === 0 ? 0 : (target - acc) / s.len;
66
+ if (s.type === 'line') {
67
+ return [s.x1 + local * (s.x2 - s.x1), s.y1 + local * (s.y2 - s.y1)];
68
+ }
69
+ if (s.type === 'arc') {
70
+ const a = s.a1 + local * (s.a2 - s.a1);
71
+ return [s.cx + s.r * Math.cos(a), s.cy + s.r * Math.sin(a)];
72
+ }
73
+ if (s.type === 'cubic') {
74
+ const u = 1 - local;
75
+ const u2 = u * u, u3 = u2 * u;
76
+ const t2 = local * local, t3 = t2 * local;
77
+ const bx = u3 * s.p0x + 3 * u2 * local * s.p1x + 3 * u * t2 * s.p2x + t3 * s.p3x;
78
+ const by = u3 * s.p0y + 3 * u2 * local * s.p1y + 3 * u * t2 * s.p2y + t3 * s.p3y;
79
+ return [bx, by];
80
+ }
81
+ }
82
+ acc += s.len;
83
+ }
84
+ return [0, 0];
85
+ }
86
+
87
+ // Emits a closed SVG path string from N (x, y) sample points (assumed CW).
88
+ // Rotates the array so the path begins at the sample closest to top-centre
89
+ // (50, 0). Combined with every shape traversing CW, this keeps point-index k
90
+ // mapping to the same relative position across shapes — so `transition: d`
91
+ // interpolates smoothly.
92
+ function emitAlignedPath(points, n) {
93
+ // Find the sample closest to (50, 0). Symmetric shapes (e.g. clover-4)
94
+ // have multiple samples at exactly the same distance; ULP-level differences
95
+ // in float arithmetic would otherwise let later samples nudge ahead of
96
+ // earlier ones non-deterministically. Tolerate ULP-scale differences so
97
+ // the first tied sample wins, matching Sass's tie-break behaviour.
98
+ const EPS = 1e-9;
99
+ let bestI = 0, bestD = 999999;
100
+ for (let i = 0; i < n; i++) {
101
+ const dx = points[i][0] - 50;
102
+ const dy = points[i][1];
103
+ const dist = dx * dx + dy * dy;
104
+ if (dist < bestD - EPS) { bestD = dist; bestI = i; }
105
+ }
106
+ let d = '';
107
+ for (let j = 0; j < n; j++) {
108
+ const idx = (bestI + j) % n;
109
+ const p = points[idx];
110
+ d += (j === 0 ? 'M' : 'L') + ' ' + toCss(p[0]) + ' ' + toCss(p[1]) + ' ';
111
+ }
112
+ return d + 'Z';
113
+ }
114
+
115
+ function buildPath(segments, n) {
116
+ const total = segmentsLength(segments);
117
+ const points = [];
118
+ for (let i = 0; i < n; i++) {
119
+ points.push(pointAt(segments, total, i / n));
120
+ }
121
+ return emitAlignedPath(points, n);
122
+ }
123
+
124
+ // --- generic M3 rounded polygon --------------------------------------------
125
+
126
+ function roundedPolygonPath(n, vertices, normalize = true) {
127
+ // Step 0: deduplicate consecutive identical vertices.
128
+ let dedup = [];
129
+ for (const curr of vertices) {
130
+ if (dedup.length > 0) {
131
+ const prev = dedup[dedup.length - 1];
132
+ const dx = curr[0] - prev[0];
133
+ const dy = curr[1] - prev[1];
134
+ if (Math.sqrt(dx * dx + dy * dy) < 0.01) continue;
135
+ }
136
+ dedup.push(curr);
137
+ }
138
+ if (dedup.length > 1) {
139
+ const first = dedup[0];
140
+ const last = dedup[dedup.length - 1];
141
+ const dx = first[0] - last[0];
142
+ const dy = first[1] - last[1];
143
+ if (Math.sqrt(dx * dx + dy * dy) < 0.01) {
144
+ dedup = dedup.slice(0, -1);
145
+ }
146
+ }
147
+ // Drop collinear vertices.
148
+ const straight = [];
149
+ const dc = dedup.length;
150
+ for (let i = 0; i < dc; i++) {
151
+ const prev = dedup[(i - 1 + dc) % dc];
152
+ const curr = dedup[i];
153
+ const next = dedup[(i + 1) % dc];
154
+ const cross = (curr[0] - prev[0]) * (next[1] - curr[1]) -
155
+ (curr[1] - prev[1]) * (next[0] - curr[0]);
156
+ if (Math.abs(cross) > 0.5) straight.push(curr);
157
+ }
158
+ vertices = straight;
159
+ const count = vertices.length;
160
+
161
+ // Step 1: per-vertex flanking-curve geometry (port of M3's
162
+ // _RoundedCorner.getCubics from AndroidX).
163
+ const corners = [];
164
+ for (let i = 0; i < count; i++) {
165
+ const vp = vertices[(i - 1 + count) % count];
166
+ const vc = vertices[i];
167
+ const vn = vertices[(i + 1) % count];
168
+
169
+ const px = vp[0], py = vp[1];
170
+ const vx = vc[0], vy = vc[1], rBase = vc[2];
171
+ const smoothingSpec = vc.length >= 4 ? vc[3] : 0;
172
+ const nx = vn[0], ny = vn[1];
173
+
174
+ let d1x = px - vx, d1y = py - vy;
175
+ const d1Len = Math.sqrt(d1x * d1x + d1y * d1y);
176
+ d1x /= d1Len; d1y /= d1Len;
177
+
178
+ let d2x = nx - vx, d2y = ny - vy;
179
+ const d2Len = Math.sqrt(d2x * d2x + d2y * d2y);
180
+ d2x /= d2Len; d2y /= d2Len;
181
+
182
+ let cosAngle = d1x * d2x + d1y * d2y;
183
+ if (cosAngle > 1) cosAngle = 1;
184
+ if (cosAngle < -1) cosAngle = -1;
185
+ const sinSq = 1 - cosAngle * cosAngle;
186
+ const sinAngle = sinSq <= 0 ? 0 : Math.sqrt(sinSq);
187
+
188
+ let expectedRoundCut = 0;
189
+ if (sinAngle > 0.001) {
190
+ expectedRoundCut = rBase * (1 + cosAngle) / sinAngle;
191
+ }
192
+ const expectedCut = (1 + smoothingSpec) * expectedRoundCut;
193
+
194
+ const allowed0 = d1Len / 2;
195
+ const allowed1 = d2Len / 2;
196
+ const allowedCut = Math.min(allowed0, allowed1);
197
+
198
+ let sm0 = 0;
199
+ if (allowed0 > expectedCut) sm0 = smoothingSpec;
200
+ else if (allowed0 > expectedRoundCut && expectedCut > expectedRoundCut) {
201
+ sm0 = smoothingSpec * (allowed0 - expectedRoundCut) / (expectedCut - expectedRoundCut);
202
+ }
203
+ let sm1 = 0;
204
+ if (allowed1 > expectedCut) sm1 = smoothingSpec;
205
+ else if (allowed1 > expectedRoundCut && expectedCut > expectedRoundCut) {
206
+ sm1 = smoothingSpec * (allowed1 - expectedRoundCut) / (expectedCut - expectedRoundCut);
207
+ }
208
+
209
+ const actualRoundCut = Math.min(allowedCut, expectedRoundCut);
210
+ const actualR = expectedRoundCut < 0.001 ? 0 : rBase * actualRoundCut / expectedRoundCut;
211
+
212
+ let bx = d1x + d2x, by = d1y + d2y;
213
+ const bLen = Math.sqrt(bx * bx + by * by);
214
+ bx /= bLen; by /= bLen;
215
+ const centerDist = Math.sqrt(actualR * actualR + actualRoundCut * actualRoundCut);
216
+ const cx = vx + bx * centerDist;
217
+ const cy = vy + by * centerDist;
218
+
219
+ const t1x = vx + d1x * actualRoundCut, t1y = vy + d1y * actualRoundCut;
220
+ const t2x = vx + d2x * actualRoundCut, t2y = vy + d2y * actualRoundCut;
221
+ const mx = (t1x + t2x) * 0.5, my = (t1y + t2y) * 0.5;
222
+
223
+ // Side 1 (incoming flanking curve)
224
+ const cs1x = vx + d1x * actualRoundCut * (1 + sm0);
225
+ const cs1y = vy + d1y * actualRoundCut * (1 + sm0);
226
+ const p1x = t1x + (mx - t1x) * sm0;
227
+ const p1y = t1y + (my - t1y) * sm0;
228
+ const dp1x = p1x - cx, dp1y = p1y - cy;
229
+ const dp1Len = Math.sqrt(dp1x * dp1x + dp1y * dp1y);
230
+ let ce1x = cx, ce1y = cy;
231
+ if (dp1Len > 0.0001) {
232
+ ce1x = cx + actualR * dp1x / dp1Len;
233
+ ce1y = cy + actualR * dp1y / dp1Len;
234
+ }
235
+
236
+ const tan1x = -(ce1y - cy);
237
+ const tan1y = (ce1x - cx);
238
+ const det1 = d1x * tan1y - d1y * tan1x;
239
+ let ae1x = t1x, ae1y = t1y;
240
+ if (Math.abs(det1) > 0.0001) {
241
+ const tt1 = ((ce1x - px) * tan1y - (ce1y - py) * tan1x) / det1;
242
+ ae1x = px + tt1 * d1x;
243
+ ae1y = py + tt1 * d1y;
244
+ }
245
+ const as1x = (cs1x + 2 * ae1x) / 3;
246
+ const as1y = (cs1y + 2 * ae1y) / 3;
247
+
248
+ // Side 2 (outgoing flanking curve)
249
+ const cs2x = vx + d2x * actualRoundCut * (1 + sm1);
250
+ const cs2y = vy + d2y * actualRoundCut * (1 + sm1);
251
+ const p2x = t2x + (mx - t2x) * sm1;
252
+ const p2y = t2y + (my - t2y) * sm1;
253
+ const dp2x = p2x - cx, dp2y = p2y - cy;
254
+ const dp2Len = Math.sqrt(dp2x * dp2x + dp2y * dp2y);
255
+ let ce2x = cx, ce2y = cy;
256
+ if (dp2Len > 0.0001) {
257
+ ce2x = cx + actualR * dp2x / dp2Len;
258
+ ce2y = cy + actualR * dp2y / dp2Len;
259
+ }
260
+
261
+ const tan2x = -(ce2y - cy);
262
+ const tan2y = (ce2x - cx);
263
+ const det2 = d2x * tan2y - d2y * tan2x;
264
+ let ae2x = t2x, ae2y = t2y;
265
+ if (Math.abs(det2) > 0.0001) {
266
+ const tt2 = ((ce2x - nx) * tan2y - (ce2y - ny) * tan2x) / det2;
267
+ ae2x = nx + tt2 * d2x;
268
+ ae2y = ny + tt2 * d2y;
269
+ }
270
+ const as2x = (cs2x + 2 * ae2x) / 3;
271
+ const as2y = (cs2y + 2 * ae2y) / 3;
272
+
273
+ // Arc start/end angles. Convex traverses increasing θ, concave decreasing.
274
+ const cross = d1x * d2y - d1y * d2x;
275
+ let a1 = Math.atan2(ce1y - cy, ce1x - cx);
276
+ let a2 = Math.atan2(ce2y - cy, ce2x - cx);
277
+ if (cross < 0) {
278
+ if (a2 < a1) a2 += 2 * Math.PI;
279
+ } else {
280
+ if (a2 > a1) a2 -= 2 * Math.PI;
281
+ }
282
+
283
+ corners.push({
284
+ cs1x, cs1y, as1x, as1y, ae1x, ae1y, ce1x, ce1y,
285
+ cx, cy, actualR, a1, a2,
286
+ ce2x, ce2y, ae2x, ae2y, as2x, as2y, cs2x, cs2y,
287
+ });
288
+ }
289
+
290
+ // Step 2: emit segments per corner.
291
+ const segments = [];
292
+ for (let i = 0; i < count; i++) {
293
+ const cprev = corners[(i - 1 + count) % count];
294
+ const c = corners[i];
295
+
296
+ const edgeLen = Math.sqrt((c.cs1x - cprev.cs2x) ** 2 + (c.cs1y - cprev.cs2y) ** 2);
297
+ segments.push({ len: edgeLen, type: 'line',
298
+ x1: cprev.cs2x, y1: cprev.cs2y, x2: c.cs1x, y2: c.cs1y });
299
+
300
+ const c1Len = (
301
+ Math.sqrt((c.as1x - c.cs1x) ** 2 + (c.as1y - c.cs1y) ** 2) +
302
+ Math.sqrt((c.ae1x - c.as1x) ** 2 + (c.ae1y - c.as1y) ** 2) +
303
+ Math.sqrt((c.ce1x - c.ae1x) ** 2 + (c.ce1y - c.ae1y) ** 2) +
304
+ Math.sqrt((c.ce1x - c.cs1x) ** 2 + (c.ce1y - c.cs1y) ** 2)
305
+ ) / 2;
306
+ segments.push({ len: c1Len, type: 'cubic',
307
+ p0x: c.cs1x, p0y: c.cs1y, p1x: c.as1x, p1y: c.as1y,
308
+ p2x: c.ae1x, p2y: c.ae1y, p3x: c.ce1x, p3y: c.ce1y });
309
+
310
+ const arcLen = Math.abs(c.a2 - c.a1) * c.actualR;
311
+ segments.push({ len: arcLen, type: 'arc',
312
+ cx: c.cx, cy: c.cy, r: c.actualR, a1: c.a1, a2: c.a2 });
313
+
314
+ const c2Len = (
315
+ Math.sqrt((c.ae2x - c.ce2x) ** 2 + (c.ae2y - c.ce2y) ** 2) +
316
+ Math.sqrt((c.as2x - c.ae2x) ** 2 + (c.as2y - c.ae2y) ** 2) +
317
+ Math.sqrt((c.cs2x - c.as2x) ** 2 + (c.cs2y - c.as2y) ** 2) +
318
+ Math.sqrt((c.cs2x - c.ce2x) ** 2 + (c.cs2y - c.ce2y) ** 2)
319
+ ) / 2;
320
+ segments.push({ len: c2Len, type: 'cubic',
321
+ p0x: c.ce2x, p0y: c.ce2y, p1x: c.ae2x, p1y: c.ae2y,
322
+ p2x: c.as2x, p2y: c.as2y, p3x: c.cs2x, p3y: c.cs2y });
323
+ }
324
+
325
+ // Step 3: optionally normalize bounds to fit 100×100. Collect samples
326
+ // during the bounds-finding pass and reuse them for the rescale, so each
327
+ // parameter t is only sampled once (was: twice per shape).
328
+ if (normalize) {
329
+ const total = segmentsLength(segments);
330
+ const samples = new Array(n);
331
+ let minX = 9999, maxX = -9999, minY = 9999, maxY = -9999;
332
+ for (let i = 0; i < n; i++) {
333
+ const p = pointAt(segments, total, i / n);
334
+ samples[i] = p;
335
+ const x = p[0], y = p[1];
336
+ if (x < minX) minX = x;
337
+ if (x > maxX) maxX = x;
338
+ if (y < minY) minY = y;
339
+ if (y > maxY) maxY = y;
340
+ }
341
+ const w = maxX - minX, h = maxY - minY;
342
+ const larger = Math.max(w, h);
343
+ const scale = 100 / larger;
344
+ const offsetX = (100 - w * scale) / 2 - minX * scale;
345
+ const offsetY = (100 - h * scale) / 2 - minY * scale;
346
+ const points = new Array(n);
347
+ for (let i = 0; i < n; i++) {
348
+ const [x, y] = samples[i];
349
+ points[i] = [x * scale + offsetX, y * scale + offsetY];
350
+ }
351
+ return emitAlignedPath(points, n);
352
+ }
353
+ return buildPath(segments, n);
354
+ }
355
+
356
+ // Faithful port of MaterialShapes._doRepeat. Polar coordinates so the
357
+ // rotate/mirror algorithm preserves CW order.
358
+ function repeatedPattern(pattern, repeat, mirror = false) {
359
+ const cx = 50, cy = 50;
360
+ const vertices = [];
361
+ const np = pattern.length;
362
+
363
+ if (mirror) {
364
+ const measures = pattern.map(v => {
365
+ const px = v[0] - cx, py = v[1] - cy;
366
+ return [Math.atan2(py, px), Math.sqrt(px * px + py * py)];
367
+ });
368
+ const firstAngle = measures[0][0];
369
+ const actualReps = 2 * repeat;
370
+ const sectionAng = 2 * Math.PI / actualReps;
371
+
372
+ for (let r = 0; r < actualReps; r++) {
373
+ const rEven = r % 2 === 0;
374
+ for (let idx = 0; idx < np; idx++) {
375
+ const i = rEven ? idx : np - 1 - idx;
376
+ if (i > 0 || rEven) {
377
+ const [mAngle, mDist] = measures[i];
378
+ const a = rEven
379
+ ? sectionAng * r + mAngle
380
+ : sectionAng * r + (sectionAng - mAngle + 2 * firstAngle);
381
+ const px = cx + mDist * Math.cos(a);
382
+ const py = cy + mDist * Math.sin(a);
383
+ const orig = pattern[i];
384
+ const rVal = orig[2];
385
+ const smoothing = orig.length >= 4 ? orig[3] : 0;
386
+ vertices.push([px, py, rVal, smoothing]);
387
+ }
388
+ }
389
+ }
390
+ } else {
391
+ const angleStep = 2 * Math.PI / repeat;
392
+ for (let i = 0; i < np * repeat; i++) {
393
+ const idx = i % np;
394
+ const repNum = Math.floor(i / np);
395
+ const rot = repNum * angleStep;
396
+ const cosR = Math.cos(rot);
397
+ const sinR = Math.sin(rot);
398
+ const v = pattern[idx];
399
+ const px = v[0] - cx, py = v[1] - cy;
400
+ const rVal = v[2];
401
+ const smoothing = v.length >= 4 ? v[3] : 0;
402
+ const rx = px * cosR - py * sinR + cx;
403
+ const ry = px * sinR + py * cosR + cy;
404
+ vertices.push([rx, ry, rVal, smoothing]);
405
+ }
406
+ }
407
+ return vertices;
408
+ }
409
+
410
+ function verticesOnCircle(numVertices, roundings, rotationDeg = 0) {
411
+ const vertices = [];
412
+ const rotRad = rotationDeg * Math.PI / 180;
413
+ for (let i = 0; i < numVertices; i++) {
414
+ const angle = 2 * Math.PI * i / numVertices + rotRad;
415
+ const x = 50 + 50 * Math.cos(angle);
416
+ const y = 50 + 50 * Math.sin(angle);
417
+ const r = roundings[i] * 50;
418
+ vertices.push([x, y, r]);
419
+ }
420
+ return vertices;
421
+ }
422
+
423
+ function starVertices(points, innerRatio, rounding, rotationDeg = -90) {
424
+ const vertices = [];
425
+ const outerR = 50;
426
+ const innerR = 50 * innerRatio;
427
+ const rounding100 = rounding * 50;
428
+ const rotRad = rotationDeg * Math.PI / 180;
429
+ for (let i = 0; i < points * 2; i++) {
430
+ const angle = 2 * Math.PI * i / (points * 2) + rotRad;
431
+ const r = i % 2 === 0 ? outerR : innerR;
432
+ const x = 50 + r * Math.cos(angle);
433
+ const y = 50 + r * Math.sin(angle);
434
+ vertices.push([x, y, rounding100]);
435
+ }
436
+ return vertices;
437
+ }
438
+
439
+ function circlePath(n) {
440
+ const points = [];
441
+ for (let i = 0; i < n; i++) {
442
+ const angle = 2 * Math.PI * i / n - Math.PI / 2;
443
+ points.push([50 + 50 * Math.cos(angle), 50 + 50 * Math.sin(angle)]);
444
+ }
445
+ return emitAlignedPath(points, n);
446
+ }
447
+
448
+ // --- shape generators (one per shape) -------------------------------------
449
+
450
+ function heartPath(n) {
451
+ return roundedPolygonPath(n, repeatedPattern([
452
+ [ 50.0, 26.8, 1.6],
453
+ [ 79.2, -6.6, 95.8],
454
+ [106.4, 27.6, 100.0],
455
+ [ 50.1, 94.6, 12.9],
456
+ ], 1, true));
457
+ }
458
+
459
+ function cookie4Path(n) {
460
+ return roundedPolygonPath(n, repeatedPattern([
461
+ [123.7, 123.6, 50.0],
462
+ [ 50.0, 91.8, 35.0],
463
+ ], 4));
464
+ }
465
+
466
+ function cookie6Path(n) {
467
+ return roundedPolygonPath(n, repeatedPattern([
468
+ [ 72.3, 88.4, 55.0],
469
+ [ 50.0, 109.9, 55.0],
470
+ ], 6));
471
+ }
472
+
473
+ function cookie7Path(n) { return roundedPolygonPath(n, starVertices( 7, 0.75, 0.5)); }
474
+ function cookie9Path(n) { return roundedPolygonPath(n, starVertices( 9, 0.8, 0.5)); }
475
+ function cookie12Path(n) { return roundedPolygonPath(n, starVertices(12, 0.8, 0.5)); }
476
+
477
+ function sunnyPath(n) { return roundedPolygonPath(n, starVertices(8, 0.8, 0.15, 0)); }
478
+
479
+ function verySunnyPath(n) {
480
+ return roundedPolygonPath(n, repeatedPattern([
481
+ [50.0, 108.0, 8.5],
482
+ [35.8, 84.3, 8.5],
483
+ ], 8));
484
+ }
485
+
486
+ function clover4Path(n) {
487
+ const hpi = Math.PI / 2;
488
+ const quarter = Math.PI / 4;
489
+ const D = 25, L = 25;
490
+ const arcLen = L * Math.PI;
491
+
492
+ const segments = [];
493
+ for (let i = 0; i < 4; i++) {
494
+ const theta = i * hpi - hpi + quarter;
495
+ const cxL = 50 + D * Math.cos(theta);
496
+ const cyL = 50 + D * Math.sin(theta);
497
+ segments.push({ len: arcLen, type: 'arc', cx: cxL, cy: cyL, r: L, a1: theta - hpi, a2: theta + hpi });
498
+ }
499
+ return buildPath(segments, n);
500
+ }
501
+
502
+ function clover8Path(n) {
503
+ const hpi = Math.PI / 2;
504
+ const section = Math.PI / 4;
505
+ const D = 35;
506
+ const L = D * Math.tan(Math.PI / 8);
507
+ const arcLen = L * Math.PI;
508
+
509
+ const segments = [];
510
+ for (let i = 0; i < 8; i++) {
511
+ const theta = i * section - hpi;
512
+ const cxL = 50 + D * Math.cos(theta);
513
+ const cyL = 50 + D * Math.sin(theta);
514
+ segments.push({ len: arcLen, type: 'arc', cx: cxL, cy: cyL, r: L, a1: theta - hpi, a2: theta + hpi });
515
+ }
516
+ return buildPath(segments, n);
517
+ }
518
+
519
+ function softBurstPath(n) {
520
+ return roundedPolygonPath(n, repeatedPattern([
521
+ [19.3, 27.7, 5.3],
522
+ [17.6, 5.5, 5.3],
523
+ ], 10));
524
+ }
525
+
526
+ function burstPath(n) {
527
+ return roundedPolygonPath(n, repeatedPattern([
528
+ [50.0, -0.6, 0.6],
529
+ [59.2, 15.8, 0.6],
530
+ ], 12));
531
+ }
532
+
533
+ function boomPath(n) {
534
+ return roundedPolygonPath(n, repeatedPattern([
535
+ [45.7, 29.6, 0.7],
536
+ [50.0, -5.1, 0.7],
537
+ ], 15));
538
+ }
539
+
540
+ function softBoomPath(n) {
541
+ return roundedPolygonPath(n, repeatedPattern([
542
+ [73.3, 45.4, 0.0, 0],
543
+ [83.9, 43.7, 53.2, 0],
544
+ [94.9, 44.9, 43.9, 1],
545
+ [99.8, 47.8, 17.4, 0],
546
+ ], 16, true));
547
+ }
548
+
549
+ function pentagonPath(n) {
550
+ return roundedPolygonPath(n, repeatedPattern([
551
+ [ 50.0, -0.9, 17.2],
552
+ [103.0, 36.5, 16.4],
553
+ [ 82.8, 97.0, 16.9],
554
+ ], 1, true));
555
+ }
556
+
557
+ function diamondPath(n) {
558
+ return roundedPolygonPath(n, repeatedPattern([
559
+ [50.0, 109.6, 15.1, 0.524],
560
+ [ 4.0, 50.0, 15.9, 0],
561
+ ], 2));
562
+ }
563
+
564
+ function puffyDiamondPath(n) {
565
+ const hpi = Math.PI / 2;
566
+ const D = 35, L = 35;
567
+ const arcLen = L * Math.PI;
568
+
569
+ const segments = [];
570
+ for (let i = 0; i < 4; i++) {
571
+ const theta = i * hpi - Math.PI / 4;
572
+ const cxL = 50 + D * Math.cos(theta);
573
+ const cyL = 50 + D * Math.sin(theta);
574
+ segments.push({ len: arcLen, type: 'arc', cx: cxL, cy: cyL, r: L, a1: theta - hpi, a2: theta + hpi });
575
+ }
576
+ return buildPath(segments, n);
577
+ }
578
+
579
+ function ghostIshPath(n) {
580
+ return roundedPolygonPath(n, repeatedPattern([
581
+ [ 50.0, 0.0, 100.0],
582
+ [100.0, 0.0, 100.0],
583
+ [100.0, 114.0, 25.4],
584
+ [ 57.5, 90.6, 25.3],
585
+ ], 1, true));
586
+ }
587
+
588
+ function bunPath(n) {
589
+ return roundedPolygonPath(n, repeatedPattern([
590
+ [79.6, 50.0, 0.0],
591
+ [85.3, 51.8, 100.0],
592
+ [99.2, 63.1, 100.0],
593
+ [96.8, 100.0, 100.0],
594
+ ], 2, true));
595
+ }
596
+
597
+ function clamshellPath(n) {
598
+ return roundedPolygonPath(n, repeatedPattern([
599
+ [ 17.1, 84.1, 15.9],
600
+ [ -2.0, 50.0, 14.0],
601
+ [ 17.0, 15.9, 15.9],
602
+ ], 2));
603
+ }
604
+
605
+ function gemPath(n) {
606
+ return roundedPolygonPath(n, repeatedPattern([
607
+ [49.9, 102.3, 24.1, 0.778],
608
+ [-0.5, 79.2, 20.8, 0],
609
+ [ 7.3, 25.8, 22.8, 0],
610
+ [43.3, 0.0, 49.1, 0],
611
+ ], 1, true));
612
+ }
613
+
614
+ function pillPath(n) {
615
+ return roundedPolygonPath(n, [
616
+ [90, 75, 50],
617
+ [10, 75, 50],
618
+ [10, 25, 50],
619
+ [90, 25, 50],
620
+ ]);
621
+ }
622
+
623
+ function slantedPath(n) {
624
+ return roundedPolygonPath(n, repeatedPattern([
625
+ [92.6, 97.0, 18.9, 0.811],
626
+ [-2.1, 96.7, 18.7, 0.057],
627
+ ], 2));
628
+ }
629
+
630
+ function flowerPath(n) {
631
+ return roundedPolygonPath(n, repeatedPattern([
632
+ [37.0, 18.7, 0.0],
633
+ [41.6, 4.9, 38.1],
634
+ [47.9, 0.1, 9.5],
635
+ ], 8, true));
636
+ }
637
+
638
+ function arrowPath(n) {
639
+ return roundedPolygonPath(n, repeatedPattern([
640
+ [ 50.0, 89.2, 31.3, 0],
641
+ [-21.6, 105.0, 20.7, 0],
642
+ [ 49.9, -16.0, 21.5, 1],
643
+ [122.5, 106.0, 21.1, 0],
644
+ ], 1));
645
+ }
646
+
647
+ function fanPath(n) {
648
+ return roundedPolygonPath(n, repeatedPattern([
649
+ [100.4, 100.0, 14.8, 0.417],
650
+ [ 0.0, 100.0, 15.1, 0],
651
+ [ 0.0, -0.3, 14.8, 0],
652
+ [ 97.8, 2.0, 80.3, 0],
653
+ ], 1));
654
+ }
655
+
656
+ function squarePath(n) {
657
+ return roundedPolygonPath(n, [
658
+ [100, 100, 30], [ 0, 100, 30], [ 0, 0, 30], [100, 0, 30],
659
+ ]);
660
+ }
661
+
662
+ function trianglePath(n) {
663
+ return roundedPolygonPath(n, verticesOnCircle(3, [0.2, 0.2, 0.2], -90));
664
+ }
665
+
666
+ function archPath(n) {
667
+ return roundedPolygonPath(n, verticesOnCircle(4, [1, 1, 0.2, 0.2], -135));
668
+ }
669
+
670
+ function ovalPath(n) {
671
+ const a = 50;
672
+ const b = 50 * 0.64;
673
+ const cos45 = Math.sqrt(2) / 2;
674
+ const sin45 = -Math.sqrt(2) / 2;
675
+ const points = [];
676
+ for (let i = 0; i < n; i++) {
677
+ const angle = 2 * Math.PI * i / n - Math.PI / 2;
678
+ const x = a * Math.cos(angle);
679
+ const y = b * Math.sin(angle);
680
+ const rx = x * cos45 - y * sin45;
681
+ const ry = x * sin45 + y * cos45;
682
+ points.push([50 + rx, 50 + ry]);
683
+ }
684
+ return emitAlignedPath(points, n);
685
+ }
686
+
687
+ function pixelCirclePath(n) {
688
+ return roundedPolygonPath(n, repeatedPattern([
689
+ [ 50.0, 0.0, 0],
690
+ [ 70.4, 0.0, 0],
691
+ [ 70.4, 6.5, 0],
692
+ [ 84.3, 6.5, 0],
693
+ [ 84.3, 14.8, 0],
694
+ [ 92.6, 14.8, 0],
695
+ [ 92.6, 29.6, 0],
696
+ [100.0, 29.6, 0],
697
+ ], 2, true));
698
+ }
699
+
700
+ function pixelTrianglePath(n) {
701
+ return roundedPolygonPath(n, repeatedPattern([
702
+ [ 11.0, 50.0, 0],
703
+ [ 11.3, 0.0, 0],
704
+ [ 28.7, 0.0, 0],
705
+ [ 28.7, 8.7, 0],
706
+ [ 42.1, 8.7, 0],
707
+ [ 42.1, 17.0, 0],
708
+ [ 56.0, 17.0, 0],
709
+ [ 56.0, 26.5, 0],
710
+ [ 67.4, 26.5, 0],
711
+ [ 67.5, 34.4, 0],
712
+ [ 78.9, 34.4, 0],
713
+ [ 78.9, 43.9, 0],
714
+ [ 88.8, 43.9, 0],
715
+ ], 1, true));
716
+ }
717
+
718
+ function puffyPath(n) {
719
+ const puffs = 6;
720
+ const hpi = Math.PI / 2;
721
+ const section = 2 * Math.PI / puffs;
722
+ const tanH = Math.tan(Math.PI / puffs);
723
+ const D = 50 / (1 + tanH);
724
+ const L = D * tanH;
725
+ const arcLen = L * Math.PI;
726
+
727
+ const segments = [];
728
+ for (let i = 0; i < puffs; i++) {
729
+ const theta = i * section - hpi;
730
+ const cxL = 50 + D * Math.cos(theta);
731
+ const cyL = 50 + D * Math.sin(theta);
732
+ segments.push({ len: arcLen, type: 'arc', cx: cxL, cy: cyL, r: L, a1: theta - hpi, a2: theta + hpi });
733
+ }
734
+
735
+ const total = segmentsLength(segments);
736
+ const points = [];
737
+ for (let i = 0; i < n; i++) {
738
+ const [x, y] = pointAt(segments, total, i / n);
739
+ points.push([x, 50 + (y - 50) * 0.742]);
740
+ }
741
+ return emitAlignedPath(points, n);
742
+ }
743
+
744
+ function semicirclePath(n) {
745
+ const hpi = Math.PI / 2;
746
+ const r = 16;
747
+ const bot = 75 + r / 2;
748
+ const cy = bot - r;
749
+ const R = 50;
750
+ const halfArc = hpi * R;
751
+ const arcFillet = hpi * r;
752
+ const flat = 100 - 2 * r;
753
+
754
+ const segments = [
755
+ { len: halfArc, type: 'arc', cx: 50, cy, r: R, a1: -hpi, a2: 0 },
756
+ { len: arcFillet, type: 'arc', cx: 100 - r, cy, r, a1: 0, a2: hpi },
757
+ { len: flat, type: 'line', x1: 100 - r, y1: bot, x2: r, y2: bot },
758
+ { len: arcFillet, type: 'arc', cx: r, cy, r, a1: hpi, a2: Math.PI },
759
+ { len: halfArc, type: 'arc', cx: 50, cy, r: R, a1: Math.PI, a2: Math.PI + hpi },
760
+ ];
761
+ return buildPath(segments, n);
762
+ }
763
+
764
+ // --- entry point ----------------------------------------------------------
765
+
766
+ const SHAPE_GENERATORS = {
767
+ 'circle': circlePath,
768
+ 'square': squarePath,
769
+ 'slanted': slantedPath,
770
+ 'arch': archPath,
771
+ 'semicircle': semicirclePath,
772
+ 'oval': ovalPath,
773
+ 'pill': pillPath,
774
+ 'triangle': trianglePath,
775
+ 'arrow': arrowPath,
776
+ 'fan': fanPath,
777
+ 'diamond': diamondPath,
778
+ 'clamshell': clamshellPath,
779
+ 'pentagon': pentagonPath,
780
+ 'gem': gemPath,
781
+ 'very-sunny': verySunnyPath,
782
+ 'sunny': sunnyPath,
783
+ 'cookie-4': cookie4Path,
784
+ 'cookie-6': cookie6Path,
785
+ 'cookie-7': cookie7Path,
786
+ 'cookie-9': cookie9Path,
787
+ 'cookie-12': cookie12Path,
788
+ 'clover-4': clover4Path,
789
+ 'clover-8': clover8Path,
790
+ 'burst': burstPath,
791
+ 'soft-burst': softBurstPath,
792
+ 'boom': boomPath,
793
+ 'soft-boom': softBoomPath,
794
+ 'flower': flowerPath,
795
+ 'puffy': puffyPath,
796
+ 'puffy-diamond': puffyDiamondPath,
797
+ 'ghost-ish': ghostIshPath,
798
+ 'pixel-circle': pixelCirclePath,
799
+ 'pixel-triangle': pixelTrianglePath,
800
+ 'bun': bunPath,
801
+ 'heart': heartPath,
802
+ };
803
+
804
+ const DEFAULT_VERTEX_COUNT = 128;
805
+
806
+ export function generatePathsScss(vertexCount = DEFAULT_VERTEX_COUNT) {
807
+ const longest = Math.max(...Object.keys(SHAPE_GENERATORS).map(s => s.length));
808
+ const lines = [
809
+ '//',
810
+ '// GENERATED by tools/shapes/generate.mjs — do not edit by hand.',
811
+ '// Regenerate with `npm run gen:shapes` after changing the generator',
812
+ '// or any shape parameter. CI fails (`npm run check:shapes`) if this',
813
+ '// file is out of date.',
814
+ '//',
815
+ '',
816
+ '$paths: (',
817
+ ];
818
+ for (const [name, fn] of Object.entries(SHAPE_GENERATORS)) {
819
+ const d = fn(vertexCount);
820
+ const padding = ' '.repeat(longest - name.length);
821
+ lines.push(` ${name}:${padding} '${d}',`);
822
+ }
823
+ lines.push(');', '');
824
+ return lines.join('\n');
825
+ }
826
+
827
+ export const TARGET_PATH = targetPath;
828
+
829
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
830
+ if (isMain) {
831
+ const out = generatePathsScss();
832
+ fs.writeFileSync(targetPath, out);
833
+ console.log(`Wrote ${Object.keys(SHAPE_GENERATORS).length} shapes to ${path.relative(process.cwd(), targetPath)} (${out.length} bytes).`);
834
+ }