@plannotator/ui 0.38.2 → 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 -9
- package/README.md +4 -4
- package/components/AnnotationPanel.tsx +244 -13
- package/components/CommentPopover.tsx +91 -2
- package/components/DecisionControl.tsx +0 -10
- 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 +533 -9
- package/components/html-viewer/bridge-script.ts +533 -9
- package/components/html-viewer/useHtmlAnnotation.ts +71 -4
- 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/types.ts +45 -0
- package/utils/annotationScope.ts +159 -0
- package/utils/cssColor.ts +463 -0
- package/utils/decisionSpec.ts +1 -4
- 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 +150 -4
- package/utils/terminalToolsAnnouncement.ts +76 -0
|
@@ -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
|
+
}
|
package/utils/decisionSpec.ts
CHANGED
|
@@ -35,7 +35,6 @@ export interface DecisionPrimary {
|
|
|
35
35
|
title: string; // tooltip / aria description
|
|
36
36
|
tone: Exclude<DecisionTone, 'destructive'>;
|
|
37
37
|
icon?: 'check' | 'send';
|
|
38
|
-
count?: number; // rendered as the inline pill; omitted when 0
|
|
39
38
|
/**
|
|
40
39
|
* Platform self-approval (PR6, §3.4): rendered dimmed but NOT disabled.
|
|
41
40
|
* The reason surfaces through the shared Tooltip + aria-describedby (the
|
|
@@ -87,7 +86,7 @@ export interface DecisionSpecInput {
|
|
|
87
86
|
app: 'annotate' | 'review';
|
|
88
87
|
/** Annotate: `gate`. Review: always true — review's primary decision IS approval. */
|
|
89
88
|
gate: boolean;
|
|
90
|
-
/** The count
|
|
89
|
+
/** The annotation count interpolated into the menu's discard and note copy (never the primary label). */
|
|
91
90
|
count: number;
|
|
92
91
|
/**
|
|
93
92
|
* Whether there is anything to send. Deliberately separate from `count`:
|
|
@@ -327,7 +326,6 @@ function buildFeedbackSpec(input: DecisionSpecInput, approvalFlow: boolean): Dec
|
|
|
327
326
|
title: 'Send your feedback to the agent',
|
|
328
327
|
tone: 'primary',
|
|
329
328
|
icon: 'send',
|
|
330
|
-
count: count > 0 ? count : undefined,
|
|
331
329
|
},
|
|
332
330
|
items,
|
|
333
331
|
};
|
|
@@ -395,7 +393,6 @@ function buildPlatformSpec(input: DecisionSpecInput, platform: DecisionPlatformI
|
|
|
395
393
|
title: `Post review to ${platform.label}`,
|
|
396
394
|
tone: 'primary',
|
|
397
395
|
icon: 'send',
|
|
398
|
-
count: count > 0 ? count : undefined,
|
|
399
396
|
},
|
|
400
397
|
items: [
|
|
401
398
|
{
|
package/utils/htmlChrome.ts
CHANGED
|
@@ -16,9 +16,16 @@ import { isStalePreference } from './preferenceTtl';
|
|
|
16
16
|
*
|
|
17
17
|
* `toolsHidden` is the header "Hide tools" toggle: while true, ALL floating
|
|
18
18
|
* chrome over the page (sidebar tongue tabs + the comment/attachments
|
|
19
|
-
* cluster) is removed from the DOM.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* cluster) is removed from the DOM. It DEFAULTS to true — an HTML document is
|
|
20
|
+
* authored to fill the viewport, so a first-ever session shows the page and
|
|
21
|
+
* nothing else, and the header eye (plus Mod+Shift+X) is what reveals the
|
|
22
|
+
* tools. Defaulting hidden can never strand a user for the same reason
|
|
23
|
+
* restoring hidden can't: the control that flips it back lives in the header,
|
|
24
|
+
* never in the hidden chrome. A fresh persisted record still wins in both
|
|
25
|
+
* directions, so a user who showed the tools keeps them next session —
|
|
26
|
+
* including in a folder annotate session, which restores and records the
|
|
27
|
+
* `toolsHidden` half while leaving the sidebar/panel halves to sessions whose
|
|
28
|
+
* sidebar it actually owns (see {@link mergeHtmlChromeState}).
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
const STORAGE_KEY = 'plannotator-html-chrome';
|
|
@@ -32,11 +39,14 @@ export interface HtmlChromeState {
|
|
|
32
39
|
toolsHidden: boolean;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Default: both side surfaces closed AND the floating tools hidden — the page
|
|
44
|
+
* gets the whole viewport until the user asks for the tools.
|
|
45
|
+
*/
|
|
36
46
|
export const DEFAULT_HTML_CHROME_STATE: HtmlChromeState = {
|
|
37
47
|
sidebarOpen: false,
|
|
38
48
|
panelOpen: false,
|
|
39
|
-
toolsHidden:
|
|
49
|
+
toolsHidden: true,
|
|
40
50
|
};
|
|
41
51
|
|
|
42
52
|
/** Pure resolution logic (exported for tests): raw cookie value → state. */
|
|
@@ -68,6 +78,61 @@ export function resolveHtmlChromeState(
|
|
|
68
78
|
}
|
|
69
79
|
}
|
|
70
80
|
|
|
81
|
+
/** Inputs to the restore-on-entry decision (see {@link shouldRestoreHtmlChrome}). */
|
|
82
|
+
export interface HtmlChromeRestoreConditions {
|
|
83
|
+
/** The surface being rendered now is raw HTML or a live app. */
|
|
84
|
+
isHtmlSurface: boolean;
|
|
85
|
+
/** The surface rendered on the previous pass was too. */
|
|
86
|
+
wasHtmlSurface: boolean;
|
|
87
|
+
/** This session takes no part in the persisted chrome at all (archive, goal
|
|
88
|
+
* setup): nothing is restored and nothing is written. A folder annotate
|
|
89
|
+
* session is NOT suppressed — see {@link mergeHtmlChromeState}. */
|
|
90
|
+
suppressed: boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether to apply the persisted chrome state, i.e. whether this render is an
|
|
95
|
+
* ENTRY into an HTML surface.
|
|
96
|
+
*
|
|
97
|
+
* The `wasHtmlSurface` term is what keeps navigation between two HTML
|
|
98
|
+
* documents from re-running the restore: `toolsHidden` defaults to true and a
|
|
99
|
+
* link click deliberately leaves the sidebar alone, so a re-run mid-session
|
|
100
|
+
* would flip both back under the user. Leaving an HTML surface for a markdown
|
|
101
|
+
* one and returning IS an entry, and restores again on purpose — that is what
|
|
102
|
+
* stops the markdown surface's sidebar state from leaking into the HTML
|
|
103
|
+
* cookie.
|
|
104
|
+
*/
|
|
105
|
+
export function shouldRestoreHtmlChrome(conditions: HtmlChromeRestoreConditions): boolean {
|
|
106
|
+
if (!conditions.isHtmlSurface) return false;
|
|
107
|
+
if (conditions.wasHtmlSurface) return false;
|
|
108
|
+
return !conditions.suppressed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The record to persist when the chrome changes on an HTML surface.
|
|
113
|
+
*
|
|
114
|
+
* `sideSurfacesOwned` marks a session where the left sidebar and the
|
|
115
|
+
* annotations drawer are not this surface's to remember — a folder annotate
|
|
116
|
+
* session, whose file browser owns the sidebar for the whole session. Those two
|
|
117
|
+
* halves then keep whatever the last ordinary HTML session left, while the
|
|
118
|
+
* `toolsHidden` half is recorded normally: it is a property of the HTML surface
|
|
119
|
+
* itself and means the same thing in every session.
|
|
120
|
+
*/
|
|
121
|
+
export function mergeHtmlChromeState(input: {
|
|
122
|
+
/** The record on disk right now. */
|
|
123
|
+
persisted: HtmlChromeState;
|
|
124
|
+
/** The chrome this session is in. */
|
|
125
|
+
live: HtmlChromeState;
|
|
126
|
+
sideSurfacesOwned: boolean;
|
|
127
|
+
}): HtmlChromeState {
|
|
128
|
+
if (!input.sideSurfacesOwned) return input.live;
|
|
129
|
+
return {
|
|
130
|
+
sidebarOpen: input.persisted.sidebarOpen,
|
|
131
|
+
panelOpen: input.persisted.panelOpen,
|
|
132
|
+
toolsHidden: input.live.toolsHidden,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
71
136
|
export function getHtmlChromeState(): HtmlChromeState {
|
|
72
137
|
return resolveHtmlChromeState(storage.getItem(STORAGE_KEY));
|
|
73
138
|
}
|