diff --git a/apps/control-plane/app/layout.tsx b/apps/control-plane/app/layout.tsx
index c482c82..71a7fa2 100644
--- a/apps/control-plane/app/layout.tsx
+++ b/apps/control-plane/app/layout.tsx
@@ -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 and
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 (
-
- {children}
+
+
+ {children}
+
);
-}
+}
\ No newline at end of file
diff --git a/apps/control-plane/app/shell/_lib.ts b/apps/control-plane/app/shell/_lib.ts
new file mode 100644
index 0000000..683b12f
--- /dev/null
+++ b/apps/control-plane/app/shell/_lib.ts
@@ -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 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 } | 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 };
+}
\ No newline at end of file
diff --git a/apps/control-plane/app/shell/providers.tsx b/apps/control-plane/app/shell/providers.tsx
new file mode 100644
index 0000000..d3e5350
--- /dev/null
+++ b/apps/control-plane/app/shell/providers.tsx
@@ -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
+ * ; 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({
+ pref: "system",
+ theme: "light",
+ setPref: () => undefined,
+});
+
+function ThemeProvider({ children }: { children: ReactNode }) {
+ const [pref, setPrefState] = useState(() =>
+ 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 {children};
+}
+
+/* ----------------------------- session ---------------------------- */
+
+interface SessionContextValue extends SessionSnapshot {
+ refresh: () => void;
+}
+
+const SessionContext = createContext({
+ status: "loading",
+ user: null,
+ refresh: () => undefined,
+});
+
+function SessionProvider({ children }: { children: ReactNode }) {
+ const apiBase = useMemo(
+ () => resolveApiBase(process.env[SHELL_ENV.apiBaseUrl]),
+ [],
+ );
+ const [snapshot, setSnapshot] = useState({
+ 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 {children};
+}
+
+/* ----------------------------- exports ---------------------------- */
+
+export function useShellTheme(): ThemeContextValue {
+ return useContext(ThemeContext);
+}
+
+export function useShellSession(): SessionContextValue {
+ return useContext(SessionContext);
+}
+
+export default function Providers({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/control-plane/tests/shell-lib.test.ts b/apps/control-plane/tests/shell-lib.test.ts
new file mode 100644
index 0000000..69f22a5
--- /dev/null
+++ b/apps/control-plane/tests/shell-lib.test.ts
@@ -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 });
+ });
+});
\ No newline at end of file
diff --git a/apps/control-plane/vitest.config.ts b/apps/control-plane/vitest.config.ts
index 8ac1c28..252dfa9 100644
--- a/apps/control-plane/vitest.config.ts
+++ b/apps/control-plane/vitest.config.ts
@@ -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"],
},