@plannotator/ui 0.24.0 → 0.25.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.
@@ -17,39 +17,16 @@ import { CommentPopover, type CommentAskAIHandler } from "../CommentPopover";
17
17
  import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
18
18
  import type { ViewerHandle } from "../Viewer";
19
19
  import { useHtmlAnnotation } from "./useHtmlAnnotation";
20
- import { ANNOTATION_HIGHLIGHT_CSS, BRIDGE_SCRIPT } from "./bridge-script";
20
+ import {
21
+ THEME_TOKENS,
22
+ buildSrcdocInjection,
23
+ buildThemeTokenPayload,
24
+ hasHostThemeOptIn,
25
+ injectIntoHead,
26
+ } from "./srcdoc";
21
27
 
22
28
  const PREFIX = "plannotator-bridge-";
23
29
 
24
- const THEME_TOKENS = [
25
- "--background",
26
- "--foreground",
27
- "--card",
28
- "--card-foreground",
29
- "--primary",
30
- "--primary-foreground",
31
- "--secondary",
32
- "--secondary-foreground",
33
- "--muted",
34
- "--muted-foreground",
35
- "--accent",
36
- "--accent-foreground",
37
- "--destructive",
38
- "--destructive-foreground",
39
- "--success",
40
- "--success-foreground",
41
- "--warning",
42
- "--warning-foreground",
43
- "--border",
44
- "--input",
45
- "--ring",
46
- "--code-bg",
47
- "--focus-highlight",
48
- "--font-sans",
49
- "--font-mono",
50
- "--radius",
51
- ] as const;
52
-
53
30
  function readThemeTokens(): Record<string, string> {
54
31
  const style = getComputedStyle(document.documentElement);
55
32
  const tokens: Record<string, string> = {};
@@ -124,26 +101,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
124
101
  contextText: string;
125
102
  } | null>(null);
126
103
 
127
- const srcdoc = useMemo(() => {
128
- const tokens = readThemeTokens();
129
- let themeCSS = ":root {\n";
130
- for (const [key, val] of Object.entries(tokens)) {
131
- themeCSS += ` ${key}: ${val};\n`;
132
- }
133
- themeCSS += "}\n";
134
- if (isLightTheme()) themeCSS += ":root { color-scheme: light; }\n:root.light, :root { }\n";
104
+ // Host theming is opt-in per document (Plannotator-generated artifacts tag
105
+ // themselves); arbitrary HTML renders untouched, like a standalone tab.
106
+ const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
135
107
 
136
- // Version-diff highlights: htmlDiff wraps changed text in <ins>/<del>.
137
- const diffCSS =
138
- "ins{background:#e6ffec;color:#0a7d33;text-decoration:none;border-radius:2px;box-shadow:0 0 0 1px #abf2bc inset}" +
139
- "del{background:#ffebe9;color:#b31d28;text-decoration:line-through;border-radius:2px;box-shadow:0 0 0 1px #ffc1bc inset}";
140
- const injection = `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style><script>${BRIDGE_SCRIPT}</script>`;
141
- const headClose = rawHtml.indexOf("</head>");
142
- if (headClose !== -1) {
143
- return rawHtml.slice(0, headClose) + injection + rawHtml.slice(headClose);
144
- }
145
- return injection + rawHtml;
146
- }, [rawHtml]);
108
+ const srcdoc = useMemo(() => {
109
+ const injection = buildSrcdocInjection({
110
+ tokens: readThemeTokens(),
111
+ isLight: isLightTheme(),
112
+ hostTheme,
113
+ diffActive: !!diffActive,
114
+ });
115
+ return injectIntoHead(rawHtml, injection);
116
+ }, [rawHtml, hostTheme, diffActive]);
147
117
 
148
118
  const handleResize = useCallback((height: number) => {
149
119
  setIframeHeight(height);
@@ -189,9 +159,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
189
159
  useEffect(() => {
190
160
  if (!iframeReady) return;
191
161
  function sendTheme() {
192
- const tokens = readThemeTokens();
193
162
  iframeRef.current?.contentWindow?.postMessage(
194
- { type: `${PREFIX}theme`, tokens, isLight: isLightTheme() },
163
+ {
164
+ type: `${PREFIX}theme`,
165
+ tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
166
+ isLight: isLightTheme(),
167
+ hostTheme,
168
+ },
195
169
  "*",
196
170
  );
197
171
  }
@@ -202,7 +176,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
202
176
  attributeFilter: ["class", "style"],
203
177
  });
204
178
  return () => observer.disconnect();
205
- }, [iframeReady]);
179
+ }, [iframeReady, hostTheme]);
206
180
 
207
181
  useImperativeHandle(ref, () => ({
208
182
  removeHighlight: hook.removeHighlight,
@@ -9,6 +9,11 @@
9
9
  * No external dependencies.
10
10
  */
11
11
 
12
+ /**
13
+ * Reads only viewer-namespaced \`--pn-*\` variables (with fallbacks): arbitrary
14
+ * documents may define bare token names like \`--accent\` for themselves, and the
15
+ * viewer must never depend on — or collide with — the author's namespace.
16
+ */
12
17
  export const ANNOTATION_HIGHLIGHT_CSS = `
13
18
  .annotation-highlight {
14
19
  border-radius: 2px;
@@ -17,33 +22,33 @@ export const ANNOTATION_HIGHLIGHT_CSS = `
17
22
  cursor: pointer;
18
23
  }
19
24
  .annotation-highlight.deletion {
20
- background: oklch(from var(--destructive, #c0392b) l c h / 0.35);
25
+ background: oklch(from var(--pn-destructive, #c0392b) l c h / 0.35);
21
26
  text-decoration: line-through;
22
- text-decoration-color: var(--destructive, #c0392b);
27
+ text-decoration-color: var(--pn-destructive, #c0392b);
23
28
  text-decoration-thickness: 2px;
24
29
  }
25
30
  .annotation-highlight.comment {
26
31
  background: oklch(0.70 0.18 60 / 0.3);
27
- border-bottom: 2px solid var(--accent, #d97757);
32
+ border-bottom: 2px solid var(--pn-accent, #d97757);
28
33
  }
29
34
  .annotation-highlight.focused {
30
- background: oklch(from var(--focus-highlight, #4493f8) l c h / 0.45) !important;
31
- box-shadow: 0 0 8px oklch(from var(--focus-highlight, #4493f8) l c h / 0.4);
32
- border-bottom: 2px solid var(--focus-highlight, #4493f8);
35
+ background: oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.45) !important;
36
+ box-shadow: 0 0 8px oklch(from var(--pn-focus-highlight, #4493f8) l c h / 0.4);
37
+ border-bottom: 2px solid var(--pn-focus-highlight, #4493f8);
33
38
  filter: none;
34
39
  }
35
40
  .annotation-highlight:hover {
36
41
  filter: brightness(1.2);
37
42
  }
38
43
  .plannotator-pinpoint-hover {
39
- outline: 2px solid var(--focus-highlight, #4493f8) !important;
44
+ outline: 2px solid var(--pn-focus-highlight, #4493f8) !important;
40
45
  outline-offset: 1px;
41
46
  cursor: crosshair !important;
42
47
  }
43
48
  /* SVG nodes can't take a CSS outline — stroke their shapes instead. */
44
49
  .plannotator-pinpoint-hover rect, .plannotator-pinpoint-hover path,
45
50
  .plannotator-pinpoint-hover circle, .plannotator-pinpoint-hover ellipse, .plannotator-pinpoint-hover polygon {
46
- stroke: var(--focus-highlight, #4493f8) !important; stroke-width: 2.5px !important;
51
+ stroke: var(--pn-focus-highlight, #4493f8) !important; stroke-width: 2.5px !important;
47
52
  }
48
53
  `;
49
54
 
@@ -51,15 +56,23 @@ export const BRIDGE_SCRIPT = `(function() {
51
56
  var PREFIX = 'plannotator-bridge-';
52
57
 
53
58
  // --- Theme ---
59
+ // The author owns this document. Unless it opted in to host theming
60
+ // (hostTheme), only viewer-namespaced --pn-* properties may be written to its
61
+ // root, and its class list is never touched.
54
62
  window.addEventListener('message', function(e) {
55
63
  if (!e.data || e.data.type !== PREFIX + 'theme') return;
56
64
  var root = document.documentElement;
57
- var tokens = e.data.tokens;
65
+ var tokens = e.data.tokens || {};
66
+ var hostTheme = !!e.data.hostTheme;
58
67
  for (var key in tokens) {
59
- if (tokens.hasOwnProperty(key)) root.style.setProperty(key, tokens[key]);
68
+ if (!tokens.hasOwnProperty(key)) continue;
69
+ if (!hostTheme && key.indexOf('--pn-') !== 0) continue;
70
+ root.style.setProperty(key, tokens[key]);
71
+ }
72
+ if (hostTheme) {
73
+ root.classList.remove('light');
74
+ if (e.data.isLight) root.classList.add('light');
60
75
  }
61
- root.classList.remove('light');
62
- if (e.data.isLight) root.classList.add('light');
63
76
  });
64
77
 
65
78
  // --- Resize ---
@@ -244,7 +257,7 @@ export const BRIDGE_SCRIPT = `(function() {
244
257
  if (!pinpointLabelEl) {
245
258
  pinpointLabelEl = document.createElement('div');
246
259
  pinpointLabelEl.setAttribute('data-plannotator-pinpoint-label', '');
247
- pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
260
+ pinpointLabelEl.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;display:none;font:600 11px/1.3 system-ui,-apple-system,sans-serif;padding:2px 7px;border-radius:5px;background:var(--pn-focus-highlight,#4493f8);color:#fff;white-space:nowrap;box-shadow:0 1px 5px rgba(0,0,0,.35);';
248
261
  document.body.appendChild(pinpointLabelEl);
249
262
  }
250
263
  return pinpointLabelEl;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Srcdoc injection builder for the HTML viewer.
3
+ *
4
+ * Product rule: arbitrary HTML must render exactly as it would in a plain
5
+ * browser tab. The viewer never writes into the document's namespace — no bare
6
+ * CSS custom properties, no classes on the author's root, no `color-scheme`,
7
+ * no styling of author elements. Host theme tokens are pushed under the
8
+ * viewer-owned `--pn-*` prefix, which the annotation CSS reads.
9
+ *
10
+ * Documents that WANT to follow the host theme (e.g. Plannotator-generated
11
+ * artifacts) opt in with `<meta name="plannotator-theme" content="host">`,
12
+ * which re-enables the bare-token push, the `light` class on their root, and
13
+ * `color-scheme` sync — for that document only.
14
+ *
15
+ * Pure string logic (no DOM) so the rendering-neutrality contract is unit-testable.
16
+ */
17
+ import { ANNOTATION_HIGHLIGHT_CSS, BRIDGE_SCRIPT } from "./bridge-script";
18
+
19
+ export const THEME_TOKENS = [
20
+ "--background",
21
+ "--foreground",
22
+ "--card",
23
+ "--card-foreground",
24
+ "--primary",
25
+ "--primary-foreground",
26
+ "--secondary",
27
+ "--secondary-foreground",
28
+ "--muted",
29
+ "--muted-foreground",
30
+ "--accent",
31
+ "--accent-foreground",
32
+ "--destructive",
33
+ "--destructive-foreground",
34
+ "--success",
35
+ "--success-foreground",
36
+ "--warning",
37
+ "--warning-foreground",
38
+ "--border",
39
+ "--input",
40
+ "--ring",
41
+ "--code-bg",
42
+ "--focus-highlight",
43
+ "--font-sans",
44
+ "--font-mono",
45
+ "--radius",
46
+ ] as const;
47
+
48
+ /** Viewer-owned namespace for properties injected into the document. */
49
+ export const PN_TOKEN_PREFIX = "--pn-";
50
+
51
+ /**
52
+ * Version-diff highlights. htmlDiff tags the <ins>/<del> it generates with
53
+ * this class so author-written <ins>/<del> markup is never restyled.
54
+ */
55
+ export const DIFF_HIGHLIGHT_CSS =
56
+ "ins.plannotator-diff{background:#e6ffec;color:#0a7d33;text-decoration:none;border-radius:2px;box-shadow:0 0 0 1px #abf2bc inset}" +
57
+ "del.plannotator-diff{background:#ffebe9;color:#b31d28;text-decoration:line-through;border-radius:2px;box-shadow:0 0 0 1px #ffc1bc inset}";
58
+
59
+ /**
60
+ * True when the document opts in to following the host theme via
61
+ * `<meta name="plannotator-theme" content="host">` (attribute order/quoting agnostic).
62
+ */
63
+ export function hasHostThemeOptIn(rawHtml: string): boolean {
64
+ const metas = rawHtml.match(/<meta\b[^>]*>/gi);
65
+ if (!metas) return false;
66
+ return metas.some(
67
+ (tag) =>
68
+ /\bname\s*=\s*["']?plannotator-theme["']?/i.test(tag) &&
69
+ /\bcontent\s*=\s*["']?host["']?/i.test(tag),
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Build the theme properties to write into the document. Bare host token names
75
+ * (`--muted`, `--background`, …) collide with author variables, so they are
76
+ * remapped to `--pn-*`; the originals ride along only for host-theme documents.
77
+ */
78
+ export function buildThemeTokenPayload(
79
+ tokens: Record<string, string>,
80
+ hostTheme: boolean,
81
+ ): Record<string, string> {
82
+ const payload: Record<string, string> = {};
83
+ for (const [key, val] of Object.entries(tokens)) {
84
+ payload[PN_TOKEN_PREFIX + key.slice(2)] = val;
85
+ if (hostTheme) payload[key] = val;
86
+ }
87
+ return payload;
88
+ }
89
+
90
+ export interface SrcdocInjectionOptions {
91
+ /** Host theme tokens, keyed by bare name (as read from the host root). */
92
+ tokens: Record<string, string>;
93
+ /** Whether the host is currently in its light theme. */
94
+ isLight: boolean;
95
+ /** Document opted in to host theming (see {@link hasHostThemeOptIn}). */
96
+ hostTheme: boolean;
97
+ /** The version-diff view is showing (rawHtml is htmlDiff output). */
98
+ diffActive: boolean;
99
+ }
100
+
101
+ /** The `<style>` + `<script>` block spliced into the document's head. */
102
+ export function buildSrcdocInjection({
103
+ tokens,
104
+ isLight,
105
+ hostTheme,
106
+ diffActive,
107
+ }: SrcdocInjectionOptions): string {
108
+ const payload = buildThemeTokenPayload(tokens, hostTheme);
109
+ let themeCSS = ":root {\n";
110
+ for (const [key, val] of Object.entries(payload)) {
111
+ themeCSS += ` ${key}: ${val};\n`;
112
+ }
113
+ themeCSS += "}\n";
114
+ // Host-theme documents mirror the host's light/dark; arbitrary documents keep
115
+ // their own color-scheme resolution (document + OS), like a standalone tab.
116
+ if (hostTheme) {
117
+ themeCSS += `:root { color-scheme: ${isLight ? "light" : "dark"}; }\n`;
118
+ }
119
+ const diffCSS = diffActive ? DIFF_HIGHLIGHT_CSS : "";
120
+ return `<style>${themeCSS}${ANNOTATION_HIGHLIGHT_CSS}${diffCSS}</style><script>${BRIDGE_SCRIPT}</script>`;
121
+ }
122
+
123
+ /** Splice the injection just before `</head>`, or prepend when there is none. */
124
+ export function injectIntoHead(rawHtml: string, injection: string): string {
125
+ const headClose = rawHtml.indexOf("</head>");
126
+ if (headClose !== -1) {
127
+ return rawHtml.slice(0, headClose) + injection + rawHtml.slice(headClose);
128
+ }
129
+ return injection + rawHtml;
130
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",