diff --git a/.gitignore b/.gitignore
index dbcfb3e5..0af205f6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,6 +94,7 @@ packages/cli/.mcp-fixture/
packages/cli/.island-fixture/
packages/cli/.overrides-fixture/
packages/cli/.prerender-fixture/
+packages/cli/.prerender-app-root-fixture/
packages/cli/.mcp-host-fixture/
packages/cli/.roles-script-csp-fixture/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e821e956..311363f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,17 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major
## [Unreleased]
-Nothing yet.
+### Fixed
+
+- **render / cli:** a stylesheet's surface is read below the app root, never off its absolute path.
+ Under the scaffold container's `WORKDIR /app` every absolute path starts with an `app/` segment,
+ so every sheet — `site/` modules, `shared/global.scss`, `@ultimat3/ui`'s — classified as `app`,
+ `stylesFor('site')` was empty, and every prerendered and `site/` document shipped with no
+ ``. The same held for any root with `site/`, `api/` or `shared/` above it.
+ `loadApp(root)` now names the root before it imports a module (`setStylesheetRoot`, new in
+ `@ultimat3/render/server`; the working directory when unset), sheets registered earlier are
+ reclassified, and a sheet under `node_modules/` is a package sheet whatever its directories are
+ called. Route and boundary classification already read root-relative paths and are unchanged.
## 22.2.0 - 2026-09-24
diff --git a/packages/cli/src/app-load.ts b/packages/cli/src/app-load.ts
index 852d30d7..24de6140 100644
--- a/packages/cli/src/app-load.ts
+++ b/packages/cli/src/app-load.ts
@@ -19,8 +19,9 @@ import { isRouteConfig, pageComponentOf, registerRoute, routeEntries } from '@ul
// every app module below is loaded by the dynamic `import()` in this file. Before the render
// barrel split it came free with the line above; after it, the only other path to `/server` from
// here is six hops through `error-contract` → `fix-command` → the command registry, which is an
-// accident one refactor away from compiling every app's `.tsx` to `React.createElement`.
-import '@ultimat3/render/server';
+// accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named
+// import is a value import, so the side effect holds without the bare line it replaced.
+import { setStylesheetRoot } from '@ultimat3/render/server';
import { APP_CONFIG_FILE } from './app-root';
import { collectDeclaredCodes } from './error-contract';
import type { Finding } from './output';
@@ -108,6 +109,10 @@ export function resetAppLoad(): void {
}
export async function loadApp(root: string): Promise {
+ // Before any import: a stylesheet's surface is read below the app root, never off its absolute
+ // path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
+ // sheet, the site's included, classified as app CSS.
+ setStylesheetRoot(root);
const files: string[] = [];
const findings: Finding[] = [];
diff --git a/packages/cli/src/prerender-app-root.test.ts b/packages/cli/src/prerender-app-root.test.ts
new file mode 100644
index 00000000..43946b11
--- /dev/null
+++ b/packages/cli/src/prerender-app-root.test.ts
@@ -0,0 +1,74 @@
+// The static build under a root whose last segment is `app` — the scaffold container's
+// `WORKDIR /app`. A real app on disk, loaded the way `x build` loads it, because the defect lived in
+// how an IMPORTED stylesheet was classified: registering a sheet by hand would not reach it.
+
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import { rm } from 'node:fs/promises'; // why: Bun has no recursive remove, only a per-file delete.
+// why: Bun exposes no path-join primitive; Bun.file and import() take one already joined.
+import { join } from 'node:path';
+import { clearRoutes } from '@ultimat3/render';
+import { clearStylesheets, setStylesheetRoot } from '@ultimat3/render/server';
+import { prerenderSite } from './prerender';
+
+// Inside the package, so the fixture's `@ultimat3/*` imports resolve; `app` is the point.
+const FIXTURE = join(import.meta.dir, '..', '.prerender-app-root-fixture');
+const APP_ROOT = join(FIXTURE, 'app');
+
+beforeEach(async () => {
+ clearRoutes();
+ await rm(FIXTURE, { recursive: true, force: true });
+});
+
+afterEach(async () => {
+ clearRoutes();
+ // Both registries are process-global, and this file writes into them.
+ clearStylesheets();
+ setStylesheetRoot(undefined);
+ await rm(FIXTURE, { recursive: true, force: true });
+});
+
+// Reproduced in production: the scaffold's Dockerfile builds and serves from `WORKDIR /app`, and the
+// stylesheet loader read the surface off the ABSOLUTE path — whose first `app/` segment is the root
+// itself. Every sheet classified as `app`, the site bundle was empty, and every prerendered page
+// shipped with no ``. A real app on disk, loaded the way `x build` loads it, under a root
+// whose last segment is `app`.
+describe('an app whose root is a directory named app/', () => {
+ test('the site page links its own stylesheet, carrying the global layer and not app/ CSS', async () => {
+ await Bun.write(
+ join(APP_ROOT, 'package.json'),
+ JSON.stringify({ name: 'app-root-fixture', version: '1.0.0' }),
+ );
+ await Bun.write(join(APP_ROOT, 'apps/web/shared/global.scss'), ':root{--space-9:9rem}\n');
+ await Bun.write(join(APP_ROOT, 'apps/web/site/page.module.scss'), '.hero{margin:3px}\n');
+ await Bun.write(join(APP_ROOT, 'apps/web/app/feed/panel.module.scss'), '.panel{margin:7px}\n');
+ await Bun.write(
+ join(APP_ROOT, 'apps/web/site/page.tsx'),
+ [
+ "import { defineRoute } from '@ultimat3/render';",
+ "import '../shared/global.scss';",
+ "import styles from './page.module.scss';",
+ "export const config = defineRoute({ render: 'static', hydrate: 'never', offline: 'precache',",
+ " meta: () => ({ title: 'Home', description: 'the landing page' }) });",
+ 'export default function Home() { return ; }',
+ '',
+ ].join('\n'),
+ );
+ // An app/ module with CSS of its own, so an empty site bundle is not the only way to fail.
+ await Bun.write(
+ join(APP_ROOT, 'apps/web/app/feed/panel.ts'),
+ "import styles from './panel.module.scss';\nexport const panel = styles.panel;\n",
+ );
+ const out = join(APP_ROOT, 'static');
+
+ const report = await prerenderSite({ root: APP_ROOT, out, origin: 'https://example.test' });
+
+ const html = await Bun.file(join(out, 'index.html')).text();
+ const href = //.exec(html)?.groups?.['url'] ?? '';
+ expect(href).toMatch(/^\/styles\/[0-9a-f]{8}\.css$/);
+ expect(report.styles).toContain(href);
+ const css = await Bun.file(join(out, href.slice(1))).text();
+ expect(css).toContain('margin:3px');
+ expect(css).toContain('--space-9:9rem');
+ expect(css).not.toContain('margin:7px');
+ });
+});
diff --git a/packages/cli/src/style-bundle.test.ts b/packages/cli/src/style-bundle.test.ts
index 11e7b322..810d1c0a 100644
--- a/packages/cli/src/style-bundle.test.ts
+++ b/packages/cli/src/style-bundle.test.ts
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { rm } from 'node:fs/promises';
// why: Bun exposes no path API — nothing native joins a directory to a file.
import { join } from 'node:path';
-import { clearStylesheets, loadStylesheet } from '@ultimat3/render/server';
+import { clearStylesheets, loadStylesheet, setStylesheetRoot } from '@ultimat3/render/server';
import { STYLE_BASE_PATH, styleBundle, styleBundleOf, writeStyles } from './style-bundle';
const SITE = '/srv/demo/apps/web/site/page.module.scss';
@@ -24,6 +24,7 @@ beforeEach(() => {
afterEach(async () => {
clearStylesheets();
+ setStylesheetRoot(undefined);
await rm(OUT, { recursive: true, force: true });
});
@@ -100,3 +101,48 @@ describe('styleBundle', () => {
expect(await Bun.file(join(OUT, url.slice(1))).text()).toBe('.hero{color:red}');
});
});
+
+// The container's shape: the app runs from `WORKDIR /app`, so every sheet's absolute path begins
+// with an `app/` segment. The fingerprinted pipeline has to hold there exactly as it does in a
+// checkout — the site gets a file of its own, and its name moves with its bytes and only with them.
+describe('styleBundle, for an app served from /app', () => {
+ const SITE_SHEET = '/app/apps/web/site/page.module.scss';
+ const APP_SHEET = '/app/apps/web/app/feed/page.module.scss';
+ const GLOBAL_SHEET = '/app/apps/web/shared/global.scss';
+
+ beforeEach(() => {
+ setStylesheetRoot('/app');
+ });
+
+ test('the site gets its own content-hashed file, carrying the global layer and no app CSS', () => {
+ loadStylesheet(GLOBAL_SHEET, ':root{--space-9:9rem}');
+ loadStylesheet(SITE_SHEET, '.hero{color:red}');
+ loadStylesheet(APP_SHEET, '.feed{color:blue}');
+ const bundle = styleBundle();
+ const site = bundle.hrefFor('site') ?? '';
+ const app = bundle.hrefFor('app') ?? '';
+
+ expect(site).toMatch(new RegExp(`^${STYLE_BASE_PATH}/[0-9a-f]{8}\\.css$`));
+ expect(site).not.toBe(app);
+ const css = bundle.chunkAt(site)?.css ?? '';
+ expect(css).toContain('--space-9:9rem');
+ expect(css).toContain('color:red');
+ expect(css).not.toContain('color:blue');
+ });
+
+ test('the site URL is stable while its CSS is, and moves only when its CSS does', () => {
+ loadStylesheet(SITE_SHEET, '.hero{color:red}');
+ loadStylesheet(APP_SHEET, '.feed{color:blue}');
+ const first = styleBundle().hrefFor('site');
+
+ // The same bytes again, and an edit on the OTHER surface: neither is a reason to re-download.
+ loadStylesheet(SITE_SHEET, '.hero{color:red}');
+ loadStylesheet(APP_SHEET, '.feed{color:purple}');
+ expect(styleBundle().hrefFor('site')).toBe(first);
+
+ loadStylesheet(SITE_SHEET, '.hero{color:green}');
+ const moved = styleBundle().hrefFor('site') ?? '';
+ expect(moved).not.toBe(first);
+ expect(styleBundle().chunkAt(moved)?.css).toContain('color:green');
+ });
+});
diff --git a/packages/render/src/module-loader.test.ts b/packages/render/src/module-loader.test.ts
index 7c0ab782..af1c99e4 100644
--- a/packages/render/src/module-loader.test.ts
+++ b/packages/render/src/module-loader.test.ts
@@ -11,6 +11,7 @@ import {
JSX_FACTORY_SPECIFIER,
loadStylesheet,
registeredStylesheets,
+ setStylesheetRoot,
stylesFor,
stylesheetsRevision,
transformTsx,
@@ -27,6 +28,7 @@ const occurrences = (haystack: string, needle: string): number => haystack.split
afterEach(() => {
clearStylesheets();
+ setStylesheetRoot(undefined);
});
describe('installRenderLoader', () => {
@@ -231,3 +233,53 @@ describe('stylesFor', () => {
expect(stylesFor('app').startsWith(GLOBAL_CSS)).toBe(true);
});
});
+
+// The container runs the app from `WORKDIR /app`, so every absolute path a Bun plugin hands the
+// loader starts with an `app/` segment. Classified from the absolute path, the site page, the
+// global layer and the UI kit all landed on `app`, and every site document went out unstyled.
+describe('an app served from /app', () => {
+ const ROOT = '/app';
+ const SITE_SHEET = '/app/apps/web/site/page.module.scss';
+ const APP_SHEET = '/app/apps/web/app/feed/page.module.scss';
+ const GLOBAL_SHEET = '/app/apps/web/shared/global.scss';
+ const KIT_SHEET = '/app/node_modules/@ultimat3/ui/src/stack.module.scss';
+
+ test('each sheet is classified by where it sits in the app, not by the root it sits under', () => {
+ setStylesheetRoot(ROOT);
+ loadStylesheet(SITE_SHEET, '.hero{color:red}');
+ loadStylesheet(APP_SHEET, '.feed{color:blue}');
+ loadStylesheet(GLOBAL_SHEET, GLOBAL_CSS);
+ loadStylesheet(KIT_SHEET, '.stack{display:flex}');
+
+ const surfaces = Object.fromEntries(registeredStylesheets().map((s) => [s.file, s.surface]));
+ expect(surfaces).toEqual({
+ [SITE_SHEET]: 'site',
+ [APP_SHEET]: 'app',
+ [GLOBAL_SHEET]: 'shared',
+ [KIT_SHEET]: null,
+ });
+ const site = stylesFor('site');
+ expect(site).toContain('color:red');
+ expect(site).toContain('--color-fg:');
+ expect(site).toContain('display:flex');
+ expect(site).not.toContain('color:blue');
+ });
+
+ // The order `x build` meets: a sheet can register before `loadApp` names the root, and a
+ // classification frozen at registration would keep the answer the wrong root gave.
+ test('naming the root reclassifies what registered before it, and moves the revision', () => {
+ setStylesheetRoot('/');
+ loadStylesheet(SITE_SHEET, '.hero{color:red}');
+ expect(stylesFor('site')).toBe('');
+
+ const before = stylesheetsRevision();
+ setStylesheetRoot(ROOT);
+ expect(stylesFor('site')).toContain('color:red');
+ expect(stylesheetsRevision()).toBeGreaterThan(before);
+
+ // Naming the same root again changes no answer, so it mints no new stylesheet URL.
+ const settled = stylesheetsRevision();
+ setStylesheetRoot(ROOT);
+ expect(stylesheetsRevision()).toBe(settled);
+ });
+});
diff --git a/packages/render/src/module-loader.ts b/packages/render/src/module-loader.ts
index 31bf35cc..c2ea54df 100644
--- a/packages/render/src/module-loader.ts
+++ b/packages/render/src/module-loader.ts
@@ -8,7 +8,7 @@ import { renderThrowable } from '@ultimat3/core';
import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
import { PrerenderFailedError } from './errors';
import type { Surface } from './surfaces';
-import { surfaceOf } from './surfaces';
+import { surfaceUnder } from './surfaces';
/**
* Why a transform at all: `tsconfig.json` says `jsx: 'preserve'`, which makes Bun fall back to the
@@ -101,6 +101,32 @@ export function registeredStylesheets(): readonly Stylesheet[] {
return [...stylesheets.values()];
}
+/**
+ * The directory a sheet's surface is read below. Absent, the process's working directory — which
+ * is the app root in the container (`WORKDIR /app`) and under every `x` command run from one.
+ */
+let stylesheetRoot: string | undefined;
+
+const surfaceOfSheet = (path: string): Surface | null =>
+ surfaceUnder(stylesheetRoot ?? process.cwd(), path);
+
+/**
+ * Names the app root the registry classifies against; `loadApp` calls it before importing a module.
+ * Every sheet already registered is classified again, because a sheet can load before the root is
+ * named — and a classification frozen then would keep the answer the wrong root gave. The revision
+ * moves only when an answer does, so naming the same root twice mints no new stylesheet URL.
+ * `undefined` returns to the working directory.
+ */
+export function setStylesheetRoot(root: string | undefined): void {
+ stylesheetRoot = root;
+ for (const [path, sheet] of stylesheets) {
+ const surface = surfaceOfSheet(path);
+ if (surface === sheet.surface) continue;
+ stylesheets.set(path, { ...sheet, surface });
+ revision += 1;
+ }
+}
+
/** Test seam: the registry is process-global because the module cache it mirrors is too. */
export function clearStylesheets(): void {
if (stylesheets.size > 0) revision += 1;
@@ -159,7 +185,7 @@ export function loadStylesheet(path: string, source: string): string {
if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
stylesheets.set(path, {
file: path,
- surface: surfaceOf(path),
+ surface: surfaceOfSheet(path),
global: isGlobalStylesheet(path),
css: compiled.css,
});
diff --git a/packages/render/src/server.ts b/packages/render/src/server.ts
index 6cb04a48..2a493d76 100644
--- a/packages/render/src/server.ts
+++ b/packages/render/src/server.ts
@@ -30,6 +30,7 @@ export {
installRenderLoader,
loadStylesheet,
registeredStylesheets,
+ setStylesheetRoot,
stylesFor,
stylesheetsRevision,
transformTsx,
diff --git a/packages/render/src/surfaces.test.ts b/packages/render/src/surfaces.test.ts
index ccfce203..37842609 100644
--- a/packages/render/src/surfaces.test.ts
+++ b/packages/render/src/surfaces.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { SurfaceBoundaryError } from './errors';
-import { assertSurfaceBoundary, checkSurfaceBoundary, importGraph, surfaceOf } from './surfaces';
+import {
+ assertSurfaceBoundary,
+ checkSurfaceBoundary,
+ importGraph,
+ surfaceOf,
+ surfaceUnder,
+} from './surfaces';
describe('surfaceOf', () => {
test('reads the surface out of a monorepo path', () => {
@@ -11,6 +17,47 @@ describe('surfaceOf', () => {
});
});
+// Reproduced in production: the scaffold's Dockerfile runs the app from `WORKDIR /app`, so every
+// ABSOLUTE stylesheet path starts `/app/` — and the first `app/` segment won. The site page, the
+// shared global layer and every package sheet classified as `app`, `stylesFor('site')` was empty,
+// and every prerendered page shipped with no `` at all.
+describe('surfaceUnder', () => {
+ test('an app root named app/ does not make every file an app/ file', () => {
+ expect(surfaceUnder('/app', '/app/apps/web/site/page.module.scss')).toBe('site');
+ expect(surfaceUnder('/app', '/app/apps/web/shared/global.scss')).toBe('shared');
+ expect(surfaceUnder('/app', '/app/apps/web/app/feed/page.module.scss')).toBe('app');
+ expect(surfaceUnder('/app', '/app/packages/ui/src/card.module.scss')).toBe(null);
+ });
+
+ test('nor does a root with site/ somewhere above it', () => {
+ expect(surfaceUnder('/srv/site/x', '/srv/site/x/apps/web/app/feed/page.module.scss')).toBe(
+ 'app',
+ );
+ expect(surfaceUnder('/srv/site/x/', '/srv/site/x/apps/web/shared/global.scss')).toBe('shared');
+ });
+
+ // A package sheet is carried by both graphs; `node_modules/lib/app/theme.scss` is not app code
+ // because the library happens to keep its sheets in a directory called `app`.
+ test('an installed package sheet has no surface, whatever directories it sits in', () => {
+ expect(surfaceUnder('/app', '/app/node_modules/@ultimat3/ui/src/card.module.scss')).toBe(null);
+ expect(surfaceUnder('/app', '/app/node_modules/some-lib/app/theme.scss')).toBe(null);
+ expect(
+ surfaceUnder('/app', '/app/apps/web/node_modules/@ultimat3/ui/src/stack.module.scss'),
+ ).toBe(null);
+ });
+
+ test('a file outside the root is read as it always was', () => {
+ expect(surfaceUnder('/app', '/srv/demo/apps/web/site/page.module.scss')).toBe('site');
+ expect(surfaceUnder('/app', '/srv/demo/packages/ui/src/card.module.scss')).toBe(null);
+ // A sibling whose name merely STARTS with the root is not under it.
+ expect(surfaceUnder('/app', '/apple/apps/web/site/page.module.scss')).toBe('site');
+ });
+
+ test('Windows separators are read as POSIX ones', () => {
+ expect(surfaceUnder('C:\\app', 'C:\\app\\apps\\web\\site\\page.module.scss')).toBe('site');
+ });
+});
+
describe('checkSurfaceBoundary', () => {
// The exact failure this prevents: