app: minimal root-layout shell - theme + env-config session providers (M3 slice 1, coreci-chat#16) #17

Merged
jonathan merged 1 commits from po/m3-slice-1-root-shell into main 2026-11-05 20:39:33 +00:00
5 changed files with 387 additions and 8 deletions
+13 -8
View File
@@ -1,18 +1,21 @@
/**
* / - App Router root layout (coreci-chat#10).
* / - App Router root layout (coreci-chat#10 minimum; coreci-chat#16 slice 1).
*
* Next.js 15 requires a root layout that renders <html> and <body> above
* every route. Without one, `next build` fails with "login/page.tsx does not
* have a root layout", so apps/control-plane has never been buildable - the
* pipeline never built the app, so nothing caught it.
*
* This is the build-enabling minimum only. The shell content (theme,
* providers, session wrapper) is an M3 scope call for the line head and is
* deliberately NOT decided here - it rides the M3 kickoff verdict on
* coreci-chat#1.
* Slice 1 (M3) adds the minimal shell content around {children}: the theme
* provider and the auth/session provider (env-based client config), both
* defined in app/shell/providers.tsx. The shell is additive: it never
* redirects or gates - /login and every SSR page keep their server-side
* session resolution - and no boot-time DDL rides it (deploy owns DDL, the
* app boots schema-check only, coreci-chat#1 comment 16428).
*/
import type { ReactNode } from "react";
import Providers from "./shell/providers";
export const metadata = {
title: "CoreCI Chat - control plane",
@@ -20,8 +23,10 @@ export const metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Root-shell pure logic (coreci-chat#16, M3 slice 1).
*
* Extracted from the React providers for node-testability, one repo
* convention applied from Wave J (app/_lib.ts files tested via vitest):
* no DOM, no Next imports — only decisions. The provider components in
* app/shell/providers.tsx consume these; env names live here too:
* NEXT_PUBLIC_API_BASE_URL — client session config (default same-origin)
* NEXT_PUBLIC_DEFAULT_THEME — fallback theme before user preference
*/
export type ThemeName = "light" | "dark" | "system";
export type ResolvedTheme = "light" | "dark";
export const THEME_STORAGE_KEY = "coreci.theme";
/** Env names the shell reads (build-time inlined, undefined-safe). */
export const SHELL_ENV = {
apiBaseUrl: "NEXT_PUBLIC_API_BASE_URL",
defaultTheme: "NEXT_PUBLIC_DEFAULT_THEME",
} as const;
/** Coerce a stored/default candidate into a theme name. */
export function normalizeTheme(
value: string | null | undefined,
fallback: ThemeName = "system",
): ThemeName {
return value === "light" || value === "dark" || value === "system"
? value
: fallback;
}
/**
* Resolve a concrete theme: "system" follows prefers-color-scheme,
* everything else is explicit.
*/
export function resolveTheme(
pref: ThemeName,
systemPrefersDark: boolean,
): ResolvedTheme {
if (pref === "system") return systemPrefersDark ? "dark" : "light";
return pref;
}
/** CSS class applied to <html> for the resolved theme. */
export function themeClass(theme: ResolvedTheme): string {
return `theme-${theme}`;
}
/**
* Session client config (the GO's env-decided shape): the client's API base
* comes from NEXT_PUBLIC_API_BASE_URL and defaults to same-origin ("") — no
* runner-image or deployment assumption is baked in. Only same-origin or
* absolute http(s) URLs are accepted; anything else falls back to same-origin.
*/
export function resolveApiBase(value: string | null | undefined): string {
if (typeof value !== "string") return "";
const v = value.trim().replace(/\/+$/, "");
if (v === "") return "";
if (v.startsWith("/") && !v.startsWith("//")) return v;
if (v.startsWith("http://") || v.startsWith("https://")) return v;
return "";
}
/** Client-visible session shape — mirrors GET /api/me. */
export interface SessionUser {
id: string;
tenantId: string;
role: "admin" | "operator" | "viewer";
}
export type SessionStatus = "loading" | "guest" | "authenticated" | "error";
export interface SessionSnapshot {
status: SessionStatus;
user: SessionUser | null;
}
/**
* Classify a /api/me response into a session snapshot. 200 → authenticated,
* 401/403 → guest (the login route itself renders without a session — never
* redirect from the root shell), anything else → error.
*/
export function classifyMe(
httpStatus: number,
payload: unknown,
): SessionSnapshot {
if (httpStatus === 200) {
const rec = payload as { user?: Record<string, unknown> } | null;
const user = rec?.user ?? undefined;
if (
user &&
typeof user.id === "string" &&
typeof user.tenantId === "string" &&
(user.role === "admin" || user.role === "operator" || user.role === "viewer")
) {
return {
status: "authenticated",
user: { id: user.id, tenantId: user.tenantId, role: user.role },
};
}
return { status: "error", user: null };
}
if (httpStatus === 401 || httpStatus === 403) {
return { status: "guest", user: null };
}
return { status: "error", user: null };
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
/**
* Root-shell providers (coreci-chat#16, M3 slice 1).
*
* Minimal shell content for the app router root layout:
* 1. ThemeProvider — light/dark/system, persisted, applied as a class on
* <html>; no external dependency.
* 2. SessionProvider — the client-side session wrapper over @coreci/auth's
* server session lane: it reads the coreci_session cookie's identity via
* GET /api/me and exposes it as context. The client config comes from
* NEXT_PUBLIC_API_BASE_URL (default same-origin) — env-based, no
* runner-image assumption.
*
* Deliberate limits (slice 1, per the task scope):
* - The root shell NEVER redirects or gates: /login and every SSR page
* keep their existing server-side session resolution (lib/session via
* getMe()); this context is additive for the chat UI.
* - No boot-time DDL and no DB access here — deploy owns DDL, the app
* boots schema-check only (coreci-chat#1 comment 16428).
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
classifyMe,
normalizeTheme,
resolveApiBase,
resolveTheme,
themeClass,
THEME_STORAGE_KEY,
SHELL_ENV,
type SessionSnapshot,
type ThemeName,
type ResolvedTheme,
} from "./_lib.js";
/* ------------------------------ theme ------------------------------ */
interface ThemeContextValue {
pref: ThemeName;
theme: ResolvedTheme;
setPref: (next: ThemeName) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
pref: "system",
theme: "light",
setPref: () => undefined,
});
function ThemeProvider({ children }: { children: ReactNode }) {
const [pref, setPrefState] = useState<ThemeName>(() =>
normalizeTheme(process.env[SHELL_ENV.defaultTheme], "system"),
);
useEffect(() => {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
setPrefState(normalizeTheme(stored, normalizeTheme(process.env[SHELL_ENV.defaultTheme], "system")));
}, []);
const setPref = useCallback((next: ThemeName) => {
setPrefState(next);
try {
window.localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Storage may be unavailable; the preference still applies this session.
}
}, []);
// SSR-safe: "system" reads prefers-color-scheme only in the browser, after
// hydration, and tracks changes (the server render never touches window).
const [systemDark, setSystemDark] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-color-scheme: dark)");
setSystemDark(query.matches);
const onChange = (e: MediaQueryListEvent) => {
setSystemDark(e.matches);
};
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const theme = resolveTheme(pref, systemDark);
useEffect(() => {
const el = document.documentElement;
el.classList.add(themeClass(theme));
return () => el.classList.remove(themeClass(theme));
}, [theme]);
return <ThemeContext.Provider value={{ pref, theme, setPref }}>{children}</ThemeContext.Provider>;
}
/* ----------------------------- session ---------------------------- */
interface SessionContextValue extends SessionSnapshot {
refresh: () => void;
}
const SessionContext = createContext<SessionContextValue>({
status: "loading",
user: null,
refresh: () => undefined,
});
function SessionProvider({ children }: { children: ReactNode }) {
const apiBase = useMemo(
() => resolveApiBase(process.env[SHELL_ENV.apiBaseUrl]),
[],
);
const [snapshot, setSnapshot] = useState<SessionSnapshot>({
status: "loading",
user: null,
});
const fetchMe = useCallback(async () => {
try {
const res = await fetch(`${apiBase}/api/me`, {
credentials: "same-origin",
cache: "no-store",
});
let payload: unknown = null;
try {
payload = await res.json();
} catch {
payload = null;
}
setSnapshot(classifyMe(res.status, payload));
} catch {
setSnapshot({ status: "error", user: null });
}
}, [apiBase]);
useEffect(() => {
void fetchMe();
}, [fetchMe]);
const refresh = useCallback(() => {
void fetchMe();
}, [fetchMe]);
return <SessionContext.Provider value={{ ...snapshot, refresh }}>{children}</SessionContext.Provider>;
}
/* ----------------------------- exports ---------------------------- */
export function useShellTheme(): ThemeContextValue {
return useContext(ThemeContext);
}
export function useShellSession(): SessionContextValue {
return useContext(SessionContext);
}
export default function Providers({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<SessionProvider>{children}</SessionProvider>
</ThemeProvider>
);
}
@@ -0,0 +1,95 @@
/**
* Root-shell pure logic (coreci-chat#16, M3 slice 1).
*/
import { describe, expect, it } from "vitest";
import {
classifyMe,
normalizeTheme,
resolveApiBase,
resolveTheme,
themeClass,
THEME_STORAGE_KEY,
} from "../app/shell/_lib.js";
describe("normalizeTheme", () => {
it("accepts the three theme names", () => {
expect(normalizeTheme("light")).toBe("light");
expect(normalizeTheme("dark")).toBe("dark");
expect(normalizeTheme("system")).toBe("system");
});
it("falls back for stored garbage and absence", () => {
expect(normalizeTheme("blue")).toBe("system");
expect(normalizeTheme(null)).toBe("system");
expect(normalizeTheme(undefined, "dark")).toBe("dark");
});
it("uses the env default as fallback (the shipped env default)", () => {
expect(normalizeTheme(null, "light")).toBe("light");
});
});
describe("resolveTheme + themeClass", () => {
it("resolves system to the media query result", () => {
expect(resolveTheme("system", false)).toBe("light");
expect(resolveTheme("system", true)).toBe("dark");
});
it("resolves explicit preferences over the media query", () => {
expect(resolveTheme("dark", false)).toBe("dark");
expect(resolveTheme("light", true)).toBe("light");
});
it("produces the html class name", () => {
expect(themeClass("dark")).toBe("theme-dark");
expect(themeClass("light")).toBe("theme-light");
});
it("exposes the storage key shape", () => {
expect(THEME_STORAGE_KEY).toBe("coreci.theme");
});
});
describe("resolveApiBase (env-based session client config)", () => {
it("defaults to same-origin when the env var is absent", () => {
expect(resolveApiBase(undefined)).toBe("");
expect(resolveApiBase(null)).toBe("");
expect(resolveApiBase("")).toBe("");
expect(resolveApiBase(" ")).toBe("");
});
it("accepts same-origin-relative and absolute http(s) bases", () => {
expect(resolveApiBase("/gw")).toBe("/gw");
expect(resolveApiBase("https://chat.coreci.dev/")).toBe("https://chat.coreci.dev");
expect(resolveApiBase(" http://localhost:3000/ ")).toBe("http://localhost:3000");
});
it("falls back to same-origin for unsupported forms", () => {
expect(resolveApiBase("//evil.example")).toBe("");
expect(resolveApiBase("ftp://x")).toBe("");
expect(resolveApiBase("javascript:alert(1)")).toBe("");
});
});
describe("classifyMe (/api/me responses)", () => {
it("classifies a valid 200 payload as authenticated with the user", () => {
const out = classifyMe(200, { user: { id: "u1", tenantId: "t1", role: "operator" } });
expect(out.status).toBe("authenticated");
expect(out.user).toEqual({ id: "u1", tenantId: "t1", role: "operator" });
});
it("treats a malformed 200 payload as an error, not a crash", () => {
expect(classifyMe(200, { user: { id: 7 } }).status).toBe("error");
expect(classifyMe(200, null).status).toBe("error");
});
it("treats 401/403 as guest — the shell never redirects", () => {
expect(classifyMe(401, null)).toEqual({ status: "guest", user: null });
expect(classifyMe(403, null)).toEqual({ status: "guest", user: null });
});
it("treats other statuses as an error state", () => {
expect(classifyMe(500, null)).toEqual({ status: "error", user: null });
});
});
+2
View File
@@ -15,6 +15,8 @@ export default defineConfig({
"app/dashboard/settings/adapters/_lib.ts",
"app/dashboard/test-call/_helpers.ts",
"app/dashboard/test-call/_lib.ts",
// M3 slice 1 root-shell pure logic (coreci-chat#16).
"app/shell/_lib.ts",
],
reporter: ["text", "json"],
},