@plannotator/ui 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/HANDOFF.md +102 -6
  2. package/README.md +128 -3
  3. package/components/AnnotationPanel.tsx +2 -1
  4. package/components/AttachmentsButton.tsx +1 -1
  5. package/components/BlockRenderer.tsx +4 -1
  6. package/components/CodeFilePopout.tsx +6 -4
  7. package/components/CodeSnippetPreview.tsx +174 -0
  8. package/components/ComposerTokenTextarea.tsx +43 -2
  9. package/components/DeferredMarkdownContentsRail.tsx +38 -0
  10. package/components/DiffFileTree.tsx +7 -6
  11. package/components/DocBadges.tsx +47 -35
  12. package/components/DocumentContents.tsx +74 -0
  13. package/components/ImageLightbox.tsx +121 -63
  14. package/components/ImageZoomStage.tsx +32 -4
  15. package/components/InlineMarkdown.tsx +15 -150
  16. package/components/MarkdownContentsRail.tsx +777 -0
  17. package/components/Settings.tsx +2 -1
  18. package/components/TableOfContents.tsx +22 -7
  19. package/components/Viewer.tsx +51 -19
  20. package/components/blocks/QuestionBlock.tsx +14 -4
  21. package/components/chat/ChatComposer.tsx +192 -0
  22. package/components/chat/ChatFrame.tsx +524 -0
  23. package/components/chat/ChatTranscript.tsx +422 -0
  24. package/components/chat/chatMarkdown.ts +436 -0
  25. package/components/chat/chatRecipes.tsx +39 -0
  26. package/components/chat/chatWindowStore.ts +196 -0
  27. package/components/chat/index.ts +72 -0
  28. package/components/diagram/DiagramCanvas.tsx +7 -0
  29. package/components/diagram/DiagramComposer.tsx +15 -3
  30. package/components/diagram/DiagramViewer.tsx +1 -0
  31. package/components/docBadgesWidth.ts +29 -0
  32. package/components/html-viewer/HtmlViewer.tsx +36 -1
  33. package/components/imageZoom.ts +21 -0
  34. package/components/inbox-thread/FileName.tsx +118 -0
  35. package/components/inbox-thread/GuideCard.tsx +39 -0
  36. package/components/inbox-thread/ReaskNote.tsx +25 -0
  37. package/components/inbox-thread/SkillsLine.tsx +40 -0
  38. package/components/inbox-thread/Tip.tsx +105 -0
  39. package/components/inbox-thread/icons.tsx +262 -0
  40. package/components/inbox-thread/reask.ts +222 -0
  41. package/contents-rail.css +337 -0
  42. package/hooks/useActiveSection.ts +46 -63
  43. package/hooks/useAnnotationHighlighter.ts +40 -1
  44. package/hooks/useFileBrowser.ts +4 -3
  45. package/hooks/useHeadingScrollSpy.ts +77 -0
  46. package/hooks/useHoverPreview.ts +94 -0
  47. package/hooks/usePrefersReducedMotion.ts +23 -0
  48. package/hooks/useSwipeStep.ts +597 -0
  49. package/inbox-thread.css +4542 -0
  50. package/package.json +13 -4
  51. package/styles.css +1 -1
  52. package/theme.css +1 -0
  53. package/themes/tokyo-night-moon.css +34 -0
  54. package/utils/aiChatFormat.ts +32 -11
  55. package/utils/annotationHelpers.ts +32 -4
  56. package/utils/blockParse.ts +45 -0
  57. package/utils/headingScrollSpy.ts +105 -0
  58. package/utils/syntaxTheme.ts +1 -0
  59. package/utils/themeRegistry.ts +11 -0
package/theme.css CHANGED
@@ -24,6 +24,7 @@
24
24
  @import "./themes/solar-dusk.css";
25
25
  @import "./themes/terminal.css";
26
26
  @import "./themes/tokyo-night.css";
27
+ @import "./themes/tokyo-night-moon.css";
27
28
  @import "./themes/tinacious.css";
28
29
  @import "./themes/cursor.css";
29
30
  @import "./themes/cursor-hc.css";
@@ -0,0 +1,34 @@
1
+ /* Tokyo Night Moon — folke/tokyonight.nvim "moon" (dark only) */
2
+ .theme-tokyo-night-moon,
3
+ .theme-tokyo-night-moon.light {
4
+ --background: #222436;
5
+ --foreground: #c8d3f5;
6
+ --card: #1e2030;
7
+ --card-foreground: #c8d3f5;
8
+ --popover: #1e2030;
9
+ --popover-foreground: #c8d3f5;
10
+ --primary: #82aaff;
11
+ --primary-foreground: #1e2030;
12
+ --secondary: #444a73;
13
+ --secondary-foreground: #c8d3f5;
14
+ --muted: #2f334d;
15
+ --muted-foreground: #828bb8;
16
+ --accent: #86e1fc;
17
+ --accent-foreground: #1e2030;
18
+ --destructive: #ff757f;
19
+ --destructive-foreground: #1e2030;
20
+ --border: #444a73;
21
+ --input: #2f334d;
22
+ --ring: #82aaff;
23
+ --success: #c3e88d;
24
+ --success-foreground: #1e2030;
25
+ --warning: #ffc777;
26
+ --warning-foreground: #1e2030;
27
+
28
+ --font-sans: system-ui, sans-serif;
29
+ --font-mono: ui-monospace, monospace;
30
+ --radius: 0.625rem;
31
+
32
+ --code-bg: #191b29;
33
+ --focus-highlight: #86e1fc;
34
+ }
@@ -13,8 +13,14 @@ export const CHAT_SANITIZE_CONFIG = {
13
13
  'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'a', 'blockquote',
14
14
  'table', 'thead', 'tbody', 'tr', 'th', 'td',
15
15
  ],
16
- // `align` is what marked writes on th/td for aligned GFM columns.
17
- ALLOWED_ATTR: ['href', 'rel', 'class', 'align'],
16
+ // `align` is what marked writes on th/td for aligned GFM columns. No `class`
17
+ // (an answer's raw HTML could style itself with the app's utility classes;
18
+ // nothing in `.ai-markdown` reads one) and no `rel` (marked never writes it
19
+ // and `target` is not allowed).
20
+ ALLOWED_ATTR: ['href', 'align'],
21
+ // DOMPurify keeps data-* and aria-* unless told not to.
22
+ ALLOW_DATA_ATTR: false,
23
+ ALLOW_ARIA_ATTR: false,
18
24
  };
19
25
 
20
26
  /** The markdown step of {@link renderChatMarkdown}, before sanitizing. */
@@ -22,16 +28,31 @@ export function chatMarkdownToHtml(text: string): string {
22
28
  return marked.parse(text, { async: false, breaks: true }) as string;
23
29
  }
24
30
 
31
+ /**
32
+ * Never throws: deeply nested input can overflow the stack in marked, and a
33
+ * throw inside a render would unmount the whole page. On any failure the
34
+ * answer is shown as plain text, line breaks kept, as a React text child
35
+ * (React escapes it).
36
+ */
25
37
  export function renderChatMarkdown(text: string): ReactNode {
26
- const clean = DOMPurify.sanitize(chatMarkdownToHtml(text), {
27
- ALLOWED_TAGS: [...CHAT_SANITIZE_CONFIG.ALLOWED_TAGS],
28
- ALLOWED_ATTR: [...CHAT_SANITIZE_CONFIG.ALLOWED_ATTR],
29
- });
30
-
31
- return createElement('div', {
32
- className: 'ai-markdown',
33
- dangerouslySetInnerHTML: { __html: clean },
34
- });
38
+ try {
39
+ const clean = DOMPurify.sanitize(chatMarkdownToHtml(text), {
40
+ ALLOWED_TAGS: [...CHAT_SANITIZE_CONFIG.ALLOWED_TAGS],
41
+ ALLOWED_ATTR: [...CHAT_SANITIZE_CONFIG.ALLOWED_ATTR],
42
+ ALLOW_DATA_ATTR: CHAT_SANITIZE_CONFIG.ALLOW_DATA_ATTR,
43
+ ALLOW_ARIA_ATTR: CHAT_SANITIZE_CONFIG.ALLOW_ARIA_ATTR,
44
+ });
45
+ return createElement('div', {
46
+ className: 'ai-markdown',
47
+ dangerouslySetInnerHTML: { __html: clean },
48
+ });
49
+ } catch {
50
+ return createElement(
51
+ 'div',
52
+ { className: 'ai-markdown', style: { whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' } },
53
+ String(text),
54
+ );
55
+ }
35
56
  }
36
57
 
37
58
  export function formatRelativeTime(ts: number): string {
@@ -60,13 +60,41 @@ export interface TocItem {
60
60
  annotationCount: number;
61
61
  }
62
62
 
63
+ /**
64
+ * The headings a Contents list shows, in document order: levels 1 to
65
+ * `maxLevel` (3 by default, plan review's list). The one place that picks
66
+ * them: `TableOfContents`, plan review's "has entries" check and the contents
67
+ * rail (`MarkdownContentsRail`, levels 1 to 4) all read it.
68
+ */
69
+ export function tocHeadings(blocks: readonly Block[], maxLevel = 3): Block[] {
70
+ return blocks
71
+ .filter(b => b.type === 'heading' && (b.level ?? 0) <= maxLevel)
72
+ .sort((a, b) => a.order - b.order);
73
+ }
74
+
75
+ /** The contents rail lists headings of levels 1 to this (`MarkdownContentsRail`). */
76
+ export const CONTENTS_RAIL_MAX_LEVEL = 4;
77
+ /** The contents rail shows for a document with at least this many of them. */
78
+ export const CONTENTS_RAIL_MIN_HEADINGS = 4;
79
+ /** The contents rail shows in a scroll element at least this wide (and with a 68 px gutter). */
80
+ export const CONTENTS_RAIL_MIN_PANE_WIDTH = 480;
81
+
82
+ /**
83
+ * Whether a document gets the contents rail: at least
84
+ * `CONTENTS_RAIL_MIN_HEADINGS` headings of levels 1 to
85
+ * `CONTENTS_RAIL_MAX_LEVEL`. Kept out of the rail's own module, so a host can
86
+ * decide without loading the (lazy) rail.
87
+ */
88
+ export function hasContentsRailHeadings(blocks: readonly Block[]): boolean {
89
+ return tocHeadings(blocks, CONTENTS_RAIL_MAX_LEVEL).length >= CONTENTS_RAIL_MIN_HEADINGS;
90
+ }
91
+
63
92
  export function buildTocHierarchy(
64
93
  blocks: Block[],
65
- annotationCounts: Map<string, number>
94
+ annotationCounts: Map<string, number>,
95
+ maxLevel = 3
66
96
  ): TocItem[] {
67
- const headings = blocks
68
- .filter(b => b.type === 'heading' && (b.level ?? 0) <= 3)
69
- .sort((a, b) => a.order - b.order);
97
+ const headings = tocHeadings(blocks, maxLevel);
70
98
 
71
99
  const root: TocItem[] = [];
72
100
  const stack: TocItem[] = [];
@@ -0,0 +1,45 @@
1
+ // Moved from the Workspaces app's `block-parse.ts` with its contents rail
2
+ // (MarkdownContentsRail, approved 2026-10-03). Port: none; the module-level
3
+ // cache is the same.
4
+
5
+ import type { Block } from '../types';
6
+ import { parseMarkdownToBlocks } from './parser';
7
+
8
+ /**
9
+ * ONE parse per document body, shared across consumers: a host's Viewer and
10
+ * its contents rail render from the same body string, and each would
11
+ * otherwise run its own full `parseMarkdownToBlocks`. This module-level cache
12
+ * keys on the content string (value equality: the call sites derive `content`
13
+ * independently via `extractFrontmatter`), so whoever parses first feeds
14
+ * everyone.
15
+ *
16
+ * Sharing one blocks array is safe: the consumers treat the parser output as
17
+ * immutable.
18
+ *
19
+ * Capacity 4 with hit-refresh recency: a page holds at most a couple of live
20
+ * bodies at once; the cap only exists so navigating across documents cannot
21
+ * grow it without bound.
22
+ */
23
+ const MAX_ENTRIES = 4;
24
+ const cache = new Map<string, Block[]>();
25
+
26
+ /** Test probe: counts real parses (cache misses). */
27
+ export const blockParseProbe = { parses: 0 };
28
+
29
+ export function parseBlocksShared(content: string): Block[] {
30
+ const hit = cache.get(content);
31
+ if (hit !== undefined) {
32
+ // Refresh recency (Map preserves insertion order; oldest is evicted).
33
+ cache.delete(content);
34
+ cache.set(content, hit);
35
+ return hit;
36
+ }
37
+ blockParseProbe.parses += 1;
38
+ const blocks = parseMarkdownToBlocks(content);
39
+ cache.set(content, blocks);
40
+ if (cache.size > MAX_ENTRIES) {
41
+ const oldest = cache.keys().next().value;
42
+ if (oldest !== undefined) cache.delete(oldest);
43
+ }
44
+ return blocks;
45
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The contents rail's scroll-spy: which heading is being read, from the
3
+ * headings' cached tops and the scroll position, on every scroll frame.
4
+ *
5
+ * Moved as it was from the Workspaces app's
6
+ * `apps/web/src/plannotator/MarkdownContentsRail.tsx` (approved 2026-10-03):
7
+ * `topInsideViewport`, `currentBinding` (the binary search) and the rule that
8
+ * the last heading is the current one at the document's end. One change: the
9
+ * activation line is 80 px below the top (it was 52), plan review's
10
+ * `TableOfContents` scroll offset, so a pick lands where plan review's does.
11
+ *
12
+ * Plan review's own list uses `hooks/useActiveSection` (live heading
13
+ * positions at the same activation line). One mechanism per surface: the rail, and a
14
+ * Contents list on the rail's surface (`DocumentContents`), use this one.
15
+ */
16
+
17
+ /** How far below the scroll element's top a heading becomes the current one. */
18
+ export const CONTENTS_ACTIVATION_OFFSET = 80;
19
+
20
+ /**
21
+ * How far below the activation line a heading still counts as reached. A pick
22
+ * scrolls to the heading's top less the offset, and that top is often
23
+ * fractional (1522.22 px); the browser snaps the scroll offset to a whole
24
+ * device pixel (1442, not 1442.22), so the picked heading can end up a fraction
25
+ * of a pixel below the line and the heading above it would be marked. One
26
+ * pixel covers the snap, as the document's end does below.
27
+ */
28
+ export const CONTENTS_SNAP_TOLERANCE = 1;
29
+
30
+ /** A heading drawn in the scroll element, with its top inside the scrolled content. */
31
+ export interface HeadingBinding {
32
+ /** The heading's index in the caller's own list. */
33
+ readonly itemIndex: number;
34
+ readonly node: HTMLElement;
35
+ readonly top: number;
36
+ }
37
+
38
+ export function topInsideViewport(node: HTMLElement, viewport: HTMLElement): number {
39
+ const viewportRect = viewport.getBoundingClientRect();
40
+ return node.getBoundingClientRect().top - viewportRect.top + viewport.scrollTop;
41
+ }
42
+
43
+ /**
44
+ * Bind each listed heading id to its drawn heading (`[data-block-type="heading"]`
45
+ * with that `data-block-id`), sorted by top. Only the caller's own ids are
46
+ * bound, so a heading of an embedded document (its ids are namespaced) is never
47
+ * read; when an id is drawn twice, the first one in document order is.
48
+ */
49
+ export function bindHeadings(
50
+ viewport: HTMLElement,
51
+ blockIds: readonly string[],
52
+ ): HeadingBinding[] {
53
+ const nodesByBlockId = new Map<string, HTMLElement>();
54
+ for (const node of viewport.querySelectorAll<HTMLElement>('[data-block-type="heading"]')) {
55
+ const id = node.dataset.blockId ?? '';
56
+ if (!nodesByBlockId.has(id)) nodesByBlockId.set(id, node);
57
+ }
58
+ const bindings = blockIds.flatMap<HeadingBinding>((blockId, itemIndex) => {
59
+ const node = nodesByBlockId.get(blockId);
60
+ return node === undefined ? [] : [{ itemIndex, node, top: topInsideViewport(node, viewport) }];
61
+ });
62
+ bindings.sort((left, right) => left.top - right.top);
63
+ return bindings;
64
+ }
65
+
66
+ /** The last binding whose top is at or above `activationTop`, else the first. */
67
+ export function currentBinding(
68
+ bindings: readonly HeadingBinding[],
69
+ activationTop: number,
70
+ ): HeadingBinding {
71
+ let low = 0;
72
+ let high = bindings.length - 1;
73
+ let result = bindings[0]!;
74
+ while (low <= high) {
75
+ const middle = (low + high) >> 1;
76
+ const candidate = bindings[middle]!;
77
+ if (candidate.top <= activationTop) {
78
+ result = candidate;
79
+ low = middle + 1;
80
+ } else {
81
+ high = middle - 1;
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ /**
88
+ * The heading being read: the last one at or above the activation line (or
89
+ * within `CONTENTS_SNAP_TOLERANCE` below it), or the last heading once the
90
+ * document is scrolled to its end. `null` with no bindings.
91
+ */
92
+ export function activeHeadingBinding(
93
+ bindings: readonly HeadingBinding[],
94
+ viewport: HTMLElement,
95
+ ): HeadingBinding | null {
96
+ if (bindings.length === 0) return null;
97
+ const maxScrollTop = Math.max(0, viewport.scrollHeight - viewport.clientHeight);
98
+ const atDocumentEnd = maxScrollTop > 0 && viewport.scrollTop >= maxScrollTop - 1;
99
+ return atDocumentEnd
100
+ ? bindings.at(-1)!
101
+ : currentBinding(
102
+ bindings,
103
+ viewport.scrollTop + CONTENTS_ACTIVATION_OFFSET + CONTENTS_SNAP_TOLERANCE,
104
+ );
105
+ }
@@ -50,6 +50,7 @@ export const SHIKI_THEME_MAP: Record<string, { dark: string | null; light: strin
50
50
  'solarized': { dark: 'solarized-dark', light: 'solarized-light' },
51
51
  'synthwave-84': { dark: 'synthwave-84', light: null },
52
52
  'tokyo-night': { dark: 'tokyo-night', light: null },
53
+ 'tokyo-night-moon': { dark: 'tokyo-night', light: null },
53
54
  'vesper': { dark: 'vesper', light: null },
54
55
  'vitesse': { dark: 'vitesse-dark', light: 'vitesse-light' },
55
56
  'vitesse-black': { dark: 'vitesse-black', light: null },
@@ -544,6 +544,17 @@ export const BUILT_IN_THEMES: ThemeInfo[] = [
544
544
  light: { primary: '#2e7de9', secondary: '#a1a6c5', accent: '#007197', background: '#e1e2e7', foreground: '#3760bf' },
545
545
  },
546
546
  },
547
+ {
548
+ id: 'tokyo-night-moon',
549
+ name: 'Tokyo Night Moon',
550
+ builtIn: true,
551
+ modeSupport: 'dark-only',
552
+ syntaxHighlighting: true,
553
+ colors: {
554
+ dark: { primary: '#82aaff', secondary: '#444a73', accent: '#86e1fc', background: '#222436', foreground: '#c8d3f5' },
555
+ light: { primary: '#82aaff', secondary: '#444a73', accent: '#86e1fc', background: '#222436', foreground: '#c8d3f5' },
556
+ },
557
+ },
547
558
  {
548
559
  id: 'vesper',
549
560
  name: 'Vesper',