From 1ef423148dc0689870457fdd865df4024c4f1b76 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Tue, 13 Oct 2026 11:32:15 -0700 Subject: [PATCH] Lay the styling foundation on Tailwind v4. styles/theme.css holds every token from design-reference under Tailwind's namespaces, with Tailwind's own palette, scales, and breakpoints cleared so utilities can only name the theme, and styles/base.css styles only the document. Main's stand-in look moves to styles/legacy.css, in a layer between the base and the utilities, scoped to stop at any area marked data-ported and restoring the browser defaults Preflight removes, until the port is done. A System, Light, or Dark choice is kept on the device and applied by a script in the head before the first paint, and useInstantThemeSwitch switches without smearing for the system and other tabs. The icons come over from design-reference taking a class, the logo draws inline in theme colors from the favicon's file, cn merges classes knowing the theme's names, Biome sorts classes, and a test keeps raw colors and lengths out of components. --- apps/web/package.json | 8 +- apps/web/src/logo.svg | 17 + apps/web/src/routes/__root.tsx | 23 +- apps/web/src/shared/cn.ts | 51 ++ apps/web/src/shared/icons.tsx | 535 +++++++++++++++++ apps/web/src/shared/theme.ts | 58 ++ apps/web/src/shared/ui/Logo.tsx | 45 ++ apps/web/src/shared/useInstantThemeSwitch.ts | 22 + apps/web/src/styles.css | 18 - apps/web/src/styles/app.css | 12 + apps/web/src/styles/base.css | 72 +++ apps/web/src/styles/legacy.css | 582 +++++++++++++++++++ apps/web/src/styles/theme.css | 309 ++++++++++ apps/web/src/theme.css | 527 ----------------- apps/web/tests/shared/theme.test.tsx | 43 ++ apps/web/tests/styles/theme.test.ts | 71 +++ apps/web/vite.config.ts | 2 + biome.json | 20 +- pnpm-lock.yaml | 355 +++++++++++ specs/styling.md | 18 +- 20 files changed, 2224 insertions(+), 564 deletions(-) create mode 100644 apps/web/src/logo.svg create mode 100644 apps/web/src/shared/cn.ts create mode 100644 apps/web/src/shared/icons.tsx create mode 100644 apps/web/src/shared/theme.ts create mode 100644 apps/web/src/shared/ui/Logo.tsx create mode 100644 apps/web/src/shared/useInstantThemeSwitch.ts delete mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/styles/app.css create mode 100644 apps/web/src/styles/base.css create mode 100644 apps/web/src/styles/legacy.css create mode 100644 apps/web/src/styles/theme.css delete mode 100644 apps/web/src/theme.css create mode 100644 apps/web/tests/shared/theme.test.tsx create mode 100644 apps/web/tests/styles/theme.test.ts 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 @@ + +SCN + + + + + + + + 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) => ( +