Merge branch 'themefixes'

This commit is contained in:
Thomas G. Lopes 2025-06-18 19:48:47 +01:00
commit a97761b69a
13 changed files with 324 additions and 35 deletions

View file

@ -13,6 +13,7 @@ Clone of [T3 Chat](https://t3.chat/)
- Privacy mode for streams and screen-sharing - Privacy mode for streams and screen-sharing
- Markdown rendered messages with syntax highlighting - Markdown rendered messages with syntax highlighting
- Chat sharing - Chat sharing
- Keyboard shortcuts
## 🛠️ Tech Stack ## 🛠️ Tech Stack
@ -51,7 +52,7 @@ Clone of [T3 Chat](https://t3.chat/)
- [x] Syntax highlighting with Shiki/markdown renderer - [x] Syntax highlighting with Shiki/markdown renderer
- [ ] Eliminate FOUC - [ ] Eliminate FOUC
- [x] Cascade deletes - [x] Cascade deletes
- [ ] Google Auth - [x] Google Auth
- [ ] Fix light mode (urgh) - [ ] Fix light mode (urgh)
- [x] Privacy mode - [x] Privacy mode

View file

@ -79,6 +79,7 @@
"@fontsource-variable/open-sans": "^5.2.6", "@fontsource-variable/open-sans": "^5.2.6",
"better-auth": "^1.2.9", "better-auth": "^1.2.9",
"convex-helpers": "^0.1.94", "convex-helpers": "^0.1.94",
"hastscript": "^9.0.1",
"markdown-it-async": "^2.2.0", "markdown-it-async": "^2.2.0",
"openai": "^5.3.0", "openai": "^5.3.0",
"zod": "^3.25.64" "zod": "^3.25.64"

21
pnpm-lock.yaml generated
View file

@ -38,6 +38,9 @@ importers:
convex-helpers: convex-helpers:
specifier: ^0.1.94 specifier: ^0.1.94
version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64) version: 0.1.94(convex@1.24.8)(typescript@5.8.3)(zod@3.25.64)
hastscript:
specifier: ^9.0.1
version: 9.0.1
markdown-it-async: markdown-it-async:
specifier: ^2.2.0 specifier: ^2.2.0
version: 2.2.0 version: 2.2.0
@ -1634,12 +1637,18 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
hast-util-parse-selector@4.0.0:
resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==}
hast-util-to-html@9.0.5: hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-whitespace@3.0.0: hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
html-encoding-sniffer@4.0.0: html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -4072,6 +4081,10 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
hast-util-parse-selector@4.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-to-html@9.0.5: hast-util-to-html@9.0.5:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
@ -4090,6 +4103,14 @@ snapshots:
dependencies: dependencies:
'@types/hast': 3.0.4 '@types/hast': 3.0.4
hastscript@9.0.1:
dependencies:
'@types/hast': 3.0.4
comma-separated-tokens: 2.0.3
hast-util-parse-selector: 4.0.0
property-information: 7.1.0
space-separated-tokens: 2.0.2
html-encoding-sniffer@4.0.0: html-encoding-sniffer@4.0.0:
dependencies: dependencies:
whatwg-encoding: 3.1.1 whatwg-encoding: 3.1.1

View file

@ -8,42 +8,41 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--background: oklch(0.9754 0.0084 325.6414); /* Improved light theme with better harmony */
--foreground: oklch(0.3257 0.1161 325.0372); --background: oklch(0.99 0.005 280);
--card: oklch(0.9754 0.0084 325.6414); --foreground: oklch(0.15 0.02 280);
--card-foreground: oklch(0.3257 0.1161 325.0372); --card: oklch(0.98 0.008 280);
--card-foreground: oklch(0.15 0.02 280);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.3257 0.1161 325.0372); --popover-foreground: oklch(0.15 0.02 280);
--primary: oklch(0.5797 0.1194 237.7893); --primary: oklch(0.5797 0.1194 237.7893);
--heading: oklch(0.5797 0.1194 237.7893); --heading: oklch(0.5797 0.1194 237.7893);
--primary-foreground: oklch(1 0 0); --primary-foreground: oklch(1 0 0);
--secondary: oklch(0.8696 0.0675 334.8991); --secondary: oklch(0.92 0.015 280);
--secondary-foreground: oklch(0.4448 0.1341 324.7991); --secondary-foreground: oklch(0.25 0.03 280);
--muted: oklch(0.9395 0.026 331.5454); --muted: oklch(0.94 0.012 280);
--muted-foreground: oklch(0.4924 0.1244 324.4523); --muted-foreground: oklch(0.45 0.025 280);
--accent: oklch(0.8696 0.0675 334.8991); --accent: oklch(0.92 0.015 280);
--accent-foreground: oklch(0.4448 0.1341 324.7991); --accent-foreground: oklch(0.25 0.03 280);
--destructive: oklch(0.5248 0.1368 20.8317); --destructive: oklch(0.5248 0.1368 20.8317);
--destructive-foreground: oklch(1 0 0); --destructive-foreground: oklch(1 0 0);
--border: oklch(0.8568 0.0829 328.911); --border: oklch(0.88 0.02 280);
--input: oklch(0.8517 0.0558 336.6002); --input: oklch(0.96 0.01 280);
--ring: oklch(0.5916 0.218 0.5844); --ring: oklch(0.5797 0.1194 237.7893);
--chart-1: oklch(0.6038 0.2363 344.4657); --chart-1: oklch(0.6038 0.2363 344.4657);
--chart-2: oklch(0.4445 0.2251 300.6246); --chart-2: oklch(0.4445 0.2251 300.6246);
--chart-3: oklch(0.379 0.0438 226.1538); --chart-3: oklch(0.379 0.0438 226.1538);
--chart-4: oklch(0.833 0.1185 88.3461); --chart-4: oklch(0.833 0.1185 88.3461);
--chart-5: oklch(0.7843 0.1256 58.9964); --chart-5: oklch(0.7843 0.1256 58.9964);
/* Subtle blue shift for sidebar colors in light mode */ /* Harmonized sidebar colors for light mode */
--sidebar: oklch(0.936 0.0288 280); /* Original hue 320.5788 -> 280 (more blue) */ --sidebar: oklch(0.97 0.008 280);
--sidebar-foreground: oklch(0.4948 0.1909 285); /* Original hue 354.5435 -> 285 */ --sidebar-foreground: oklch(0.2 0.025 280);
--sidebar-primary: oklch(0.3963 0.0251 270); /* Original hue 285.1962 -> 270 */ --sidebar-primary: oklch(0.5797 0.1194 237.7893);
--sidebar-primary-foreground: oklch( --sidebar-primary-foreground: oklch(1 0 0);
0.9668 0.0124 337.5228 --sidebar-accent: oklch(0.94 0.012 280);
); /* No change, keep white/near white */ --sidebar-accent-foreground: oklch(0.25 0.03 280);
--sidebar-accent: oklch(0.9789 0.0013 280); /* Original hue 106.4235 -> 280 */ --sidebar-border: oklch(0.9 0.015 280);
--sidebar-accent-foreground: oklch(0.3963 0.0251 270); /* Original hue 285.1962 -> 270 */ --sidebar-ring: oklch(0.5797 0.1194 237.7893);
--sidebar-border: oklch(0.9383 0.0026 280); /* Original hue 48.7178 -> 280 */
--sidebar-ring: oklch(0.5916 0.218 0.5844);
--radius: 0.5rem; --radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -82,13 +81,13 @@
--chart-4: oklch(0.6193 0.2029 312.7422); --chart-4: oklch(0.6193 0.2029 312.7422);
--chart-5: oklch(0.6118 0.2093 6.1387); --chart-5: oklch(0.6118 0.2093 6.1387);
/* Subtle blue shift for sidebar colors in dark mode */ /* Subtle blue shift for sidebar colors in dark mode */
--sidebar: oklch(0.1693 0.0143 280); /* Original hue 331.0475 -> 260 */ --sidebar: oklch(0.1693 0.0143 280);
--sidebar-foreground: oklch(0.8607 0.0293 265); /* Original hue 343.6612 -> 265 */ --sidebar-foreground: oklch(0.8607 0.0293 265);
--sidebar-primary: oklch(0.4882 0.2172 250); /* Original hue 264.3763 -> 250 */ --sidebar-primary: oklch(0.4882 0.2172 250);
--sidebar-primary-foreground: oklch(1 0 0); /* No change, keep white */ --sidebar-primary-foreground: oklch(1 0 0);
--sidebar-accent: oklch(0.2337 0.0261 260); /* Original hue 338.1961 -> 260 */ --sidebar-accent: oklch(0.2337 0.0261 260);
--sidebar-accent-foreground: oklch(0.9674 0.0013 250); /* Original hue 286.3752 -> 250 */ --sidebar-accent-foreground: oklch(0.9674 0.0013 250);
--sidebar-border: oklch(0 0 0); /* No change, keep black */ --sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.5797 0.1194 237.7893); --sidebar-ring: oklch(0.5797 0.1194 237.7893);
--radius: 0.5rem; --radius: 0.5rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05); --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
@ -476,3 +475,100 @@
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
} }
/* Copy button */
pre:has(code) {
position: relative;
}
pre button.copy {
position: absolute;
right: 12px;
top: 12px;
height: 32px;
width: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
@apply bg-background/80 hover:bg-background border-border hover:border-border/60 hover:text-foreground rounded-md border shadow-sm hover:shadow-md;
/* Improved hover and focus states */
&:hover {
transform: translateY(-1px);
@apply bg-background;
}
&:active {
transform: translateY(0px);
transition: all 0.1s cubic-bezier(0.4, 0, 0.2, 1);
}
&:focus-visible {
@apply ring-ring ring-offset-background ring-2 ring-offset-2 outline-none;
}
& span {
width: 16px;
height: 16px;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background-color: transparent !important;
}
& svg {
width: 16px;
height: 16px;
}
& .ready {
background-image: url(/icons/copy.svg);
}
& .success {
display: none;
background-image: url(/icons/copy-success.svg);
transform: scale(1.1);
}
&.copied {
/* Light mode success state (dark blue text on light background) */
@apply bg-primary/10 border-primary/30 text-primary;
& .success {
display: block;
animation: copySuccess 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
& .ready {
display: none;
}
}
}
/* Dark mode adjustments for copy button success state */
.dark pre button.copy.copied {
/* Dark mode success state (light blue text on dark background) */
@apply bg-primary/20 border-primary/40 text-primary-foreground;
/* Using primary-foreground which is white/light for better visibility */
}
/* Copy success animation */
@keyframes copySuccess {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1.1);
opacity: 1;
}
}

View file

@ -8,6 +8,16 @@
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<script>
// check mode-watcher-mode variable, set body bg accordingly
// TODO: make this smarter pls
const mode = localStorage.getItem('mode-watcher-mode');
if (mode === 'dark') {
document.body.style.backgroundColor = 'oklch(0.2409 0.0201 307.5346)';
} else {
// document.body.style.backgroundColor = 'oklch(0.99 0.005 280)';
}
</script>
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { UseClipboard } from '$lib/hooks/use-clipboard.svelte';
import { cn } from '$lib/utils/utils';
import CheckIcon from '~icons/lucide/check';
import CopyIcon from '~icons/lucide/copy';
import { scale } from 'svelte/transition';
interface Props {
code: string;
language?: string;
class?: string;
}
let { code, language, class: className }: Props = $props();
const clipboard = new UseClipboard();
function copyCode() {
clipboard.copy(code);
}
</script>
<div class={cn('group relative', className)}>
<div class="absolute right-2 top-2 z-10">
<Button
variant="ghost"
size="sm"
class="size-8 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onclick={copyCode}
>
{#if clipboard.status === 'success'}
<div in:scale={{ duration: 200, start: 0.8 }}>
<CheckIcon class="size-3.5" />
</div>
{:else}
<CopyIcon class="size-3.5" />
{/if}
<span class="sr-only">Copy code</span>
</Button>
</div>
<slot />
</div>
</script>

View file

@ -0,0 +1 @@
export { default as CodeBlock } from './code-block.svelte';

View file

@ -1,3 +1,7 @@
export function isString(value: unknown): value is string { export function isString(value: unknown): value is string {
return typeof value === 'string'; return typeof value === 'string';
} }
export function isHtmlElement(value: unknown): value is HTMLElement {
return value instanceof HTMLElement;
}

View file

@ -1,4 +1,5 @@
import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async'; import { fromAsyncCodeToHtml } from '@shikijs/markdown-it/async';
import { h } from 'hastscript';
import MarkdownItAsync from 'markdown-it-async'; import MarkdownItAsync from 'markdown-it-async';
import { codeToHtml } from 'shiki'; import { codeToHtml } from 'shiki';
import DOMPurify from 'isomorphic-dompurify'; import DOMPurify from 'isomorphic-dompurify';
@ -14,6 +15,72 @@ md.use(
light: 'github-light-default', light: 'github-light-default',
dark: 'github-dark-default', dark: 'github-dark-default',
}, },
transformers: [
{
name: 'shiki-transformer-copy-button',
pre(node) {
const copyIcon = h(
'svg',
{
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
[
h('rect', { width: '14', height: '14', x: '8', y: '8', rx: '2', ry: '2' }),
h('path', {
d: 'M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2',
}),
]
);
const checkIcon = h(
'svg',
{
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
},
[h('path', { d: 'M20 6 9 17l-5-5' })]
);
const button = h(
'button',
{
class: 'copy',
'data-code': this.source,
onclick: `
navigator.clipboard.writeText(this.dataset.code);
this.classList.add('copied');
setTimeout(() => this.classList.remove('copied'), ${3000})
`,
},
[
h('span', { class: 'ready', style: 'background-color: transparent !important;' }, [
copyIcon,
]),
h(
'span',
{ class: 'success', style: 'background-color: transparent !important;' },
[checkIcon]
),
]
);
node.children.push(button);
},
},
],
} }
) )
); );

View file

@ -46,6 +46,7 @@
import LockOpenIcon from '~icons/lucide/lock-open'; import LockOpenIcon from '~icons/lucide/lock-open';
import StopIcon from '~icons/lucide/square'; import StopIcon from '~icons/lucide/square';
import SearchModal from './search-modal.svelte'; import SearchModal from './search-modal.svelte';
import { isHtmlElement } from '$lib/utils/is.js';
const client = useConvexClient(); const client = useConvexClient();
@ -408,6 +409,21 @@
<title>Chat | Thom.chat</title> <title>Chat | Thom.chat</title>
</svelte:head> </svelte:head>
<svelte:document
onclick={(e) => {
const el = e.target as HTMLElement;
const closestCopyButton = el.closest('.copy[data-code]');
if (!isHtmlElement(closestCopyButton)) return;
const code = closestCopyButton.dataset.code;
if (!code) return;
navigator.clipboard.writeText(code);
closestCopyButton.classList.add('copied');
setTimeout(() => closestCopyButton.classList.remove('copied'), 3000);
}}
/>
<Sidebar.Root <Sidebar.Root
bind:open={sidebarOpen} bind:open={sidebarOpen}
class="h-screen overflow-clip" class="h-screen overflow-clip"
@ -554,7 +570,8 @@
class={[ class={[
'bg-background/50 text-foreground dark:bg-secondary/20 relative flex w-full flex-col items-stretch gap-2 rounded-t-xl border border-b-0 border-white/70 pt-3 pb-3 outline-8 dark:border-white/10', 'bg-background/50 text-foreground dark:bg-secondary/20 relative flex w-full flex-col items-stretch gap-2 rounded-t-xl border border-b-0 border-white/70 pt-3 pb-3 outline-8 dark:border-white/10',
'transition duration-200', 'transition duration-200',
'outline-primary/1 group-focus-within:outline-primary/10', 'outline-primary/10 group-focus-within:outline-primary/20',
'dark:outline-primary/1 dark:group-focus-within:outline-primary/10',
]} ]}
style="box-shadow: rgba(0, 0, 0, 0.1) 0px 80px 50px 0px, rgba(0, 0, 0, 0.07) 0px 50px 30px 0px, rgba(0, 0, 0, 0.06) 0px 30px 15px 0px, rgba(0, 0, 0, 0.04) 0px 15px 8px, rgba(0, 0, 0, 0.04) 0px 6px 4px, rgba(0, 0, 0, 0.02) 0px 2px 2px;" style="box-shadow: rgba(0, 0, 0, 0.1) 0px 80px 50px 0px, rgba(0, 0, 0, 0.07) 0px 50px 30px 0px, rgba(0, 0, 0, 0.06) 0px 30px 15px 0px, rgba(0, 0, 0, 0.04) 0px 15px 8px, rgba(0, 0, 0, 0.04) 0px 6px 4px, rgba(0, 0, 0, 0.02) 0px 2px 2px;"
onsubmit={(e) => { onsubmit={(e) => {

View file

@ -12,7 +12,7 @@
base: 'prose rounded-xl p-2', base: 'prose rounded-xl p-2',
variants: { variants: {
role: { role: {
user: 'bg-secondary/50 border border-secondary/70 px-3 py-2 !text-primary-foreground self-end', user: 'bg-secondary/50 border border-secondary/70 px-3 py-2 !text-black/80 dark:!text-primary-foreground self-end',
assistant: 'text-foreground', assistant: 'text-foreground',
}, },
}, },

View file

@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5" /></svg
>

After

Width:  |  Height:  |  Size: 273 B

15
static/icons/copy.svg Normal file
View file

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-copy-icon lucide-copy"
><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path
d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"
/></svg
>

After

Width:  |  Height:  |  Size: 373 B