@heyramzi/ui 6.0.1 → 7.0.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.
Files changed (114) hide show
  1. package/COMPONENTS.md +7 -26
  2. package/README.md +12 -5
  3. package/package.json +14 -16
  4. package/scripts/sync-shadcn.mjs +12 -3
  5. package/src/agents/agent-avatar.svelte +639 -0
  6. package/src/agents/agent-cast.svelte +173 -0
  7. package/src/agents/demo.svelte +78 -0
  8. package/src/agents/index.ts +11 -0
  9. package/src/agents/types.ts +72 -0
  10. package/{LICENSE → src/agents/vendor/LICENSE} +1 -1
  11. package/src/agents/vendor/color.ts +155 -0
  12. package/src/agents/vendor/draw.ts +840 -0
  13. package/src/agents/vendor/engine.ts +845 -0
  14. package/src/agents/vendor/mesh.ts +838 -0
  15. package/src/agents/vendor/plastic.ts +4958 -0
  16. package/src/agents/vendor/presets.ts +103 -0
  17. package/src/agents/vendor/shapes.ts +41 -0
  18. package/src/agents/vendor/ticker.ts +67 -0
  19. package/src/agents/vendor/types.ts +261 -0
  20. package/src/agents/vendor/wear.ts +1699 -0
  21. package/src/ai/index.ts +0 -18
  22. package/src/animations/README.md +3 -3
  23. package/src/lib/sliding-pill.ts +68 -0
  24. package/src/lib/theme-transition.ts +4 -2
  25. package/src/shadcn/code-block/code-block-actions.svelte +23 -0
  26. package/src/shadcn/code-block/code-block-content.svelte +55 -0
  27. package/src/shadcn/code-block/code-block-copy-button.svelte +59 -0
  28. package/src/shadcn/code-block/code-block-footer.svelte +20 -0
  29. package/src/shadcn/code-block/code-block-header.svelte +23 -0
  30. package/src/shadcn/code-block/code-block-line.svelte +71 -0
  31. package/src/shadcn/code-block/code-block-title.svelte +23 -0
  32. package/src/shadcn/code-block/code-block.svelte +47 -0
  33. package/src/shadcn/code-block/index.ts +42 -0
  34. package/src/shadcn/hooks/highlighter.ts +132 -0
  35. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +98 -0
  36. package/src/shadcn/kbd/index.ts +10 -0
  37. package/src/shadcn/kbd/kbd-group.svelte +20 -0
  38. package/src/shadcn/kbd/kbd.svelte +23 -0
  39. package/src/shadcn/message-scroller/index.ts +28 -0
  40. package/src/shadcn/message-scroller/message-scroller-button.svelte +52 -0
  41. package/src/shadcn/message-scroller/message-scroller-content.svelte +17 -0
  42. package/src/shadcn/message-scroller/message-scroller-item.svelte +22 -0
  43. package/src/shadcn/message-scroller/message-scroller-provider.svelte +7 -0
  44. package/src/shadcn/message-scroller/message-scroller-viewport.svelte +20 -0
  45. package/src/shadcn/message-scroller/message-scroller.svelte +20 -0
  46. package/src/shadcn/tabs/tabs-list.svelte +13 -2
  47. package/src/shadcn/tabs/tabs-trigger.svelte +1 -1
  48. package/src/shadcn/trace/index.ts +29 -0
  49. package/src/shadcn/trace/trace-collapsible-content.svelte +29 -0
  50. package/src/shadcn/trace/trace-collapsible-trigger.svelte +29 -0
  51. package/src/shadcn/trace/trace-collapsible.svelte +20 -0
  52. package/src/shadcn/trace/trace-description.svelte +20 -0
  53. package/src/shadcn/trace/trace-icon.svelte +24 -0
  54. package/src/shadcn/trace/trace-line.svelte +31 -0
  55. package/src/shadcn/trace/trace-title.svelte +20 -0
  56. package/src/shadcn/trace/trace.svelte +23 -0
  57. package/src/showcase/AiSection.svelte +0 -7
  58. package/src/showcase/demos/shadcn/code-block.svelte +80 -0
  59. package/src/showcase/demos/shadcn/index.ts +24 -0
  60. package/src/showcase/demos/shadcn/message-scroller.svelte +44 -0
  61. package/src/showcase/demos/shadcn/trace.svelte +51 -0
  62. package/src/showcase/kit.ts +1 -2
  63. package/src/ai/conversation/Conversation.svelte +0 -28
  64. package/src/ai/conversation/ConversationContent.svelte +0 -35
  65. package/src/ai/conversation/ConversationEmptyState.svelte +0 -46
  66. package/src/ai/conversation/ConversationScrollButton.svelte +0 -51
  67. package/src/ai/conversation/index.ts +0 -33
  68. package/src/ai/conversation/stick-to-bottom-context.svelte.ts +0 -131
  69. package/src/charts/animated-value.css +0 -7
  70. package/src/charts/animated-value.svelte +0 -110
  71. package/src/charts/area-chart.css +0 -29
  72. package/src/charts/area-chart.svelte +0 -195
  73. package/src/charts/bar-chart.css +0 -119
  74. package/src/charts/bar-chart.svelte +0 -269
  75. package/src/charts/charts.css +0 -201
  76. package/src/charts/crosshair.svelte +0 -35
  77. package/src/charts/donut-chart.css +0 -56
  78. package/src/charts/donut-chart.svelte +0 -158
  79. package/src/charts/edge-fade.svelte +0 -43
  80. package/src/charts/edge-fade.ts +0 -16
  81. package/src/charts/figure.css +0 -100
  82. package/src/charts/figure.svelte +0 -122
  83. package/src/charts/gauge-chart.css +0 -94
  84. package/src/charts/gauge-chart.svelte +0 -303
  85. package/src/charts/hover-band.test.ts +0 -30
  86. package/src/charts/hover-band.ts +0 -21
  87. package/src/charts/hover-focus.svelte +0 -85
  88. package/src/charts/index.ts +0 -30
  89. package/src/charts/legend.css +0 -131
  90. package/src/charts/legend.svelte +0 -112
  91. package/src/charts/line-chart.css +0 -26
  92. package/src/charts/line-chart.svelte +0 -256
  93. package/src/charts/motion.ts +0 -19
  94. package/src/charts/number-chart.css +0 -168
  95. package/src/charts/number-chart.svelte +0 -177
  96. package/src/charts/primitives/chart-container.svelte +0 -57
  97. package/src/charts/primitives/chart-style.svelte +0 -36
  98. package/src/charts/primitives/chart-tooltip.svelte +0 -161
  99. package/src/charts/primitives/chart-utils.ts +0 -54
  100. package/src/charts/primitives/index.ts +0 -11
  101. package/src/charts/reveal.svelte +0 -61
  102. package/src/charts/ring-chart.css +0 -96
  103. package/src/charts/ring-chart.svelte +0 -158
  104. package/src/charts/sparkline.css +0 -45
  105. package/src/charts/sparkline.svelte +0 -140
  106. package/src/charts/stat-card.css +0 -103
  107. package/src/charts/stat-card.svelte +0 -267
  108. package/src/charts/time-series-chart.css +0 -28
  109. package/src/charts/time-series-chart.svelte +0 -179
  110. package/src/charts/trend-pill.css +0 -38
  111. package/src/charts/trend-pill.svelte +0 -48
  112. package/src/lib/hooks/is-mobile.svelte.ts +0 -11
  113. package/src/showcase/ChartsSection.svelte +0 -458
  114. package/src/showcase/demos/ai/conversation.svelte +0 -59
@@ -0,0 +1,173 @@
1
+ <script lang="ts">
2
+ import AgentAvatar from './agent-avatar.svelte';
3
+ import type { Agent } from './types';
4
+
5
+ type Props = {
6
+ /** The cast to lay out. */
7
+ agents: Agent[];
8
+ /** Selected agent id, when the cast is a picker. */
9
+ selectedId?: string | null;
10
+ /** Called with the agent that was picked. */
11
+ onSelect?: (agent: Agent) => void;
12
+ /** Hide the role line under each name. */
13
+ showRole?: boolean;
14
+ /** Extra props forwarded to every `<AgentAvatar>`. */
15
+ avatarProps?: Record<string, unknown>;
16
+ /** Minimum card width, any CSS length. Controls the column count. */
17
+ minColumn?: string;
18
+ class?: string;
19
+ style?: string;
20
+ };
21
+
22
+ let {
23
+ agents,
24
+ selectedId = null,
25
+ onSelect,
26
+ showRole = true,
27
+ avatarProps = {},
28
+ minColumn = '8.5rem',
29
+ class: className,
30
+ style,
31
+ }: Props = $props();
32
+
33
+ const keyOf = (agent: Agent) => agent.id ?? agent.name;
34
+
35
+ /* Group when any agent asks for one, otherwise a single flat grid. */
36
+ const sections = $derived.by(() => {
37
+ const anyGroup = agents.some((agent) => agent.group);
38
+ if (!anyGroup) return [{ label: '', items: agents }];
39
+ const order: string[] = [];
40
+ const byGroup = new Map<string, Agent[]>();
41
+ for (const agent of agents) {
42
+ const label = agent.group ?? '';
43
+ let list = byGroup.get(label);
44
+ if (!list) {
45
+ list = [];
46
+ byGroup.set(label, list);
47
+ order.push(label);
48
+ }
49
+ list.push(agent);
50
+ }
51
+ return order.map((label) => ({ label, items: byGroup.get(label) ?? [] }));
52
+ });
53
+
54
+ const rootStyle = $derived(
55
+ `--agent-cast-min:${minColumn};${style ?? ''}`,
56
+ );
57
+ </script>
58
+
59
+ <div
60
+ class={className ? `agent-cast ${className}` : 'agent-cast'}
61
+ style={rootStyle}
62
+ >
63
+ {#each sections as section, s (section.label || `flat-${s}`)}
64
+ <section class="agent-cast__section">
65
+ {#if section.label}
66
+ <h3 class="agent-cast__heading">{section.label}</h3>
67
+ {/if}
68
+ <ul class="agent-cast__grid">
69
+ {#each section.items as agent (keyOf(agent))}
70
+ {@const id = keyOf(agent)}
71
+ <li>
72
+ <button
73
+ type="button"
74
+ class="agent-cast__card"
75
+ aria-current={id === selectedId ? 'true' : undefined}
76
+ onclick={() => onSelect?.(agent)}
77
+ >
78
+ <span class="agent-cast__slot">
79
+ <AgentAvatar
80
+ name={agent.name}
81
+ role={agent.role}
82
+ shape={agent.shape ?? 'clover'}
83
+ color={agent.color}
84
+ {...avatarProps}
85
+ />
86
+ </span>
87
+ <span class="agent-cast__meta">
88
+ <span class="agent-cast__name">{agent.name}</span>
89
+ {#if showRole && agent.role}
90
+ <span class="agent-cast__role">{agent.role}</span>
91
+ {/if}
92
+ </span>
93
+ </button>
94
+ </li>
95
+ {/each}
96
+ </ul>
97
+ </section>
98
+ {/each}
99
+ </div>
100
+
101
+ <style>
102
+ .agent-cast {
103
+ width: 100%;
104
+ color: var(--color-foreground, #18181b);
105
+ }
106
+ .agent-cast__section + .agent-cast__section {
107
+ margin-top: 2rem;
108
+ }
109
+ .agent-cast__heading {
110
+ margin: 0 0 0.75rem;
111
+ font-size: 0.9375rem;
112
+ font-weight: 600;
113
+ letter-spacing: -0.015em;
114
+ }
115
+ .agent-cast__grid {
116
+ display: grid;
117
+ gap: 0.75rem;
118
+ grid-template-columns: repeat(auto-fill, minmax(var(--agent-cast-min, 8.5rem), 1fr));
119
+ margin: 0;
120
+ padding: 0;
121
+ list-style: none;
122
+ }
123
+ .agent-cast__card {
124
+ display: flex;
125
+ flex-direction: column;
126
+ width: 100%;
127
+ height: 100%;
128
+ overflow: hidden;
129
+ padding: 0;
130
+ text-align: left;
131
+ background: var(--color-card, #fff);
132
+ border: 1px solid var(--color-border, #e4e4e7);
133
+ border-radius: 0.75rem;
134
+ cursor: pointer;
135
+ transition:
136
+ border-color 0.15s ease,
137
+ transform 0.15s ease;
138
+ }
139
+ .agent-cast__card:hover {
140
+ border-color: color-mix(in oklab, var(--color-foreground, #18181b) 25%, transparent);
141
+ }
142
+ .agent-cast__card:active {
143
+ transform: scale(0.985);
144
+ }
145
+ .agent-cast__card:focus-visible {
146
+ outline: 2px solid var(--color-ring, #6366f1);
147
+ outline-offset: 2px;
148
+ }
149
+ .agent-cast__card[aria-current='true'] {
150
+ border-color: var(--color-accent, #6366f1);
151
+ box-shadow: 0 0 0 1px var(--color-accent, #6366f1);
152
+ }
153
+ .agent-cast__slot {
154
+ display: block;
155
+ width: 100%;
156
+ }
157
+ .agent-cast__meta {
158
+ display: flex;
159
+ flex-direction: column;
160
+ gap: 0.125rem;
161
+ padding: 0.625rem;
162
+ }
163
+ .agent-cast__name {
164
+ font-size: 0.8125rem;
165
+ font-weight: 500;
166
+ line-height: 1.2;
167
+ }
168
+ .agent-cast__role {
169
+ font-size: 0.75rem;
170
+ line-height: 1.3;
171
+ color: var(--color-muted-foreground, #71717a);
172
+ }
173
+ </style>
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ import AgentAvatar from './agent-avatar.svelte';
3
+ import AgentCast from './agent-cast.svelte';
4
+ import type { Agent } from './types';
5
+
6
+ const cast: Agent[] = [
7
+ { id: 'ada', name: 'Ada', role: 'Researcher', shape: 'clover', color: '#41C4FF', group: 'Research' },
8
+ { id: 'bo', name: 'Bo', role: 'Analyst', shape: 'flower', group: 'Research' },
9
+ { id: 'cy', name: 'Cy', role: 'Planner', shape: 'mech', group: 'Planning' },
10
+ { id: 'dia', name: 'Dia', role: 'Scheduler', shape: 'droid', group: 'Planning' },
11
+ { id: 'eli', name: 'Eli', role: 'Writer', shape: 'cat', group: 'Craft' },
12
+ { id: 'fen', name: 'Fen', role: 'Editor', shape: 'pill', group: 'Craft' },
13
+ { id: 'gus', name: 'Gus', role: 'Runner', shape: 'ghost', group: 'Craft' },
14
+ { id: 'hal', name: 'Hal', role: 'Watcher', shape: 'star', group: 'Craft' },
15
+ ];
16
+
17
+ let selected = $state<string | null>('ada');
18
+ let lead = $derived(cast.find((agent) => agent.id === selected) ?? cast[0]);
19
+ </script>
20
+
21
+ <div class="demo">
22
+ <header class="demo__hero">
23
+ <div class="demo__portrait">
24
+ <AgentAvatar
25
+ name={lead.name}
26
+ role={lead.role}
27
+ shape={lead.shape}
28
+ color={lead.color}
29
+ state="working"
30
+ theme="light"
31
+ />
32
+ </div>
33
+ <div class="demo__intro">
34
+ <p class="demo__eyebrow">AgentCast</p>
35
+ <h1 class="demo__title">{lead.name}</h1>
36
+ <p class="demo__role">{lead.role}</p>
37
+ </div>
38
+ </header>
39
+
40
+ <AgentCast agents={cast} selectedId={selected} onSelect={(agent) => (selected = agent.id ?? agent.name)} />
41
+ </div>
42
+
43
+ <style>
44
+ .demo {
45
+ max-width: 56rem;
46
+ margin: 0 auto;
47
+ padding: 2rem 1.5rem 4rem;
48
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
49
+ color: #18181b;
50
+ background: #fafafa;
51
+ }
52
+ .demo__hero {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 1.5rem;
56
+ margin-bottom: 2.5rem;
57
+ }
58
+ .demo__portrait {
59
+ flex: none;
60
+ width: 9rem;
61
+ }
62
+ .demo__eyebrow {
63
+ margin: 0;
64
+ font-size: 0.75rem;
65
+ letter-spacing: 0.14em;
66
+ text-transform: uppercase;
67
+ color: #71717a;
68
+ }
69
+ .demo__title {
70
+ margin: 0.25rem 0 0;
71
+ font-size: 2.25rem;
72
+ letter-spacing: -0.03em;
73
+ }
74
+ .demo__role {
75
+ margin: 0.25rem 0 0;
76
+ color: #52525b;
77
+ }
78
+ </style>
@@ -0,0 +1,11 @@
1
+ export { default as AgentAvatar } from './agent-avatar.svelte';
2
+ export { default as AgentCast } from './agent-cast.svelte';
3
+ export type { Agent, AgentAvatarProps, AgentShape } from './types';
4
+ export {
5
+ agentPresets,
6
+ agentShapes,
7
+ agentFaces,
8
+ agentStates,
9
+ agentPalette,
10
+ agentStateLabels,
11
+ } from './types';
@@ -0,0 +1,72 @@
1
+ import type { BotAvatarFace, BotAvatarProps, BotAvatarType } from './vendor/types';
2
+
3
+ /** The body shapes an agent can be drawn as. */
4
+ export type AgentShape = BotAvatarType;
5
+
6
+ export type {
7
+ BotAvatarFace as AgentFace,
8
+ BotAvatarState as AgentState,
9
+ BotAvatarShading as AgentShading,
10
+ BotAvatarSquashEase as AgentSquashEase,
11
+ BotAvatarHat as AgentHat,
12
+ BotAvatarGlasses as AgentGlasses,
13
+ BotAvatarPreset as AgentPreset,
14
+ } from './vendor/types';
15
+
16
+ export {
17
+ botAvatarPresets as agentPresets,
18
+ botAvatarTypes as agentShapes,
19
+ botAvatarFaces as agentFaces,
20
+ botAvatarStates as agentStates,
21
+ botAvatarPalette as agentPalette,
22
+ stateLabels as agentStateLabels,
23
+ } from './vendor/presets';
24
+
25
+ /** One member of a cast: a name, a role, and the face they draw. */
26
+ export type Agent = {
27
+ /** Stable key. Falls back to `name` when missing. */
28
+ id?: string;
29
+ /** Display name. */
30
+ name: string;
31
+ /** What they do. Shown under the name, read out with it. */
32
+ role?: string;
33
+ /** Body shape. Defaults to the avatar's own. */
34
+ shape?: AgentShape;
35
+ /** Body colour. Defaults to the shape's palette colour. */
36
+ color?: string;
37
+ /** Optional section heading. Agents sharing a group render together. */
38
+ group?: string;
39
+ };
40
+
41
+ /* The vendored props carry an open `Record<string, unknown>` index signature
42
+ so the canvas can forward loose attributes. Strip it before Omit, otherwise
43
+ every named prop collapses to `unknown`. */
44
+ type KnownProps<T> = {
45
+ [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K];
46
+ };
47
+
48
+ /**
49
+ * Props for `<AgentAvatar>`. Every engine prop from the bot avatar is
50
+ * available; `shape`, `color`, `name` and `role` are the plain ones.
51
+ */
52
+ export type AgentAvatarProps = Omit<
53
+ KnownProps<BotAvatarProps>,
54
+ 'type' | 'face' | 'ink' | 'size' | 'className' | 'style'
55
+ > & {
56
+ /** Display name, for the default aria-label. */
57
+ name?: string;
58
+ /** What the agent does, read out with the name. */
59
+ role?: string;
60
+ /** Body shape. Default `clover`. */
61
+ shape?: AgentShape;
62
+ /** Face kind. Default `eyes`. */
63
+ face?: BotAvatarFace;
64
+ /** Face ink. Default `#1E1A33`. */
65
+ ink?: string;
66
+ /** Rendered size in px. Omit to fill 75% of the slot. */
67
+ size?: number;
68
+ class?: string;
69
+ style?: string;
70
+ onclick?: (event: MouseEvent) => void;
71
+ 'aria-label'?: string;
72
+ };
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Ramzi
3
+ Copyright (c) 2026 Jakub Antalik
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -0,0 +1,155 @@
1
+ // @ts-nocheck WHY: vendored engine, kept as upstream wrote it so a re-sync stays a clean diff. Strict flags in consumers (review) would flag ~870 spots.
2
+ /** Parse #rgb / #rrggbb / rgb() / hsl() into 0–255 channels; null for anything else. */
3
+ export function parseColor(input: string): [number, number, number] | null {
4
+ const s = input.trim();
5
+ const hsl = s.match(/^hsla?\(\s*([\d.]+)(?:deg)?[,\s]+([\d.]+)%[,\s]+([\d.]+)%/i);
6
+ if (hsl) return hslToRgb([Number(hsl[1]) / 360, Number(hsl[2]) / 100, Number(hsl[3]) / 100]);
7
+ const hex = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
8
+ if (hex) {
9
+ let h = hex[1];
10
+ if (h.length === 3)
11
+ h = h
12
+ .split("")
13
+ .map((c) => c + c)
14
+ .join("");
15
+ const n = parseInt(h, 16);
16
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
17
+ }
18
+ const rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
19
+ if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
20
+ return null;
21
+ }
22
+
23
+ /** Relative luminance (WCAG), 0–1. Unparseable colours count as mid-grey. */
24
+ export function luminance(color: string): number {
25
+ const c = parseColor(color);
26
+ if (!c) return 0.5;
27
+ const lin = (v: number) => {
28
+ const x = v / 255;
29
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
30
+ };
31
+ return 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2]);
32
+ }
33
+
34
+ export const DARK_INK = "#1E1A33";
35
+ export const LIGHT_INK = "#F7F5F2";
36
+
37
+ /** Face ink for a body colour: dark ink, or light ink on a dark body. */
38
+ export function autoInk(color: string): string {
39
+ return luminance(color) < 0.13 ? LIGHT_INK : DARK_INK;
40
+ }
41
+
42
+ /* ── HSL, for the body's shades ─────────────────────────────────────── */
43
+
44
+ function rgbToHsl([r, g, b]: [number, number, number]): [number, number, number] {
45
+ r /= 255;
46
+ g /= 255;
47
+ b /= 255;
48
+ const max = Math.max(r, g, b),
49
+ min = Math.min(r, g, b);
50
+ const l = (max + min) / 2;
51
+ if (max === min) return [0, 0, l];
52
+ const d = max - min;
53
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
54
+ let h = 0;
55
+ if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
56
+ else if (max === g) h = (b - r) / d + 2;
57
+ else h = (r - g) / d + 4;
58
+ return [h / 6, s, l];
59
+ }
60
+
61
+ function hslToRgb([h, s, l]: [number, number, number]): [number, number, number] {
62
+ if (s === 0) return [l * 255, l * 255, l * 255];
63
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
64
+ const p = 2 * l - q;
65
+ const f = (t: number) => {
66
+ t = ((t % 1) + 1) % 1;
67
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
68
+ if (t < 1 / 2) return q;
69
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
70
+ return p;
71
+ };
72
+ return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255];
73
+ }
74
+
75
+ function hslToCss([h, s, l]: [number, number, number]): string {
76
+ return `hsl(${(h * 360).toFixed(1)} ${(s * 100).toFixed(1)}% ${(l * 100).toFixed(1)}%)`;
77
+ }
78
+
79
+ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
80
+
81
+ /**
82
+ * A shade of a colour: `dl` moves the lightness (−1..1), `ds` the
83
+ * saturation. Darker shades get a touch more saturation so they stay
84
+ * rich instead of going grey, the way a painted surface falls into shadow.
85
+ */
86
+ export function shade(color: string, dl: number, ds = 0): string {
87
+ const c = parseColor(color);
88
+ if (!c) return color;
89
+ const [h, s, l] = rgbToHsl(c);
90
+ return hslToCss([h, clamp01(s + ds + (dl < 0 ? -dl * 0.25 : 0)), clamp01(l + dl)]);
91
+ }
92
+
93
+ /* OKLab, for moving a colour along its own hue (Björn Ottosson's) */
94
+ const toLinear = (u: number) => (u <= 0.04045 ? u / 12.92 : Math.pow((u + 0.055) / 1.055, 2.4));
95
+ const toSrgb = (u: number) => (u <= 0.0031308 ? 12.92 * u : 1.055 * Math.pow(u, 1 / 2.4) - 0.055);
96
+ function oklab(r: number, g: number, b: number): [number, number, number] {
97
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
98
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
99
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
100
+ return [
101
+ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
102
+ 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
103
+ 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
104
+ ];
105
+ }
106
+ function fromOklab(L: number, a: number, b: number): [number, number, number] {
107
+ const l = Math.pow(L + 0.3963377774 * a + 0.2158037573 * b, 3);
108
+ const m = Math.pow(L - 0.1055613458 * a - 0.0638541728 * b, 3);
109
+ const s = Math.pow(L - 0.0894841775 * a - 1.291485548 * b, 3);
110
+ return [
111
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
112
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
113
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
114
+ ];
115
+ }
116
+ const inGamut = (c: [number, number, number]) => c.every((v) => v >= -1e-4 && v <= 1 + 1e-4);
117
+
118
+ /** A more vivid version of a colour, `t` 0–1: its chroma raised along its
119
+ own hue (in OKLab) by up to half again. Where the screen cannot show that
120
+ at the same lightness, the colour deepens a little — by at most 15% of
121
+ its lightness — toward the hue's most vivid shade (a sky blue toward a
122
+ royal one), and the chroma stops at whatever the screen can show. */
123
+ export function richer(color: string, t: number): string {
124
+ const c = parseColor(color);
125
+ if (!c || !(t > 0)) return color;
126
+ const [L, a, b] = oklab(toLinear(c[0] / 255), toLinear(c[1] / 255), toLinear(c[2] / 255));
127
+ const C = Math.hypot(a, b);
128
+ if (C < 1e-4) return color;
129
+ const ua = a / C,
130
+ ub = b / C;
131
+ const want = C * (1 + 0.5 * t);
132
+ let best: [number, number, number] | null = null,
133
+ bestC = C;
134
+ /* the lightness from the colour's own down to 15% deeper, in steps; at
135
+ each, the most chroma the screen shows up to `want` */
136
+ for (let k = 0; k <= 6; k++) {
137
+ const Lk = L * (1 - (0.15 * t * k) / 6);
138
+ let lo = 0,
139
+ hi = want;
140
+ for (let it = 0; it < 18; it++) {
141
+ const mid = (lo + hi) / 2;
142
+ if (inGamut(fromOklab(Lk, ua * mid, ub * mid))) lo = mid;
143
+ else hi = mid;
144
+ }
145
+ /* deeper only for what it gains: each step must buy real chroma */
146
+ if (lo > bestC + 0.004 * k) {
147
+ bestC = lo;
148
+ best = fromOklab(Lk, ua * lo, ub * lo);
149
+ }
150
+ if (lo >= want - 1e-4) break;
151
+ }
152
+ if (!best) return color;
153
+ const out = best.map((v) => Math.round(255 * toSrgb(Math.min(1, Math.max(0, v)))));
154
+ return `rgb(${out[0]} ${out[1]} ${out[2]})`;
155
+ }