@peaske7/readit 0.2.0 → 0.2.1

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 (173) hide show
  1. package/.claude/CLAUDE.md +118 -76
  2. package/.claude/commands/review.md +1 -1
  3. package/.claude/roadmap.md +32 -9
  4. package/.claude/user-stories.md +100 -15
  5. package/AGENTS.md +30 -26
  6. package/Makefile +32 -0
  7. package/README.md +90 -2
  8. package/biome.json +18 -8
  9. package/bun.lock +426 -568
  10. package/bunfig.toml +2 -0
  11. package/docs/perf-baseline.md +56 -1
  12. package/docs/superpowers/specs/2026-03-27-go-server-rewrite-design.md +284 -0
  13. package/e2e/comments.spec.ts +14 -58
  14. package/e2e/document-load.spec.ts +1 -23
  15. package/e2e/export.spec.ts +4 -4
  16. package/e2e/perf/add-comment.spec.ts +9 -11
  17. package/e2e/perf/fixtures/generate.ts +1 -5
  18. package/e2e/perf/screenshot-final.png +0 -0
  19. package/e2e/perf/utils/metrics.ts +73 -9
  20. package/e2e/persistence-file.spec.ts +41 -26
  21. package/e2e/utils/selection.ts +17 -73
  22. package/go/cmd/readit/main.go +416 -0
  23. package/go/go.mod +20 -0
  24. package/go/go.sum +41 -0
  25. package/go/internal/server/anchor.go +302 -0
  26. package/go/internal/server/anchor_test.go +111 -0
  27. package/go/internal/server/comments.go +390 -0
  28. package/go/internal/server/documents.go +113 -0
  29. package/go/internal/server/embed.go +17 -0
  30. package/go/internal/server/headings.go +33 -0
  31. package/go/internal/server/headings_test.go +75 -0
  32. package/go/internal/server/htmltext.go +123 -0
  33. package/go/internal/server/markdown.go +157 -0
  34. package/go/internal/server/markdown_bench_test.go +42 -0
  35. package/go/internal/server/markdown_test.go +79 -0
  36. package/go/internal/server/server.go +453 -0
  37. package/go/internal/server/server_bench_test.go +122 -0
  38. package/go/internal/server/settings.go +110 -0
  39. package/go/internal/server/sse.go +140 -0
  40. package/go/internal/server/storage.go +275 -0
  41. package/go/internal/server/storage_test.go +118 -0
  42. package/go/internal/server/template.go +66 -0
  43. package/go/internal/server/types.go +101 -0
  44. package/go/internal/server/watcher.go +74 -0
  45. package/index.html +4 -14
  46. package/nvim-readit/lua/readit/health.lua +64 -0
  47. package/nvim-readit/lua/readit/init.lua +463 -0
  48. package/nvim-readit/plugin/readit.lua +19 -0
  49. package/package.json +20 -28
  50. package/shell/_readit +158 -0
  51. package/shell/readit.zsh +87 -0
  52. package/src/App.svelte +881 -0
  53. package/src/cli.ts +183 -21
  54. package/src/components/ActionsMenu.svelte +95 -0
  55. package/src/components/CommentBadge.svelte +67 -0
  56. package/src/components/CommentErrorBanner.svelte +33 -0
  57. package/src/components/CommentInput.svelte +75 -0
  58. package/src/components/CommentListItem.svelte +95 -0
  59. package/src/components/CommentManager.svelte +129 -0
  60. package/src/components/CommentNav.svelte +109 -0
  61. package/src/components/DocumentViewer.svelte +218 -0
  62. package/src/components/FloatingComment.svelte +107 -0
  63. package/src/components/Header.svelte +76 -0
  64. package/src/components/InlineEditor.svelte +72 -0
  65. package/src/components/MarginNote.svelte +167 -0
  66. package/src/components/MarginNotesContainer.svelte +33 -0
  67. package/src/components/RawModal.svelte +126 -0
  68. package/src/components/ReanchorConfirm.svelte +30 -0
  69. package/src/components/SettingsModal.svelte +220 -0
  70. package/src/components/ShortcutCapture.svelte +82 -0
  71. package/src/components/ShortcutList.svelte +145 -0
  72. package/src/components/TabBar.svelte +52 -0
  73. package/src/components/TableOfContents.svelte +125 -0
  74. package/src/components/ui/ActionLink.svelte +40 -0
  75. package/src/components/ui/{Button.tsx → Button.svelte} +19 -20
  76. package/src/components/ui/Dialog.svelte +97 -0
  77. package/src/components/ui/DropdownMenu.svelte +85 -0
  78. package/src/components/ui/DropdownMenuItem.svelte +38 -0
  79. package/src/components/ui/DropdownMenuSeparator.svelte +11 -0
  80. package/src/components/ui/{Text.tsx → Text.svelte} +18 -23
  81. package/src/env.d.ts +6 -0
  82. package/src/index.css +36 -166
  83. package/src/lib/__fixtures__/bench-data.ts +0 -13
  84. package/src/lib/anchor.bench.ts +1 -12
  85. package/src/lib/anchor.test.ts +0 -8
  86. package/src/lib/anchor.ts +0 -4
  87. package/src/lib/comment-storage.bench.ts +49 -0
  88. package/src/lib/comment-storage.test.ts +41 -33
  89. package/src/lib/comment-storage.ts +21 -18
  90. package/src/lib/export.bench.ts +21 -0
  91. package/src/lib/export.ts +0 -1
  92. package/src/{hooks/useHeadings.test.ts → lib/headings.test.ts} +10 -24
  93. package/src/{hooks/useHeadings.ts → lib/headings.ts} +11 -13
  94. package/src/lib/highlight/core.test.ts +0 -5
  95. package/src/lib/highlight/dom.ts +52 -216
  96. package/src/lib/highlight/highlight-registry.ts +221 -0
  97. package/src/lib/highlight/highlight.bench.ts +92 -0
  98. package/src/lib/highlight/highlighter.ts +112 -132
  99. package/src/lib/highlight/resolver.ts +5 -79
  100. package/src/lib/highlight/types.ts +0 -5
  101. package/src/lib/html-text.test.ts +162 -0
  102. package/src/lib/html-text.ts +161 -0
  103. package/src/lib/i18n/en.ts +26 -0
  104. package/src/lib/i18n/ja.ts +26 -0
  105. package/src/lib/i18n/types.ts +25 -0
  106. package/src/lib/margin-layout.bench.ts +61 -0
  107. package/src/lib/margin-layout.ts +0 -7
  108. package/src/lib/markdown-renderer.test.ts +154 -0
  109. package/src/lib/markdown-renderer.ts +177 -0
  110. package/src/lib/mermaid-config.ts +38 -0
  111. package/src/lib/mermaid-renderer.ts +162 -0
  112. package/src/lib/mermaid-worker.ts +60 -0
  113. package/src/lib/positions.ts +31 -24
  114. package/src/lib/shortcut-registry.ts +244 -0
  115. package/src/lib/utils.ts +0 -29
  116. package/src/main.ts +16 -0
  117. package/src/schema.ts +16 -5
  118. package/src/server.ts +355 -91
  119. package/src/stores/app.svelte.ts +231 -0
  120. package/src/stores/locale.svelte.ts +46 -0
  121. package/src/stores/settings.svelte.ts +90 -0
  122. package/src/stores/shortcuts.svelte.ts +104 -0
  123. package/src/stores/ui.svelte.ts +12 -0
  124. package/src/template.ts +104 -0
  125. package/src/test-setup.ts +47 -0
  126. package/svelte.config.js +5 -0
  127. package/tsconfig.json +2 -2
  128. package/vite.config.ts +23 -3
  129. package/vscode-readit/.mcp.json +7 -0
  130. package/vscode-readit/.vscodeignore +7 -0
  131. package/vscode-readit/bun.lock +78 -0
  132. package/vscode-readit/icon.svg +10 -0
  133. package/vscode-readit/package.json +110 -0
  134. package/vscode-readit/src/extension.ts +117 -0
  135. package/vscode-readit/src/server-manager.ts +272 -0
  136. package/vscode-readit/src/webview-provider.ts +204 -0
  137. package/vscode-readit/tsconfig.json +20 -0
  138. package/e2e/fixtures/sample.html +0 -13
  139. package/src/App.tsx +0 -368
  140. package/src/components/ActionsMenu.tsx +0 -91
  141. package/src/components/DocumentViewer/CodeBlock.tsx +0 -160
  142. package/src/components/DocumentViewer/DocumentViewer.tsx +0 -230
  143. package/src/components/DocumentViewer/MermaidDiagram.tsx +0 -136
  144. package/src/components/Header.tsx +0 -54
  145. package/src/components/InlineEditor.tsx +0 -74
  146. package/src/components/MarginNote.tsx +0 -185
  147. package/src/components/MarginNotes.tsx +0 -23
  148. package/src/components/RawModal.tsx +0 -144
  149. package/src/components/ReanchorConfirm.tsx +0 -36
  150. package/src/components/SettingsModal.tsx +0 -232
  151. package/src/components/TabBar.tsx +0 -60
  152. package/src/components/TableOfContents.tsx +0 -108
  153. package/src/components/comments/CommentBadge.tsx +0 -49
  154. package/src/components/comments/CommentInput.tsx +0 -86
  155. package/src/components/comments/CommentListItem.tsx +0 -90
  156. package/src/components/comments/CommentManager.tsx +0 -129
  157. package/src/components/comments/CommentNav.tsx +0 -109
  158. package/src/components/ui/ActionLink.tsx +0 -28
  159. package/src/components/ui/Dialog.tsx +0 -116
  160. package/src/components/ui/DropdownMenu.tsx +0 -158
  161. package/src/contexts/CommentContext.tsx +0 -198
  162. package/src/contexts/LocaleContext.tsx +0 -76
  163. package/src/contexts/PositionsContext.tsx +0 -16
  164. package/src/contexts/SettingsContext.tsx +0 -133
  165. package/src/hooks/useClickOutside.ts +0 -31
  166. package/src/hooks/useCommentNavigation.ts +0 -107
  167. package/src/hooks/useComments.ts +0 -311
  168. package/src/hooks/useDocument.ts +0 -157
  169. package/src/hooks/useScrollSpy.ts +0 -77
  170. package/src/hooks/useTextSelection.ts +0 -86
  171. package/src/lib/highlight/worker.ts +0 -45
  172. package/src/main.tsx +0 -13
  173. package/src/store.ts +0 -222
@@ -0,0 +1,167 @@
1
+ <script lang="ts">
2
+ import type { Positions } from "../lib/positions";
3
+ import { cn } from "../lib/utils";
4
+ import { type Comment, FontFamilies } from "../schema";
5
+ import { t } from "../stores/locale.svelte";
6
+ import { settings } from "../stores/settings.svelte";
7
+ import { setHoveredCommentId, ui } from "../stores/ui.svelte";
8
+ import InlineEditor from "./InlineEditor.svelte";
9
+ import ActionLink from "./ui/ActionLink.svelte";
10
+
11
+ interface Props {
12
+ comment: Comment;
13
+ commentIndex?: number;
14
+ positions: Positions;
15
+ onedit: (id: string, text: string) => void;
16
+ ondelete: (id: string) => void;
17
+ oncopy: (comment: Comment) => void;
18
+ onnavigate: (commentId: string) => void;
19
+ }
20
+
21
+ let {
22
+ comment,
23
+ commentIndex = 0,
24
+ positions,
25
+ onedit,
26
+ ondelete,
27
+ oncopy,
28
+ onnavigate,
29
+ }: Props = $props();
30
+
31
+ let isEditing = $state(false);
32
+ let articleEl: HTMLElement | undefined = $state();
33
+
34
+ let isHovered = $derived(ui.hoveredCommentId === comment.id);
35
+ let fontClass = $derived(
36
+ settings.fontFamily === FontFamilies.SANS_SERIF ? "font-sans" : "font-serif",
37
+ );
38
+ let hasNote = $derived(comment.comment.trim().length > 0);
39
+
40
+ $effect(() => {
41
+ if (articleEl) {
42
+ positions.register(comment.id, articleEl);
43
+ }
44
+
45
+ return () => {
46
+ positions.unregister(comment.id);
47
+ };
48
+ });
49
+
50
+ function selectedTextClass(hovered: boolean): string {
51
+ return cn(
52
+ "text-sm italic mb-1 line-clamp-1 flex items-center gap-1 transition-colors duration-150",
53
+ hovered
54
+ ? "text-zinc-600 dark:text-zinc-400"
55
+ : "text-zinc-400 dark:text-zinc-500",
56
+ );
57
+ }
58
+
59
+ function commentTextClass(hovered: boolean): string {
60
+ return cn(
61
+ "text-sm whitespace-pre-wrap transition-colors duration-150",
62
+ hovered
63
+ ? "text-zinc-800 dark:text-zinc-200"
64
+ : "text-zinc-500 dark:text-zinc-400",
65
+ );
66
+ }
67
+
68
+ function badgeClass(hovered: boolean): string {
69
+ return cn(
70
+ "absolute -left-4 top-2 text-xs tabular-nums transition-colors duration-150",
71
+ hovered
72
+ ? "text-zinc-600 dark:text-zinc-400"
73
+ : "text-zinc-400 dark:text-zinc-500",
74
+ );
75
+ }
76
+ </script>
77
+
78
+ {#if !hasNote && !isEditing}
79
+ <!-- Highlight-only (no note): minimal em-dash marker -->
80
+ <article
81
+ bind:this={articleEl}
82
+ class="absolute left-0 right-0 group"
83
+ style="visibility: hidden; content-visibility: auto; contain-intrinsic-size: auto 80px;"
84
+ data-comment-id={comment.id}
85
+ onmouseenter={() => setHoveredCommentId(comment.id)}
86
+ onmouseleave={() => setHoveredCommentId(undefined)}
87
+ >
88
+ <span class={badgeClass(isHovered)}>&mdash;</span>
89
+
90
+ <div class="pt-2 pb-2 pl-3">
91
+ <div
92
+ class={cn(
93
+ "flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity",
94
+ "gap-1.5 duration-150",
95
+ isHovered && "opacity-100",
96
+ )}
97
+ >
98
+ <ActionLink onclick={() => (isEditing = true)}>
99
+ {t("marginNote.addNote")}
100
+ </ActionLink>
101
+ <span aria-hidden="true">&middot;</span>
102
+ <ActionLink variant="destructive" onclick={() => ondelete(comment.id)}>
103
+ {t("marginNote.delete")}
104
+ </ActionLink>
105
+ </div>
106
+ </div>
107
+ </article>
108
+ {:else}
109
+ <!-- Comment with note -->
110
+ <article
111
+ bind:this={articleEl}
112
+ class="absolute left-0 right-0 group"
113
+ style="visibility: hidden"
114
+ data-comment-id={comment.id}
115
+ onmouseenter={() => setHoveredCommentId(comment.id)}
116
+ onmouseleave={() => setHoveredCommentId(undefined)}
117
+ >
118
+ <span class={badgeClass(isHovered)}>{commentIndex + 1}</span>
119
+
120
+ <div
121
+ class={cn(
122
+ "relative border-t border-zinc-100 dark:border-zinc-800 pt-3 pb-2 pl-3 transition-colors duration-150",
123
+ comment.anchorConfidence === "unresolved" && "opacity-60",
124
+ )}
125
+ >
126
+ {#if !isEditing}
127
+ <div class={cn(fontClass, selectedTextClass(isHovered))}>
128
+ <button
129
+ type="button"
130
+ onclick={() => onnavigate(comment.id)}
131
+ class="cursor-pointer hover:underline text-left"
132
+ >
133
+ "{comment.selectedText}"
134
+ </button>
135
+ </div>
136
+ {/if}
137
+
138
+ {#if isEditing}
139
+ <InlineEditor
140
+ initialText={comment.comment}
141
+ onsave={(text) => {
142
+ onedit(comment.id, text);
143
+ isEditing = false;
144
+ }}
145
+ oncancel={() => (isEditing = false)}
146
+ />
147
+ {:else}
148
+ <p class={cn(fontClass, commentTextClass(isHovered))}>
149
+ {comment.comment}
150
+ </p>
151
+ <div class="flex items-center text-xs text-zinc-400 opacity-0 group-hover:opacity-100 transition-opacity gap-1.5 mt-2">
152
+ <ActionLink onclick={() => (isEditing = true)}>
153
+ {t("marginNote.edit")}
154
+ </ActionLink>
155
+ <span aria-hidden="true">&middot;</span>
156
+ <ActionLink variant="destructive" onclick={() => ondelete(comment.id)}>
157
+ {t("marginNote.delete")}
158
+ </ActionLink>
159
+ <span aria-hidden="true">&middot;</span>
160
+ <ActionLink onclick={() => oncopy(comment)}>
161
+ {t("marginNote.copy")}
162
+ </ActionLink>
163
+ </div>
164
+ {/if}
165
+ </div>
166
+ </article>
167
+ {/if}
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import type { Positions } from "../lib/positions";
3
+ import type { Comment } from "../schema";
4
+ import MarginNote from "./MarginNote.svelte";
5
+
6
+ interface Props {
7
+ sortedComments: Comment[];
8
+ positions: Positions;
9
+ onedit: (id: string, text: string) => void;
10
+ ondelete: (id: string) => void;
11
+ oncopy: (comment: Comment) => void;
12
+ onnavigate: (commentId: string) => void;
13
+ }
14
+
15
+ let { sortedComments, positions, onedit, ondelete, oncopy, onnavigate }: Props =
16
+ $props();
17
+ </script>
18
+
19
+ {#if sortedComments.length > 0}
20
+ <div class="relative w-64">
21
+ {#each sortedComments as comment, index (comment.id)}
22
+ <MarginNote
23
+ {comment}
24
+ commentIndex={index}
25
+ {positions}
26
+ {onedit}
27
+ {ondelete}
28
+ {oncopy}
29
+ {onnavigate}
30
+ />
31
+ {/each}
32
+ </div>
33
+ {/if}
@@ -0,0 +1,126 @@
1
+ <script lang="ts">
2
+ import { Copy } from "lucide-svelte";
3
+ import { app } from "../stores/app.svelte";
4
+ import { t } from "../stores/locale.svelte";
5
+ import Button from "./ui/Button.svelte";
6
+ import Dialog from "./ui/Dialog.svelte";
7
+ import Text from "./ui/Text.svelte";
8
+
9
+ interface Props {
10
+ open: boolean;
11
+ onclose: () => void;
12
+ }
13
+
14
+ let { open = $bindable(false), onclose }: Props = $props();
15
+
16
+ type ModalState =
17
+ | { status: "idle" }
18
+ | { status: "loading" }
19
+ | { status: "error"; error: string }
20
+ | { status: "empty"; path: string }
21
+ | { status: "success"; content: string; path: string };
22
+
23
+ let modalState = $state<ModalState>({ status: "idle" });
24
+
25
+ $effect(() => {
26
+ if (!open) {
27
+ modalState = { status: "idle" };
28
+ return;
29
+ }
30
+
31
+ modalState = { status: "loading" };
32
+
33
+ const query = app.activeDocumentPath
34
+ ? `?path=${encodeURIComponent(app.activeDocumentPath)}`
35
+ : "";
36
+
37
+ fetch(`/api/comments/raw${query}`)
38
+ .then((response) => {
39
+ if (!response.ok) {
40
+ throw new Error("Failed to fetch raw comments");
41
+ }
42
+ return response.json();
43
+ })
44
+ .then((result) => {
45
+ if (result.content === null) {
46
+ modalState = { status: "empty", path: result.path };
47
+ } else {
48
+ modalState = {
49
+ status: "success",
50
+ content: result.content,
51
+ path: result.path,
52
+ };
53
+ }
54
+ })
55
+ .catch((err) => {
56
+ modalState = {
57
+ status: "error",
58
+ error: err instanceof Error ? err.message : "Unknown error",
59
+ };
60
+ });
61
+ });
62
+
63
+ async function handleCopy() {
64
+ if (modalState.status !== "success") return;
65
+
66
+ try {
67
+ await navigator.clipboard.writeText(modalState.content);
68
+ } catch {}
69
+ }
70
+ </script>
71
+
72
+ <Dialog bind:open {onclose} contentClass="max-w-2xl max-h-[80vh]">
73
+ {#snippet header()}
74
+ {t("rawModal.title")}
75
+ {/snippet}
76
+
77
+ {#snippet headerActions()}
78
+ {#if modalState.status === "success"}
79
+ <Button
80
+ variant="ghost"
81
+ size="icon"
82
+ class="size-7"
83
+ onclick={handleCopy}
84
+ title={t("rawModal.copyTitle")}
85
+ >
86
+ <Copy class="w-4 h-4" />
87
+ </Button>
88
+ {/if}
89
+ {/snippet}
90
+
91
+ {#if modalState.status === "success" || modalState.status === "empty"}
92
+ <div
93
+ class="px-4 py-2 border-b border-zinc-50 dark:border-zinc-800 text-xs text-zinc-400 dark:text-zinc-500 font-mono truncate -mt-4 -mx-4 mb-4"
94
+ >
95
+ {modalState.path}
96
+ </div>
97
+ {/if}
98
+
99
+ {#if modalState.status === "loading"}
100
+ <Text variant="caption" class="text-center py-8">
101
+ {t("rawModal.loading")}
102
+ </Text>
103
+ {/if}
104
+
105
+ {#if modalState.status === "error"}
106
+ <Text variant="body" class="text-red-500 text-center py-8">
107
+ {modalState.error}
108
+ </Text>
109
+ {/if}
110
+
111
+ {#if modalState.status === "empty"}
112
+ <Text variant="caption" class="text-center py-8">
113
+ {t("rawModal.noComments")}
114
+ </Text>
115
+ {/if}
116
+
117
+ {#if modalState.status === "success"}
118
+ <Text
119
+ variant="body"
120
+ as="pre"
121
+ class="text-xs font-mono whitespace-pre-wrap break-words leading-relaxed"
122
+ >
123
+ {modalState.content}
124
+ </Text>
125
+ {/if}
126
+ </Dialog>
@@ -0,0 +1,30 @@
1
+ <script lang="ts">
2
+ import { t } from "../stores/locale.svelte";
3
+ import Button from "./ui/Button.svelte";
4
+ import Text from "./ui/Text.svelte";
5
+
6
+ interface Props {
7
+ selectionText: string;
8
+ onconfirm: () => void;
9
+ oncancel: () => void;
10
+ }
11
+
12
+ let { selectionText, onconfirm, oncancel }: Props = $props();
13
+ </script>
14
+
15
+ <div class="border-t border-zinc-200 dark:border-zinc-700 pt-2 pb-3 pl-6">
16
+ <Text variant="body" class="mb-2">
17
+ {t("reanchor.question")}
18
+ </Text>
19
+ <Text variant="caption" class="italic line-clamp-2 mb-2">
20
+ "{selectionText}"
21
+ </Text>
22
+ <div class="flex gap-3 text-sm">
23
+ <Button variant="link" size="sm" onclick={onconfirm}>
24
+ {t("reanchor.confirm")}
25
+ </Button>
26
+ <Button variant="ghost" size="sm" onclick={oncancel}>
27
+ {t("reanchor.cancel")}
28
+ </Button>
29
+ </div>
30
+ </div>
@@ -0,0 +1,220 @@
1
+ <script lang="ts">
2
+ import { Check, ChevronDown } from "lucide-svelte";
3
+ import { type Locale, Locales } from "../lib/i18n";
4
+ import { cn } from "../lib/utils";
5
+ import { FontFamilies, type FontFamily, ThemeModes } from "../schema";
6
+ import { localeState, setLocale, t } from "../stores/locale.svelte";
7
+ import {
8
+ settings,
9
+ updateFontFamily,
10
+ updateThemeMode,
11
+ } from "../stores/settings.svelte";
12
+ import ShortcutList from "./ShortcutList.svelte";
13
+ import Dialog from "./ui/Dialog.svelte";
14
+ import DropdownMenu from "./ui/DropdownMenu.svelte";
15
+ import DropdownMenuItem from "./ui/DropdownMenuItem.svelte";
16
+ import Text from "./ui/Text.svelte";
17
+
18
+ interface Props {
19
+ open: boolean;
20
+ onclose: () => void;
21
+ }
22
+
23
+ let { open = $bindable(false), onclose }: Props = $props();
24
+
25
+ const LOCALE_OPTIONS = [
26
+ { value: Locales.JA, label: "日本語" },
27
+ { value: Locales.EN, label: "English" },
28
+ ] as const;
29
+
30
+ let themeOptions = $derived([
31
+ { value: ThemeModes.SYSTEM, label: t("settings.theme.system") },
32
+ { value: ThemeModes.LIGHT, label: t("settings.theme.light") },
33
+ { value: ThemeModes.DARK, label: t("settings.theme.dark") },
34
+ ]);
35
+
36
+ let fontOptions = $derived([
37
+ {
38
+ value: FontFamilies.SERIF,
39
+ label: t("settings.font.serif"),
40
+ fontClass: "font-serif",
41
+ },
42
+ {
43
+ value: FontFamilies.SANS_SERIF,
44
+ label: t("settings.font.sansSerif"),
45
+ fontClass: "font-sans",
46
+ },
47
+ ]);
48
+
49
+ let activeTheme = $derived(
50
+ themeOptions.find((o) => o.value === settings.themeMode) ?? themeOptions[0],
51
+ );
52
+ let activeFont = $derived(
53
+ fontOptions.find((o) => o.value === settings.fontFamily) ?? fontOptions[0],
54
+ );
55
+ let activeLocale = $derived(
56
+ LOCALE_OPTIONS.find((o) => o.value === localeState.locale) ??
57
+ LOCALE_OPTIONS[0],
58
+ );
59
+
60
+ const triggerClassName = cn(
61
+ "inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm",
62
+ "border border-zinc-200 dark:border-zinc-700",
63
+ "bg-white dark:bg-zinc-800",
64
+ "text-zinc-700 dark:text-zinc-300",
65
+ "hover:bg-zinc-50 dark:hover:bg-zinc-700/50",
66
+ "transition-colors cursor-pointer",
67
+ );
68
+
69
+ let themeDropdownOpen = $state(false);
70
+ let fontDropdownOpen = $state(false);
71
+ let localeDropdownOpen = $state(false);
72
+ </script>
73
+
74
+ <Dialog bind:open {onclose} contentClass="max-w-md">
75
+ {#snippet header()}
76
+ {t("settings.title")}
77
+ {/snippet}
78
+
79
+ <div class="space-y-4">
80
+ <div>
81
+ <Text variant="overline" as="h3" class="mb-3">
82
+ {t("settings.theme")}
83
+ </Text>
84
+ <DropdownMenu bind:open={themeDropdownOpen} align="start" contentClass="min-w-[160px]">
85
+ {#snippet trigger()}
86
+ <button type="button" class={triggerClassName}>
87
+ {#if activeTheme.value === ThemeModes.SYSTEM}
88
+ <span
89
+ class="size-2.5 rounded-full bg-gradient-to-r from-amber-400 to-indigo-400"
90
+ ></span>
91
+ {:else}
92
+ <span
93
+ class={cn(
94
+ "size-2.5 rounded-full",
95
+ activeTheme.value === ThemeModes.LIGHT
96
+ ? "bg-amber-400"
97
+ : "bg-indigo-400",
98
+ )}
99
+ ></span>
100
+ {/if}
101
+ <span
102
+ class="text-[10px] font-semibold leading-none text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5"
103
+ >Aa</span>
104
+ <span>{activeTheme.label}</span>
105
+ <ChevronDown class="size-3 text-zinc-400 dark:text-zinc-500" />
106
+ </button>
107
+ {/snippet}
108
+
109
+ {#each themeOptions as option (option.value)}
110
+ <DropdownMenuItem
111
+ onselect={() => {
112
+ updateThemeMode(option.value);
113
+ themeDropdownOpen = false;
114
+ }}
115
+ class="flex items-center gap-2"
116
+ >
117
+ {#if option.value === ThemeModes.SYSTEM}
118
+ <span
119
+ class="size-2.5 rounded-full bg-gradient-to-r from-amber-400 to-indigo-400"
120
+ ></span>
121
+ {:else}
122
+ <span
123
+ class={cn(
124
+ "size-2.5 rounded-full",
125
+ option.value === ThemeModes.LIGHT
126
+ ? "bg-amber-400"
127
+ : "bg-indigo-400",
128
+ )}
129
+ ></span>
130
+ {/if}
131
+ <span
132
+ class="text-[10px] font-semibold leading-none text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5"
133
+ >Aa</span>
134
+ <span class="flex-1">{option.label}</span>
135
+ {#if settings.themeMode === option.value}
136
+ <Check class="size-3.5 text-zinc-500 dark:text-zinc-400" />
137
+ {/if}
138
+ </DropdownMenuItem>
139
+ {/each}
140
+ </DropdownMenu>
141
+ </div>
142
+
143
+ <div>
144
+ <Text variant="overline" as="h3" class="mb-3">
145
+ {t("settings.font")}
146
+ </Text>
147
+ <DropdownMenu bind:open={fontDropdownOpen} align="start" contentClass="min-w-[160px]">
148
+ {#snippet trigger()}
149
+ <button type="button" class={triggerClassName}>
150
+ <span
151
+ class={cn(
152
+ "text-[10px] font-semibold leading-none text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5",
153
+ activeFont.fontClass,
154
+ )}
155
+ >Aa</span>
156
+ <span>{activeFont.label}</span>
157
+ <ChevronDown class="size-3 text-zinc-400 dark:text-zinc-500" />
158
+ </button>
159
+ {/snippet}
160
+
161
+ {#each fontOptions as option (option.value)}
162
+ <DropdownMenuItem
163
+ onselect={() => {
164
+ updateFontFamily(option.value as FontFamily);
165
+ fontDropdownOpen = false;
166
+ }}
167
+ class="flex items-center gap-2"
168
+ >
169
+ <span
170
+ class={cn(
171
+ "text-[10px] font-semibold leading-none text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 rounded px-1 py-0.5",
172
+ option.fontClass,
173
+ )}
174
+ >Aa</span>
175
+ <span class="flex-1">{option.label}</span>
176
+ {#if settings.fontFamily === option.value}
177
+ <Check class="size-3.5 text-zinc-500 dark:text-zinc-400" />
178
+ {/if}
179
+ </DropdownMenuItem>
180
+ {/each}
181
+ </DropdownMenu>
182
+ </div>
183
+
184
+ <div>
185
+ <Text variant="overline" as="h3" class="mb-3">
186
+ {t("settings.language")}
187
+ </Text>
188
+ <DropdownMenu bind:open={localeDropdownOpen} align="start" contentClass="min-w-[160px]">
189
+ {#snippet trigger()}
190
+ <button type="button" class={triggerClassName}>
191
+ <span>{activeLocale.label}</span>
192
+ <ChevronDown class="size-3 text-zinc-400 dark:text-zinc-500" />
193
+ </button>
194
+ {/snippet}
195
+
196
+ {#each LOCALE_OPTIONS as option (option.value)}
197
+ <DropdownMenuItem
198
+ onselect={() => {
199
+ setLocale(option.value as Locale);
200
+ localeDropdownOpen = false;
201
+ }}
202
+ class="flex items-center gap-2"
203
+ >
204
+ <span class="flex-1">{option.label}</span>
205
+ {#if localeState.locale === option.value}
206
+ <Check class="size-3.5 text-zinc-500 dark:text-zinc-400" />
207
+ {/if}
208
+ </DropdownMenuItem>
209
+ {/each}
210
+ </DropdownMenu>
211
+ </div>
212
+
213
+ <div>
214
+ <Text variant="overline" as="h3" class="mb-3">
215
+ {t("shortcuts.title")}
216
+ </Text>
217
+ <ShortcutList />
218
+ </div>
219
+ </div>
220
+ </Dialog>
@@ -0,0 +1,82 @@
1
+ <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import {
4
+ eventToBinding,
5
+ formatBinding,
6
+ isReservedBinding,
7
+ } from "../lib/shortcut-registry";
8
+ import { cn } from "../lib/utils";
9
+ import type { ShortcutBinding } from "../schema";
10
+ import { t } from "../stores/locale.svelte";
11
+
12
+ interface Props {
13
+ oncapture: (binding: ShortcutBinding) => void;
14
+ oncancel: () => void;
15
+ }
16
+
17
+ let { oncapture, oncancel }: Props = $props();
18
+
19
+ let captureEl: HTMLDivElement | undefined = $state();
20
+ let error = $state<string | null>(null);
21
+ const isMac = navigator.platform.includes("Mac");
22
+
23
+ function handleKeyDown(e: KeyboardEvent) {
24
+ e.preventDefault();
25
+ e.stopPropagation();
26
+
27
+ if (
28
+ e.key === "Alt" ||
29
+ e.key === "Shift" ||
30
+ e.key === "Control" ||
31
+ e.key === "Meta"
32
+ ) {
33
+ return;
34
+ }
35
+
36
+ if (e.key === "Escape" && !e.altKey && !e.metaKey && !e.shiftKey) {
37
+ oncancel();
38
+ return;
39
+ }
40
+
41
+ const binding = eventToBinding(e);
42
+
43
+ if (isReservedBinding(binding, isMac)) {
44
+ error = t("shortcutCapture.reserved", {
45
+ binding: formatBinding(binding, isMac),
46
+ });
47
+ return;
48
+ }
49
+
50
+ error = null;
51
+ oncapture(binding);
52
+ }
53
+
54
+ onMount(() => {
55
+ captureEl?.focus();
56
+ });
57
+ </script>
58
+
59
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
60
+ <div
61
+ role="button"
62
+ aria-label={t("shortcutCapture.ariaLabel")}
63
+ bind:this={captureEl}
64
+ tabindex={0}
65
+ onkeydown={handleKeyDown}
66
+ onblur={oncancel}
67
+ class={cn(
68
+ "inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium",
69
+ "bg-amber-50 dark:bg-amber-900/30",
70
+ "border border-amber-300 dark:border-amber-600",
71
+ "text-amber-700 dark:text-amber-300",
72
+ "outline-none ring-2 ring-amber-400/50",
73
+ "animate-pulse",
74
+ )}
75
+ >
76
+ <span>{t("shortcutCapture.pressKeys")}</span>
77
+ {#if error}
78
+ <span class="text-red-500 dark:text-red-400 text-[10px] animate-none"
79
+ >{error}</span
80
+ >
81
+ {/if}
82
+ </div>