diff --git a/docs/skills/component-testing.md b/docs/skills/component-testing.md index 2d393662..7cdf1bc7 100644 --- a/docs/skills/component-testing.md +++ b/docs/skills/component-testing.md @@ -131,6 +131,25 @@ assert.ok( assert.equal(render(props), render(props)); ``` +## Effectful visual components + +`renderToStaticMarkup` does not run effects. For a component whose visible +behavior depends on scrolling, resizing, media queries, or browser observers: + +1. Extract coordinate math and state transitions into a pure TypeScript module + and test that module with `node:test`. +2. Static-render the component shell to prove deterministic, accessible SSR + markup and verify browser globals are deferred to `useEffect`. +3. Run `npm run build:ci` to exercise Docusaurus server rendering and route + generation. +4. Start `just dev --port 3000` and use local Chromium for the behavior only a + browser can prove: transforms, overflow, responsive visibility, focus, media + preferences, console errors, and hydration. + +Do not add a DOM test framework merely to simulate browser layout. Pure logic, +static SSR output, the production build, and one local-browser check are the +smallest complete test stack for this class of component. + ## Common Rationalizations | Rationalization | Reality | diff --git a/scripts/portal-parallax-model.test.js b/scripts/portal-parallax-model.test.js new file mode 100644 index 00000000..b3397c93 --- /dev/null +++ b/scripts/portal-parallax-model.test.js @@ -0,0 +1,111 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); + +function loadTsModule(file) { + const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + }, + }); + const mod = { exports: {} }; + new Function("require", "module", "exports", outputText)( + require, + mod, + mod.exports, + ); + return mod.exports; +} + +const modelPath = path.join( + __dirname, + "..", + "src", + "components", + "portal", + "portalModel.ts", +); + +test("portal model preserves the website layer contract", () => { + const { + PORTAL_BREAKPOINT_PX, + PORTAL_LAYERS, + MOBILE_LAYER_SRC, + TRANSITION_SRC, + } = loadTsModule(modelPath); + + assert.equal(PORTAL_BREAKPOINT_PX, 956); + assert.equal(PORTAL_LAYERS.length, 15); + assert.deepEqual( + PORTAL_LAYERS.map(({ key, top, rate }) => [key, top, rate]), + [ + ["sky", 0, 0], + ["clouds-right", -60, 0], + ["sun", -90, 0.05], + ["clouds-left", 0, 0], + ["mountains", 0, 0], + ["fog-a", 0, 0], + ["background-a", 165, 0], + ["fog-b", 200, 0], + ["background-b", 175, -0.01], + ["midground-a", 210, -0.03], + ["midground-b", 250, -0.05], + ["midground-c", 300, -0.07], + ["foreground-a", 320, -0.09], + ["foreground-b", 340, -0.11], + ["foreground-c", 360, -0.13], + ], + ); + assert.equal(PORTAL_LAYERS[1].drift, "right"); + assert.equal(PORTAL_LAYERS[3].drift, "left"); + assert.equal( + PORTAL_LAYERS[1].src, + PORTAL_LAYERS[3].src, + "byte-identical clouds must share one copied asset", + ); + assert.equal(MOBILE_LAYER_SRC, "/img/portal/mobile-parallax.webp"); + assert.equal(TRANSITION_SRC, "/img/portal/layer-transition.webp"); + + const uniqueSources = new Set(PORTAL_LAYERS.map(({ src }) => src)); + assert.equal(uniqueSources.size, 14); + for (const src of [...uniqueSources, MOBILE_LAYER_SRC, TRANSITION_SRC]) { + assert.ok( + fs.existsSync(path.join(__dirname, "..", "static", src)), + `missing copied asset ${src}`, + ); + } + for (const character of ["bluefin.webp", "karl.webp", "nest.webp"]) { + assert.ok( + fs.existsSync( + path.join( + __dirname, + "..", + "static", + "img", + "portal", + "characters", + character, + ), + ), + `missing copied character ${character}`, + ); + } +}); + +test("portal motion math clamps overlay, preserves rates, and culls", () => { + const { overlayOpacity, layerTransform, isParallaxVisible } = + loadTsModule(modelPath); + + assert.equal(overlayOpacity(0, 900), 0); + assert.equal(overlayOpacity(225, 900), 0); + assert.equal(overlayOpacity(550, 900), 0.5); + assert.equal(overlayOpacity(875, 900), 1); + assert.equal(overlayOpacity(2000, 900), 1); + assert.equal(layerTransform(100, -0.13), "translate3d(0, -13px, 0)"); + assert.equal(layerTransform(100, 0.05), "translate3d(0, 5px, 0)"); + assert.equal(isParallaxVisible(3000, 3000), true); + assert.equal(isParallaxVisible(3001, 3000), false); +}); diff --git a/scripts/portal-parallax-render.test.js b/scripts/portal-parallax-render.test.js new file mode 100644 index 00000000..d3ccb5e0 --- /dev/null +++ b/scripts/portal-parallax-render.test.js @@ -0,0 +1,77 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); +const React = require("react"); +const { renderToStaticMarkup } = require("react-dom/server"); + +function loadModule(file) { + const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + jsx: ts.JsxEmit.React, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + esModuleInterop: true, + }, + }); + const mod = { exports: {} }; + new Function("require", "module", "exports", outputText)( + (id) => { + if (id.endsWith(".css")) return {}; + if (id.startsWith(".")) { + const base = path.resolve(path.dirname(file), id); + for (const suffix of [".ts", ".tsx", "/index.ts", "/index.tsx"]) { + if (fs.existsSync(base + suffix)) return loadModule(base + suffix); + } + } + return require(id); + }, + mod, + mod.exports, + ); + return mod.exports; +} + +const componentPath = path.join( + __dirname, + "..", + "src", + "components", + "portal", + "PortalParallax.tsx", +); + +test("parallax statically renders fifteen decorative desktop planes", () => { + const PortalParallax = loadModule(componentPath).default; + const html = renderToStaticMarkup(React.createElement(PortalParallax)); + + assert.ok(html.includes('data-portal-parallax="true"')); + assert.equal(html.split('data-portal-mode="desktop"').length - 1, 15); + assert.equal(html.split('data-portal-mode="mobile"').length - 1, 1); + assert.equal(html.split('data-layer="').length - 1, 15); + assert.ok(html.includes('data-layer="sun"')); + assert.ok(html.includes('data-rate="0.05"')); + assert.ok(html.includes('data-rate="-0.13"')); + assert.ok(html.includes('aria-hidden="true"')); + assert.equal(html.split('alt=""').length - 1, 16); +}); + +test("browser globals are deferred to the effect", () => { + const source = fs.readFileSync(componentPath, "utf8"); + const effect = source.indexOf("React.useEffect"); + + assert.notEqual(effect, -1); + for (const browserGlobal of [ + "window.scrollY", + "window.innerHeight", + "window.matchMedia", + "document.getElementById", + "ResizeObserver", + ]) { + assert.ok( + source.indexOf(browserGlobal) > effect, + `${browserGlobal} must only appear inside useEffect`, + ); + } +}); diff --git a/scripts/portal-prototype-page.test.js b/scripts/portal-prototype-page.test.js new file mode 100644 index 00000000..0a0f6719 --- /dev/null +++ b/scripts/portal-prototype-page.test.js @@ -0,0 +1,116 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const ts = require("typescript"); +const React = require("react"); +const { renderToStaticMarkup } = require("react-dom/server"); + +function loadModule(file) { + const { outputText } = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { + jsx: ts.JsxEmit.React, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + esModuleInterop: true, + }, + }); + const mod = { exports: {} }; + new Function("require", "module", "exports", outputText)( + (id) => { + if (id.endsWith(".css")) return {}; + if (id.startsWith(".")) { + const base = path.resolve(path.dirname(file), id); + for (const suffix of [".ts", ".tsx", "/index.ts", "/index.tsx"]) { + if (fs.existsSync(base + suffix)) return loadModule(base + suffix); + } + } + return require(id); + }, + mod, + mod.exports, + ); + return mod.exports; +} + +const root = path.join(__dirname, ".."); +const componentPath = path.join( + root, + "src", + "components", + "portal", + "PortalPrototype.tsx", +); +const pagePath = path.join(root, "src", "pages", "portal-prototype.tsx"); +const cssPath = path.join( + root, + "src", + "components", + "portal", + "PortalPrototype.module.css", +); + +test("prototype renders source-authored scenes in order", () => { + const PortalPrototype = loadModule(componentPath).default; + const html = renderToStaticMarkup(React.createElement(PortalPrototype)); + + const ids = [ + 'id="scene-landing"', + 'id="scene-users"', + 'id="scene-developers"', + 'id="scene-mission"', + ]; + ids.reduce((previous, id) => { + const current = html.indexOf(id); + assert.ok(current > previous, `${id} must follow the previous scene`); + return current; + }, -1); + + assert.ok(html.includes('id="portal-scenes"')); + assert.match(html, /]*>\s*]*alt="Bluefin"[^>]*\/>\s*<\/h1>/); + assert.match( + html, + /]*>\s*]*src="\/img\/bluefin-wordmark-light\.svg"[^>]*\/>\s*<\/h1>/, + ); + assert.ok( + html.includes( + "The next generation Linux workstation, designed for reliability, performance, and sustainability.", + ), + ); + assert.ok(html.includes(">For<")); + assert.ok(html.includes(">You<")); + assert.ok(html.includes(">Developers<")); + assert.ok(html.includes(">Mission<")); + assert.ok(html.includes('href="#scene-users"')); + assert.match(html, /id="scene-users"[^>]*tabindex="-1"/); + assert.ok(html.includes('src="/img/portal/layer-transition.webp"')); +}); + +test("route stays temporary and does not replace the documentation root", () => { + const page = fs.readFileSync(pagePath, "utf8"); + const index = fs.readFileSync(path.join(root, "docs", "index.md"), "utf8"); + + assert.ok(page.includes("PortalPrototype")); + assert.ok(page.includes(" { + const css = fs.readFileSync(cssPath, "utf8"); + + assert.match(css, /\.parallaxViewport\s*\{[^}]*overflow:\s*clip/s); + assert.match(css, /\.parallaxLayer\s*\{[^}]*position:\s*absolute/s); + assert.match( + css, + /\.contentScene\s*\{[^}]*scroll-margin-top:\s*var\(--ifm-navbar-height/s, + ); + assert.match(css, /@media \(max-width:\s*956px\)/); + assert.match(css, /@media \(prefers-reduced-motion:\s*reduce\)/); + const sharedGridMatches = css.match(/\.landingGrid,\s*\.twoColumn/g); + assert.equal(sharedGridMatches?.length, 2); + assert.ok(!css.includes("scroll-behavior")); + assert.ok(!css.includes("html {")); + assert.ok(!css.includes(":root {")); +}); diff --git a/src/components/portal/PortalParallax.tsx b/src/components/portal/PortalParallax.tsx new file mode 100644 index 00000000..29c3d0c7 --- /dev/null +++ b/src/components/portal/PortalParallax.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import styles from "./PortalPrototype.module.css"; +import { + MOBILE_LAYER_SRC, + PORTAL_BREAKPOINT_PX, + PORTAL_LAYERS, + isParallaxVisible, + layerTransform, + overlayOpacity, +} from "./portalModel"; + +const TRANSPARENT_PIXEL = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw=="; + +function ResponsiveImage({ + src, + media, + priority = false, +}: { + src: string; + media: string; + priority?: boolean; +}): React.JSX.Element { + return ( + + + + + ); +} + +export default function PortalParallax(): React.JSX.Element { + const rootRef = React.useRef(null); + + React.useEffect(() => { + const root = rootRef.current; + const scenes = document.getElementById("portal-scenes"); + if (!root || !scenes) return; + + const rateLayers = Array.from( + root.querySelectorAll("[data-rate]"), + (layer) => ({ + element: layer, + rate: Number(layer.dataset.rate ?? 0), + }), + ); + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + let frame = 0; + + const render = (): void => { + frame = 0; + const scrollY = window.scrollY; + const reduced = reducedMotion.matches; + const sceneEnd = scenes.getBoundingClientRect().bottom + scrollY; + root.hidden = !isParallaxVisible(scrollY, sceneEnd); + root.style.setProperty( + "--portal-night-opacity", + reduced ? "0" : String(overlayOpacity(scrollY, window.innerHeight)), + ); + for (const { element, rate } of rateLayers) { + element.style.transform = reduced + ? "none" + : layerTransform(scrollY, rate); + } + }; + + const schedule = (): void => { + if (!frame) frame = window.requestAnimationFrame(render); + }; + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => { + schedule(); + }); + resizeObserver.observe(scenes); + } + + render(); + window.addEventListener("scroll", schedule, { passive: true }); + window.addEventListener("resize", schedule); + reducedMotion.addEventListener("change", schedule); + + return () => { + window.removeEventListener("scroll", schedule); + window.removeEventListener("resize", schedule); + reducedMotion.removeEventListener("change", schedule); + if (resizeObserver) resizeObserver.disconnect(); + if (frame) window.cancelAnimationFrame(frame); + }; + }, []); + + return ( + + ); +} diff --git a/src/components/portal/PortalPrototype.module.css b/src/components/portal/PortalPrototype.module.css new file mode 100644 index 00000000..631fb0ec --- /dev/null +++ b/src/components/portal/PortalPrototype.module.css @@ -0,0 +1,385 @@ +.portal { + --portal-bg: #0c1016; + --portal-bg-rgb: 12, 16, 22; + --portal-border: #272727; + --portal-border-light: #616161; + --portal-text: #bdbdbd; + --portal-text-light: #ffffff; + --portal-blue-light: #8a97f7; + position: relative; + isolation: isolate; + overflow-x: clip; + background: var(--portal-bg); + color: var(--portal-text-light); +} + +.sceneStack { + position: relative; + z-index: 10; + padding-bottom: 128px; +} + +.landingScene, +.contentScene { + position: relative; + min-height: 100dvh; + border-bottom: 1px solid var(--portal-border); +} + +.landingScene { + display: grid; + align-items: center; + z-index: 50; +} + +.landingGrid, +.twoColumn, +.developerLayout, +.missionGrid { + display: grid; + grid-template-columns: 1fr 1fr; + align-items: center; + gap: 40px; + width: min(1200px, 100%); + margin: 0 auto; + padding: 64px; +} + +.landingCopy { + text-align: center; + color: var(--portal-bg); + text-shadow: 1px 1px 1px rgb(0 0 0 / 25%); +} + +.landingCopy p { + margin: 24px 0 30px; + font-size: 30px; + line-height: 1.3; +} + +.wordmarkTitle { + margin: 0; + padding: 0; + font-size: inherit; + font-weight: inherit; + line-height: inherit; +} + +.wordmark { + display: block; + width: min(503px, 100%); + height: auto; + margin: 0 auto; + border: 0; +} + +.heroCharacter { + display: block; + width: min(692px, 100%); + justify-self: center; + border: 0; +} + +.primaryAction { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + padding: 10px 24px; + border: 1px solid var(--portal-bg); + border-radius: 999px; + background: var(--portal-blue-light); + color: var(--portal-bg); + font-size: 18px; + font-weight: 700; + text-decoration: none; +} + +.primaryAction:focus-visible { + outline: 3px solid var(--portal-text-light); + outline-offset: 3px; +} + +.contentScene { + display: grid; + align-items: center; + scroll-margin-top: var(--ifm-navbar-height, 60px); +} + +.contentScene:focus { + outline: none; +} + +.sceneContent strong { + display: block; + color: var(--portal-text-light); + font-size: 20px; + font-weight: 400; + text-transform: uppercase; +} + +.sceneContent h2 { + margin: 0 0 30px; + color: var(--portal-text-light); + font-size: clamp(64px, 7vw, 112px); + line-height: 0.95; + text-transform: uppercase; +} + +.sceneContent p { + color: var(--portal-text-light); + font-size: 20px; + line-height: 1.6; +} + +.userCharacter, +.developerCharacter, +.missionCharacter { + display: block; + width: 100%; + border: 0; +} + +.userCharacter { + transform: translateX(10%) scale(1.2); +} + +.developerLayout { + grid-template-columns: 3fr 2fr; +} + +.developerCharacter { + width: min(756px, 120%); + transform: translateX(-10%); +} + +.benefitGrid, +.developerGrid { + display: grid; + margin-top: 40px; + border-top: 1px solid var(--portal-border-light); + border-bottom: 1px solid var(--portal-border-light); +} + +.benefitGrid { + grid-template-columns: repeat(3, 1fr); +} + +.developerGrid { + grid-template-columns: repeat(2, 1fr); +} + +.benefitGrid p, +.developerGrid p { + display: grid; + place-items: center; + min-height: 110px; + margin: 0; + padding: 20px; + border-right: 1px solid var(--portal-border-light); + text-align: center; + text-shadow: 1px 1px 2px rgb(var(--portal-bg-rgb) / 50%); +} + +.benefitGrid p:last-child, +.developerGrid p:nth-child(even) { + border-right: 0; +} + +.parallaxViewport { + position: fixed; + inset: var(--ifm-navbar-height) 0 0; + z-index: 5; + overflow: clip; + pointer-events: none; +} + +.parallaxCanvas { + position: absolute; + inset: -10%; + transform: scaleX(-1); +} + +.nightOverlay { + position: absolute; + inset: 0; + z-index: 100; + background: rgb(var(--portal-bg-rgb) / 80%); + opacity: var(--portal-night-opacity, 0); + backdrop-filter: hue-rotate(210deg) contrast(1.2); +} + +.parallaxLayer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + will-change: transform; +} + +.parallaxLayer img { + position: absolute; + top: 0; + left: 0; + display: block; + width: 100%; + max-width: none; + border: 0; + object-fit: cover; +} + +.mobileLayer { + display: none; +} + +@keyframes cloudsRight { + 0%, + 100% { + transform: translate3d(-5%, 0, 0); + } + 50% { + transform: translate3d(5%, 0, 0); + } +} + +@keyframes cloudsLeft { + 0%, + 100% { + transform: translate3d(5%, 0, 0); + } + 50% { + transform: translate3d(-5%, 0, 0); + } +} + +.cloudsRight { + animation: cloudsRight 150s infinite linear; +} + +.cloudsLeft { + animation: cloudsLeft 100s infinite linear; +} + +.missionScene { + position: relative; + z-index: 10; + min-height: 110dvh; + background: var(--portal-bg); +} + +.transition { + position: absolute; + right: 0; + bottom: 90%; + display: block; + width: 120%; + max-width: none; + border: 0; + transform: scaleX(-1); +} + +.missionCharacter { + width: min(768px, 110%); +} + +@media (max-width: 956px) { + .sceneStack { + padding-bottom: 64px; + } + + .landingScene, + .contentScene { + min-height: 85dvh; + padding: 64px 0; + } + + .landingGrid, + .twoColumn, + .developerLayout, + .missionGrid { + grid-template-columns: 1fr; + padding: 32px; + } + + .landingGrid { + display: flex; + flex-direction: column-reverse; + } + + .landingCopy p { + font-size: 22px; + } + + .heroCharacter { + width: 264px; + } + + .sceneContent { + width: 100%; + text-align: center; + } + + .sceneContent h2 { + font-size: 64px; + } + + .sceneContent p { + font-size: 18px; + } + + .userCharacter, + .developerCharacter, + .missionCharacter { + width: min(360px, 100%); + margin: 0 auto; + transform: none; + } + + .benefitGrid, + .developerGrid { + grid-template-columns: 1fr; + } + + .benefitGrid p, + .developerGrid p, + .developerGrid p:nth-child(even) { + min-height: 0; + border-right: 0; + border-bottom: 1px solid var(--portal-border-light); + } + + .benefitGrid p:last-child, + .developerGrid p:last-child { + border-bottom: 0; + } + + .desktopLayer { + display: none; + } + + .mobileLayer { + display: block; + height: 110dvh; + } + + .mobileLayer img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .parallaxCanvas { + inset: 0; + height: 110dvh; + } +} + +@media (prefers-reduced-motion: reduce) { + .parallaxLayer, + .cloudsRight, + .cloudsLeft { + animation: none !important; + transform: none !important; + } +} diff --git a/src/components/portal/PortalPrototype.tsx b/src/components/portal/PortalPrototype.tsx new file mode 100644 index 00000000..a20582dc --- /dev/null +++ b/src/components/portal/PortalPrototype.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import PortalParallax from "./PortalParallax"; +import styles from "./PortalPrototype.module.css"; +import { TRANSITION_SRC } from "./portalModel"; + +const userBenefits = [ + "Applications by Flathub", + "Near-zero maintenance", + "Included GPU drivers", +]; + +const developerBenefits = [ + "Visual Studio Code with devcontainers", + "Work with your favorite Linux distributions with a container-focused terminal", + "Designed for cloud native development with the CNCF's best tools, including Kubernetes", + "Homebrew on-tap by default, same tools as your Mac", + "Podman Desktop for graphical container management", + "JetBrains IDEs one command away", +]; + +export default function PortalPrototype(): React.JSX.Element { + const handleDiscoverClick = ( + event: React.MouseEvent, + ): void => { + event.preventDefault(); + const target = document.getElementById("scene-users"); + if (!target) return; + + const prefersReduced = + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + target.scrollIntoView({ + behavior: prefersReduced ? "auto" : "smooth", + block: "start", + }); + target.focus({ preventScroll: true }); + window.history.pushState(null, "", "#scene-users"); + }; + + return ( +
+
+
+
+
+

+ Bluefin +

+

+ The next generation Linux workstation, designed for reliability, + performance, and sustainability. +

+ + Discover + +
+ Bluefin +
+
+ +
+
+ Bluefin looking at the future of Linux - it's to the left apparently +
+ For +

You

+

+ Bluefin is an operating system for your computer. The best of + both worlds: the reliability and ease of use of a Chromebook, + with the power of a GNOME desktop. +

+
+ {userBenefits.map((benefit) => ( +

{benefit}

+ ))} +
+
+
+
+ +
+
+
+ For +

Developers

+

+ Bluefin comes with an optional "developer mode" that + transforms your device into a powerful workstation. It features + container-focused workflows to get you started depending on + where you're coming from, or bring your own. +

+
+ {developerBenefits.map((benefit) => ( +

{benefit}

+ ))} +
+
+ Karl towering over the Backlog +
+
+
+ + + +
+ +
+ Bluefin laying down and chilling +
+ Our +

Mission

+

+ Bluefin is not just software, she is a new breed of animal, + adapted to survive the rigors of an ecosystem dominated by giants + while protecting her family. +

+

+ Technology begins with the local computer, the device that you + touch, and it should be as essential as the rest of the Linux + ecosystem. +

+

+ Bluefin is about sustainability, encompassing the software, the + hardware, and the people. +

+
+
+
+
+ ); +} diff --git a/src/components/portal/portalModel.ts b/src/components/portal/portalModel.ts new file mode 100644 index 00000000..493146ef --- /dev/null +++ b/src/components/portal/portalModel.ts @@ -0,0 +1,119 @@ +export const PORTAL_BREAKPOINT_PX = 956; +export const MOBILE_LAYER_SRC = "/img/portal/mobile-parallax.webp"; +export const TRANSITION_SRC = "/img/portal/layer-transition.webp"; + +export type PortalDrift = "left" | "right"; + +export type PortalLayer = { + key: string; + src: string; + top: number; + rate: number; + drift?: PortalDrift; + priority?: boolean; +}; + +const evening = (file: string): string => `/img/portal/evening/${file}`; +const clouds = evening("BlueFinSite_2_Clouds-min.webp"); + +export const PORTAL_LAYERS: readonly PortalLayer[] = [ + { + key: "sky", + src: evening("BlueFinSite_1_Sky-min.webp"), + top: 0, + rate: 0, + priority: true, + }, + { key: "clouds-right", src: clouds, top: -60, rate: 0, drift: "right" }, + { + key: "sun", + src: evening("BlueFinSite_2_Sun-min.webp"), + top: -90, + rate: 0.05, + }, + { key: "clouds-left", src: clouds, top: 0, rate: 0, drift: "left" }, + { + key: "mountains", + src: evening("BlueFinSite_4_Mountains-min.webp"), + top: 0, + rate: 0, + }, + { + key: "fog-a", + src: evening("BlueFinSite_5_FogA-min.webp"), + top: 0, + rate: 0, + }, + { + key: "background-a", + src: evening("BlueFinSite_6_BackgroundA-min.webp"), + top: 165, + rate: 0, + }, + { + key: "fog-b", + src: evening("BlueFinSite_7_FogB-min.webp"), + top: 200, + rate: 0, + }, + { + key: "background-b", + src: evening("BlueFinSite_8_BackgroundB-min.webp"), + top: 175, + rate: -0.01, + }, + { + key: "midground-a", + src: evening("BlueFinSite_9_MidGroundA-min.webp"), + top: 210, + rate: -0.03, + }, + { + key: "midground-b", + src: evening("BlueFinSite_10_MidgroundB-min.webp"), + top: 250, + rate: -0.05, + }, + { + key: "midground-c", + src: evening("BlueFinSite_11_MidGroundC-min.webp"), + top: 300, + rate: -0.07, + }, + { + key: "foreground-a", + src: evening("BlueFinSite_12_ForeGroundA-min.webp"), + top: 320, + rate: -0.09, + }, + { + key: "foreground-b", + src: evening("BlueFinSite_13_ForegroundB-min.webp"), + top: 340, + rate: -0.11, + }, + { + key: "foreground-c", + src: evening("BlueFinSite_14_ForegroundC-min.webp"), + top: 360, + rate: -0.13, + }, +]; + +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +export function overlayOpacity( + scrollY: number, + viewportHeight: number, +): number { + return clamp((scrollY - viewportHeight * 0.25) / 650, 0, 1); +} + +export function layerTransform(scrollY: number, rate: number): string { + return `translate3d(0, ${scrollY * rate}px, 0)`; +} + +export function isParallaxVisible(scrollY: number, sceneEnd: number): boolean { + return scrollY <= sceneEnd; +} diff --git a/src/pages/portal-prototype.tsx b/src/pages/portal-prototype.tsx new file mode 100644 index 00000000..eaa779eb --- /dev/null +++ b/src/pages/portal-prototype.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import Layout from "@theme/Layout"; +import PortalPrototype from "../components/portal/PortalPrototype"; + +const description = + "The next generation Linux workstation, designed for reliability, performance, and sustainability."; + +export default function PortalPrototypePage(): React.JSX.Element { + return ( + + + + ); +} diff --git a/static/img/portal/characters/bluefin.webp b/static/img/portal/characters/bluefin.webp new file mode 100644 index 00000000..4b1fee2a Binary files /dev/null and b/static/img/portal/characters/bluefin.webp differ diff --git a/static/img/portal/characters/karl.webp b/static/img/portal/characters/karl.webp new file mode 100644 index 00000000..249d0206 Binary files /dev/null and b/static/img/portal/characters/karl.webp differ diff --git a/static/img/portal/characters/nest.webp b/static/img/portal/characters/nest.webp new file mode 100644 index 00000000..dabc9993 Binary files /dev/null and b/static/img/portal/characters/nest.webp differ diff --git a/static/img/portal/evening/BlueFinSite_10_MidgroundB-min.webp b/static/img/portal/evening/BlueFinSite_10_MidgroundB-min.webp new file mode 100644 index 00000000..af525682 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_10_MidgroundB-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_11_MidGroundC-min.webp b/static/img/portal/evening/BlueFinSite_11_MidGroundC-min.webp new file mode 100644 index 00000000..26e6d57b Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_11_MidGroundC-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_12_ForeGroundA-min.webp b/static/img/portal/evening/BlueFinSite_12_ForeGroundA-min.webp new file mode 100644 index 00000000..7d3f9752 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_12_ForeGroundA-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_13_ForegroundB-min.webp b/static/img/portal/evening/BlueFinSite_13_ForegroundB-min.webp new file mode 100644 index 00000000..38fef185 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_13_ForegroundB-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_14_ForegroundC-min.webp b/static/img/portal/evening/BlueFinSite_14_ForegroundC-min.webp new file mode 100644 index 00000000..67478b8b Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_14_ForegroundC-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_1_Sky-min.webp b/static/img/portal/evening/BlueFinSite_1_Sky-min.webp new file mode 100644 index 00000000..bcbc23f8 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_1_Sky-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_2_Clouds-min.webp b/static/img/portal/evening/BlueFinSite_2_Clouds-min.webp new file mode 100644 index 00000000..fe7e96f8 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_2_Clouds-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_2_Sun-min.webp b/static/img/portal/evening/BlueFinSite_2_Sun-min.webp new file mode 100644 index 00000000..2b883f3d Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_2_Sun-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_4_Mountains-min.webp b/static/img/portal/evening/BlueFinSite_4_Mountains-min.webp new file mode 100644 index 00000000..61b87136 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_4_Mountains-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_5_FogA-min.webp b/static/img/portal/evening/BlueFinSite_5_FogA-min.webp new file mode 100644 index 00000000..5765ef29 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_5_FogA-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_6_BackgroundA-min.webp b/static/img/portal/evening/BlueFinSite_6_BackgroundA-min.webp new file mode 100644 index 00000000..bf4efbad Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_6_BackgroundA-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_7_FogB-min.webp b/static/img/portal/evening/BlueFinSite_7_FogB-min.webp new file mode 100644 index 00000000..d2bf1643 Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_7_FogB-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_8_BackgroundB-min.webp b/static/img/portal/evening/BlueFinSite_8_BackgroundB-min.webp new file mode 100644 index 00000000..b076caaf Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_8_BackgroundB-min.webp differ diff --git a/static/img/portal/evening/BlueFinSite_9_MidGroundA-min.webp b/static/img/portal/evening/BlueFinSite_9_MidGroundA-min.webp new file mode 100644 index 00000000..d53e47df Binary files /dev/null and b/static/img/portal/evening/BlueFinSite_9_MidGroundA-min.webp differ diff --git a/static/img/portal/layer-transition.webp b/static/img/portal/layer-transition.webp new file mode 100644 index 00000000..6c049b83 Binary files /dev/null and b/static/img/portal/layer-transition.webp differ diff --git a/static/img/portal/mobile-parallax.webp b/static/img/portal/mobile-parallax.webp new file mode 100644 index 00000000..c1b88924 Binary files /dev/null and b/static/img/portal/mobile-parallax.webp differ