Compare commits

..
Author SHA1 Message Date
shaggy (mosaic-dev box)andClaude Fable 5 068d0f9b1c feat(web): P1 Vite skeleton beside Next — entry, router, guards, vitest 3
ci/woodpecker/pr/ci Pipeline was successful
First increment of the approved Phase P RFC (webui-mission). Adds a Vite + React
Router SPA scaffold coexisting with the Next app: index.html with the theme
anti-flash script, src/main.tsx entry, the v1 parity route table under Guest/Auth
guard shells, and a dev proxy (/api, /socket.io ws) to the gateway on 14242 so the
SPA is same-origin in dev. vitest bumped to v3 (vite 8 pairing); existing specs
pass unchanged. Next remains the served app until the P5 cutover.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-09 19:02:55 -05:00
velmaandmos-dt-0 24bbd40dc7 docs: WebUI fleet Claude bridge — Task 0 decision plan (#1131)
ci/woodpecker/push/publish Pipeline failed
Docs-only plan PR. FRED_APPROVED_REF=0629361ca39a4dd7fb3e575d11c64bea9e545dae (review 147). Merged by fred (orchestrator) via API: pr-merge.sh policy predates the next lane (main-only hardcode) — wrapper fix tracked separately.

Co-authored-by: Velma <[email protected]>
2026-08-09 10:28:41 +00:00
10 changed files with 951 additions and 72 deletions
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mosaic</title>
<meta name="description" content="Mosaic Stack Dashboard" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
/>
<script>
// set data-theme before first paint so the stored theme never flashes
(function () {
try {
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6 -1
View File
@@ -4,7 +4,9 @@
"private": true,
"scripts": {
"build": "node ../../scripts/build-web.mjs",
"build:vite": "vite build",
"dev": "next dev",
"dev:vite": "vite",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
@@ -19,6 +21,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2",
"socket.io-client": "^4.8.0",
"tailwind-merge": "^3.5.0"
},
@@ -28,9 +31,11 @@
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.5",
"jsdom": "^29.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
"vite": "^8.2.1",
"vitest": "^3.2.7"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { ThemeProvider } from '@/providers/theme-provider';
import { createAppRouter } from '@/routes';
import '@/app/globals.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('missing #root element');
}
createRoot(container).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={createAppRouter()} />
</ThemeProvider>
</StrictMode>,
);
+30
View File
@@ -0,0 +1,30 @@
import { createBrowserRouter, Navigate, type RouteObject } from 'react-router-dom';
import { AuthGuard, GuestGuard } from '@/spa/guards';
import { Placeholder } from '@/spa/placeholder';
export const routes: RouteObject[] = [
{
element: <GuestGuard />,
children: [
{ path: '/login', element: <Placeholder title="Login" /> },
{ path: '/register', element: <Placeholder title="Register" /> },
{ path: '/auth/provider/:provider', element: <Placeholder title="Signing in" /> },
],
},
{
element: <AuthGuard />,
children: [
{ path: '/', element: <Navigate to="/chat" replace /> },
{ path: '/chat', element: <Placeholder title="Chat" /> },
{ path: '/projects', element: <Placeholder title="Projects" /> },
{ path: '/projects/:id', element: <Placeholder title="Project" /> },
{ path: '/tasks', element: <Placeholder title="Tasks" /> },
{ path: '/settings', element: <Placeholder title="Settings" /> },
{ path: '/admin', element: <Placeholder title="Admin" /> },
],
},
];
export function createAppRouter(): ReturnType<typeof createBrowserRouter> {
return createBrowserRouter(routes);
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactElement } from 'react';
import { Outlet } from 'react-router-dom';
// P1 shells: session-aware redirects arrive with the reworked auth client in P2.
export function GuestGuard(): ReactElement {
return <Outlet />;
}
export function AuthGuard(): ReactElement {
return <Outlet />;
}
+9
View File
@@ -0,0 +1,9 @@
import type { ReactElement } from 'react';
export function Placeholder({ title }: { title: string }): ReactElement {
return (
<main className="flex min-h-screen items-center justify-center">
<h1 className="text-xl font-medium">{title}</h1>
</main>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import type { RouteObject } from 'react-router-dom';
import { routes } from '@/routes';
function collectPaths(routeObjects: RouteObject[]): string[] {
return routeObjects.flatMap((route) => [
...(route.path ? [route.path] : []),
...(route.children ? collectPaths(route.children) : []),
]);
}
describe('SPA route table', () => {
it('covers every v1 parity route from the Phase P RFC', () => {
expect(collectPaths(routes).sort()).toEqual(
[
'/',
'/admin',
'/auth/provider/:provider',
'/chat',
'/login',
'/projects',
'/projects/:id',
'/register',
'/settings',
'/tasks',
].sort(),
);
});
it('separates guest and authenticated route groups', () => {
const guestPaths = collectPaths(routes.at(0)?.children ?? []);
const authPaths = collectPaths(routes.at(1)?.children ?? []);
expect(guestPaths).toContain('/login');
expect(guestPaths).not.toContain('/chat');
expect(authPaths).toContain('/chat');
});
});
+24
View File
@@ -0,0 +1,24 @@
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// The proxy exists only in dev; in production the SPA is same-origin with the gateway
// (served by it under Candidate A, or behind one FQDN under Candidate B) and every
// request uses a relative path, so no origin may ever be configured here or in src/.
const gatewayTarget = 'http://localhost:14242';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 3100,
proxy: {
'/api': gatewayTarget,
'/socket.io': { target: gatewayTarget, ws: true },
},
},
});
+10
View File
@@ -1,6 +1,16 @@
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
// tsconfig uses "jsx": "preserve" for Next; tests need esbuild to compile it
esbuild: {
jsx: 'automatic',
},
test: {
globals: true,
environment: 'jsdom',
+774 -71
View File
File diff suppressed because it is too large Load Diff