@plannotator/ui 0.30.0 → 0.32.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 (102) hide show
  1. package/README.md +46 -1
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
2
2
  import { configStore } from '../config/configStore';
3
3
  import { readThemePairCookies, writeThemePairCookies } from '../config/settings';
4
4
  import { useConfigValue } from '../config/useConfig';
5
+ import { faviconDataUrl } from '@plannotator/core/favicon';
5
6
  import { storage } from '../utils/storage';
6
7
  import {
7
8
  BUILT_IN_THEMES,
@@ -36,6 +37,12 @@ type ThemeProviderState = {
36
37
  darkTheme: string;
37
38
  setHalfTheme: (half: ThemeHalf, theme: string) => void;
38
39
  availableThemes: ThemeInfo[];
40
+ /**
41
+ * Whether this provider owns the document's favicon. Published so the Settings
42
+ * UI can hide the favicon control on hosts that did not opt in: a knob that
43
+ * changes nothing is worse than no knob.
44
+ */
45
+ manageFavicon: boolean;
39
46
  };
40
47
 
41
48
  const ThemeProviderContext = createContext<ThemeProviderState>({
@@ -51,6 +58,7 @@ const ThemeProviderContext = createContext<ThemeProviderState>({
51
58
  darkTheme: 'plannotator',
52
59
  setHalfTheme: () => null,
53
60
  availableThemes: BUILT_IN_THEMES,
61
+ manageFavicon: false,
54
62
  });
55
63
 
56
64
  /** Sync theme classes on <html> without stripping non-theme classes (e.g. transitions-ready). */
@@ -94,6 +102,18 @@ interface ThemeProviderProps {
94
102
  * `plannotator-dark-theme`; these props rename only the two legacy values.
95
103
  */
96
104
  colorThemeStorageKey?: string;
105
+ /**
106
+ * Opt in to letting this provider own `<link rel="icon">` on the document.
107
+ *
108
+ * OFF by default, and deliberately so: `@plannotator/ui` is installed into
109
+ * host applications with their own branding, and a mounted provider must not
110
+ * silently replace a host page's favicon with Plannotator's. Plannotator's own
111
+ * apps pass `manageFavicon`; hosts opt in only if they want the same feature.
112
+ *
113
+ * The value also gates the Settings favicon control (see ThemeTab), so a host
114
+ * that leaves it off never renders a switch that would do nothing.
115
+ */
116
+ manageFavicon?: boolean;
97
117
  }
98
118
 
99
119
  export function ThemeProvider({
@@ -102,6 +122,7 @@ export function ThemeProvider({
102
122
  defaultColorTheme = 'plannotator',
103
123
  storageKey = 'plannotator-theme',
104
124
  colorThemeStorageKey = 'plannotator-color-theme',
125
+ manageFavicon = false,
105
126
  }: ThemeProviderProps) {
106
127
  const legacyKeys = useMemo(
107
128
  () => ({ mode: storageKey, colorTheme: colorThemeStorageKey }),
@@ -124,6 +145,26 @@ export function ThemeProvider({
124
145
  const storePair = useConfigValue('themePair');
125
146
  const pair = pendingSeed.current ?? storePair;
126
147
  const mode = pair.mode;
148
+ const faviconStyle = useConfigValue('faviconStyle');
149
+
150
+ useEffect(() => {
151
+ if (!manageFavicon) return;
152
+ if (typeof document === 'undefined') return;
153
+ let link = document.head.querySelector<HTMLLinkElement>('link[rel="icon"]');
154
+ if (!link) {
155
+ link = document.createElement('link');
156
+ link.rel = 'icon';
157
+ document.head.appendChild(link);
158
+ }
159
+ if (faviconStyle === 'classic') {
160
+ link.type = 'image/svg+xml';
161
+ link.removeAttribute('sizes');
162
+ } else {
163
+ link.type = 'image/png';
164
+ link.setAttribute('sizes', '64x64');
165
+ }
166
+ link.href = faviconDataUrl(faviconStyle);
167
+ }, [faviconStyle, manageFavicon]);
127
168
 
128
169
  // Hand the resolved pair to the store as a SEED, not a user choice: seeding
129
170
  // writes memory + cookies only. Routing it through set() would queue a
@@ -243,7 +284,8 @@ export function ThemeProvider({
243
284
  darkTheme: pair.dark,
244
285
  setHalfTheme,
245
286
  availableThemes: BUILT_IN_THEMES,
246
- }), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme]);
287
+ manageFavicon,
288
+ }), [mode, preferredMode, resolvedMode, colorTheme, pair.light, pair.dark, setMode, setColorTheme, setHalfTheme, manageFavicon]);
247
289
 
248
290
  return (
249
291
  <ThemeProviderContext.Provider value={value}>
@@ -2,6 +2,9 @@ import React, { useEffect, useState } from 'react';
2
2
  import { useTheme } from './ThemeProvider';
3
3
  import { THEME_MODES } from './themeModes';
4
4
  import { themesForHalf, type ThemeHalf } from '../utils/themeRegistry';
5
+ import { configStore } from '../config/configStore';
6
+ import { useConfigValue } from '../config/useConfig';
7
+ import { faviconDataUrl, type FaviconStyle } from '@plannotator/core/favicon';
5
8
 
6
9
  interface ThemeTabProps {
7
10
  onPreview?: () => void;
@@ -13,12 +16,46 @@ const HALVES: { id: ThemeHalf; label: string }[] = [
13
16
  { id: 'dark', label: 'Dark' },
14
17
  ];
15
18
 
19
+ const FAVICON_STYLES: { id: FaviconStyle; label: string }[] = [
20
+ { id: 'totman', label: 'Totman' },
21
+ { id: 'classic', label: 'Classic P' },
22
+ ];
23
+
16
24
  const SyntaxLinesIcon: React.FC<{ className?: string }> = ({ className }) => (
17
25
  <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
18
26
  <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h7" />
19
27
  </svg>
20
28
  );
21
29
 
30
+ /**
31
+ * The favicon style choices. Rendered in both layouts, so it lives in one place.
32
+ * The compact layout drops the visible "Favicon" heading the full layout has, so
33
+ * the group carries its own accessible name and two unlabelled image buttons are
34
+ * never all a screen reader gets.
35
+ */
36
+ const FaviconStyleControl: React.FC<{ selected: FaviconStyle }> = ({ selected }) => (
37
+ <div className="flex gap-1" role="group" aria-label="Favicon style">
38
+ {FAVICON_STYLES.map(({ id, label }) => (
39
+ <button
40
+ key={id}
41
+ type="button"
42
+ aria-pressed={selected === id}
43
+ onClick={() => configStore.set('faviconStyle', id)}
44
+ className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
45
+ selected === id
46
+ ? 'bg-primary text-primary-foreground'
47
+ : 'bg-muted text-muted-foreground hover:text-foreground'
48
+ }`}
49
+ >
50
+ <span className="flex items-center gap-1.5">
51
+ <img src={faviconDataUrl(id)} alt="" className="w-5 h-5 rounded-sm shrink-0" />
52
+ {label}
53
+ </span>
54
+ </button>
55
+ ))}
56
+ </div>
57
+ );
58
+
22
59
  export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
23
60
  const {
24
61
  mode,
@@ -28,7 +65,9 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
28
65
  setHalfTheme,
29
66
  availableThemes,
30
67
  preferredMode,
68
+ manageFavicon,
31
69
  } = useTheme();
70
+ const faviconStyle = useConfigValue('faviconStyle');
32
71
 
33
72
  // Which half the grid assigns to. Follows the mode you are actually seeing,
34
73
  // so opening Settings in dark mode edits the dark half first.
@@ -68,6 +107,7 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
68
107
  {THEME_MODES.map(({ id, label, Icon }) => (
69
108
  <button
70
109
  key={id}
110
+ type="button"
71
111
  onClick={() => setMode(id)}
72
112
  className={`px-2.5 py-1.5 rounded-md text-xs font-medium transition-colors ${
73
113
  mode === id
@@ -82,12 +122,23 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({ onPreview, compact }) => {
82
122
  </button>
83
123
  ))}
84
124
  </div>
125
+ {!compact && manageFavicon && (
126
+ <div className="space-y-1.5 pt-1">
127
+ <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Favicon</label>
128
+ <FaviconStyleControl selected={faviconStyle} />
129
+ </div>
130
+ )}
85
131
  {!compact && (
86
132
  <p className="text-[11px] text-muted-foreground/70">
87
133
  System follows your OS and switches between the two themes below.
88
134
  </p>
89
135
  )}
90
- {compact && <div className="ml-auto">{summary}</div>}
136
+ {compact && (
137
+ <>
138
+ {manageFavicon && <FaviconStyleControl selected={faviconStyle} />}
139
+ <div className="ml-auto">{summary}</div>
140
+ </>
141
+ )}
91
142
  </div>
92
143
 
93
144
  {/* Theme pair */}
@@ -63,7 +63,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
63
63
  <BaseTooltip.Positioner side={side} align={align} sideOffset={sideOffset} className="isolate z-50">
64
64
  <BaseTooltip.Popup
65
65
  className={`z-50 px-2 py-1 text-xs bg-popover text-popover-foreground border border-border rounded shadow-md origin-[var(--transform-origin)] transition-[opacity,scale] duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 ${
66
- wide ? 'max-w-[260px] leading-snug whitespace-normal' : 'whitespace-nowrap'
66
+ wide
67
+ ? 'max-w-[min(260px,calc(100vw-1rem))] leading-snug whitespace-normal [overflow-wrap:anywhere]'
68
+ : 'whitespace-nowrap'
67
69
  }`}
68
70
  >
69
71
  {content}
@@ -49,7 +49,13 @@ import { PinpointOverlay } from './PinpointOverlay';
49
49
  import { usePinpoint } from '../hooks/usePinpoint';
50
50
  import { useAnnotationHighlighter } from '../hooks/useAnnotationHighlighter';
51
51
  import { useVimSelection } from '../hooks/useVimSelection';
52
- import { useScrollViewport } from '../hooks/useScrollViewport';
52
+ import {
53
+ getScrollViewportIntersectionRoot,
54
+ getScrollViewportRect,
55
+ getScrollViewportTop,
56
+ scrollViewportTo,
57
+ useScrollViewport,
58
+ } from '../hooks/useScrollViewport';
53
59
  import { decodeAnchorHash } from '../utils/anchors';
54
60
  import { VimModeOverlay } from './VimModeOverlay';
55
61
 
@@ -602,7 +608,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
602
608
  if (!stickyActions || !stickySentinelRef.current || !scrollViewport) return;
603
609
  const observer = new IntersectionObserver(
604
610
  ([entry]) => setIsStuck(!entry.isIntersecting),
605
- { root: scrollViewport, threshold: 0 }
611
+ { root: getScrollViewportIntersectionRoot(scrollViewport), threshold: 0 }
606
612
  );
607
613
  observer.observe(stickySentinelRef.current);
608
614
  return () => observer.disconnect();
@@ -635,12 +641,12 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
635
641
  const headerOffset = stickyActionsEl
636
642
  ? stickyActionsEl.getBoundingClientRect().height + stickyTop
637
643
  : 0;
638
- const containerRect = scrollViewport.getBoundingClientRect();
644
+ const containerRect = getScrollViewportRect(scrollViewport);
639
645
  const targetRect = target.getBoundingClientRect();
640
646
  const relativeTop = targetRect.top - containerRect.top;
641
- const offsetPosition = scrollViewport.scrollTop + relativeTop - headerOffset;
647
+ const offsetPosition = getScrollViewportTop(scrollViewport) + relativeTop - headerOffset;
642
648
 
643
- scrollViewport.scrollTo({
649
+ scrollViewportTo(scrollViewport, {
644
650
  top: Math.max(0, offsetPosition),
645
651
  behavior: 'smooth',
646
652
  });
@@ -778,6 +784,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
778
784
  setViewerCommentPopover(null);
779
785
  }, []);
780
786
 
787
+ const commentDraftScope = linkedDocInfo?.filepath ?? sourceInfo ?? markdown.slice(0, 120);
788
+
781
789
  const codePathValidation = useValidatedCodePaths(markdown, codePathBaseDir, disableCodePathValidation);
782
790
 
783
791
  return (
@@ -1154,6 +1162,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1154
1162
  contextText={hookCommentPopover.contextText}
1155
1163
  isGlobal={false}
1156
1164
  initialText={hookCommentPopover.initialText}
1165
+ draftKey={`plan:${commentDraftScope}:${hookCommentPopover.draftKey}`}
1157
1166
  onSubmit={hookCommentSubmit}
1158
1167
  onClose={hookCommentClose}
1159
1168
  allowImages={allowImages}
@@ -1173,6 +1182,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1173
1182
  contextText={viewerCommentPopover.contextText}
1174
1183
  isGlobal={viewerCommentPopover.isGlobal}
1175
1184
  initialText={viewerCommentPopover.initialText}
1185
+ draftKey={`plan:${commentDraftScope}:${
1186
+ viewerCommentPopover.isGlobal
1187
+ ? 'global'
1188
+ : `code-block:${viewerCommentPopover.codeBlock?.block.id ?? viewerCommentPopover.contextText}`
1189
+ }`}
1176
1190
  onSubmit={handleViewerCommentSubmit}
1177
1191
  onClose={handleViewerCommentClose}
1178
1192
  allowImages={allowImages}
@@ -4,7 +4,11 @@ import {
4
4
  useState,
5
5
  type RefObject,
6
6
  } from 'react';
7
- import { useScrollViewport } from '../hooks/useScrollViewport';
7
+ import {
8
+ addScrollViewportListener,
9
+ getScrollViewportRect,
10
+ useScrollViewport,
11
+ } from '../hooks/useScrollViewport';
8
12
  import type { SemanticTarget } from '../utils/blockTargeting';
9
13
  import {
10
14
  createRangeBetweenTextPositions,
@@ -186,7 +190,9 @@ export function VimTargetReticle({
186
190
  return;
187
191
  }
188
192
  const containerRect = container.getBoundingClientRect();
189
- const viewportTop = scrollViewport?.getBoundingClientRect().top ?? containerRect.top;
193
+ const viewportTop = scrollViewport
194
+ ? getScrollViewportRect(scrollViewport).top
195
+ : containerRect.top;
190
196
  const stickyBottom = container
191
197
  .querySelector<HTMLElement>('[data-sticky-actions]')
192
198
  ?.getBoundingClientRect()
@@ -205,7 +211,9 @@ export function VimTargetReticle({
205
211
 
206
212
  update();
207
213
  window.addEventListener('resize', scheduleUpdate, { passive: true });
208
- scrollViewport?.addEventListener('scroll', scheduleUpdate, { passive: true });
214
+ const removeScrollListener = scrollViewport
215
+ ? addScrollViewportListener(scrollViewport, scheduleUpdate)
216
+ : undefined;
209
217
  const resizeObserver = typeof ResizeObserver === 'undefined'
210
218
  ? null
211
219
  : new ResizeObserver(scheduleUpdate);
@@ -214,7 +222,7 @@ export function VimTargetReticle({
214
222
  return () => {
215
223
  cancelAnimationFrame(rafRef.current);
216
224
  window.removeEventListener('resize', scheduleUpdate);
217
- scrollViewport?.removeEventListener('scroll', scheduleUpdate);
225
+ removeScrollListener?.();
218
226
  resizeObserver?.disconnect();
219
227
  };
220
228
  }, [containerRef, restoredState, scrollViewport, target]);
@@ -268,6 +268,7 @@ const GeneralInput: React.FC<{
268
268
  <div className="border-t border-border/50 p-2">
269
269
  <div className="flex items-end gap-1.5">
270
270
  <textarea
271
+ data-pn-mobile-editable="true"
271
272
  ref={textareaRef}
272
273
  value={value}
273
274
  onChange={(event) => onChange(event.target.value)}
@@ -1,26 +1,38 @@
1
1
  import React, { useMemo } from 'react';
2
- import katex from 'katex';
3
2
  import type { Block } from '../../types';
3
+ import { normalizeMathTex, renderMathToHtml } from '../../utils/math';
4
+ import { useMathRenderer } from '../../hooks/useMathRenderer';
5
+
6
+ // Kept as re-exports: InlineMarkdown and older consumers import them from here.
7
+ export { normalizeMathTex, renderMathToHtml };
4
8
 
5
9
  type MathBlockProps = {
6
10
  block: Block;
7
11
  };
8
12
 
9
- export const normalizeMathTex = (tex: string): string => tex.trim();
10
-
11
- export const renderMathToHtml = (tex: string, displayMode: boolean): string => (
12
- katex.renderToString(tex, {
13
- displayMode,
14
- throwOnError: false,
15
- strict: 'warn',
16
- trust: false,
17
- output: 'html',
18
- })
19
- );
20
-
21
13
  export const MathBlock: React.FC<MathBlockProps> = ({ block }) => {
22
14
  const tex = normalizeMathTex(block.content);
23
- const html = useMemo(() => renderMathToHtml(tex, true), [tex]);
15
+ const renderer = useMathRenderer();
16
+ const html = useMemo(() => renderMathToHtml(tex, true, renderer), [tex, renderer]);
17
+
18
+ // The wrapper (class names, data-* anchors, aria-label) is identical in both
19
+ // branches: block targeting and math-annotation restore key on these
20
+ // attributes, so a placeholder must be addressable exactly like the typeset
21
+ // block. The placeholder's child is a React text node, never markup.
22
+ if (html === null) {
23
+ return (
24
+ <div
25
+ className="math-block math-annotatable my-5 overflow-x-auto py-2 text-foreground"
26
+ data-block-id={block.id}
27
+ data-block-type="math"
28
+ data-math-tex={tex}
29
+ data-math-display="true"
30
+ aria-label={tex}
31
+ >
32
+ {tex}
33
+ </div>
34
+ );
35
+ }
24
36
 
25
37
  return (
26
38
  <div
@@ -13,6 +13,12 @@ function cx(...classes: Array<string | false | null | undefined>): string {
13
13
  return classes.filter(Boolean).join(' ');
14
14
  }
15
15
 
16
+ /**
17
+ * Legacy shared button primitive.
18
+ *
19
+ * The visual sizes remain compact, while the shared touch marker expands the
20
+ * physical target only when a coarse pointer is present.
21
+ */
16
22
  export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
17
23
  className,
18
24
  variant = 'outline',
@@ -22,23 +28,25 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
22
28
  ...props
23
29
  }, ref) => (
24
30
  <button
31
+ {...props}
25
32
  ref={ref}
26
33
  type={type}
34
+ data-pn-touch-target="true"
35
+ data-pn-touch-target-icon={size === 'icon' || variant === 'icon' ? 'true' : undefined}
27
36
  className={cx(
28
37
  'inline-flex items-center justify-center gap-2 rounded-md text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
29
38
  size === 'sm' && 'h-8 px-2.5',
30
39
  size === 'md' && 'h-9 px-3',
31
40
  size === 'icon' && 'h-8 w-8',
32
- variant === 'primary' && 'bg-primary text-primary-foreground hover:opacity-90',
33
- variant === 'outline' && 'border border-border bg-card text-foreground hover:bg-muted',
34
- variant === 'ghost' && 'text-muted-foreground hover:bg-muted hover:text-foreground',
41
+ variant === 'primary' && 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
42
+ variant === 'outline' && 'border border-border bg-card text-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted',
43
+ variant === 'ghost' && 'text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground',
35
44
  variant === 'icon' && (active
36
45
  ? 'border border-primary/50 bg-primary/10 text-primary'
37
- : 'border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground'),
38
- variant === 'danger' && 'border border-border bg-background text-muted-foreground hover:border-destructive/50 hover:bg-destructive/10 hover:text-destructive',
46
+ : 'border border-border bg-background text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground'),
47
+ variant === 'danger' && 'border border-border bg-background text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:border-destructive/50 [@media(hover:hover)_and_(pointer:fine)]:hover:bg-destructive/10 [@media(hover:hover)_and_(pointer:fine)]:hover:text-destructive',
39
48
  className
40
49
  )}
41
- {...props}
42
50
  />
43
51
  ));
44
52
  Button.displayName = 'Button';