@plannotator/ui 0.39.0 → 0.40.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/HANDOFF.md +99 -10
- package/README.md +3 -3
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/HtmlSurfaceControls.tsx +188 -23
- package/components/ListMarker.tsx +10 -1
- package/components/MermaidBlock.tsx +39 -5
- package/components/TableOfContents.tsx +5 -1
- package/components/TerminalToolsAnnouncementDialog.tsx +454 -0
- package/components/Viewer.tsx +25 -1
- package/components/blocks/AlertBlock.tsx +7 -2
- package/components/html-viewer/HtmlViewer.tsx +32 -0
- package/components/html-viewer/bridge-script.asset.js +96 -9
- package/components/html-viewer/bridge-script.ts +96 -9
- package/components/html-viewer/useHtmlAnnotation.ts +44 -151
- package/hooks/useAnnotationHighlighter.ts +464 -14
- package/hooks/useLinkedDoc.ts +100 -9
- package/package.json +3 -3
- package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +21 -7
- package/styles.css +1 -1
- package/theme.css +62 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/htmlChrome.ts +70 -5
- package/utils/htmlLinkNavigation.ts +196 -0
- package/utils/mermaid-eager.ts +13 -11
- package/utils/mermaid.ts +19 -10
- package/utils/mermaidTheme.ts +732 -0
- package/utils/parser.ts +21 -6
- package/utils/terminalToolsAnnouncement.ts +76 -0
package/theme.css
CHANGED
|
@@ -243,6 +243,68 @@
|
|
|
243
243
|
animation: vim-announcement-dialog-in 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/* First-run terminal-tools announcement: same entrance as the Vim one, and
|
|
247
|
+
* under reduced motion the panel still fades in, it just does not travel. */
|
|
248
|
+
.terminal-tools-announcement-backdrop {
|
|
249
|
+
animation: vim-announcement-backdrop-in 160ms ease-out both;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.terminal-tools-announcement-dialog {
|
|
253
|
+
animation: vim-announcement-dialog-in 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
@media (prefers-reduced-motion: reduce) {
|
|
257
|
+
.terminal-tools-announcement-dialog {
|
|
258
|
+
animation: vim-announcement-backdrop-in 160ms ease-out both;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* The "New · Watch:" corner block on the announcement footage, flush to the
|
|
263
|
+
* frame's top-left edge. Colors come from the dialog's own --announce-brand*
|
|
264
|
+
* variables (Herdr Annotate's badge palette, deliberately not app tokens); the
|
|
265
|
+
* periwinkle edge runs only along the two sides that meet the footage. The
|
|
266
|
+
* sheen is a slow diagonal highlight across the whole block every 4s, never a
|
|
267
|
+
* blink; the dialog drops the --sheen class under reduced motion and the media
|
|
268
|
+
* query below is the backstop. */
|
|
269
|
+
.terminal-tools-announcement-tag {
|
|
270
|
+
position: absolute;
|
|
271
|
+
overflow: hidden;
|
|
272
|
+
background: var(--announce-brand, #312b52);
|
|
273
|
+
color: var(--announce-brand-text, #c9c6f1);
|
|
274
|
+
border-right: 1px solid color-mix(in srgb, var(--announce-brand-accent, #B9C0FF) 55%, transparent);
|
|
275
|
+
border-bottom: 1px solid color-mix(in srgb, var(--announce-brand-accent, #B9C0FF) 55%, transparent);
|
|
276
|
+
box-shadow:
|
|
277
|
+
2px 2px 0 rgba(0, 0, 0, 0.18),
|
|
278
|
+
4px 6px 18px rgba(0, 0, 0, 0.32);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
.terminal-tools-announcement-tag--sheen::after {
|
|
282
|
+
content: '';
|
|
283
|
+
position: absolute;
|
|
284
|
+
inset: -1px;
|
|
285
|
+
pointer-events: none;
|
|
286
|
+
background: linear-gradient(
|
|
287
|
+
115deg,
|
|
288
|
+
transparent 38%,
|
|
289
|
+
color-mix(in srgb, var(--announce-brand-accent, #B9C0FF) 34%, transparent) 50%,
|
|
290
|
+
transparent 62%
|
|
291
|
+
);
|
|
292
|
+
transform: translateX(-100%);
|
|
293
|
+
animation: terminal-tools-announcement-sheen 4s ease-in-out infinite;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
@keyframes terminal-tools-announcement-sheen {
|
|
297
|
+
0% { transform: translateX(-100%); }
|
|
298
|
+
45%, 100% { transform: translateX(100%); }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
@media (prefers-reduced-motion: reduce) {
|
|
302
|
+
.terminal-tools-announcement-tag--sheen::after {
|
|
303
|
+
animation: none;
|
|
304
|
+
display: none;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
246
308
|
.vim-announcement-switch {
|
|
247
309
|
transition:
|
|
248
310
|
border-color 140ms ease,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-file annotation scope (multi-document annotate sessions).
|
|
3
|
+
*
|
|
4
|
+
* A folder session spreads one body of feedback across many documents, but the
|
|
5
|
+
* annotations panel only ever showed the open file's comments. This module owns
|
|
6
|
+
* the pure half of the "All files" view: which documents have feedback, how
|
|
7
|
+
* they are ordered and labelled, and which scope the panel opens on.
|
|
8
|
+
*
|
|
9
|
+
* Everything here is pure except the two preference accessors, which use the
|
|
10
|
+
* shared storage backend (cookies by default) like the other panel prefs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { normalizeBrowserPath, pathIsInsideDir } from '@plannotator/core/browser-paths';
|
|
14
|
+
import type { Annotation } from '../types';
|
|
15
|
+
import { getItem, setItem } from './storage';
|
|
16
|
+
|
|
17
|
+
/** `current` = only the open document (the incumbent view). `all` = every document. */
|
|
18
|
+
export type AnnotationScope = 'current' | 'all';
|
|
19
|
+
|
|
20
|
+
const ANNOTATION_SCOPE_KEY = 'plannotator-annotation-scope';
|
|
21
|
+
|
|
22
|
+
/** The user's explicit last choice, or null when they have never chosen. */
|
|
23
|
+
export function getAnnotationScopePreference(): AnnotationScope | null {
|
|
24
|
+
const raw = getItem(ANNOTATION_SCOPE_KEY);
|
|
25
|
+
return raw === 'all' || raw === 'current' ? raw : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function setAnnotationScopePreference(scope: AnnotationScope): void {
|
|
29
|
+
setItem(ANNOTATION_SCOPE_KEY, scope);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Group key for the open document when it has no path of its own — plan review,
|
|
34
|
+
* where the plan is the document and `sourceFilePath` is annotate-only. Without
|
|
35
|
+
* a key the root document forms no group at all, so the "All files" view hid the
|
|
36
|
+
* plan's own comments while listing every linked document's. It contains no path
|
|
37
|
+
* separator, so `normalizeBrowserPath` leaves it alone, and the angle brackets
|
|
38
|
+
* keep it from colliding with a real file path (illegal in Windows paths, and
|
|
39
|
+
* absent from the absolute paths these groups carry).
|
|
40
|
+
*/
|
|
41
|
+
export const ROOT_DOCUMENT_GROUP_KEY = '<plannotator-root-document>';
|
|
42
|
+
|
|
43
|
+
export interface AnnotationDocumentInput {
|
|
44
|
+
/** Absolute path of the document. */
|
|
45
|
+
path: string;
|
|
46
|
+
annotations: readonly Annotation[];
|
|
47
|
+
/** Display name override. Only the pathless root document needs one: every
|
|
48
|
+
* other group derives its label from its path. */
|
|
49
|
+
label?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AnnotationDocumentGroup {
|
|
53
|
+
path: string;
|
|
54
|
+
/** Path relative to the nearest session root, else the bare file name. */
|
|
55
|
+
label: string;
|
|
56
|
+
annotations: Annotation[];
|
|
57
|
+
isCurrent: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Display name for a document: its path relative to the deepest session root
|
|
62
|
+
* that contains it (the same `${dir}/${node.path}` shape the file browser
|
|
63
|
+
* renders), falling back to the bare file name when no root matches.
|
|
64
|
+
*/
|
|
65
|
+
export function documentLabel(path: string, roots: readonly string[] = []): string {
|
|
66
|
+
const normalized = normalizeBrowserPath(path);
|
|
67
|
+
let best = '';
|
|
68
|
+
for (const root of roots) {
|
|
69
|
+
if (!root) continue;
|
|
70
|
+
const normalizedRoot = normalizeBrowserPath(root);
|
|
71
|
+
if (!pathIsInsideDir(normalized, normalizedRoot)) continue;
|
|
72
|
+
if (normalizedRoot.length > best.length) best = normalizedRoot;
|
|
73
|
+
}
|
|
74
|
+
if (best && normalized.length > best.length) {
|
|
75
|
+
return normalized.slice(best.endsWith('/') ? best.length : best.length + 1);
|
|
76
|
+
}
|
|
77
|
+
const lastSlash = normalized.lastIndexOf('/');
|
|
78
|
+
return lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* One group per document that actually carries feedback, the open document
|
|
83
|
+
* first and the rest ordered by path. Documents without annotations are
|
|
84
|
+
* dropped: the view answers "where is my feedback", not "what files exist".
|
|
85
|
+
*/
|
|
86
|
+
export function groupAnnotationsByDocument(
|
|
87
|
+
documents: Iterable<AnnotationDocumentInput>,
|
|
88
|
+
currentPath: string | null,
|
|
89
|
+
roots: readonly string[] = [],
|
|
90
|
+
): AnnotationDocumentGroup[] {
|
|
91
|
+
const normalizedCurrent = currentPath ? normalizeBrowserPath(currentPath) : null;
|
|
92
|
+
const groups: AnnotationDocumentGroup[] = [];
|
|
93
|
+
const seen = new Set<string>();
|
|
94
|
+
for (const doc of documents) {
|
|
95
|
+
const path = normalizeBrowserPath(doc.path);
|
|
96
|
+
if (!path || seen.has(path) || doc.annotations.length === 0) continue;
|
|
97
|
+
seen.add(path);
|
|
98
|
+
groups.push({
|
|
99
|
+
path,
|
|
100
|
+
label: doc.label ?? documentLabel(path, roots),
|
|
101
|
+
annotations: [...doc.annotations],
|
|
102
|
+
isCurrent: path === normalizedCurrent,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
groups.sort((a, b) => {
|
|
106
|
+
if (a.isCurrent !== b.isCurrent) return a.isCurrent ? -1 : 1;
|
|
107
|
+
return a.path.localeCompare(b.path);
|
|
108
|
+
});
|
|
109
|
+
return groups;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The panel's groups for a session: every cached document plus the OPEN one,
|
|
114
|
+
* whose live list (externals included) is the same set its "This file" timeline
|
|
115
|
+
* renders — the cache copy behind it can be stale.
|
|
116
|
+
*
|
|
117
|
+
* The open document is always contributed, under `current.key`. In plan review
|
|
118
|
+
* that key is {@link ROOT_DOCUMENT_GROUP_KEY}: the plan has no path, and keying
|
|
119
|
+
* groups by path alone dropped it from the list, so the "All files" view hid
|
|
120
|
+
* the reviewer's own comments on the document in front of them.
|
|
121
|
+
*/
|
|
122
|
+
export function buildAnnotationDocumentGroups(input: {
|
|
123
|
+
/** Documents held in the linked-doc cache, keyed by path. */
|
|
124
|
+
cached: Iterable<readonly [string, readonly Annotation[]]>;
|
|
125
|
+
current: { key: string; label?: string; annotations: readonly Annotation[] };
|
|
126
|
+
roots?: readonly string[];
|
|
127
|
+
}): AnnotationDocumentGroup[] {
|
|
128
|
+
const byPath = new Map<string, readonly Annotation[]>();
|
|
129
|
+
for (const [path, annotations] of input.cached) byPath.set(path, annotations);
|
|
130
|
+
byPath.set(input.current.key, input.current.annotations);
|
|
131
|
+
return groupAnnotationsByDocument(
|
|
132
|
+
Array.from(byPath, ([path, annotations]) => ({
|
|
133
|
+
path,
|
|
134
|
+
annotations,
|
|
135
|
+
label: path === input.current.key ? input.current.label : undefined,
|
|
136
|
+
})),
|
|
137
|
+
input.current.key,
|
|
138
|
+
input.roots ?? [],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Which scope the panel opens on for the document that just became active.
|
|
144
|
+
*
|
|
145
|
+
* The saved preference wins, with one exception that is the whole point of the
|
|
146
|
+
* feature: landing on a file with no feedback while feedback exists elsewhere
|
|
147
|
+
* would otherwise show "No annotations yet" next to a session full of comments.
|
|
148
|
+
*/
|
|
149
|
+
export function resolveInitialAnnotationScope(input: {
|
|
150
|
+
saved: AnnotationScope | null;
|
|
151
|
+
/** Annotations on the document that is open now. */
|
|
152
|
+
currentCount: number;
|
|
153
|
+
/** Annotations on every other document in the session. */
|
|
154
|
+
otherCount: number;
|
|
155
|
+
}): AnnotationScope {
|
|
156
|
+
if (input.saved === 'all') return 'all';
|
|
157
|
+
if (input.currentCount === 0 && input.otherCount > 0) return 'all';
|
|
158
|
+
return 'current';
|
|
159
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small, dependency-free CSS colour toolkit for theme-derived rendering.
|
|
3
|
+
*
|
|
4
|
+
* Plannotator's palettes write their tokens as hex, `rgb()` and `oklch()`
|
|
5
|
+
* (see `packages/ui/themes/*.css`), and a browser's computed value for a
|
|
6
|
+
* colour can also come back as `oklab()`, `lab()`, `lch()`, `hsl()` or
|
|
7
|
+
* `color(srgb ...)`. Mermaid's own colour library (khroma) understands only
|
|
8
|
+
* the legacy syntaxes, so anything handed to it as a theme variable must first
|
|
9
|
+
* be reduced to an opaque hex string. That reduction, plus the perceptual
|
|
10
|
+
* mixing and the WCAG contrast arithmetic the diagram theme is built on, live
|
|
11
|
+
* here so `mermaidTheme.ts` reads as a mapping rather than as colour math.
|
|
12
|
+
*
|
|
13
|
+
* Browser-free on purpose: every function is pure and runs under plain `bun
|
|
14
|
+
* test`, which is what lets the contrast guard be unit-tested per palette.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** sRGB colour, channels 0..1, straight (non-premultiplied) alpha. */
|
|
18
|
+
export interface RgbColor {
|
|
19
|
+
r: number;
|
|
20
|
+
g: number;
|
|
21
|
+
b: number;
|
|
22
|
+
a: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
26
|
+
|
|
27
|
+
const NAMED_COLORS: Record<string, string> = {
|
|
28
|
+
white: '#ffffff',
|
|
29
|
+
black: '#000000',
|
|
30
|
+
red: '#ff0000',
|
|
31
|
+
green: '#008000',
|
|
32
|
+
blue: '#0000ff',
|
|
33
|
+
yellow: '#ffff00',
|
|
34
|
+
navy: '#000080',
|
|
35
|
+
grey: '#808080',
|
|
36
|
+
gray: '#808080',
|
|
37
|
+
lightgrey: '#d3d3d3',
|
|
38
|
+
lightgray: '#d3d3d3',
|
|
39
|
+
darkgrey: '#a9a9a9',
|
|
40
|
+
darkgray: '#a9a9a9',
|
|
41
|
+
silver: '#c0c0c0',
|
|
42
|
+
orange: '#ffa500',
|
|
43
|
+
purple: '#800080',
|
|
44
|
+
teal: '#008080',
|
|
45
|
+
transparent: '#00000000',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse one CSS colour value into sRGB. Returns `undefined` for anything it
|
|
50
|
+
* does not understand (a `var()` reference, `color-mix()`, an empty string),
|
|
51
|
+
* which callers treat as "token absent".
|
|
52
|
+
*
|
|
53
|
+
* Supported: `#rgb[a]`, `#rrggbb[aa]`, `rgb()`/`rgba()` (comma or space
|
|
54
|
+
* syntax, percentages, `/ alpha`), `hsl()`/`hsla()`, `oklch()`, `oklab()`,
|
|
55
|
+
* `lab()`, `lch()`, `color(srgb|srgb-linear|display-p3 ...)`, and a handful of
|
|
56
|
+
* named colours. Wide-gamut input is clipped to sRGB per channel.
|
|
57
|
+
*/
|
|
58
|
+
export function parseCssColor(input: string | null | undefined): RgbColor | undefined {
|
|
59
|
+
if (typeof input !== 'string') return undefined;
|
|
60
|
+
const value = input.trim().toLowerCase();
|
|
61
|
+
if (!value) return undefined;
|
|
62
|
+
|
|
63
|
+
if (value.startsWith('#')) return parseHex(value);
|
|
64
|
+
if (value in NAMED_COLORS) return parseHex(NAMED_COLORS[value]);
|
|
65
|
+
|
|
66
|
+
const fn = value.match(/^([a-z-]+)\((.*)\)$/s);
|
|
67
|
+
if (!fn) return undefined;
|
|
68
|
+
const name = fn[1];
|
|
69
|
+
const body = fn[2].trim();
|
|
70
|
+
|
|
71
|
+
switch (name) {
|
|
72
|
+
case 'rgb':
|
|
73
|
+
case 'rgba':
|
|
74
|
+
return parseRgbFunction(body);
|
|
75
|
+
case 'hsl':
|
|
76
|
+
case 'hsla':
|
|
77
|
+
return parseHslFunction(body);
|
|
78
|
+
case 'oklch':
|
|
79
|
+
return parseOklchFunction(body);
|
|
80
|
+
case 'oklab':
|
|
81
|
+
return parseOklabFunction(body);
|
|
82
|
+
case 'lab':
|
|
83
|
+
return parseLabFunction(body);
|
|
84
|
+
case 'lch':
|
|
85
|
+
return parseLchFunction(body);
|
|
86
|
+
case 'color':
|
|
87
|
+
return parseColorFunction(body);
|
|
88
|
+
default:
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseHex(value: string): RgbColor | undefined {
|
|
94
|
+
const hex = value.slice(1);
|
|
95
|
+
if (!/^[0-9a-f]+$/.test(hex)) return undefined;
|
|
96
|
+
let r: number;
|
|
97
|
+
let g: number;
|
|
98
|
+
let b: number;
|
|
99
|
+
let a = 255;
|
|
100
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
101
|
+
r = parseInt(hex[0] + hex[0], 16);
|
|
102
|
+
g = parseInt(hex[1] + hex[1], 16);
|
|
103
|
+
b = parseInt(hex[2] + hex[2], 16);
|
|
104
|
+
if (hex.length === 4) a = parseInt(hex[3] + hex[3], 16);
|
|
105
|
+
} else if (hex.length === 6 || hex.length === 8) {
|
|
106
|
+
r = parseInt(hex.slice(0, 2), 16);
|
|
107
|
+
g = parseInt(hex.slice(2, 4), 16);
|
|
108
|
+
b = parseInt(hex.slice(4, 6), 16);
|
|
109
|
+
if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16);
|
|
110
|
+
} else {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
return { r: r / 255, g: g / 255, b: b / 255, a: a / 255 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Split a function body into channel tokens and an optional `/ alpha`. */
|
|
117
|
+
function splitChannels(body: string): { parts: string[]; alpha: string | undefined } | undefined {
|
|
118
|
+
let main = body;
|
|
119
|
+
let alpha: string | undefined;
|
|
120
|
+
const slash = body.indexOf('/');
|
|
121
|
+
if (slash >= 0) {
|
|
122
|
+
main = body.slice(0, slash);
|
|
123
|
+
alpha = body.slice(slash + 1).trim();
|
|
124
|
+
}
|
|
125
|
+
const parts = main
|
|
126
|
+
.split(/[\s,]+/)
|
|
127
|
+
.map((p) => p.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
if (alpha === undefined && parts.length === 4) {
|
|
130
|
+
// legacy `rgba(r, g, b, a)` / `hsla(h, s, l, a)`
|
|
131
|
+
alpha = parts.pop();
|
|
132
|
+
}
|
|
133
|
+
if (parts.length !== 3) return undefined;
|
|
134
|
+
return { parts, alpha };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseNumber(token: string, scale = 1): number | undefined {
|
|
138
|
+
if (token === 'none') return 0;
|
|
139
|
+
const m = token.match(/^(-?\d*\.?\d+(?:e[-+]?\d+)?)(%|deg|rad|grad|turn)?$/);
|
|
140
|
+
if (!m) return undefined;
|
|
141
|
+
const n = Number.parseFloat(m[1]);
|
|
142
|
+
if (!Number.isFinite(n)) return undefined;
|
|
143
|
+
switch (m[2]) {
|
|
144
|
+
case '%':
|
|
145
|
+
return (n / 100) * scale;
|
|
146
|
+
case 'rad':
|
|
147
|
+
return (n * 180) / Math.PI;
|
|
148
|
+
case 'grad':
|
|
149
|
+
return n * 0.9;
|
|
150
|
+
case 'turn':
|
|
151
|
+
return n * 360;
|
|
152
|
+
default:
|
|
153
|
+
return n;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseAlpha(token: string | undefined): number | undefined {
|
|
158
|
+
if (token === undefined) return 1;
|
|
159
|
+
const a = parseNumber(token, 1);
|
|
160
|
+
return a === undefined ? undefined : clamp01(a);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseRgbFunction(body: string): RgbColor | undefined {
|
|
164
|
+
const split = splitChannels(body);
|
|
165
|
+
if (!split) return undefined;
|
|
166
|
+
const ch = split.parts.map((p) => (p.endsWith('%') ? parseNumber(p, 1) : (parseNumber(p) ?? NaN) / 255));
|
|
167
|
+
const a = parseAlpha(split.alpha);
|
|
168
|
+
if (ch.some((v) => v === undefined || Number.isNaN(v)) || a === undefined) return undefined;
|
|
169
|
+
return { r: clamp01(ch[0] as number), g: clamp01(ch[1] as number), b: clamp01(ch[2] as number), a };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseHslFunction(body: string): RgbColor | undefined {
|
|
173
|
+
const split = splitChannels(body);
|
|
174
|
+
if (!split) return undefined;
|
|
175
|
+
const h = parseNumber(split.parts[0]);
|
|
176
|
+
const s = parseNumber(split.parts[1], 1);
|
|
177
|
+
const l = parseNumber(split.parts[2], 1);
|
|
178
|
+
const a = parseAlpha(split.alpha);
|
|
179
|
+
if (h === undefined || s === undefined || l === undefined || a === undefined) return undefined;
|
|
180
|
+
const { r, g, b } = hslToRgb(((h % 360) + 360) % 360, clamp01(s), clamp01(l));
|
|
181
|
+
return { r, g, b, a };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
|
185
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
186
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
187
|
+
const m = l - c / 2;
|
|
188
|
+
let rgb: [number, number, number];
|
|
189
|
+
if (h < 60) rgb = [c, x, 0];
|
|
190
|
+
else if (h < 120) rgb = [x, c, 0];
|
|
191
|
+
else if (h < 180) rgb = [0, c, x];
|
|
192
|
+
else if (h < 240) rgb = [0, x, c];
|
|
193
|
+
else if (h < 300) rgb = [x, 0, c];
|
|
194
|
+
else rgb = [c, 0, x];
|
|
195
|
+
return { r: rgb[0] + m, g: rgb[1] + m, b: rgb[2] + m };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseOklchFunction(body: string): RgbColor | undefined {
|
|
199
|
+
const split = splitChannels(body);
|
|
200
|
+
if (!split) return undefined;
|
|
201
|
+
const L = parseNumber(split.parts[0], 1);
|
|
202
|
+
const C = parseNumber(split.parts[1], 0.4);
|
|
203
|
+
const H = parseNumber(split.parts[2]);
|
|
204
|
+
const a = parseAlpha(split.alpha);
|
|
205
|
+
if (L === undefined || C === undefined || H === undefined || a === undefined) return undefined;
|
|
206
|
+
return { ...oklchToRgb({ L, C, H }), a };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseOklabFunction(body: string): RgbColor | undefined {
|
|
210
|
+
const split = splitChannels(body);
|
|
211
|
+
if (!split) return undefined;
|
|
212
|
+
const L = parseNumber(split.parts[0], 1);
|
|
213
|
+
const aa = parseNumber(split.parts[1], 0.4);
|
|
214
|
+
const bb = parseNumber(split.parts[2], 0.4);
|
|
215
|
+
const a = parseAlpha(split.alpha);
|
|
216
|
+
if (L === undefined || aa === undefined || bb === undefined || a === undefined) return undefined;
|
|
217
|
+
return { ...oklabToRgb({ L, a: aa, b: bb }), a };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseLabFunction(body: string): RgbColor | undefined {
|
|
221
|
+
const split = splitChannels(body);
|
|
222
|
+
if (!split) return undefined;
|
|
223
|
+
const L = parseNumber(split.parts[0], 100);
|
|
224
|
+
const aa = parseNumber(split.parts[1], 125);
|
|
225
|
+
const bb = parseNumber(split.parts[2], 125);
|
|
226
|
+
const a = parseAlpha(split.alpha);
|
|
227
|
+
if (L === undefined || aa === undefined || bb === undefined || a === undefined) return undefined;
|
|
228
|
+
return { ...xyzToRgb(labToXyz(L, aa, bb)), a };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseLchFunction(body: string): RgbColor | undefined {
|
|
232
|
+
const split = splitChannels(body);
|
|
233
|
+
if (!split) return undefined;
|
|
234
|
+
const L = parseNumber(split.parts[0], 100);
|
|
235
|
+
const C = parseNumber(split.parts[1], 150);
|
|
236
|
+
const H = parseNumber(split.parts[2]);
|
|
237
|
+
const a = parseAlpha(split.alpha);
|
|
238
|
+
if (L === undefined || C === undefined || H === undefined || a === undefined) return undefined;
|
|
239
|
+
const rad = (H * Math.PI) / 180;
|
|
240
|
+
return { ...xyzToRgb(labToXyz(L, C * Math.cos(rad), C * Math.sin(rad))), a };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parseColorFunction(body: string): RgbColor | undefined {
|
|
244
|
+
const m = body.match(/^([a-z0-9-]+)\s+(.*)$/s);
|
|
245
|
+
if (!m) return undefined;
|
|
246
|
+
const space = m[1];
|
|
247
|
+
const split = splitChannels(m[2]);
|
|
248
|
+
if (!split) return undefined;
|
|
249
|
+
const ch = split.parts.map((p) => parseNumber(p, 1));
|
|
250
|
+
const a = parseAlpha(split.alpha);
|
|
251
|
+
if (ch.some((v) => v === undefined) || a === undefined) return undefined;
|
|
252
|
+
const [x, y, z] = ch as [number, number, number];
|
|
253
|
+
switch (space) {
|
|
254
|
+
case 'srgb':
|
|
255
|
+
return { r: clamp01(x), g: clamp01(y), b: clamp01(z), a };
|
|
256
|
+
case 'srgb-linear':
|
|
257
|
+
return { r: clamp01(linearToSrgb(x)), g: clamp01(linearToSrgb(y)), b: clamp01(linearToSrgb(z)), a };
|
|
258
|
+
case 'display-p3': {
|
|
259
|
+
const lin = [srgbToLinear(x), srgbToLinear(y), srgbToLinear(z)];
|
|
260
|
+
// display-p3 (linear) -> XYZ D65
|
|
261
|
+
const X = 0.4865709 * lin[0] + 0.2656677 * lin[1] + 0.1982173 * lin[2];
|
|
262
|
+
const Y = 0.2289746 * lin[0] + 0.6917385 * lin[1] + 0.0792869 * lin[2];
|
|
263
|
+
const Z = 0.0 * lin[0] + 0.0451134 * lin[1] + 1.0439444 * lin[2];
|
|
264
|
+
return { ...xyzToRgb({ X, Y, Z }), a };
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Colour spaces
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
export function srgbToLinear(c: number): number {
|
|
276
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function linearToSrgb(c: number): number {
|
|
280
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
interface Xyz {
|
|
284
|
+
X: number;
|
|
285
|
+
Y: number;
|
|
286
|
+
Z: number;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function xyzToRgb({ X, Y, Z }: Xyz): { r: number; g: number; b: number } {
|
|
290
|
+
const rl = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z;
|
|
291
|
+
const gl = -0.969266 * X + 1.8760108 * Y + 0.041556 * Z;
|
|
292
|
+
const bl = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z;
|
|
293
|
+
return { r: clamp01(linearToSrgb(rl)), g: clamp01(linearToSrgb(gl)), b: clamp01(linearToSrgb(bl)) };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** CIE Lab (D50 white as CSS specifies) -> XYZ D65 via Bradford. */
|
|
297
|
+
function labToXyz(L: number, a: number, b: number): Xyz {
|
|
298
|
+
const fy = (L + 16) / 116;
|
|
299
|
+
const fx = fy + a / 500;
|
|
300
|
+
const fz = fy - b / 200;
|
|
301
|
+
const e = 216 / 24389;
|
|
302
|
+
const k = 24389 / 27;
|
|
303
|
+
const xr = Math.pow(fx, 3) > e ? Math.pow(fx, 3) : (116 * fx - 16) / k;
|
|
304
|
+
const yr = L > k * e ? Math.pow((L + 16) / 116, 3) : L / k;
|
|
305
|
+
const zr = Math.pow(fz, 3) > e ? Math.pow(fz, 3) : (116 * fz - 16) / k;
|
|
306
|
+
// D50 reference white
|
|
307
|
+
const X50 = xr * 0.3457 / 0.3585;
|
|
308
|
+
const Y50 = yr;
|
|
309
|
+
const Z50 = zr * (1 - 0.3457 - 0.3585) / 0.3585;
|
|
310
|
+
// Bradford D50 -> D65
|
|
311
|
+
return {
|
|
312
|
+
X: 0.9554734 * X50 - 0.0230985 * Y50 + 0.0632593 * Z50,
|
|
313
|
+
Y: -0.0283697 * X50 + 1.0099954 * Y50 + 0.0210413 * Z50,
|
|
314
|
+
Z: 0.0123141 * X50 - 0.0205050 * Y50 + 1.3299098 * Z50,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** OKLab, the perceptual space every mix and lightness edit here is done in. */
|
|
319
|
+
export interface Oklab {
|
|
320
|
+
L: number;
|
|
321
|
+
a: number;
|
|
322
|
+
b: number;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface Oklch {
|
|
326
|
+
L: number;
|
|
327
|
+
C: number;
|
|
328
|
+
H: number;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function rgbToOklab({ r, g, b }: { r: number; g: number; b: number }): Oklab {
|
|
332
|
+
const lr = srgbToLinear(r);
|
|
333
|
+
const lg = srgbToLinear(g);
|
|
334
|
+
const lb = srgbToLinear(b);
|
|
335
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
336
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
337
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
338
|
+
return {
|
|
339
|
+
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
340
|
+
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
341
|
+
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function oklabToRgb({ L, a, b }: Oklab): { r: number; g: number; b: number } {
|
|
346
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
347
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
348
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
349
|
+
const l = l_ * l_ * l_;
|
|
350
|
+
const m = m_ * m_ * m_;
|
|
351
|
+
const s = s_ * s_ * s_;
|
|
352
|
+
const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
353
|
+
const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
354
|
+
const lb = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
355
|
+
return { r: clamp01(linearToSrgb(lr)), g: clamp01(linearToSrgb(lg)), b: clamp01(linearToSrgb(lb)) };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function oklabToOklch({ L, a, b }: Oklab): Oklch {
|
|
359
|
+
const C = Math.sqrt(a * a + b * b);
|
|
360
|
+
let H = (Math.atan2(b, a) * 180) / Math.PI;
|
|
361
|
+
if (H < 0) H += 360;
|
|
362
|
+
return { L, C, H };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function oklchToOklab({ L, C, H }: Oklch): Oklab {
|
|
366
|
+
const rad = (H * Math.PI) / 180;
|
|
367
|
+
return { L, a: C * Math.cos(rad), b: C * Math.sin(rad) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function oklchToRgb(c: Oklch): { r: number; g: number; b: number } {
|
|
371
|
+
return oklabToRgb(oklchToOklab(c));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function rgbToOklch(c: { r: number; g: number; b: number }): Oklch {
|
|
375
|
+
return oklabToOklch(rgbToOklab(c));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Compositing, mixing, contrast
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/** Alpha-composite `top` over an opaque `under` (source-over). */
|
|
383
|
+
export function compositeOver(top: RgbColor, under: RgbColor): RgbColor {
|
|
384
|
+
const a = clamp01(top.a);
|
|
385
|
+
if (a >= 1) return { r: top.r, g: top.g, b: top.b, a: 1 };
|
|
386
|
+
return {
|
|
387
|
+
r: top.r * a + under.r * (1 - a),
|
|
388
|
+
g: top.g * a + under.g * (1 - a),
|
|
389
|
+
b: top.b * a + under.b * (1 - a),
|
|
390
|
+
a: 1,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Perceptual mix in OKLab: `t = 0` is `from`, `t = 1` is `to`. Alpha ignored. */
|
|
395
|
+
export function mixOklab(from: RgbColor, to: RgbColor, t: number): RgbColor {
|
|
396
|
+
const k = clamp01(t);
|
|
397
|
+
const a = rgbToOklab(from);
|
|
398
|
+
const b = rgbToOklab(to);
|
|
399
|
+
const rgb = oklabToRgb({ L: a.L + (b.L - a.L) * k, a: a.a + (b.a - a.a) * k, b: a.b + (b.b - a.b) * k });
|
|
400
|
+
return { ...rgb, a: 1 };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** WCAG relative luminance of an opaque sRGB colour. */
|
|
404
|
+
export function relativeLuminance({ r, g, b }: { r: number; g: number; b: number }): number {
|
|
405
|
+
return 0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** WCAG 2.x contrast ratio between two opaque colours (1..21). */
|
|
409
|
+
export function contrastRatio(a: { r: number; g: number; b: number }, b: { r: number; g: number; b: number }): number {
|
|
410
|
+
const la = relativeLuminance(a);
|
|
411
|
+
const lb = relativeLuminance(b);
|
|
412
|
+
const light = Math.max(la, lb);
|
|
413
|
+
const dark = Math.min(la, lb);
|
|
414
|
+
return (light + 0.05) / (dark + 0.05);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Round to the 8-bit sRGB grid a hex string carries, so a contrast measured
|
|
418
|
+
* here equals the contrast of the colour Mermaid actually receives. */
|
|
419
|
+
export function quantize(color: RgbColor): RgbColor {
|
|
420
|
+
const q = (v: number) => Math.round(clamp01(v) * 255) / 255;
|
|
421
|
+
return { r: q(color.r), g: q(color.g), b: q(color.b), a: 1 };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** `#rrggbb` (alpha dropped; callers composite first when it matters). */
|
|
425
|
+
export function toHex({ r, g, b }: { r: number; g: number; b: number }): string {
|
|
426
|
+
const h = (v: number) =>
|
|
427
|
+
Math.round(clamp01(v) * 255)
|
|
428
|
+
.toString(16)
|
|
429
|
+
.padStart(2, '0');
|
|
430
|
+
return `#${h(r)}${h(g)}${h(b)}`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Parse, then composite any alpha over `backdrop` so the result is opaque. */
|
|
434
|
+
export function parseOpaqueColor(value: string | null | undefined, backdrop: RgbColor): RgbColor | undefined {
|
|
435
|
+
const parsed = parseCssColor(value);
|
|
436
|
+
if (!parsed) return undefined;
|
|
437
|
+
return compositeOver(parsed, backdrop);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Return a copy of `color` with its OKLCH lightness set to `L` (0..1). */
|
|
441
|
+
export function withOklchLightness(color: RgbColor, L: number): RgbColor {
|
|
442
|
+
const lch = rgbToOklch(color);
|
|
443
|
+
return { ...oklchToRgb({ ...lch, L: clamp01(L) }), a: 1 };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Return a copy of `color` with its OKLCH chroma capped at `maxC`. */
|
|
447
|
+
export function withMaxChroma(color: RgbColor, maxC: number): RgbColor {
|
|
448
|
+
const lch = rgbToOklch(color);
|
|
449
|
+
if (lch.C <= maxC) return { ...color, a: 1 };
|
|
450
|
+
return { ...oklchToRgb({ ...lch, C: maxC }), a: 1 };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Rotate hue by `deg` in OKLCH. */
|
|
454
|
+
export function rotateHue(color: RgbColor, deg: number): RgbColor {
|
|
455
|
+
const lch = rgbToOklch(color);
|
|
456
|
+
return { ...oklchToRgb({ ...lch, H: (((lch.H + deg) % 360) + 360) % 360 }), a: 1 };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Smallest angular distance between two hues (degrees, 0..180). */
|
|
460
|
+
export function hueDistance(a: number, b: number): number {
|
|
461
|
+
const d = Math.abs(((a - b) % 360) + 360) % 360;
|
|
462
|
+
return d > 180 ? 360 - d : d;
|
|
463
|
+
}
|