1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="UTF-8" />
5 <meta
6 name="viewport"
7 content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content"
8 />
9 <meta name="theme-color" content="#000000" />
10 <link rel="icon" href="/favicon.ico" sizes="48x48" />
11 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
12 <link rel="manifest" href="/manifest.webmanifest" />
13 <script>
14 (() => {
15 const LIGHT_BACKGROUND = "#ffffff";
16 const DARK_BACKGROUND = "#000000";
17 const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1";
18 const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";
19 const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";
20 const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1";
21 const SPLASH_COLORS = {
22 light: {
23 background: "#ffffff",
24 foreground: "#262626",
25 accent: "#4f46e5",
26 },
27 dark: {
28 background: "#000000",
29 foreground: "#f5f5f5",
30 accent: "#818cf8",
31 },
32 };
33 // These are the runtime defaults used to complete a custom palette
34 // when a stored theme omits a role. Keep them separate from the
35 // unselected splash so a partial custom theme does not flash generic
36 // app colors before React mounts.
37 const DEFAULT_THEME_PALETTES = {
38 light: {
39 background: "oklch(0.982446 0.010114 325.653)",
40 foreground: "oklch(0.325698 0.116116 325.037)",
41 accent: "oklch(0.591646 0.217985 0.584)",
42 chrome: "oklch(0.982446 0.010114 325.653)",
43 },
44 dark: {
45 background: "oklch(0.22813 0.020366 307.469)",
46 foreground: "oklch(0.980735 0.004092 301.426)",
47 accent: "oklch(0.460685 0.185347 4.099)",
48 chrome: "oklch(0.22813 0.020366 307.469)",
49 },
50 };
51 // Keep this small boot-time copy in sync with the built-in palettes so
52 // the selected theme is visible before the app has mounted.
53 const BUILT_IN_THEME_PALETTES = {
54 "t3-chat": {
55 light: {
56 background: "oklch(0.982446 0.010114 325.653)",
57 foreground: "oklch(0.325698 0.116116 325.037)",
58 accent: "oklch(0.591646 0.217985 0.584)",
59 chrome: "oklch(0.982446 0.010114 325.653)",
60 },
61 dark: {
62 background: "oklch(0.22813 0.020366 307.469)",
63 foreground: "oklch(0.980735 0.004092 301.426)",
64 accent: "oklch(0.460685 0.185347 4.099)",
65 chrome: "oklch(0.22813 0.020366 307.469)",
66 },
67 },
68 grove: {
69 light: {
70 background: "oklch(0.972369 0.005497 157.15)",
71 foreground: "oklch(0.222003 0.03479 328.979)",
72 accent: "oklch(0.523295 0.112292 158.089)",
73 chrome: "oklch(0.972369 0.005497 157.15)",
74 },
75 dark: {
76 background: "oklch(0.260865 0.02152 162.75)",
77 foreground: "oklch(0.990339 0.008411 325.64)",
78 accent: "oklch(0.796228 0.133058 157.319)",
79 chrome: "oklch(0.260865 0.02152 162.75)",
80 },
81 },
82 ocean: {
83 light: {
84 background: "oklch(0.974199 0.002856 241.597)",
85 foreground: "oklch(0.222003 0.03479 328.979)",
86 accent: "oklch(0.536684 0.120219 247.01)",
87 chrome: "oklch(0.974199 0.002856 241.597)",
88 },
89 dark: {
90 background: "oklch(0.242641 0.024125 250.573)",
91 foreground: "oklch(0.990339 0.008411 325.64)",
92 accent: "oklch(0.758933 0.105833 241.548)",
93 chrome: "oklch(0.242641 0.024125 250.573)",
94 },
95 },
96 ember: {
97 light: {
98 background: "oklch(0.976527 0.002685 60.725)",
99 foreground: "oklch(0.222003 0.03479 328.979)",
100 accent: "oklch(0.552831 0.129438 44.656)",
101 chrome: "oklch(0.976527 0.002685 60.725)",
102 },
103 dark: {
104 background: "oklch(0.245899 0.019144 42.044)",
105 foreground: "oklch(0.990339 0.008411 325.64)",
106 accent: "oklch(0.762174 0.124117 52.082)",
107 chrome: "oklch(0.245899 0.019144 42.044)",
108 },
109 },
110 iris: {
111 light: {
112 background: "oklch(0.976531 0.003855 303.226)",
113 foreground: "oklch(0.222003 0.03479 328.979)",
114 accent: "oklch(0.525348 0.15373 294.176)",
115 chrome: "oklch(0.976531 0.003855 303.226)",
116 },
117 dark: {
118 background: "oklch(0.225975 0.031062 293.741)",
119 foreground: "oklch(0.990339 0.008411 325.64)",
120 accent: "oklch(0.671712 0.169136 293.929)",
121 chrome: "oklch(0.225975 0.031062 293.741)",
122 },
123 },
124 };
125 const BUILT_IN_THEME_MODES = Object.fromEntries(
126 Object.entries(BUILT_IN_THEME_PALETTES).map(([id, palettes]) => [
127 id,
128 Object.keys(palettes),
129 ]),
130 );
131 const RESERVED_THEME_IDS = [
132 "system",
133 "light",
134 "dark",
135 "t3-chat",
136 "grove",
137 "ocean",
138 "ember",
139 "iris",
140 "t3-chat-dark",
141 "t3-grove",
142 "t3-ocean",
143 "t3-ember",
144 "t3-iris",
145 ];
146 const setThemeColor = (color) => {
147 for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
148 meta.setAttribute("content", color);
149 }
150 };
151
152 // The runtime decodes the same literal CSS formats into OKLCH. Keep
153 // contextual colors out: they cannot be resolved from stored data.
154 const SYSTEM_COLOR_KEYWORD =
155 /^(?:accentcolor(?:text)?|active(?:border|caption|text)|appworkspace|background|button(?:border|face|highlight|shadow|text)|canvas(?:text)?|captiontext|field(?:text)?|graytext|highlight(?:text)?|inactive(?:border|caption(?:text)?)|info(?:background|text)|linktext|mark(?:text)?|menu(?:text)?|scrollbar|selecteditem(?:text)?|threed(?:darkshadow|face|highlight|lightshadow|shadow)|visitedtext|window(?:frame|text)?)$/i;
156 const isThemeColor = (value) => {
157 if (typeof value !== "string" || typeof CSS === "undefined") return false;
158 const color = value.trim();
159 if (
160 /^(?:currentcolor|inherit|initial|revert|revert-layer|unset)$/i.test(color) ||
161 SYSTEM_COLOR_KEYWORD.test(color) ||
162 /\bfrom\b|(?:var|env|attr|calc|min|max|clamp)\(/i.test(color)
163 ) {
164 return false;
165 }
166 const isLiteralColor =
167 /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) ||
168 /^[a-z]+$/i.test(color) ||
169 /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(color);
170 return isLiteralColor && CSS.supports("color", color);
171 };
172 const isRecord = (value) =>
173 typeof value === "object" && value !== null && !Array.isArray(value);
174 const isThemeId = (value) =>
175 typeof value === "string" && /^[a-z0-9](?:[a-z0-9-]{0,47})$/.test(value);
176 const isThemeLabel = (value) =>
177 typeof value === "string" && value.trim().length > 0 && value.trim().length <= 48;
178 const isThemePreferenceMode = (value) =>
179 value === "light" || value === "dark" || value === "system";
180 const isThemeAppearance = (value) => value === "light" || value === "dark";
181 const isStoredCustomTheme = (value) => {
182 if (
183 !isRecord(value) ||
184 !isThemeId(value.id) ||
185 RESERVED_THEME_IDS.includes(value.id) ||
186 !isThemeLabel(value.label) ||
187 !isThemeAppearance(value.appearance) ||
188 !isRecord(value.colors)
189 ) {
190 return false;
191 }
192 if (value.variants === undefined) return true;
193 if (!isRecord(value.variants)) return false;
194 return Object.entries(value.variants).every(
195 ([appearance, colors]) =>
196 isThemeAppearance(appearance) &&
197 (appearance === value.appearance || isRecord(colors)),
198 );
199 };
200 const findCustomTheme = (themeId) => {
201 if (RESERVED_THEME_IDS.includes(themeId)) return null;
202 try {
203 const parsed = JSON.parse(
204 window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null",
205 );
206 if (!Array.isArray(parsed)) return null;
207 return (
208 parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null
209 );
210 } catch {
211 return null;
212 }
213 };
214
215 try {
216 const storedTheme = window.localStorage.getItem("t3code:theme");
217 const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
218 const LEGACY_THEME_IDS = {
219 "t3-chat-dark": "t3-chat",
220 "t3-grove": "grove",
221 "t3-ocean": "ocean",
222 "t3-ember": "ember",
223 "t3-iris": "iris",
224 };
225 const isLegacyT3ChatDark = storedTheme === "t3-chat-dark";
226 const themeId =
227 storedTheme === null ? "" : (LEGACY_THEME_IDS[storedTheme] ?? storedTheme);
228 const legacyMode = isLegacyT3ChatDark ? "dark" : null;
229 const builtInModes = BUILT_IN_THEME_MODES[themeId] ?? null;
230 const customTheme = builtInModes ? null : findCustomTheme(themeId);
231 const themeModes =
232 builtInModes ??
233 (customTheme
234 ? ["light", "dark"].filter(
235 (mode) => mode === customTheme.appearance || customTheme.variants?.[mode],
236 )
237 : null);
238 const isKnownTheme =
239 storedTheme === "light" ||
240 storedTheme === "dark" ||
241 storedTheme === "system" ||
242 themeModes !== null;
243 const theme = isKnownTheme && storedTheme ? storedTheme : "system";
244 const isThemed = isKnownTheme && themeModes !== null;
245 const baseAppearance = customTheme ? customTheme.appearance : "light";
246 const systemMode = prefersDark ? "dark" : "light";
247 const storedAppearanceMode = window.localStorage.getItem(
248 THEME_APPEARANCE_MODE_STORAGE_KEY,
249 );
250 const storedFollowSystem = window.localStorage.getItem(THEME_FOLLOW_SYSTEM_STORAGE_KEY);
251 const preferenceMode =
252 theme === "light" || theme === "dark" ? theme : (legacyMode ?? baseAppearance);
253 const appearanceMode = isThemePreferenceMode(storedAppearanceMode)
254 ? storedAppearanceMode
255 : storedFollowSystem === "true"
256 ? "system"
257 : storedFollowSystem === "false"
258 ? preferenceMode
259 : theme === "system"
260 ? "system"
261 : preferenceMode;
262 // A valid mix half for the wanted appearance takes over: it decides
263 // light vs dark even when the base theme cannot render that mode.
264 // Mirrors the runtime's halves-aware resolveThemeAppearance.
265 const wantedMode = appearanceMode === "system" ? systemMode : appearanceMode;
266 let halfThemeId = null;
267 let halfCustomTheme = null;
268 try {
269 const halvesRaw = JSON.parse(
270 window.localStorage.getItem(THEME_HALVES_STORAGE_KEY) ?? "null",
271 );
272 const candidate = isRecord(halvesRaw) ? halvesRaw[wantedMode] : null;
273 if (typeof candidate === "string") {
274 const halfId = LEGACY_THEME_IDS[candidate] ?? candidate;
275 const halfBuiltIn = BUILT_IN_THEME_MODES[halfId] ?? null;
276 const halfCustom = halfBuiltIn ? null : findCustomTheme(halfId);
277 const halfModes =
278 halfBuiltIn ??
279 (halfCustom
280 ? ["light", "dark"].filter(
281 (mode) => mode === halfCustom.appearance || halfCustom.variants?.[mode],
282 )
283 : null);
284 if (halfModes && halfModes.includes(wantedMode)) {
285 halfThemeId = halfId;
286 halfCustomTheme = halfCustom;
287 }
288 }
289 } catch {}
290 const themeMode = !isThemed
291 ? null
292 : appearanceMode === "system"
293 ? themeModes.includes(systemMode)
294 ? systemMode
295 : baseAppearance
296 : themeModes.includes(appearanceMode)
297 ? appearanceMode
298 : baseAppearance;
299 const isDark =
300 halfThemeId !== null
301 ? wantedMode === "dark"
302 : isThemed
303 ? themeMode === "dark"
304 : appearanceMode === "system"
305 ? prefersDark
306 : appearanceMode === "dark";
307 const effectiveIsThemed = halfThemeId !== null || isThemed;
308 const effectiveThemeId = halfThemeId ?? themeId;
309 const effectiveCustomTheme = halfThemeId !== null ? halfCustomTheme : customTheme;
310 const effectiveThemeMode = halfThemeId !== null ? wantedMode : themeMode;
311 const customColors =
312 effectiveIsThemed && effectiveCustomTheme
313 ? effectiveThemeMode === effectiveCustomTheme.appearance
314 ? effectiveCustomTheme.colors
315 : (effectiveCustomTheme.variants?.[effectiveThemeMode] ??
316 effectiveCustomTheme.colors)
317 : null;
318 const customDefaults =
319 effectiveCustomTheme && effectiveThemeMode
320 ? DEFAULT_THEME_PALETTES[effectiveThemeMode]
321 : null;
322 // The runtime tolerates individual malformed colors, so fall back
323 // per role rather than dropping the theme.
324 const builtInSplash =
325 effectiveIsThemed && !effectiveCustomTheme && effectiveThemeMode
326 ? (BUILT_IN_THEME_PALETTES[effectiveThemeId]?.[effectiveThemeMode] ?? null)
327 : null;
328 const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light;
329 const customSplash = customColors
330 ? {
331 background: isThemeColor(customColors.canvas)
332 ? customColors.canvas
333 : (customDefaults?.background ?? fallbackSplash.background),
334 foreground: isThemeColor(customColors.text)
335 ? customColors.text
336 : (customDefaults?.foreground ?? fallbackSplash.foreground),
337 accent: isThemeColor(customColors.accent)
338 ? customColors.accent
339 : (customDefaults?.accent ?? fallbackSplash.accent),
340 }
341 : null;
342 if (effectiveIsThemed) {
343 document.documentElement.dataset.themeId = effectiveCustomTheme
344 ? effectiveCustomTheme.id
345 : effectiveThemeId;
346 } else {
347 delete document.documentElement.dataset.themeId;
348 }
349 // A resolved half is a selection even when the base theme is gone
350 // (deleting the base leaves the mix pointing at a real theme).
351 const hasSelection = halfThemeId !== null || (isKnownTheme && storedTheme !== null);
352 if (hasSelection) {
353 document.documentElement.dataset.themeSelected = "true";
354 } else {
355 delete document.documentElement.dataset.themeSelected;
356 }
357 document.documentElement.classList.toggle("dark", isDark);
358 const chromeColor = customColors
359 ? isThemeColor(customColors.chrome)
360 ? customColors.chrome
361 : (customDefaults?.chrome ?? fallbackSplash.background)
362 : builtInSplash
363 ? builtInSplash.chrome
364 : isDark
365 ? DARK_BACKGROUND
366 : LIGHT_BACKGROUND;
367 document.documentElement.style.backgroundColor = chromeColor;
368 if (hasSelection) {
369 const splashColors = customSplash ?? builtInSplash ?? fallbackSplash;
370 for (const [name, value] of Object.entries(splashColors)) {
371 document.documentElement.style.setProperty(`--boot-${name}`, value);
372 }
373 }
374 setThemeColor(chromeColor);
375 } catch {
376 let prefersDark = true;
377 try {
378 prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
379 } catch {}
380 delete document.documentElement.dataset.themeId;
381 delete document.documentElement.dataset.themeSelected;
382 document.documentElement.classList.toggle("dark", prefersDark);
383 const fallbackColor = prefersDark ? DARK_BACKGROUND : LIGHT_BACKGROUND;
384 document.documentElement.style.backgroundColor = fallbackColor;
385 setThemeColor(fallbackColor);
386 }
387 })();
388 </script>
389 <style>
390 html,
391 body,
392 #root {
393 width: 100%;
394 height: 100%;
395 margin: 0;
396 }
397
398 body {
399 background: #ffffff;
400 color: #262626;
401 font-family:
402 -apple-system, BlinkMacSystemFont, "Inter Variable", "Segoe UI", system-ui, sans-serif;
403 }
404
405 html.dark body {
406 background: #000000;
407 color: #f5f5f5;
408 }
409
410 #boot-shell {
411 position: relative;
412 overflow: hidden;
413 display: flex;
414 min-height: 100%;
415 align-items: center;
416 justify-content: center;
417 background: inherit;
418 color: inherit;
419 }
420
421 html[data-theme-selected="true"] #boot-shell {
422 background: var(--boot-background);
423 color: var(--boot-foreground);
424 }
425
426 #boot-shell-card {
427 position: relative;
428 z-index: 1;
429 display: flex;
430 align-items: center;
431 justify-content: center;
432 width: 96px;
433 height: 96px;
434 }
435
436 #boot-shell-logo {
437 width: 64px;
438 height: 64px;
439 object-fit: contain;
440 }
441
442 /* App CSS locks body and #root scrolling once main loads, so the error screen scrolls itself. */
443 #boot-shell:has(> #boot-error) {
444 height: 100%;
445 overflow-x: hidden;
446 overflow-y: auto;
447 }
448
449 #boot-error {
450 --boot-error-fg: #262626;
451 --boot-error-bg: #ffffff;
452 --boot-error-line: color-mix(in srgb, currentColor 12%, transparent);
453 display: flex;
454 flex-direction: column;
455 align-items: flex-start;
456 align-self: flex-start;
457 width: min(400px, calc(100% - 48px));
458 padding: clamp(24px, 22vh, 220px) 0 24px;
459 font-size: 13px;
460 line-height: 20px;
461 }
462
463 html.dark #boot-error {
464 --boot-error-fg: #f5f5f5;
465 --boot-error-bg: #000000;
466 }
467
468 html[data-theme-selected="true"] #boot-error {
469 --boot-error-fg: var(--boot-foreground);
470 --boot-error-bg: var(--boot-background);
471 }
472
473 #boot-error h1 {
474 margin: 0;
475 font-size: 15px;
476 font-weight: 600;
477 line-height: 22px;
478 }
479
480 #boot-error p {
481 margin: 4px 0 0;
482 opacity: 0.7;
483 overflow-wrap: anywhere;
484 text-wrap: pretty;
485 }
486
487 #boot-error code,
488 #boot-error pre {
489 font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
490 font-size: 12px;
491 overflow-wrap: anywhere;
492 }
493
494 #boot-error a {
495 color: inherit;
496 text-decoration-line: underline;
497 text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
498 text-underline-offset: 3px;
499 }
500
501 #boot-error a:hover {
502 text-decoration-color: currentColor;
503 }
504
505 #boot-error button {
506 height: 32px;
507 padding: 0 14px;
508 border: 1px solid var(--boot-error-line);
509 border-radius: 6px;
510 background: transparent;
511 color: inherit;
512 font: inherit;
513 font-weight: 500;
514 cursor: pointer;
515 }
516
517 #boot-error button[aria-disabled="true"] {
518 opacity: 0.6;
519 cursor: default;
520 }
521
522 #boot-error :is(button, a, summary, pre):focus-visible {
523 outline: 2px solid var(--boot-error-fg);
524 outline-offset: 2px;
525 }
526
527 #boot-error-actions {
528 display: flex;
529 flex-wrap: wrap;
530 gap: 8px;
531 margin-top: 16px;
532 }
533
534 #boot-error #boot-error-reload {
535 border-color: var(--boot-error-fg);
536 background: var(--boot-error-fg);
537 color: var(--boot-error-bg);
538 }
539
540 #boot-error-reload:hover {
541 opacity: 0.9;
542 }
543
544 #boot-error #boot-error-update-note {
545 margin-top: 8px;
546 font-size: 12px;
547 line-height: 18px;
548 }
549
550 #boot-error details {
551 width: 100%;
552 margin-top: 32px;
553 }
554
555 #boot-error summary {
556 display: inline-flex;
557 align-items: center;
558 gap: 8px;
559 border-radius: 4px;
560 list-style: none;
561 opacity: 0.7;
562 cursor: pointer;
563 }
564
565 #boot-error summary::-webkit-details-marker {
566 display: none;
567 }
568
569 #boot-error summary::before {
570 content: "";
571 width: 5px;
572 height: 5px;
573 border-right: 1.5px solid currentColor;
574 border-bottom: 1.5px solid currentColor;
575 transform: rotate(-45deg);
576 }
577
578 #boot-error details[open] summary::before {
579 transform: rotate(45deg);
580 }
581
582 #boot-error summary:hover,
583 #boot-error details[open] summary {
584 opacity: 1;
585 }
586
587 #boot-error details > :not(summary) {
588 display: grid;
589 gap: 8px;
590 margin-top: 20px;
591 }
592
593 #boot-error details > a {
594 display: block;
595 width: fit-content;
596 }
597
598 #boot-error details p {
599 margin: 0;
600 font-size: 12px;
601 line-height: 18px;
602 }
603
604 #boot-error-command {
605 display: flex;
606 align-items: center;
607 gap: 8px;
608 padding: 4px 4px 4px 10px;
609 border: 1px solid var(--boot-error-line);
610 border-radius: 6px;
611 }
612
613 #boot-error-command code {
614 flex: 1;
615 min-width: 0;
616 opacity: 0.8;
617 }
618
619 #boot-error .boot-error-icon {
620 display: grid;
621 flex: none;
622 place-items: center;
623 min-width: 24px;
624 height: 24px;
625 padding: 0 4px;
626 border: 0;
627 border-radius: 4px;
628 font-size: 12px;
629 font-weight: 400;
630 opacity: 0.7;
631 }
632
633 #boot-error .boot-error-icon:hover {
634 opacity: 1;
635 background: color-mix(in srgb, currentColor 8%, transparent);
636 }
637
638 #boot-error-details {
639 max-height: 200px;
640 margin: 0;
641 padding: 10px 12px;
642 overflow: auto;
643 border: 1px solid var(--boot-error-line);
644 border-radius: 6px;
645 font-size: 11px;
646 line-height: 16px;
647 white-space: pre-wrap;
648 opacity: 0.8;
649 }
650
651 #boot-error-report {
652 display: flex;
653 align-items: center;
654 gap: 16px;
655 }
656
657 #boot-error-report button {
658 height: 28px;
659 padding: 0 10px;
660 }
661
662 .boot-error-sr {
663 position: absolute;
664 width: 1px;
665 height: 1px;
666 overflow: hidden;
667 clip-path: inset(50%);
668 white-space: nowrap;
669 }
670 </style>
671 <script>
672 // Desktop's preload injects branding before page scripts. Titling before the <title> below
673 // parses keeps Electron from reporting the web build's channel to the window first.
674 const desktopDisplayName = window.desktopBridge?.getAppBranding?.()?.displayName;
675 if (desktopDisplayName) document.title = desktopDisplayName;
676 </script>
677 <!-- vite.config.ts stamps the web build's channel into this title. -->
678 <title>T3 Code</title>
679 </head>
680 <body>
681 <div id="root">
682 <div id="boot-shell">
683 <div id="boot-shell-card" aria-label="T3 Code splash screen">
684 <img id="boot-shell-logo" src="/apple-touch-icon.png" alt="T3 Code" />
685 </div>
686 </div>
687 </div>
688 <script type="module" src="/src/bootstrap.ts"></script>
689 </body>
690</html>