feat(web): scaffold browser-based Sprout web client (#474)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-04 13:49:48 -06:00
committed by GitHub
parent c1063a0c92
commit 9c6b770aa5
35 changed files with 3676 additions and 2 deletions

View File

@@ -1,5 +1,6 @@
target/
desktop/
web/
.git/
.scratch/
*.md

View File

@@ -4,6 +4,7 @@ set dotenv-load := true
desktop_dir := "desktop"
desktop_tauri_manifest := "desktop/src-tauri/Cargo.toml"
web_dir := "web"
# List all available tasks
default:
@@ -43,7 +44,7 @@ build-release:
cargo build --workspace --release
# Run repo lint and formatting checks
check: fmt-check clippy desktop-check desktop-tauri-fmt-check mobile-check
check: fmt-check clippy desktop-check desktop-tauri-fmt-check web-check mobile-check
# Format all Rust code
fmt:
@@ -126,7 +127,7 @@ desktop-e2e-integration:
cd {{desktop_dir}} && pnpm test:e2e:integration
# Run all checks suitable for CI / pre-push (no infra needed)
ci: check test-unit desktop-build desktop-tauri-check mobile-test
ci: check test-unit desktop-build desktop-tauri-check web-build mobile-test
# ─── Test ─────────────────────────────────────────────────────────────────────
@@ -198,6 +199,46 @@ desktop-dev:
desktop-app *ARGS:
just dev {{ARGS}}
# ─── Web ─────────────────────────────────────────────────────────────────────
# Run the web frontend dev server (port derived from worktree to avoid collisions)
web:
#!/usr/bin/env bash
set -euo pipefail
cd {{web_dir}}
[[ -d node_modules ]] || pnpm install
cd ..
source scripts/instance-env.sh
export VITE_PORT=$((SPROUT_VITE_PORT + 100))
export VITE_RELAY_URL="${SPROUT_RELAY_URL}"
echo "Starting web dev server on port ${VITE_PORT}, relay ${SPROUT_RELAY_URL}"
cd {{web_dir}}
pnpm exec vite --port "${VITE_PORT}" --strictPort
# Install web JS dependencies
web-install:
cd {{web_dir}} && pnpm install
# Install web JS dependencies reproducibly for CI
web-install-ci:
cd {{web_dir}} && pnpm install --frozen-lockfile
# Run web lint and format checks
web-check:
cd {{web_dir}} && pnpm check
# Run web TypeScript checks
web-typecheck:
cd {{web_dir}} && pnpm typecheck
# Build web frontend assets
web-build:
cd {{web_dir}} && pnpm build
# Run web browser smoke tests
web-e2e-smoke:
cd {{web_dir}} && pnpm test:e2e:smoke
# ─── Mobile ──────────────────────────────────────────────────────────────────
mobile_dir := "mobile"

View File

@@ -6,6 +6,8 @@ pre-commit:
run: just desktop-tauri-fmt-check
desktop-check:
run: just desktop-check
web-check:
run: just web-check
mobile-check:
run: just mobile-check
@@ -26,6 +28,10 @@ pre-push:
run: just desktop-build
desktop-tauri-check:
run: just desktop-tauri-check
web-check:
run: just web-check
web-build:
run: just web-build
mobile-check:
run: just mobile-check
mobile-test:

View File

@@ -193,6 +193,23 @@ else
warn "Desktop directory not found at ${DESKTOP_DIR} — skipping."
fi
# ---- Install web dependencies -----------------------------------------------
WEB_DIR="${REPO_ROOT}/web"
if [[ -d "${WEB_DIR}" ]]; then
if command -v pnpm &>/dev/null; then
log "Installing web dependencies (pnpm install)..."
(cd "${WEB_DIR}" && pnpm install)
success "Web dependencies installed"
else
warn "pnpm not found — skipping web dependency install."
warn "Run '. ./bin/activate-hermit' to get pnpm, then 'just web-install'."
fi
else
warn "Web directory not found at ${WEB_DIR} — skipping."
fi
# ---- Print connection info --------------------------------------------------
echo ""

12
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
node_modules
dist
dist-ssr
*.local
playwright-report
test-results

37
web/biome.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
}
},
"assist": {
"enabled": false
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

19
web/components.json Normal file
View File

@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/shared/styles/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/shared/ui",
"utils": "@/shared/lib/cn",
"ui": "@/shared/ui",
"lib": "@/shared/lib"
}
}

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sprout</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

47
web/package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "sprout-web",
"private": true,
"version": "0.1.0",
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"typecheck": "tsc --noEmit",
"check:file-sizes": "node ./scripts/check-file-sizes.mjs",
"lint": "biome lint .",
"check": "biome check . && pnpm check:file-sizes",
"format": "biome format --write .",
"preview": "vite preview",
"test:e2e": "pnpm build && playwright test",
"test:e2e:smoke": "pnpm build && playwright test --project=smoke"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@biomejs/biome": "^2.4.6",
"@playwright/test": "^1.58.2",
"@tanstack/router-plugin": "^1.167.12",
"@tanstack/virtual-file-routes": "^1.161.7",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.0",
"vite": "^7.0.4"
}
}

33
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [
["list"],
["html", { open: "never", outputFolder: "playwright-report" }],
],
use: {
baseURL: "http://127.0.0.1:4173",
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "retain-on-failure",
},
projects: [
{
name: "smoke",
testMatch: ["**/smoke.spec.ts"],
use: {
...devices["Desktop Chrome"],
},
},
],
webServer: {
command: "pnpm exec vite preview --port 4173 --strictPort --host 127.0.0.1",
cwd: ".",
reuseExistingServer: !process.env.CI,
url: "http://127.0.0.1:4173",
},
});

2586
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,108 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const rules = [
{
root: "src/app",
extensions: new Set([".ts", ".tsx"]),
maxLines: 500,
},
{
root: "src/features",
extensions: new Set([".ts", ".tsx"]),
maxLines: 500,
},
{
root: "src/shared/api",
extensions: new Set([".ts", ".tsx"]),
maxLines: 500,
},
];
const overrides = new Map([]);
async function walkFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
return walkFiles(fullPath);
}
return [fullPath];
}),
);
return files.flat();
}
function findRule(relativePath) {
return rules.find((rule) => {
const normalizedRoot = `${rule.root}${path.sep}`;
return relativePath.startsWith(normalizedRoot);
});
}
function countLines(content) {
if (content.length === 0) {
return 0;
}
return content.split(/\r?\n/).length;
}
const candidateFiles = (
await Promise.all(
rules.map((rule) => {
const dir = path.join(projectRoot, rule.root);
return fs
.access(dir)
.then(() => walkFiles(dir))
.catch(() => []);
}),
)
).flat();
const violations = [];
for (const filePath of candidateFiles) {
const relativePath = path.relative(projectRoot, filePath);
const rule = findRule(relativePath);
if (!rule) {
continue;
}
const extension = path.extname(relativePath);
if (!rule.extensions.has(extension)) {
continue;
}
const limit = overrides.get(relativePath) ?? rule.maxLines;
const content = await fs.readFile(filePath, "utf8");
const lineCount = countLines(content);
if (lineCount > limit) {
violations.push({
limit,
lineCount,
relativePath,
});
}
}
if (violations.length > 0) {
console.error("Web file size check failed:");
for (const violation of violations) {
console.error(
`- ${violation.relativePath}: ${violation.lineCount} lines (limit ${violation.limit})`,
);
}
console.error(
"Split the file or add a narrowly scoped exception in `web/scripts/check-file-sizes.mjs`.",
);
process.exit(1);
}

7
web/src/app/App.tsx Normal file
View File

@@ -0,0 +1,7 @@
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/app/router";
export function App() {
return <RouterProvider router={router} />;
}

View File

@@ -0,0 +1,55 @@
// biome-ignore-all lint: generated by TanStack Router
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/root";
import { Route as indexRouteImport } from "./routes/index";
const indexRoute = indexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/";
fileRoutesByTo: FileRoutesByTo;
to: "/";
id: "__root__" | "/";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof indexRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();

17
web/src/app/router.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { createBrowserHistory, createRouter } from "@tanstack/react-router";
import { routeTree } from "@/app/routeTree.gen";
export const router = createRouter({
routeTree,
history: createBrowserHistory(),
scrollRestoration: true,
getScrollRestorationKey: (location: { pathname: string }) =>
location.pathname,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}

3
web/src/app/routes.ts Normal file
View File

@@ -0,0 +1,3 @@
import { index, rootRoute } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", [index("index.tsx")]);

View File

@@ -0,0 +1,36 @@
import { createFileRoute } from "@tanstack/react-router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/ui/card";
export const Route = createFileRoute("/")({
component: HomeRoute,
});
function HomeRoute() {
const relayUrl = import.meta.env.VITE_RELAY_URL || "ws://localhost:3000";
return (
<div className="flex flex-1 items-center justify-center p-4">
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Relay</CardTitle>
<CardDescription>Connected relay endpoint</CardDescription>
</CardHeader>
<CardContent>
<code
className="text-sm font-mono text-muted-foreground"
data-testid="relay-url"
>
{relayUrl}
</code>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { Outlet, createRootRoute } from "@tanstack/react-router";
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
return (
<div className="flex min-h-dvh flex-col">
<header className="flex h-12 items-center justify-between border-b px-4">
<span className="text-sm font-semibold tracking-tight">Sprout</span>
<ThemeToggle />
</header>
<main className="flex flex-1 flex-col">
<Outlet />
</main>
</div>
);
}

View File

35
web/src/main.tsx Normal file
View File

@@ -0,0 +1,35 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/app/App";
import "@/shared/styles/globals.css";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
networkMode: "always",
gcTime: 5 * 60 * 1_000,
},
mutations: {
networkMode: "always",
},
},
});
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<TooltipProvider delayDuration={300}>
<App />
<Toaster />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
</React.StrictMode>,
);

6
web/src/shared/lib/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,99 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* Catppuccin Latte (mauve accent) */
--radius: 0.625rem;
--background: 220 23.08% 94.9%;
--foreground: 234 16.02% 35.49%;
--card: 220 23.08% 94.9%;
--card-foreground: 234 16.02% 35.49%;
--popover: 220 23.08% 94.9%;
--popover-foreground: 234 16.02% 35.49%;
--primary: 266 85.05% 58.04%;
--primary-foreground: 220 23.08% 94.9%;
--secondary: 223 15.91% 82.75%;
--secondary-foreground: 234 16.02% 35.49%;
--muted: 223 15.91% 82.75%;
--muted-foreground: 233 12.8% 41.37%;
--accent: 223 15.91% 82.75%;
--accent-foreground: 234 16.02% 35.49%;
--destructive: 347 86.67% 44.12%;
--destructive-foreground: 220 23.08% 94.9%;
--border: 225 13.56% 76.86%;
--input: 225 13.56% 76.86%;
--ring: 234 16.02% 35.49%;
--chart-1: 345 57.65% 74.12%;
--chart-2: 185 37.25% 70.59%;
--chart-3: 219 81.96% 78.43%;
--chart-4: 35 61.18% 76.86%;
--chart-5: 113 32.94% 73.33%;
--sidebar: 218 25.1% 93.73%;
--sidebar-background: 218 25.1% 93.73%;
--sidebar-foreground: 234 16.02% 35.49%;
--sidebar-primary: 266 85.05% 58.04%;
--sidebar-primary-foreground: 220 23.08% 94.9%;
--sidebar-accent: 220 23.08% 94.9%;
--sidebar-accent-foreground: 234 16.02% 35.49%;
--sidebar-border: 225 13.56% 76.86%;
--sidebar-ring: 225 13.56% 76.86%;
}
.dark {
/* Catppuccin Macchiato (mauve accent) */
--radius: 0.625rem;
--background: 232 23.4% 18.43%;
--foreground: 227 68.25% 87.65%;
--card: 232 23.4% 18.43%;
--card-foreground: 227 68.25% 87.65%;
--popover: 232 23.4% 18.43%;
--popover-foreground: 227 68.25% 87.65%;
--primary: 267 82.69% 79.61%;
--primary-foreground: 232 23.4% 18.43%;
--secondary: 230 18.8% 26.08%;
--secondary-foreground: 227 68.25% 87.65%;
--muted: 230 18.8% 26.08%;
--muted-foreground: 228 39.22% 80%;
--accent: 230 18.8% 26.08%;
--accent-foreground: 227 68.25% 87.65%;
--destructive: 351 73.91% 72.94%;
--destructive-foreground: 232 23.4% 18.43%;
--border: 231 15.61% 33.92%;
--input: 231 15.61% 33.92%;
--ring: 227 68.25% 87.65%;
--chart-1: 333 20.78% 38.04%;
--chart-2: 193 19.61% 38.04%;
--chart-3: 224 26.67% 41.18%;
--chart-4: 30 8.24% 42.35%;
--chart-5: 139 10.98% 38.82%;
--sidebar: 234 22.75% 17.25%;
--sidebar-background: 234 22.75% 17.25%;
--sidebar-foreground: 227 68.25% 87.65%;
--sidebar-primary: 267 82.69% 79.61%;
--sidebar-primary-foreground: 232 23.4% 18.43%;
--sidebar-accent: 232 23.4% 18.43%;
--sidebar-accent-foreground: 227 68.25% 87.65%;
--sidebar-border: 231 15.61% 33.92%;
--sidebar-ring: 231 15.61% 33.92%;
}
* {
@apply border-border;
}
mark {
background-color: transparent;
color: inherit;
}
html.dark {
color-scheme: dark;
}
body {
@apply bg-background text-foreground antialiased;
font-family: "Geist", "Avenir Next", "Segoe UI", sans-serif;
}
}

View File

@@ -0,0 +1,85 @@
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
type Theme = "light" | "dark" | "system";
type ThemeContextValue = {
theme: Theme;
isDark: boolean;
setTheme: (theme: Theme) => void;
};
const STORAGE_KEY = "sprout-web-theme";
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function getSystemDark(): boolean {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function applyClass(isDark: boolean) {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(isDark ? "dark" : "light");
}
function readStoredTheme(): Theme {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "light" || stored === "dark" || stored === "system") {
return stored;
}
return "system";
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
const [isDark, setIsDark] = useState<boolean>(() => {
const t = readStoredTheme();
const dark = t === "system" ? getSystemDark() : t === "dark";
applyClass(dark);
return dark;
});
useEffect(() => {
const dark = theme === "system" ? getSystemDark() : theme === "dark";
applyClass(dark);
setIsDark(dark);
}, [theme]);
useEffect(() => {
if (theme !== "system") return;
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
applyClass(e.matches);
setIsDark(e.matches);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
window.localStorage.setItem(STORAGE_KEY, t);
setThemeState(t);
}, []);
return (
<ThemeContext.Provider value={{ theme, isDark, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}

View File

@@ -0,0 +1,32 @@
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
const icons = {
light: Sun,
dark: Moon,
system: Monitor,
} as const;
const next: Record<string, "dark" | "system" | "light"> = {
light: "dark",
dark: "system",
system: "light",
};
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const Icon = icons[theme];
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setTheme(next[theme])}
aria-label={`Theme: ${theme}. Click to switch.`}
>
<Icon className="h-4 w-4" />
</Button>
);
}

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/shared/lib/cn";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border/70 bg-card/80 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,29 @@
import { Toaster as Sonner } from "sonner";
import { useTheme } from "@/shared/theme/ThemeProvider";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { isDark } = useTheme();
return (
<Sonner
theme={isDark ? "dark" : "light"}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/shared/lib/cn";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

71
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,71 @@
import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
status: {
added: "var(--status-added)",
deleted: "var(--status-deleted)",
modified: "var(--status-modified)",
},
warning: {
DEFAULT: "var(--ui-warning)",
bg: "var(--ui-warning-bg)",
},
},
},
},
plugins: [tailwindcssAnimate],
};

View File

@@ -0,0 +1,13 @@
import { expect, test } from "@playwright/test";
test("home page loads with Sprout heading", async ({ page }) => {
await page.goto("/");
await expect(page.locator("header")).toContainText("Sprout");
});
test("relay URL is visible", async ({ page }) => {
await page.goto("/");
const relayUrl = page.getByTestId("relay-url");
await expect(relayUrl).toBeVisible();
await expect(relayUrl).toContainText("ws://");
});

29
web/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

30
web/vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
tanstackRouter({
target: "react",
routesDirectory: "./src/app/routes",
generatedRouteTree: "./src/app/routeTree.gen.ts",
virtualRouteConfig: "./src/app/routes.ts",
quoteStyle: "double",
semicolons: true,
routeTreeFileHeader: [
"// biome-ignore-all lint: generated by TanStack Router",
],
}),
react(),
],
resolve: {
alias: {
"@": "/src",
},
},
server: {
port: parseInt(process.env.VITE_PORT || "5173", 10),
strictPort: true,
},
});