diff --git a/apps/web/package.json b/apps/web/package.json
index b41bb10..76c2233 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,17 +15,22 @@
"@tanstack/react-router": "1.170.41",
"@tanstack/react-router-ssr-query": "1.167.3",
"@tanstack/react-start": "1.168.60",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
"comlink": "4.4.2",
"hono": "4.13.9",
+ "lucide-react": "1.47.0",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-markdown": "10.1.0",
- "remark-gfm": "4.0.1"
+ "remark-gfm": "4.0.1",
+ "tailwind-merge": "3.7.0"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@rolldown/plugin-babel": "0.2.4",
"@scn-chat/server": "workspace:*",
+ "@tailwindcss/vite": "4.3.3",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.7",
@@ -37,6 +42,7 @@
"better-sqlite3": "13.0.3",
"jsdom": "30.1.1",
"react-scan": "0.5.3",
+ "tailwindcss": "4.3.3",
"vite": "8.3.1"
}
}
diff --git a/apps/web/src/logo.svg b/apps/web/src/logo.svg
new file mode 100644
index 0000000..22f8aa5
--- /dev/null
+++ b/apps/web/src/logo.svg
@@ -0,0 +1,17 @@
+
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 069ef59..1f78aee 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -2,10 +2,12 @@ import type { QueryClient } from '@tanstack/react-query'
import { createRootRouteWithContext, HeadContent, Navigate, Scripts } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import { checkSession } from '../features/auth/session.ts'
+import logo from '../logo.svg?url'
import { appName } from '../shared/app-name.ts'
import { messageOf } from '../shared/errors.ts'
-import styles from '../styles.css?url'
-import theme from '../theme.css?url'
+import { themeScript } from '../shared/theme.ts'
+import { useInstantThemeSwitch } from '../shared/useInstantThemeSwitch.ts'
+import styles from '../styles/app.css?url'
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
staleTime: Number.POSITIVE_INFINITY,
@@ -21,11 +23,16 @@ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()(
],
links: [
{ rel: 'stylesheet', href: styles },
- { rel: 'stylesheet', href: theme },
+ { rel: 'icon', type: 'image/svg+xml', href: logo },
+ ],
+ scripts: [
+ // Applies the light or dark chosen on this device before the first paint.
+ { children: themeScript },
+ // In development, React Scan outlines components as they render. It hooks into React
+ // through the devtools global before React loads, so it runs as a plain script ahead of
+ // the app.
+ ...(import.meta.env.DEV ? [{ src: '/node_modules/react-scan/dist/auto.global.js' }] : []),
],
- // In development, React Scan outlines components as they render. It hooks into React through
- // the devtools global before React loads, so it runs as a plain script ahead of the app.
- scripts: import.meta.env.DEV ? [{ src: '/node_modules/react-scan/dist/auto.global.js' }] : [],
}),
shellComponent: Shell,
errorComponent: ({ error }) =>
{messageOf(error)}
,
@@ -37,8 +44,10 @@ interface Props {
}
function Shell(props: Props) {
+ useInstantThemeSwitch()
return (
-
+ // The theme script sets data-theme on the root before React hydrates it.
+
diff --git a/apps/web/src/shared/cn.ts b/apps/web/src/shared/cn.ts
new file mode 100644
index 0000000..893068b
--- /dev/null
+++ b/apps/web/src/shared/cn.ts
@@ -0,0 +1,51 @@
+import { type ClassValue, clsx } from 'clsx'
+import { extendTailwindMerge } from 'tailwind-merge'
+
+/**
+ * The theme's names that tailwind-merge can't tell apart on its own, such as text-ui, a size, from
+ * text-muted, a color. A test keeps these in step with styles/theme.css.
+ */
+export const themeNames = {
+ text: ['caption', 'small', 'ui', 'body', 'heading', 'title', 'display'],
+ font: ['ui', 'prose', 'mono'],
+ radius: ['panel', 'card', 'small', 'badge', 'row', 'menu', 'bubble', 'composer'],
+ shadow: ['float', 'lift'],
+ spacing: [
+ 'icon',
+ 'sidebar',
+ 'sidebar-pad',
+ 'row',
+ 'bar',
+ 'toggle',
+ 'composer-button',
+ 'gutter',
+ 'card-pad',
+ 'within',
+ 'turn',
+ ],
+ container: ['column', 'settings', 'narrow'],
+ ease: ['out'],
+ animate: ['fade-in', 'breathe'],
+ breakpoint: ['wide'],
+}
+
+const merge = extendTailwindMerge({
+ extend: {
+ theme: {
+ text: themeNames.text,
+ font: themeNames.font,
+ radius: themeNames.radius,
+ shadow: themeNames.shadow,
+ spacing: themeNames.spacing,
+ container: themeNames.container,
+ ease: themeNames.ease,
+ animate: themeNames.animate,
+ breakpoint: themeNames.breakpoint,
+ },
+ },
+})
+
+/** Joins class names, letting a later utility win over an earlier one of the same kind. */
+export function cn(...classes: ClassValue[]) {
+ return merge(clsx(classes))
+}
diff --git a/apps/web/src/shared/icons.tsx b/apps/web/src/shared/icons.tsx
new file mode 100644
index 0000000..d4e63bf
--- /dev/null
+++ b/apps/web/src/shared/icons.tsx
@@ -0,0 +1,535 @@
+import {
+ ArrowDown,
+ ArrowUp,
+ Box,
+ createLucideIcon,
+ KeyRound,
+ type LucideIcon,
+ Puzzle,
+ Users,
+} from 'lucide-react'
+import { cn } from './cn.ts'
+
+interface IconOptions {
+ /** Drawn solid, as the stop square is. */
+ filled?: boolean
+ /** Points along the line, as back and next do, so it mirrors in right-to-left text. */
+ directional?: boolean
+}
+
+interface Props {
+ /** Sizes, colors, or turns the icon where it is used. */
+ className?: string
+}
+
+/**
+ * The app's line icons, from Lucide, in the current text color. Their stroke is drawn in screen
+ * pixels whatever their size, at the weight of the text beside them, heavier inside a button or a
+ * link. They are decoration beside a visible label or an aria-label, so they are hidden from
+ * assistive technology. A fork can swap these for another icon set without touching the
+ * components that use them.
+ */
+function icon(Glyph: LucideIcon, options: IconOptions = {}) {
+ return (props: Props) => (
+
+ )
+}
+
+/**
+ * Lucide's panel-left made puffy, to sit with the bubble letters of the logo: a wide, round box
+ * whose sidebar is a short bar with round ends, floating inside it.
+ */
+const SoftPanelLeft = createLucideIcon('soft-panel-left', [
+ ['rect', { width: '18', height: '15', x: '3', y: '4.5', rx: '5.5', key: 'box' }],
+ ['path', { d: 'M7.75 8.75v6.5', key: 'divider' }],
+])
+
+/**
+ * A puffy magnifier, to sit with the bubble letters of the logo: a round lens, and a short handle
+ * with round ends floating just off it, as the sidebar toggle's bar floats in its box.
+ */
+const CompactSearch = createLucideIcon('compact-search', [
+ ['circle', { cx: '10.5', cy: '10.5', r: '7', key: 'lens' }],
+ ['path', { d: 'm17.25 17.25 2.5 2.5', key: 'handle' }],
+])
+
+/**
+ * A wide, puffy speech bubble with a plus, its tail ending in a round tip, to sit with the bubble
+ * letters of the logo.
+ */
+const PuffyChatPlus = createLucideIcon('puffy-chat-plus', [
+ [
+ 'path',
+ {
+ d: 'M12 4c5.2 0 9 2.9 9 7s-3.8 7-9 7c-.7 0-1.4-.1-2.1-.2C8.6 19.7 6.8 20.6 5 20.5c-.6 0-.8-.5-.5-.9.6-.7.9-1.5.9-2.4C4 15.9 3 13.6 3 11c0-4.1 3.8-7 9-7z',
+ key: 'bubble',
+ },
+ ],
+ ['path', { d: 'M12 8.6v4.8M9.6 11h4.8', key: 'plus' }],
+])
+
+/**
+ * A chubby paperclip, to sit with the bubble letters of the logo: two wide turns where Lucide's
+ * has three tight ones, drawn upright and tilted.
+ */
+const PuffyPaperclip = createLucideIcon('puffy-paperclip', [
+ [
+ 'path',
+ {
+ d: 'M12 10.75V17.75a2.5 2.5 0 0 0 5 0V8.25a5 5 0 0 0-10 0V20.75',
+ transform: 'rotate(45 12 12)',
+ key: 'clip',
+ },
+ ],
+])
+
+/**
+ * An arrow pointing up, for Send: a solid head, as the stop square it trades places with is solid,
+ * its corners softened by its stroke, on a stem as thin as a line icon's.
+ */
+const PuffyArrowUp = createLucideIcon('puffy-arrow-up', [
+ ['path', { d: 'M12 6l4 4.25H8z', key: 'head' }],
+ ['path', { d: 'M12 10v8', key: 'stem' }],
+])
+
+/**
+ * Three round dots, a little larger than Lucide's, to sit with the bubble letters of the logo. The
+ * stroke drawn around each fill makes them half again as wide, so they keep Lucide's spacing.
+ */
+const RoundDots = createLucideIcon('round-dots', [
+ ['circle', { cx: '5', cy: '12', r: '1', key: 'start' }],
+ ['circle', { cx: '12', cy: '12', r: '1', key: 'middle' }],
+ ['circle', { cx: '19', cy: '12', r: '1', key: 'end' }],
+])
+
+/** The round dots stacked upright. */
+const RoundDotsVertical = createLucideIcon('round-dots-vertical', [
+ ['circle', { cx: '12', cy: '5', r: '1', key: 'top' }],
+ ['circle', { cx: '12', cy: '12', r: '1', key: 'middle' }],
+ ['circle', { cx: '12', cy: '19', r: '1', key: 'bottom' }],
+])
+
+/**
+ * A soft list, to sit with the bubble letters of the logo: small round bullets, and short lines
+ * whose last runs shorter, as a list's last line of text often does.
+ */
+const SoftList = createLucideIcon('soft-list', [
+ ['circle', { cx: '5.5', cy: '6.5', r: '0.5', key: 'bullet-1' }],
+ ['circle', { cx: '5.5', cy: '12', r: '0.5', key: 'bullet-2' }],
+ ['circle', { cx: '5.5', cy: '17.5', r: '0.5', key: 'bullet-3' }],
+ ['path', { d: 'M9.5 6.5h9M9.5 12h9M9.5 17.5h6', fill: 'none', key: 'lines' }],
+])
+
+/**
+ * Lucide's share made puffy, to sit with the bubble letters of the logo: a shorter arrow, and a
+ * tray rounded deep at its corners.
+ */
+const PuffyShare = createLucideIcon('puffy-share', [
+ ['path', { d: 'M12 4v9.5', key: 'stem' }],
+ ['path', { d: 'm8.5 7.5 3.5-3.5 3.5 3.5', key: 'head' }],
+ ['path', { d: 'M5.5 11.5v4a4.5 4.5 0 0 0 4.5 4.5h4a4.5 4.5 0 0 0 4.5-4.5v-4', key: 'tray' }],
+])
+
+/**
+ * Lucide's copy made puffy, to sit with the bubble letters of the logo: two deeply rounded
+ * squares, the one behind showing only its top and side.
+ */
+const PuffyCopy = createLucideIcon('puffy-copy', [
+ ['rect', { width: '12', height: '12', x: '8.5', y: '8.5', rx: '3.5', key: 'front' }],
+ [
+ 'path',
+ {
+ d: 'M15.5 8.5V7a3.5 3.5 0 0 0-3.5-3.5H7A3.5 3.5 0 0 0 3.5 7v5A3.5 3.5 0 0 0 7 15.5h1.5',
+ key: 'back',
+ },
+ ],
+])
+
+/**
+ * Lucide's file made puffy, to sit with the bubble letters of the logo: a page rounded deep at its
+ * corners, as Copy's squares are, with a soft fold.
+ */
+const PuffyFile = createLucideIcon('puffy-file', [
+ [
+ 'path',
+ {
+ d: 'M13.5 3.5H8A3.5 3.5 0 0 0 4.5 7v10A3.5 3.5 0 0 0 8 20.5h8a3.5 3.5 0 0 0 3.5-3.5V9.5z',
+ key: 'page',
+ },
+ ],
+ ['path', { d: 'M13.5 3.5V7A2.5 2.5 0 0 0 16 9.5h3.5', key: 'fold' }],
+])
+
+/**
+ * Lucide's lightbulb made puffy, to sit with the bubble letters of the logo: a rounder bulb closed
+ * at its neck, over one short bar.
+ */
+const PuffyBulb = createLucideIcon('puffy-bulb', [
+ [
+ 'path',
+ {
+ d: 'M9.5 16c-.3-1.75-3.75-3.25-3.75-6.75a6.25 6.25 0 1 1 12.5 0c0 3.5-3.45 5-3.75 6.75z',
+ key: 'bulb',
+ },
+ ],
+ ['path', { d: 'M10 19.5h4', key: 'base' }],
+])
+
+/**
+ * Lucide's sparkles made puffy, to sit with the bubble letters of the logo: two four-pointed stars
+ * with full, soft sides, a large one and a small one, where Lucide's has a thin star and two marks.
+ */
+const PuffySparkles = createLucideIcon('puffy-sparkles', [
+ [
+ 'path',
+ {
+ d: 'M10.5 5.5c2.4 5.6 2.4 5.6 8 8-5.6 2.4-5.6 2.4-8 8-2.4-5.6-2.4-5.6-8-8 5.6-2.4 5.6-2.4 8-8z',
+ key: 'large',
+ },
+ ],
+ [
+ 'path',
+ {
+ d: 'M18.5 2.5c.9 2.1.9 2.1 3 3-2.1.9-2.1.9-3 3-.9-2.1-.9-2.1-3-3 2.1-.9 2.1-.9 3-3z',
+ key: 'small',
+ },
+ ],
+])
+
+/**
+ * Lucide's tag made puffy, to sit with the bubble letters of the logo: a soft point and deeply
+ * rounded corners, with a round hole, drawn upright and tilted.
+ */
+const PuffyTag = createLucideIcon('puffy-tag', [
+ [
+ 'path',
+ {
+ d: 'M10.6 3.9a2 2 0 0 1 2.8 0L18.5 9v9a3.5 3.5 0 0 1-3.5 3.5H9A3.5 3.5 0 0 1 5.5 18V9z',
+ transform: 'rotate(-45 12 12)',
+ key: 'tag',
+ },
+ ],
+ [
+ 'circle',
+ {
+ cx: '12',
+ cy: '9.75',
+ r: '1',
+ fill: 'currentColor',
+ transform: 'rotate(-45 12 12)',
+ key: 'hole',
+ },
+ ],
+])
+
+/**
+ * Lucide's trash made puffy, to sit with the bubble letters of the logo: a wide can that narrows
+ * to a round bottom, a round handle on its lid, and no lines inside.
+ */
+const PuffyTrash = createLucideIcon('puffy-trash', [
+ ['path', { d: 'M4 6.5h16', key: 'lid' }],
+ ['path', { d: 'M9.5 6.5v-1a2 2 0 0 1 2-2h1a2 2 0 0 1 2 2v1', key: 'handle' }],
+ [
+ 'path',
+ { d: 'm6 6.5.8 10.8a3.5 3.5 0 0 0 3.5 3.2h3.4a3.5 3.5 0 0 0 3.5-3.2l.8-10.8', key: 'can' },
+ ],
+])
+
+/**
+ * Lucide's hash with shorter strokes, so its four crossing lines sit at the weight of the icons
+ * beside it rather than darker.
+ */
+const SoftHash = createLucideIcon('soft-hash', [
+ ['path', { d: 'M6 9.5h12M6 14.5h12', key: 'bars' }],
+ ['path', { d: 'M10.5 5.5 9.25 18.5M14.75 5.5 13.5 18.5', key: 'posts' }],
+])
+
+/**
+ * Lucide's settings gear made puffy, to sit with the bubble letters of the logo: six round-topped
+ * teeth where Lucide's has eight, around a round hub.
+ */
+const PuffyGear = createLucideIcon('puffy-gear', [
+ [
+ 'path',
+ {
+ d: 'M10 5.29V4.25a2 2 0 0 1 4 0v1.04a7 7 0 0 1 2.81 1.62l.9-.52a2 2 0 0 1 2 3.47l-.9.52a7 7 0 0 1 0 3.24l.9.52a2 2 0 0 1-2 3.47l-.9-.52A7 7 0 0 1 14 18.71v1.04a2 2 0 0 1-4 0v-1.04a7 7 0 0 1-2.81-1.62l-.9.52a2 2 0 0 1-2-3.47l.9-.52a7 7 0 0 1 0-3.24l-.9-.52a2 2 0 0 1 2-3.47l.9.52A7 7 0 0 1 10 5.29z',
+ key: 'gear',
+ },
+ ],
+ ['circle', { cx: '12', cy: '12', r: '2.75', key: 'hub' }],
+])
+
+/**
+ * Lucide's shield made puffy, to sit with the bubble letters of the logo: softer shoulders and a
+ * rounder point.
+ */
+const PuffyShield = createLucideIcon('puffy-shield', [
+ [
+ 'path',
+ {
+ d: 'M12 3.5c2.2 1.4 4.3 1.9 6.25 2.1a1 1 0 0 1 .75 1V12c0 4.4-3 7.1-7 8.5-4-1.4-7-4.1-7-8.5V6.6a1 1 0 0 1 .75-1C7.7 5.4 9.8 4.9 12 3.5z',
+ key: 'shield',
+ },
+ ],
+])
+
+/**
+ * Lucide's log-out made puffy, to sit with the bubble letters of the logo: a door rounded deep at
+ * its corners, and an arrow with Share's shorter head.
+ */
+const PuffySignOut = createLucideIcon('puffy-sign-out', [
+ ['path', { d: 'M9.5 4.5h-2A3.5 3.5 0 0 0 4 8v8a3.5 3.5 0 0 0 3.5 3.5h2', key: 'door' }],
+ ['path', { d: 'M10 12h9.5', key: 'stem' }],
+ ['path', { d: 'm16 8.5 3.5 3.5-3.5 3.5', key: 'head' }],
+])
+
+/**
+ * Lucide's refresh made puffy, to sit with the bubble letters of the logo: two rounder turns, each
+ * with an open head aimed along the curve just behind its tip, as Regenerate's is.
+ */
+const PuffySync = createLucideIcon('puffy-sync', [
+ ['path', { d: 'M4.5 12A7.5 7.5 0 0 1 17.75 7.18', key: 'turn-1' }],
+ ['path', { d: 'M17.41 4.2 17.75 7.18 14.76 7.52', key: 'head-1' }],
+ ['path', { d: 'M19.5 12A7.5 7.5 0 0 1 6.25 16.82', key: 'turn-2' }],
+ ['path', { d: 'M6.59 19.8 6.25 16.82 9.24 16.48', key: 'head-2' }],
+])
+
+/** A puffy cloud, to sit with the bubble letters of the logo: three even puffs on a flat base. */
+const PuffyCloud = createLucideIcon('puffy-cloud', [
+ [
+ 'path',
+ {
+ d: 'M6.5 19.25A4.25 4.25 0 0 1 5.76 10.82a6 6 0 0 1 11.98-.06 4.25 4.25 0 0 1-.24 8.49z',
+ key: 'cloud',
+ },
+ ],
+])
+
+/**
+ * Lucide's hard drive made puffy, to sit with the bubble letters of the logo: deep corners, soft
+ * sloping shoulders, and one small light.
+ */
+const PuffyDrive = createLucideIcon('puffy-drive', [
+ [
+ 'path',
+ {
+ d: 'M3 13.25 6.05 6.6A3.25 3.25 0 0 1 9 4.75h6a3.25 3.25 0 0 1 2.95 1.85L21 13.25v3A3.75 3.75 0 0 1 17.25 20H6.75A3.75 3.75 0 0 1 3 16.25z',
+ key: 'case',
+ },
+ ],
+ ['path', { d: 'M3 13.25h18', key: 'seam' }],
+ ['circle', { cx: '7.25', cy: '16.6', r: '1', fill: 'currentColor', key: 'light' }],
+])
+
+/**
+ * Lucide's sliders made puffy, to sit with the bubble letters of the logo: two tracks, each with a
+ * round knob, where Lucide's has three with flat ticks.
+ */
+const SoftSliders = createLucideIcon('soft-sliders', [
+ ['path', { d: 'M4 8h3.5M12.5 8H20', key: 'track-1' }],
+ ['circle', { cx: '10', cy: '8', r: '2.5', key: 'knob-1' }],
+ ['path', { d: 'M4 16h8.5M17.5 16H20', key: 'track-2' }],
+ ['circle', { cx: '15', cy: '16', r: '2.5', key: 'knob-2' }],
+])
+
+/**
+ * Lucide's id card made puffy, to sit with the bubble letters of the logo: a card rounded deep at
+ * its corners, with a person and two short lines.
+ */
+const PuffyIdCard = createLucideIcon('puffy-id-card', [
+ ['rect', { x: '3', y: '5', width: '18', height: '14', rx: '4.5', key: 'card' }],
+ ['circle', { cx: '9', cy: '10.5', r: '2', key: 'head' }],
+ ['path', { d: 'M6.25 15.5a3 3 0 0 1 5.5 0', key: 'shoulders' }],
+ ['path', { d: 'M15 10.5h2.5M15 14h2.5', key: 'lines' }],
+])
+
+/**
+ * Lucide's lock made puffy, to sit with the bubble letters of the logo: a rounder body and
+ * shackle, with a round keyhole.
+ */
+const PuffyLock = createLucideIcon('puffy-lock', [
+ ['rect', { x: '4.5', y: '10.5', width: '15', height: '10', rx: '3.5', key: 'body' }],
+ ['path', { d: 'M8 10.5V8a4 4 0 0 1 8 0v2.5', key: 'shackle' }],
+ ['circle', { cx: '12', cy: '15.5', r: '1', fill: 'currentColor', key: 'keyhole' }],
+])
+
+/** New chat's puffy speech bubble without its plus, for a chat. */
+const PuffyChat = createLucideIcon('puffy-chat', [
+ [
+ 'path',
+ {
+ d: 'M12 4c5.2 0 9 2.9 9 7s-3.8 7-9 7c-.7 0-1.4-.1-2.1-.2C8.6 19.7 6.8 20.6 5 20.5c-.6 0-.8-.5-.5-.9.6-.7.9-1.5.9-2.4C4 15.9 3 13.6 3 11c0-4.1 3.8-7 9-7z',
+ key: 'bubble',
+ },
+ ],
+])
+
+/** Lucide's clock in Info's softer circle, with shorter hands. */
+const SoftClock = createLucideIcon('soft-clock', [
+ ['circle', { cx: '12', cy: '12', r: '8.5', key: 'face' }],
+ ['path', { d: 'M12 7.75V12l2.75 2', key: 'hands' }],
+])
+
+/** A check with shorter, softer arms than Lucide's, as Copy's turns to once it copies. */
+const SoftCheck = createLucideIcon('soft-check', [
+ ['path', { d: 'm5.5 12.5 4 4 9-9', key: 'check' }],
+])
+
+/**
+ * Lucide's rotate-ccw made puffy, to sit with the bubble letters of the logo: a rounder turn whose
+ * open head sits at its top. The head is aimed along the curve just behind its tip rather than
+ * the flat tangent at it, so its arms sit evenly about the line as it bends away.
+ */
+const PuffyRetry = createLucideIcon('puffy-retry', [
+ ['path', { d: 'M5.07 8A8 8 0 1 0 12 4', key: 'turn' }],
+ ['path', { d: 'M14.92 2.01 12 4l1.99 2.92', key: 'head' }],
+])
+
+/**
+ * Closing quote marks as type draws them, to sit with the bubble letters of the logo: round solid
+ * heads with comma tails, where Lucide's are squared blocks.
+ */
+const RoundQuote = createLucideIcon('round-quote', [
+ ['circle', { cx: '7.5', cy: '10', r: '2', fill: 'currentColor', key: 'head-1' }],
+ ['path', { d: 'M9.5 10c0 3-1.2 5.3-3.75 6.6', key: 'tail-1' }],
+ ['circle', { cx: '16.5', cy: '10', r: '2', fill: 'currentColor', key: 'head-2' }],
+ ['path', { d: 'M18.5 10c0 3-1.2 5.3-3.75 6.6', key: 'tail-2' }],
+])
+
+/**
+ * Lucide's pencil made puffy, to sit with the bubble letters of the logo: a wider barrel with a
+ * round end and a rounded point, drawn upright and tilted.
+ */
+const PuffyPencil = createLucideIcon('puffy-pencil', [
+ [
+ 'path',
+ {
+ d: 'M8.5 17V4a3.5 3.5 0 0 1 7 0v13l-2.55 4.9a1.1 1.1 0 0 1-1.9 0z',
+ transform: 'rotate(45 12 12)',
+ key: 'barrel',
+ },
+ ],
+ ['path', { d: 'M8.5 7.75h7', transform: 'rotate(45 12 12)', key: 'band' }],
+])
+
+/**
+ * An arrow pointing down, for Jump to latest, softer than Lucide's: a shorter stem and a narrower
+ * head, so it sits calmly in its round button.
+ */
+const SoftArrowDown = createLucideIcon('soft-arrow-down', [
+ ['path', { d: 'M12 5.5v12.5', key: 'stem' }],
+ ['path', { d: 'm7 13 5 5 5-5', key: 'head' }],
+])
+
+/**
+ * Lucide's chevrons made wider and flatter, to sit with the wide bubble letters of the logo, turned
+ * three ways.
+ */
+const SoftChevronDown = createLucideIcon('soft-chevron-down', [
+ ['path', { d: 'm5.5 9.5 6.5 5 6.5-5', key: 'chevron' }],
+])
+
+const SoftChevronLeft = createLucideIcon('soft-chevron-left', [
+ ['path', { d: 'm14.5 5.5-5 6.5 5 6.5', key: 'chevron' }],
+])
+
+const SoftChevronRight = createLucideIcon('soft-chevron-right', [
+ ['path', { d: 'm9.5 5.5 5 6.5-5 6.5', key: 'chevron' }],
+])
+
+/** Jump to latest's arrow turned to point back, the way the text reads. */
+const SoftArrowLeft = createLucideIcon('soft-arrow-left', [
+ ['path', { d: 'M18.5 12H6', key: 'stem' }],
+ ['path', { d: 'm11 7-5 5 5 5', key: 'head' }],
+])
+
+/** Jump to latest's arrow turned to point on. */
+const SoftArrowRight = createLucideIcon('soft-arrow-right', [
+ ['path', { d: 'M5.5 12H18', key: 'stem' }],
+ ['path', { d: 'm13 7 5 5-5 5', key: 'head' }],
+])
+
+/** A plus with shorter arms than Lucide's. */
+const SoftPlus = createLucideIcon('soft-plus', [['path', { d: 'M12 6v12M6 12h12', key: 'plus' }]])
+
+/** A cross with shorter arms than Lucide's, as the soft check's are. */
+const SoftX = createLucideIcon('soft-x', [['path', { d: 'M17 7 7 17M7 7l10 10', key: 'cross' }]])
+
+/**
+ * A square with deeply rounded corners, for Stop, to sit with the bubble letters of the logo and
+ * the soft head of the Send arrow it trades places with.
+ */
+const SoftSquare = createLucideIcon('soft-square', [
+ ['rect', { width: '18', height: '18', x: '3', y: '3', rx: '5', key: 'square' }],
+])
+
+/** Lucide's info, rounder: a smaller circle, a shorter stem, and a round dot. */
+const SoftInfo = createLucideIcon('soft-info', [
+ ['circle', { cx: '12', cy: '12', r: '8.5', key: 'ring' }],
+ ['path', { d: 'M12 16v-4.25', key: 'stem' }],
+ ['circle', { cx: '12', cy: '8.25', r: '0.4', fill: 'currentColor', key: 'dot' }],
+])
+
+/** Info turned over, for an alert: the same circle, its stem above a round dot. */
+const SoftAlert = createLucideIcon('soft-alert', [
+ ['circle', { cx: '12', cy: '12', r: '8.5', key: 'ring' }],
+ ['path', { d: 'M12 8v4.25', key: 'stem' }],
+ ['circle', { cx: '12', cy: '15.75', r: '0.4', fill: 'currentColor', key: 'dot' }],
+])
+
+export const PlusIcon = icon(SoftPlus)
+export const NewChatIcon = icon(PuffyChatPlus)
+export const HashIcon = icon(SoftHash)
+export const SidebarIcon = icon(SoftPanelLeft)
+export const SettingsIcon = icon(PuffyGear)
+export const SignOutIcon = icon(PuffySignOut)
+export const ShieldIcon = icon(PuffyShield)
+export const MoreIcon = icon(RoundDots, { filled: true })
+export const ListIcon = icon(SoftList, { filled: true })
+export const PencilIcon = icon(PuffyPencil)
+export const TagIcon = icon(PuffyTag)
+export const TrashIcon = icon(PuffyTrash)
+export const CloseIcon = icon(SoftX)
+export const CloudIcon = icon(PuffyCloud)
+export const DriveIcon = icon(PuffyDrive)
+export const ShareIcon = icon(PuffyShare)
+export const SyncIcon = icon(PuffySync)
+export const ChevronDownIcon = icon(SoftChevronDown)
+export const ChevronLeftIcon = icon(SoftChevronLeft, { directional: true })
+export const ChevronRightIcon = icon(SoftChevronRight, { directional: true })
+export const MoreVerticalIcon = icon(RoundDotsVertical, { filled: true })
+export const ArrowRightIcon = icon(SoftArrowRight, { directional: true })
+export const ArrowLeftIcon = icon(SoftArrowLeft, { directional: true })
+export const CheckIcon = icon(SoftCheck)
+export const CopyIcon = icon(PuffyCopy)
+export const InfoIcon = icon(SoftInfo)
+export const QuoteIcon = icon(RoundQuote)
+export const SlidersIcon = icon(SoftSliders)
+export const KeyIcon = icon(KeyRound)
+export const PuzzleIcon = icon(Puzzle)
+export const UsersIcon = icon(Users)
+export const RolesIcon = icon(PuffyIdCard)
+export const LockIcon = icon(PuffyLock)
+export const CubeIcon = icon(Box)
+export const ChatIcon = icon(PuffyChat)
+export const ClockIcon = icon(SoftClock)
+export const ArrowUpIcon = icon(ArrowUp)
+export const SendIcon = icon(PuffyArrowUp, { filled: true })
+export const ArrowDownIcon = icon(ArrowDown)
+export const JumpDownIcon = icon(SoftArrowDown)
+export const PaperclipIcon = icon(PuffyPaperclip)
+export const StopIcon = icon(SoftSquare, { filled: true })
+export const FileIcon = icon(PuffyFile)
+export const SearchIcon = icon(CompactSearch)
+export const BulbIcon = icon(PuffyBulb)
+export const SparkleIcon = icon(PuffySparkles)
+export const AlertIcon = icon(SoftAlert)
+export const RetryIcon = icon(PuffyRetry)
diff --git a/apps/web/src/shared/theme.ts b/apps/web/src/shared/theme.ts
new file mode 100644
index 0000000..0d4812f
--- /dev/null
+++ b/apps/web/src/shared/theme.ts
@@ -0,0 +1,58 @@
+/** The scheme a device shows: the system's light or dark, or one fixed in Preferences. */
+export type ThemeChoice = 'system' | 'light' | 'dark'
+
+export const themeKey = 'scn-chat:theme'
+
+/**
+ * Runs in the page's head before the first paint, so a fixed choice never flashes the other
+ * scheme. It reads the store the same way storedTheme does.
+ */
+export const themeScript = `try{var t=localStorage.getItem('${themeKey}');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch(e){}`
+
+export function themeOf(value: string | null): ThemeChoice {
+ return value === 'light' || value === 'dark' ? value : 'system'
+}
+
+/** The choice stored on this device, or system when there is none or the store can't be read. */
+export function storedTheme(): ThemeChoice {
+ try {
+ return themeOf(localStorage.getItem(themeKey))
+ } catch {
+ return 'system'
+ }
+}
+
+/** Stores the choice on this device and shows it at once. */
+export function chooseTheme(choice: ThemeChoice) {
+ try {
+ if (choice === 'system') localStorage.removeItem(themeKey)
+ else localStorage.setItem(themeKey, choice)
+ } catch {
+ // The choice still applies to this page when it can't be kept.
+ }
+ showTheme(choice)
+}
+
+/** Sets the scheme the page's colors follow, through the data-theme attribute on the root. */
+export function showTheme(choice: ThemeChoice) {
+ switchInstantly(() => {
+ const root = document.documentElement
+ if (choice === 'system') delete root.dataset.theme
+ else root.dataset.theme = choice
+ })
+}
+
+/**
+ * Changes the scheme with transitions held off until the new colors have painted. The colors follow
+ * the scheme through light-dark(), so otherwise every color transition on the page would run
+ * together and the switch would smear.
+ */
+export function switchInstantly(change: () => void) {
+ const style = document.createElement('style')
+ style.textContent = '*,*::before,*::after{transition:none !important}'
+ document.head.append(style)
+ change()
+ // Reading layout makes the browser apply the new colors while transitions are off.
+ void document.body.offsetHeight
+ requestAnimationFrame(() => requestAnimationFrame(() => style.remove()))
+}
diff --git a/apps/web/src/shared/ui/Logo.tsx b/apps/web/src/shared/ui/Logo.tsx
new file mode 100644
index 0000000..6bb3049
--- /dev/null
+++ b/apps/web/src/shared/ui/Logo.tsx
@@ -0,0 +1,45 @@
+import { useId } from 'react'
+import logo from '../../logo.svg?raw'
+import { cn } from '../cn.ts'
+
+// The logo's outlines come from the favicon's file, so there is one drawing of it: the outline
+// that cuts the rim, the bubble letters' face, then their rim.
+const [cutout, face, rim] = Array.from(logo.matchAll(/ d="([^"]+)"/g), (match) => match[1])
+
+interface Props {
+ /** The app's name, read out in place of the drawing. */
+ label: string
+ className?: string
+}
+
+/**
+ * The app's logo in the theme's colors. It is drawn inline rather than loaded as the favicon's
+ * file, since a picture follows the system's light or dark, not the scheme chosen in Preferences.
+ */
+export function Logo(props: Props) {
+ const mask = useId()
+ return (
+
+ )
+}
diff --git a/apps/web/src/shared/useInstantThemeSwitch.ts b/apps/web/src/shared/useInstantThemeSwitch.ts
new file mode 100644
index 0000000..83e7f43
--- /dev/null
+++ b/apps/web/src/shared/useInstantThemeSwitch.ts
@@ -0,0 +1,22 @@
+import { useEffect } from 'react'
+import { showTheme, switchInstantly, themeKey, themeOf } from './theme.ts'
+
+/**
+ * Switches between light and dark at once when the system does, and when another tab on this
+ * device changes the choice in Preferences, which this tab then shows too.
+ */
+export function useInstantThemeSwitch() {
+ useEffect(() => {
+ const scheme = window.matchMedia?.('(prefers-color-scheme: dark)')
+ const onSchemeChange = () => switchInstantly(() => {})
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === themeKey) showTheme(themeOf(event.newValue))
+ }
+ scheme?.addEventListener('change', onSchemeChange)
+ window.addEventListener('storage', onStorage)
+ return () => {
+ scheme?.removeEventListener('change', onSchemeChange)
+ window.removeEventListener('storage', onStorage)
+ }
+ }, [])
+}
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
deleted file mode 100644
index 59fd5b5..0000000
--- a/apps/web/src/styles.css
+++ /dev/null
@@ -1,18 +0,0 @@
-.layout {
- display: flex;
- gap: 1rem;
- min-height: 100vh;
-}
-.sidebar {
- flex: 0 0 16rem;
-}
-.layout > main {
- flex: 1;
- min-width: 0;
-}
-.text {
- white-space: pre-wrap;
-}
-img {
- max-width: 100%;
-}
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
new file mode 100644
index 0000000..7f7b2ac
--- /dev/null
+++ b/apps/web/src/styles/app.css
@@ -0,0 +1,12 @@
+/*
+ * The app's one stylesheet. Components style themselves with utilities; this only brings in the
+ * theme, the document's base, and the few styles utilities can't express.
+ */
+
+/* Main's stand-in look sits between the base and the utilities until the styling port is done. */
+@layer theme, base, legacy, components, utilities;
+
+@import "tailwindcss";
+@import "./theme.css";
+@import "./base.css";
+@import "./legacy.css" layer(legacy);
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
new file mode 100644
index 0000000..a951b4f
--- /dev/null
+++ b/apps/web/src/styles/base.css
@@ -0,0 +1,72 @@
+/*
+ * The document itself, over Tailwind's Preflight. No element gets a look here: buttons, fields,
+ * and headings are styled by the components that render them.
+ */
+
+@layer base {
+ :root {
+ color-scheme: light dark;
+ /* 15.5px at the browser's default size, and larger or smaller with the reader's setting. */
+ font-size: 96.875%;
+ line-height: 1.55;
+ color: var(--color-ink);
+ accent-color: var(--color-accent);
+ caret-color: var(--color-accent-strong);
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+ interpolate-size: allow-keywords;
+ }
+
+ /* Light or Dark chosen in Preferences fixes the scheme every color follows. */
+ :root[data-theme="light"] {
+ color-scheme: light;
+ }
+
+ :root[data-theme="dark"] {
+ color-scheme: dark;
+ }
+
+ body {
+ background: var(--color-chrome);
+ }
+
+ ::selection {
+ background: var(--color-highlight);
+ color: inherit;
+ }
+
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-line) transparent;
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--color-focus);
+ outline-offset: 2px;
+ }
+
+ a {
+ text-decoration-line: underline;
+ text-decoration-color: var(--color-faint);
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.2em;
+ transition: text-decoration-color var(--dur-feedback);
+ }
+
+ @media (hover: hover) {
+ a:hover {
+ text-decoration-color: var(--color-accent);
+ }
+ }
+
+ input,
+ select,
+ textarea {
+ font-size: max(var(--input-floor), 1em);
+ }
+
+ ::placeholder {
+ color: var(--color-faint);
+ }
+}
diff --git a/apps/web/src/styles/legacy.css b/apps/web/src/styles/legacy.css
new file mode 100644
index 0000000..20ef316
--- /dev/null
+++ b/apps/web/src/styles/legacy.css
@@ -0,0 +1,582 @@
+/*
+ * Main's stand-in look from before the styling phase, kept while design-reference is ported area
+ * by area. It sits in the legacy layer, after the base and before the utilities, so pages not yet
+ * ported keep their look over Preflight. It stops at any element marked data-ported, so a ported
+ * area shows only its own styles. Delete this file, its import, and every data-ported once the
+ * port is done.
+ */
+
+@scope (:root) to ([data-ported]) {
+ .layout {
+ display: flex;
+ gap: 1rem;
+ min-height: 100vh;
+ }
+ .sidebar {
+ flex: 0 0 16rem;
+ }
+ .layout > main {
+ flex: 1;
+ min-width: 0;
+ }
+ .text {
+ white-space: pre-wrap;
+ }
+ img {
+ max-width: 100%;
+ }
+
+ /* The browser's own defaults that the stand-in look relied on, which Preflight removes. */
+ :where(
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6,
+ p,
+ ul,
+ ol,
+ menu,
+ dl,
+ dd,
+ blockquote,
+ figure,
+ fieldset,
+ legend,
+ hr
+ ) {
+ margin: revert;
+ padding: revert;
+ border: revert;
+ font-size: revert;
+ font-weight: revert;
+ list-style: revert;
+ }
+
+ :where(img, svg, video, canvas, iframe) {
+ display: revert;
+ vertical-align: revert;
+ }
+
+ :scope {
+ --bg: light-dark(#ffffff, #212121);
+ --sidebar: light-dark(#f9f9f9, #171717);
+ --surface: light-dark(#f4f4f4, #2f2f2f);
+ --hover: light-dark(#ececec, #3a3a3a);
+ --border: light-dark(#e3e3e3, #3d3d3d);
+ --text: light-dark(#0d0d0d, #ececec);
+ --muted: light-dark(#6b6b6b, #a4a4a4);
+ --accent: light-dark(#0d0d0d, #ececec);
+ --on-accent: light-dark(#ffffff, #0d0d0d);
+ --danger: light-dark(#c62828, #f28b82);
+ --radius: 0.75rem;
+ --column: 48rem;
+ --settings-column: 92rem;
+ }
+
+ * {
+ box-sizing: border-box;
+ }
+
+ body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ }
+
+ a {
+ color: inherit;
+ }
+
+ h1,
+ h2,
+ h3 {
+ line-height: 1.3;
+ }
+
+ button,
+ input,
+ select,
+ textarea {
+ font: inherit;
+ color: inherit;
+ }
+
+ button {
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 0.3rem 0.7rem;
+ cursor: pointer;
+ }
+
+ button:hover:not(:disabled) {
+ background: var(--hover);
+ }
+
+ button:disabled {
+ opacity: 0.4;
+ cursor: default;
+ }
+
+ button[type="submit"] {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--on-accent);
+ }
+
+ button[type="submit"]:hover:not(:disabled) {
+ background: var(--accent);
+ opacity: 0.85;
+ }
+
+ input:not([type="checkbox"], [type="file"]),
+ select,
+ textarea {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 0.35rem 0.6rem;
+ }
+
+ input::file-selector-button {
+ font: inherit;
+ color: inherit;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 0.3rem 0.7rem;
+ margin-right: 0.5rem;
+ cursor: pointer;
+ }
+
+ :focus-visible {
+ outline: 2px solid var(--muted);
+ outline-offset: 1px;
+ }
+
+ [role="alert"] {
+ color: var(--danger);
+ }
+
+ pre {
+ background: var(--surface);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ font-size: 0.85em;
+ }
+
+ details {
+ color: var(--muted);
+ margin: 0.5rem 0;
+ }
+
+ summary {
+ cursor: pointer;
+ }
+
+ /* Layout: a fixed sidebar, and a main column that scrolls on its own. */
+
+ .layout {
+ gap: 0;
+ height: 100vh;
+ min-height: 0;
+ }
+
+ .layout > main {
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .sidebar {
+ flex-basis: 16rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.75rem;
+ background: var(--sidebar);
+ border-right: 1px solid var(--border);
+ overflow-y: auto;
+ }
+
+ .sidebar ul {
+ list-style: none;
+ margin: 0.5rem 0;
+ padding: 0;
+ flex: 1;
+ }
+
+ .sidebar a {
+ display: block;
+ padding: 0.4rem 0.6rem;
+ border-radius: 0.5rem;
+ text-decoration: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .sidebar a:hover,
+ .sidebar a[aria-current="page"] {
+ background: var(--hover);
+ }
+
+ .chats .sidebar > input[type="search"] {
+ margin: 0.25rem 0;
+ }
+
+ .search-results {
+ flex: 1;
+ }
+
+ .search-results > p {
+ color: var(--muted);
+ font-size: 0.85rem;
+ padding: 0 0.6rem;
+ }
+
+ .search-results a {
+ white-space: normal;
+ }
+
+ .search-results small {
+ color: var(--muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ mark {
+ background: light-dark(#fff3a3, #5c4d00);
+ color: inherit;
+ border-radius: 0.2rem;
+ }
+
+ .sidebar > button {
+ text-align: left;
+ }
+
+ .banner {
+ margin: 0.75rem auto 0;
+ padding: 0.5rem 0.75rem;
+ background: var(--surface);
+ border-radius: 0.5rem;
+ }
+
+ main > p {
+ margin: auto;
+ color: var(--muted);
+ }
+
+ /* Conversation */
+
+ .conversation {
+ width: 100%;
+ max-width: var(--column);
+ margin: 0 auto;
+ padding: 0 1rem;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .conversation > header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.75rem 0;
+ background: var(--bg);
+ }
+
+ .conversation > header h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0 auto 0 0;
+ }
+
+ .conversation > header h2 button {
+ font-size: 0.8rem;
+ font-weight: normal;
+ border: none;
+ color: var(--muted);
+ }
+
+ .conversation > header form {
+ display: flex;
+ gap: 0.5rem;
+ margin-right: auto;
+ }
+
+ .conversation fieldset {
+ flex-basis: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 0.75rem;
+ }
+
+ .conversation fieldset input {
+ flex: 1;
+ min-width: 12rem;
+ }
+
+ /* Messages: the user's in a bubble on the right, the assistant's as plain text. */
+
+ .message {
+ margin: 1rem 0;
+ }
+
+ .message > header strong {
+ display: none;
+ }
+
+ .message > header {
+ font-size: 0.8rem;
+ color: var(--muted);
+ }
+
+ .message.user {
+ align-self: flex-end;
+ max-width: 80%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ }
+
+ .message.user > .text {
+ background: var(--surface);
+ border-radius: 1.25rem;
+ padding: 0.6rem 1rem;
+ margin: 0;
+ }
+
+ .markdown :not(pre) > code {
+ background: var(--surface);
+ border-radius: 0.3rem;
+ padding: 0.1rem 0.3rem;
+ font-size: 0.85em;
+ }
+
+ .markdown pre {
+ white-space: pre;
+ }
+
+ .markdown table {
+ border-collapse: collapse;
+ display: block;
+ overflow-x: auto;
+ }
+
+ .markdown th,
+ .markdown td {
+ border: 1px solid var(--border);
+ padding: 0.3rem 0.6rem;
+ }
+
+ .markdown blockquote {
+ border-left: 3px solid var(--border);
+ color: var(--muted);
+ margin: 0;
+ padding-left: 1rem;
+ }
+
+ .siblings {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ }
+
+ .siblings button,
+ .message > footer button {
+ border: none;
+ color: var(--muted);
+ font-size: 0.8rem;
+ padding: 0.15rem 0.4rem;
+ }
+
+ .message > footer {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.15s;
+ }
+
+ .message:hover > footer,
+ .message > footer:focus-within {
+ opacity: 1;
+ }
+
+ .message > footer select {
+ font-size: 0.8rem;
+ padding: 0.1rem 0.3rem;
+ border: none;
+ color: var(--muted);
+ }
+
+ .message > ul {
+ font-size: 0.85rem;
+ padding-left: 1.25rem;
+ }
+
+ /* Composer: one rounded box pinned to the bottom of the column. */
+
+ .composer {
+ position: sticky;
+ bottom: 0;
+ margin-top: auto;
+ padding-bottom: 1rem;
+ background: linear-gradient(transparent, var(--bg) 1rem);
+ }
+
+ .composer textarea {
+ display: block;
+ width: 100%;
+ min-height: 3.5rem;
+ max-height: 40vh;
+ resize: vertical;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ border-radius: 1.5rem 1.5rem 0 0;
+ padding: 1rem 1.25rem 0.25rem;
+ background: var(--surface);
+ }
+
+ .composer textarea:focus-visible {
+ outline: none;
+ }
+
+ .composer > div {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-top: none;
+ border-radius: 0 0 1.5rem 1.5rem;
+ font-size: 0.85rem;
+ }
+
+ .composer > div select {
+ background: transparent;
+ border: none;
+ color: var(--muted);
+ }
+
+ .composer input[type="file"] {
+ color: var(--muted);
+ max-width: 14rem;
+ }
+
+ .composer > div > span {
+ color: var(--muted);
+ }
+
+ .composer button[type="submit"] {
+ margin-left: auto;
+ border-radius: 1rem;
+ padding: 0.3rem 1rem;
+ }
+
+ /* Login and settings */
+
+ .login {
+ max-width: 22rem;
+ margin: 20vh auto 0;
+ padding: 0 1rem;
+ text-align: center;
+ }
+
+ .login form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ }
+
+ .login label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ text-align: left;
+ }
+
+ .login button {
+ padding: 0.5rem;
+ }
+
+ .settings > main {
+ padding: 0 1rem 4rem;
+ }
+
+ .settings main > section,
+ .settings main > form {
+ width: 100%;
+ max-width: var(--settings-column);
+ margin: 1.5rem auto 0;
+ padding: 1.25rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ }
+
+ .settings h2 {
+ margin-top: 0;
+ font-size: 1.1rem;
+ }
+
+ .settings form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .settings label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ flex-basis: 100%;
+ }
+
+ .settings label:has(> input[type="checkbox"]) {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .settings form > input,
+ .settings form > select {
+ flex: 1 1 12rem;
+ }
+
+ .settings main ul {
+ padding-left: 1.25rem;
+ }
+
+ .settings fieldset {
+ flex-basis: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin: 0 0 1rem;
+ padding: 0;
+ border: none;
+ }
+
+ .settings legend {
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ }
+}
diff --git a/apps/web/src/styles/theme.css b/apps/web/src/styles/theme.css
new file mode 100644
index 0000000..ea445e1
--- /dev/null
+++ b/apps/web/src/styles/theme.css
@@ -0,0 +1,309 @@
+/*
+ * The theme: every value the app's look is made of. Change the look here, and only here.
+ *
+ * A quiet, native-feeling window, after Ryo Lu's "Closer to the Material" and calm technology.
+ *
+ * - A near-white sidebar and a white content panel fill the window side by side, split by a
+ * faint line, with the sidebar's top row and the chat's header on one line.
+ * - Things you can touch are small white cards with a hairline border, flat on the panel.
+ * - Status is a small badge: pale green with dark green text for what is live or good, grey for
+ * what is done, pale red for what failed.
+ * - The model's work is a quiet numbered timeline of faintly tinted rows, below the cards, each
+ * step opening to show exactly what happened. Tool input and output are in mono; everything else
+ * is the system sans.
+ * - Slow, small motion: new messages fade in, live work breathes. It all stops under
+ * prefers-reduced-motion.
+ *
+ * Every color is light-dark(, ), so it follows the page's color scheme: the system's,
+ * or the one chosen in Preferences. Every color is a step of the Gray, Green, Red, or Amber palette
+ * from oklch.fyi/color-palettes, the Radix scales in OKLCH, each with a light and a dark scale of
+ * twelve steps. The reds are the Red scale turned to a warmer hue, with its ink retuned to a true
+ * red.
+ *
+ * Tailwind's own palette, type scale, radii, shadows, easings, animations, widths, and breakpoints
+ * are cleared, so a component can only name the values below. specs/styling.md has the rules.
+ */
+
+@theme static {
+ --color-*: initial;
+ --text-*: initial;
+ --font-*: initial;
+ --radius-*: initial;
+ --shadow-*: initial;
+ --inset-shadow-*: initial;
+ --drop-shadow-*: initial;
+ --text-shadow-*: initial;
+ --ease-*: initial;
+ --animate-*: initial;
+ --container-*: initial;
+ --breakpoint-*: initial;
+
+ /* The window frame, the panel inside it, and the cards on the panel. */
+ --color-chrome: light-dark(oklch(0.982 0 0), oklch(0.162 0 0));
+ --color-panel: light-dark(oklch(0.993 0 0), oklch(0.195 0 0));
+ --color-sidebar: light-dark(oklch(0.982 0 0), oklch(0.162 0 0));
+ --color-surface: light-dark(oklch(0.94 0 0), oklch(0.254 0 0));
+ /* A faint tint for asides on the panel, a step below a card. */
+ --color-sunken: light-dark(oklch(0.982 0 0), oklch(0.254 0 0));
+ /* A card of settings: a grey tint with no edge, as in system settings, a touch deeper than the
+ sunken tint in light so it shows on the panel. Muted ink clears 5.5:1 on it. */
+ --color-card: light-dark(oklch(0.975 0 0), oklch(0.225 0 0));
+ /* The well a quote sits in, a step darker than the sunken tint so it shows inside a card too. */
+ --color-well: light-dark(oklch(0.961 0 0), oklch(0.302 0 0));
+ --color-raised: light-dark(oklch(1 0 0), oklch(0.195 0 0));
+ --color-raised-hover: light-dark(oklch(0.993 0 0), oklch(0.254 0 0));
+ /* Every floating menu, a step lighter than the panel. */
+ --color-lifted: light-dark(oklch(1 0 0), oklch(0.254 0 0));
+ /* Your message's bubble, the panel with a faint tint of the accent. */
+ --color-bubble: color-mix(in srgb, var(--color-accent) 12%, var(--color-panel));
+ --color-hover: light-dark(oklch(0.918 0 0), oklch(0.302 0 0));
+ /* The sidebar's rows: a faint tint when hovered, and a firmer one for the search field and a
+ row's menu button. The open row's tint is fainter still, so it never reads as New chat's. */
+ --color-row-hover: light-dark(#0000000a, #ffffff0a);
+ --color-row-active: light-dark(#00000012, #ffffff14);
+ --color-row-current: light-dark(#00000007, #ffffff08);
+ /* The hovered row's tint as a solid color, for an edge that must match it. */
+ --color-row-hover-solid: color-mix(in srgb, var(--color-sidebar), light-dark(#000, #fff) 4%);
+ /* A switch's sliding knob. */
+ --color-knob: light-dark(oklch(1 0 0), oklch(0.933 0 0));
+ /* The empty chat's illustration: a light face inside a slightly darker rim. */
+ --color-illo-face: light-dark(oklch(0.961 0 0), oklch(0.254 0 0));
+ --color-illo-rim: light-dark(oklch(0.918 0 0), oklch(0.302 0 0));
+ --color-illo-back: light-dark(oklch(0.94 0 0), oklch(0.302 0 0));
+ /* The logo's bubble letters: a face inside a rim. */
+ --color-logo-face: light-dark(#ffffff, #111110);
+ --color-logo-rim: light-dark(#b9b8b0, #8c8b85);
+
+ /* Soft grey ink, never black. */
+ --color-ink: light-dark(oklch(0.375 0 0), oklch(0.933 0 0));
+ --color-ink-strong: light-dark(oklch(0.375 0 0), oklch(0.933 0 0));
+ --color-prose: light-dark(oklch(0.375 0 0), oklch(0.863 0 0));
+ /* Muted ink still clears 4.5:1 on the panel, the frame, the sunken tint, and the grey fills of
+ badges and hovered rows. */
+ --color-muted: light-dark(oklch(0.503 0 0), oklch(0.72 0 0));
+ /* Quieter ink for text on the panel or a card alone, such as a reply's model name and the
+ composer's model. It clears 4.5:1 on the panel, 4.56:1 light and 4.63:1 dark, but not on
+ tints or fills. The light step sits between the Gray scale's steps 10 and 11, since step 10
+ falls short. */
+ --color-muted-quiet: light-dark(oklch(0.56 0 0), oklch(0.6 0 0));
+ /* For placeholders, markers, and dots, never for text someone has to read. An icon on a card
+ may take it, since it still clears 3:1 there. */
+ --color-faint: light-dark(oklch(0.649 0 0), oklch(0.549 0 0));
+ /* Icon buttons that stay quiet until hovered, such as a message's actions. A control's icon
+ needs 3:1, not text's 4.5:1, and this still clears 3.2:1 on the panel, the frame, the sunken
+ tint, and a card. Never for text. */
+ --color-icon-quiet: light-dark(oklch(0.606 0 0), oklch(0.549 0 0));
+ /* A chat's title in the list, a touch softer than ink, so the open chat and one with a reply
+ not yet seen stand out in full ink. */
+ --color-row-ink: light-dark(oklch(0.47 0 0), oklch(0.8 0 0));
+ /* The list's section headings, such as Today and Tags, in the quietest grey that still clears
+ 4.5:1 on the sidebar once rounded to sRGB: 4.5:1 light and 4.62:1 dark. */
+ --color-section-ink: light-dark(oklch(0.555 0 0), oklch(0.585 0 0));
+ /* The quietest text on the steps' sunken tint, such as a thought's Show more, a visible step
+ under muted ink that still clears 4.5:1 there once rounded to sRGB: 4.5:1 light and 4.61:1
+ dark. The tint is lighter than the sidebar in dark, so section ink falls short on it. */
+ --color-tint-quiet: light-dark(oklch(0.555 0 0), oklch(0.635 0 0));
+
+ --color-line: light-dark(oklch(0.894 0 0), oklch(0.302 0 0));
+ /* A field's border while you type in it. */
+ --color-line-strong: light-dark(oklch(0.865 0 0), oklch(0.348 0 0));
+ /* The line between the sidebar and the panel, wider and quieter than a card's border. */
+ --color-divider: light-dark(oklch(0.961 0 0), oklch(0.195 0 0));
+ /* The darker bottom edge under a floating menu. */
+ --color-bevel: light-dark(oklch(0.865 0 0), oklch(0.162 0 0));
+ /* The edge drawn inside a picture: a shade of its own colors rather than a grey frame. */
+ --color-image-edge: light-dark(oklch(0 0 0 / 0.1), oklch(1 0 0 / 0.1));
+
+ /* Green for what is live or good and for the main action, with dark green ink on its pale
+ badges. */
+ --color-accent: light-dark(oklch(0.762 0.153 145), oklch(0.843 0.107 145));
+ --color-accent-strong: light-dark(oklch(0.464 0.143 145), oklch(0.843 0.107 145));
+ --color-accent-soft: light-dark(oklch(0.931 0.062 145), oklch(0.322 0.103 145));
+ --color-accent-soft-hover: light-dark(oklch(0.902 0.082 145), oklch(0.367 0.125 145));
+ --color-accent-border: light-dark(oklch(0.868 0.102 145), oklch(0.417 0.145 145));
+ /* Green for small marks, such as live dots, dark enough to clear 3:1 on any panel. */
+ --color-accent-mark: light-dark(oklch(0.579 0.179 145), oklch(0.676 0.167 145));
+ /* Dark ink on an accent fill, in both modes. */
+ --color-on-accent: oklch(0.348 0.095 145);
+ --color-focus: var(--color-accent-mark);
+ /* A true red, warmer than the crimson the Red scale leans to, so it never reads as pink or
+ maroon. Its ink clears 4.5:1 on every panel and on its own pale fills, hovered too. */
+ --color-danger: light-dark(oklch(0.52 0.19 25), oklch(0.7 0.16 25));
+ --color-danger-soft: light-dark(oklch(0.946 0.051 25), oklch(0.227 0.041 25));
+ --color-danger-soft-hover: light-dark(oklch(0.924 0.067 25), oklch(0.295 0.082 25));
+ --color-danger-border: light-dark(oklch(0.895 0.083 25), oklch(0.348 0.111 25));
+ /* A softer red for the detail under a red heading, still clearing 4.5:1 on the panel. */
+ --color-danger-muted: color-mix(in srgb, var(--color-danger) 90%, var(--color-panel));
+ /* Pale amber for selected text and search matches, with amber-brown ink. */
+ --color-highlight: light-dark(oklch(0.973 0.052 75), oklch(0.288 0.072 75));
+ --color-highlight-ink: light-dark(oklch(0.534 0.141 75), oklch(0.894 0.11 75));
+
+ --font-ui:
+ ui-sans-serif, system-ui, -apple-system, "SF Pro Text", "Segoe UI", Roboto, "Helvetica Neue",
+ sans-serif;
+ --font-prose: var(--font-ui);
+ --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
+ --default-font-family: var(--font-ui);
+ --default-mono-font-family: var(--font-mono);
+
+ /* The type scale. Every size in the app is one of these steps, and code inside text sizes
+ itself against them in em. Weights are 400 for reading, 500 for controls and emphasis, and
+ 600 for headings and the main action. */
+ /* Badges, timestamps, counts, and small labels, at the 12px floor. */
+ --text-caption: 0.78rem;
+ /* Secondary text: hints, menus, step rows, and details under a name. */
+ --text-small: 0.86rem;
+ /* The sidebar, titles in a bar, and the controls around a chat. */
+ --text-ui: 0.92rem;
+ /* Messages, replies, and the composer. */
+ --text-body: 1rem;
+ /* A section's heading. */
+ --text-heading: 1.25rem;
+ /* The settings title beside its sections, and each section's own title. */
+ --text-title: 1.5rem;
+ /* The app's name on the login page. */
+ --text-display: 1.75rem;
+
+ /* Where a surface holds rounded things at an even inset, its corner is theirs plus the inset
+ and its border, so the curves nest. */
+ --radius-panel: 0.9rem;
+ --radius-card: 0.65rem;
+ --radius-small: 0.55rem;
+ --radius-badge: 0.35rem;
+ --radius-row: 0.4rem;
+ /* A floating menu, around rows at the menu's padding, inside an edge as wide as the line
+ between the sidebar and the panel. */
+ --radius-menu: calc(var(--radius-row) + var(--menu-pad) + var(--menu-edge));
+ /* Your message's bubble. */
+ --radius-bubble: 1.5rem;
+ /* The composer, nesting its round Send button in its corner. */
+ --radius-composer: calc(var(--spacing-composer-button) / 2 + 0.55rem + 1px);
+
+ /* A floating menu: a bevel under it and a soft shadow. */
+ --shadow-float: 0 1px 0 var(--color-bevel), 0 10px 30px -14px light-dark(#0000002e, #000a);
+ /* A part of the sidebar lifted over it, such as the account menu: a soft shadow with no bevel,
+ since its edge is the sidebar's own line. */
+ --shadow-lift: 0 12px 32px -16px light-dark(#0000002e, #000c);
+
+ /* Spacing is Tailwind's scale of quarter rems. These are the sizes the design names. */
+ --spacing: 0.25rem;
+ /* Icons are 16px at the 15px design size, and grow with the text. */
+ --spacing-icon: calc(16rem / 15);
+ --spacing-sidebar: 17.75rem;
+ /* The space between the window's edge and the sidebar's rows, and the settings nav's. */
+ --spacing-sidebar-pad: 0.6rem;
+ /* The height of a row in the sidebar, and of its search. */
+ --spacing-row: 2.2rem;
+ /* The sidebar's top row and the chat's header share one height, so the app's name and the
+ chat's title sit on one line across the window. */
+ --spacing-bar: 3.5rem;
+ /* The square icon button that shows or hides the sidebar. */
+ --spacing-toggle: 2rem;
+ /* The composer's round buttons. */
+ --spacing-composer-button: 2rem;
+ /* The space beside the conversation and the settings, narrower on a phone. */
+ --spacing-gutter: 1.5rem;
+ /* The space inside a settings card, the same on every side, around each of its rows. */
+ --spacing-card-pad: 1rem;
+ /* The gap inside a turn, between your message and its reply, and the much larger one between
+ turns, so each exchange reads as one unit. Each message's footer row adds about 1.85rem to
+ both, which puts the gaps you see at about 2.5 to 1. */
+ --spacing-within: 0.25rem;
+ --spacing-turn: 3.5rem;
+
+ /* The conversation's column, and every settings and admin section, so moving between them
+ doesn't jump. */
+ --container-column: 44rem;
+ --container-settings: 44rem;
+ /* Below this a conversation or settings section lays itself out for a narrow panel. */
+ --container-narrow: 34rem;
+ /* Below this the sidebar becomes a drawer. */
+ --breakpoint-wide: 45rem;
+
+ /* Motion eases out and never overshoots. */
+ --ease-out: cubic-bezier(0.25, 0.8, 0.25, 1);
+ /* What arrives fades in; live work breathes at the slow pace. */
+ --animate-fade-in: fade-in var(--dur-enter) var(--ease-out) backwards;
+ --animate-breathe: breathe var(--slow) ease-in-out infinite;
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ }
+
+ @keyframes fade-out {
+ to {
+ opacity: 0;
+ }
+ }
+
+ @keyframes panel-in {
+ from {
+ opacity: 0;
+ transform: translateY(calc(-1 * var(--rise)));
+ }
+ }
+
+ @keyframes panel-up {
+ from {
+ opacity: 0;
+ transform: translateY(var(--rise));
+ }
+ }
+
+ @keyframes landed {
+ from,
+ 15% {
+ box-shadow: 0 0 0 2px var(--color-accent-mark);
+ }
+ }
+
+ @keyframes breathe {
+ 50% {
+ opacity: 0.55;
+ }
+ }
+}
+
+/* Values with no Tailwind namespace, used as utility-(--name). */
+@layer theme {
+ :root {
+ /* Feedback answers a touch, layout moves things into place, and enter fades in what arrives.
+ A panel opening from a control arrives a little quicker, and a layer over the whole chat,
+ like Jump to's, comes and goes at a calmer pace. */
+ --dur-feedback: 140ms;
+ --dur-panel: 180ms;
+ --dur-layout: 220ms;
+ --dur-enter: 240ms;
+ --dur-layer: 300ms;
+ /* How far a panel rises toward its place as it opens. */
+ --rise: 4px;
+ /* How long a reply waits quietly before it shows that it is waiting, so a quick one never
+ flashes a waiting state. */
+ --wait-quiet: 400ms;
+ --slow: 2.8s;
+ /* A floating menu's padding, the gap between New chat and the search button, and its edge. */
+ --menu-pad: 0.35rem;
+ --menu-edge: 2px;
+ /* Icon strokes are drawn in screen pixels whatever the icon's size, at the weight of the text
+ beside them: regular text, or the medium text of a control. */
+ --icon-stroke: 1.25;
+ --icon-stroke-control: 1.75;
+ /* iOS zooms the page into a field whose text is under 16px, so on a touch screen fields never
+ go below it. */
+ --input-floor: 0px;
+ }
+
+ @media (pointer: coarse) {
+ :root {
+ --input-floor: 16px;
+ }
+ }
+
+ @media (width < 45rem) {
+ :root {
+ --spacing-gutter: 1rem;
+ }
+ }
+}
diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css
deleted file mode 100644
index 31f9ecd..0000000
--- a/apps/web/src/theme.css
+++ /dev/null
@@ -1,527 +0,0 @@
-/* A stand-in theme over the barebones UI. Colors follow the system light or dark mode. */
-
-:root {
- color-scheme: light dark;
- --bg: light-dark(#ffffff, #212121);
- --sidebar: light-dark(#f9f9f9, #171717);
- --surface: light-dark(#f4f4f4, #2f2f2f);
- --hover: light-dark(#ececec, #3a3a3a);
- --border: light-dark(#e3e3e3, #3d3d3d);
- --text: light-dark(#0d0d0d, #ececec);
- --muted: light-dark(#6b6b6b, #a4a4a4);
- --accent: light-dark(#0d0d0d, #ececec);
- --on-accent: light-dark(#ffffff, #0d0d0d);
- --danger: light-dark(#c62828, #f28b82);
- --radius: 0.75rem;
- --column: 48rem;
- --settings-column: 92rem;
- font-family:
- ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
- font-size: 15px;
- line-height: 1.6;
-}
-
-* {
- box-sizing: border-box;
-}
-
-body {
- margin: 0;
- background: var(--bg);
- color: var(--text);
-}
-
-a {
- color: inherit;
-}
-
-h1,
-h2,
-h3 {
- line-height: 1.3;
-}
-
-button,
-input,
-select,
-textarea {
- font: inherit;
- color: inherit;
-}
-
-button {
- background: transparent;
- border: 1px solid var(--border);
- border-radius: 0.5rem;
- padding: 0.3rem 0.7rem;
- cursor: pointer;
-}
-
-button:hover:not(:disabled) {
- background: var(--hover);
-}
-
-button:disabled {
- opacity: 0.4;
- cursor: default;
-}
-
-button[type="submit"] {
- background: var(--accent);
- border-color: var(--accent);
- color: var(--on-accent);
-}
-
-button[type="submit"]:hover:not(:disabled) {
- background: var(--accent);
- opacity: 0.85;
-}
-
-input:not([type="checkbox"], [type="file"]),
-select,
-textarea {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: 0.5rem;
- padding: 0.35rem 0.6rem;
-}
-
-input::file-selector-button {
- font: inherit;
- color: inherit;
- background: transparent;
- border: 1px solid var(--border);
- border-radius: 0.5rem;
- padding: 0.3rem 0.7rem;
- margin-right: 0.5rem;
- cursor: pointer;
-}
-
-:focus-visible {
- outline: 2px solid var(--muted);
- outline-offset: 1px;
-}
-
-[role="alert"] {
- color: var(--danger);
-}
-
-pre {
- background: var(--surface);
- border-radius: 0.5rem;
- padding: 0.75rem;
- overflow-x: auto;
- white-space: pre-wrap;
- font-size: 0.85em;
-}
-
-details {
- color: var(--muted);
- margin: 0.5rem 0;
-}
-
-summary {
- cursor: pointer;
-}
-
-/* Layout: a fixed sidebar, and a main column that scrolls on its own. */
-
-.layout {
- gap: 0;
- height: 100vh;
- min-height: 0;
-}
-
-.layout > main {
- overflow-y: auto;
- display: flex;
- flex-direction: column;
-}
-
-.sidebar {
- flex-basis: 16rem;
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- padding: 0.75rem;
- background: var(--sidebar);
- border-right: 1px solid var(--border);
- overflow-y: auto;
-}
-
-.sidebar ul {
- list-style: none;
- margin: 0.5rem 0;
- padding: 0;
- flex: 1;
-}
-
-.sidebar a {
- display: block;
- padding: 0.4rem 0.6rem;
- border-radius: 0.5rem;
- text-decoration: none;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.sidebar a:hover,
-.sidebar a[aria-current="page"] {
- background: var(--hover);
-}
-
-.chats .sidebar > input[type="search"] {
- margin: 0.25rem 0;
-}
-
-.search-results {
- flex: 1;
-}
-
-.search-results > p {
- color: var(--muted);
- font-size: 0.85rem;
- padding: 0 0.6rem;
-}
-
-.search-results a {
- white-space: normal;
-}
-
-.search-results small {
- color: var(--muted);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-
-mark {
- background: light-dark(#fff3a3, #5c4d00);
- color: inherit;
- border-radius: 0.2rem;
-}
-
-.sidebar > button {
- text-align: left;
-}
-
-.banner {
- margin: 0.75rem auto 0;
- padding: 0.5rem 0.75rem;
- background: var(--surface);
- border-radius: 0.5rem;
-}
-
-main > p {
- margin: auto;
- color: var(--muted);
-}
-
-/* Conversation */
-
-.conversation {
- width: 100%;
- max-width: var(--column);
- margin: 0 auto;
- padding: 0 1rem;
- flex: 1;
- display: flex;
- flex-direction: column;
-}
-
-.conversation > header {
- position: sticky;
- top: 0;
- z-index: 1;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
- padding: 0.75rem 0;
- background: var(--bg);
-}
-
-.conversation > header h2 {
- font-size: 1rem;
- font-weight: 600;
- margin: 0 auto 0 0;
-}
-
-.conversation > header h2 button {
- font-size: 0.8rem;
- font-weight: normal;
- border: none;
- color: var(--muted);
-}
-
-.conversation > header form {
- display: flex;
- gap: 0.5rem;
- margin-right: auto;
-}
-
-.conversation fieldset {
- flex-basis: 100%;
- display: flex;
- flex-wrap: wrap;
- gap: 0.5rem;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 0.75rem;
-}
-
-.conversation fieldset input {
- flex: 1;
- min-width: 12rem;
-}
-
-/* Messages: the user's in a bubble on the right, the assistant's as plain text. */
-
-.message {
- margin: 1rem 0;
-}
-
-.message > header strong {
- display: none;
-}
-
-.message > header {
- font-size: 0.8rem;
- color: var(--muted);
-}
-
-.message.user {
- align-self: flex-end;
- max-width: 80%;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
-}
-
-.message.user > .text {
- background: var(--surface);
- border-radius: 1.25rem;
- padding: 0.6rem 1rem;
- margin: 0;
-}
-
-.markdown :not(pre) > code {
- background: var(--surface);
- border-radius: 0.3rem;
- padding: 0.1rem 0.3rem;
- font-size: 0.85em;
-}
-
-.markdown pre {
- white-space: pre;
-}
-
-.markdown table {
- border-collapse: collapse;
- display: block;
- overflow-x: auto;
-}
-
-.markdown th,
-.markdown td {
- border: 1px solid var(--border);
- padding: 0.3rem 0.6rem;
-}
-
-.markdown blockquote {
- border-left: 3px solid var(--border);
- color: var(--muted);
- margin: 0;
- padding-left: 1rem;
-}
-
-.siblings {
- display: inline-flex;
- align-items: center;
- gap: 0.25rem;
-}
-
-.siblings button,
-.message > footer button {
- border: none;
- color: var(--muted);
- font-size: 0.8rem;
- padding: 0.15rem 0.4rem;
-}
-
-.message > footer {
- display: flex;
- align-items: center;
- gap: 0.25rem;
- opacity: 0;
- transition: opacity 0.15s;
-}
-
-.message:hover > footer,
-.message > footer:focus-within {
- opacity: 1;
-}
-
-.message > footer select {
- font-size: 0.8rem;
- padding: 0.1rem 0.3rem;
- border: none;
- color: var(--muted);
-}
-
-.message > ul {
- font-size: 0.85rem;
- padding-left: 1.25rem;
-}
-
-/* Composer: one rounded box pinned to the bottom of the column. */
-
-.composer {
- position: sticky;
- bottom: 0;
- margin-top: auto;
- padding-bottom: 1rem;
- background: linear-gradient(transparent, var(--bg) 1rem);
-}
-
-.composer textarea {
- display: block;
- width: 100%;
- min-height: 3.5rem;
- max-height: 40vh;
- resize: vertical;
- border: 1px solid var(--border);
- border-bottom: none;
- border-radius: 1.5rem 1.5rem 0 0;
- padding: 1rem 1.25rem 0.25rem;
- background: var(--surface);
-}
-
-.composer textarea:focus-visible {
- outline: none;
-}
-
-.composer > div {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
- padding: 0.5rem 0.75rem;
- background: var(--surface);
- border: 1px solid var(--border);
- border-top: none;
- border-radius: 0 0 1.5rem 1.5rem;
- font-size: 0.85rem;
-}
-
-.composer > div select {
- background: transparent;
- border: none;
- color: var(--muted);
-}
-
-.composer input[type="file"] {
- color: var(--muted);
- max-width: 14rem;
-}
-
-.composer > div > span {
- color: var(--muted);
-}
-
-.composer button[type="submit"] {
- margin-left: auto;
- border-radius: 1rem;
- padding: 0.3rem 1rem;
-}
-
-/* Login and settings */
-
-.login {
- max-width: 22rem;
- margin: 20vh auto 0;
- padding: 0 1rem;
- text-align: center;
-}
-
-.login form {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
-}
-
-.login label {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- text-align: left;
-}
-
-.login button {
- padding: 0.5rem;
-}
-
-.settings > main {
- padding: 0 1rem 4rem;
-}
-
-.settings main > section,
-.settings main > form {
- width: 100%;
- max-width: var(--settings-column);
- margin: 1.5rem auto 0;
- padding: 1.25rem;
- border: 1px solid var(--border);
- border-radius: var(--radius);
-}
-
-.settings h2 {
- margin-top: 0;
- font-size: 1.1rem;
-}
-
-.settings form {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 0.5rem;
-}
-
-.settings label {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
- flex-basis: 100%;
-}
-
-.settings label:has(> input[type="checkbox"]) {
- flex-direction: row;
- align-items: center;
- gap: 0.5rem;
-}
-
-.settings form > input,
-.settings form > select {
- flex: 1 1 12rem;
-}
-
-.settings main ul {
- padding-left: 1.25rem;
-}
-
-.settings fieldset {
- flex-basis: 100%;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
- margin: 0 0 1rem;
- padding: 0;
- border: none;
-}
-
-.settings legend {
- font-weight: 600;
- margin-bottom: 0.5rem;
-}
diff --git a/apps/web/tests/shared/theme.test.tsx b/apps/web/tests/shared/theme.test.tsx
new file mode 100644
index 0000000..e4fc8e1
--- /dev/null
+++ b/apps/web/tests/shared/theme.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { chooseTheme, storedTheme, themeKey, themeScript } from '../../src/shared/theme.ts'
+import { Logo } from '../../src/shared/ui/Logo.tsx'
+
+afterEach(() => {
+ localStorage.clear()
+ delete document.documentElement.dataset.theme
+})
+
+describe('the theme choice', () => {
+ it('is the system with nothing stored, or anything but light or dark', () => {
+ expect(storedTheme()).toBe('system')
+ localStorage.setItem(themeKey, 'sepia')
+ expect(storedTheme()).toBe('system')
+ })
+
+ it('fixes light or dark on the page and keeps it on the device', () => {
+ chooseTheme('dark')
+ expect(document.documentElement.dataset.theme).toBe('dark')
+ expect(storedTheme()).toBe('dark')
+
+ chooseTheme('system')
+ expect(document.documentElement.dataset.theme).toBeUndefined()
+ expect(localStorage.getItem(themeKey)).toBeNull()
+ })
+
+ it('is applied by the head script before the app loads', () => {
+ localStorage.setItem(themeKey, 'light')
+ new Function(themeScript)()
+ expect(document.documentElement.dataset.theme).toBe('light')
+ })
+})
+
+describe('Logo', () => {
+ it('draws the bubble letters under the app name', () => {
+ render()
+ const logo = screen.getByRole('img', { name: 'SCN Chat' })
+ const paths = logo.querySelectorAll('path')
+ expect(paths).toHaveLength(3)
+ for (const path of paths) expect(path.getAttribute('d')).toMatch(/^M/)
+ })
+})
diff --git a/apps/web/tests/styles/theme.test.ts b/apps/web/tests/styles/theme.test.ts
new file mode 100644
index 0000000..b8b5234
--- /dev/null
+++ b/apps/web/tests/styles/theme.test.ts
@@ -0,0 +1,71 @@
+import { readdirSync, readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { cn, themeNames } from '../../src/shared/cn.ts'
+
+// Read from disk, since the test run leaves stylesheets empty when they are imported.
+const src = join(import.meta.dirname, '../../src')
+const files = readdirSync(src, { recursive: true, encoding: 'utf8' }).map((path) => ({
+ path,
+ text: () => readFileSync(join(src, path), 'utf8'),
+}))
+const theme = readFileSync(join(src, 'styles/theme.css'), 'utf8')
+const sources = files.filter((file) => /\.tsx?$/.test(file.path))
+const sheets = files.filter(
+ (file) => file.path.endsWith('.css') && !/^styles\/(theme|legacy)\.css$/.test(file.path),
+)
+
+/** The names the theme defines in one Tailwind namespace, such as ui in --text-ui. */
+function namesIn(namespace: string) {
+ const pattern = new RegExp(`^\\s*--${namespace}-([a-z][a-z-]*):`, 'gm')
+ return [...new Set(Array.from(theme.matchAll(pattern), (match) => match[1]))]
+}
+
+// A color, or a length or duration, written into a utility instead of taken from the theme.
+const rawColor = /#[0-9a-f]{3,8}\b|\b(?:oklch|oklab|rgba?|hsla?|lab|lch)\(/i
+const rawLength = /\d(?:px|rem|em|vh|vw|dvh|svh|ch|ms|s|%)(?![a-z])/i
+// An arbitrary value such as p-[13px], or an arbitrary property such as [width:2rem].
+const arbitrary = /(?:-|^|[\s'"`:])\[([^\]\s'"`]+)\]/g
+
+describe('the theme', () => {
+ it('lists every name tailwind-merge needs in cn', () => {
+ expect(themeNames.text).toEqual(namesIn('text'))
+ expect(themeNames.font).toEqual(namesIn('font'))
+ expect(themeNames.radius).toEqual(namesIn('radius'))
+ expect(themeNames.shadow).toEqual(namesIn('shadow'))
+ expect(themeNames.spacing).toEqual(namesIn('spacing'))
+ expect(themeNames.container).toEqual(namesIn('container'))
+ expect(themeNames.ease).toEqual(namesIn('ease'))
+ expect(themeNames.animate).toEqual(namesIn('animate'))
+ expect(themeNames.breakpoint).toEqual(namesIn('breakpoint'))
+ })
+
+ it('lets cn tell a size from a color', () => {
+ expect(cn('text-muted text-ui', 'text-small')).toBe('text-muted text-small')
+ expect(cn('rounded-card p-2', 'rounded-row p-card-pad')).toBe('rounded-row p-card-pad')
+ })
+})
+
+describe('components', () => {
+ it('take colors and lengths from the theme, not arbitrary values', () => {
+ const found = sources.flatMap(({ path, text }) =>
+ Array.from(text().matchAll(arbitrary), (match) => match[1] ?? '')
+ .filter((value) => rawColor.test(value) || rawLength.test(value))
+ .map((value) => `${path}: [${value}]`),
+ )
+ expect(found).toEqual([])
+ })
+})
+
+describe('stylesheets', () => {
+ it('take colors from the theme', () => {
+ const found = sheets.flatMap(({ path, text }) =>
+ text()
+ .replace(/\/\*[\s\S]*?\*\//g, '')
+ .split('\n')
+ .filter((line) => rawColor.test(line))
+ .map((line) => `${path}: ${line.trim()}`),
+ )
+ expect(found).toEqual([])
+ })
+})
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index c37a45b..7035faf 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,4 +1,5 @@
import babel from '@rolldown/plugin-babel'
+import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
@@ -6,6 +7,7 @@ import { defineConfig } from 'vite'
// The server runs this config in middleware mode in development (apps/server/src/web.ts).
export default defineConfig({
plugins: [
+ tailwindcss(),
tanstackStart({ router: { addExtensions: true, quoteStyle: 'single', semicolons: false } }),
react(),
// React Compiler memoizes components and hooks, in the browser build only.
diff --git a/biome.json b/biome.json
index 25a0998..2e86b54 100644
--- a/biome.json
+++ b/biome.json
@@ -30,7 +30,16 @@
"linter": {
"enabled": true,
"rules": {
- "preset": "recommended"
+ "preset": "recommended",
+ "nursery": {
+ "useSortedClasses": {
+ "level": "error",
+ "fix": "safe",
+ "options": {
+ "functions": ["cn", "cva", "clsx"]
+ }
+ }
+ }
}
},
"assist": {
@@ -62,7 +71,7 @@
}
},
{
- "includes": ["apps/web/src/theme.css"],
+ "includes": ["apps/web/src/styles/legacy.css"],
"linter": {
"rules": {
"style": {
@@ -71,5 +80,10 @@
}
}
}
- ]
+ ],
+ "css": {
+ "parser": {
+ "tailwindDirectives": true
+ }
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index caca548..58a4aa6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -216,12 +216,21 @@ importers:
'@tanstack/react-start':
specifier: 1.168.60
version: 1.168.60(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(rolldown@1.2.11)(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1))
+ class-variance-authority:
+ specifier: 0.7.1
+ version: 0.7.1
+ clsx:
+ specifier: 2.1.1
+ version: 2.1.1
comlink:
specifier: 4.4.2
version: 4.4.2
hono:
specifier: 4.13.9
version: 4.13.9
+ lucide-react:
+ specifier: 1.47.0
+ version: 1.47.0(react@19.3.0)
react:
specifier: 19.3.0
version: 19.3.0
@@ -234,6 +243,9 @@ importers:
remark-gfm:
specifier: 4.0.1
version: 4.0.1
+ tailwind-merge:
+ specifier: 3.7.0
+ version: 3.7.0
devDependencies:
'@babel/core':
specifier: 7.29.7
@@ -244,6 +256,9 @@ importers:
'@scn-chat/server':
specifier: workspace:*
version: link:../server
+ '@tailwindcss/vite':
+ specifier: 4.3.3
+ version: 4.3.3(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1))
'@testing-library/jest-dom':
specifier: 7.0.1
version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.2(@types/node@24.19.0)(jsdom@30.1.1(@noble/hashes@1.8.0))(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)))
@@ -277,6 +292,9 @@ importers:
react-scan:
specifier: 0.5.3
version: 0.5.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
+ tailwindcss:
+ specifier: 4.3.3
+ version: 4.3.3
vite:
specifier: 8.3.1
version: 8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)
@@ -1182,6 +1200,100 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@tailwindcss/node@4.3.3':
+ resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [android]
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm]
+ os: [linux]
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+ bundledDependencies:
+ - '@napi-rs/wasm-runtime'
+ - '@emnapi/core'
+ - '@emnapi/runtime'
+ - '@tybys/wasm-util'
+ - '@emnapi/wasi-threads'
+ - tslib
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==}
+ engines: {node: '>= 20'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==}
+ engines: {node: '>= 20'}
+ cpu: [x64]
+ os: [win32]
+
+ '@tailwindcss/oxide@4.3.3':
+ resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==}
+ engines: {node: '>= 20'}
+
+ '@tailwindcss/vite@4.3.3':
+ resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==}
+ peerDependencies:
+ vite: ^5.2.0 || ^6 || ^7 || ^8
+
'@tanstack/devtools-event-client@0.4.4':
resolution: {integrity: sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw==}
engines: {node: '>=18'}
@@ -1760,10 +1872,17 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
+ class-variance-authority@0.7.1:
+ resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
@@ -1924,6 +2043,10 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
+ enhanced-resolve@5.26.0:
+ resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==}
+ engines: {node: '>=10.13.0'}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -2054,6 +2177,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
h3@2.0.1-rc.20:
resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==}
engines: {node: '>=20.11.1'}
@@ -2197,36 +2323,73 @@ packages:
resolution: {integrity: sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==}
engines: {node: '>=22.0.0'}
+ lightningcss-android-arm64@1.32.0:
+ resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [android]
+
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
+ lightningcss-darwin-arm64@1.32.0:
+ resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [darwin]
+
lightningcss-darwin-arm64@1.33.0:
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
+ lightningcss-darwin-x64@1.32.0:
+ resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [darwin]
+
lightningcss-darwin-x64@1.33.0:
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
+ lightningcss-freebsd-x64@1.32.0:
+ resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [freebsd]
+
lightningcss-freebsd-x64@1.33.0:
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm]
+ os: [linux]
+
lightningcss-linux-arm-gnueabihf@1.33.0:
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
+ lightningcss-linux-arm64-gnu@1.32.0:
+ resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
lightningcss-linux-arm64-gnu@1.33.0:
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
engines: {node: '>= 12.0.0'}
@@ -2234,6 +2397,13 @@ packages:
os: [linux]
libc: [glibc]
+ lightningcss-linux-arm64-musl@1.32.0:
+ resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
lightningcss-linux-arm64-musl@1.33.0:
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
engines: {node: '>= 12.0.0'}
@@ -2241,6 +2411,13 @@ packages:
os: [linux]
libc: [musl]
+ lightningcss-linux-x64-gnu@1.32.0:
+ resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
lightningcss-linux-x64-gnu@1.33.0:
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
engines: {node: '>= 12.0.0'}
@@ -2248,6 +2425,13 @@ packages:
os: [linux]
libc: [glibc]
+ lightningcss-linux-x64-musl@1.32.0:
+ resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
lightningcss-linux-x64-musl@1.33.0:
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
engines: {node: '>= 12.0.0'}
@@ -2255,18 +2439,34 @@ packages:
os: [linux]
libc: [musl]
+ lightningcss-win32-arm64-msvc@1.32.0:
+ resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [arm64]
+ os: [win32]
+
lightningcss-win32-arm64-msvc@1.33.0:
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
+ lightningcss-win32-x64-msvc@1.32.0:
+ resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
+ engines: {node: '>= 12.0.0'}
+ cpu: [x64]
+ os: [win32]
+
lightningcss-win32-x64-msvc@1.33.0:
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
+ lightningcss@1.32.0:
+ resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
+ engines: {node: '>= 12.0.0'}
+
lightningcss@1.33.0:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
@@ -2293,6 +2493,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+ lucide-react@1.47.0:
+ resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -2858,6 +3063,16 @@ packages:
style-to-object@1.0.14:
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
+ tailwind-merge@3.7.0:
+ resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==}
+
+ tailwindcss@4.3.3:
+ resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==}
+
+ tapable@2.3.3:
+ resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
+ engines: {node: '>=6'}
+
thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'}
@@ -3960,6 +4175,74 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@tailwindcss/node@4.3.3':
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ enhanced-resolve: 5.26.0
+ jiti: 2.7.0
+ lightningcss: 1.32.0
+ magic-string: 0.30.21
+ source-map-js: 1.2.1
+ tailwindcss: 4.3.3
+
+ '@tailwindcss/oxide-android-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-arm64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-darwin-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-freebsd-x64@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-arm64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-gnu@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-linux-x64-musl@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-wasm32-wasi@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-arm64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide-win32-x64-msvc@4.3.3':
+ optional: true
+
+ '@tailwindcss/oxide@4.3.3':
+ optionalDependencies:
+ '@tailwindcss/oxide-android-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-arm64': 4.3.3
+ '@tailwindcss/oxide-darwin-x64': 4.3.3
+ '@tailwindcss/oxide-freebsd-x64': 4.3.3
+ '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-arm64-musl': 4.3.3
+ '@tailwindcss/oxide-linux-x64-gnu': 4.3.3
+ '@tailwindcss/oxide-linux-x64-musl': 4.3.3
+ '@tailwindcss/oxide-wasm32-wasi': 4.3.3
+ '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
+ '@tailwindcss/oxide-win32-x64-msvc': 4.3.3
+
+ '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1))':
+ dependencies:
+ '@tailwindcss/node': 4.3.3
+ '@tailwindcss/oxide': 4.3.3
+ tailwindcss: 4.3.3
+ vite: 8.3.1(@types/node@24.19.0)(jiti@2.7.0)(yaml@2.9.1)
+
'@tanstack/devtools-event-client@0.4.4': {}
'@tanstack/form-core@1.33.5':
@@ -4541,12 +4824,18 @@ snapshots:
dependencies:
readdirp: 5.1.1
+ class-variance-authority@0.7.1:
+ dependencies:
+ clsx: 2.1.1
+
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
+ clsx@2.1.1: {}
+
code-block-writer@13.0.3: {}
comlink@4.4.2: {}
@@ -4681,6 +4970,11 @@ snapshots:
encodeurl@2.0.0: {}
+ enhanced-resolve@5.26.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.3.3
+
entities@4.5.0: {}
entities@7.0.1: {}
@@ -4839,6 +5133,8 @@ snapshots:
gopd@1.2.0: {}
+ graceful-fs@4.2.11: {}
+
h3@2.0.1-rc.20:
dependencies:
rou3: 0.8.1
@@ -4984,39 +5280,88 @@ snapshots:
kysely@0.29.6: {}
+ lightningcss-android-arm64@1.32.0:
+ optional: true
+
lightningcss-android-arm64@1.33.0:
optional: true
+ lightningcss-darwin-arm64@1.32.0:
+ optional: true
+
lightningcss-darwin-arm64@1.33.0:
optional: true
+ lightningcss-darwin-x64@1.32.0:
+ optional: true
+
lightningcss-darwin-x64@1.33.0:
optional: true
+ lightningcss-freebsd-x64@1.32.0:
+ optional: true
+
lightningcss-freebsd-x64@1.33.0:
optional: true
+ lightningcss-linux-arm-gnueabihf@1.32.0:
+ optional: true
+
lightningcss-linux-arm-gnueabihf@1.33.0:
optional: true
+ lightningcss-linux-arm64-gnu@1.32.0:
+ optional: true
+
lightningcss-linux-arm64-gnu@1.33.0:
optional: true
+ lightningcss-linux-arm64-musl@1.32.0:
+ optional: true
+
lightningcss-linux-arm64-musl@1.33.0:
optional: true
+ lightningcss-linux-x64-gnu@1.32.0:
+ optional: true
+
lightningcss-linux-x64-gnu@1.33.0:
optional: true
+ lightningcss-linux-x64-musl@1.32.0:
+ optional: true
+
lightningcss-linux-x64-musl@1.33.0:
optional: true
+ lightningcss-win32-arm64-msvc@1.32.0:
+ optional: true
+
lightningcss-win32-arm64-msvc@1.33.0:
optional: true
+ lightningcss-win32-x64-msvc@1.32.0:
+ optional: true
+
lightningcss-win32-x64-msvc@1.33.0:
optional: true
+ lightningcss@1.32.0:
+ dependencies:
+ detect-libc: 2.1.2
+ optionalDependencies:
+ lightningcss-android-arm64: 1.32.0
+ lightningcss-darwin-arm64: 1.32.0
+ lightningcss-darwin-x64: 1.32.0
+ lightningcss-freebsd-x64: 1.32.0
+ lightningcss-linux-arm-gnueabihf: 1.32.0
+ lightningcss-linux-arm64-gnu: 1.32.0
+ lightningcss-linux-arm64-musl: 1.32.0
+ lightningcss-linux-x64-gnu: 1.32.0
+ lightningcss-linux-x64-musl: 1.32.0
+ lightningcss-win32-arm64-msvc: 1.32.0
+ lightningcss-win32-x64-msvc: 1.32.0
+
lightningcss@1.33.0:
dependencies:
detect-libc: 2.1.2
@@ -5051,6 +5396,10 @@ snapshots:
dependencies:
yallist: 3.1.1
+ lucide-react@1.47.0(react@19.3.0):
+ dependencies:
+ react: 19.3.0
+
lz-string@1.5.0: {}
magic-string@0.30.21:
@@ -5866,6 +6215,12 @@ snapshots:
dependencies:
inline-style-parser: 0.2.7
+ tailwind-merge@3.7.0: {}
+
+ tailwindcss@4.3.3: {}
+
+ tapable@2.3.3: {}
+
thread-stream@4.2.0:
dependencies:
real-require: 1.0.0
diff --git a/specs/styling.md b/specs/styling.md
index 5ba9157..8eda28f 100644
--- a/specs/styling.md
+++ b/specs/styling.md
@@ -48,7 +48,7 @@ Every color is `light-dark(, )` in oklch, so dark mode needs no `da
| Kind | Tokens | Utilities |
|---|---|---|
-| Surfaces | `chrome`, `panel` (was `--bg`), `sidebar`, `surface`, `sunken`, `card` (was `--group`), `well` (was `--quote-well`), `raised`, `raised-hover`, `lifted`, `bubble`, `hover`, `row-hover`, `row-active`, `row-current`, `row-hover-solid`, `knob`, `illo-face`, `illo-rim`, `illo-back` | `bg-*` |
+| Surfaces | `chrome`, `panel` (was `--bg`), `sidebar`, `surface`, `sunken`, `card` (was `--group`), `well` (was `--quote-well`), `raised`, `raised-hover`, `lifted`, `bubble`, `hover`, `row-hover`, `row-active`, `row-current`, `row-hover-solid`, `knob`, `illo-face`, `illo-rim`, `illo-back`, `logo-face`, `logo-rim` | `bg-*`, `fill-*` |
| Ink | `ink` (was `--text`), `ink-strong` (was `--strong`), `prose`, `muted`, `muted-quiet`, `faint`, `icon-quiet`, `row-ink`, `section-ink`, `tint-quiet` | `text-*` |
| Lines | `line` (was `--border`), `line-strong`, `divider`, `bevel`, `image-edge` | `border-*`, `outline-*` |
| Accent | `accent`, `accent-strong`, `accent-soft`, `accent-soft-hover`, `accent-border`, `accent-mark`, `on-accent`, `focus` (was `--focus-ring`) | any color utility |
@@ -56,16 +56,16 @@ Every color is `light-dark(, )` in oklch, so dark mode needs no `da
| Highlight | `highlight`, `highlight-ink` | any color utility |
| Fonts | `--font-ui`, `--font-prose`, `--font-mono`, with `--default-font-family` set to the UI font | `font-*` |
| Type | `--text-caption`, `small`, `ui`, `body`, `heading`, `title`, `display` | `text-*` |
-| Radius | `--radius-panel` (was `--radius`), `card`, `small`, `badge`, `row`, `menu`, `bubble`, `composer`, `pill` | `rounded-*` |
+| Radius | `--radius-panel` (was `--radius`), `card`, `small`, `badge`, `row`, `menu`, `bubble`, `composer`, with Tailwind's own `rounded-full` for pills | `rounded-*` |
| Shadows | `--shadow-float`, `--shadow-lift` | `shadow-*` |
| Motion | `--ease-out`, and `--animate-*` with their `@keyframes`: fade-in, fade-out, panel-in, panel-up, landed, breathe | `ease-*`, `animate-*` |
-| Named sizes | `--spacing-sidebar`, `sidebar-pad`, `row`, `bar`, `toggle`, `composer-button`, `gutter`, `card-pad`, `within`, `turn` | `w-*`, `h-*`, `p-*`, `gap-*`, … |
+| Named sizes | `--spacing-icon`, `sidebar`, `sidebar-pad`, `row`, `bar`, `toggle`, `composer-button`, `gutter`, `card-pad`, `within`, `turn` | `size-*`, `w-*`, `h-*`, `p-*`, `gap-*`, … |
| Widths | `--container-column`, `--container-settings`, `--container-narrow` (34rem) | `max-w-*`, `@max-narrow:` |
| Breakpoint | `--breakpoint-wide: 45rem`, where the sidebar becomes a drawer below | `wide:`, `max-wide:` |
Ink colors are named `ink` because a color named `text` would make `text-text`, and the type scale already owns `text-*`.
-Durations, `--menu-pad`, `--menu-edge`, `--rise`, `--wait-quiet`, and `--slow` have no Tailwind namespace. They are plain custom properties on `:root` in `theme.css`, used as `duration-(--dur-panel)`. A token built from others, like `bubble` or `radius-menu`, refers to them with `var()`, which resolves where it is used.
+Durations, `--menu-pad`, `--menu-edge`, `--rise`, `--wait-quiet`, `--slow`, and the icon strokes `--icon-stroke` and `--icon-stroke-control` have no Tailwind namespace. They are plain custom properties on `:root` in `theme.css`, used as `duration-(--dur-panel)`. A token built from others, like `bubble` or `radius-menu`, refers to them with `var()`, which resolves where it is used.
Two tokens change with the device, in `theme.css` below the tokens: `--spacing-gutter` narrows to 1rem below the `wide` breakpoint, and `--input-floor` is 16px on a coarse pointer, so iOS never zooms into a field.
@@ -104,7 +104,7 @@ Fenced code in replies is highlighted by Shiki, loaded lazily by `parts/markdown
The port re-applies `design-reference` area by area onto main's refactored components, not as a merge, since nearly every web file changed on both sides. Its behavior comes along, refactored to main's conventions: forms on the form kit, data through loaders, and no React Compiler bailouts. Its specs come into the feature specs, corrected where they had fallen behind its code. The plan lists each feature in the order it is ported.
-Until the port is done, main's stand-in sheets load in a `legacy` layer, ordered after `base` and before `utilities`. Pages not yet ported keep their look over Preflight, and a ported component's utilities win over them. The legacy layer is deleted once every area is ported.
+Until the port is done, main's stand-in sheets load from `src/styles/legacy.css` in a `legacy` layer, ordered after `base` and before `utilities`, with the browser's own defaults for headings, margins, and lists restored over Preflight, so pages not yet ported keep their look. The sheet is wrapped in `@scope (:root) to ([data-ported])`, so the root element of each ported area carries `data-ported` and shows only its own styles. The sheet, its import, and every `data-ported` are deleted once every area is ported.
## Scope Boundaries
@@ -119,7 +119,8 @@ Until the port is done, main's stand-in sheets load in a `legacy` layer, ordered
- **No `dark:` variant.** With `light-dark()` in every token, a component never needs to know the mode. The picker only sets `color-scheme`.
- **Picker on the device.** The setting is kept per device rather than in the preferences record, so it applies before the first paint without waiting for the PDS, and it can differ between devices.
- **Components over `@apply`.** A reused look is a component, so it can take variants and carry its markup and accessibility along with its look.
-- **The guard.** A test fails when a component carries a raw color or a length in an arbitrary value, so values stay in the theme.
+- **The guard.** A test fails when a component carries a raw color or a length in an arbitrary value, or a stylesheet other than the theme carries a raw color, so values stay in the theme. Biome's `noTailwindArbitraryValue` was too broad, since it also rejects layout values like `grid-cols-[auto_1fr]`. Biome's `useSortedClasses` keeps class lists in one order, in `className` and in `cn`, `cva`, and `clsx` calls.
+- **Icons.** `src/shared/icons.tsx` wraps the app's own puffy icons and Lucide's in one component each, sized by `size-icon` and stroked in screen pixels through `vector-effect: non-scaling-stroke`, heavier inside a button or link. An icon takes a `className` for its size or color where it is used.
## Acceptance Criteria
@@ -127,7 +128,7 @@ Until the port is done, main's stand-in sheets load in a `legacy` layer, ordered
- [ ] Tailwind's default palette, type scale, radii, shadows, and breakpoints produce no utilities.
- [ ] Changing a color token in `theme.css` changes it everywhere it is used, in light and dark.
- [ ] Appearance in Preferences switches between System, Light, and Dark at once, and the choice survives a reload with no flash, on server-rendered and client-only routes.
-- [ ] The guard test fails on a raw color or arbitrary length in a component.
+- [x] The guard test fails on a raw color or arbitrary length in a component.
- [ ] Every area matches `design-reference` in light and dark, at desktop width and at 390px.
- [ ] Animations stop under reduced motion.
- [ ] A fenced code block in a reply is highlighted in theme colors in both modes, and a block in an unknown language shows as plain monospace.
@@ -136,7 +137,8 @@ Until the port is done, main's stand-in sheets load in a `legacy` layer, ordered
## Files
- `apps/web/src/styles/`
-- `apps/web/src/shared/ui/`, `apps/web/src/shared/cn.ts`, `apps/web/src/shared/icons.tsx`, `apps/web/src/shared/useInstantThemeSwitch.ts`
+- `apps/web/src/shared/ui/`, `apps/web/src/shared/cn.ts`, `apps/web/src/shared/icons.tsx`, `apps/web/src/shared/theme.ts`, `apps/web/src/shared/useInstantThemeSwitch.ts`, `apps/web/src/logo.svg`
+- `apps/web/tests/styles/theme.test.ts`, `apps/web/tests/shared/theme.test.tsx`
- `apps/web/src/parts/markdown.tsx`, `apps/web/src/parts/markdown.css`
- `apps/web/src/routes/__root.tsx`
- `apps/web/src/features/settings/pages/PreferencesSettings.tsx`