feat(web): migrate to Next.js App Router and add design tokens (ADR 0018)
The Sprint 1 shell moves to /app with its own root layout so its dark theme does not leak. Figma tokens ported with light and dark parity. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014SdXCbrN5PtGExkpvMaMHx
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const css = readFileSync(resolve(process.cwd(), 'src/styles/tokens.css'), 'utf8');
|
||||
|
||||
/** Returns the declarations of the first block whose selector contains `selector`. */
|
||||
function block(selector: string): Record<string, string> {
|
||||
const start = css.indexOf(selector);
|
||||
if (start === -1) return {};
|
||||
const open = css.indexOf('{', start);
|
||||
const close = css.indexOf('}', open);
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of css.slice(open + 1, close).matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
|
||||
out[m[1]!] = m[2]!.trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const light = block(':root {');
|
||||
const dark = block(':root[data-theme="dark"]');
|
||||
const auto = block(':root:not([data-theme="light"])');
|
||||
|
||||
const COLOR_TOKENS = [
|
||||
'--color-bg-canvas', '--color-bg-surface', '--color-bg-sunken', '--color-bg-inverse',
|
||||
'--color-text-primary', '--color-text-secondary', '--color-text-tertiary', '--color-text-inverse',
|
||||
'--color-text-on-accent', '--color-text-link',
|
||||
'--color-border-subtle', '--color-border-default', '--color-border-strong',
|
||||
'--color-accent-default', '--color-accent-text', '--color-accent-subtle',
|
||||
'--color-status-working-dot', '--color-status-working-fg', '--color-status-working-bg',
|
||||
'--color-status-needs-you-dot', '--color-status-needs-you-fg', '--color-status-needs-you-bg',
|
||||
'--color-status-success-dot', '--color-status-success-fg', '--color-status-success-bg',
|
||||
'--color-diff-add-fg', '--color-diff-remove-fg', '--icon-color',
|
||||
];
|
||||
|
||||
describe('design tokens', () => {
|
||||
it('defines every color token for the light theme', () => {
|
||||
expect(COLOR_TOKENS.filter((t) => !(t in light))).toEqual([]);
|
||||
});
|
||||
|
||||
it('defines every color token for the dark theme (explicit override)', () => {
|
||||
expect(COLOR_TOKENS.filter((t) => !(t in dark))).toEqual([]);
|
||||
});
|
||||
|
||||
it('follows the device setting for dark unless the light theme is forced', () => {
|
||||
expect(COLOR_TOKENS.filter((t) => !(t in auto))).toEqual([]);
|
||||
expect(css).toMatch(/@media \(prefers-color-scheme: dark\)/);
|
||||
});
|
||||
|
||||
it('uses the Figma light values', () => {
|
||||
expect(light['--color-bg-canvas']).toBe('#f7f7f4');
|
||||
expect(light['--color-accent-default']).toBe('#e8590c');
|
||||
expect(light['--color-text-primary']).toBe('#14120b');
|
||||
});
|
||||
|
||||
it('uses the Figma dark values', () => {
|
||||
expect(dark['--color-bg-canvas']).toBe('#0e0e0c');
|
||||
expect(dark['--color-accent-default']).toBe('#ff7a33');
|
||||
});
|
||||
|
||||
it('keeps dark and automatic dark in agreement', () => {
|
||||
expect(auto).toEqual(dark);
|
||||
});
|
||||
|
||||
it('defines the space, radius and control scales', () => {
|
||||
expect(light['--space-4']).toBe('16px');
|
||||
expect(light['--radius-full']).toBe('999px');
|
||||
expect(light['--size-control-lg']).toBe('44px');
|
||||
});
|
||||
|
||||
it('defines the Figma type styles as classes', () => {
|
||||
for (const name of ['display-xl', 'display-l', 'display-m', 'title-l', 'title-m', 'title-s',
|
||||
'body-l', 'body-m', 'body-m-strong', 'label-m', 'label-s', 'caption', 'overline', 'mono-s']) {
|
||||
expect(css).toContain(`.t-${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('converts Figma percent tracking to em (Display/XL -3 is -0.03em)', () => {
|
||||
expect(css).toMatch(/\.t-display-xl\s*\{[^}]*letter-spacing:\s*-0\.03em/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import '../../../src/styles/global.css';
|
||||
|
||||
export const metadata = { title: 'Wursor' };
|
||||
|
||||
// Interim product shell from Sprint 1. Its own root layout keeps its dark theme
|
||||
// from leaking into the marketing site. Replaced by the designed screens.
|
||||
export default function LegacyLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyApp } from '../../../src/LegacyApp.tsx';
|
||||
|
||||
export default function Page() {
|
||||
return <LegacyApp />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import type { Metadata } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import '../../src/styles/tokens.css';
|
||||
import '../../src/styles/marketing.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: { default: 'Wursor', template: '%s · Wursor' },
|
||||
description: 'Your website, handled. Tell Wursor what to change. Watch it work. Approve what you like.',
|
||||
};
|
||||
|
||||
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function HomePage() {
|
||||
return <main>Wursor</main>;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Wursor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const apiOrigin = process.env.WURSOR_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Interim: the Sprint 1 shell at /app still calls the API on the same origin.
|
||||
// Replaced by server-held sessions (ADR 0018, step C).
|
||||
async rewrites() {
|
||||
return ['/auth/:path*', '/chat/:path*', '/chat', '/sites/:path*', '/sites', '/health'].map(
|
||||
(source) => ({ source, destination: `${apiOrigin}${source}` }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
+6
-3
@@ -4,11 +4,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"dev": "next dev -p 5173",
|
||||
"build": "next build",
|
||||
"start": "next start -p 5173",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"geist": "^1.7.2",
|
||||
"next": "^16.4.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ApproveBar } from './components/ApproveBar.tsx';
|
||||
import { ChatPanel } from './components/ChatPanel.tsx';
|
||||
@@ -19,7 +21,7 @@ async function signUp(email: string, password: string): Promise<string> {
|
||||
return body.sessionToken;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
export function LegacyApp() {
|
||||
const [sessionToken, setSessionToken] = useState<string | null>(null);
|
||||
const [applied, setApplied] = useState(false);
|
||||
const chat = useChat(sessionToken);
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.tsx';
|
||||
import './styles/global.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg-canvas);
|
||||
color: var(--color-text-primary);
|
||||
font: 400 16px/24px var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--color-accent-default); outline-offset: 2px; }
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Wursor design tokens, ported from the Figma file "Wursor - weown.net"
|
||||
* (Design system page). Light is the default; dark follows the device
|
||||
* unless data-theme forces one (PX-10). Change a value here, not in a component.
|
||||
* Figma letter-spacing is a percentage, so it is written in em here.
|
||||
*/
|
||||
:root {
|
||||
--icon-color: #14120b;
|
||||
|
||||
--color-bg-canvas: #f7f7f4;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-sunken: #f1f0eb;
|
||||
--color-bg-inverse: #14120b;
|
||||
|
||||
--color-text-primary: #14120b;
|
||||
--color-text-secondary: #6b675e;
|
||||
--color-text-tertiary: #8f8b80;
|
||||
--color-text-inverse: #f2f1ec;
|
||||
--color-text-on-accent: #ffffff;
|
||||
--color-text-link: #2a52b0;
|
||||
|
||||
--color-border-subtle: #14120b14;
|
||||
--color-border-default: #dddcd5;
|
||||
--color-border-strong: #c4c2b8;
|
||||
|
||||
--color-accent-default: #e8590c;
|
||||
--color-accent-text: #b8420a;
|
||||
--color-accent-subtle: #fdede3;
|
||||
|
||||
--color-status-working-dot: #3b6fe0;
|
||||
--color-status-working-fg: #2a52b0;
|
||||
--color-status-working-bg: #e8eefb;
|
||||
--color-status-needs-you-dot: #c98a12;
|
||||
--color-status-needs-you-fg: #8a5a00;
|
||||
--color-status-needs-you-bg: #fff6e5;
|
||||
--color-status-success-dot: #2f8f5b;
|
||||
--color-status-success-fg: #1f6b42;
|
||||
--color-status-success-bg: #e4f2e8;
|
||||
|
||||
--color-diff-add-fg: #1f6b42;
|
||||
--color-diff-remove-fg: #a12a2a;
|
||||
|
||||
--space-0: 0px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
--space-16: 64px;
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-full: 999px;
|
||||
|
||||
--size-control-sm: 28px;
|
||||
--size-control-md: 36px;
|
||||
--size-control-lg: 44px;
|
||||
|
||||
--shadow-modal: 0 24px 64px -8px #14120b24, 0 2px 6px 0 #14120b0f;
|
||||
|
||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, monospace;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--icon-color: #f2f1ec;
|
||||
|
||||
--color-bg-canvas: #0e0e0c;
|
||||
--color-bg-surface: #171714;
|
||||
--color-bg-sunken: #1e1e1a;
|
||||
--color-bg-inverse: #f2f1ec;
|
||||
|
||||
--color-text-primary: #f2f1ec;
|
||||
--color-text-secondary: #8f8b80;
|
||||
--color-text-tertiary: #6b675e;
|
||||
--color-text-inverse: #14120b;
|
||||
--color-text-on-accent: #14120b;
|
||||
/* Figma keeps the light value (#2a52b0) in dark, which fails AA on #0e0e0c. Lightened to the dark "working" blue. */
|
||||
--color-text-link: #7c9cf0;
|
||||
|
||||
--color-border-subtle: #f2f1ec14;
|
||||
--color-border-default: #2c2c27;
|
||||
--color-border-strong: #4a473f;
|
||||
|
||||
--color-accent-default: #ff7a33;
|
||||
--color-accent-text: #ff9a5e;
|
||||
--color-accent-subtle: #3a1a08;
|
||||
|
||||
--color-status-working-dot: #7c9cf0;
|
||||
--color-status-working-fg: #7c9cf0;
|
||||
--color-status-working-bg: #1b2440;
|
||||
--color-status-needs-you-dot: #e8b04a;
|
||||
--color-status-needs-you-fg: #e8b04a;
|
||||
--color-status-needs-you-bg: #3a2d12;
|
||||
--color-status-success-dot: #5cc48a;
|
||||
--color-status-success-fg: #5cc48a;
|
||||
--color-status-success-bg: #17301f;
|
||||
|
||||
--color-diff-add-fg: #5cc48a;
|
||||
--color-diff-remove-fg: #e86a6a;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--icon-color: #f2f1ec;
|
||||
|
||||
--color-bg-canvas: #0e0e0c;
|
||||
--color-bg-surface: #171714;
|
||||
--color-bg-sunken: #1e1e1a;
|
||||
--color-bg-inverse: #f2f1ec;
|
||||
|
||||
--color-text-primary: #f2f1ec;
|
||||
--color-text-secondary: #8f8b80;
|
||||
--color-text-tertiary: #6b675e;
|
||||
--color-text-inverse: #14120b;
|
||||
--color-text-on-accent: #14120b;
|
||||
--color-text-link: #7c9cf0;
|
||||
|
||||
--color-border-subtle: #f2f1ec14;
|
||||
--color-border-default: #2c2c27;
|
||||
--color-border-strong: #4a473f;
|
||||
|
||||
--color-accent-default: #ff7a33;
|
||||
--color-accent-text: #ff9a5e;
|
||||
--color-accent-subtle: #3a1a08;
|
||||
|
||||
--color-status-working-dot: #7c9cf0;
|
||||
--color-status-working-fg: #7c9cf0;
|
||||
--color-status-working-bg: #1b2440;
|
||||
--color-status-needs-you-dot: #e8b04a;
|
||||
--color-status-needs-you-fg: #e8b04a;
|
||||
--color-status-needs-you-bg: #3a2d12;
|
||||
--color-status-success-dot: #5cc48a;
|
||||
--color-status-success-fg: #5cc48a;
|
||||
--color-status-success-bg: #17301f;
|
||||
|
||||
--color-diff-add-fg: #5cc48a;
|
||||
--color-diff-remove-fg: #e86a6a;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Type styles (Figma text styles). */
|
||||
.t-display-xl { font: 300 72px/72px var(--font-sans); letter-spacing: -0.03em; }
|
||||
.t-display-l { font: 300 48px/52px var(--font-sans); letter-spacing: -0.025em; }
|
||||
.t-display-m { font: 300 36px/40px var(--font-sans); letter-spacing: -0.02em; }
|
||||
.t-title-l { font: 400 28px/34px var(--font-sans); letter-spacing: -0.015em; }
|
||||
.t-title-m { font: 500 20px/28px var(--font-sans); letter-spacing: -0.01em; }
|
||||
.t-title-s { font: 500 16px/24px var(--font-sans); letter-spacing: -0.005em; }
|
||||
.t-body-l { font: 400 16px/24px var(--font-sans); }
|
||||
.t-body-m { font: 400 14px/20px var(--font-sans); }
|
||||
.t-body-m-strong { font: 500 14px/20px var(--font-sans); }
|
||||
.t-label-m { font: 500 13px/16px var(--font-sans); }
|
||||
.t-label-s { font: 500 12px/16px var(--font-sans); }
|
||||
.t-caption { font: 400 12px/16px var(--font-sans); }
|
||||
.t-overline { font: 600 11px/16px var(--font-sans); letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.t-mono-s { font: 400 12px/16px var(--font-mono); }
|
||||
+10
-5
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "preserve",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
"incremental": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["next-env.d.ts", "app", "src", "__tests__", "next.config.ts", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -3,16 +3,10 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/auth': 'http://localhost:3000',
|
||||
'/chat': 'http://localhost:3000',
|
||||
'/sites': 'http://localhost:3000',
|
||||
'/health': 'http://localhost:3000',
|
||||
},
|
||||
},
|
||||
esbuild: { jsx: 'automatic' },
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
include: ['__tests__/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user