@heyramzi/ui 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPONENTS.md +2 -2
- package/README.md +6 -1
- package/package.json +8 -2
- package/src/agents/agent-avatar.svelte +639 -0
- package/src/agents/agent-cast.svelte +173 -0
- package/src/agents/demo.svelte +78 -0
- package/src/agents/index.ts +11 -0
- package/src/agents/types.ts +72 -0
- package/{LICENSE → src/agents/vendor/LICENSE} +1 -1
- package/src/agents/vendor/color.ts +155 -0
- package/src/agents/vendor/draw.ts +840 -0
- package/src/agents/vendor/engine.ts +845 -0
- package/src/agents/vendor/mesh.ts +838 -0
- package/src/agents/vendor/plastic.ts +4958 -0
- package/src/agents/vendor/presets.ts +103 -0
- package/src/agents/vendor/shapes.ts +41 -0
- package/src/agents/vendor/ticker.ts +67 -0
- package/src/agents/vendor/types.ts +261 -0
- package/src/agents/vendor/wear.ts +1699 -0
|
@@ -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
|
+
};
|
|
@@ -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
|
+
}
|