diff --git a/nextjs_space/app/globals.css b/nextjs_space/app/globals.css
index 49ceb892..b72898a5 100644
--- a/nextjs_space/app/globals.css
+++ b/nextjs_space/app/globals.css
@@ -159,10 +159,6 @@
--tenant-button-font-size: 1rem;
/* SaaS light theme accents */
- --saas-surface: 0 0% 100%;
- --saas-surface-muted: 210 40% 98%;
- --saas-border: 214.3 31.8% 91.4%;
- --saas-shadow: 0 20px 40px -30px rgb(15 23 42 / 0.3);
/* Sidebar tokens */
--sidebar-background: 0 0% 98%;
@@ -258,13 +254,6 @@
/* .admin-bg deprecated/removed in US-009 — was unused. */
- /* Legacy alias used by storefront /store/[slug]/dashboard + /settings */
- .saas-shell {
- background-color: #fafaf9;
- background-image: radial-gradient(circle, #e7e5e4 1px, transparent 1px);
- background-size: 24px 24px;
- }
-
* {
@apply border-border;
}
@@ -455,31 +444,6 @@
transition: all 0.2s ease-out;
}
- /* Legacy saas-card */
- .saas-card {
- background: white;
- border-radius: 1rem;
- box-shadow:
- 0 4px 6px rgba(0, 0, 0, 0.07),
- 0 10px 20px rgba(0, 0, 0, 0.12),
- 0 30px 60px rgba(0, 0, 0, 0.18);
- border: 1px solid rgba(0, 0, 0, 0.08);
- }
-
- .saas-pill {
- display: inline-flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.35rem 0.85rem;
- border-radius: 999px;
- border: 1px solid hsl(var(--saas-border));
- background: rgba(255, 255, 255, 0.9);
- font-size: 0.75rem;
- font-weight: 600;
- letter-spacing: 0.02em;
- text-transform: uppercase;
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
- }
/* ========== ICON BADGES ========== */
@@ -1182,8 +1146,10 @@ html {
removed in US-009 (Phase 0.4 / Phase 1 exit). Admin pages now use
bs-* tokens directly via the @layer components block (below) and the
[data-surface="admin"] gate. Storefront homepage classes (.bs-smoky,
- .bs-cube-*, .bs-pill-nav, .bs-pill-footer, .bs-drgreen-inline) and
- storefront utilities (.saas-shell) intentionally preserved (PRD §4).
+ .bs-cube-*, .bs-pill-nav, .bs-pill-footer, .bs-drgreen-inline)
+ intentionally preserved (PRD §4). The saas-shell / saas-card / saas-pill
+ storefront aliases were removed once /settings moved onto tenant tokens
+ (PR #277) — they painted BudStacks' own light chrome onto tenant pages.
============================================================ */
/* ============================================================
diff --git a/nextjs_space/components/admin/AdminSidebar.tsx b/nextjs_space/components/admin/AdminSidebar.tsx
index b10a82ac..877d65eb 100644
--- a/nextjs_space/components/admin/AdminSidebar.tsx
+++ b/nextjs_space/components/admin/AdminSidebar.tsx
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import Image from "next/image";
import { cn } from "@/lib/utils";
+import { shouldFullLoad } from "@/lib/admin/hard-navigation";
export interface AdminMenuItem {
id: string;
@@ -153,22 +154,21 @@ export function AdminSidebar({
{menuItems.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
-
- return (
- setMobileOpen(false)}
- className={cn(
- "flex items-center gap-3 px-3 py-2.5 rounded-bs-md transition-all group relative",
- "before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-[3px] before:rounded-full before:transition-all",
- active
- ? cn("bg-bs-card text-bs-fg", ACTIVE_RAIL[accent])
- : "before:bg-transparent text-bs-fg-muted hover:text-bs-fg hover:bg-bs-card",
- FOCUS_RING[theme]
- )}
- title={collapsed ? item.label : undefined}
- >
+ const linkProps = {
+ href: item.href,
+ onClick: () => setMobileOpen(false),
+ className: cn(
+ "flex items-center gap-3 px-3 py-2.5 rounded-bs-md transition-all group relative",
+ "before:absolute before:left-0 before:top-1.5 before:bottom-1.5 before:w-[3px] before:rounded-full before:transition-all",
+ active
+ ? cn("bg-bs-card text-bs-fg", ACTIVE_RAIL[accent])
+ : "before:bg-transparent text-bs-fg-muted hover:text-bs-fg hover:bg-bs-card",
+ FOCUS_RING[theme]
+ ),
+ title: collapsed ? item.label : undefined,
+ };
+ const content = (
+ <>
)}
+ >
+ );
+
+ // The analytics pages run under a wider CSP than the rest of the
+ // admin; a policy belongs to the document, so links into and out
+ // of them are full loads (lib/admin/hard-navigation.ts).
+ return shouldFullLoad(pathname, item.href) ? (
+ // eslint-disable-next-line @next/next/no-html-link-for-pages -- a full document load is the point
+
+ {content}
+
+ ) : (
+
+ {content}
);
})}
diff --git a/nextjs_space/components/admin/QuickActionsWidget.tsx b/nextjs_space/components/admin/QuickActionsWidget.tsx
index acbed425..9fa165eb 100644
--- a/nextjs_space/components/admin/QuickActionsWidget.tsx
+++ b/nextjs_space/components/admin/QuickActionsWidget.tsx
@@ -2,6 +2,7 @@
import * as React from "react";
import Link from "next/link";
+import { needsFullDocument } from "@/lib/admin/hard-navigation";
import { cn } from "@/lib/utils";
import {
Package,
@@ -87,18 +88,18 @@ export const QuickActionsWidget = React.forwardRef<
const Icon = action.icon;
const isPrimary = index === 0;
- return (
-
+ ),
+ };
+ const content = (
+ <>
{action.label}
+ >
+ );
+ // Analytics needs its own document (wider CSP) — see lib/admin/hard-navigation.ts
+ return needsFullDocument(action.href) ? (
+ // eslint-disable-next-line @next/next/no-html-link-for-pages -- a full document load is the point
+
+ {content}
+
+ ) : (
+
+ {content}
);
})}
diff --git a/nextjs_space/lib/admin/hard-navigation.ts b/nextjs_space/lib/admin/hard-navigation.ts
new file mode 100644
index 00000000..fb18bb75
--- /dev/null
+++ b/nextjs_space/lib/admin/hard-navigation.ts
@@ -0,0 +1,39 @@
+/**
+ * Routes that must be reached by a full document load.
+ *
+ * The Content-Security-Policy is built per route in middleware
+ * (lib/security/csp.ts), and the analytics pages are the one place the admin
+ * policy is wider: they carry `'unsafe-eval'` for plotly.js. A policy belongs
+ * to the DOCUMENT, so a client-side `` navigation into analytics keeps
+ * the policy of the page the user started on — plotly then fails to run —
+ * and a `` out of analytics carries the widened policy onto pages that
+ * should not have it. Both directions are avoided by rendering those links
+ * as plain anchors, which the admin navigation does via these helpers.
+ *
+ * Kept free of React/Next imports so it is unit-testable in the node runner.
+ */
+
+/** Pathname prefixes whose CSP variant differs from the rest of the admin. */
+export const FULL_DOCUMENT_ROUTES = [
+ "/tenant-admin/analytics",
+ "/super-admin/analytics",
+] as const;
+
+function matchesRoute(pathname: string, route: string): boolean {
+ return pathname === route || pathname.startsWith(`${route}/`);
+}
+
+/** True when `href` points at a route that needs its own document. */
+export function needsFullDocument(href: string): boolean {
+ const path = href.split(/[?#]/)[0];
+ return FULL_DOCUMENT_ROUTES.some((route) => matchesRoute(path, route));
+}
+
+/**
+ * True when a link from the page at `pathname` to `href` must be a full load:
+ * either the destination needs its own document, or the current page is one
+ * whose widened policy must not travel to the destination.
+ */
+export function shouldFullLoad(pathname: string | null, href: string): boolean {
+ return needsFullDocument(href) || (pathname !== null && needsFullDocument(pathname));
+}
diff --git a/nextjs_space/lib/security/csp.ts b/nextjs_space/lib/security/csp.ts
index 50453833..4b16c44a 100644
--- a/nextjs_space/lib/security/csp.ts
+++ b/nextjs_space/lib/security/csp.ts
@@ -119,6 +119,13 @@ export function buildCsp({
* Pick the CSP variant for the path actually being served (after middleware
* rewrites). Analytics pages need 'unsafe-eval' (plotly); store pages need
* frame-ancestors 'self' for the editor iframe viewport switcher.
+ *
+ * CONTRACT: a policy belongs to the document, not the route. A client-side
+ * navigation keeps the policy of the page the user loaded first, so any route
+ * whose variant WIDENS a directive must be entered and left by a full document
+ * load. The admin navigation does this for the analytics routes via
+ * lib/admin/hard-navigation.ts; the YouTube frame host is on every variant for
+ * the same reason (PR #278). Add a route here → add it there.
*/
export function variantForServedPath(servedPath: string): CspVariant {
if (servedPath.startsWith("/store")) return "store";
diff --git a/nextjs_space/tests/unit/hard-navigation.test.ts b/nextjs_space/tests/unit/hard-navigation.test.ts
new file mode 100644
index 00000000..f02b55a0
--- /dev/null
+++ b/nextjs_space/tests/unit/hard-navigation.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import { needsFullDocument, shouldFullLoad } from "@/lib/admin/hard-navigation";
+
+/**
+ * The analytics pages carry a wider CSP ('unsafe-eval' for plotly) than the
+ * rest of the admin. A policy belongs to the document, so links into and out
+ * of those pages must be full loads — the same trap that blocked the guide
+ * videos on /documents (PR #278), applied to scripts instead of frames.
+ */
+describe("needsFullDocument", () => {
+ it("recognises both analytics routes, with sub-paths and query strings", () => {
+ expect(needsFullDocument("/tenant-admin/analytics")).toBe(true);
+ expect(needsFullDocument("/super-admin/analytics")).toBe(true);
+ expect(needsFullDocument("/tenant-admin/analytics/retention")).toBe(true);
+ expect(needsFullDocument("/tenant-admin/analytics?period=30d")).toBe(true);
+ });
+
+ it("leaves every other admin route on client-side navigation", () => {
+ expect(needsFullDocument("/tenant-admin")).toBe(false);
+ expect(needsFullDocument("/tenant-admin/analytics-export")).toBe(false);
+ expect(needsFullDocument("/tenant-admin/orders")).toBe(false);
+ expect(needsFullDocument("/super-admin/tenants")).toBe(false);
+ });
+});
+
+describe("shouldFullLoad", () => {
+ it("forces a full load INTO analytics from an ordinary page", () => {
+ expect(shouldFullLoad("/tenant-admin/orders", "/tenant-admin/analytics")).toBe(true);
+ });
+
+ it("forces a full load OUT of analytics so the wider policy does not travel", () => {
+ expect(shouldFullLoad("/tenant-admin/analytics", "/tenant-admin/orders")).toBe(true);
+ });
+
+ it("keeps ordinary admin links client-side", () => {
+ expect(shouldFullLoad("/tenant-admin/orders", "/tenant-admin/customers")).toBe(false);
+ expect(shouldFullLoad(null, "/tenant-admin/customers")).toBe(false);
+ });
+});