@peaske7/readit 0.1.7 → 0.2.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 (118) hide show
  1. package/README.md +0 -3
  2. package/biome.json +1 -1
  3. package/bun.lock +43 -185
  4. package/docs/perf-baseline.md +75 -0
  5. package/docs/superpowers/plans/2026-03-26-surgical-pruning.md +1176 -0
  6. package/e2e/perf/add-comment.spec.ts +118 -0
  7. package/e2e/perf/fixtures/generate.ts +331 -0
  8. package/e2e/perf/initial-load.spec.ts +49 -0
  9. package/e2e/perf/perf.setup.ts +23 -0
  10. package/e2e/perf/perf.teardown.ts +9 -0
  11. package/e2e/perf/scroll.spec.ts +39 -0
  12. package/e2e/perf/tab-switch.spec.ts +69 -0
  13. package/e2e/perf/text-selection.spec.ts +119 -0
  14. package/e2e/perf/utils/metrics.ts +286 -0
  15. package/e2e/perf/utils/perf-cli.ts +86 -0
  16. package/package.json +9 -18
  17. package/playwright.config.ts +12 -0
  18. package/src/App.tsx +133 -178
  19. package/src/{cli/index.ts → cli.ts} +211 -107
  20. package/src/components/ActionsMenu.tsx +6 -27
  21. package/src/components/DocumentViewer/DocumentViewer.tsx +78 -105
  22. package/src/components/DocumentViewer/MermaidDiagram.tsx +6 -7
  23. package/src/components/Header.tsx +9 -20
  24. package/src/components/InlineEditor.tsx +5 -5
  25. package/src/components/MarginNote.tsx +71 -93
  26. package/src/components/MarginNotes.tsx +7 -34
  27. package/src/components/RawModal.tsx +9 -8
  28. package/src/components/ReanchorConfirm.tsx +2 -2
  29. package/src/components/SettingsModal.tsx +11 -89
  30. package/src/components/TabBar.tsx +4 -4
  31. package/src/components/TableOfContents.tsx +5 -5
  32. package/src/components/comments/CommentInput.tsx +7 -35
  33. package/src/components/comments/CommentListItem.tsx +9 -11
  34. package/src/components/comments/CommentManager.tsx +53 -37
  35. package/src/components/comments/CommentNav.tsx +14 -14
  36. package/src/components/ui/ActionLink.tsx +14 -18
  37. package/src/components/ui/Button.tsx +42 -43
  38. package/src/components/ui/Dialog.tsx +73 -113
  39. package/src/components/ui/DropdownMenu.tsx +113 -69
  40. package/src/components/ui/Text.tsx +30 -37
  41. package/src/contexts/CommentContext.tsx +75 -106
  42. package/src/contexts/LocaleContext.tsx +45 -4
  43. package/src/contexts/PositionsContext.tsx +16 -0
  44. package/src/contexts/SettingsContext.tsx +133 -0
  45. package/src/hooks/useClickOutside.ts +0 -4
  46. package/src/hooks/useCommentNavigation.ts +6 -29
  47. package/src/hooks/useComments.ts +6 -18
  48. package/src/hooks/useDocument.ts +35 -34
  49. package/src/hooks/useHeadings.test.ts +8 -50
  50. package/src/hooks/useHeadings.ts +5 -88
  51. package/src/hooks/useScrollSpy.ts +10 -14
  52. package/src/hooks/useTextSelection.ts +1 -38
  53. package/src/lib/__fixtures__/bench-data.ts +1 -41
  54. package/src/lib/anchor.bench.ts +57 -67
  55. package/src/lib/anchor.test.ts +5 -1
  56. package/src/lib/anchor.ts +13 -93
  57. package/src/lib/comment-storage.test.ts +4 -4
  58. package/src/lib/comment-storage.ts +2 -46
  59. package/src/lib/export.ts +7 -13
  60. package/src/lib/highlight/core.test.ts +1 -1
  61. package/src/lib/highlight/dom.ts +5 -68
  62. package/src/lib/highlight/highlighter.ts +102 -262
  63. package/src/lib/highlight/resolver.ts +112 -0
  64. package/src/lib/highlight/types.ts +0 -35
  65. package/src/lib/highlight/worker.ts +45 -0
  66. package/src/lib/i18n/en.ts +1 -50
  67. package/src/lib/i18n/ja.ts +1 -50
  68. package/src/lib/i18n/types.ts +1 -49
  69. package/src/lib/margin-layout.ts +5 -27
  70. package/src/lib/positions.ts +150 -0
  71. package/src/lib/utils.ts +2 -19
  72. package/src/schema.ts +81 -0
  73. package/src/{server/index.ts → server.ts} +111 -81
  74. package/src/{store/index.ts → store.ts} +14 -46
  75. package/vite.config.ts +8 -0
  76. package/src/components/DocumentViewer/IframeContainer.tsx +0 -251
  77. package/src/components/DocumentViewer/InlineCode.tsx +0 -60
  78. package/src/components/DocumentViewer/index.ts +0 -1
  79. package/src/components/FloatingTOC.tsx +0 -61
  80. package/src/components/ShortcutCapture.tsx +0 -48
  81. package/src/components/ShortcutList.tsx +0 -198
  82. package/src/components/comments/CommentMinimap.tsx +0 -62
  83. package/src/components/ui/ActionBar.tsx +0 -16
  84. package/src/components/ui/SeparatorDot.tsx +0 -9
  85. package/src/contexts/LayoutContext.tsx +0 -88
  86. package/src/hooks/useClipboard.ts +0 -82
  87. package/src/hooks/useEditorScheme.ts +0 -51
  88. package/src/hooks/useFontPreference.ts +0 -59
  89. package/src/hooks/useKeybindings.ts +0 -108
  90. package/src/hooks/useKeyboardShortcuts.ts +0 -63
  91. package/src/hooks/useLayoutMode.ts +0 -44
  92. package/src/hooks/useLocalePreference.ts +0 -42
  93. package/src/hooks/useReanchorMode.ts +0 -33
  94. package/src/hooks/useScrollMetrics.ts +0 -56
  95. package/src/hooks/useThemePreference.ts +0 -66
  96. package/src/lib/comment-storage.bench.ts +0 -63
  97. package/src/lib/context.bench.ts +0 -41
  98. package/src/lib/context.test.ts +0 -224
  99. package/src/lib/context.ts +0 -193
  100. package/src/lib/editor-links.ts +0 -59
  101. package/src/lib/export.bench.ts +0 -35
  102. package/src/lib/highlight/colors.ts +0 -37
  103. package/src/lib/highlight/core.ts +0 -54
  104. package/src/lib/highlight/index.ts +0 -23
  105. package/src/lib/highlight/script-builder.ts +0 -485
  106. package/src/lib/html-processor.test.tsx +0 -170
  107. package/src/lib/html-processor.tsx +0 -95
  108. package/src/lib/i18n/completeness.test.ts +0 -51
  109. package/src/lib/i18n/translations.test.ts +0 -39
  110. package/src/lib/layout-constants.ts +0 -12
  111. package/src/lib/margin-layout.bench.ts +0 -28
  112. package/src/lib/scroll.test.ts +0 -118
  113. package/src/lib/scroll.ts +0 -47
  114. package/src/lib/shortcut-registry.test.ts +0 -173
  115. package/src/lib/shortcut-registry.ts +0 -209
  116. package/src/lib/utils.test.ts +0 -110
  117. package/src/store/index.test.ts +0 -242
  118. package/src/types/index.ts +0 -127
@@ -1,54 +1,47 @@
1
- import { Slot } from "@radix-ui/react-slot";
2
- import { cva, type VariantProps } from "class-variance-authority";
3
1
  import { use } from "react";
4
- import { LayoutContext } from "../../contexts/LayoutContext";
2
+ import { SettingsContext } from "../../contexts/SettingsContext";
5
3
  import { cn } from "../../lib/utils";
6
- import { FontFamilies } from "../../types";
4
+ import { FontFamilies } from "../../schema";
7
5
 
8
- const textVariants = cva("", {
9
- variants: {
10
- variant: {
11
- title:
12
- "text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100",
13
- section: "text-sm font-medium text-zinc-900 dark:text-zinc-100",
14
- subsection: "text-xs font-medium text-zinc-700 dark:text-zinc-300",
15
- overline:
16
- "text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider",
17
- body: "text-sm text-zinc-600 dark:text-zinc-400",
18
- caption: "text-xs text-zinc-500 dark:text-zinc-400",
19
- micro: "text-[10px] text-zinc-400 dark:text-zinc-500",
20
- },
21
- },
22
- defaultVariants: {
23
- variant: "body",
24
- },
25
- });
6
+ const variantStyles = {
7
+ title:
8
+ "text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100",
9
+ section: "text-sm font-medium text-zinc-900 dark:text-zinc-100",
10
+ subsection: "text-xs font-medium text-zinc-700 dark:text-zinc-300",
11
+ overline:
12
+ "text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider",
13
+ body: "text-sm text-zinc-600 dark:text-zinc-400",
14
+ caption: "text-xs text-zinc-500 dark:text-zinc-400",
15
+ micro: "text-[10px] text-zinc-400 dark:text-zinc-500",
16
+ } as const;
17
+
18
+ type TextVariant = keyof typeof variantStyles;
19
+
20
+ interface TextProps extends React.HTMLAttributes<HTMLElement> {
21
+ variant?: TextVariant;
22
+ as?: "p" | "span" | "div" | "h1" | "h2" | "h3" | "label" | "pre";
23
+ }
26
24
 
27
25
  function Text({
28
26
  className,
29
- variant,
30
- asChild = false,
27
+ variant = "body",
28
+ as: Tag = "p",
31
29
  ...props
32
- }: React.ComponentProps<"p"> &
33
- VariantProps<typeof textVariants> & {
34
- asChild?: boolean;
35
- }) {
36
- const layout = use(LayoutContext);
37
- const fontClass = layout
38
- ? layout.fontFamily === FontFamilies.SANS_SERIF
30
+ }: TextProps) {
31
+ const settings = use(SettingsContext);
32
+ const fontClass = settings
33
+ ? settings.fontFamily === FontFamilies.SANS_SERIF
39
34
  ? "font-sans"
40
35
  : "font-serif"
41
36
  : undefined;
42
37
 
43
- const Comp = asChild ? Slot : "p";
44
-
45
38
  return (
46
- <Comp
47
- data-slot="text"
48
- className={cn(fontClass, textVariants({ variant }), className)}
39
+ <Tag
40
+ className={cn(fontClass, variantStyles[variant], className)}
49
41
  {...props}
50
42
  />
51
43
  );
52
44
  }
53
45
 
54
- export { Text, textVariants };
46
+ export type { TextVariant };
47
+ export { Text, variantStyles };
@@ -9,18 +9,14 @@ import {
9
9
  import { toast } from "sonner";
10
10
  import { useCommentNavigation } from "../hooks/useCommentNavigation";
11
11
  import { useComments } from "../hooks/useComments";
12
- import { useReanchorMode } from "../hooks/useReanchorMode";
13
- import { extractContext, formatForLLM } from "../lib/context";
14
- import { generatePrompt } from "../lib/export";
12
+ import { formatComment } from "../lib/export";
15
13
  import { truncate } from "../lib/utils";
16
- import { useAppStore } from "../store";
17
- import type { Comment, DocumentType } from "../types";
14
+ import type { Comment } from "../schema";
15
+ import { appStore, useAppStore } from "../store";
18
16
  import { useLocale } from "./LocaleContext";
19
17
 
20
- interface CommentContextValue {
21
- // From useComments
22
- comments: Comment[];
23
- commentCount: number;
18
+ // Stable callbacks — never causes re-renders
19
+ interface CommentActionsValue {
24
20
  addComment: (
25
21
  selectedText: string,
26
22
  comment: string,
@@ -36,52 +32,62 @@ interface CommentContextValue {
36
32
  startOffset: number,
37
33
  endOffset: number,
38
34
  ) => void;
39
- // Derived
40
- sortedComments: Comment[];
41
- // From useCommentNavigation
42
- currentIndex: number;
43
- hoveredCommentId: string | undefined;
44
35
  setHoveredCommentId: (id: string | undefined) => void;
45
36
  navigateToComment: (commentId: string) => void;
46
37
  navigatePrevious: () => void;
47
38
  navigateNext: () => void;
48
- // From useReanchorMode
49
- reanchorTarget: { commentId: string } | null;
50
39
  startReanchor: (commentId: string) => void;
51
40
  cancelReanchor: () => void;
52
- // Copy operations
53
- copyCommentRaw: (comment: Comment) => void;
54
- copyCommentForLLM: (comment: Comment) => void;
55
- copyAllForLLM: () => void;
56
- // Scroll to highlight
41
+ copyComment: (comment: Comment) => void;
57
42
  scrollToHighlight: (commentId: string) => void;
58
43
  }
59
44
 
60
- export const CommentContext = createContext<CommentContextValue | null>(null);
45
+ const CommentActionsContext = createContext<CommentActionsValue | null>(null);
61
46
 
62
- export function useCommentContext(): CommentContextValue {
63
- const value = use(CommentContext);
47
+ export function useCommentActions(): CommentActionsValue {
48
+ const value = use(CommentActionsContext);
64
49
  if (!value) {
65
- throw new Error("useCommentContext must be used within a CommentProvider");
50
+ throw new Error("useCommentActions must be used within a CommentProvider");
66
51
  }
67
52
  return value;
68
53
  }
69
54
 
55
+ // Volatile — re-renders consumers on change
56
+ interface CommentDataValue {
57
+ comments: Comment[];
58
+ commentCount: number;
59
+ sortedComments: Comment[];
60
+ currentIndex: number;
61
+ reanchorTarget: { commentId: string } | null;
62
+ }
63
+
64
+ const CommentDataContext = createContext<CommentDataValue | null>(null);
65
+
66
+ export function useCommentData(): CommentDataValue {
67
+ const value = use(CommentDataContext);
68
+ if (!value) {
69
+ throw new Error("useCommentData must be used within a CommentProvider");
70
+ }
71
+ return value;
72
+ }
73
+
74
+ export type CommentContextValue = CommentActionsValue & CommentDataValue;
75
+
76
+ export function useCommentContext(): CommentContextValue {
77
+ return { ...useCommentActions(), ...useCommentData() };
78
+ }
79
+
80
+ export const CommentContext = CommentDataContext;
81
+
70
82
  interface CommentProviderProps {
71
83
  filePath: string;
72
84
  clean: boolean;
73
- documentContent: string;
74
- fileName: string;
75
- documentType: DocumentType;
76
85
  children: ReactNode;
77
86
  }
78
87
 
79
88
  export function CommentProvider({
80
89
  filePath,
81
90
  clean,
82
- documentContent,
83
- fileName,
84
- documentType,
85
91
  children,
86
92
  }: CommentProviderProps) {
87
93
  const {
@@ -94,136 +100,99 @@ export function CommentProvider({
94
100
  reanchorComment,
95
101
  } = useComments(filePath, { clean });
96
102
 
97
- // sortedComments from store (already sorted by setComments)
98
103
  const sortedComments = useAppStore(
99
104
  (s) => s.documents.get(filePath)?.sortedComments ?? [],
100
105
  );
101
106
 
102
107
  const {
103
108
  currentIndex,
104
- hoveredCommentId,
105
109
  setHoveredCommentId,
106
110
  navigateToComment,
107
111
  navigatePrevious,
108
112
  navigateNext,
109
113
  } = useCommentNavigation(sortedComments);
110
114
 
111
- const { reanchorTarget, startReanchor, cancelReanchor } = useReanchorMode();
115
+ const reanchorTarget = useAppStore(
116
+ (s) => s.getActiveDocumentState()?.reanchorTarget ?? null,
117
+ );
118
+ const startReanchor = useCallback((commentId: string) => {
119
+ appStore.getState().setReanchorTarget({ commentId });
120
+ }, []);
121
+ const cancelReanchor = useCallback(() => {
122
+ appStore.getState().setReanchorTarget(null);
123
+ }, []);
112
124
  const { t } = useLocale();
113
125
 
114
- // Show comments errors as toast
115
126
  useEffect(() => {
116
127
  if (commentsError) {
117
128
  toast.error(commentsError);
118
129
  }
119
130
  }, [commentsError]);
120
131
 
121
- const copyCommentRaw = useCallback(
132
+ const copyComment = useCallback(
122
133
  (comment: Comment) => {
123
- const raw = `${comment.selectedText}\n\n${comment.comment}`;
124
- navigator.clipboard.writeText(raw);
134
+ navigator.clipboard.writeText(formatComment(comment));
125
135
  toast.success(t("toast.copied", { text: truncate(comment.comment) }));
126
136
  },
127
137
  [t],
128
138
  );
129
139
 
130
- const copyCommentForLLM = useCallback(
131
- (comment: Comment) => {
132
- const context = extractContext({
133
- content: documentContent,
134
- startOffset: comment.startOffset,
135
- endOffset: comment.endOffset,
136
- });
137
- const formatted = formatForLLM({
138
- context,
139
- fileName,
140
- comment: comment.comment,
141
- });
142
-
143
- navigator.clipboard.writeText(formatted);
144
- toast.success(
145
- t("toast.copiedForLLM", { text: truncate(comment.comment) }),
146
- );
147
- },
148
- [documentContent, fileName, t],
149
- );
150
-
151
- const copyAllForLLM = useCallback(() => {
152
- const prompt = generatePrompt(comments, fileName);
153
- navigator.clipboard.writeText(prompt);
154
- toast.success(t("toast.copiedAllComments"));
155
- }, [comments, fileName, t]);
156
-
157
- const scrollToHighlight = useCallback(
158
- (commentId: string) => {
159
- if (documentType === "html") {
160
- const iframe = window.document.querySelector("iframe");
161
- iframe?.contentWindow?.postMessage(
162
- { type: "scrollToHighlight", commentId },
163
- "*",
164
- );
165
- } else {
166
- const mark = window.document.querySelector(
167
- `mark[data-comment-id="${commentId}"]`,
168
- );
169
- if (mark) {
170
- mark.scrollIntoView({ behavior: "smooth", block: "center" });
171
- }
172
- }
173
- },
174
- [documentType],
175
- );
176
-
177
- const commentCount = comments.length;
140
+ const scrollToHighlight = useCallback((commentId: string) => {
141
+ const mark = window.document.querySelector(
142
+ `mark[data-comment-id="${commentId}"]`,
143
+ );
144
+ if (mark) {
145
+ mark.scrollIntoView({ behavior: "smooth", block: "center" });
146
+ }
147
+ }, []);
178
148
 
179
- const value = useMemo<CommentContextValue>(
149
+ const actions = useMemo<CommentActionsValue>(
180
150
  () => ({
181
- comments,
182
- commentCount,
183
151
  addComment,
184
152
  editComment,
185
153
  deleteComment,
186
154
  deleteAll,
187
155
  reanchorComment,
188
- sortedComments,
189
- currentIndex,
190
- hoveredCommentId,
191
156
  setHoveredCommentId,
192
157
  navigateToComment,
193
158
  navigatePrevious,
194
159
  navigateNext,
195
- reanchorTarget,
196
160
  startReanchor,
197
161
  cancelReanchor,
198
- copyCommentRaw,
199
- copyCommentForLLM,
200
- copyAllForLLM,
162
+ copyComment,
201
163
  scrollToHighlight,
202
164
  }),
203
165
  [
204
- comments,
205
- commentCount,
206
166
  addComment,
207
167
  editComment,
208
168
  deleteComment,
209
169
  deleteAll,
210
170
  reanchorComment,
211
- sortedComments,
212
- currentIndex,
213
- hoveredCommentId,
214
171
  setHoveredCommentId,
215
172
  navigateToComment,
216
173
  navigatePrevious,
217
174
  navigateNext,
218
- reanchorTarget,
219
175
  startReanchor,
220
176
  cancelReanchor,
221
- copyCommentRaw,
222
- copyCommentForLLM,
223
- copyAllForLLM,
177
+ copyComment,
224
178
  scrollToHighlight,
225
179
  ],
226
180
  );
227
181
 
228
- return <CommentContext value={value}>{children}</CommentContext>;
182
+ const data = useMemo<CommentDataValue>(
183
+ () => ({
184
+ comments,
185
+ commentCount: comments.length,
186
+ sortedComments,
187
+ currentIndex,
188
+ reanchorTarget,
189
+ }),
190
+ [comments, sortedComments, currentIndex, reanchorTarget],
191
+ );
192
+
193
+ return (
194
+ <CommentActionsContext value={actions}>
195
+ <CommentDataContext value={data}>{children}</CommentDataContext>
196
+ </CommentActionsContext>
197
+ );
229
198
  }
@@ -1,6 +1,37 @@
1
- import { createContext, type ReactNode, use, useMemo } from "react";
2
- import { useLocalePreference } from "../hooks/useLocalePreference";
3
- import { createT, type Locale, type TranslationKey } from "../lib/i18n";
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ use,
5
+ useCallback,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+ import {
10
+ createT,
11
+ type Locale,
12
+ Locales,
13
+ type TranslationKey,
14
+ } from "../lib/i18n";
15
+
16
+ const STORAGE_KEY = "readit:locale";
17
+
18
+ function detectLocale(): Locale {
19
+ const browserLang = navigator.language.slice(0, 2).toLowerCase();
20
+ if (browserLang === "ja") return Locales.JA;
21
+ return Locales.EN;
22
+ }
23
+
24
+ function getStoredLocale(): Locale {
25
+ try {
26
+ const stored = localStorage.getItem(STORAGE_KEY);
27
+ if (stored === Locales.JA || stored === Locales.EN) {
28
+ return stored;
29
+ }
30
+ } catch {
31
+ // localStorage may be unavailable
32
+ }
33
+ return detectLocale();
34
+ }
4
35
 
5
36
  interface LocaleContextValue {
6
37
  locale: Locale;
@@ -23,7 +54,17 @@ interface LocaleProviderProps {
23
54
  }
24
55
 
25
56
  export function LocaleProvider({ children }: LocaleProviderProps) {
26
- const { locale, setLocale } = useLocalePreference();
57
+ const [locale, setLocaleState] = useState<Locale>(getStoredLocale);
58
+
59
+ const setLocale = useCallback((newLocale: Locale) => {
60
+ setLocaleState(newLocale);
61
+ try {
62
+ localStorage.setItem(STORAGE_KEY, newLocale);
63
+ } catch {
64
+ // localStorage may be unavailable
65
+ }
66
+ }, []);
67
+
27
68
  const t = useMemo(() => createT(locale), [locale]);
28
69
 
29
70
  const value = useMemo<LocaleContextValue>(
@@ -0,0 +1,16 @@
1
+ import { createContext, type ReactNode, use, useRef } from "react";
2
+ import { Positions } from "../lib/positions";
3
+
4
+ const Ctx = createContext<Positions | null>(null);
5
+
6
+ export function usePositions(): Positions {
7
+ const value = use(Ctx);
8
+ if (!value) throw new Error("usePositions requires PositionsProvider");
9
+ return value;
10
+ }
11
+
12
+ export function PositionsProvider({ children }: { children: ReactNode }) {
13
+ const ref = useRef<Positions | null>(null);
14
+ if (!ref.current) ref.current = new Positions();
15
+ return <Ctx value={ref.current}>{children}</Ctx>;
16
+ }
@@ -0,0 +1,133 @@
1
+ import {
2
+ createContext,
3
+ type ReactNode,
4
+ use,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useState,
9
+ } from "react";
10
+ import { toast } from "sonner";
11
+ import {
12
+ FontFamilies,
13
+ type FontFamily,
14
+ type ThemeMode,
15
+ ThemeModes,
16
+ } from "../schema";
17
+
18
+ const THEME_STORAGE_KEY = "readit:theme";
19
+ const DARK_MQ = "(prefers-color-scheme: dark)";
20
+
21
+ function getStoredTheme(): ThemeMode {
22
+ try {
23
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
24
+ if (
25
+ stored === ThemeModes.LIGHT ||
26
+ stored === ThemeModes.DARK ||
27
+ stored === ThemeModes.SYSTEM
28
+ ) {
29
+ return stored;
30
+ }
31
+ } catch {
32
+ // localStorage may be unavailable
33
+ }
34
+ return ThemeModes.SYSTEM;
35
+ }
36
+
37
+ function applyTheme(mode: ThemeMode): void {
38
+ const isDark =
39
+ mode === ThemeModes.DARK ||
40
+ (mode === ThemeModes.SYSTEM && window.matchMedia(DARK_MQ).matches);
41
+
42
+ document.documentElement.classList.toggle("dark", isDark);
43
+ }
44
+
45
+ interface SettingsContextValue {
46
+ fontFamily: FontFamily;
47
+ setFontFamily: (font: FontFamily) => Promise<void>;
48
+ themeMode: ThemeMode;
49
+ setThemeMode: (mode: ThemeMode) => void;
50
+ }
51
+
52
+ export const SettingsContext = createContext<SettingsContextValue | null>(null);
53
+
54
+ export function useSettings(): SettingsContextValue {
55
+ const value = use(SettingsContext);
56
+ if (!value) {
57
+ throw new Error("useSettings must be used within a SettingsProvider");
58
+ }
59
+ return value;
60
+ }
61
+
62
+ export function SettingsProvider({ children }: { children: ReactNode }) {
63
+ const [fontFamily, setFontFamilyState] = useState<FontFamily>(
64
+ FontFamilies.SERIF,
65
+ );
66
+
67
+ useEffect(() => {
68
+ const fetchSettings = async () => {
69
+ try {
70
+ const response = await fetch("/api/settings");
71
+ if (response.ok) {
72
+ const settings = await response.json();
73
+ setFontFamilyState(settings.fontFamily || FontFamilies.SERIF);
74
+ }
75
+ } catch (err) {
76
+ console.error("Failed to fetch settings:", err);
77
+ }
78
+ };
79
+
80
+ fetchSettings();
81
+ }, []);
82
+
83
+ const setFontFamily = useCallback(async (font: FontFamily) => {
84
+ setFontFamilyState(font);
85
+
86
+ try {
87
+ const response = await fetch("/api/settings", {
88
+ method: "PUT",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ fontFamily: font }),
91
+ });
92
+
93
+ if (!response.ok) {
94
+ throw new Error("Failed to save settings");
95
+ }
96
+ } catch (err) {
97
+ console.error("Failed to save font preference:", err);
98
+ toast.error("Failed to save font preference");
99
+ }
100
+ }, []);
101
+
102
+ const [themeMode, setThemeModeState] = useState<ThemeMode>(getStoredTheme);
103
+
104
+ useEffect(() => {
105
+ applyTheme(themeMode);
106
+ }, [themeMode]);
107
+
108
+ useEffect(() => {
109
+ if (themeMode !== ThemeModes.SYSTEM) return;
110
+
111
+ const mq = window.matchMedia(DARK_MQ);
112
+ const handler = () => applyTheme(ThemeModes.SYSTEM);
113
+
114
+ mq.addEventListener("change", handler);
115
+ return () => mq.removeEventListener("change", handler);
116
+ }, [themeMode]);
117
+
118
+ const setThemeMode = useCallback((mode: ThemeMode) => {
119
+ setThemeModeState(mode);
120
+ try {
121
+ localStorage.setItem(THEME_STORAGE_KEY, mode);
122
+ } catch {
123
+ // localStorage may be unavailable
124
+ }
125
+ }, []);
126
+
127
+ const value = useMemo<SettingsContextValue>(
128
+ () => ({ fontFamily, setFontFamily, themeMode, setThemeMode }),
129
+ [fontFamily, setFontFamily, themeMode, setThemeMode],
130
+ );
131
+
132
+ return <SettingsContext value={value}>{children}</SettingsContext>;
133
+ }
@@ -1,9 +1,5 @@
1
1
  import { type RefObject, useEffect } from "react";
2
2
 
3
- /**
4
- * Close a dropdown/popover when clicking outside or pressing Escape.
5
- * Only attaches listeners when `active` is true.
6
- */
7
3
  export function useClickOutside(
8
4
  ref: RefObject<HTMLElement | null>,
9
5
  onClose: () => void,
@@ -1,27 +1,19 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { appStore, useAppStore } from "../store";
3
- import type { Comment } from "../types";
2
+ import type { Comment } from "../schema";
3
+ import { uiStore } from "../store";
4
4
 
5
5
  interface UseCommentNavigationResult {
6
6
  currentIndex: number;
7
- hoveredCommentId: string | undefined;
8
7
  setHoveredCommentId: (id: string | undefined) => void;
9
8
  navigateToComment: (commentId: string) => void;
10
9
  navigatePrevious: () => void;
11
10
  navigateNext: () => void;
12
11
  }
13
12
 
14
- /**
15
- * Manage comment navigation with cycling, keyboard shortcuts, and scroll-to-comment.
16
- * Handles Alt+↑/↓ keyboard navigation.
17
- */
18
13
  export function useCommentNavigation(
19
14
  sortedComments: Comment[],
20
15
  ): UseCommentNavigationResult {
21
16
  const [currentIndex, setCurrentIndex] = useState(0);
22
- const hoveredCommentId = useAppStore(
23
- (s) => s.getActiveDocumentState()?.hoveredCommentId,
24
- );
25
17
  const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(
26
18
  undefined,
27
19
  );
@@ -30,7 +22,6 @@ export function useCommentNavigation(
30
22
  const sortedRef = useRef(sortedComments);
31
23
  sortedRef.current = sortedComments;
32
24
 
33
- // Cleanup hover timeout on unmount
34
25
  useEffect(() => {
35
26
  return () => clearTimeout(hoverTimeoutRef.current);
36
27
  }, []);
@@ -44,7 +35,6 @@ export function useCommentNavigation(
44
35
  setCurrentIndex(clampedIndex);
45
36
  }
46
37
 
47
- // Update DOM data-focused attributes imperatively
48
38
  const updateFocusedMarks = useCallback((commentId: string | undefined) => {
49
39
  const marks = window.document.querySelectorAll("mark[data-comment-id]");
50
40
  for (const mark of marks) {
@@ -59,13 +49,12 @@ export function useCommentNavigation(
59
49
 
60
50
  const setHoveredCommentId = useCallback(
61
51
  (id: string | undefined) => {
62
- appStore.getState().setHoveredCommentId(id);
52
+ uiStore.setState({ hoveredCommentId: id });
63
53
  updateFocusedMarks(id);
64
54
  },
65
55
  [updateFocusedMarks],
66
56
  );
67
57
 
68
- // Navigate to a comment by scrolling its highlight into view
69
58
  const navigateToComment = useCallback(
70
59
  (commentId: string) => {
71
60
  const selector = `mark[data-comment-id="${commentId}"]`;
@@ -80,24 +69,14 @@ export function useCommentNavigation(
80
69
  );
81
70
  };
82
71
 
83
- // Try main document first (for markdown)
84
- const mainHighlight = document.querySelector(selector);
85
- if (mainHighlight) {
86
- scrollAndHighlight(mainHighlight);
87
- return;
88
- }
89
-
90
- // Try inside iframe (for HTML content)
91
- const iframe = document.querySelector("iframe");
92
- const iframeHighlight = iframe?.contentDocument?.querySelector(selector);
93
- if (iframeHighlight) {
94
- scrollAndHighlight(iframeHighlight);
72
+ const highlight = document.querySelector(selector);
73
+ if (highlight) {
74
+ scrollAndHighlight(highlight);
95
75
  }
96
76
  },
97
77
  [setHoveredCommentId],
98
78
  );
99
79
 
100
- // Navigate to previous comment (cycles to last when at first)
101
80
  const navigatePrevious = useCallback(() => {
102
81
  const sc = sortedRef.current;
103
82
  if (sc.length === 0) return;
@@ -108,7 +87,6 @@ export function useCommentNavigation(
108
87
  });
109
88
  }, [navigateToComment]);
110
89
 
111
- // Navigate to next comment (cycles to first when at last)
112
90
  const navigateNext = useCallback(() => {
113
91
  const sc = sortedRef.current;
114
92
  if (sc.length === 0) return;
@@ -121,7 +99,6 @@ export function useCommentNavigation(
121
99
 
122
100
  return {
123
101
  currentIndex: clampedIndex,
124
- hoveredCommentId,
125
102
  setHoveredCommentId,
126
103
  navigateToComment,
127
104
  navigatePrevious,