launchframe 0.2.0 → 0.2.2

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 (73) hide show
  1. package/README.md +144 -183
  2. package/bin/launchframe.mjs +261 -28
  3. package/package.json +52 -67
  4. package/template/.aider.conf.yml +3 -0
  5. package/template/.amazonq/cli-agents/clone-website.json +9 -0
  6. package/template/.amazonq/rules/project.md +161 -0
  7. package/template/.augment/commands/clone-website.md +518 -0
  8. package/template/.claude/skills/clone-website/SKILL.md +517 -0
  9. package/template/.clinerules +161 -0
  10. package/template/.codex/skills/clone-website/SKILL.md +517 -0
  11. package/template/.continue/commands/clone-website.md +519 -0
  12. package/template/.continue/rules/project.md +165 -0
  13. package/template/.cursor/commands/clone-website.md +514 -0
  14. package/template/.cursor/rules/project.mdc +20 -0
  15. package/template/.dockerignore +60 -0
  16. package/template/.gemini/commands/clone-website.toml +520 -0
  17. package/template/.gitattributes +9 -0
  18. package/template/.github/ISSUE_TEMPLATE/bug_report.yml +86 -0
  19. package/template/.github/ISSUE_TEMPLATE/config.yml +5 -0
  20. package/template/.github/ISSUE_TEMPLATE/feature_request.yml +50 -0
  21. package/template/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  22. package/template/.github/copilot-instructions.md +161 -0
  23. package/template/.github/copilot-setup-steps.yml +3 -0
  24. package/template/.github/skills/clone-website/SKILL.md +517 -0
  25. package/template/.github/workflows/ci.yml +36 -0
  26. package/template/.nvmrc +1 -0
  27. package/template/.opencode/commands/clone-website.md +517 -0
  28. package/template/.windsurf/workflows/clone-website.md +514 -0
  29. package/template/.windsurfrules +2 -0
  30. package/template/AGENTS.md +79 -0
  31. package/template/CHANGELOG.md +80 -0
  32. package/template/CLAUDE.md +1 -0
  33. package/template/Dockerfile +114 -0
  34. package/template/Dockerfile.dev +15 -0
  35. package/template/GEMINI.md +1 -0
  36. package/template/README.md +118 -0
  37. package/template/START_HERE.md +15 -0
  38. package/template/components.json +25 -0
  39. package/template/docker-compose.yml +53 -0
  40. package/template/docs/design-references/.gitkeep +0 -0
  41. package/template/docs/design-references/comparison.png +0 -0
  42. package/template/docs/research/INSPECTION_GUIDE.md +80 -0
  43. package/template/eslint.config.mjs +18 -0
  44. package/template/next.config.ts +8 -0
  45. package/template/package.json +59 -0
  46. package/template/postcss.config.mjs +7 -0
  47. package/template/public/images/.gitkeep +0 -0
  48. package/template/public/seo/.gitkeep +0 -0
  49. package/template/public/videos/.gitkeep +0 -0
  50. package/template/scripts/.gitkeep +0 -0
  51. package/template/scripts/sync-agent-rules.sh +88 -0
  52. package/template/scripts/sync-skills.mjs +111 -0
  53. package/template/src/app/favicon.ico +0 -0
  54. package/template/src/app/globals.css +130 -0
  55. package/template/src/app/layout.tsx +33 -0
  56. package/template/src/app/page.tsx +9 -0
  57. package/template/src/components/ui/button.tsx +60 -0
  58. package/template/src/hooks/.gitkeep +0 -0
  59. package/template/src/lib/utils.ts +6 -0
  60. package/template/src/types/.gitkeep +0 -0
  61. package/template/tsconfig.json +34 -0
  62. package/packages/extract/automated-clone-pass.ts +0 -353
  63. package/packages/extract/browser-extract.ts +0 -237
  64. package/packages/extract/cloner-research-emit.ts +0 -270
  65. package/packages/extract/dom-crawler.ts +0 -521
  66. package/packages/extract/emit.ts +0 -553
  67. package/packages/extract/extract.ts +0 -548
  68. package/packages/extract/host-slug.ts +0 -5
  69. package/packages/extract/mirror-emit.ts +0 -620
  70. package/packages/extract/package.json +0 -13
  71. package/packages/extract/reference-dump.ts +0 -431
  72. package/packages/extract/synthesize.ts +0 -551
  73. package/packages/extract/types.ts +0 -316
@@ -1,551 +0,0 @@
1
- /**
2
- * Synthesizer.
3
- *
4
- * Pure function from raw per-site observations → a single normalized,
5
- * shadcn-compatible DesignSystem. The synthesizer never copies a value
6
- * from a single source verbatim; it clusters across all sites, picks
7
- * representative anchors, and derives the rest of the system by rule.
8
- *
9
- * Each step is small and inspectable so it's easy to tune.
10
- */
11
-
12
- import type {
13
- ColorObservation,
14
- ColorRamp,
15
- DesignSystem,
16
- RadiusScale,
17
- RawTokens,
18
- ShadowScale,
19
- SpacingScale,
20
- TypeScale,
21
- } from "./types.js";
22
-
23
- /* -------------------------------------------------------------------------- */
24
- /* Public entry */
25
- /* -------------------------------------------------------------------------- */
26
-
27
- export interface SynthesizeOptions {
28
- runId: string;
29
- sources: Array<{ url: string; capturedAt: string }>;
30
- }
31
-
32
- export function synthesize(rawList: RawTokens[], opts: SynthesizeOptions): DesignSystem {
33
- const allColors = rawList.flatMap((r) => r.colors);
34
- const allType = rawList.flatMap((r) => r.typography);
35
- const allSpacing = rawList.flatMap((r) => r.spacing);
36
- const allRadii = rawList.flatMap((r) => r.radii);
37
- const containers = rawList
38
- .map((r) => r.dominantContainerPx)
39
- .filter((v): v is number => v !== null);
40
-
41
- const palette = synthesizePalette(allColors);
42
- const typography = synthesizeTypography(allType);
43
- const spacing = synthesizeSpacing(allSpacing, containers);
44
- const radius = synthesizeRadius(allRadii);
45
- const shadows = synthesizeShadows();
46
-
47
- const notes: string[] = [];
48
- if (typography.fontSansSubstituted) {
49
- notes.push(
50
- `The most common sans family observed appeared to be proprietary; substituted with ${typography.fontSans}, an open-source alternative with similar metrics.`,
51
- );
52
- }
53
- if (typography.fontMonoSubstituted) {
54
- notes.push(
55
- `The most common mono family observed appeared to be proprietary; substituted with ${typography.fontMono}.`,
56
- );
57
- }
58
- if (containers.length > 0) {
59
- notes.push(
60
- `Container width ${spacing.containerPx}px chosen as the median of ${containers.length} dominant block widths across the corpus.`,
61
- );
62
- }
63
-
64
- return {
65
- runId: opts.runId,
66
- sources: opts.sources,
67
- light: palette.light,
68
- dark: palette.dark,
69
- typography,
70
- spacing,
71
- radius,
72
- shadows,
73
- notes,
74
- };
75
- }
76
-
77
- /* -------------------------------------------------------------------------- */
78
- /* Color synthesis */
79
- /* -------------------------------------------------------------------------- */
80
-
81
- interface ColorWithHsl {
82
- hex: string;
83
- role: ColorObservation["role"];
84
- area: number;
85
- h: number;
86
- s: number;
87
- l: number;
88
- }
89
-
90
- function synthesizePalette(observations: ColorObservation[]): {
91
- light: ColorRamp;
92
- dark: ColorRamp;
93
- } {
94
- const enriched: ColorWithHsl[] = observations
95
- .map((o) => {
96
- const hsl = hexToHsl(o.hex);
97
- if (!hsl) return null;
98
- return { ...o, ...hsl };
99
- })
100
- .filter((v): v is ColorWithHsl => v !== null);
101
-
102
- const backgrounds = enriched.filter((c) => c.role === "background");
103
- const texts = enriched.filter((c) => c.role === "text");
104
-
105
- // Pick the lightest abundant background and the darkest abundant text.
106
- const background = pickByAreaAndExtreme(backgrounds, "lightest") ?? "#ffffff";
107
- const foreground = pickByAreaAndExtreme(texts, "darkest") ?? "#0a0a0a";
108
-
109
- // Find the most-used non-grayscale color across text and background.
110
- const accentCandidates = enriched.filter((c) => c.s > 0.18);
111
- const accentHsl = dominantByArea(accentCandidates);
112
- const primary = accentHsl
113
- ? hslToHex({ h: accentHsl.h, s: clamp(accentHsl.s, 0.4, 0.85), l: 0.42 })
114
- : foreground;
115
- const primaryForeground = pickReadableForeground(primary);
116
-
117
- // Light theme: derive everything from background + foreground anchors.
118
- const light = deriveLightRamp(background, foreground, primary, primaryForeground);
119
-
120
- // Dark theme: invert the lightness anchors.
121
- const darkBg = "#0a0a0c";
122
- const darkFg = "#fafafa";
123
- const dark = deriveDarkRamp(darkBg, darkFg, primary, primaryForeground);
124
-
125
- return { light, dark };
126
- }
127
-
128
- function deriveLightRamp(
129
- background: string,
130
- foreground: string,
131
- primary: string,
132
- primaryForeground: string,
133
- ): ColorRamp {
134
- const fgHsl = hexToHsl(foreground)!;
135
- return {
136
- background,
137
- foreground,
138
- card: background,
139
- cardForeground: foreground,
140
- popover: background,
141
- popoverForeground: foreground,
142
- primary,
143
- primaryForeground,
144
- secondary: shiftLightness(background, -0.04),
145
- secondaryForeground: foreground,
146
- muted: shiftLightness(background, -0.04),
147
- mutedForeground: hslToHex({ h: fgHsl.h, s: 0.05, l: 0.42 }),
148
- accent: shiftLightness(background, -0.04),
149
- accentForeground: foreground,
150
- destructive: "#dc2626",
151
- destructiveForeground: "#ffffff",
152
- border: shiftLightness(background, -0.10),
153
- input: shiftLightness(background, -0.10),
154
- ring: foreground,
155
- };
156
- }
157
-
158
- function deriveDarkRamp(
159
- background: string,
160
- foreground: string,
161
- primary: string,
162
- primaryForeground: string,
163
- ): ColorRamp {
164
- const fgHsl = hexToHsl(foreground)!;
165
- return {
166
- background,
167
- foreground,
168
- card: shiftLightness(background, 0.03),
169
- cardForeground: foreground,
170
- popover: shiftLightness(background, 0.03),
171
- popoverForeground: foreground,
172
- primary: foreground,
173
- primaryForeground: background,
174
- secondary: shiftLightness(background, 0.06),
175
- secondaryForeground: foreground,
176
- muted: shiftLightness(background, 0.06),
177
- mutedForeground: hslToHex({ h: fgHsl.h, s: 0.05, l: 0.65 }),
178
- accent: shiftLightness(background, 0.08),
179
- accentForeground: foreground,
180
- destructive: "#ef4444",
181
- destructiveForeground: "#ffffff",
182
- border: shiftLightness(background, 0.10),
183
- input: shiftLightness(background, 0.10),
184
- ring: foreground,
185
- };
186
- }
187
-
188
- /* -------------------------------------------------------------------------- */
189
- /* Typography synthesis */
190
- /* -------------------------------------------------------------------------- */
191
-
192
- const PROPRIETARY_FAMILIES = new Set(
193
- [
194
- "sf pro display",
195
- "sf pro text",
196
- "sf pro",
197
- "sohne",
198
- "söhne",
199
- "circular",
200
- "ginto",
201
- "graphik",
202
- "tiempos",
203
- "matter",
204
- "founders grotesk",
205
- "monument grotesk",
206
- "neue haas grotesk",
207
- "neue haas unica",
208
- "ibm plex sans",
209
- ].map((s) => s.toLowerCase()),
210
- );
211
-
212
- const SUBSTITUTE_SANS = "Inter";
213
- const SUBSTITUTE_MONO = "JetBrains Mono";
214
-
215
- function synthesizeTypography(observations: RawTokens["typography"]): TypeScale {
216
- if (observations.length === 0) return defaultTypography();
217
-
218
- // Family selection, weighted by observed character count.
219
- const familyScores = new Map<string, number>();
220
- let monoScore = 0;
221
- let monoFamily: string | null = null;
222
- for (const o of observations) {
223
- const fam = o.fontFamily;
224
- const isMono =
225
- /mono|code|courier|menlo|consolas|cascadia|fira code|jetbrains/i.test(fam);
226
- if (isMono) {
227
- if (o.count > monoScore) {
228
- monoScore = o.count;
229
- monoFamily = fam;
230
- }
231
- } else {
232
- familyScores.set(fam, (familyScores.get(fam) ?? 0) + o.count);
233
- }
234
- }
235
- let topSans = "system-ui";
236
- let topSansScore = 0;
237
- for (const [fam, score] of familyScores) {
238
- if (score > topSansScore) {
239
- topSans = fam;
240
- topSansScore = score;
241
- }
242
- }
243
-
244
- const sansSubstituted = PROPRIETARY_FAMILIES.has(topSans.toLowerCase());
245
- const fontSans = sansSubstituted ? SUBSTITUTE_SANS : topSans;
246
- const monoSubstituted =
247
- monoFamily !== null && PROPRIETARY_FAMILIES.has(monoFamily.toLowerCase());
248
- const fontMono = monoFamily ? (monoSubstituted ? SUBSTITUTE_MONO : monoFamily) : SUBSTITUTE_MONO;
249
-
250
- // Body base size: count-weighted mode of font-sizes between 13 and 19 px.
251
- const bodyCandidates = observations.filter((o) => o.fontSize >= 13 && o.fontSize <= 19);
252
- const baseSizeBucket = new Map<number, number>();
253
- for (const o of bodyCandidates) {
254
- baseSizeBucket.set(o.fontSize, (baseSizeBucket.get(o.fontSize) ?? 0) + o.count);
255
- }
256
- let basePx = 16;
257
- let bestScore = 0;
258
- for (const [size, score] of baseSizeBucket) {
259
- if (score > bestScore) {
260
- basePx = size;
261
- bestScore = score;
262
- }
263
- }
264
-
265
- // Display size: largest fontSize seen with count > 0 and area significance.
266
- const headingCandidates = observations.filter((o) => o.fontSize >= 32);
267
- const display = headingCandidates.length > 0
268
- ? Math.max(...headingCandidates.map((o) => o.fontSize))
269
- : basePx * 4;
270
-
271
- // Solve scale ratio so basePx * r^7 ≈ display (we span xs..6xl, that's 9 steps;
272
- // body sits at index 2 (base), display at index 9 => r^7 = display/base).
273
- const scaleRatio = clamp(Math.pow(display / basePx, 1 / 7), 1.15, 1.35);
274
-
275
- const stepFromIndex = (i: number) => Math.round(basePx * Math.pow(scaleRatio, i));
276
-
277
- const steps = {
278
- xs: stepFromIndex(-2),
279
- sm: stepFromIndex(-1),
280
- base: basePx,
281
- lg: stepFromIndex(1),
282
- xl: stepFromIndex(2),
283
- "2xl": stepFromIndex(3),
284
- "3xl": stepFromIndex(4),
285
- "4xl": stepFromIndex(5),
286
- "5xl": stepFromIndex(6),
287
- "6xl": stepFromIndex(7),
288
- };
289
-
290
- // Body line-height: weighted average ratio for body-sized observations.
291
- let bodyLhWeighted = 0;
292
- let bodyLhWeight = 0;
293
- for (const o of bodyCandidates) {
294
- if (o.fontSize > 0) {
295
- bodyLhWeighted += (o.lineHeight / o.fontSize) * o.count;
296
- bodyLhWeight += o.count;
297
- }
298
- }
299
- const bodyLineHeight = bodyLhWeight > 0 ? round2(bodyLhWeighted / bodyLhWeight) : 1.55;
300
-
301
- // Heading line-height: tighter
302
- const headingLineHeight = round2(clamp(bodyLineHeight - 0.4, 1.05, 1.25));
303
-
304
- // Body letter-spacing in em.
305
- let lsWeighted = 0;
306
- let lsWeight = 0;
307
- for (const o of bodyCandidates) {
308
- if (o.fontSize > 0) {
309
- lsWeighted += (o.letterSpacing / o.fontSize) * o.count;
310
- lsWeight += o.count;
311
- }
312
- }
313
- const bodyLetterSpacingEm = lsWeight > 0 ? round3(lsWeighted / lsWeight) : 0;
314
-
315
- return {
316
- fontSans,
317
- fontMono,
318
- fontSansSubstituted: sansSubstituted,
319
- fontMonoSubstituted: monoSubstituted,
320
- basePx,
321
- scaleRatio: round3(scaleRatio),
322
- steps,
323
- bodyLineHeight,
324
- headingLineHeight,
325
- bodyLetterSpacingEm,
326
- };
327
- }
328
-
329
- function defaultTypography(): TypeScale {
330
- return {
331
- fontSans: SUBSTITUTE_SANS,
332
- fontMono: SUBSTITUTE_MONO,
333
- fontSansSubstituted: false,
334
- fontMonoSubstituted: false,
335
- basePx: 16,
336
- scaleRatio: 1.25,
337
- steps: {
338
- xs: 12,
339
- sm: 14,
340
- base: 16,
341
- lg: 18,
342
- xl: 20,
343
- "2xl": 24,
344
- "3xl": 30,
345
- "4xl": 36,
346
- "5xl": 48,
347
- "6xl": 60,
348
- },
349
- bodyLineHeight: 1.55,
350
- headingLineHeight: 1.15,
351
- bodyLetterSpacingEm: 0,
352
- };
353
- }
354
-
355
- /* -------------------------------------------------------------------------- */
356
- /* Spacing */
357
- /* -------------------------------------------------------------------------- */
358
-
359
- function synthesizeSpacing(
360
- observations: RawTokens["spacing"],
361
- containers: number[],
362
- ): SpacingScale {
363
- // Snap every observed value to the nearest 4 px, count weighted, take the
364
- // top buckets, sort, dedupe.
365
- const buckets = new Map<number, number>();
366
- for (const o of observations) {
367
- const snapped = Math.round(o.px / 4) * 4;
368
- if (snapped < 4 || snapped > 192) continue;
369
- buckets.set(snapped, (buckets.get(snapped) ?? 0) + o.count);
370
- }
371
- const sortedSteps = Array.from(buckets.entries())
372
- .sort((a, b) => b[1] - a[1])
373
- .slice(0, 12)
374
- .map(([px]) => px)
375
- .sort((a, b) => a - b);
376
-
377
- const steps = sortedSteps.length > 0 ? sortedSteps : [4, 8, 12, 16, 24, 32, 48, 64, 96];
378
-
379
- const containerPx =
380
- containers.length > 0 ? Math.round(median(containers) / 8) * 8 : 1152;
381
-
382
- return { basePx: 4, steps, containerPx };
383
- }
384
-
385
- /* -------------------------------------------------------------------------- */
386
- /* Radius and shadows */
387
- /* -------------------------------------------------------------------------- */
388
-
389
- function synthesizeRadius(observations: RawTokens["radii"]): RadiusScale {
390
- if (observations.length === 0) return { basePx: 10 };
391
- const buckets = new Map<number, number>();
392
- for (const o of observations) {
393
- const snapped = Math.round(o.px / 2) * 2;
394
- if (snapped <= 0) continue;
395
- buckets.set(snapped, (buckets.get(snapped) ?? 0) + o.count);
396
- }
397
- let bestPx = 10;
398
- let bestScore = 0;
399
- for (const [px, count] of buckets) {
400
- if (px < 4 || px > 24) continue;
401
- if (count > bestScore) {
402
- bestScore = count;
403
- bestPx = px;
404
- }
405
- }
406
- return { basePx: bestPx };
407
- }
408
-
409
- function synthesizeShadows(): ShadowScale {
410
- // Shadow synthesis: generic but tasteful three-stop scale derived from
411
- // shadcn defaults. We avoid copying any specific site's exact stack.
412
- return {
413
- sm: "0 1px 2px 0 rgb(0 0 0 / 0.04)",
414
- md: "0 4px 12px -2px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.04)",
415
- lg: "0 12px 32px -8px rgb(0 0 0 / 0.12), 0 4px 12px -4px rgb(0 0 0 / 0.06)",
416
- };
417
- }
418
-
419
- /* -------------------------------------------------------------------------- */
420
- /* Color helpers */
421
- /* -------------------------------------------------------------------------- */
422
-
423
- function pickByAreaAndExtreme(
424
- candidates: ColorWithHsl[],
425
- extreme: "lightest" | "darkest",
426
- ): string | null {
427
- if (candidates.length === 0) return null;
428
- // Aggregate area by hex.
429
- const agg = new Map<string, ColorWithHsl>();
430
- for (const c of candidates) {
431
- const existing = agg.get(c.hex);
432
- if (existing) existing.area += c.area;
433
- else agg.set(c.hex, { ...c });
434
- }
435
- // Drop colors that are too rare (< 1% of total area).
436
- const total = Array.from(agg.values()).reduce((s, c) => s + c.area, 0);
437
- const abundant = Array.from(agg.values()).filter((c) => c.area >= total * 0.01);
438
- if (abundant.length === 0) return null;
439
- abundant.sort((a, b) =>
440
- extreme === "lightest" ? b.l - a.l : a.l - b.l,
441
- );
442
- return abundant[0]!.hex;
443
- }
444
-
445
- function dominantByArea(candidates: ColorWithHsl[]): { h: number; s: number; l: number } | null {
446
- if (candidates.length === 0) return null;
447
- // Cluster by 30-degree hue buckets.
448
- const buckets = new Map<number, { weight: number; h: number; s: number; l: number }>();
449
- for (const c of candidates) {
450
- const bucket = Math.round(c.h / 30) * 30;
451
- const existing = buckets.get(bucket);
452
- if (existing) {
453
- existing.weight += c.area;
454
- existing.h = (existing.h + c.h) / 2;
455
- existing.s = (existing.s + c.s) / 2;
456
- existing.l = (existing.l + c.l) / 2;
457
- } else {
458
- buckets.set(bucket, { weight: c.area, h: c.h, s: c.s, l: c.l });
459
- }
460
- }
461
- let best: { weight: number; h: number; s: number; l: number } | null = null;
462
- for (const v of buckets.values()) {
463
- if (!best || v.weight > best.weight) best = v;
464
- }
465
- return best ? { h: best.h, s: best.s, l: best.l } : null;
466
- }
467
-
468
- function pickReadableForeground(hex: string): string {
469
- const hsl = hexToHsl(hex);
470
- if (!hsl) return "#ffffff";
471
- return hsl.l < 0.5 ? "#ffffff" : "#0a0a0a";
472
- }
473
-
474
- function shiftLightness(hex: string, delta: number): string {
475
- const hsl = hexToHsl(hex);
476
- if (!hsl) return hex;
477
- return hslToHex({ h: hsl.h, s: hsl.s, l: clamp(hsl.l + delta, 0, 1) });
478
- }
479
-
480
- function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
481
- const m = hex.match(/^#([0-9a-fA-F]{6})$/);
482
- if (!m) return null;
483
- const num = parseInt(m[1]!, 16);
484
- const r = ((num >> 16) & 0xff) / 255;
485
- const g = ((num >> 8) & 0xff) / 255;
486
- const b = (num & 0xff) / 255;
487
- const max = Math.max(r, g, b);
488
- const min = Math.min(r, g, b);
489
- const l = (max + min) / 2;
490
- let h = 0;
491
- let s = 0;
492
- if (max !== min) {
493
- const d = max - min;
494
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
495
- switch (max) {
496
- case r:
497
- h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
498
- break;
499
- case g:
500
- h = ((b - r) / d + 2) * 60;
501
- break;
502
- default:
503
- h = ((r - g) / d + 4) * 60;
504
- }
505
- }
506
- return { h, s, l };
507
- }
508
-
509
- function hslToHex({ h, s, l }: { h: number; s: number; l: number }): string {
510
- const c = (1 - Math.abs(2 * l - 1)) * s;
511
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
512
- const m = l - c / 2;
513
- let r = 0;
514
- let g = 0;
515
- let b = 0;
516
- if (h < 60) [r, g, b] = [c, x, 0];
517
- else if (h < 120) [r, g, b] = [x, c, 0];
518
- else if (h < 180) [r, g, b] = [0, c, x];
519
- else if (h < 240) [r, g, b] = [0, x, c];
520
- else if (h < 300) [r, g, b] = [x, 0, c];
521
- else [r, g, b] = [c, 0, x];
522
- const to = (v: number) =>
523
- Math.round((v + m) * 255)
524
- .toString(16)
525
- .padStart(2, "0");
526
- return `#${to(r)}${to(g)}${to(b)}`;
527
- }
528
-
529
- /* -------------------------------------------------------------------------- */
530
- /* Tiny helpers */
531
- /* -------------------------------------------------------------------------- */
532
-
533
- function clamp(v: number, lo: number, hi: number): number {
534
- return Math.max(lo, Math.min(hi, v));
535
- }
536
-
537
- function round2(v: number): number {
538
- return Math.round(v * 100) / 100;
539
- }
540
-
541
- function round3(v: number): number {
542
- return Math.round(v * 1000) / 1000;
543
- }
544
-
545
- function median(values: number[]): number {
546
- const sorted = [...values].sort((a, b) => a - b);
547
- const mid = Math.floor(sorted.length / 2);
548
- return sorted.length % 2 === 0
549
- ? (sorted[mid - 1]! + sorted[mid]!) / 2
550
- : sorted[mid]!;
551
- }