feat: add warm sand theme colors and light / dark mode toggle (#302)

This commit was merged in pull request #302.
This commit is contained in:
Arjun Patel
2026-06-21 10:56:59 -07:00
committed by GitHub
parent 3d80ac2993
commit d14cec5885
43 changed files with 540 additions and 347 deletions
+17 -1
View File
@@ -1,8 +1,24 @@
<!doctype html>
<html class="dark">
<html>
<head>
<meta charset="UTF-8" />
<title>llink</title>
<script>
// Apply the stored theme before first paint to avoid a flash. Mirrors
// the logic in stores/theme-store.ts; defaults to dark.
try {
var m = localStorage.getItem('llink:theme');
var dark =
m === 'light'
? false
: m === 'system'
? matchMedia('(prefers-color-scheme: dark)').matches
: true;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body>
<div id="root"></div>
@@ -29,14 +29,14 @@ export function ComposingIndicator({
return (
<div
key={u.humanId}
className="flex rotate-180 items-center gap-1.5 rounded-full bg-white/10 px-2 py-1 backdrop-blur-sm"
className="bg-card/70 border-border flex rotate-180 items-center gap-1.5 rounded-full border px-2 py-1 backdrop-blur-sm"
>
<span className="flex gap-0.5">
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:0ms]" />
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:150ms]" />
<span className="size-1 rounded-full bg-white/70 animate-bounce [animation-delay:300ms]" />
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:0ms]" />
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:150ms]" />
<span className="bg-muted-foreground size-1 animate-bounce rounded-full [animation-delay:300ms]" />
</span>
<span className="whitespace-nowrap text-[10px] text-white/50">
<span className="text-muted-foreground whitespace-nowrap text-[10px]">
{displayName} {modeLabel}
</span>
</div>
@@ -38,23 +38,23 @@ export function ConfirmDestructiveOverlay({
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
<h2 className="text-sm font-semibold">{title}</h2>
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
className="text-muted-foreground text-xs"
>
to close
</KeyHint>
</div>
<div className="text-sm text-white/60">{description}</div>
<div className="text-muted-foreground text-sm">{description}</div>
<div className="mt-5 flex items-center justify-end gap-2">
<Button
+4 -3
View File
@@ -2,9 +2,10 @@ import { Fragment } from 'react';
import { Kbd } from '@/components/ui/kbd';
import { cn } from '@/lib/utils';
/** Dark-overlay chip restyle of the design-system Kbd, kept in one place. */
/** Chip restyle of the design-system Kbd, kept in one place. Tints to the
surrounding text color so it reads on themed panels and over media alike. */
const chipClass =
'rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
'rounded bg-current/10 px-1.5 py-0.5 font-mono text-xs font-normal text-current';
interface KeyHintProps {
/** Key chip(s): "Esc" or ["Esc", "Q"]. */
@@ -68,7 +69,7 @@ export function KeyHint({
onMouseDown={(e) => e.preventDefault()}
title={title}
className={cn(
'cursor-pointer rounded transition-colors hover:text-white/80',
'cursor-pointer rounded transition-opacity hover:opacity-80',
className,
)}
{...rest}
@@ -48,19 +48,19 @@ export function KeybindingsOverlay({
<div className="fixed inset-0 z-[100]">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">{title}</h2>
<h2 className="text-sm font-semibold">{title}</h2>
<KeyHint
keys={['Esc', '?']}
separator="or"
onClick={onClose}
title="Close (or press Esc / ?)"
className="text-xs text-white/30"
className="text-muted-foreground text-xs"
>
to close
</KeyHint>
@@ -68,21 +68,19 @@ export function KeybindingsOverlay({
<div className="flex flex-col gap-5">
{groups.map((group) => (
<section key={group.label}>
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
{group.label}
</h3>
<ul className="flex flex-col gap-1">
{group.bindings.map((binding) => (
<li
key={binding.description}
className="flex items-center justify-between border-b border-white/5 pb-1 last:border-0 last:pb-0"
className="border-border flex items-center justify-between border-b pb-1 last:border-0 last:pb-0"
>
<span className="text-sm text-white/70">
{binding.description}
</span>
<span className="text-sm">{binding.description}</span>
<KeyHint
keys={binding.keys}
className="flex items-center gap-1 text-white/60"
className="text-muted-foreground flex items-center gap-1"
/>
</li>
))}
+11 -11
View File
@@ -22,7 +22,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
return (
<div
className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md"
className="bg-card text-card-foreground border-border max-w-sm overflow-hidden rounded-2xl border"
onClick={(e) => {
e.stopPropagation();
handleOpen(e);
@@ -39,7 +39,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
/>
)}
<div className="flex flex-col gap-1 p-3">
<div className="flex items-center gap-1.5 text-xs text-white/50">
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
{metadata.favicon ? (
<img
src={metadata.favicon}
@@ -57,12 +57,12 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
<span className="truncate">{metadata.domain}</span>
</div>
{metadata.title && (
<p className="truncate text-sm font-semibold leading-snug text-white">
<p className="truncate text-sm font-semibold leading-snug">
{metadata.title}
</p>
)}
{metadata.description && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/70">
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{metadata.description}
</p>
)}
@@ -71,7 +71,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
className="text-muted-foreground"
onClick={handleOpen}
>
<ExternalLink data-icon="inline-start" />
@@ -80,7 +80,7 @@ export function LinkPreviewCard({ metadata, compact }: LinkPreviewCardProps) {
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
className="text-muted-foreground"
onClick={handleCopy}
>
<Copy data-icon="inline-start" />
@@ -111,12 +111,12 @@ export function LinkPreviewCardFallback({ url }: { url: string }) {
export function LinkPreviewCardSkeleton() {
return (
<div className="max-w-sm overflow-hidden rounded-2xl bg-white/10 backdrop-blur-md">
<Skeleton className="h-32 w-full rounded-none bg-white/5" />
<div className="bg-card border-border max-w-sm overflow-hidden rounded-2xl border">
<Skeleton className="h-32 w-full rounded-none" />
<div className="flex flex-col gap-2 p-3">
<Skeleton className="h-3 w-24 bg-white/10" />
<Skeleton className="h-4 w-48 bg-white/10" />
<Skeleton className="h-3 w-full bg-white/10" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-full" />
</div>
</div>
);
@@ -34,13 +34,13 @@ export function ScreenSourcePicker({
const windows = sources.filter((s) => s.id.startsWith('window:'));
return (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/90">
<div className="mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg bg-zinc-900 shadow-xl">
<div className="flex items-center justify-between border-b border-zinc-700 px-5 py-4">
<h2 className="text-base font-medium text-zinc-100">{title}</h2>
<div className="bg-scrim/80 absolute inset-0 z-50 flex items-center justify-center">
<div className="bg-popover text-popover-foreground mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg shadow-xl">
<div className="border-border flex items-center justify-between border-b px-5 py-4">
<h2 className="text-base font-medium">{title}</h2>
<button
onClick={onCancel}
className="text-zinc-400 hover:text-zinc-200"
className="text-muted-foreground hover:text-foreground"
>
</button>
@@ -48,7 +48,7 @@ export function ScreenSourcePicker({
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading ? (
<p className="text-center text-sm text-zinc-400">
<p className="text-muted-foreground text-center text-sm">
Loading sources...
</p>
) : (
@@ -73,17 +73,17 @@ export function ScreenSourcePicker({
)}
</div>
<div className="flex justify-end gap-2 border-t border-zinc-700 px-5 py-3">
<div className="border-border flex justify-end gap-2 border-t px-5 py-3">
<button
onClick={onCancel}
className="rounded-md px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-800"
className="hover:bg-accent text-muted-foreground rounded-md px-4 py-2 text-sm"
>
Cancel
</button>
<button
disabled={!selectedId}
onClick={() => selectedId && onSelect(selectedId)}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-40 disabled:hover:bg-blue-600"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium hover:bg-primary/90 disabled:opacity-40"
>
{confirmLabel}
</button>
@@ -106,7 +106,7 @@ function SourceSection({
}) {
return (
<div className="mb-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-zinc-400">
<h3 className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide">
{title}
</h3>
<div className="grid grid-cols-3 gap-3">
@@ -116,8 +116,8 @@ function SourceSection({
onClick={() => onSelect(source.id)}
className={`overflow-hidden rounded-lg border-2 text-left transition-colors ${
selectedId === source.id
? 'border-blue-500 bg-zinc-800'
: 'border-transparent bg-zinc-800/50 hover:border-zinc-600'
? 'border-primary bg-accent'
: 'border-transparent bg-muted hover:border-border'
}`}
>
<img
@@ -125,7 +125,7 @@ function SourceSection({
alt={source.name}
className="aspect-video w-full object-cover"
/>
<p className="truncate px-2 py-1.5 text-xs text-zinc-300">
<p className="text-muted-foreground truncate px-2 py-1.5 text-xs">
{source.name}
</p>
</button>
+1 -1
View File
@@ -39,7 +39,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50',
className,
)}
{...props}
+1 -1
View File
@@ -47,7 +47,7 @@ function Slider({
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="relative block size-3 shrink-0 rounded-full border border-ring bg-white ring-ring/50 transition-[color,box-shadow] select-none after:absolute after:-inset-2 hover:ring-3 focus-visible:ring-3 focus-visible:outline-hidden active:ring-3 disabled:pointer-events-none disabled:opacity-50"
className="border-ring bg-background ring-ring/50 relative block size-3 shrink-0 select-none rounded-full border transition-[color,box-shadow] after:absolute after:-inset-2 hover:ring-3 focus-visible:outline-hidden focus-visible:ring-3 active:ring-3 disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
+3 -1
View File
@@ -6,11 +6,13 @@ import {
OctagonXIcon,
Loader2Icon,
} from 'lucide-react';
import { useThemeStore } from '@/stores/theme-store';
const Toaster = ({ ...props }: ToasterProps) => {
const isDark = useThemeStore((s) => s.isDark);
return (
<Sonner
theme="dark"
theme={isDark ? 'dark' : 'light'}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
@@ -21,7 +21,7 @@ export function WindowControls() {
size="icon-sm"
onClick={() => platform.window.minimize()}
aria-label="Minimize"
className="dark:hover:bg-white/10 rounded text-white/50"
className="text-muted-foreground rounded"
>
<Minus />
</Button>
@@ -34,7 +34,7 @@ export function WindowControls() {
size="icon-sm"
onClick={() => platform.window.maximize()}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
className="dark:hover:bg-white/10 rounded text-white/50"
className="text-muted-foreground rounded"
>
{isMaximized ? <Copy /> : <Square />}
</Button>
@@ -47,7 +47,7 @@ export function WindowControls() {
size="icon-sm"
onClick={() => platform.window.close()}
aria-label="Close"
className="hover:text-destructive dark:hover:bg-white/10 rounded text-white/50"
className="hover:text-destructive text-muted-foreground rounded"
>
<X />
</Button>
@@ -172,7 +172,7 @@ export function AttachmentLightbox({
}}
>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" />
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-scrim/90 fixed inset-0 z-50 backdrop-blur-sm duration-100" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
@@ -62,7 +62,7 @@ function AttachmentThumbnail({
tabIndex={previewable ? 0 : undefined}
onClick={previewable && onPreview ? onPreview : undefined}
className={cn(
'group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10',
'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
previewable && 'cursor-pointer',
isError && 'ring-1 ring-red-400/50',
)}
@@ -75,19 +75,19 @@ function AttachmentThumbnail({
/>
) : (
<div className="flex flex-col items-center gap-0.5 px-1">
<FileIcon className="size-5 text-white/60" />
<span className="max-w-full truncate text-[9px] text-white/50">
<FileIcon className="text-muted-foreground size-5" />
<span className="text-muted-foreground max-w-full truncate text-[9px]">
{attachment.file.name}
</span>
<span className="text-[9px] text-white/40">
<span className="text-muted-foreground text-[9px]">
{formatFileSize(attachment.file.size)}
</span>
</div>
)}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 className="size-4 animate-spin text-white/70" />
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
<Loader2 className="size-4 animate-spin text-white" />
</div>
)}
@@ -97,7 +97,7 @@ function AttachmentThumbnail({
e.stopPropagation();
onRemove();
}}
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
className="bg-scrim/70 absolute right-0.5 top-0.5 hidden rounded-full p-0.5 text-white/80 hover:text-white group-hover:block"
>
<X className="size-3" />
</button>
@@ -108,9 +108,9 @@ function AttachmentThumbnail({
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
if (entry.isLoading) {
return (
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
<Skeleton className="h-2 w-16 bg-white/10" />
<Skeleton className="h-3 w-24 bg-white/10" />
<div className="bg-muted flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg p-2">
<Skeleton className="h-2 w-16" />
<Skeleton className="h-3 w-24" />
</div>
);
}
@@ -124,9 +124,9 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
<button
type="button"
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
className="bg-muted hover:bg-accent flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg px-2 py-1.5 text-left transition-colors"
>
<div className="flex items-center gap-1 text-[10px] text-white/40">
<div className="text-muted-foreground flex items-center gap-1 text-[10px]">
{metadata?.favicon ? (
<img
src={metadata.favicon}
@@ -142,7 +142,7 @@ function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
<span className="truncate">{domain}</span>
</div>
{title && (
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
<p className="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
{title}
</p>
)}
@@ -197,7 +197,7 @@ export function AttachmentStrip({
e.stopPropagation();
onAddClick();
}}
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
className="border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed transition-colors"
>
<Plus className="size-5" />
</button>
@@ -768,7 +768,7 @@ export function ComposeOverlay({
/>
)}
{step === 'recording' && recordingSource === 'screen' && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
<div className="bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center">
<div className="absolute top-8 z-10">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-red-500" />
@@ -840,8 +840,8 @@ export function ComposeOverlay({
/>
)}
{step === 'submitting' && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90">
<span className="animate-pulse text-sm text-white/60">
<div className="bg-background/95 absolute inset-0 z-50 flex flex-col items-center justify-center backdrop-blur-sm">
<span className="text-muted-foreground animate-pulse text-sm">
Sending...
</span>
</div>
@@ -77,13 +77,13 @@ export function ConfigureContainerStep({
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div>
<Label className="mb-1 text-xs text-white/50">
<Label className="text-muted-foreground mb-1 text-xs">
{kind === 'folder' ? 'Folder name' : 'Stream name'}
</Label>
<Input
@@ -94,21 +94,22 @@ export function ConfigureContainerStep({
setName(e.target.value);
}}
placeholder="Give it a name..."
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
{/* Visibility */}
<div>
<Label className="mb-1 text-xs text-white/50">Visible to</Label>
<div className="rounded-md border border-white/10">
<Label className="text-muted-foreground mb-1 text-xs">
Visible to
</Label>
<div className="border-border rounded-md border">
{/* Everyone in network */}
<div
role="button"
onClick={() => setEveryone((prev) => !prev)}
className={cn(
'flex w-full cursor-pointer items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors',
'text-white/70 hover:bg-white/5',
'hover:bg-accent',
)}
>
<Checkbox
@@ -137,7 +138,7 @@ export function ConfigureContainerStep({
onClick={() => toggleMember(member.id)}
className={cn(
'flex w-full cursor-pointer items-center gap-2.5 rounded px-3 py-1.5 text-left text-sm transition-colors',
'text-white/70 hover:bg-white/5',
'hover:bg-accent',
)}
>
<Checkbox
@@ -145,7 +146,7 @@ export function ConfigureContainerStep({
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-[10px] font-medium">
<span className="bg-muted flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-medium">
{initials}
</span>
<span className="flex-1 truncate">
@@ -162,7 +163,7 @@ export function ConfigureContainerStep({
</div>
{/* Keyboard hints */}
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
@@ -1,13 +1,14 @@
/* Theme Milkdown Crepe to blend into the app's translucent "glass" surfaces.
* Crepe's frame-dark theme is a grayscale palette driven by --crepe-* custom
* properties; we override those to white-on-transparent with a blue accent so
* the editor (and the read-only display, which uses the same engine) sits on
/* Theme Milkdown Crepe to the app's design tokens so it follows light/dark.
* Crepe's theme is a palette driven by --crepe-* custom properties; we map
* those onto our CSS variables (globals.css) so the editor (and the read-only
* display, which uses the same engine) inherits the active theme and sits on
* top of the existing card instead of painting its own opaque background.
*
* The floating menus (slash menu, selection toolbar, link tooltip) are portaled
* to <body>, OUTSIDE .milkdown — so the variables must be declared on those
* selectors too, otherwise they fall back to transparent and the menu is
* unreadable over the content behind it. */
* unreadable over the content behind it. Our tokens resolve against the
* :root/.dark cascade on <html>, so the portaled menus stay themed too. */
.llink-crepe .milkdown,
.milkdown-slash-menu,
.milkdown-toolbar,
@@ -15,24 +16,30 @@
.milkdown-link-preview,
.milkdown-block-handle {
--crepe-color-background: transparent;
--crepe-color-on-background: rgb(255 255 255 / 0.92);
/* Surface backs code blocks, tables, and the floating menus — keep it
* (near-)opaque so menus read clearly over content. */
--crepe-color-surface: rgb(24 24 28 / 0.96);
--crepe-color-surface-low: rgb(42 42 50 / 0.96);
--crepe-color-on-surface: #ffffff;
--crepe-color-on-surface-variant: rgb(255 255 255 / 0.65);
--crepe-color-outline: rgb(255 255 255 / 0.2);
--crepe-color-primary: #60a5fa;
--crepe-color-secondary: rgb(96 165 250 / 0.25);
--crepe-color-on-secondary: #ffffff;
--crepe-color-inverse: #ffffff;
--crepe-color-on-inverse: #0b0b0b;
--crepe-color-inline-code: #fca5a5;
--crepe-color-inline-area: rgb(255 255 255 / 0.1);
--crepe-color-error: #f87171;
--crepe-color-hover: rgb(255 255 255 / 0.08);
--crepe-color-selected: rgb(96 165 250 / 0.25);
--crepe-color-on-background: var(--card-foreground);
/* Surface backs code blocks, tables, and the floating menus — use an opaque
* themed surface so menus read clearly over content. */
--crepe-color-surface: var(--popover);
--crepe-color-surface-low: var(--muted);
--crepe-color-on-surface: var(--popover-foreground);
--crepe-color-on-surface-variant: var(--muted-foreground);
--crepe-color-outline: var(--border);
--crepe-color-primary: var(--primary);
--crepe-color-secondary: color-mix(in oklch, var(--primary) 22%, transparent);
--crepe-color-on-secondary: var(--foreground);
--crepe-color-inverse: var(--foreground);
--crepe-color-on-inverse: var(--background);
--crepe-color-inline-code: var(--primary);
--crepe-color-inline-area: var(--muted);
--crepe-color-error: var(--destructive);
--crepe-color-hover: var(--accent);
--crepe-color-selected: color-mix(in oklch, var(--primary) 22%, transparent);
/* frame-dark ships white "glow" shadows that vanish on light surfaces;
* use a conventional dark drop shadow so floating menus lift in both themes. */
--crepe-shadow-1:
0px 1px 2px 0px rgb(0 0 0 / 0.12), 0px 1px 3px 1px rgb(0 0 0 / 0.08);
--crepe-shadow-2:
0px 1px 2px 0px rgb(0 0 0 / 0.16), 0px 2px 6px 2px rgb(0 0 0 / 0.12);
/* Use the application font, not Crepe's bundled Noto Sans / Noto Serif. */
--crepe-font-default: inherit;
--crepe-font-title: inherit;
@@ -57,7 +64,7 @@
.llink-crepe .milkdown .ProseMirror {
padding: 0;
outline: none;
caret-color: white;
caret-color: var(--foreground);
}
/* Crepe's default heading scale (42px h1) is sized for a document editor;
@@ -103,7 +110,7 @@
line-height: 1.375rem;
font-weight: 700;
margin-top: 12px;
color: rgb(255 255 255 / 0.7);
color: var(--muted-foreground);
}
.llink-crepe .milkdown .ProseMirror > :first-child {
@@ -178,7 +178,7 @@ export function RecordingOverlay({
return (
<div
className={cn(
'absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/90',
'bg-scrim/90 absolute inset-0 z-50 flex flex-col items-center justify-center',
isReviewing && isDragging && 'ring-2 ring-inset ring-white/30',
isRecording && isWarning && 'record-warning-glow',
)}
@@ -230,7 +230,7 @@ export function RecordingOverlay({
{/* Bottom gradient scrim for keyboard hint readability */}
{(isRecording || isReviewing) && !isLoading && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
<div className="from-scrim/60 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
)}
{/* Top center: recording indicator */}
@@ -63,37 +63,38 @@ export function TaskComposeStep({
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
className="bg-background/95 absolute inset-0 z-50 flex flex-col pt-16 backdrop-blur-sm"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div>
<Label className="mb-1 text-xs text-white/50">Task</Label>
<Label className="text-muted-foreground mb-1 text-xs">Task</Label>
<Input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What needs to get done?"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Notes</Label>
<Label className="text-muted-foreground mb-1 text-xs">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional details…"
className="min-h-20 border-white/10 bg-white/5 text-white placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0 dark:bg-white/5"
className="min-h-20"
/>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Assign to</Label>
<Label className="text-muted-foreground mb-1 text-xs">
Assign to
</Label>
<Select value={assignedTo} onValueChange={setAssignedTo}>
<SelectTrigger className="w-full border-white/10 bg-white/5 text-white">
<SelectTrigger className="w-full">
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
<SelectContent>
@@ -108,7 +109,7 @@ export function TaskComposeStep({
</div>
</div>
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<div className="text-muted-foreground absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
@@ -112,7 +112,7 @@ export function TextEditor({
);
const keyboardHints = (
<div className="absolute bottom-4 flex items-center gap-4 text-sm text-white/50">
<div className="text-muted-foreground absolute bottom-4 flex items-center gap-4 text-sm">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
@@ -161,8 +161,8 @@ export function TextEditor({
return (
<div
className={cn(
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
isDragging && 'ring-2 ring-inset ring-white/30',
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
isDragging && 'ring-primary/40 ring-2 ring-inset',
)}
{...dropZoneProps}
>
@@ -174,7 +174,7 @@ export function TextEditor({
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className={cn(
'w-full resize-none border-none bg-transparent p-8 text-center text-white placeholder-white/40 outline-none',
'text-foreground placeholder-muted-foreground w-full resize-none border-none bg-transparent p-8 text-center outline-none',
style.size,
style.weight,
)}
@@ -189,13 +189,13 @@ export function TextEditor({
return (
<div
className={cn(
'absolute inset-0 z-50 flex items-center justify-center bg-black/90',
isDragging && 'ring-2 ring-inset ring-white/30',
'bg-background/95 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
isDragging && 'ring-primary/40 ring-2 ring-inset',
)}
{...dropZoneProps}
onKeyDownCapture={handleKeyDown}
>
<div className="mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border border-white/10 bg-white/5 backdrop-blur-xl animate-in fade-in zoom-in-95 duration-200">
<div className="bg-card text-card-foreground border-border animate-in fade-in zoom-in-95 mx-8 flex h-[calc(100%-8rem)] w-full min-w-0 max-w-[calc(var(--message-content-width)_+_var(--message-editor-gutter)*2)] flex-col overflow-hidden rounded border duration-200">
{/* No padding here: the editor's own scroll box hosts the slash menu,
so we pad inside the editor (ProseMirror) instead. That keeps the
menu's clipping bounds the full card rather than the inset box. */}
@@ -208,7 +208,7 @@ export function TextEditor({
</div>
{hasEnrichments && strip && (
<div className="shrink-0 border-t border-white/10 px-5 py-3">
<div className="border-border shrink-0 border-t px-5 py-3">
{strip}
</div>
)}
@@ -230,7 +230,7 @@ function ContainerControls({
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<KeyHint keys={['↑↓', 'Enter']}>navigate</KeyHint>
<KeyHint keys="19">jump</KeyHint>
<VideoAudioToggle />
@@ -38,14 +38,16 @@ export function DeletedParticleView({
}, [paused, onEnded, particle.id]);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8">
<div className="flex h-full w-full items-center justify-center px-8">
<div className="flex flex-col items-center gap-3 text-center">
<Trash2 className="text-white/40 size-6" />
<p className="text-white/70 text-base font-medium">
<Trash2 className="text-muted-foreground size-6" />
<p className="text-foreground text-base font-medium">
This particle was deleted
</p>
{deleter && (
<p className="text-white/40 text-xs">by {deleter.displayName}</p>
<p className="text-muted-foreground text-xs">
by {deleter.displayName}
</p>
)}
</div>
</div>
@@ -141,7 +141,7 @@ export const MediaParticleView = forwardRef<
if (isAudio) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
<div className="bg-scrim/90 relative flex h-full w-full items-center justify-center">
<audio
ref={(el) => {
audioRef.current = el;
@@ -186,7 +186,7 @@ export const MediaParticleView = forwardRef<
playsInline
onEnded={onEnded}
onTimeUpdate={handleTimeUpdate}
className="h-full w-full object-contain bg-black"
className="bg-black h-full w-full object-contain"
/>
{transcript && (
@@ -63,7 +63,7 @@ function ImageAttachment({
);
if (isLoading || !url) {
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg bg-white/10" />;
return <Skeleton className="h-20 w-20 shrink-0 rounded-lg" />;
}
const handleDownload = (e: React.MouseEvent) => {
@@ -78,7 +78,7 @@ function ImageAttachment({
e.stopPropagation();
onPreview();
}}
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
className="bg-muted group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg"
>
<img
src={url}
@@ -89,7 +89,7 @@ function ImageAttachment({
role="button"
tabIndex={0}
onClick={handleDownload}
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
className="bg-scrim/60 absolute bottom-1 right-1 rounded-full p-1 text-white/80 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
>
<Download className="size-3.5" />
</span>
@@ -123,14 +123,14 @@ function FileAttachment({
<div
role="button"
onClick={handleOpen}
className="flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg bg-white/10 px-3 py-2 transition-colors hover:bg-white/15"
className="bg-muted hover:bg-accent flex shrink-0 cursor-pointer flex-col gap-1.5 rounded-lg px-3 py-2 transition-colors"
>
<div className="flex items-center gap-2">
<FileIcon className="size-4 shrink-0 text-white/60" />
<span className="max-w-[10rem] truncate text-xs font-medium text-white/90">
<FileIcon className="text-muted-foreground size-4 shrink-0" />
<span className="text-foreground max-w-[10rem] truncate text-xs font-medium">
{particle.properties.filename}
</span>
<span className="text-[10px] text-white/40">
<span className="text-muted-foreground text-[10px]">
{formatFileSize(particle.properties.size_bytes)}
</span>
</div>
@@ -138,7 +138,7 @@ function FileAttachment({
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
className="text-muted-foreground"
onClick={handleOpen}
>
<ExternalLink data-icon="inline-start" />
@@ -147,7 +147,7 @@ function FileAttachment({
<Button
variant="ghost"
size="xs"
className="text-white/70 hover:bg-white/10 hover:text-white"
className="text-muted-foreground"
onClick={handleDownload}
>
<Download data-icon="inline-start" />
@@ -177,7 +177,7 @@ function CompactAttachmentItem({
e.stopPropagation();
openParticle(particle, index, url, onPreview);
}}
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
className="bg-muted hover:bg-accent flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors"
>
{isImage ? (
url ? (
@@ -187,15 +187,15 @@ function CompactAttachmentItem({
className="size-5 shrink-0 rounded object-cover"
/>
) : (
<ImageIcon className="size-4 shrink-0 text-white/50" />
<ImageIcon className="text-muted-foreground size-4 shrink-0" />
)
) : (
<FileIcon className="size-4 shrink-0 text-white/50" />
<FileIcon className="text-muted-foreground size-4 shrink-0" />
)}
<span className="min-w-0 truncate text-xs text-white/80">
<span className="text-foreground min-w-0 truncate text-xs">
{particle.properties.filename}
</span>
<span className="shrink-0 text-[10px] text-white/40">
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatFileSize(particle.properties.size_bytes)}
</span>
</button>
@@ -191,7 +191,7 @@ const StreamRow = memo(function StreamRow({
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
{shortcutKey}
</kbd>
)}
@@ -220,8 +220,8 @@ const StreamRow = memo(function StreamRow({
<div className="flex shrink-0 items-center gap-1.5">
{hasActiveHuddle && (
<span className="flex items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones className="size-3 text-red-400" />
<span className="text-[10px] font-medium text-red-400">
<Headphones className="size-3 text-red-600 dark:text-red-400" />
<span className="text-[10px] font-medium text-red-600 dark:text-red-400">
{huddleCount}
</span>
</span>
@@ -298,7 +298,7 @@ const FolderRow = memo(function FolderRow({
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
{shortcutKey}
</kbd>
)}
@@ -356,7 +356,7 @@ const LeafRow = memo(function LeafRow({
)}
>
{shortcutKey && (
<kbd className="flex size-5 shrink-0 items-center justify-center rounded bg-white/10 font-mono text-xs text-muted-foreground">
<kbd className="bg-muted text-muted-foreground flex size-5 shrink-0 items-center justify-center rounded font-mono text-xs">
{shortcutKey}
</kbd>
)}
@@ -69,9 +69,9 @@ function MediaPreview({
}
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-black/90">
<Mic className="h-8 w-8 text-white/60" />
<span className="font-mono text-xs text-white/50">{durationLabel}</span>
<div className="bg-muted text-muted-foreground flex h-full w-full flex-col items-center justify-center gap-2">
<Mic className="h-8 w-8" />
<span className="font-mono text-xs">{durationLabel}</span>
</div>
);
}
@@ -103,8 +103,8 @@ function VideoThumbnail({
if (error) {
return (
<div className="flex h-full w-full items-center justify-center bg-black/80">
<Video className="h-8 w-8 text-white/40" />
<div className="bg-muted flex h-full w-full items-center justify-center">
<Video className="text-muted-foreground h-8 w-8" />
</div>
);
}
@@ -114,7 +114,7 @@ function VideoThumbnail({
}
return (
<div className="relative h-full w-full bg-black">
<div className="bg-muted relative h-full w-full">
<video
src={`${url}#t=2`}
preload="metadata"
@@ -122,7 +122,7 @@ function VideoThumbnail({
playsInline
className="h-full w-full object-cover"
/>
<span className="absolute right-1.5 bottom-1.5 rounded bg-black/70 px-1.5 py-0.5 font-mono text-[10px] text-white/80">
<span className="bg-scrim/70 absolute bottom-1.5 right-1.5 rounded px-1.5 py-0.5 font-mono text-[10px] text-white">
{duration}
</span>
</div>
@@ -138,7 +138,7 @@ function LeafParticleView({
})();
return (
<div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
<div className="bg-background min-h-0 flex-1 [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
{content}
</div>
);
@@ -77,10 +77,10 @@ export function PlaybackPageIndicator({
className="group relative block h-3 w-full"
>
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
<div className="bg-muted-foreground/30 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
className="bg-foreground absolute bottom-0 left-0 h-[3px] transition-all group-hover:h-1.5"
style={{
width:
i < current
@@ -107,7 +107,7 @@ export function PlaybackPageIndicator({
)}
</div>
{paginated && showTracks && current >= 0 && (
<div className="pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide text-white/40">
<div className="text-muted-foreground pointer-events-none pt-1 text-center text-[10px] font-medium tabular-nums tracking-wide">
{current + 1} / {total}
</div>
)}
@@ -141,7 +141,7 @@ function GhostStub({
className="group relative block h-3 w-2 shrink-0"
aria-label="Jump to adjacent page"
>
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/15 transition-all group-hover:h-1.5 group-hover:bg-white/40" />
<div className="bg-muted-foreground/20 group-hover:bg-muted-foreground/40 absolute inset-x-0 bottom-0 h-[3px] transition-all group-hover:h-1.5" />
</button>
);
}
@@ -166,7 +166,7 @@ function SegmentPresenceAvatars({
className={
onlineHumanIds?.has(human.humanId)
? 'ring-2 ring-green-500'
: 'ring-1 ring-black/50'
: 'ring-1 ring-background'
}
avatarObjectId={human.avatarObjectId}
initials={human.emailPrefix.slice(0, 2).toUpperCase()}
@@ -178,7 +178,9 @@ function SegmentPresenceAvatars({
</Tooltip>
))}
{overflow > 0 && (
<span className="text-[10px] text-white/70 pl-1">+{overflow}</span>
<span className="text-muted-foreground pl-1 text-[10px]">
+{overflow}
</span>
)}
</div>
);
@@ -77,12 +77,12 @@ export function ReactionBar({
className={cn(
'flex items-center gap-1 rounded-full px-2 py-0.5 text-xs backdrop-blur-sm transition-colors',
isMine
? 'bg-white/20 ring-1 ring-white/40'
: 'bg-black/40 hover:bg-black/50',
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
)}
>
<span className="text-sm">{emoji}</span>
<span className="text-white/80">{reactors.length}</span>
<span className="text-muted-foreground">{reactors.length}</span>
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
@@ -109,8 +109,8 @@ export function ReactionBar({
className={cn(
'flex max-w-[200px] items-center gap-1.5 rounded-full py-0.5 pl-0.5 pr-2.5 text-xs backdrop-blur-sm transition-colors',
isMine
? 'bg-white/20 ring-1 ring-white/40'
: 'bg-black/40 hover:bg-black/50',
? 'bg-primary/15 text-foreground ring-1 ring-primary/40'
: 'bg-card/70 text-foreground border border-border hover:bg-accent',
)}
>
<HumanAvatar
@@ -118,11 +118,11 @@ export function ReactionBar({
className="shrink-0"
avatarObjectId={firstReactor.avatarObjectId}
initials={firstReactor.initials}
fallbackClassName="bg-white/15 text-[9px] font-medium text-white"
fallbackClassName="bg-muted text-muted-foreground text-[9px] font-medium"
/>
<span className="truncate text-white/90">{text}</span>
<span className="truncate text-foreground">{text}</span>
{reactors.length > 1 && (
<span className="shrink-0 text-white/60">
<span className="text-muted-foreground shrink-0">
{reactors.length}
</span>
)}
@@ -154,7 +154,7 @@ export function ReactionBar({
{/* Picker / actions */}
{expanded ? (
<div className="flex flex-col items-center gap-0.5 rounded-full bg-black/40 px-0.5 py-1.5 backdrop-blur-sm">
<div className="bg-card/80 border-border flex flex-col items-center gap-0.5 rounded-full border px-0.5 py-1.5 backdrop-blur-sm">
{REACTION_EMOJIS.map((emoji) => {
if (activeEmojis.includes(emoji)) return null;
return (
@@ -164,7 +164,7 @@ export function ReactionBar({
e.stopPropagation();
handleToggle(emoji);
}}
className="rounded-full px-0.5 py-1 text-sm transition-colors hover:bg-white/15"
className="hover:bg-accent rounded-full px-0.5 py-1 text-sm transition-colors"
>
{emoji}
</button>
@@ -175,9 +175,9 @@ export function ReactionBar({
e.stopPropagation();
setExpanded(false);
}}
className="flex size-5 items-center justify-center rounded-full transition-colors hover:bg-white/15"
className="hover:bg-accent flex size-5 items-center justify-center rounded-full transition-colors"
>
<X className="size-3 text-white/60" />
<X className="text-muted-foreground size-3" />
</button>
</div>
) : (
@@ -189,14 +189,14 @@ export function ReactionBar({
e.stopPropagation();
onOpenTextReaction();
}}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
>
<Type className="size-3 text-white/60" />
<Type className="text-muted-foreground size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left" className="text-xs">
Quick reply{' '}
<kbd className="ml-1 rounded bg-white/10 px-1 font-mono text-[10px]">
<kbd className="bg-muted ml-1 rounded px-1 font-mono text-[10px]">
R
</kbd>
</TooltipContent>
@@ -206,9 +206,9 @@ export function ReactionBar({
e.stopPropagation();
setExpanded(true);
}}
className="flex size-6 items-center justify-center rounded-full bg-black/40 backdrop-blur-sm transition-colors hover:bg-black/50"
className="bg-card/70 border-border hover:bg-accent flex size-6 items-center justify-center rounded-full border backdrop-blur-sm transition-colors"
>
<Plus className="size-3 text-white/60" />
<Plus className="text-muted-foreground size-3" />
</button>
</div>
)}
@@ -56,17 +56,17 @@ export function RenameStreamOverlay({
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="absolute inset-0 bg-scrim/60 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Rename stream</h2>
<h2 className="text-sm font-semibold">Rename stream</h2>
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
className="text-muted-foreground text-xs"
>
to close
</KeyHint>
@@ -85,7 +85,6 @@ export function RenameStreamOverlay({
}
}}
placeholder="Stream name"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
<div className="mt-4 flex items-center justify-end gap-2">
@@ -61,7 +61,7 @@ export function BottomBar({
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
<span className="bg-card/70 text-muted-foreground border-border rounded-full border px-2 text-xs backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
@@ -91,7 +91,7 @@ export function StreamViewControls({
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
return (
<div className="flex items-center gap-4 text-sm text-white/50">
<div className="text-muted-foreground flex items-center gap-4 text-sm">
{showEscape && (
<KeyHint
keys="Esc"
@@ -39,17 +39,17 @@ export function StreamListSidebar({
}, [currentIndex]);
return (
<aside className="dark flex w-60 shrink-0 flex-col overflow-hidden border-l border-white/10 bg-zinc-950">
<div className="flex shrink-0 items-center gap-2 border-b border-white/10 px-4 py-3">
<List className="size-3.5 text-white/40" />
<span className="truncate text-sm font-medium text-white/90 mr-auto">
<aside className="bg-sidebar text-sidebar-foreground border-border flex w-60 shrink-0 flex-col overflow-hidden border-l">
<div className="border-border flex shrink-0 items-center gap-2 border-b px-4 py-3">
<List className="text-muted-foreground size-3.5" />
<span className="mr-auto truncate text-sm font-medium">
{items.length} messages
</span>
<KeyHint
keys="L"
onClick={onToggle}
title="Hide list (or press L)"
className="text-xs text-white/40"
className="text-muted-foreground text-xs"
>
to close
</KeyHint>
@@ -72,7 +72,7 @@ export function StreamListSidebar({
</div>
))}
{items.length === 0 && (
<p className="px-2 py-8 text-center text-sm text-white/40">
<p className="text-muted-foreground px-2 py-8 text-center text-sm">
No particles in this stream yet.
</p>
)}
@@ -106,7 +106,7 @@ function ChatRow({
}}
className={cn(
'flex cursor-pointer items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors',
isSelected ? 'bg-white/10' : 'hover:bg-white/5',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
>
<HumanAvatar
@@ -114,14 +114,14 @@ function ChatRow({
className="mt-0.5 shrink-0"
initials={sender.initials}
avatarObjectId={sender.avatarObjectId}
fallbackClassName="bg-white/10 text-white/80 text-[10px] font-medium"
fallbackClassName="bg-muted text-muted-foreground text-[10px] font-medium"
/>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-xs font-semibold text-white/90">
<span className="truncate text-xs font-semibold">
{sender.displayName}
</span>
<span className="shrink-0 text-[10px] text-white/35">
<span className="text-muted-foreground shrink-0 text-[10px]">
<RelativeTimestamp date={particle.created_at} />
</span>
</div>
@@ -134,14 +134,16 @@ function ChatRow({
function ChatRowContent({ particle }: { particle: Particle }) {
if (isParticleDeleted(particle)) {
return (
<p className="text-xs italic text-white/35">This particle was deleted</p>
<p className="text-muted-foreground text-xs italic">
This particle was deleted
</p>
);
}
switch (particle.type) {
case 'text':
return (
<p className="line-clamp-2 text-xs leading-relaxed [overflow-wrap:anywhere] whitespace-pre-line text-white/70">
<p className="text-muted-foreground line-clamp-2 whitespace-pre-line text-xs leading-relaxed [overflow-wrap:anywhere]">
{particle.properties.content}
</p>
);
@@ -166,13 +168,13 @@ function ChatRowContent({ particle }: { particle: Particle }) {
: '';
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<Icon className="size-3.5 shrink-0 text-white/50" />
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<Icon className="text-muted-foreground size-3.5 shrink-0" />
{label}
{duration}
</span>
{transcript && (
<p className="line-clamp-2 text-xs leading-relaxed text-white/45">
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{transcript}
</p>
)}
@@ -181,8 +183,8 @@ function ChatRowContent({ particle }: { particle: Particle }) {
}
case 'file':
return (
<span className="flex items-center gap-1.5 text-xs text-white/70">
<FileText className="size-3.5 shrink-0 text-white/50" />
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<FileText className="text-muted-foreground size-3.5 shrink-0" />
<span className="truncate">{particle.properties.filename}</span>
</span>
);
@@ -191,21 +193,26 @@ function ChatRowContent({ particle }: { particle: Particle }) {
const doneCount = checklist.filter((item) => item.done).length;
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<span className="text-muted-foreground flex items-center gap-1.5 text-xs">
<CircleCheck
className={cn(
'size-3.5 shrink-0',
done ? 'text-emerald-400' : 'text-white/50',
done
? 'text-emerald-600 dark:text-emerald-400'
: 'text-muted-foreground',
)}
/>
<span
className={cn('truncate', done && 'text-white/40 line-through')}
className={cn(
'truncate',
done && 'text-muted-foreground line-through',
)}
>
{title}
</span>
</span>
{checklist.length > 0 && (
<span className="pl-5 text-[10px] text-white/40">
<span className="text-muted-foreground pl-5 text-[10px]">
{doneCount} / {checklist.length} subtasks
</span>
)}
@@ -214,11 +221,11 @@ function ChatRowContent({ particle }: { particle: Particle }) {
}
case 'paper':
return (
<p className="truncate text-xs text-white/70">
<p className="text-muted-foreground truncate text-xs">
{particle.properties.title}
</p>
);
default:
return <p className="text-xs text-white/45">{particle.type}</p>;
return <p className="text-muted-foreground text-xs">{particle.type}</p>;
}
}
@@ -97,18 +97,18 @@ export function StreamMembersOverlay({
return createPortal(
<div className="fixed inset-0 z-[100]">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
className="bg-scrim/60 absolute inset-0 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur-xl">
<div className="bg-popover/95 text-popover-foreground border-border absolute left-1/2 top-1/2 flex max-h-[80vh] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl border p-6 shadow-2xl backdrop-blur-xl">
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-white/70">Members</h2>
<h2 className="text-sm font-semibold">Members</h2>
<KeyHint
keys="Esc"
onClick={onClose}
title="Close (or press Esc)"
className="text-xs text-white/30"
className="text-muted-foreground text-xs"
>
to close
</KeyHint>
@@ -116,11 +116,11 @@ export function StreamMembersOverlay({
{/* Visibility */}
<section className="mb-4">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
Visibility
</h3>
{isCreator ? (
<div className="grid grid-cols-2 gap-1 rounded-lg bg-white/5 p-1">
<div className="bg-muted grid grid-cols-2 gap-1 rounded-lg p-1">
<VisibilityPill
active={visibility.mode === 'network'}
icon={<Globe className="size-3.5" />}
@@ -135,15 +135,15 @@ export function StreamMembersOverlay({
/>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-white/70">
<div className="text-foreground flex items-center gap-2 text-sm">
{visibility.mode === 'network' ? (
<>
<Globe className="size-3.5 text-white/40" />
<Globe className="text-muted-foreground size-3.5" />
<span>Everyone in {network?.name ?? 'network'}</span>
</>
) : (
<>
<Lock className="size-3.5 text-white/40" />
<Lock className="text-muted-foreground size-3.5" />
<span>{memberIds.length} specific people</span>
</>
)}
@@ -153,9 +153,11 @@ export function StreamMembersOverlay({
{/* Member list */}
<section className="flex min-h-0 flex-1 flex-col">
<h3 className="mb-2 text-[10px] font-medium uppercase tracking-widest text-white/30">
<h3 className="text-muted-foreground mb-2 text-[10px] font-medium uppercase tracking-widest">
{visibility.mode === 'network' ? 'Has access' : 'People'}{' '}
<span className="ml-1 text-white/20">{memberIds.length}</span>
<span className="text-muted-foreground ml-1">
{memberIds.length}
</span>
</h3>
<ScrollArea className="min-h-0 flex-1">
<ul className="flex flex-col gap-0.5 pr-2">
@@ -167,7 +169,7 @@ export function StreamMembersOverlay({
return (
<li
key={id}
className="group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm text-white/70"
className="text-foreground group flex items-center gap-2.5 rounded px-2 py-1.5 text-sm"
>
<HumanAvatar
size="sm"
@@ -178,13 +180,13 @@ export function StreamMembersOverlay({
<span
className={cn(
'flex-1 truncate',
!display.exists && 'italic text-white/40',
!display.exists && 'text-muted-foreground italic',
)}
>
{display.displayName}
</span>
{isCreatorRow && (
<span className="text-[10px] uppercase tracking-wider text-white/30">
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">
Creator
</span>
)}
@@ -192,7 +194,7 @@ export function StreamMembersOverlay({
<button
type="button"
onClick={() => removeMember(id)}
className="rounded p-1 text-white/30 opacity-0 transition-opacity hover:bg-white/10 hover:text-white/70 group-hover:opacity-100"
className="text-muted-foreground hover:bg-accent hover:text-foreground rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${display.displayName}`}
>
<X className="size-3.5" />
@@ -209,8 +211,8 @@ export function StreamMembersOverlay({
{isCreator &&
visibility.mode === 'custom' &&
availableToAdd.length > 0 && (
<section className="mt-4 border-t border-white/5 pt-4">
<h3 className="mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-white/30">
<section className="border-border mt-4 border-t pt-4">
<h3 className="text-muted-foreground mb-2 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest">
<UserPlus className="size-3" />
Add people
</h3>
@@ -222,7 +224,7 @@ export function StreamMembersOverlay({
type="button"
onClick={() => addMember(human.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm text-white/70 transition-colors hover:bg-white/5',
'text-foreground hover:bg-accent flex w-full items-center gap-2.5 rounded px-2 py-1.5 text-left text-sm transition-colors',
)}
>
<HumanAvatar
@@ -234,7 +236,7 @@ export function StreamMembersOverlay({
<span className="flex-1 truncate">
{human.email_prefix}
</span>
<UserPlus className="size-3.5 text-white/30" />
<UserPlus className="text-muted-foreground size-3.5" />
</button>
</li>
))}
@@ -246,7 +248,7 @@ export function StreamMembersOverlay({
{isCreator &&
visibility.mode === 'custom' &&
availableToAdd.length === 0 && (
<p className="mt-4 text-center text-xs text-white/30">
<p className="text-muted-foreground mt-4 text-center text-xs">
<Users className="mr-1 inline size-3" />
Everyone in the network is already a member
</p>
@@ -275,8 +277,8 @@ function VisibilityPill({
className={cn(
'flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs transition-colors',
active
? 'bg-white/10 text-white/90'
: 'text-white/50 hover:text-white/80',
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{icon}
@@ -92,7 +92,7 @@ export function TopBar({
<div className="drag-region flex flex-row px-2 gap-1 items-center">
<WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
<Breadcrumb className="no-drag bg-card/70 border-border mx-auto rounded-full border px-3 py-1 backdrop-blur-sm">
<BreadcrumbList>
{streamParticle && (
<>
@@ -139,7 +139,7 @@ export function TopBar({
size="sm"
avatarObjectId={display.avatarObjectId}
initials={display.initials}
fallbackClassName="bg-red-500/30 text-[8px] text-red-200"
fallbackClassName="bg-red-500/30 text-[8px] text-red-700 dark:text-red-200"
/>
</TooltipTrigger>
<TooltipContent>{display.email}</TooltipContent>
@@ -147,12 +147,14 @@ export function TopBar({
);
})}
</AvatarGroup>
<span className="text-xs font-medium text-red-200">Join</span>
<span className="text-xs font-medium text-red-600 dark:text-red-200">
Join
</span>
</button>
)}
{!isStreamOpen(streamParticle) && (
<span className="no-drag text-muted-foreground flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs backdrop-blur-sm">
<span className="no-drag text-muted-foreground bg-muted border-border flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
@@ -280,16 +282,16 @@ function MembersIndicator({
<TooltipTrigger asChild>
<button
onClick={onClick}
className="no-drag flex items-center gap-1.5 rounded-full bg-white/5 px-2 py-1 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-white/10"
className="no-drag bg-card/70 text-muted-foreground border-border hover:bg-accent flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs backdrop-blur-sm transition-colors"
>
{isNetworkWide ? (
<>
<Globe className="size-3 text-white/50" />
<Globe className="text-muted-foreground size-3" />
<span>Everyone</span>
</>
) : isPrivate ? (
<>
<Lock className="size-3 text-white/50" />
<Lock className="text-muted-foreground size-3" />
<span>Private</span>
</>
) : (
@@ -306,7 +308,7 @@ function MembersIndicator({
))}
</AvatarGroup>
{overflow > 0 && (
<span className="text-white/50">+{overflow}</span>
<span className="text-muted-foreground">+{overflow}</span>
)}
</>
)}
@@ -381,7 +381,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
if (children.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
<div className="bg-background flex h-full flex-col items-center justify-center gap-4">
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
@@ -460,15 +460,15 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
}
return (
<div className="flex h-screen overflow-hidden bg-black">
<div className="bg-background flex h-screen overflow-hidden">
{/* Stream chrome — immersive playback column */}
<div
className="relative flex min-w-0 flex-1 flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
className="bg-background relative flex min-w-0 flex-1 flex-col overflow-hidden [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
<div className="from-background/80 pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 pt-2">
@@ -488,7 +488,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
<div className="bg-card/70 text-foreground border-border rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm">
1.5x
</div>
)}
@@ -497,7 +497,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
type="button"
onClick={resume}
title="Resume (or press Space)"
className="pointer-events-auto flex items-center gap-1.5 rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground pointer-events-auto flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium backdrop-blur-sm transition-colors"
>
<Play className="size-3 fill-current" />
Paused
@@ -541,7 +541,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
<div className="from-background/80 pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t to-transparent" />
{/* BottomBar — pinned visible while browsing, mouse-activity in player */}
<BottomBar
@@ -212,20 +212,20 @@ export function TaskParticleView({
const doneCount = checklist.filter((item) => item.done).length;
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
{immersive && editing && (
<div
className="animate-in fade-in-0 fixed inset-0 z-30 bg-black/50 backdrop-blur-md duration-200"
className="animate-in fade-in-0 fixed inset-0 z-30 bg-scrim/50 backdrop-blur-md duration-200"
onMouseDown={exitFocus}
aria-hidden
/>
)}
<div
className={cn(
'scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md transition-shadow',
'scrollbar-card bg-card text-card-foreground border-border flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded border p-[var(--message-card-padding)] transition-shadow',
immersive &&
editing &&
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-white/15',
'relative z-40 shadow-2xl shadow-black/50 ring-1 ring-border',
)}
onFocusCapture={() => setEditing(true)}
onBlurCapture={(e) => {
@@ -236,7 +236,7 @@ export function TaskParticleView({
<Checkbox
checked={done}
onCheckedChange={(checked) => handleToggleDone(checked === true)}
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
className="mt-1.5 size-5 rounded-full border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
/>
<input
@@ -246,8 +246,8 @@ export function TaskParticleView({
onBlur={titleField.onBlur}
placeholder="Task title"
className={cn(
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
done && 'text-white/50 line-through',
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-2xl font-semibold outline-none',
done && 'text-muted-foreground line-through',
)}
/>
</div>
@@ -258,12 +258,12 @@ export function TaskParticleView({
onFocus={notesField.onFocus}
onBlur={notesField.onBlur}
placeholder="Add notes…"
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
className="text-foreground placeholder:text-muted-foreground min-h-16 resize-none border-none bg-transparent p-0 text-sm shadow-none focus-visible:ring-0 dark:bg-transparent"
/>
<div className="flex flex-col gap-1.5">
{checklist.length > 0 && (
<span className="text-xs text-white/40">
<span className="text-muted-foreground text-xs">
{doneCount} / {checklist.length} done
</span>
)}
@@ -286,10 +286,7 @@ export function TaskParticleView({
value={assigned_to ?? UNASSIGNED}
onValueChange={handleAssign}
>
<SelectTrigger
size="sm"
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
>
<SelectTrigger size="sm" className="w-fit gap-2">
{assigned_to && assignee.exists && (
<HumanAvatar
size="sm"
@@ -315,7 +312,7 @@ export function TaskParticleView({
keys="Esc"
onClick={exitFocus}
title="Finish editing (or press Esc)"
className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 -translate-x-1/2 text-xs text-white/40"
className="fixed bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-40 -translate-x-1/2 text-xs text-white/60"
>
to finish
</KeyHint>
@@ -345,7 +342,7 @@ function ChecklistItemRow({
<Checkbox
checked={item.done}
onCheckedChange={(checked) => onToggle(checked === true)}
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
className="border-muted-foreground/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
aria-label={
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
}
@@ -357,14 +354,14 @@ function ChecklistItemRow({
onBlur={textField.onBlur}
placeholder="Subtask"
className={cn(
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
item.done && 'text-white/40 line-through',
'text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none',
item.done && 'text-muted-foreground line-through',
)}
/>
<button
type="button"
onClick={onRemove}
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
className="text-muted-foreground hover:text-foreground opacity-0 transition-opacity group-hover/item:opacity-100"
aria-label="Remove subtask"
>
<X className="size-3.5" />
@@ -385,7 +382,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
return (
<div className="flex items-center gap-2.5">
<Plus className="size-4 text-white/30" />
<Plus className="text-muted-foreground size-4" />
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
@@ -397,7 +394,7 @@ function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
}}
onBlur={submit}
placeholder="Add subtask…"
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
className="text-foreground placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
/>
</div>
);
@@ -122,7 +122,7 @@ export function TextParticleView({
setIsEditing(true);
}}
title="Edit"
className="absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 rounded-full bg-black/40 px-3 py-1.5 text-xs text-white/70 backdrop-blur-sm transition-colors hover:bg-black/60 hover:text-white"
className="bg-card/70 text-muted-foreground border-border hover:bg-accent hover:text-foreground absolute bottom-[calc(var(--stream-safe-bottom,2rem)+0.5rem)] left-1/2 z-30 flex -translate-x-1/2 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs backdrop-blur-sm transition-colors"
>
<Pencil className="size-3.5" />
Edit
@@ -130,7 +130,7 @@ export function TextParticleView({
);
const editedLabel = particle.properties.edited_at && (
<span className="text-xs text-white/40">
<span className="text-muted-foreground text-xs">
edited <RelativeTimestamp date={particle.properties.edited_at} />
</span>
);
@@ -146,7 +146,7 @@ export function TextParticleView({
// Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) {
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<LinkPreviews entries={linkPreviews} />
{editedLabel && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2">
@@ -167,10 +167,10 @@ export function TextParticleView({
) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="group relative flex h-full w-full flex-col items-center justify-center gap-4 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<p
className={cn(
'max-w-2xl break-words text-center leading-relaxed text-white select-text cursor-text',
'text-foreground max-w-2xl cursor-text select-text break-words text-center leading-relaxed',
style.size,
style.weight,
)}
@@ -186,8 +186,8 @@ export function TextParticleView({
// Mode 3: card layout
return (
<div className="group relative flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md">
<div className="group relative flex h-full w-full items-center justify-center px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div className="scrollbar-card bg-card text-card-foreground border-border flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-4 overflow-y-auto overscroll-contain rounded border p-[var(--message-card-padding)]">
<MarkdownEditor
key={content}
value={content}
@@ -54,7 +54,7 @@ export function TextReactionInput({
return (
<div
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-1 rounded-full bg-black/60 py-1 pl-3 pr-1 shadow-lg ring-1 ring-white/15 backdrop-blur-md"
className="bg-popover/90 text-popover-foreground border-border flex items-center gap-1 rounded-full border py-1 pl-3 pr-1 shadow-lg backdrop-blur-md"
>
<input
ref={inputRef}
@@ -74,12 +74,14 @@ export function TextReactionInput({
}}
placeholder="Quick reply…"
maxLength={MAX_LENGTH}
className="w-24 bg-transparent text-sm text-white outline-none placeholder:text-white/40"
className="text-foreground placeholder:text-muted-foreground w-24 bg-transparent text-sm outline-none"
/>
<span
className={cn(
'min-w-[1.5ch] text-right text-[10px] tabular-nums',
remaining <= 8 ? 'text-amber-300/80' : 'text-white/30',
remaining <= 8
? 'text-amber-600 dark:text-amber-400'
: 'text-muted-foreground',
)}
>
{remaining}
@@ -91,8 +93,8 @@ export function TextReactionInput({
className={cn(
'ml-1 flex size-6 items-center justify-center rounded-full transition-colors',
canSubmit
? 'bg-white/20 text-white hover:bg-white/30'
: 'text-white/30',
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'text-muted-foreground',
)}
aria-label="Send reaction"
>
+57 -1
View File
@@ -12,11 +12,16 @@ import {
Volume2,
ArrowLeft,
Camera,
Sun,
Moon,
Monitor,
Palette,
} from 'lucide-react';
import { HumanAvatar } from '@/components/human-avatar';
import { AvatarEditDialog } from '@/features/settings/avatar-edit-dialog';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { WindowControls } from '@/components/window-controls';
import { Button } from '@/components/ui/button';
import { Muted } from '@/components/ui/typography';
@@ -24,6 +29,7 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { CopyableEmail } from '@/components/copyable-email';
import { useAuthStore } from '@/stores/auth-store';
import { useSoundEffectsStore } from '@/stores/sound-effects-store';
import { useThemeStore, type ThemeMode } from '@/stores/theme-store';
import { apiClient } from '@/api/client';
import { logError, toUserMessage } from '@/lib/errors';
import { toast } from 'sonner';
@@ -80,6 +86,50 @@ function SettingsGroup({
);
}
const THEME_OPTIONS: {
value: ThemeMode;
label: string;
icon: React.ReactNode;
}[] = [
{ value: 'system', label: 'System', icon: <Monitor className="size-3.5" /> },
{ value: 'light', label: 'Light', icon: <Sun className="size-3.5" /> },
{ value: 'dark', label: 'Dark', icon: <Moon className="size-3.5" /> },
];
function ThemeSetting() {
const mode = useThemeStore((s) => s.mode);
const setMode = useThemeStore((s) => s.setMode);
return (
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
<Palette className="size-4" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium">Theme</span>
<ToggleGroup
type="single"
size="sm"
value={mode}
onValueChange={(value) => value && setMode(value as ThemeMode)}
className="border-border bg-muted/50 border p-0.5"
spacing={2}
>
{THEME_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm gap-1.5 rounded-md px-2.5 text-xs"
>
{option.icon}
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
@@ -150,7 +200,7 @@ export default function SettingsPage() {
initials={initials}
fallbackClassName="bg-primary/10 text-primary text-xl font-medium"
/>
<span className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100" />
<span className="bg-scrim/40 absolute inset-0 flex items-center justify-center rounded-full opacity-0 transition-opacity group-hover:opacity-100" />
<span className="bg-primary text-primary-foreground ring-background absolute bottom-0 right-0 flex size-5 items-center justify-center rounded-full ring-2">
<Camera className="size-2.5" />
</span>
@@ -165,6 +215,12 @@ export default function SettingsPage() {
<Separator />
<SettingsGroup title="Appearance">
<ThemeSetting />
</SettingsGroup>
<Separator className="mt-4" />
<SettingsGroup title="Notifications">
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
+1
View File
@@ -1,6 +1,7 @@
import { createRoot } from 'react-dom/client';
import App from './App';
import { initSentryRenderer } from '@/lib/sentry';
import '@/stores/theme-store';
import './styles/globals.css';
initSentryRenderer();
+63
View File
@@ -0,0 +1,63 @@
import { create } from 'zustand';
export type ThemeMode = 'light' | 'dark' | 'system';
const STORAGE_KEY = 'llink:theme';
const DEFAULT_MODE: ThemeMode = 'dark';
function loadMode(): ThemeMode {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'light' || v === 'dark' || v === 'system') return v;
} catch {
// storage unavailable
}
return DEFAULT_MODE;
}
function systemPrefersDark(): boolean {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true;
}
function resolveDark(mode: ThemeMode): boolean {
return mode === 'system' ? systemPrefersDark() : mode === 'dark';
}
function applyMode(mode: ThemeMode) {
document.documentElement.classList.toggle('dark', resolveDark(mode));
}
interface ThemeState {
mode: ThemeMode;
isDark: boolean;
setMode: (mode: ThemeMode) => void;
}
export const useThemeStore = create<ThemeState>((set, get) => {
const initialMode = loadMode();
applyMode(initialMode);
// Track the OS preference so "system" mode stays in sync live.
window
.matchMedia?.('(prefers-color-scheme: dark)')
.addEventListener('change', () => {
if (get().mode === 'system') {
applyMode('system');
set({ isDark: systemPrefersDark() });
}
});
return {
mode: initialMode,
isDark: resolveDark(initialMode),
setMode: (mode) => {
applyMode(mode);
set({ mode, isDark: resolveDark(mode) });
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// storage unavailable
}
},
};
});
+82 -65
View File
@@ -47,75 +47,92 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-scrim: var(--scrim);
}
/* Warm Sand / Amber palette. Neutrals carry a low-chroma warm hue (~60-85°)
so surfaces read as paper/espresso rather than flat gray, with a burnt-amber
primary as the brand accent. */
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--background: oklch(0.985 0.006 83);
--foreground: oklch(0.255 0.012 64);
--card: oklch(0.998 0.003 90);
--card-foreground: oklch(0.255 0.012 64);
--popover: oklch(0.998 0.003 90);
--popover-foreground: oklch(0.255 0.012 64);
--primary: oklch(0.62 0.128 62);
--primary-foreground: oklch(0.99 0.006 90);
--secondary: oklch(0.945 0.014 78);
--secondary-foreground: oklch(0.32 0.014 60);
--muted: oklch(0.945 0.012 78);
--muted-foreground: oklch(0.535 0.02 62);
--accent: oklch(0.92 0.03 70);
--accent-foreground: oklch(0.3 0.035 55);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--border: oklch(0.9 0.014 75);
--input: oklch(0.9 0.014 75);
--ring: oklch(0.62 0.128 62);
--chart-1: oklch(0.66 0.14 60);
--chart-2: oklch(0.7 0.12 95);
--chart-3: oklch(0.58 0.09 45);
--chart-4: oklch(0.75 0.13 80);
--chart-5: oklch(0.6 0.11 30);
--sidebar: oklch(0.965 0.01 80);
--sidebar-foreground: oklch(0.255 0.012 64);
--sidebar-primary: oklch(0.62 0.128 62);
--sidebar-primary-foreground: oklch(0.99 0.006 90);
--sidebar-accent: oklch(0.92 0.03 70);
--sidebar-accent-foreground: oklch(0.3 0.035 55);
--sidebar-border: oklch(0.9 0.014 75);
--sidebar-ring: oklch(0.62 0.128 62);
--scrollbar-thumb: oklch(0.45 0.03 60 / 25%);
--scrollbar-thumb-hover: oklch(0.45 0.03 60 / 40%);
--scrollbar-thumb-strong: oklch(0.45 0.03 60 / 35%);
--scrollbar-thumb-strong-hover: oklch(0.45 0.03 60 / 55%);
/* Modal/overlay backdrop. A dim scrim behind dialogs reads the same in both
themes (matching shadcn's default overlay), so it is intentionally
theme-independent. Use with an alpha modifier, e.g. bg-scrim/60. */
--scrim: oklch(0 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--background: oklch(0.19 0.012 58);
--foreground: oklch(0.955 0.008 85);
--card: oklch(0.232 0.014 56);
--card-foreground: oklch(0.955 0.008 85);
--popover: oklch(0.232 0.014 56);
--popover-foreground: oklch(0.955 0.008 85);
--primary: oklch(0.78 0.13 78);
--primary-foreground: oklch(0.24 0.03 60);
--secondary: oklch(0.285 0.014 54);
--secondary-foreground: oklch(0.955 0.008 85);
--muted: oklch(0.285 0.014 54);
--muted-foreground: oklch(0.72 0.018 72);
--accent: oklch(0.33 0.032 56);
--accent-foreground: oklch(0.955 0.008 85);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--border: oklch(0.92 0.03 85 / 10%);
--input: oklch(0.92 0.03 85 / 15%);
--ring: oklch(0.78 0.13 78);
--chart-1: oklch(0.78 0.13 78);
--chart-2: oklch(0.7 0.12 50);
--chart-3: oklch(0.72 0.11 95);
--chart-4: oklch(0.65 0.13 35);
--chart-5: oklch(0.6 0.1 60);
--sidebar: oklch(0.165 0.012 58);
--sidebar-foreground: oklch(0.955 0.008 85);
--sidebar-primary: oklch(0.78 0.13 78);
--sidebar-primary-foreground: oklch(0.24 0.03 60);
--sidebar-accent: oklch(0.33 0.032 56);
--sidebar-accent-foreground: oklch(0.955 0.008 85);
--sidebar-border: oklch(0.92 0.03 85 / 10%);
--sidebar-ring: oklch(0.78 0.13 78);
--scrollbar-thumb: oklch(0.92 0.03 85 / 18%);
--scrollbar-thumb-hover: oklch(0.92 0.03 85 / 32%);
--scrollbar-thumb-strong: oklch(0.92 0.03 85 / 30%);
--scrollbar-thumb-strong-hover: oklch(0.92 0.03 85 / 50%);
}
@layer base {
@@ -130,7 +147,7 @@
/* Scrollbar styling */
* {
scrollbar-width: thin;
scrollbar-color: oklch(1 0 0 / 20%) transparent;
scrollbar-color: var(--scrollbar-thumb) transparent;
}
::-webkit-scrollbar {
@@ -143,12 +160,12 @@
}
::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 20%);
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 35%);
background: var(--scrollbar-thumb-hover);
}
/* Message cards: more prominent scrollbar than the subtle global default.
@@ -160,13 +177,13 @@
.scrollbar-card::-webkit-scrollbar-thumb,
.scrollbar-card ::-webkit-scrollbar-thumb {
background: oklch(1 0 0 / 30%);
background: var(--scrollbar-thumb-strong);
border-radius: 9999px;
}
.scrollbar-card::-webkit-scrollbar-thumb:hover,
.scrollbar-card ::-webkit-scrollbar-thumb:hover {
background: oklch(1 0 0 / 50%);
background: var(--scrollbar-thumb-strong-hover);
}
/* Message layout: the compose editor and the posted card derive their widths
+17 -1
View File
@@ -1,10 +1,26 @@
<!doctype html>
<html class="dark">
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/png" href="/icon.png" />
<title>llink</title>
<script>
// Apply the stored theme before first paint to avoid a flash. Mirrors
// the logic in stores/theme-store.ts; defaults to dark.
try {
var m = localStorage.getItem('llink:theme');
var dark =
m === 'light'
? false
: m === 'system'
? matchMedia('(prefers-color-scheme: dark)').matches
: true;
if (dark) document.documentElement.classList.add('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
</script>
</head>
<body>
<div id="root"></div>
+1
View File
@@ -1,6 +1,7 @@
import { createRoot } from 'react-dom/client';
import App from '@/App';
import { initSentryRenderer } from '@/lib/sentry';
import '@/stores/theme-store';
import '@/styles/globals.css';
initSentryRenderer();