Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ For the smallest development import graph, use a component subpath:
import { AtTheHorizon } from "@designcodeio/threeui/components/AtTheHorizon";
```

Components that render full HTML documents expect their runtime files at the same root-relative URLs used by the ThreeUI preview. Copy the needed files from `node_modules/@designcodeio/threeui/lib-dist/assets/` into your app's public directory, or override the component's `sourceUrl` or `assetBaseUrl` prop where available.
Components that render full HTML documents expect their runtime files at the same root-relative URLs used by the ThreeUI preview. Copy the needed files from `node_modules/@designcodeio/threeui/lib-dist/assets/` into your app's public directory, or override the component's `sourceUrl` or `assetBaseUrl` prop where available. `SylvaLivingWorldScene` inlines its Lexend font so its sandboxed scene does not require a cross-origin font request; pass `assetBaseUrl` when serving additional relative assets from a different location.

## Pro source access

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"sync:community": "node scripts/sync-community-from-main.mjs",
"generate:lib": "node scripts/generate-library-entry.mjs",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/community-sanitizers.test.mjs scripts/public-boundary.test.mjs scripts/library-package.test.mjs scripts/prepare-community-release.test.mjs packages/cli/test/*.test.mjs",
"test": "node --test scripts/community-sanitizers.test.mjs scripts/public-boundary.test.mjs scripts/library-package.test.mjs scripts/prepare-community-release.test.mjs scripts/sylva-living-world.test.mjs packages/cli/test/*.test.mjs",
"audit:public": "node scripts/audit-public.mjs",
"build:site": "npm run typecheck && npm run audit:public && vite build && node scripts/audit-build.mjs",
"build:lib": "npm run generate:lib && vite build --config vite.lib.config.js && tsc -p tsconfig.lib.json && node scripts/copy-library-assets.mjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ export async function installBundle(bundle, { directory = process.cwd(), force =
await mkdir(dirname(target.destination), { recursive: true });
await writeFile(target.destination, target.bytes);
}
return targets.map((target) => relative(root, target.destination));
return targets.map((target) => relative(root, target.destination).split(sep).join("/"));
}
10 changes: 6 additions & 4 deletions scripts/package-install-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const npmCommand = process.platform === "win32" ? process.env.ComSpec ?? "cmd.exe" : "npm";
const npmPrefix = process.platform === "win32" ? ["/d", "/s", "/c", "npm.cmd"] : [];
const temporaryRoot = await mkdtemp(join(tmpdir(), "threeui-package-smoke-"));
const packDirectory = join(temporaryRoot, "pack");
const consumerDirectory = join(temporaryRoot, "consumer");
Expand All @@ -28,15 +30,15 @@ try {
writeFile(emptyGlobalConfig, ""),
]);
const packResult = JSON.parse(execFileSync(
"npm",
["pack", "--ignore-scripts", "--json", "--pack-destination", packDirectory],
npmCommand,
[...npmPrefix, "pack", "--ignore-scripts", "--json", "--pack-destination", packDirectory],
{ cwd: root, encoding: "utf8", env: environment },
));
const tarball = join(packDirectory, packResult[0].filename);
await writeFile(join(consumerDirectory, "package.json"), `${JSON.stringify({ private: true, type: "module" }, null, 2)}\n`);
execFileSync(
"npm",
["install", "--ignore-scripts", tarball, "react@19", "react-dom@19", "three@0.149.0"],
npmCommand,
[...npmPrefix, "install", "--ignore-scripts", tarball, "react@19", "react-dom@19", "three@0.149.0"],
{ cwd: consumerDirectory, stdio: "inherit", env: environment },
);
execFileSync(
Expand Down
8 changes: 6 additions & 2 deletions scripts/public-boundary.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const sourceRegistry = JSON.parse(await readFile(join(root, "public", "source-co
const styles = await readFile(join(root, "src", "styles.css"));
const rendererStyles = await readFile(join(root, "src", "shaders", "community.css"));

function snapshotHash(value) {
return createHash("sha256").update(value.toString("utf8").replace(/\r\n/g, "\n")).digest("hex");
}

const vite = await createServer({ root, server: { middlewareMode: true }, appType: "custom", logLevel: "silent" });
let catalog;
try {
Expand Down Expand Up @@ -104,11 +108,11 @@ test("all Community parents include their implementation source", () => {
});

test("the main layout stylesheet remains byte-identical to the synchronized snapshot", () => {
assert.equal(createHash("sha256").update(styles).digest("hex"), report.layoutStylesSha256);
assert.equal(snapshotHash(styles), report.layoutStylesSha256);
});

test("the main Community renderer styles are present without Pro or Beta selectors", async () => {
assert.equal(createHash("sha256").update(rendererStyles).digest("hex"), report.rendererStylesSha256);
assert.equal(snapshotHash(rendererStyles), report.rendererStylesSha256);
const css = rendererStyles.toString("utf8");
for (const selector of [
".threeui-background",
Expand Down
32 changes: 32 additions & 0 deletions scripts/sylva-living-world.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFile, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const componentPath = join(root, "src", "shaders", "sylva-living-world", "SylvaLivingWorldScene.tsx");
const source = await readFile(componentPath, "utf8");
const syncSource = await readFile(join(root, "scripts", "sync-community-from-main.mjs"), "utf8");

test("Sylva keeps its sandboxed scene font self-contained", async () => {
assert.match(source, /lexend-latin\.woff2\?inline/);
assert.match(source, /assetBaseUrl\?: string/);
assert.match(source, /<base href=/);
assert.match(source, /sandbox="allow-scripts"/);
assert.doesNotMatch(source, /sandbox="allow-scripts allow-same-origin"/);
await stat(join(root, "src", "shaders", "sylva-living-world", "sources", "inner-green-assets", "lexend-latin.woff2"));
});

test("Sylva preserves the scene-only adapter and reduced-motion path", () => {
assert.match(source, /SCENE_ONLY_MARKUP/);
assert.match(source, /data-threeui-three-runtime/);
assert.match(source, /prefers-reduced-motion/);
assert.match(source, /requestAnimationFrame\(loop\)/);
});

test("community sync preserves the self-contained Sylva font", () => {
assert.match(syncSource, /copySylvaLivingWorldFont/);
assert.match(syncSource, /lexend-latin\.woff2/);
assert.match(syncSource, /generatePatchedSylvaLivingWorldModule/);
});
50 changes: 48 additions & 2 deletions scripts/sync-community-from-main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ async function copyFromSource(path) {
await copyFile(from, to);
}

async function copySylvaLivingWorldFont() {
const targetPath = "src/shaders/sylva-living-world/sources/inner-green-assets/lexend-latin.woff2";
try {
await copyFromSource(targetPath);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
await mkdir(dirname(join(projectRoot, targetPath)), { recursive: true });
await copyFile(
join(sourceRoot, "public", "landing-pages", "inner-green-assets", "lexend-latin.woff2"),
join(projectRoot, targetPath),
);
}
}

async function writeGenerated(path, contents) {
const target = join(projectRoot, path);
await mkdir(dirname(target), { recursive: true });
Expand Down Expand Up @@ -368,17 +382,48 @@ export function TempleNightScene({ className = "" }: TempleNightSceneProps) {
function generateSylvaLivingWorldModule() {
return `import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
import innerGreenSource from "./sources/inner-green-3d.html?raw";
import lexendFont from "./sources/inner-green-assets/lexend-latin.woff2?inline";
import threeRuntime from "./sources/inner-green-assets/three.min.js?raw";
export const SYLVA_LIVING_WORLD_VARIANTS = ["living-green"] as const;
export type SylvaLivingWorldVariant = "living-green";
export type SylvaLivingWorldSceneProps = { variant?: SylvaLivingWorldVariant; className?: string; style?: CSSProperties };
export type SylvaLivingWorldSceneProps = { variant?: SylvaLivingWorldVariant; assetBaseUrl?: string; className?: string; style?: CSSProperties };
const DEFAULT_ASSET_BASE_URL = "/landing-pages/";
const SCENE_ONLY_MARKUP = \`<main class="hero" id="hero"><canvas id="scene" role="img" aria-label="Sylva Living Green"></canvas><div class="stage" id="stage" aria-hidden="true"></div></main>\`;
const SCENE_ONLY_STYLE = \`<style data-threeui-sylva-scene>html,body{width:100%!important;height:100%!important;min-height:0!important;margin:0!important;overflow:hidden!important}body{position:relative!important;background:#4a4d44!important}.hero{height:100%!important;min-height:0!important}#scene{pointer-events:auto!important}</style>\`;
function buildSceneDocument(reducedMotion: boolean) { const presentationStart = innerGreenSource.indexOf('<main class="hero" id="hero">'); const runtimeStart = innerGreenSource.indexOf('<script src="inner-green-assets/three.min.js"></script>'); if (presentationStart < 0 || runtimeStart < 0 || runtimeStart <= presentationStart) throw new Error("Sylva scene adapter could not isolate the authored Three.js scene."); let source = \`\${innerGreenSource.slice(0, presentationStart)}\${SCENE_ONLY_MARKUP}\n\n\${innerGreenSource.slice(runtimeStart)}\`.replace("</head>", \`\${SCENE_ONLY_STYLE}</head>\`).replace('<script src="inner-green-assets/three.min.js"></script>', \`<script data-threeui-three-runtime>\${threeRuntime}</script>\`); if (reducedMotion) source = source.replace("(function loop() { requestAnimationFrame(loop); tick(); })();", "(function loop() { if (!REDUCED) requestAnimationFrame(loop); tick(); })();"); return source; }
export function SylvaLivingWorldScene({ className = "", style }: SylvaLivingWorldSceneProps) { const hostRef = useRef<HTMLDivElement>(null); const [hostVisible, setHostVisible] = useState(true); const [documentVisible, setDocumentVisible] = useState(() => typeof document === "undefined" || !document.hidden); const [reducedMotion, setReducedMotion] = useState(() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches); const [ready, setReady] = useState(false); useEffect(() => { const host = hostRef.current; if (!host || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver(([entry]) => setHostVisible(entry?.isIntersecting ?? true)); observer.observe(host); return () => observer.disconnect(); }, []); useEffect(() => { if (typeof document === "undefined") return; const update = () => setDocumentVisible(!document.hidden); document.addEventListener("visibilitychange", update); return () => document.removeEventListener("visibilitychange", update); }, []); useEffect(() => { const media = window.matchMedia("(prefers-reduced-motion: reduce)"); const update = () => setReducedMotion(media.matches); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); const source = useMemo(() => buildSceneDocument(reducedMotion), [reducedMotion]); const mounted = hostVisible && documentVisible; useEffect(() => setReady(false), [mounted, reducedMotion]); return <div ref={hostRef} className={\`threeui-background sylva-living-world-scene\${className ? \` \${className}\` : ""}\`} role="img" aria-label="Sylva Living Green with ferns, flowers, pollen, and a butterfly" data-variant="living-green" data-state={ready ? "ready" : "loading"} style={{ background: "#4a4d44", pointerEvents: "auto", ...style }}>{mounted ? <iframe key={reducedMotion ? "reduced" : "motion"} title="Sylva Living Green" srcDoc={source} sandbox="allow-scripts" loading="eager" onLoad={() => setReady(true)} style={{ position: "absolute", inset: 0, display: "block", width: "100%", height: "100%", border: 0, background: "#4a4d44" }} /> : null}</div>; }
`;
}

function generatePatchedSylvaLivingWorldModule() {
let source = generateSylvaLivingWorldModule();
source = source.replace(
"function buildSceneDocument(reducedMotion: boolean) {",
[
"function normalizeAssetBaseUrl(assetBaseUrl: string) { const value = assetBaseUrl.trim(); if (!value) return DEFAULT_ASSET_BASE_URL; return value.endsWith(\"/\") ? value : value + \"/\"; }",
'function escapeHtmlAttribute(value: string) { return value.replaceAll("&", "&amp;").replaceAll(\'"\', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;"); }',
"function buildSceneDocument(reducedMotion: boolean, assetBaseUrl: string) {",
].join("\n"),
);
source = source.replace(
"if (reducedMotion) source = source.replace",
"source = source.replace(\"url('inner-green-assets/lexend-latin.woff2')\", \"url(\\\"\" + lexendFont + \"\\\")\"); source = source.replace(\"<head>\", \"<head><base href=\\\"\" + escapeHtmlAttribute(normalizeAssetBaseUrl(assetBaseUrl)) + \"\\\">\"); if (reducedMotion) source = source.replace",
);
source = source.replace(
'export function SylvaLivingWorldScene({ className = "", style }',
'export function SylvaLivingWorldScene({ assetBaseUrl = DEFAULT_ASSET_BASE_URL, className = "", style }',
);
source = source.replace(
"buildSceneDocument(reducedMotion), [reducedMotion]",
"buildSceneDocument(reducedMotion, assetBaseUrl), [assetBaseUrl, reducedMotion]",
);
source = source.replace(
"setReady(false), [mounted, reducedMotion]",
"setReady(false), [mounted, reducedMotion, assetBaseUrl]",
);
return source;
}

function sanitizeSparkBadgeHtml(source) {
const replaceBetween = (value, start, end, replacement = "") => {
const from = value.indexOf(start);
Expand Down Expand Up @@ -592,6 +637,7 @@ for (const path of componentPaths) {
for (const path of assetPaths) {
if (!excludedAssetPaths.has(path)) await copyFromSource(path);
}
if (freeIds.has("sylva-living-world")) await copySylvaLivingWorldFont();

await writeGenerated("src/data/shaders.tsx", generateShadersModule(freeShaders, registryById, shaderModule.getShaderAccess));
const mainSidebarSource = await readFile(join(sourceRoot, "src/components/Sidebar.tsx"), "utf8");
Expand Down Expand Up @@ -633,7 +679,7 @@ await writeGenerated(
);
await writeGenerated("src/shaders/spark-badge/SparkBadge.tsx", generateSparkBadgeModule());
await writeGenerated("src/shaders/temple-night/TempleNightScene.tsx", generateTempleNightModule());
await writeGenerated("src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx", generateSylvaLivingWorldModule());
await writeGenerated("src/shaders/sylva-living-world/SylvaLivingWorldScene.tsx", generatePatchedSylvaLivingWorldModule());

const sparkHtml = sanitizeSparkBadgeHtml(await readFile(join(sourceRoot, "public", "spark-badge.html"), "utf8"));
await writeFile(join(projectRoot, "public", "spark-badge.html"), sparkHtml);
Expand Down
Loading