morphico 0.2.0 → 0.4.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/dist/morphico.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! morphico v0.2.0 | code: MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE)
1
+ /*! morphico v0.4.0 | code: MIT License, Copyright (c) 2026 Raycho Ivanov (see LICENSE)
2
2
  * Icons based on Lucide (https://lucide.dev), some of them from Feather (https://feathericons.com):
3
3
  *
4
4
  * ISC License
@@ -3017,7 +3017,7 @@ return { iconSets, resolveIconSet };
3017
3017
  })();
3018
3018
 
3019
3019
  // morph.ts
3020
- const { EMPTY_STATE, hasState, createMorphingPath } = (() => {
3020
+ const { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion, clampToUnit, createMorphingPath } = (() => {
3021
3021
  // One SVG <path> that morphs between the states of an icon set. Shared by icon() and every text() cell.
3022
3022
  /** The empty state: nothing is drawn. Shapes grow out of it and shrink into it while fading. */
3023
3023
  const EMPTY_STATE = ' ';
@@ -3122,7 +3122,425 @@ const createMorphingPath = (pathElement, iconSet, initialState) => {
3122
3122
  stop: () => finish(true),
3123
3123
  };
3124
3124
  };
3125
- return { EMPTY_STATE, hasState, createMorphingPath };
3125
+ return { EMPTY_STATE, DEFAULT_DURATION, shapesOf, hasState, prefersReducedMotion, clampToUnit, createMorphingPath };
3126
+ })();
3127
+
3128
+ // sets/shapes.ts
3129
+ const { lucideShapeOutlines, clipShapes } = (() => {
3130
+ // Shapes for clip(): closed outlines that cut out an element. From Lucide 1.61.0 (ISC, see LICENSE-lucide), imported
3131
+ // by `node tools/import-lucide.ts <name>`; tag without its dot, club without its stem. The states are the Lucide outlines made comparable for
3132
+ // morphing: clockwise, starting where the outline crosses the vertical middle at the top, scaled evenly to fill the
3133
+ // 24×24 box and centred (computed on first use, not at import). Roundness 1 = Lucide's own corners … 0 = sharp.
3134
+ const DEGREE = Math.PI / 180;
3135
+ const GRID_SIZE = 24;
3136
+ const GRID_CENTER = 12;
3137
+ const SAMPLES_PER_STROKE = 48;
3138
+ /** Enough for the shape that needs the most (puzzle: 24 strokes, its knobs ≈ 40 segments); the rest spreads by length. */
3139
+ const SEGMENT_COUNT = 48;
3140
+ /** The shapes as Lucide draws them (Lucide coordinates); the set's states are made from these. */
3141
+ const lucideShapeOutlines = {
3142
+ circle: [arc(12, 12, 10, -90, 270)],
3143
+ ellipse: ellipse(12, 12, 10, 6),
3144
+ heart: [
3145
+ arc(7.5, 9.5001, 5.5, -179.9989, -41.9423),
3146
+ arc(12, 5.4415, 0.56, 136.9163, 43.0837),
3147
+ arc(16.5101, 9.4738, 5.49, -138.3317, 0.2729),
3148
+ cubic(22, 9.5, 22, 11.79, 20.5, 13.5, 19, 15),
3149
+ line(19, 15, 13.508, 20.313),
3150
+ arc(11.9996, 18.9997, 2, 41.0455, 138.2288),
3151
+ line(10.508, 20.332, 5, 15),
3152
+ cubic(5, 15, 3.5, 13.5, 2, 11.8, 2, 9.5),
3153
+ ],
3154
+ sparkle: [
3155
+ arc(12, 2.9976, 1, -169.4202, -10.5798),
3156
+ line(12.983, 2.814, 14.034, 8.372),
3157
+ arc(15.9992, 8.0008, 2, 169.3026, 100.6974),
3158
+ line(15.628, 9.966, 21.186, 11.017),
3159
+ arc(21.0024, 12, 1, -79.4202, 79.4202),
3160
+ line(21.186, 12.983, 15.628, 14.034),
3161
+ arc(15.9992, 15.9992, 2, -100.6974, -169.3026),
3162
+ line(14.034, 15.628, 12.983, 21.186),
3163
+ arc(12, 21.0024, 1, 10.5798, 169.4202),
3164
+ line(11.017, 21.186, 9.966, 15.628),
3165
+ arc(8.0008, 15.9992, 2, -10.6974, -79.3026),
3166
+ line(8.372, 14.034, 2.814, 12.983),
3167
+ arc(2.9976, 12, 1, 100.5798, 259.4202),
3168
+ line(2.814, 11.017, 8.372, 9.966),
3169
+ arc(8.0008, 8.0008, 2, 79.3026, 10.6974),
3170
+ line(9.966, 8.372, 11.017, 2.814),
3171
+ ],
3172
+ astroid: [
3173
+ arc(12, 21.0024, 1, 10.5798, 169.4202),
3174
+ arc(1.1555, 22.8445, 10, -9.5467, -80.4533),
3175
+ arc(2.9976, 12, 1, 100.5798, 259.4202),
3176
+ arc(1.1555, 1.1555, 10, 80.4533, 9.5467),
3177
+ arc(12, 2.9976, 1, -169.4202, -10.5798),
3178
+ arc(22.8445, 1.1555, 10, 170.4533, 99.5467),
3179
+ arc(21.0024, 12, 1, -79.4202, 79.4202),
3180
+ arc(22.8445, 22.8445, 10, -99.5467, -170.4533),
3181
+ ],
3182
+ bookmark: [
3183
+ arc(17, 5, 2, -90, 0),
3184
+ line(19, 5, 19, 20),
3185
+ arc(18, 19.9997, 1, 0.0185, 119.7357),
3186
+ line(17.504, 20.868, 12.992, 18.29),
3187
+ arc(12, 20.0266, 2, -60.2643, -119.7357),
3188
+ line(11.008, 18.29, 6.496, 20.868),
3189
+ arc(6, 19.9997, 1, 60.2643, 179.9815),
3190
+ line(5, 20, 5, 5),
3191
+ arc(7, 5, 2, 180, 270),
3192
+ line(7, 3, 17, 3),
3193
+ ],
3194
+ shield: [
3195
+ cubic(20, 13, 20, 18, 16.5, 20.5, 12.34, 21.95),
3196
+ arc(12.0191, 21.0029, 1, 71.2803, 110.4299),
3197
+ cubic(11.67, 21.94, 7.5, 20.5, 4, 18, 4, 13),
3198
+ line(4, 13, 4, 6),
3199
+ arc(5, 6, 1, 180, 270),
3200
+ cubic(5, 5, 7, 5, 9.5, 3.8, 11.24, 2.28),
3201
+ arc(12, 3.1696, 1.17, -130.5094, -49.4906),
3202
+ cubic(12.76, 2.28, 14.51, 3.81, 17, 5, 19, 5),
3203
+ arc(19, 6, 1, -90, 0),
3204
+ line(20, 6, 20, 13),
3205
+ ],
3206
+ moon: [
3207
+ arc(11.9981, 12.0009, 9, 3.09, 266.904),
3208
+ cubic(11.512, 3.014, 11.917, 2.992, 12.129, 3.474, 11.914, 3.817),
3209
+ arc(17.002, 6.997, 6, -147.9941, -302.0059),
3210
+ cubic(20.182, 12.085, 20.526, 11.87, 21.007, 12.081, 20.985, 12.486),
3211
+ ],
3212
+ tag: [
3213
+ arc(11.1716, 4, 2, -44.9913, -89.9878),
3214
+ line(11.172, 2, 4, 2),
3215
+ arc(4, 4, 2, -90, -180),
3216
+ line(2, 4, 2, 11.172),
3217
+ arc(4, 11.1716, 2, 179.9878, 134.9913),
3218
+ line(2.586, 12.586, 11.29, 21.29),
3219
+ arc(13, 19.5691, 2.426, 134.8186, 45.1814),
3220
+ line(14.71, 21.29, 21.29, 14.71),
3221
+ arc(19.5691, 13, 2.426, 44.8186, -44.8186),
3222
+ line(21.29, 11.29, 12.586, 2.586),
3223
+ ],
3224
+ square: [
3225
+ line(5, 3, 19, 3),
3226
+ arc(19, 5, 2, -90, 0),
3227
+ line(21, 5, 21, 19),
3228
+ arc(19, 19, 2, 0, 90),
3229
+ line(19, 21, 5, 21),
3230
+ arc(5, 19, 2, 90, 180),
3231
+ line(3, 19, 3, 5),
3232
+ arc(5, 5, 2, 180, 270),
3233
+ ],
3234
+ badge: [
3235
+ arc(7.7549, 7.7531, 4, 167.4834, 282.6366),
3236
+ arc(12, 6.0048, 4, -147.4051, -32.5949),
3237
+ arc(16.2474, 7.7526, 4, -102.6711, 12.6711),
3238
+ arc(17.9952, 12, 4, -57.4051, 57.4051),
3239
+ arc(16.2469, 16.2451, 4, -12.6366, 102.5166),
3240
+ arc(12.005, 18.0031, 4, 32.4617, 147.5383),
3241
+ arc(7.7549, 16.2469, 4, 77.3634, 192.5166),
3242
+ arc(5.9891, 12, 4, 122.3281, 237.6719),
3243
+ ],
3244
+ triangle: [
3245
+ arc(12, 5.0035, 2, -30.1173, -149.8827),
3246
+ line(10.27, 4, 2.27, 18),
3247
+ arc(4.0021, 19, 2, -150, -269.9413),
3248
+ line(4, 21, 20, 21),
3249
+ arc(19.9979, 19, 2, 89.9413, -30),
3250
+ line(21.73, 18, 13.73, 4),
3251
+ ],
3252
+ droplet: [
3253
+ arc(12, 15, 7, 90, 0),
3254
+ cubic(19, 15, 19, 13, 18, 11.1, 16, 9.5),
3255
+ cubic(16, 9.5, 14, 7.9, 12.5, 5.5, 12, 3),
3256
+ cubic(12, 3, 11.5, 5.5, 10, 7.9, 8, 9.5),
3257
+ cubic(8, 9.5, 6, 11.1, 5, 13, 5, 15),
3258
+ arc(12, 15, 7, 180, 90),
3259
+ ],
3260
+ pentagon: [
3261
+ arc(12, 4.0021, 2, -125.803, -54.197),
3262
+ line(13.17, 2.38, 21.17, 8.12),
3263
+ arc(20.0009, 9.7427, 2, -54.2293, 18.2786),
3264
+ line(21.9, 10.37, 18.86, 19.63),
3265
+ arc(16.9618, 19, 2, 18.3608, 90.052),
3266
+ line(16.96, 21, 7.04, 21),
3267
+ arc(7.0382, 19, 2, 89.948, 161.6392),
3268
+ line(5.14, 19.63, 2.1, 10.37),
3269
+ arc(3.9991, 9.7427, 2, 161.7214, 234.2293),
3270
+ line(2.83, 8.12, 10.83, 2.38),
3271
+ ],
3272
+ hexagon: [
3273
+ line(21, 16, 21, 8),
3274
+ arc(19, 8.0021, 2, -0.0588, -60),
3275
+ line(20, 6.27, 13, 2.27),
3276
+ arc(12, 4.0021, 2, -60, -120),
3277
+ line(11, 2.27, 4, 6.27),
3278
+ arc(5, 8.0021, 2, -120, -179.9412),
3279
+ line(3, 8, 3, 16),
3280
+ arc(5, 15.9979, 2, 179.9412, 120),
3281
+ line(4, 17.73, 11, 21.73),
3282
+ arc(12, 19.9979, 2, 120, 60),
3283
+ line(13, 21.73, 20, 17.73),
3284
+ arc(19, 15.9979, 2, 60, 0.0588),
3285
+ ],
3286
+ octagon: [
3287
+ arc(4, 15.3116, 2, 134.9913, 179.9878),
3288
+ line(2, 15.312, 2, 8.688),
3289
+ arc(4, 8.6884, 2, -179.9878, -134.9913),
3290
+ line(2.586, 7.274, 7.274, 2.586),
3291
+ arc(8.6884, 4, 2, -135.0087, -90.0122),
3292
+ line(8.688, 2, 15.312, 2),
3293
+ arc(15.3116, 4, 2, -89.9878, -44.9913),
3294
+ line(16.726, 2.586, 21.414, 7.274),
3295
+ arc(20, 8.6884, 2, -45.0087, -0.0122),
3296
+ line(22, 8.688, 22, 15.312),
3297
+ arc(20, 15.3116, 2, 0.0122, 45.0087),
3298
+ line(21.414, 16.726, 16.726, 21.414),
3299
+ arc(15.3116, 20, 2, 44.9913, 89.9878),
3300
+ line(15.312, 22, 8.688, 22),
3301
+ arc(8.6884, 20, 2, 90.0122, 135.0087),
3302
+ line(7.274, 21.414, 2.586, 16.726),
3303
+ ],
3304
+ squircle: [
3305
+ cubic(12, 3, 19.2, 3, 21, 4.8, 21, 12),
3306
+ cubic(21, 12, 21, 19.2, 19.2, 21, 12, 21),
3307
+ cubic(12, 21, 4.8, 21, 3, 19.2, 3, 12),
3308
+ cubic(3, 12, 3, 4.8, 4.8, 3, 12, 3),
3309
+ ],
3310
+ star: [
3311
+ arc(12, 2.5301, 0.53, -153.6664, -26.3336),
3312
+ line(12.475, 2.295, 14.785, 6.974),
3313
+ arc(16.6883, 6.0335, 2.123, 153.7044, 98.3503),
3314
+ line(16.38, 8.134, 21.546, 8.89),
3315
+ arc(21.47, 9.4145, 0.53, -81.756, 45.7247),
3316
+ line(21.84, 9.794, 18.104, 13.432),
3317
+ arc(19.5857, 14.9525, 2.123, -134.26, -189.6958),
3318
+ line(17.493, 15.31, 18.375, 20.45),
3319
+ arc(17.8531, 20.5422, 0.53, -10.0155, 118.0314),
3320
+ line(17.604, 21.01, 12.986, 18.582),
3321
+ arc(11.9995, 20.4608, 2.122, -62.2968, -117.7032),
3322
+ line(11.013, 18.582, 6.396, 21.01),
3323
+ arc(6.148, 20.5416, 0.53, 62.1036, 189.9512),
3324
+ line(5.626, 20.45, 6.507, 15.311),
3325
+ arc(4.4155, 14.9522, 2.122, 9.7335, -45.7597),
3326
+ line(5.896, 13.432, 2.16, 9.795),
3327
+ arc(2.5282, 9.4138, 0.53, 134.0047, 261.9521),
3328
+ line(2.454, 8.889, 7.619, 8.134),
3329
+ arc(7.3135, 6.0341, 2.122, 81.7227, 26.2909),
3330
+ line(9.216, 6.974, 11.525, 2.295),
3331
+ ],
3332
+ 'rectangle-horizontal': [
3333
+ line(4, 6, 20, 6),
3334
+ arc(20, 8, 2, -90, 0),
3335
+ line(22, 8, 22, 16),
3336
+ arc(20, 16, 2, 0, 90),
3337
+ line(20, 18, 4, 18),
3338
+ arc(4, 16, 2, 90, 180),
3339
+ line(2, 16, 2, 8),
3340
+ arc(4, 8, 2, 180, 270),
3341
+ ],
3342
+ 'message-circle': [
3343
+ arc(1.1328, 17.079, 2, -21.6245, 12.4142),
3344
+ line(3.086, 17.509, 2.021, 20.799),
3345
+ arc(3.0005, 21.0005, 1, -168.3779, -284.8626),
3346
+ line(3.257, 21.967, 6.67, 20.969),
3347
+ arc(7.0591, 22.9308, 2, -101.2192, -69.2104),
3348
+ arc(12.0003, 12.0003, 10, 115.0325, -205.7324),
3349
+ ],
3350
+ 'message-square': [
3351
+ arc(20, 17, 2, 0, 90),
3352
+ line(20, 19, 6.828, 19),
3353
+ arc(6.8284, 21, 2, -90.0122, -135.0087),
3354
+ line(5.414, 19.586, 3.212, 21.788),
3355
+ arc(2.71, 21.2859, 0.71, 45.0052, 179.9926),
3356
+ line(2, 21.286, 2, 5),
3357
+ arc(4, 5, 2, 180, 270),
3358
+ line(4, 3, 20, 3),
3359
+ arc(20, 5, 2, -90, 0),
3360
+ line(22, 5, 22, 17),
3361
+ ],
3362
+ cloud: [
3363
+ line(17.5, 19, 9, 19),
3364
+ arc(9.0018, 12, 7, 90.0147, 343.3984),
3365
+ line(15.71, 10, 17.5, 10),
3366
+ arc(17.5, 14.5, 4.5, -90, 90),
3367
+ ],
3368
+ puzzle: [
3369
+ arc(16.0975, 3.6833, 1, 135.0289, 13.459),
3370
+ arc(19.5008, 4.5, 2.5, -166.4917, 76.5107),
3371
+ arc(20.3167, 7.9035, 1, -103.459, -225.0289),
3372
+ line(19.61, 8.611, 21.293, 10.293),
3373
+ arc(19.5861, 12, 2.414, -45.0015, 45.0015),
3374
+ line(21.293, 13.707, 19.61, 15.39),
3375
+ arc(18.9025, 14.6833, 1, 44.9711, 166.541),
3376
+ arc(15.4992, 15.5, 2.5, -13.5083, -256.5107),
3377
+ arc(14.6833, 18.9035, 1, -76.541, 45.0289),
3378
+ line(15.39, 19.611, 13.707, 21.293),
3379
+ arc(12, 19.5861, 2.414, 44.9985, 135.0015),
3380
+ line(10.293, 21.293, 8.61, 19.61),
3381
+ arc(7.9025, 20.3167, 1, -44.9711, -166.541),
3382
+ arc(4.4992, 19.5, 2.5, 13.5083, 256.5107),
3383
+ arc(3.6833, 16.0965, 1, 76.541, -45.0289),
3384
+ line(4.39, 15.389, 2.707, 13.707),
3385
+ arc(4.4139, 12, 2.414, 134.9985, 225.0015),
3386
+ line(2.707, 10.293, 4.39, 8.61),
3387
+ arc(5.0975, 9.3167, 1, -135.0289, -13.459),
3388
+ arc(8.5008, 8.5, 2.5, 166.4917, -76.5107),
3389
+ arc(9.3167, 5.0965, 1, 103.459, 225.0289),
3390
+ line(8.61, 4.389, 10.293, 2.707),
3391
+ arc(12, 4.4139, 2.414, -135.0015, -44.9985),
3392
+ line(13.707, 2.707, 15.39, 4.39),
3393
+ ],
3394
+ // without its stem line (Lucide's second piece)
3395
+ club: [
3396
+ arc(12, 7.51, 5.5, 16.2602, -196.2602),
3397
+ arc(7.5026, 14.494, 5.5, -98.1803, -324.8563),
3398
+ arc(16.5052, 14.5052, 5.5, 144.9978, -81.9018),
3399
+ line(17.28, 9.06, 17.28, 9.05),
3400
+ ],
3401
+ };
3402
+ // ---- making the outlines comparable ----
3403
+ const pointOnStroke = (stroke, progress) => strokePiece(stroke, 0, progress)[3];
3404
+ const samplesOf = (strokes) => strokes.flatMap((stroke, strokeIndex) => Array.from({ length: SAMPLES_PER_STROKE }, (_, sample) => {
3405
+ const progress = sample / SAMPLES_PER_STROKE;
3406
+ return { point: pointOnStroke(stroke, progress), strokeIndex, progress };
3407
+ }));
3408
+ const transformStroke = (stroke, scale, offsetX, offsetY) => {
3409
+ const move = ([x, y]) => [x * scale + offsetX, y * scale + offsetY];
3410
+ switch (stroke.kind) {
3411
+ case 'line': return { ...stroke, from: move(stroke.from), to: move(stroke.to) };
3412
+ case 'arc': return { ...stroke, center: move(stroke.center), radius: stroke.radius * scale };
3413
+ case 'cubic': return { kind: 'cubic', segment: stroke.segment.map(move) };
3414
+ }
3415
+ };
3416
+ /** Splits a stroke at `progress` into two strokes of the same kind (arcs stay arcs, so they keep their segment rule). */
3417
+ const splitStroke = (stroke, progress) => {
3418
+ if (stroke.kind === 'arc') {
3419
+ const splitAngle = stroke.startAngle + (stroke.endAngle - stroke.startAngle) * progress;
3420
+ return [{ ...stroke, endAngle: splitAngle }, { ...stroke, startAngle: splitAngle }];
3421
+ }
3422
+ if (stroke.kind === 'line') {
3423
+ const splitPoint = pointOnStroke(stroke, progress);
3424
+ return [{ ...stroke, to: splitPoint }, { ...stroke, from: splitPoint }];
3425
+ }
3426
+ return [{ kind: 'cubic', segment: strokePiece(stroke, 0, progress) }, { kind: 'cubic', segment: strokePiece(stroke, progress, 1) }];
3427
+ };
3428
+ const fitToBox = (strokes) => {
3429
+ const points = samplesOf(strokes).map(({ point }) => point);
3430
+ const xValues = points.map(([x]) => x), yValues = points.map(([, y]) => y);
3431
+ const minX = Math.min(...xValues), maxX = Math.max(...xValues), minY = Math.min(...yValues), maxY = Math.max(...yValues);
3432
+ const scale = GRID_SIZE / Math.max(maxX - minX, maxY - minY);
3433
+ return strokes.map((stroke) => transformStroke(stroke, scale, GRID_CENTER - ((minX + maxX) / 2) * scale, GRID_CENTER - ((minY + maxY) / 2) * scale));
3434
+ };
3435
+ /** Positive = clockwise on screen (y points down). */
3436
+ const signedArea = (strokes) => {
3437
+ const points = samplesOf(strokes).map(({ point }) => point);
3438
+ return points.reduce((sum, [x, y], index) => {
3439
+ const [nextX, nextY] = points[(index + 1) % points.length];
3440
+ return sum + x * nextY - nextX * y;
3441
+ }, 0);
3442
+ };
3443
+ const clockwise = (strokes) => (signedArea(strokes) >= 0 ? strokes : strokes.slice().reverse().map(reversedStroke));
3444
+ /** Starts the closed outline where it crosses x = 12 highest up (splitting that stroke). */
3445
+ const startAtTopMiddle = (strokes) => {
3446
+ const samples = samplesOf(strokes);
3447
+ let best = null;
3448
+ for (let index = 0; index < samples.length; index++) {
3449
+ const sample = samples[index], next = samples[(index + 1) % samples.length];
3450
+ const [x, y] = sample.point, [nextX, nextY] = next.point;
3451
+ if ((x - GRID_CENTER) * (nextX - GRID_CENTER) > 0 || x === nextX)
3452
+ continue;
3453
+ const fraction = (GRID_CENTER - x) / (nextX - x);
3454
+ const crossingY = y + (nextY - y) * fraction;
3455
+ if (best && crossingY >= best.y)
3456
+ continue;
3457
+ const nextProgress = next.strokeIndex === sample.strokeIndex ? next.progress : 1;
3458
+ best = { strokeIndex: sample.strokeIndex, progress: sample.progress + (nextProgress - sample.progress) * fraction, y: crossingY };
3459
+ }
3460
+ if (!best)
3461
+ return strokes;
3462
+ const { strokeIndex, progress } = best;
3463
+ const before = strokes.slice(0, strokeIndex), after = strokes.slice(strokeIndex + 1);
3464
+ if (progress < 1e-6)
3465
+ return [...strokes.slice(strokeIndex), ...before];
3466
+ if (progress > 1 - 1e-6)
3467
+ return [...after, ...before, strokes[strokeIndex]];
3468
+ const [firstPart, secondPart] = splitStroke(strokes[strokeIndex], progress);
3469
+ return [secondPart, ...after, ...before, firstPart];
3470
+ };
3471
+ // ---- roundness: 1 = Lucide's own corners, 0 = sharp ----
3472
+ /** Lucide's corner arcs are small (radius 0.56 … 2.43); bigger arcs are part of the shape (circle, moon, droplet). */
3473
+ const LARGEST_CORNER_RADIUS = 2.5;
3474
+ const crossingOfLines = (point, direction, otherPoint, otherDirection) => {
3475
+ const determinant = direction[0] * otherDirection[1] - direction[1] * otherDirection[0];
3476
+ if (Math.abs(determinant) < 1e-9)
3477
+ return null;
3478
+ const along = ((otherPoint[0] - point[0]) * otherDirection[1] - (otherPoint[1] - point[1]) * otherDirection[0]) / determinant;
3479
+ return [point[0] + direction[0] * along, point[1] + direction[1] * along];
3480
+ };
3481
+ /** Moves a stroke's start (or end) to `point`; a cubic's neighbouring control point moves along, so its direction there stays. */
3482
+ const withMovedEnd = (stroke, which, point) => {
3483
+ if (stroke.kind === 'line')
3484
+ return which === 'start' ? { ...stroke, from: point } : { ...stroke, to: point };
3485
+ if (stroke.kind !== 'cubic')
3486
+ return stroke;
3487
+ const [start, control1, control2, end] = stroke.segment;
3488
+ if (which === 'start') {
3489
+ const shift = [point[0] - start[0], point[1] - start[1]];
3490
+ return { kind: 'cubic', segment: [point, [control1[0] + shift[0], control1[1] + shift[1]], control2, end] };
3491
+ }
3492
+ const shift = [point[0] - end[0], point[1] - end[1]];
3493
+ return { kind: 'cubic', segment: [start, control1, [control2[0] + shift[0], control2[1] + shift[1]], point] };
3494
+ };
3495
+ /**
3496
+ * Every corner arc (small, between two lines / cubics) shrinks towards the sharp corner where its two neighbours'
3497
+ * directions meet: radius × roundness, angles unchanged. At 0 the arc is a point on the corner. Same strokes in the
3498
+ * same order at any roundness, so morphs keep working.
3499
+ */
3500
+ const withCornerRoundness = (strokes, roundness) => {
3501
+ const result = strokes.slice();
3502
+ strokes.forEach((stroke, index) => {
3503
+ const previousIndex = (index - 1 + strokes.length) % strokes.length, nextIndex = (index + 1) % strokes.length;
3504
+ const isEdge = (neighbour) => neighbour.kind === 'line' || neighbour.kind === 'cubic';
3505
+ if (stroke.kind !== 'arc' || stroke.radius > LARGEST_CORNER_RADIUS)
3506
+ return;
3507
+ if (!isEdge(strokes[previousIndex]) || !isEdge(strokes[nextIndex]))
3508
+ return;
3509
+ const turning = Math.sign(stroke.endAngle - stroke.startAngle);
3510
+ const tangent = (angle) => [-Math.sin(angle * DEGREE) * turning, Math.cos(angle * DEGREE) * turning];
3511
+ const start = pointOnStroke(stroke, 0), end = pointOnStroke(stroke, 1);
3512
+ const corner = crossingOfLines(start, tangent(stroke.startAngle), end, tangent(stroke.endAngle));
3513
+ if (!corner)
3514
+ return;
3515
+ const towardsCorner = ([x, y]) => [corner[0] + (x - corner[0]) * roundness, corner[1] + (y - corner[1]) * roundness];
3516
+ result[index] = { ...stroke, center: towardsCorner(stroke.center), radius: stroke.radius * roundness };
3517
+ result[previousIndex] = withMovedEnd(result[previousIndex], 'end', towardsCorner(start));
3518
+ result[nextIndex] = withMovedEnd(result[nextIndex], 'start', towardsCorner(end));
3519
+ });
3520
+ return result;
3521
+ };
3522
+ /** The shape set at a roundness (0 = sharp … 1 = Lucide's own corners); its strokes are built on first use. */
3523
+ const shapeSetWithRoundness = (roundness) => {
3524
+ let strokes = null;
3525
+ return {
3526
+ get strokes() {
3527
+ strokes ??= Object.fromEntries(Object.entries(lucideShapeOutlines).map(([name, outline]) => [name, startAtTopMiddle(clockwise(fitToBox(withCornerRoundness(outline, roundness))))]));
3528
+ return strokes;
3529
+ },
3530
+ alternativeStrokes: {},
3531
+ pairLayouts: {},
3532
+ spins: {},
3533
+ segmentCount: SEGMENT_COUNT,
3534
+ // shapes are filled (clip), not stroked; these only matter for the outlined band's corners
3535
+ strokeWidth: 0,
3536
+ lineCap: 'butt',
3537
+ lineJoin: 'miter',
3538
+ withRoundness: shapeSetWithRoundness,
3539
+ };
3540
+ };
3541
+ /** The shape set of clip(): states `circle`, `ellipse`, `heart` … `puzzle` (see `lucideShapeOutlines`). */
3542
+ const clipShapes = shapeSetWithRoundness(1);
3543
+ return { lucideShapeOutlines, clipShapes };
3126
3544
  })();
3127
3545
 
3128
3546
  // svg.ts
@@ -3160,6 +3578,933 @@ const createStrokedElement = (tagName, style, attributes = {}) => createSvgEleme
3160
3578
  return { hasFixedStrokeWidth, createSvgElement, createIconSvg, createStrokedElement };
3161
3579
  })();
3162
3580
 
3581
+ // clip.ts
3582
+ const { clip } = (() => {
3583
+ // Layer 3: an element cut out by a morphing shape (a star, a heart, a speech bubble …), e.g. a photo or a card.
3584
+ // The shape is square (1:1), placed by its centre (x, y) and size in % or px of the element, can be turned and
3585
+ // outlined (only a band along its edge shows). Filled → CSS `clip-path: url(#id)`; outlined → `mask: url(#id)`
3586
+ // (clip-path ignores line width). The <clipPath> / <mask> live in one hidden <svg> per clip.
3587
+ const GRID_SIZE = 24;
3588
+ const GRID_CENTER = 12;
3589
+ const DEGREE = Math.PI / 180;
3590
+ /** Masks and their clip regions reach far past the element, so bands and turned shapes are never cut by them. */
3591
+ const FAR = 100000;
3592
+ /** In objectBoundingBox units (1 = the element's width / height): far around the element. */
3593
+ const INVERTED_SURROUNDING = 'M-1000 -1000H1001V1001H-1000Z';
3594
+ let clipCount = 0;
3595
+ const lengthToPixels = (length, reference, name) => {
3596
+ if (typeof length === 'number' && Number.isFinite(length))
3597
+ return (length / 100) * reference;
3598
+ const match = typeof length === 'string' ? /^\s*(-?[\d.]+(?:e[-+]?\d+)?)\s*(px|%)\s*$/i.exec(length) : null;
3599
+ if (!match)
3600
+ throw new Error(`morphico: ${name} "${length}" is not a length — use a number (= %), '40%' or '120px'`);
3601
+ const value = Number(match[1]);
3602
+ return match[2] === 'px' ? value : (value / 100) * reference;
3603
+ };
3604
+ const checkedOutline = (outline) => {
3605
+ if (!outline)
3606
+ return false;
3607
+ if (!(outline.width > 0))
3608
+ throw new Error('morphico: outline.width must be a number of px above 0');
3609
+ if (outline.band && !['inside', 'centre', 'outside'].includes(outline.band)) {
3610
+ throw new Error(`morphico: outline.band "${outline.band}" — use 'inside', 'centre' or 'outside'`);
3611
+ }
3612
+ return outline;
3613
+ };
3614
+ /** Cuts `element` out by a morphing shape. */
3615
+ const clip = (element, options = {}) => {
3616
+ const { set = clipShapes, state: initialState = 'circle', x = 50, y = 50, size, rotation = 0, roundness = 1, outline = false, inverted = false, ...defaultMorphOptions } = options;
3617
+ const checkedState = (shapeSet, state) => {
3618
+ if (!(state in shapeSet.strokes)) {
3619
+ throw new Error(`morphico: unknown shape "${state}" (known: ${Object.keys(shapeSet.strokes).join(', ')})`);
3620
+ }
3621
+ return state;
3622
+ };
3623
+ for (const [name, length] of [['x', x], ['y', y], ['size', size]]) {
3624
+ if (length !== undefined)
3625
+ lengthToPixels(length, 1, name);
3626
+ }
3627
+ /** Where the change goes (or what shows when nothing moves). */
3628
+ const target = {
3629
+ state: checkedState(set, initialState), set: resolveIconSet(set, roundness), roundness,
3630
+ x, y, size, rotation, outline: checkedOutline(outline), inverted,
3631
+ };
3632
+ const pieceLength = pieceLengthOf(set);
3633
+ let frame = null; // null = resting at the target
3634
+ let running = null;
3635
+ let elementWidth = 0, elementHeight = 0;
3636
+ // ---- the hidden <svg> with the <clipPath> (filled) and the <mask> (outlined) ----
3637
+ const id = `morphico-clip-${++clipCount}`;
3638
+ const svg = createSvgElement('svg', { width: 0, height: 0, 'aria-hidden': 'true', focusable: 'false' });
3639
+ svg.style.position = 'absolute';
3640
+ svg.style.pointerEvents = 'none';
3641
+ const clipPathElement = svg.appendChild(createSvgElement('clipPath', { id: `${id}-shape`, clipPathUnits: 'objectBoundingBox' }));
3642
+ const clipShape = clipPathElement.appendChild(createSvgElement('path'));
3643
+ const farRegion = { maskUnits: 'userSpaceOnUse', x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR };
3644
+ const maskElement = svg.appendChild(createSvgElement('mask', { id: `${id}-band`, ...farRegion }));
3645
+ /** Every path drawn in element pixels for the outlined band (the band itself and its helpers). */
3646
+ let bandPaths = [];
3647
+ const buildBand = () => {
3648
+ for (const helper of svg.querySelectorAll('.morphico-band-helper'))
3649
+ helper.remove();
3650
+ maskElement.replaceChildren();
3651
+ bandPaths = [];
3652
+ clipShape.setAttribute('clip-rule', target.inverted ? 'evenodd' : 'nonzero');
3653
+ if (!target.outline)
3654
+ return;
3655
+ const { width, band = 'inside' } = target.outline;
3656
+ // inverted: everything shows (white), the band is the hole (black)
3657
+ const bandColor = target.inverted ? 'black' : 'white';
3658
+ if (target.inverted) {
3659
+ maskElement.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }));
3660
+ }
3661
+ const pixelPath = (attributes) => {
3662
+ const path = createSvgElement('path', attributes);
3663
+ bandPaths.push(path);
3664
+ return path;
3665
+ };
3666
+ // inside = a line twice as wide, clipped to the shape; outside = a line twice as wide, the shape cut out of it
3667
+ const edgeLine = (lineWidth) => pixelPath({ fill: 'none', stroke: bandColor, 'stroke-width': lineWidth, 'stroke-linejoin': 'miter' });
3668
+ if (band === 'centre') {
3669
+ maskElement.append(edgeLine(width));
3670
+ }
3671
+ else if (band === 'inside') {
3672
+ const insideClip = createSvgElement('clipPath', { id: `${id}-inside`, class: 'morphico-band-helper' });
3673
+ insideClip.append(pixelPath({}));
3674
+ svg.append(insideClip);
3675
+ const line = edgeLine(2 * width);
3676
+ line.setAttribute('clip-path', `url(#${id}-inside)`);
3677
+ maskElement.append(line);
3678
+ }
3679
+ else {
3680
+ // painting black over the inside would only work for luminance masks; a mask cut out of the line works for both
3681
+ const outsideMask = createSvgElement('mask', { id: `${id}-outside`, class: 'morphico-band-helper', ...farRegion });
3682
+ outsideMask.append(createSvgElement('rect', { x: -FAR, y: -FAR, width: 2 * FAR, height: 2 * FAR, fill: 'white' }), pixelPath({ fill: 'black' }));
3683
+ svg.append(outsideMask);
3684
+ const line = edgeLine(2 * width);
3685
+ line.setAttribute('mask', `url(#${id}-outside)`);
3686
+ maskElement.append(line);
3687
+ }
3688
+ };
3689
+ const ownClipPath = element.style.clipPath;
3690
+ const ownMask = element.style.mask;
3691
+ const applyStyle = () => {
3692
+ const mask = target.outline ? `url(#${id}-band)` : ownMask;
3693
+ element.style.clipPath = target.outline ? ownClipPath : `url(#${id}-shape)`;
3694
+ element.style.mask = mask;
3695
+ element.style.setProperty('-webkit-mask', mask);
3696
+ };
3697
+ // ---- placement and drawing ----
3698
+ const restingPlacement = () => ({
3699
+ centerX: lengthToPixels(target.x, elementWidth, 'x'),
3700
+ centerY: lengthToPixels(target.y, elementHeight, 'y'),
3701
+ side: target.size === undefined ? Math.min(elementWidth, elementHeight) : lengthToPixels(target.size, elementWidth, 'size'),
3702
+ });
3703
+ const restingFrame = () => ({ shape: shapesOf(target.set)[target.state], rotation: target.rotation, placement: restingPlacement() });
3704
+ /** The shape in element pixels: scaled to its square, turned around the square's centre, moved into place. */
3705
+ const toElementPixels = ({ shape, rotation: angle, placement: { centerX, centerY, side } }) => {
3706
+ const scale = side / GRID_SIZE;
3707
+ const cos = Math.cos(angle * DEGREE), sin = Math.sin(angle * DEGREE);
3708
+ const pixels = [];
3709
+ for (let index = 0; index < shape.length; index += 2) {
3710
+ const offsetX = (shape[index] - GRID_CENTER) * scale, offsetY = (shape[index + 1] - GRID_CENTER) * scale;
3711
+ pixels.push(centerX + offsetX * cos - offsetY * sin, centerY + offsetX * sin + offsetY * cos);
3712
+ }
3713
+ return pixels;
3714
+ };
3715
+ const render = () => {
3716
+ if (!elementWidth || !elementHeight)
3717
+ return;
3718
+ const pixels = toElementPixels(frame ?? restingFrame());
3719
+ if (target.outline) {
3720
+ const pathData = toPathData(pixels, pieceLength);
3721
+ for (const path of bandPaths)
3722
+ path.setAttribute('d', pathData);
3723
+ }
3724
+ else {
3725
+ // objectBoundingBox: 0…1 of the element's width / height
3726
+ const boxUnits = pixels.map((value, index) => value / (index % 2 ? elementHeight : elementWidth));
3727
+ // inverted: a rectangle far around the element with the shape as a hole in it (clip-rule evenodd)
3728
+ const surrounding = target.inverted ? INVERTED_SURROUNDING : '';
3729
+ clipShape.setAttribute('d', surrounding + toPathData(boxUnits, pieceLength));
3730
+ }
3731
+ };
3732
+ const measure = (width, height) => {
3733
+ elementWidth = width;
3734
+ elementHeight = height;
3735
+ render();
3736
+ };
3737
+ // ---- changes ----
3738
+ /** Ends the running change and resolves everyone waiting for it. */
3739
+ const finish = (interrupted) => {
3740
+ if (!running)
3741
+ return;
3742
+ const { stopAnimation, resolvers } = running;
3743
+ running = null;
3744
+ stopAnimation();
3745
+ for (const resolve of resolvers)
3746
+ resolve({ state: target.state, interrupted });
3747
+ };
3748
+ /** Updates the target (checked first, so a wrong value throws right away, not in an animation frame). */
3749
+ const applyChanges = (changes) => {
3750
+ for (const name of ['x', 'y', 'size']) {
3751
+ if (changes[name] !== undefined)
3752
+ lengthToPixels(changes[name], 1, name);
3753
+ }
3754
+ if (changes.state !== undefined)
3755
+ target.state = checkedState(target.set, changes.state);
3756
+ if (changes.roundness !== undefined && changes.roundness !== target.roundness) {
3757
+ target.roundness = changes.roundness;
3758
+ target.set = resolveIconSet(set, changes.roundness);
3759
+ }
3760
+ if (changes.x !== undefined)
3761
+ target.x = changes.x;
3762
+ if (changes.y !== undefined)
3763
+ target.y = changes.y;
3764
+ if (changes.size !== undefined)
3765
+ target.size = changes.size;
3766
+ if (changes.rotation !== undefined)
3767
+ target.rotation = changes.rotation;
3768
+ if (changes.outline !== undefined || changes.inverted !== undefined) {
3769
+ if (changes.outline !== undefined)
3770
+ target.outline = checkedOutline(changes.outline);
3771
+ if (changes.inverted !== undefined)
3772
+ target.inverted = changes.inverted;
3773
+ buildBand();
3774
+ applyStyle();
3775
+ }
3776
+ };
3777
+ const change = (changes, morphOptions) => {
3778
+ const settled = !running;
3779
+ finish(true);
3780
+ const previous = { state: target.state, set: target.set };
3781
+ const from = frame ?? restingFrame();
3782
+ applyChanges(changes);
3783
+ const { duration = DEFAULT_DURATION, easing = 'easeInOutCubic' } = morphOptions;
3784
+ if (duration <= 0 || prefersReducedMotion() || !elementWidth || !elementHeight) {
3785
+ frame = null;
3786
+ render();
3787
+ return Promise.resolve({ state: target.state, interrupted: false });
3788
+ }
3789
+ // settled: both sides use the pair's layouts; interrupted: start from the shape on screen
3790
+ const fromShape = settled
3791
+ ? shapeForPair(previous.set, shapesOf(previous.set), previous.state, target.state) : from.shape;
3792
+ const toShape = shapeForPair(target.set, shapesOf(target.set), target.state, previous.state);
3793
+ const to = restingPlacement();
3794
+ const ease = typeof easing === 'function' ? easing : easings[easing];
3795
+ const startTime = performance.now();
3796
+ return new Promise((resolve) => {
3797
+ const stopAnimation = animate((frameTime) => {
3798
+ const progress = clampToUnit((frameTime - startTime) / duration);
3799
+ if (progress >= 1) {
3800
+ frame = null;
3801
+ render();
3802
+ finish(false);
3803
+ return false;
3804
+ }
3805
+ const easedProgress = ease(progress);
3806
+ const between = (start, end) => start + (end - start) * easedProgress;
3807
+ frame = {
3808
+ shape: interpolate(fromShape, toShape, easedProgress),
3809
+ rotation: between(from.rotation, target.rotation),
3810
+ placement: {
3811
+ centerX: between(from.placement.centerX, to.centerX),
3812
+ centerY: between(from.placement.centerY, to.centerY),
3813
+ side: between(from.placement.side, to.side),
3814
+ },
3815
+ };
3816
+ render();
3817
+ return true;
3818
+ });
3819
+ running = { stopAnimation, resolvers: [resolve] };
3820
+ });
3821
+ };
3822
+ // ---- start ----
3823
+ const root = element.getRootNode();
3824
+ // inside a shadow root, url(#id) only finds ids of that root
3825
+ (root instanceof ShadowRoot ? root : document.body).append(svg);
3826
+ buildBand();
3827
+ applyStyle();
3828
+ const box = element.getBoundingClientRect();
3829
+ measure(box.width, box.height);
3830
+ const resizeObserver = typeof ResizeObserver === 'function'
3831
+ ? new ResizeObserver(([entry]) => {
3832
+ const borderBox = entry.borderBoxSize?.[0];
3833
+ if (borderBox)
3834
+ measure(borderBox.inlineSize, borderBox.blockSize);
3835
+ else
3836
+ measure(entry.contentRect.width, entry.contentRect.height);
3837
+ })
3838
+ : null;
3839
+ resizeObserver?.observe(element);
3840
+ return {
3841
+ get state() { return target.state; },
3842
+ to(stateOrChanges, options = {}) {
3843
+ const { onEnd, duration, easing, ...changes } = typeof stateOrChanges === 'string'
3844
+ ? { ...options, state: stateOrChanges } : stateOrChanges;
3845
+ const result = change(changes, { ...defaultMorphOptions, ...(duration !== undefined && { duration }), ...(easing && { easing }) });
3846
+ if (onEnd)
3847
+ result.then(onEnd);
3848
+ return result;
3849
+ },
3850
+ set(changes) {
3851
+ finish(true);
3852
+ frame = null;
3853
+ applyChanges(changes);
3854
+ render();
3855
+ },
3856
+ destroy() {
3857
+ finish(true);
3858
+ resizeObserver?.disconnect();
3859
+ svg.remove();
3860
+ element.style.clipPath = ownClipPath;
3861
+ element.style.mask = ownMask;
3862
+ element.style.setProperty('-webkit-mask', ownMask);
3863
+ },
3864
+ };
3865
+ };
3866
+ return { clip };
3867
+ })();
3868
+
3869
+ // sets/faces.ts
3870
+ const { OUTLINE_POINTS, PIECE_NAMES, faceExpressionNames, faceExpressionPieces, interpolateFace, blinked, smoothClosedPath, facePaths } = (() => {
3871
+ const OUTLINE_POINTS = 24;
3872
+ const PIECE_NAMES = ['leftBrow', 'rightBrow', 'leftEye', 'rightEye', 'leftPupil', 'rightPupil', 'mouth'];
3873
+ const DENSE_SAMPLES = 360;
3874
+ const toRadians = (degrees) => (degrees * Math.PI) / 180;
3875
+ const rotatePoint = ([x, y], degrees) => {
3876
+ const angle = toRadians(degrees);
3877
+ return [x * Math.cos(angle) - y * Math.sin(angle), x * Math.sin(angle) + y * Math.cos(angle)];
3878
+ };
3879
+ const denseBlobOutline = (options) => {
3880
+ const { centre, width, height, rotation = 0, squareness = 0, topScale = 1 } = options;
3881
+ const exponent = 2 + squareness * 6;
3882
+ const halfWidth = width / 2;
3883
+ const halfHeight = height / 2;
3884
+ const outline = [];
3885
+ for (let index = 0; index < DENSE_SAMPLES; index++) {
3886
+ const angle = toRadians(-90 + (360 * index) / DENSE_SAMPLES);
3887
+ const cosine = Math.cos(angle);
3888
+ const sine = Math.sin(angle);
3889
+ const y = halfHeight * Math.sign(sine) * Math.abs(sine) ** (2 / exponent);
3890
+ const widthScale = 1 + ((topScale - 1) * (halfHeight - y)) / (2 * halfHeight);
3891
+ const x = halfWidth * widthScale * Math.sign(cosine) * Math.abs(cosine) ** (2 / exponent);
3892
+ const [turnedX, turnedY] = rotatePoint([x, y], rotation);
3893
+ outline.push([centre[0] + turnedX, centre[1] + turnedY]);
3894
+ }
3895
+ const xs = outline.map(([x]) => x);
3896
+ const left = Math.min(...xs);
3897
+ const right = Math.max(...xs);
3898
+ const lineY = ([leftY, rightY], x) => leftY + ((rightY - leftY) * (x - left)) / (right - left);
3899
+ return outline.map(([x, y]) => {
3900
+ let cutY = y;
3901
+ if (options.cutTop)
3902
+ cutY = Math.max(cutY, lineY(options.cutTop, x));
3903
+ if (options.cutBottom)
3904
+ cutY = Math.min(cutY, lineY(options.cutBottom, x));
3905
+ return [x, cutY];
3906
+ });
3907
+ };
3908
+ /** OUTLINE_POINTS points of a star-shaped outline, on rays at even angles from its box's centre (measured in the box
3909
+ * stretched to a square, so a long flat shape still gets points on its round ends). */
3910
+ const resampleByAngle = (outline) => {
3911
+ const xs = outline.map(([x]) => x);
3912
+ const ys = outline.map(([, y]) => y);
3913
+ const centreX = (Math.min(...xs) + Math.max(...xs)) / 2;
3914
+ const centreY = (Math.min(...ys) + Math.max(...ys)) / 2;
3915
+ const halfX = Math.max((Math.max(...xs) - Math.min(...xs)) / 2, 1e-9);
3916
+ const halfY = Math.max((Math.max(...ys) - Math.min(...ys)) / 2, 1e-9);
3917
+ const normalized = outline.map(([x, y]) => [(x - centreX) / halfX, (y - centreY) / halfY]);
3918
+ const points = [];
3919
+ for (let index = 0; index < OUTLINE_POINTS; index++) {
3920
+ const angle = toRadians(-90 + (360 * index) / OUTLINE_POINTS);
3921
+ const directionX = Math.cos(angle);
3922
+ const directionY = Math.sin(angle);
3923
+ let farthest = 0;
3924
+ normalized.forEach((start, edgeIndex) => {
3925
+ const end = normalized[(edgeIndex + 1) % normalized.length];
3926
+ const edgeX = end[0] - start[0];
3927
+ const edgeY = end[1] - start[1];
3928
+ const denominator = directionX * edgeY - directionY * edgeX;
3929
+ if (Math.abs(denominator) < 1e-12)
3930
+ return;
3931
+ const distance = (start[0] * edgeY - start[1] * edgeX) / denominator;
3932
+ const along = (start[0] * directionY - start[1] * directionX) / denominator;
3933
+ // a little slack: a ray passing exactly through a corner of the dense outline must still hit one of its edges
3934
+ if (along >= -1e-9 && along <= 1 + 1e-9 && distance > farthest)
3935
+ farthest = distance;
3936
+ });
3937
+ points.push([centreX + directionX * farthest * halfX, centreY + directionY * farthest * halfY]);
3938
+ }
3939
+ return points;
3940
+ };
3941
+ const blob = (options) => resampleByAngle(denseBlobOutline(options));
3942
+ const band = (options) => {
3943
+ const { from, to, bend, thickness, endShare = 1 } = options;
3944
+ const chordX = to[0] - from[0];
3945
+ const chordY = to[1] - from[1];
3946
+ const chordLength = Math.hypot(chordX, chordY);
3947
+ // the quadratic's control point sits twice as far out as the curve's middle
3948
+ const control = [
3949
+ (from[0] + to[0]) / 2 + ((2 * bend * chordY) / chordLength),
3950
+ (from[1] + to[1]) / 2 - ((2 * bend * chordX) / chordLength),
3951
+ ];
3952
+ const pointAt = (along) => {
3953
+ const rest = 1 - along;
3954
+ return [
3955
+ rest * rest * from[0] + 2 * rest * along * control[0] + along * along * to[0],
3956
+ rest * rest * from[1] + 2 * rest * along * control[1] + along * along * to[1],
3957
+ ];
3958
+ };
3959
+ const tangentAt = (along) => {
3960
+ const tangentX = 2 * (1 - along) * (control[0] - from[0]) + 2 * along * (to[0] - control[0]);
3961
+ const tangentY = 2 * (1 - along) * (control[1] - from[1]) + 2 * along * (to[1] - control[1]);
3962
+ const length = Math.hypot(tangentX, tangentY);
3963
+ return [tangentX / length, tangentY / length];
3964
+ };
3965
+ const thicknessAt = (along) => thickness * (endShare + (1 - endShare) * Math.sin(Math.PI * along));
3966
+ // side 1 = the upper edge (the left normal of a rightward curve), -1 = the lower edge
3967
+ const edge = (along, side) => {
3968
+ const [x, y] = pointAt(along);
3969
+ const [tangentX, tangentY] = tangentAt(along);
3970
+ const offset = (side * thicknessAt(along)) / 2;
3971
+ return [x + tangentY * offset, y - tangentX * offset];
3972
+ };
3973
+ // a point on the half circle around an end: turn -90° = on the upper edge, 0 = the tip, 90° = on the lower edge
3974
+ const capPoint = (along, turn) => {
3975
+ const [x, y] = pointAt(along);
3976
+ const [tangentX, tangentY] = tangentAt(along);
3977
+ const radius = thicknessAt(along) / 2;
3978
+ const outward = along === 1 ? 1 : -1;
3979
+ const alongTangent = outward * Math.cos(turn) * radius;
3980
+ const towardsLower = outward * Math.sin(turn) * radius;
3981
+ return [x + tangentX * alongTangent - tangentY * towardsLower, y + tangentY * alongTangent + tangentX * towardsLower];
3982
+ };
3983
+ // clockwise from the top middle at the same angles as a blob's points (a quarter = the right end, half = the bottom
3984
+ // middle, three quarters = the left end); the points within 30° of an end lie on its half circle, the rest along the
3985
+ // edges
3986
+ const capShare = Math.sin(Math.PI / 3);
3987
+ return Array.from({ length: OUTLINE_POINTS }, (_, index) => {
3988
+ const angle = (2 * Math.PI * index) / OUTLINE_POINTS;
3989
+ const sine = Math.sin(angle);
3990
+ if (Math.abs(sine) < capShare - 1e-9)
3991
+ return edge(0.5 + (0.5 * sine) / capShare, Math.cos(angle) > 0 ? 1 : -1);
3992
+ // the angle's distance past the cap's start, stretched from ±30° around the end to ±90° around the half circle
3993
+ const endAngle = sine > 0 ? Math.PI / 2 : (3 * Math.PI) / 2;
3994
+ return capPoint(sine > 0 ? 1 : 0, (angle - endAngle) * 3);
3995
+ });
3996
+ };
3997
+ /** A piece that isn't there: every point on one spot (draws nothing). */
3998
+ const hidden = (at) => Array.from({ length: OUTLINE_POINTS }, () => [at[0], at[1]]);
3999
+ /** The piece mirrored onto the other half of the face, still clockwise from the top. */
4000
+ const mirrored = (points) => points.map((_, index) => {
4001
+ const [x, y] = points[(OUTLINE_POINTS - index) % OUTLINE_POINTS];
4002
+ return [24 - x, y];
4003
+ });
4004
+ /** A face from its left half (as seen) + the mouth. */
4005
+ const symmetricFace = ({ brow, eye, pupil, mouth }) => ({
4006
+ leftBrow: brow,
4007
+ rightBrow: mirrored(brow),
4008
+ leftEye: eye,
4009
+ rightEye: mirrored(eye),
4010
+ leftPupil: pupil,
4011
+ rightPupil: mirrored(pupil),
4012
+ mouth,
4013
+ });
4014
+ /** The whole face mirrored: left and right pieces swap sides. */
4015
+ const mirroredFace = (face) => ({
4016
+ leftBrow: mirrored(face.rightBrow),
4017
+ rightBrow: mirrored(face.leftBrow),
4018
+ leftEye: mirrored(face.rightEye),
4019
+ rightEye: mirrored(face.leftEye),
4020
+ leftPupil: mirrored(face.rightPupil),
4021
+ rightPupil: mirrored(face.leftPupil),
4022
+ mouth: mirrored(face.mouth),
4023
+ });
4024
+ // ---- the expressions (names from the user's pictures) ----
4025
+ const faceExpressionNames = [
4026
+ 'normal',
4027
+ 'smiling',
4028
+ 'pleading',
4029
+ 'wonder',
4030
+ 'focused',
4031
+ 'fright',
4032
+ 'angry',
4033
+ 'asleep',
4034
+ 'sad',
4035
+ 'surprised',
4036
+ 'laughing',
4037
+ 'tired',
4038
+ 'wink-left',
4039
+ 'wink-right',
4040
+ 'suspicious',
4041
+ 'confused',
4042
+ 'smug',
4043
+ 'irritated',
4044
+ 'bored',
4045
+ 'curious',
4046
+ 'empathetic',
4047
+ 'guilty',
4048
+ 'worried',
4049
+ 'disappointed',
4050
+ 'ashamed',
4051
+ 'embarrassed',
4052
+ 'yearning',
4053
+ 'triumphant',
4054
+ 'proud',
4055
+ 'excited',
4056
+ 'scornful',
4057
+ 'disgusted',
4058
+ ];
4059
+ const buildFaceExpressions = () => {
4060
+ const winkRight = {
4061
+ ...symmetricFace({
4062
+ brow: blob({ centre: [6.2, 4.4], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
4063
+ eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
4064
+ pupil: blob({ centre: [7, 11.4], width: 3.2, height: 3.2 }),
4065
+ mouth: band({ from: [9.6, 18.4], to: [14.8, 18], bend: -0.9, thickness: 1.2, endShare: 0.7 }),
4066
+ }),
4067
+ rightBrow: mirrored(blob({ centre: [6.2, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 8 })),
4068
+ rightEye: mirrored(band({ from: [3.2, 12.8], to: [9.6, 12.8], bend: 1.4, thickness: 1.6, endShare: 0.7 })),
4069
+ rightPupil: hidden([17.6, 12]),
4070
+ };
4071
+ return {
4072
+ normal: symmetricFace({
4073
+ brow: blob({ centre: [6.2, 4.6], width: 5.2, height: 1.9, squareness: 0.5, rotation: -6 }),
4074
+ eye: blob({ centre: [6.4, 12], width: 8.4, height: 8.4 }),
4075
+ pupil: blob({ centre: [7, 10.7], width: 3.2, height: 3.2 }),
4076
+ mouth: blob({ centre: [12, 18.8], width: 4.4, height: 1.8, squareness: 0.6 }),
4077
+ }),
4078
+ smiling: symmetricFace({
4079
+ brow: blob({ centre: [6, 5.4], width: 5, height: 4.4, squareness: 0.2, cutBottom: [5.4, 5.4] }),
4080
+ eye: blob({ centre: [6, 13], width: 9.2, height: 7.6 }),
4081
+ pupil: blob({ centre: [6.8, 13.6], width: 3.6, height: 2.4 }),
4082
+ mouth: band({ from: [9, 18.4], to: [15, 18.4], bend: -1, thickness: 1.3, endShare: 0.7 }),
4083
+ }),
4084
+ pleading: symmetricFace({
4085
+ brow: blob({ centre: [6.6, 3.7], width: 4.6, height: 2, squareness: 0.5, rotation: -16 }),
4086
+ eye: blob({ centre: [6.8, 12.4], width: 8.6, height: 9.6, topScale: 0.75, rotation: 12 }),
4087
+ pupil: blob({ centre: [7.6, 10.7], width: 4, height: 4 }),
4088
+ mouth: blob({ centre: [12, 17.6], width: 4.3, height: 4.8, squareness: 0.3, cutTop: [17.6, 17.6] }),
4089
+ }),
4090
+ wonder: symmetricFace({
4091
+ brow: band({ from: [3.4, 4.8], to: [8.6, 3.3], bend: 0.7, thickness: 1.7 }),
4092
+ eye: blob({ centre: [6.4, 12], width: 7.8, height: 9.6 }),
4093
+ pupil: blob({ centre: [6.8, 11.2], width: 3.2, height: 4.2 }),
4094
+ mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1, squareness: 0.6 }),
4095
+ }),
4096
+ focused: symmetricFace({
4097
+ brow: blob({ centre: [6, 4.4], width: 5.6, height: 2.2, squareness: 0.55, rotation: 3 }),
4098
+ eye: blob({ centre: [6.2, 12.8], width: 9, height: 8, cutTop: [10, 11] }),
4099
+ pupil: blob({ centre: [6.8, 13.2], width: 3.8, height: 2.8 }),
4100
+ mouth: blob({ centre: [12, 18.8], width: 4.2, height: 1, squareness: 0.6 }),
4101
+ }),
4102
+ fright: symmetricFace({
4103
+ brow: blob({ centre: [5.7, 3.5], width: 4.8, height: 2.2, squareness: 0.45, rotation: -8 }),
4104
+ eye: blob({ centre: [6.2, 12.4], width: 8.4, height: 9.8, topScale: 0.7, rotation: 8 }),
4105
+ pupil: blob({ centre: [7.2, 12], width: 3.6, height: 4.2 }),
4106
+ mouth: blob({ centre: [12, 18.6], width: 3.8, height: 1.4, squareness: 0.5 }),
4107
+ }),
4108
+ angry: symmetricFace({
4109
+ brow: blob({ centre: [6.4, 5.4], width: 5.2, height: 2, squareness: 0.5, rotation: 12 }),
4110
+ eye: blob({ centre: [6.2, 12.6], width: 8.6, height: 8, cutTop: [9.2, 12] }),
4111
+ pupil: blob({ centre: [7.4, 12.8], width: 3.4, height: 3.4 }),
4112
+ mouth: blob({ centre: [12, 17.8], width: 4.2, height: 4, squareness: 0.3, cutTop: [17.8, 17.8] }),
4113
+ }),
4114
+ asleep: symmetricFace({
4115
+ brow: blob({ centre: [6, 5.4], width: 4, height: 1.7, squareness: 0.4, rotation: 4 }),
4116
+ eye: band({ from: [3, 12.4], to: [9.6, 12.4], bend: -1.2, thickness: 1.5, endShare: 0.6 }),
4117
+ pupil: hidden([6.3, 13.4]),
4118
+ mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.8 }),
4119
+ }),
4120
+ // more feelings, Claude's drafts (no pictures); the one-sided ones replace pieces of the right half — written in place
4121
+ // (a blob or band is already clockwise from its top) or drawn on the left and mirrored
4122
+ sad: symmetricFace({
4123
+ brow: blob({ centre: [6.4, 5.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -18 }),
4124
+ eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 8, cutTop: [11.2, 9.6] }),
4125
+ pupil: blob({ centre: [6.8, 13.8], width: 3.2, height: 3.4 }),
4126
+ mouth: band({ from: [9.4, 19.2], to: [14.6, 19.2], bend: 0.9, thickness: 1.2, endShare: 0.7 }),
4127
+ }),
4128
+ surprised: symmetricFace({
4129
+ brow: band({ from: [3.6, 3.6], to: [8.6, 2.8], bend: 0.6, thickness: 1.6 }),
4130
+ eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
4131
+ pupil: blob({ centre: [6.4, 12], width: 2.4, height: 2.4 }),
4132
+ mouth: blob({ centre: [12, 19], width: 2.8, height: 3.4 }),
4133
+ }),
4134
+ laughing: symmetricFace({
4135
+ brow: blob({ centre: [6, 4.6], width: 5, height: 4.4, squareness: 0.2, cutBottom: [4.6, 4.6] }),
4136
+ eye: band({ from: [3.2, 13], to: [9.6, 13], bend: 1.6, thickness: 1.6, endShare: 0.7 }),
4137
+ pupil: hidden([6.4, 12]),
4138
+ mouth: blob({ centre: [12, 17], width: 6, height: 6, squareness: 0.2, cutTop: [17, 17] }),
4139
+ }),
4140
+ tired: symmetricFace({
4141
+ brow: blob({ centre: [6, 6], width: 5, height: 1.7, squareness: 0.5, rotation: -4 }),
4142
+ eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.6, 11.6] }),
4143
+ pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3.2 }),
4144
+ mouth: blob({ centre: [12, 19], width: 3.2, height: 0.9, squareness: 0.6 }),
4145
+ }),
4146
+ // wink-left / wink-right = the eye that closes, as seen; one is the other mirrored
4147
+ 'wink-left': mirroredFace(winkRight),
4148
+ 'wink-right': winkRight,
4149
+ suspicious: {
4150
+ ...symmetricFace({
4151
+ brow: band({ from: [3.4, 4], to: [8.8, 3], bend: 0.8, thickness: 1.6 }),
4152
+ eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 7.6, cutTop: [10.8, 10.8] }),
4153
+ pupil: blob({ centre: [4.6, 13.2], width: 3.2, height: 3 }),
4154
+ mouth: blob({ centre: [12.6, 18.8], width: 3.6, height: 1, squareness: 0.6, rotation: -10 }),
4155
+ }),
4156
+ rightBrow: mirrored(blob({ centre: [6.4, 6.4], width: 5.2, height: 1.8, squareness: 0.5, rotation: 10 })),
4157
+ rightEye: mirrored(blob({ centre: [6.4, 12.8], width: 8.6, height: 7.2, cutTop: [11.8, 11.8] })),
4158
+ rightPupil: blob({ centre: [16.2, 13.4], width: 3.2, height: 3 }),
4159
+ },
4160
+ confused: {
4161
+ ...symmetricFace({
4162
+ brow: band({ from: [3.4, 3.8], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
4163
+ eye: blob({ centre: [6.4, 12], width: 8.6, height: 9 }),
4164
+ pupil: blob({ centre: [7.6, 10.6], width: 3, height: 3 }),
4165
+ mouth: blob({ centre: [12.4, 19], width: 3.8, height: 1.1, squareness: 0.5, rotation: 12 }),
4166
+ }),
4167
+ rightBrow: mirrored(blob({ centre: [6.2, 5.8], width: 5, height: 1.8, squareness: 0.5, rotation: 10 })),
4168
+ rightEye: mirrored(blob({ centre: [6.4, 12.6], width: 7.4, height: 7.4 })),
4169
+ rightPupil: blob({ centre: [18.4, 11.4], width: 2.8, height: 2.8 }),
4170
+ },
4171
+ // the user's list of feelings (2026-11-01), Claude's drafts; the ones the set had already are skipped
4172
+ smug: {
4173
+ ...symmetricFace({
4174
+ brow: blob({ centre: [6.2, 5], width: 5, height: 1.8, squareness: 0.5, rotation: -4 }),
4175
+ eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
4176
+ pupil: blob({ centre: [8, 13.4], width: 3, height: 3 }),
4177
+ mouth: band({ from: [10.2, 18.8], to: [15.2, 17.6], bend: -0.6, thickness: 1.2, endShare: 0.6 }),
4178
+ }),
4179
+ rightBrow: mirrored(band({ from: [3.6, 4.2], to: [8.6, 3.4], bend: 0.6, thickness: 1.6 })),
4180
+ rightPupil: blob({ centre: [19.4, 13.4], width: 3, height: 3 }),
4181
+ },
4182
+ irritated: symmetricFace({
4183
+ brow: blob({ centre: [6.4, 5.8], width: 5.4, height: 1.8, squareness: 0.55, rotation: 6 }),
4184
+ eye: blob({ centre: [6.4, 12.8], width: 8.6, height: 7.6, cutTop: [11.2, 11.8], cutBottom: [15.8, 15.8] }),
4185
+ pupil: blob({ centre: [6.8, 13.6], width: 3.2, height: 3 }),
4186
+ mouth: blob({ centre: [12.4, 18.8], width: 3.6, height: 0.9, squareness: 0.6, rotation: -8 }),
4187
+ }),
4188
+ bored: {
4189
+ ...symmetricFace({
4190
+ brow: blob({ centre: [6, 6.2], width: 5, height: 1.7, squareness: 0.5 }),
4191
+ eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.8, 11.8] }),
4192
+ pupil: blob({ centre: [8, 12.8], width: 3, height: 3 }),
4193
+ mouth: blob({ centre: [13.4, 19], width: 3, height: 0.9, squareness: 0.6 }),
4194
+ }),
4195
+ rightPupil: blob({ centre: [19.4, 12.8], width: 3, height: 3 }),
4196
+ },
4197
+ curious: {
4198
+ ...symmetricFace({
4199
+ brow: band({ from: [3.4, 3.6], to: [8.6, 2.6], bend: 0.7, thickness: 1.6 }),
4200
+ eye: blob({ centre: [6.4, 12], width: 8.6, height: 8.8 }),
4201
+ pupil: blob({ centre: [7.8, 10.8], width: 3.2, height: 3.2 }),
4202
+ mouth: blob({ centre: [12.6, 18.8], width: 2, height: 2.2 }),
4203
+ }),
4204
+ rightBrow: mirrored(blob({ centre: [6.2, 5.2], width: 5, height: 1.8, squareness: 0.5, rotation: 4 })),
4205
+ rightPupil: blob({ centre: [19, 10.8], width: 3.2, height: 3.2 }),
4206
+ },
4207
+ empathetic: symmetricFace({
4208
+ brow: blob({ centre: [6.4, 4.8], width: 5, height: 1.9, squareness: 0.5, rotation: -10 }),
4209
+ eye: blob({ centre: [6.4, 12.4], width: 8.4, height: 8, cutTop: [10, 9.2] }),
4210
+ pupil: blob({ centre: [6.8, 13], width: 3.4, height: 3.4 }),
4211
+ mouth: band({ from: [10, 18.6], to: [14, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
4212
+ }),
4213
+ guilty: {
4214
+ ...symmetricFace({
4215
+ brow: blob({ centre: [6.4, 5], width: 4.6, height: 1.8, squareness: 0.5, rotation: -14 }),
4216
+ eye: blob({ centre: [6.4, 12.6], width: 8.2, height: 8, cutTop: [10.6, 10.2] }),
4217
+ pupil: blob({ centre: [5, 14.4], width: 3, height: 3 }),
4218
+ mouth: blob({ centre: [11.4, 19], width: 2.8, height: 0.9, squareness: 0.6, rotation: 6 }),
4219
+ }),
4220
+ rightPupil: blob({ centre: [16.2, 14.4], width: 3, height: 3 }),
4221
+ },
4222
+ worried: symmetricFace({
4223
+ brow: blob({ centre: [6.6, 4.2], width: 4.8, height: 1.9, squareness: 0.5, rotation: -20 }),
4224
+ eye: blob({ centre: [6.4, 12.2], width: 8, height: 8.8, topScale: 0.85, rotation: 6 }),
4225
+ pupil: blob({ centre: [6.8, 12.4], width: 2.6, height: 2.8 }),
4226
+ mouth: band({ from: [10.2, 19], to: [13.8, 19], bend: 0.6, thickness: 1.1, endShare: 0.7 }),
4227
+ }),
4228
+ disappointed: symmetricFace({
4229
+ brow: blob({ centre: [6.2, 5.6], width: 5, height: 1.8, squareness: 0.5, rotation: -8 }),
4230
+ eye: blob({ centre: [6.4, 12.8], width: 8.4, height: 7.6, cutTop: [11.8, 11] }),
4231
+ pupil: blob({ centre: [6.6, 14.4], width: 3, height: 3 }),
4232
+ mouth: band({ from: [9.6, 19.2], to: [14.4, 19.2], bend: 0.4, thickness: 1.1, endShare: 0.8 }),
4233
+ }),
4234
+ ashamed: symmetricFace({
4235
+ brow: blob({ centre: [6.4, 5.6], width: 4.6, height: 1.8, squareness: 0.5, rotation: -16 }),
4236
+ eye: blob({ centre: [6.4, 13], width: 8.2, height: 7.6, cutTop: [12.2, 12.2] }),
4237
+ pupil: blob({ centre: [6.4, 15], width: 3, height: 2.6 }),
4238
+ mouth: blob({ centre: [12, 19.4], width: 2.4, height: 0.9, squareness: 0.6 }),
4239
+ }),
4240
+ embarrassed: {
4241
+ ...symmetricFace({
4242
+ brow: blob({ centre: [6.2, 4.8], width: 4.8, height: 1.8, squareness: 0.5, rotation: -12 }),
4243
+ eye: blob({ centre: [6.4, 12.4], width: 8.2, height: 8.2 }),
4244
+ pupil: blob({ centre: [4.4, 13.6], width: 3, height: 3 }),
4245
+ mouth: band({ from: [9.8, 18.8], to: [14.2, 18.6], bend: -0.5, thickness: 1.1, endShare: 0.7 }),
4246
+ }),
4247
+ rightPupil: blob({ centre: [15.8, 13.6], width: 3, height: 3 }),
4248
+ },
4249
+ yearning: symmetricFace({
4250
+ brow: blob({ centre: [6.2, 4], width: 4.8, height: 1.8, squareness: 0.5, rotation: -10 }),
4251
+ eye: blob({ centre: [6.4, 12.6], width: 8.6, height: 8, cutTop: [11, 11] }),
4252
+ pupil: blob({ centre: [6.8, 13.6], width: 4.2, height: 4.2 }),
4253
+ mouth: blob({ centre: [12, 18.8], width: 2.6, height: 1.6 }),
4254
+ }),
4255
+ triumphant: symmetricFace({
4256
+ brow: blob({ centre: [6.2, 4], width: 5.2, height: 2, squareness: 0.5, rotation: 8 }),
4257
+ eye: blob({ centre: [6.4, 11.8], width: 8.6, height: 8.6, cutBottom: [14, 14] }),
4258
+ pupil: blob({ centre: [6.8, 11.6], width: 3.4, height: 3.4 }),
4259
+ mouth: blob({ centre: [12, 16.8], width: 7, height: 6.4, squareness: 0.3, cutTop: [16.8, 16.8] }),
4260
+ }),
4261
+ proud: symmetricFace({
4262
+ brow: blob({ centre: [6.2, 3.8], width: 5, height: 1.9, squareness: 0.5, rotation: -4 }),
4263
+ eye: blob({ centre: [6.4, 12.4], width: 8.6, height: 8, cutTop: [11, 11] }),
4264
+ pupil: blob({ centre: [6.6, 13.6], width: 3.2, height: 3 }),
4265
+ mouth: band({ from: [8.8, 18], to: [15.2, 18], bend: -1.2, thickness: 1.3, endShare: 0.7 }),
4266
+ }),
4267
+ excited: symmetricFace({
4268
+ brow: band({ from: [3.4, 3.6], to: [8.6, 2.8], bend: 0.8, thickness: 1.6 }),
4269
+ eye: blob({ centre: [6.4, 12], width: 9, height: 9.2 }),
4270
+ pupil: blob({ centre: [7, 11], width: 4, height: 4 }),
4271
+ mouth: blob({ centre: [12, 16.8], width: 6, height: 6, squareness: 0.2, cutTop: [16.8, 16.8] }),
4272
+ }),
4273
+ scornful: {
4274
+ ...symmetricFace({
4275
+ brow: blob({ centre: [6.2, 5.4], width: 5, height: 1.8, squareness: 0.5, rotation: 2 }),
4276
+ eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 11.4] }),
4277
+ pupil: blob({ centre: [5.4, 14], width: 3, height: 3 }),
4278
+ mouth: band({ from: [9.2, 18.8], to: [14.6, 18], bend: -0.2, thickness: 1.1, endShare: 0.7 }),
4279
+ }),
4280
+ rightBrow: mirrored(band({ from: [3.6, 4], to: [8.6, 3.2], bend: 0.6, thickness: 1.6 })),
4281
+ rightPupil: blob({ centre: [16.6, 14], width: 3, height: 3 }),
4282
+ },
4283
+ disgusted: symmetricFace({
4284
+ brow: blob({ centre: [6.6, 6], width: 5, height: 1.8, squareness: 0.5, rotation: 14 }),
4285
+ eye: blob({ centre: [6.4, 12.6], width: 8.4, height: 7.6, cutTop: [11.4, 12.2], cutBottom: [14.8, 14.8] }),
4286
+ pupil: blob({ centre: [6.8, 13.2], width: 3, height: 2.8 }),
4287
+ // an upside-down "D": open, corners pulled down
4288
+ mouth: blob({ centre: [12, 19.6], width: 5, height: 4.8, squareness: 0.2, cutBottom: [19.6, 19.6] }),
4289
+ }),
4290
+ };
4291
+ };
4292
+ let builtExpressions = null;
4293
+ /** Every expression's pieces, built on first use (nothing runs at import). */
4294
+ const faceExpressionPieces = () => (builtExpressions ??= buildFaceExpressions());
4295
+ // ---- morphing and drawing ----
4296
+ const interpolatePoints = (from, to, progress) => from.map(([fromX, fromY], index) => [fromX + (to[index][0] - fromX) * progress, fromY + (to[index][1] - fromY) * progress]);
4297
+ const interpolateFace = (from, to, progress) => Object.fromEntries(PIECE_NAMES.map((name) => [name, interpolatePoints(from[name], to[name], progress)]));
4298
+ const boxCentre = (points) => {
4299
+ const xs = points.map(([x]) => x);
4300
+ const ys = points.map(([, y]) => y);
4301
+ return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
4302
+ };
4303
+ /** The face with its eyes partly closed: openness 1 = as drawn, 0 = shut to a flat slit (pupils gone). */
4304
+ const blinked = (face, openness) => {
4305
+ if (openness >= 1)
4306
+ return face;
4307
+ const eyeScale = 0.25 + 0.75 * openness;
4308
+ const squash = (points, middleY) => points.map(([x, y]) => [x, middleY + (y - middleY) * eyeScale]);
4309
+ const shrink = (points, [centreX, centreY]) => points.map(([x, y]) => [centreX + (x - centreX) * openness, centreY + (y - centreY) * openness]);
4310
+ const leftMiddle = boxCentre(face.leftEye)[1];
4311
+ const rightMiddle = boxCentre(face.rightEye)[1];
4312
+ return {
4313
+ ...face,
4314
+ leftEye: squash(face.leftEye, leftMiddle),
4315
+ rightEye: squash(face.rightEye, rightMiddle),
4316
+ leftPupil: squash(shrink(face.leftPupil, boxCentre(face.leftPupil)), leftMiddle),
4317
+ rightPupil: squash(shrink(face.rightPupil, boxCentre(face.rightPupil)), rightMiddle),
4318
+ };
4319
+ };
4320
+ const formatNumber = (value) => String(Math.round(value * 1000) / 1000);
4321
+ /** A smooth closed curve through the points: each handle points along the neighbours' chord and is a third of its own
4322
+ * segment long (no loops where points sit close together, as at a band's round end). Empty for a hidden piece. */
4323
+ const smoothClosedPath = (points) => {
4324
+ const [firstX, firstY] = points[0];
4325
+ if (points.every(([x, y]) => Math.abs(x - firstX) < 1e-6 && Math.abs(y - firstY) < 1e-6))
4326
+ return '';
4327
+ const count = points.length;
4328
+ const directionAt = (index) => {
4329
+ const previous = points[(index - 1 + count) % count];
4330
+ const next = points[(index + 1) % count];
4331
+ const length = Math.hypot(next[0] - previous[0], next[1] - previous[1]) || 1;
4332
+ return [(next[0] - previous[0]) / length, (next[1] - previous[1]) / length];
4333
+ };
4334
+ let path = `M${formatNumber(firstX)} ${formatNumber(firstY)}`;
4335
+ for (let index = 0; index < count; index++) {
4336
+ const start = points[index];
4337
+ const end = points[(index + 1) % count];
4338
+ const handleLength = Math.hypot(end[0] - start[0], end[1] - start[1]) / 3;
4339
+ const startDirection = directionAt(index);
4340
+ const endDirection = directionAt(index + 1);
4341
+ const control1 = [start[0] + startDirection[0] * handleLength, start[1] + startDirection[1] * handleLength];
4342
+ const control2 = [end[0] - endDirection[0] * handleLength, end[1] - endDirection[1] * handleLength];
4343
+ path += `C${[...control1, ...control2, ...end].map(formatNumber).join(' ')}`;
4344
+ }
4345
+ return `${path}Z`;
4346
+ };
4347
+ const facePaths = (face) => ({
4348
+ features: [face.leftBrow, face.rightBrow, face.leftEye, face.rightEye, face.mouth].map(smoothClosedPath).join(''),
4349
+ eyes: [face.leftEye, face.rightEye].map(smoothClosedPath).join(''),
4350
+ pupils: [face.leftPupil, face.rightPupil].map(smoothClosedPath).join(''),
4351
+ });
4352
+ return { OUTLINE_POINTS, PIECE_NAMES, faceExpressionNames, faceExpressionPieces, interpolateFace, blinked, smoothClosedPath, facePaths };
4353
+ })();
4354
+
4355
+ // face.ts
4356
+ const { face } = (() => {
4357
+ // Layer 4: a face that morphs between feelings. 7 soft filled pieces (sets/faces.ts) — brows, eyes and mouth in the
4358
+ // CSS color, the pupils in their own color and clipped to the eyes. Blinking runs on top of any expression, by itself
4359
+ // every few seconds (option) or on blink().
4360
+ const BLINK_CLOSING = 90;
4361
+ const BLINK_SHUT = 50;
4362
+ const BLINK_OPENING = 150;
4363
+ const AUTO_BLINK_MIN_PAUSE = 2500;
4364
+ const AUTO_BLINK_EXTRA_PAUSE = 3500;
4365
+ const DEFAULT_FACE_DURATION = DEFAULT_DURATION + 100;
4366
+ /** Expressions whose eyes are already shut: blinking does nothing. */
4367
+ const EYES_SHUT = ['asleep', 'laughing'];
4368
+ let faceCount = 0;
4369
+ const checkedExpression = (state) => {
4370
+ if (!faceExpressionNames.includes(state))
4371
+ throw new Error(`morphico: face() has no expression "${state}"`);
4372
+ return state;
4373
+ };
4374
+ const resolveEasing = (easing) => (typeof easing === 'function' ? easing : easings[easing ?? 'easeInOutCubic']);
4375
+ /** Adds a morphing face (an <svg>) to `element`. */
4376
+ const face = (element, options = {}) => {
4377
+ const { state: firstState = 'normal', pupilColor = 'white', blink: blinksByItself = true, size = '100%', ...defaultMorphOptions } = options;
4378
+ const expressions = faceExpressionPieces();
4379
+ let state = checkedExpression(firstState);
4380
+ let shownPieces = expressions[state];
4381
+ let openness = 1;
4382
+ const clipId = `morphico-face-${++faceCount}`;
4383
+ const svg = createSvgElement('svg', { viewBox: '0 0 24 24', fill: 'currentColor', role: 'img' });
4384
+ svg.style.width = size;
4385
+ svg.style.height = size;
4386
+ svg.style.overflow = 'visible';
4387
+ const clipPath = svg.appendChild(createSvgElement('clipPath', { id: clipId }));
4388
+ const eyesPath = clipPath.appendChild(createSvgElement('path'));
4389
+ const featuresPath = svg.appendChild(createSvgElement('path'));
4390
+ const pupilsPath = svg.appendChild(createSvgElement('path', { fill: pupilColor, 'clip-path': `url(#${clipId})` }));
4391
+ const render = () => {
4392
+ const paths = facePaths(blinked(shownPieces, openness));
4393
+ featuresPath.setAttribute('d', paths.features);
4394
+ eyesPath.setAttribute('d', paths.eyes);
4395
+ pupilsPath.setAttribute('d', paths.pupils);
4396
+ };
4397
+ const updateLabel = () => svg.setAttribute('aria-label', state);
4398
+ render();
4399
+ updateLabel();
4400
+ element.appendChild(svg);
4401
+ // ---- morphing ----
4402
+ let morph = null;
4403
+ const endMorph = (interrupted) => {
4404
+ if (!morph)
4405
+ return;
4406
+ const { stopAnimation, resolve, state: morphState } = morph;
4407
+ morph = null;
4408
+ stopAnimation();
4409
+ resolve({ state: morphState, interrupted });
4410
+ };
4411
+ const morphTo = (nextState, morphOptions) => {
4412
+ endMorph(true);
4413
+ state = nextState;
4414
+ updateLabel();
4415
+ const fromPieces = shownPieces;
4416
+ const toPieces = expressions[nextState];
4417
+ const duration = morphOptions.duration ?? DEFAULT_FACE_DURATION;
4418
+ if (prefersReducedMotion() || duration <= 0) {
4419
+ shownPieces = toPieces;
4420
+ render();
4421
+ return Promise.resolve({ state: nextState, interrupted: false });
4422
+ }
4423
+ const easing = resolveEasing(morphOptions.easing);
4424
+ let startTime = null;
4425
+ return new Promise((resolve) => {
4426
+ const stopAnimation = animate((frameTime) => {
4427
+ startTime ??= frameTime;
4428
+ const progress = Math.min((frameTime - startTime) / duration, 1);
4429
+ shownPieces = interpolateFace(fromPieces, toPieces, easing(progress));
4430
+ render();
4431
+ if (progress < 1)
4432
+ return true;
4433
+ endMorph(false);
4434
+ return false;
4435
+ });
4436
+ morph = { stopAnimation, resolve, state: nextState };
4437
+ });
4438
+ };
4439
+ // ---- blinking ----
4440
+ let stopBlink = null;
4441
+ let finishBlink = null;
4442
+ const endBlink = () => {
4443
+ stopBlink?.();
4444
+ stopBlink = null;
4445
+ if (openness < 1) {
4446
+ openness = 1;
4447
+ render();
4448
+ }
4449
+ finishBlink?.();
4450
+ finishBlink = null;
4451
+ };
4452
+ const blink = () => {
4453
+ if (EYES_SHUT.includes(state) || prefersReducedMotion())
4454
+ return Promise.resolve();
4455
+ endBlink();
4456
+ let startTime = null;
4457
+ return new Promise((resolve) => {
4458
+ finishBlink = resolve;
4459
+ stopBlink = animate((frameTime) => {
4460
+ startTime ??= frameTime;
4461
+ const elapsed = frameTime - startTime;
4462
+ if (elapsed < BLINK_CLOSING)
4463
+ openness = 1 - elapsed / BLINK_CLOSING;
4464
+ else if (elapsed < BLINK_CLOSING + BLINK_SHUT)
4465
+ openness = 0;
4466
+ else
4467
+ openness = Math.min((elapsed - BLINK_CLOSING - BLINK_SHUT) / BLINK_OPENING, 1);
4468
+ render();
4469
+ if (openness < 1 || elapsed < BLINK_CLOSING)
4470
+ return true;
4471
+ stopBlink = null;
4472
+ finishBlink = null;
4473
+ resolve();
4474
+ return false;
4475
+ });
4476
+ });
4477
+ };
4478
+ let autoBlinkTimer;
4479
+ const scheduleAutoBlink = () => {
4480
+ autoBlinkTimer = setTimeout(() => {
4481
+ if (typeof document === 'undefined' || !document.hidden)
4482
+ void blink();
4483
+ scheduleAutoBlink();
4484
+ }, AUTO_BLINK_MIN_PAUSE + Math.random() * AUTO_BLINK_EXTRA_PAUSE);
4485
+ };
4486
+ if (blinksByItself && !prefersReducedMotion())
4487
+ scheduleAutoBlink();
4488
+ return {
4489
+ get state() { return state; },
4490
+ to(nextState, { onEnd, ...morphOptions } = {}) {
4491
+ const result = morphTo(checkedExpression(nextState), { ...defaultMorphOptions, ...morphOptions });
4492
+ if (onEnd)
4493
+ result.then(onEnd);
4494
+ return result;
4495
+ },
4496
+ blink,
4497
+ destroy() {
4498
+ clearTimeout(autoBlinkTimer);
4499
+ endMorph(true);
4500
+ endBlink();
4501
+ svg.remove();
4502
+ },
4503
+ };
4504
+ };
4505
+ return { face };
4506
+ })();
4507
+
3163
4508
  // icon.ts
3164
4509
  const { icon } = (() => {
3165
4510
  // Layer 1: one icon that morphs between the states of a set, e.g. a single digit.
@@ -3294,7 +4639,7 @@ return { text };
3294
4639
  // index.ts
3295
4640
  // morphico public API.
3296
4641
  /** Package version, e.g. "0.1.0" (tools/build.ts replaces 'dev'). */
3297
- const version = '0.2.0';
4642
+ const version = '0.4.0';
3298
4643
 
3299
- return { easings, icon, iconSets, text, version };
4644
+ return { clip, easings, face, icon, iconSets, faceExpressionNames, text, version };
3300
4645
  })();