critique 0.1.8 → 0.1.10

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.
package/AGENTS.md CHANGED
@@ -8,7 +8,12 @@ ALWAYS!
8
8
 
9
9
  ## bun
10
10
 
11
- NEVER run bun run index.tsx. You cannot directly run the tui app. it will hang. instead ask me to do so.
11
+ NEVER run the interactive TUI (e.g. `bun run src/cli.tsx` without arguments). It will hang. Instead ask the user to run it.
12
+
13
+ The `web` command is safe to run - it generates HTML and exits:
14
+ ```bash
15
+ bun run src/cli.tsx web
16
+ ```
12
17
 
13
18
  NEVER use require. just import at the top of the file with esm
14
19
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ # 0.1.10
2
+
3
+ - Default command:
4
+ - Add `--filter <pattern>` option to filter files by glob pattern (e.g. `critique --filter 'src/**/*.ts'`)
5
+ - Web command:
6
+ - Add support for comparing two refs: `critique web <base> <head>`
7
+ - Add `--filter <pattern>` option to filter files by glob pattern
8
+ - Add auto light/dark mode based on system preference (uses CSS `prefers-color-scheme`)
9
+ - Disabled when `--theme` is specified
10
+ - Fix browser rendering for Safari/Chrome subpixel issues
11
+ - Themes:
12
+ - Change default theme to `github` (dark)
13
+ - Fix opencode theme line number contrast (was nearly invisible on dark background)
14
+
15
+ # 0.1.9
16
+
17
+ - Performance:
18
+ - Lazy-load themes: Only the default (github) theme is loaded at startup; other themes load on-demand when selected
19
+ - Lazy-load `@parcel/watcher`: Native file watcher module is only loaded when `--watch` flag is used
20
+ - Parallelize `diff` module import with renderer creation
21
+ - Web preview:
22
+ - Generate desktop and mobile HTML versions in parallel
23
+ - Add `--mobile-cols` option (default: 100) for mobile column width
24
+ - Add `--theme` option to specify theme for web preview
25
+ - Worker auto-detects mobile devices via `CF-Device-Type`, `Sec-CH-UA-Mobile`, or User-Agent regex
26
+ - Add `?v=desktop` / `?v=mobile` query params to force a specific version
27
+ - Mobile version uses more rows to accommodate line wrapping
28
+ - Themes:
29
+ - Add `opencode-light` theme - light mode variant of OpenCode theme
30
+ - Change default theme from `github` to `opencode-light`
31
+ - Web preview now uses theme-aware colors (background, text, diff colors)
32
+ - Theme is changeable via state (press `t` in TUI to pick theme)
33
+
1
34
  # 0.1.8
2
35
 
3
36
  - Web preview:
package/README.md CHANGED
@@ -27,16 +27,26 @@ critique
27
27
  # View staged changes
28
28
  critique --staged
29
29
 
30
+ # View the last commit (works whether pushed or unpushed)
31
+ critique HEAD
32
+
30
33
  # View a specific commit
31
34
  critique --commit HEAD~1
32
35
  critique abc1234
33
36
 
37
+ # View combined changes from last N commits
38
+ critique HEAD~3 HEAD # shows all changes from 3 commits ago to now
39
+
34
40
  # Compare two branches (PR-style, shows what head added since diverging from base)
35
41
  critique main feature-branch # what feature-branch added vs main
36
42
  critique main HEAD # what current branch added vs main
37
43
 
38
44
  # Watch mode - auto-refresh on file changes
39
45
  critique --watch
46
+
47
+ # Filter files by glob pattern (can be used multiple times)
48
+ critique --filter "src/**/*.ts"
49
+ critique --filter "src/**/*.ts" --filter "lib/**/*.js"
40
50
  ```
41
51
 
42
52
  ### Navigation
@@ -85,9 +95,15 @@ critique web
85
95
  # View staged changes
86
96
  critique web --staged
87
97
 
98
+ # View the last commit (works whether pushed or unpushed)
99
+ critique web HEAD
100
+
88
101
  # View a specific commit
89
102
  critique web --commit HEAD~1
90
103
 
104
+ # View combined changes from last N commits
105
+ critique web HEAD~3 HEAD
106
+
91
107
  # Generate local HTML file instead of uploading
92
108
  critique web --local
93
109
 
@@ -112,6 +128,7 @@ critique web --cols 100 --rows 2000
112
128
  | `--cols <n>` | Terminal width for rendering | `240` |
113
129
  | `--rows <n>` | Terminal height for rendering | `2000` |
114
130
  | `--local` | Save HTML locally instead of uploading | - |
131
+ | `--filter <pattern>` | Filter files by glob (can be used multiple times) | - |
115
132
 
116
133
  **Tips:**
117
134
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "critique",
3
3
  "module": "src/diff.tsx",
4
4
  "type": "module",
5
- "version": "0.1.8",
5
+ "version": "0.1.10",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
package/src/ansi-html.ts CHANGED
@@ -5,12 +5,16 @@ export interface AnsiToHtmlOptions {
5
5
  rows?: number
6
6
  /** Background color for the container */
7
7
  backgroundColor?: string
8
+ /** Text color for the container */
9
+ textColor?: string
8
10
  /** Font family for the output */
9
11
  fontFamily?: string
10
12
  /** Font size for the output */
11
13
  fontSize?: string
12
14
  /** Trim empty lines from the end */
13
15
  trimEmptyLines?: boolean
16
+ /** Enable auto light/dark mode based on system preference */
17
+ autoTheme?: boolean
14
18
  }
15
19
 
16
20
  /**
@@ -121,7 +125,8 @@ export function ansiToHtml(input: string | Buffer, options: AnsiToHtmlOptions =
121
125
  export function ansiToHtmlDocument(input: string | Buffer, options: AnsiToHtmlOptions = {}): string {
122
126
  const {
123
127
  cols = 500,
124
- backgroundColor = "#0f0f0f",
128
+ backgroundColor = "#ffffff",
129
+ textColor = "#1a1a1a",
125
130
  fontFamily = "Monaco, Menlo, 'Ubuntu Mono', Consolas, monospace",
126
131
  fontSize = "14px",
127
132
  } = options
@@ -150,10 +155,13 @@ html, body {
150
155
  max-width: 100vw;
151
156
  overflow-x: hidden;
152
157
  background-color: ${backgroundColor};
153
- color: #c5c8c6;
158
+ color: ${textColor};
154
159
  font-family: ${fontFamily};
155
160
  font-size: ${fontSize};
156
- line-height: 1.6;
161
+ line-height: 1.5;
162
+ -webkit-font-smoothing: antialiased;
163
+ -moz-osx-font-smoothing: grayscale;
164
+ text-rendering: optimizeLegibility;
157
165
  }
158
166
  body {
159
167
  display: flex;
@@ -169,12 +177,19 @@ body {
169
177
  white-space: pre;
170
178
  display: flex;
171
179
  content-visibility: auto;
172
- contain-intrinsic-block-size: auto 1lh;
180
+ contain-intrinsic-block-size: auto 1.5em;
173
181
  background-color: ${backgroundColor};
182
+ transform: translateZ(0);
183
+ backface-visibility: hidden;
174
184
  }
175
185
  .line span {
176
186
  white-space: pre;
177
187
  }
188
+ ${options.autoTheme ? `@media (prefers-color-scheme: light) {
189
+ html {
190
+ filter: invert(1) hue-rotate(180deg);
191
+ }
192
+ }` : ''}
178
193
  </style>
179
194
  </head>
180
195
  <body>
@@ -192,7 +207,10 @@ ${content}
192
207
  function adjustFontSize() {
193
208
  const viewportWidth = window.innerWidth;
194
209
  const calculatedSize = (viewportWidth - padding) / (cols * charRatio);
195
- const fontSize = Math.max(minFontSize, Math.min(maxFontSize, calculatedSize));
210
+ // Round to nearest even integer to prevent subpixel rendering issues
211
+ // (with line-height: 1.5, even font-size always yields integer line-height)
212
+ const clamped = Math.max(minFontSize, Math.min(maxFontSize, calculatedSize));
213
+ const fontSize = Math.round(clamped / 2) * 2;
196
214
  document.body.style.fontSize = fontSize + 'px';
197
215
  }
198
216
 
package/src/cli.tsx CHANGED
@@ -20,13 +20,22 @@ import { tmpdir, homedir } from "os";
20
20
  import { join } from "path";
21
21
  import { create } from "zustand";
22
22
  import Dropdown from "./dropdown.tsx";
23
- import * as watcher from "@parcel/watcher";
24
23
  import { debounce } from "./utils.ts";
24
+
25
+ // Lazy-load watcher only when --watch is used
26
+ let watcherModule: typeof import("@parcel/watcher") | null = null;
27
+ async function getWatcher() {
28
+ if (!watcherModule) {
29
+ watcherModule = await import("@parcel/watcher");
30
+ }
31
+ return watcherModule;
32
+ }
25
33
  import {
26
34
  getSyntaxTheme,
27
35
  getResolvedTheme,
28
36
  themeNames,
29
37
  defaultThemeName,
38
+ rgbaToHex,
30
39
  } from "./themes.ts";
31
40
 
32
41
  // State persistence
@@ -553,26 +562,39 @@ cli
553
562
  .option("--commit <ref>", "Show changes from a specific commit")
554
563
  .option("--watch", "Watch for file changes and refresh diff")
555
564
  .option("--context <lines>", "Number of context lines (default: 3)")
565
+ .option("--filter <pattern>", "Filter files by glob pattern (can be used multiple times)")
556
566
  .action(async (base, head, options) => {
557
567
  try {
558
568
  const contextArg = options.context ? `-U${options.context}` : "";
569
+ const filters = options.filter ? (Array.isArray(options.filter) ? options.filter : [options.filter]) : [];
570
+ const filterArg = filters.length > 0 ? `-- ${filters.map((f: string) => `"${f}"`).join(" ")}` : "";
559
571
  const gitCommand = (() => {
560
572
  if (options.staged)
561
- return `git diff --cached --no-prefix ${contextArg}`.trim();
573
+ return `git diff --cached --no-prefix ${contextArg} ${filterArg}`.trim();
562
574
  if (options.commit)
563
- return `git show ${options.commit} --no-prefix ${contextArg}`.trim();
575
+ return `git show ${options.commit} --no-prefix ${contextArg} ${filterArg}`.trim();
564
576
  // Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
565
577
  if (base && head)
566
- return `git diff ${base}...${head} --no-prefix ${contextArg}`.trim();
578
+ return `git diff ${base}...${head} --no-prefix ${contextArg} ${filterArg}`.trim();
567
579
  // Single ref: show that commit's changes
568
- if (base) return `git show ${base} --no-prefix ${contextArg}`.trim();
569
- return `git add -N . && git diff --no-prefix ${contextArg}`.trim();
580
+ if (base) return `git show ${base} --no-prefix ${contextArg} ${filterArg}`.trim();
581
+ return `git add -N . && git diff --no-prefix ${contextArg} ${filterArg}`.trim();
570
582
  })();
571
583
 
572
- const { parsePatch, formatPatch } = await import("diff");
573
-
574
584
  const shouldWatch = options.watch && !base && !head && !options.commit;
575
585
 
586
+ // Parallelize diff module loading with renderer creation
587
+ const [diffModule, renderer] = await Promise.all([
588
+ import("diff"),
589
+ createCliRenderer({
590
+ onDestroy() {
591
+ process.exit(0);
592
+ },
593
+ exitOnCtrlC: true,
594
+ }),
595
+ ]);
596
+ const { parsePatch, formatPatch } = diffModule;
597
+
576
598
  function AppWithWatch() {
577
599
  const [parsedFiles, setParsedFiles] = React.useState<
578
600
  ParsedFile[] | null
@@ -636,6 +658,7 @@ cli
636
658
 
637
659
  fetchDiff();
638
660
 
661
+ // Set up file watching only if --watch flag is used
639
662
  if (!shouldWatch) {
640
663
  return;
641
664
  }
@@ -646,21 +669,26 @@ cli
646
669
  fetchDiff();
647
670
  }, 200);
648
671
 
649
- let subscription: watcher.AsyncSubscription | undefined;
672
+ let subscription:
673
+ | Awaited<ReturnType<typeof import("@parcel/watcher").subscribe>>
674
+ | undefined;
650
675
 
651
- watcher
652
- .subscribe(cwd, (err, events) => {
653
- if (err) {
654
- return;
655
- }
676
+ // Lazy-load watcher module only when watching
677
+ getWatcher().then((watcher) => {
678
+ watcher
679
+ .subscribe(cwd, (err, events) => {
680
+ if (err) {
681
+ return;
682
+ }
656
683
 
657
- if (events.length > 0) {
658
- debouncedFetch();
659
- }
660
- })
661
- .then((sub) => {
662
- subscription = sub;
663
- });
684
+ if (events.length > 0) {
685
+ debouncedFetch();
686
+ }
687
+ })
688
+ .then((sub) => {
689
+ subscription = sub;
690
+ });
691
+ });
664
692
 
665
693
  return () => {
666
694
  if (subscription) {
@@ -704,12 +732,6 @@ cli
704
732
  return <App parsedFiles={parsedFiles} />;
705
733
  }
706
734
 
707
- const renderer = await createCliRenderer({
708
- onDestroy() {
709
- process.exit(0);
710
- },
711
- exitOnCtrlC: true,
712
- });
713
735
  createRoot(renderer).render(
714
736
  React.createElement(
715
737
  ErrorBoundary,
@@ -991,33 +1013,48 @@ const WORKER_URL =
991
1013
  process.env.CRITIQUE_WORKER_URL || "https://critique.work";
992
1014
 
993
1015
  cli
994
- .command("web [ref]", "Generate web preview of diff")
1016
+ .command("web [base] [head]", "Generate web preview of diff")
995
1017
  .option("--staged", "Show staged changes")
996
1018
  .option("--commit <ref>", "Show changes from a specific commit")
997
1019
  .option(
998
1020
  "--cols <cols>",
999
- "Number of columns for rendering (use ~100 for mobile)",
1021
+ "Number of columns for desktop rendering",
1000
1022
  { default: 240 },
1001
1023
  )
1002
-
1024
+ .option(
1025
+ "--mobile-cols <cols>",
1026
+ "Number of columns for mobile rendering",
1027
+ { default: 100 },
1028
+ )
1003
1029
  .option("--local", "Save local preview instead of uploading")
1004
1030
  .option("--open", "Open in browser after generating")
1005
1031
  .option("--context <lines>", "Number of context lines (default: 3)")
1006
- .action(async (ref, options) => {
1032
+ .option("--theme <name>", "Theme to use for rendering")
1033
+ .option("--filter <pattern>", "Filter files by glob pattern (can be used multiple times)")
1034
+ .action(async (base, head, options) => {
1007
1035
  const pty = await import("@xmorse/bun-pty");
1008
1036
  const { ansiToHtmlDocument } = await import("./ansi-html.ts");
1009
1037
 
1010
1038
  const contextArg = options.context ? `-U${options.context}` : "";
1039
+ const filters = options.filter ? (Array.isArray(options.filter) ? options.filter : [options.filter]) : [];
1040
+ const filterArg = filters.length > 0 ? `-- ${filters.map((f: string) => `"${f}"`).join(" ")}` : "";
1011
1041
  const gitCommand = (() => {
1012
1042
  if (options.staged)
1013
- return `git diff --cached --no-prefix ${contextArg}`.trim();
1043
+ return `git diff --cached --no-prefix ${contextArg} ${filterArg}`.trim();
1014
1044
  if (options.commit)
1015
- return `git show ${options.commit} --no-prefix ${contextArg}`.trim();
1016
- if (ref) return `git show ${ref} --no-prefix ${contextArg}`.trim();
1017
- return `git add -N . && git diff --no-prefix ${contextArg}`.trim();
1045
+ return `git show ${options.commit} --no-prefix ${contextArg} ${filterArg}`.trim();
1046
+ // Two refs: compare base...head (three-dot, shows changes since branches diverged, like GitHub PRs)
1047
+ if (base && head)
1048
+ return `git diff ${base}...${head} --no-prefix ${contextArg} ${filterArg}`.trim();
1049
+ // Single ref: show that commit's changes
1050
+ if (base) return `git show ${base} --no-prefix ${contextArg} ${filterArg}`.trim();
1051
+ return `git add -N . && git diff --no-prefix ${contextArg} ${filterArg}`.trim();
1018
1052
  })();
1019
1053
 
1020
- const cols = parseInt(options.cols) || 240;
1054
+ const desktopCols = parseInt(options.cols) || 240;
1055
+ const mobileCols = parseInt(options.mobileCols) || 100;
1056
+ const customTheme = options.theme && themeNames.includes(options.theme);
1057
+ const themeName = customTheme ? options.theme : defaultThemeName;
1021
1058
 
1022
1059
  console.log("Capturing diff output...");
1023
1060
 
@@ -1034,7 +1071,7 @@ cli
1034
1071
  // Calculate required rows from diff content
1035
1072
  const { parsePatch } = await import("diff");
1036
1073
  const files = parsePatch(gitDiff);
1037
- const renderRows = files.reduce((sum, file) => {
1074
+ const baseRenderRows = files.reduce((sum, file) => {
1038
1075
  const diffLines = file.hunks.reduce((h, hunk) => h + hunk.lines.length, 0);
1039
1076
  return sum + diffLines + 5; // header + margin per file
1040
1077
  }, 100); // base padding
@@ -1043,67 +1080,85 @@ cli
1043
1080
  const diffFile = join(tmpdir(), `critique-web-diff-${Date.now()}.patch`);
1044
1081
  fs.writeFileSync(diffFile, gitDiff);
1045
1082
 
1046
- // Spawn the TUI in a PTY to capture ANSI output
1047
- let ansiOutput = "";
1048
- const ptyProcess = pty.spawn(
1049
- "bun",
1050
- [
1051
- process.argv[1]!, // path to cli.tsx
1052
- "web-render",
1053
- diffFile,
1054
- "--cols",
1055
- String(cols),
1056
- "--rows",
1057
- String(renderRows),
1058
- ],
1059
- {
1060
- name: "xterm-256color",
1061
- cols: cols,
1062
- rows: renderRows,
1063
-
1064
- cwd: process.cwd(),
1065
- env: { ...process.env, TERM: "xterm-256color" } as Record<
1066
- string,
1067
- string
1068
- >,
1069
- },
1070
- );
1083
+ // Helper function to capture PTY output for a given column width
1084
+ async function captureHtml(cols: number, renderRows: number): Promise<string> {
1085
+ let ansiOutput = "";
1086
+ const ptyProcess = pty.spawn(
1087
+ "bun",
1088
+ [
1089
+ process.argv[1]!, // path to cli.tsx
1090
+ "web-render",
1091
+ diffFile,
1092
+ "--cols",
1093
+ String(cols),
1094
+ "--rows",
1095
+ String(renderRows),
1096
+ "--theme",
1097
+ themeName,
1098
+ ],
1099
+ {
1100
+ name: "xterm-256color",
1101
+ cols: cols,
1102
+ rows: renderRows,
1103
+ cwd: process.cwd(),
1104
+ env: { ...process.env, TERM: "xterm-256color" } as Record<
1105
+ string,
1106
+ string
1107
+ >,
1108
+ },
1109
+ );
1071
1110
 
1072
- ptyProcess.onData((data: string) => {
1073
- ansiOutput += data;
1074
- });
1111
+ ptyProcess.onData((data: string) => {
1112
+ ansiOutput += data;
1113
+ });
1075
1114
 
1076
- await new Promise<void>((resolve) => {
1077
- ptyProcess.onExit(() => {
1078
- resolve();
1115
+ await new Promise<void>((resolve) => {
1116
+ ptyProcess.onExit(() => {
1117
+ resolve();
1118
+ });
1079
1119
  });
1080
- });
1081
1120
 
1082
- // Clean up temp file
1083
- fs.unlinkSync(diffFile);
1121
+ if (!ansiOutput.trim()) {
1122
+ throw new Error("No output captured");
1123
+ }
1084
1124
 
1085
- if (!ansiOutput.trim()) {
1086
- console.log("No output captured");
1087
- process.exit(1);
1088
- }
1125
+ // Strip terminal cleanup sequences that clear the screen
1126
+ const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J");
1127
+ if (clearIdx > 0) {
1128
+ ansiOutput = ansiOutput.slice(0, clearIdx);
1129
+ }
1089
1130
 
1090
- console.log("Converting to HTML...");
1131
+ // Get theme colors for HTML output
1132
+ const theme = getResolvedTheme(themeName);
1133
+ const backgroundColor = rgbaToHex(theme.background);
1134
+ const textColor = rgbaToHex(theme.text);
1091
1135
 
1092
- // Strip terminal cleanup sequences that clear the screen
1093
- // The renderer outputs \x1b[H\x1b[J (cursor home + clear to end) on exit
1094
- const clearIdx = ansiOutput.lastIndexOf("\x1b[H\x1b[J");
1095
- if (clearIdx > 0) {
1096
- ansiOutput = ansiOutput.slice(0, clearIdx);
1136
+ return ansiToHtmlDocument(ansiOutput, { cols, rows: renderRows, backgroundColor, textColor, autoTheme: !customTheme });
1097
1137
  }
1098
1138
 
1099
- // Convert ANSI to HTML document
1100
- const html = ansiToHtmlDocument(ansiOutput, { cols, rows: renderRows });
1139
+ // Generate desktop and mobile versions in parallel
1140
+ // Mobile needs more rows since lines wrap more with fewer columns
1141
+ const mobileRenderRows = Math.ceil(baseRenderRows * (desktopCols / mobileCols));
1142
+ console.log("Generating desktop and mobile versions in parallel...");
1143
+ const [htmlDesktop, htmlMobile] = await Promise.all([
1144
+ captureHtml(desktopCols, baseRenderRows),
1145
+ captureHtml(mobileCols, mobileRenderRows),
1146
+ ]);
1147
+
1148
+ // Clean up temp file
1149
+ fs.unlinkSync(diffFile);
1150
+
1151
+ console.log("Converting to HTML...");
1101
1152
 
1102
1153
  if (options.local) {
1103
1154
  // Save locally
1104
- const htmlFile = join(tmpdir(), `critique-${Date.now()}.html`);
1105
- fs.writeFileSync(htmlFile, html);
1106
- console.log(`Saved to: ${htmlFile}`);
1155
+ const timestamp = Date.now();
1156
+ const htmlFileDesktop = join(tmpdir(), `critique-${timestamp}-desktop.html`);
1157
+ const htmlFileMobile = join(tmpdir(), `critique-${timestamp}-mobile.html`);
1158
+ fs.writeFileSync(htmlFileDesktop, htmlDesktop);
1159
+ fs.writeFileSync(htmlFileMobile, htmlMobile);
1160
+ console.log(`Saved desktop to: ${htmlFileDesktop}`);
1161
+ console.log(`Saved mobile to: ${htmlFileMobile}`);
1107
1162
 
1108
1163
  // Open in browser if requested
1109
1164
  if (options.open) {
@@ -1114,7 +1169,7 @@ cli
1114
1169
  ? "start"
1115
1170
  : "xdg-open";
1116
1171
  try {
1117
- await execAsync(`${openCmd} "${htmlFile}"`);
1172
+ await execAsync(`${openCmd} "${htmlFileDesktop}"`);
1118
1173
  } catch {
1119
1174
  console.log("Could not open browser automatically");
1120
1175
  }
@@ -1130,7 +1185,7 @@ cli
1130
1185
  headers: {
1131
1186
  "Content-Type": "application/json",
1132
1187
  },
1133
- body: JSON.stringify({ html }),
1188
+ body: JSON.stringify({ html: htmlDesktop, htmlMobile }),
1134
1189
  });
1135
1190
 
1136
1191
  if (!response.ok) {
@@ -1162,7 +1217,7 @@ cli
1162
1217
 
1163
1218
  // Fallback to local file
1164
1219
  const htmlFile = join(tmpdir(), `critique-${Date.now()}.html`);
1165
- fs.writeFileSync(htmlFile, html);
1220
+ fs.writeFileSync(htmlFile, htmlDesktop);
1166
1221
  console.log(`\nFallback: Saved locally to ${htmlFile}`);
1167
1222
  process.exit(1);
1168
1223
  }
@@ -1175,9 +1230,13 @@ cli
1175
1230
  })
1176
1231
  .option("--cols <cols>", "Terminal columns", { default: 120 })
1177
1232
  .option("--rows <rows>", "Terminal rows", { default: 1000 })
1233
+ .option("--theme <name>", "Theme to use for rendering")
1178
1234
  .action(async (diffFile: string, options) => {
1179
1235
  const cols = parseInt(options.cols) || 120;
1180
1236
  const rows = parseInt(options.rows) || 1000;
1237
+ const themeName = options.theme && themeNames.includes(options.theme)
1238
+ ? options.theme
1239
+ : defaultThemeName;
1181
1240
 
1182
1241
  const { parsePatch, formatPatch } = await import("diff");
1183
1242
 
@@ -1246,7 +1305,11 @@ cli
1246
1305
  const useSplitView = cols >= 150;
1247
1306
 
1248
1307
  // Static component - no hooks that cause re-renders
1249
- const webBg = getResolvedTheme(defaultThemeName).background;
1308
+ const webTheme = getResolvedTheme(themeName);
1309
+ const webBg = webTheme.background;
1310
+ const webText = rgbaToHex(webTheme.text);
1311
+ const webAddedColor = rgbaToHex(webTheme.diffAddedBg);
1312
+ const webRemovedColor = rgbaToHex(webTheme.diffRemovedBg);
1250
1313
  function WebApp() {
1251
1314
  return (
1252
1315
  <box
@@ -1289,15 +1352,15 @@ cli
1289
1352
  alignItems: "center",
1290
1353
  }}
1291
1354
  >
1292
- <text>{fileName.trim()}</text>
1293
- <text fg="#00ff00"> +{additions}</text>
1294
- <text fg="#ff0000">-{deletions}</text>
1355
+ <text fg={webText}>{fileName.trim()}</text>
1356
+ <text fg="#2d8a47"> +{additions}</text>
1357
+ <text fg="#c53b53">-{deletions}</text>
1295
1358
  </box>
1296
1359
  <DiffView
1297
1360
  diff={file.rawDiff || ""}
1298
1361
  view={viewMode}
1299
1362
  filetype={filetype}
1300
- themeName={defaultThemeName}
1363
+ themeName={themeName}
1301
1364
  />
1302
1365
  </box>
1303
1366
  );
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "bg": "#ffffff",
5
+ "bgPanel": "#ffffff",
6
+ "bgElement": "#f0f3f6",
7
+ "fg": "#24292f",
8
+ "fgMuted": "#57606a",
9
+ "blue": "#0969da",
10
+ "green": "#1a7f37",
11
+ "red": "#cf222e",
12
+ "orange": "#bc4c00",
13
+ "purple": "#8250df",
14
+ "pink": "#bf3989",
15
+ "yellow": "#9a6700",
16
+ "cyan": "#1b7c83"
17
+ },
18
+ "theme": {
19
+ "primary": "blue",
20
+ "secondary": "purple",
21
+ "accent": "cyan",
22
+ "error": "red",
23
+ "warning": "yellow",
24
+ "success": "green",
25
+ "info": "orange",
26
+ "text": "fg",
27
+ "textMuted": "fgMuted",
28
+ "background": "bg",
29
+ "backgroundPanel": "bgPanel",
30
+ "backgroundElement": "bgElement",
31
+ "border": "#d0d7de",
32
+ "borderActive": "blue",
33
+ "borderSubtle": "#d8dee4",
34
+ "diffAdded": "green",
35
+ "diffRemoved": "red",
36
+ "diffContext": "fgMuted",
37
+ "diffHunkHeader": "blue",
38
+ "diffHighlightAdded": "#1a7f37",
39
+ "diffHighlightRemoved": "#cf222e",
40
+ "diffAddedBg": "#e6ffec",
41
+ "diffRemovedBg": "#fff0ee",
42
+ "diffContextBg": "bg",
43
+ "diffLineNumber": "fgMuted",
44
+ "diffAddedLineNumberBg": "#dafbe1",
45
+ "diffRemovedLineNumberBg": "#ffebe9",
46
+ "syntaxComment": "fgMuted",
47
+ "syntaxKeyword": "red",
48
+ "syntaxFunction": "purple",
49
+ "syntaxVariable": "orange",
50
+ "syntaxString": "blue",
51
+ "syntaxNumber": "cyan",
52
+ "syntaxType": "orange",
53
+ "syntaxOperator": "red",
54
+ "syntaxPunctuation": "fg"
55
+ }
56
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "$schema": "https://opencode.ai/theme.json",
3
+ "defs": {
4
+ "step1": "#ffffff",
5
+ "step2": "#fafafa",
6
+ "step3": "#f5f5f5",
7
+ "step4": "#ebebeb",
8
+ "step5": "#e1e1e1",
9
+ "step6": "#d4d4d4",
10
+ "step7": "#b8b8b8",
11
+ "step8": "#a0a0a0",
12
+ "step9": "#3b7dd8",
13
+ "step10": "#2968c3",
14
+ "step11": "#8a8a8a",
15
+ "step12": "#1a1a1a",
16
+ "secondary": "#7b5bb6",
17
+ "accent": "#d68c27",
18
+ "red": "#d1383d",
19
+ "orange": "#d68c27",
20
+ "green": "#3d9a57",
21
+ "cyan": "#318795",
22
+ "yellow": "#b0851f"
23
+ },
24
+ "theme": {
25
+ "primary": "step9",
26
+ "secondary": "secondary",
27
+ "accent": "accent",
28
+ "error": "red",
29
+ "warning": "orange",
30
+ "success": "green",
31
+ "info": "cyan",
32
+ "text": "step12",
33
+ "textMuted": "step11",
34
+ "background": "step1",
35
+ "backgroundPanel": "step1",
36
+ "backgroundElement": "step3",
37
+ "border": "step6",
38
+ "borderActive": "step7",
39
+ "borderSubtle": "step5",
40
+ "diffAdded": "#1e725c",
41
+ "diffRemoved": "#c53b53",
42
+ "diffContext": "#7086b5",
43
+ "diffHunkHeader": "#7086b5",
44
+ "diffHighlightAdded": "#4db380",
45
+ "diffHighlightRemoved": "#f52a65",
46
+ "diffAddedBg": "#e8f4e8",
47
+ "diffRemovedBg": "#fceced",
48
+ "diffContextBg": "step1",
49
+ "diffLineNumber": "step11",
50
+ "diffAddedLineNumberBg": "#dceadc",
51
+ "diffRemovedLineNumberBg": "#f5e0e1",
52
+ "syntaxComment": "step11",
53
+ "syntaxKeyword": "secondary",
54
+ "syntaxFunction": "step9",
55
+ "syntaxVariable": "red",
56
+ "syntaxString": "green",
57
+ "syntaxNumber": "orange",
58
+ "syntaxType": "yellow",
59
+ "syntaxOperator": "cyan",
60
+ "syntaxPunctuation": "step12"
61
+ }
62
+ }
@@ -138,8 +138,8 @@
138
138
  "light": "lightStep2"
139
139
  },
140
140
  "diffLineNumber": {
141
- "dark": "darkStep3",
142
- "light": "lightStep3"
141
+ "dark": "darkStep8",
142
+ "light": "lightStep8"
143
143
  },
144
144
  "diffAddedLineNumberBg": {
145
145
  "dark": "#1b2b34",
package/src/themes.ts CHANGED
@@ -3,37 +3,9 @@
3
3
 
4
4
  import { parseColor, RGBA } from "@opentui/core";
5
5
 
6
- import aura from "./themes/aura.json";
7
- import ayu from "./themes/ayu.json";
8
- import catppuccin from "./themes/catppuccin.json";
9
- import catppuccinFrappe from "./themes/catppuccin-frappe.json";
10
- import catppuccinMacchiato from "./themes/catppuccin-macchiato.json";
11
- import cobalt2 from "./themes/cobalt2.json";
12
- import cursor from "./themes/cursor.json";
13
- import dracula from "./themes/dracula.json";
14
- import everforest from "./themes/everforest.json";
15
- import flexoki from "./themes/flexoki.json";
6
+ // Only import the default theme statically for fast startup
7
+ // Other themes are loaded on-demand when selected
16
8
  import github from "./themes/github.json";
17
- import gruvbox from "./themes/gruvbox.json";
18
- import kanagawa from "./themes/kanagawa.json";
19
- import lucentOrng from "./themes/lucent-orng.json";
20
- import material from "./themes/material.json";
21
- import matrix from "./themes/matrix.json";
22
- import mercury from "./themes/mercury.json";
23
- import monokai from "./themes/monokai.json";
24
- import nightowl from "./themes/nightowl.json";
25
- import nord from "./themes/nord.json";
26
- import oneDark from "./themes/one-dark.json";
27
- import opencode from "./themes/opencode.json";
28
- import orng from "./themes/orng.json";
29
- import palenight from "./themes/palenight.json";
30
- import rosepine from "./themes/rosepine.json";
31
- import solarized from "./themes/solarized.json";
32
- import synthwave84 from "./themes/synthwave84.json";
33
- import tokyonight from "./themes/tokyonight.json";
34
- import vercel from "./themes/vercel.json";
35
- import vesper from "./themes/vesper.json";
36
- import zenburn from "./themes/zenburn.json";
37
9
 
38
10
  type HexColor = `#${string}`;
39
11
  type RefName = string;
@@ -104,40 +76,74 @@ export interface SyntaxTheme {
104
76
  default: SyntaxThemeStyle;
105
77
  }
106
78
 
107
- const DEFAULT_THEMES: Record<string, ThemeJson> = {
108
- aura,
109
- ayu,
110
- catppuccin,
111
- "catppuccin-frappe": catppuccinFrappe,
112
- "catppuccin-macchiato": catppuccinMacchiato,
113
- cobalt2,
114
- cursor,
115
- dracula,
116
- everforest,
117
- flexoki,
118
- github,
119
- gruvbox,
120
- kanagawa,
121
- "lucent-orng": lucentOrng,
122
- material,
123
- matrix,
124
- mercury,
125
- monokai,
126
- nightowl,
127
- nord,
128
- "one-dark": oneDark,
129
- opencode,
130
- orng,
131
- palenight,
132
- rosepine,
133
- solarized,
134
- synthwave84,
135
- tokyonight,
136
- vercel,
137
- vesper,
138
- zenburn,
79
+ // Theme name to file mapping for lazy loading
80
+ const THEME_FILES: Record<string, string> = {
81
+ aura: "aura.json",
82
+ ayu: "ayu.json",
83
+ catppuccin: "catppuccin.json",
84
+ "catppuccin-frappe": "catppuccin-frappe.json",
85
+ "catppuccin-macchiato": "catppuccin-macchiato.json",
86
+ cobalt2: "cobalt2.json",
87
+ cursor: "cursor.json",
88
+ dracula: "dracula.json",
89
+ everforest: "everforest.json",
90
+ flexoki: "flexoki.json",
91
+ github: "github.json",
92
+ "github-light": "github-light.json",
93
+ gruvbox: "gruvbox.json",
94
+ kanagawa: "kanagawa.json",
95
+ "lucent-orng": "lucent-orng.json",
96
+ material: "material.json",
97
+ matrix: "matrix.json",
98
+ mercury: "mercury.json",
99
+ monokai: "monokai.json",
100
+ nightowl: "nightowl.json",
101
+ nord: "nord.json",
102
+ "one-dark": "one-dark.json",
103
+ opencode: "opencode.json",
104
+ "opencode-light": "opencode-light.json",
105
+ orng: "orng.json",
106
+ palenight: "palenight.json",
107
+ rosepine: "rosepine.json",
108
+ solarized: "solarized.json",
109
+ synthwave84: "synthwave84.json",
110
+ tokyonight: "tokyonight.json",
111
+ vercel: "vercel.json",
112
+ vesper: "vesper.json",
113
+ zenburn: "zenburn.json",
139
114
  };
140
115
 
116
+ // Cache for loaded themes
117
+ const themeCache: Record<string, ThemeJson> = {
118
+ github, // Pre-loaded default theme
119
+ };
120
+
121
+ // Synchronously load a theme (themes are small JSON files)
122
+ function loadTheme(name: string): ThemeJson {
123
+ if (themeCache[name]) {
124
+ return themeCache[name];
125
+ }
126
+
127
+ const fileName = THEME_FILES[name];
128
+ if (!fileName) {
129
+ return github; // Fallback to default
130
+ }
131
+
132
+ try {
133
+ // Use dynamic import with synchronous pattern for JSON
134
+ // This works because JSON imports are resolved at bundle time by Bun
135
+ const themePath = new URL(`./themes/${fileName}`, import.meta.url).pathname;
136
+ // Read file synchronously using Node fs (works in Bun)
137
+ const fs = require("fs");
138
+ const content = fs.readFileSync(themePath, "utf-8");
139
+ const themeJson = JSON.parse(content) as ThemeJson;
140
+ themeCache[name] = themeJson;
141
+ return themeJson;
142
+ } catch {
143
+ return github; // Fallback to default
144
+ }
145
+ }
146
+
141
147
  function resolveTheme(
142
148
  themeJson: ThemeJson,
143
149
  mode: "dark" | "light",
@@ -198,7 +204,7 @@ export function getResolvedTheme(
198
204
  name: string,
199
205
  mode: "dark" | "light" = "dark",
200
206
  ): ResolvedTheme {
201
- const themeJson = DEFAULT_THEMES[name] ?? DEFAULT_THEMES.github!;
207
+ const themeJson = loadTheme(name);
202
208
  return resolveTheme(themeJson, mode);
203
209
  }
204
210
 
@@ -229,7 +235,7 @@ export function getSyntaxTheme(
229
235
  };
230
236
  }
231
237
 
232
- export const themeNames = Object.keys(DEFAULT_THEMES).sort();
238
+ export const themeNames = Object.keys(THEME_FILES).sort();
233
239
 
234
240
  export const defaultThemeName = "github";
235
241
 
package/src/worker.ts CHANGED
@@ -17,18 +17,47 @@ app.get("/", (c) => {
17
17
  return c.redirect("https://github.com/remorses/critique")
18
18
  })
19
19
 
20
+ // Detect if request is from a mobile device
21
+ function isMobileDevice(c: { req: { header: (name: string) => string | undefined } }): boolean {
22
+ // Check CF-Device-Type header (Cloudflare provides this on Enterprise/APO)
23
+ const cfDeviceType = c.req.header("CF-Device-Type")
24
+ if (cfDeviceType === "mobile" || cfDeviceType === "tablet") {
25
+ return true
26
+ }
27
+ if (cfDeviceType === "desktop") {
28
+ return false
29
+ }
30
+
31
+ // Check Sec-CH-UA-Mobile header (Chromium browsers only)
32
+ const secChUaMobile = c.req.header("Sec-CH-UA-Mobile")
33
+ if (secChUaMobile === "?1") {
34
+ return true
35
+ }
36
+ if (secChUaMobile === "?0") {
37
+ return false
38
+ }
39
+
40
+ // Fallback to User-Agent parsing with comprehensive regex
41
+ const userAgent = c.req.header("User-Agent") || ""
42
+
43
+ // Comprehensive mobile detection regex (case-insensitive)
44
+ const mobileRegex = /Mobile|iP(hone|od|ad)|Android|BlackBerry|IEMobile|Kindle|Silk|NetFront|Opera M(obi|ini)|Windows Phone|webOS|Fennec|Minimo|UCBrowser|UCWEB|SonyEricsson|Symbian|Nintendo|PSP|PlayStation|MIDP|CLDC|AvantGo|Maemo|PalmOS|PalmSource|DoCoMo|UP\.Browser|Blazer|Xiino|OneBrowser/i
45
+
46
+ return mobileRegex.test(userAgent)
47
+ }
48
+
20
49
  // Upload HTML content
21
- // POST /upload with JSON body { html: string }
50
+ // POST /upload with JSON body { html: string, htmlMobile?: string }
22
51
  // Returns { id: string, url: string }
23
52
  app.post("/upload", async (c) => {
24
53
  try {
25
- const body = await c.req.json<{ html: string }>()
54
+ const body = await c.req.json<{ html: string; htmlMobile?: string }>()
26
55
 
27
56
  if (!body.html || typeof body.html !== "string") {
28
57
  return c.json({ error: "Missing or invalid 'html' field" }, 400)
29
58
  }
30
59
 
31
- // Generate hash of the HTML content as the key (enables caching/deduplication)
60
+ // Generate hash of the desktop HTML content as the key (enables caching/deduplication)
32
61
  const encoder = new TextEncoder()
33
62
  const data = encoder.encode(body.html)
34
63
  const hashBuffer = await crypto.subtle.digest("SHA-256", data)
@@ -38,11 +67,18 @@ app.post("/upload", async (c) => {
38
67
  // Use first 32 chars of hash as ID (128 bits, secure against guessing)
39
68
  const id = hashHex.slice(0, 32)
40
69
 
41
- // Store in KV with 7 day expiration
70
+ // Store desktop version in KV with 7 day expiration
42
71
  await c.env.CRITIQUE_KV.put(id, body.html, {
43
72
  expirationTtl: 60 * 60 * 24 * 7, // 7 days
44
73
  })
45
74
 
75
+ // Store mobile version if provided
76
+ if (body.htmlMobile && typeof body.htmlMobile === "string") {
77
+ await c.env.CRITIQUE_KV.put(`${id}-mobile`, body.htmlMobile, {
78
+ expirationTtl: 60 * 60 * 24 * 7, // 7 days
79
+ })
80
+ }
81
+
46
82
  const url = new URL(c.req.url)
47
83
  const viewUrl = `${url.origin}/view/${id}`
48
84
 
@@ -54,6 +90,7 @@ app.post("/upload", async (c) => {
54
90
 
55
91
  // View HTML content with streaming
56
92
  // GET /view/:id
93
+ // Query params: ?v=desktop or ?v=mobile to force a version
57
94
  app.get("/view/:id", async (c) => {
58
95
  const id = c.req.param("id")
59
96
 
@@ -61,7 +98,21 @@ app.get("/view/:id", async (c) => {
61
98
  return c.text("Invalid ID", 400)
62
99
  }
63
100
 
64
- const html = await c.env.CRITIQUE_KV.get(id)
101
+ // Check for forced version via query param
102
+ const forcedVersion = c.req.query("v")
103
+ const isMobile = forcedVersion === "mobile" || (forcedVersion !== "desktop" && isMobileDevice(c))
104
+
105
+ // Try to get the appropriate version
106
+ let html: string | null = null
107
+ if (isMobile) {
108
+ // Try mobile version first, fall back to desktop
109
+ html = await c.env.CRITIQUE_KV.get(`${id}-mobile`)
110
+ if (!html) {
111
+ html = await c.env.CRITIQUE_KV.get(id)
112
+ }
113
+ } else {
114
+ html = await c.env.CRITIQUE_KV.get(id)
115
+ }
65
116
 
66
117
  if (!html) {
67
118
  return c.text("Not found", 404)
@@ -71,6 +122,8 @@ app.get("/view/:id", async (c) => {
71
122
  return stream(c, async (s) => {
72
123
  // Set content type header
73
124
  c.header("Content-Type", "text/html; charset=utf-8")
125
+ // Vary by User-Agent and Sec-CH-UA-Mobile for proper caching
126
+ c.header("Vary", "User-Agent, Sec-CH-UA-Mobile")
74
127
  c.header("Cache-Control", "public, max-age=3600")
75
128
 
76
129
  // Stream in chunks for better performance