From 4bbf6892842990e8021b60bbcc4d27f9acd9ec10 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 7 Oct 2026 16:06:50 +0530 Subject: [PATCH 01/11] feat: show hero gradient in nested version site under the public wrapper The nested version site always runs in embedded mode, which hides the radiance blobs and uses the flat in-product banner. VersionIframe now passes _public=1 when the wrapper is the public docs site, and the nested site keeps the public hero for _iframe=1&_public=1. In-product help is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/assets/styles/index.scss | 16 ++++++++++++++++ src/components/DevDocTemplate/index.tsx | 14 +++++++++++++- src/components/VersionIframe/index.tsx | 6 ++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 32e85f14d..9a3572d6d 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -1546,6 +1546,22 @@ a.anchor { color: #fff; } } + + // Nested version site shown inside the public docs wrapper (?_iframe=1&_public=1): keep the + // public hero, i.e. undo the in-product banner overrides above. + &.embedded-public-hero { + .radiance-bg { + display: block; + } + + .header-banner { + background: none; + + .header-banner-text { + color: var(--primary-color); + } + } + } } .introCard, diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..74f0a41c2 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -663,8 +663,20 @@ if (isVersionedIframe) { return cName; }; + // Nested version site (?_iframe=1) whose wrapper is the public docs site (VersionIframe adds + // ?_public=1), as opposed to a wrapper embedded in the product. It is still "embedded" for + // layout/nav, but keeps the public hero (gradient) instead of the flat in-product banner. + const isPublicWrapperIframe = () => { + if (!isBrowser()) return false; + const urlParams = new URLSearchParams(location.search); + return urlParams.get('_iframe') === '1' && urlParams.get('_public') === '1'; + }; + const getWrapperClassName = () => { - return isEmbeddedContext() ? 'embedded-mode' : ''; + if (!isEmbeddedContext()) return ''; + return isPublicWrapperIframe() + ? 'embedded-mode embedded-public-hero' + : 'embedded-mode'; }; const getCloudLatestVersion = () => { diff --git a/src/components/VersionIframe/index.tsx b/src/components/VersionIframe/index.tsx index e6e23eaa3..c90de598c 100644 --- a/src/components/VersionIframe/index.tsx +++ b/src/components/VersionIframe/index.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { isPublicSite } from '../../utils/app-utils'; import './index.scss'; interface VersionIframeProps { @@ -38,6 +39,11 @@ const VersionIframe: React.FC = ({ url.hash = location?.hash; } url.searchParams.set('_iframe', '1'); + // Tell the nested site its wrapper is the public docs site (not the product), so it + // can show the public hero gradient instead of the in-product banner. + if (isPublicSite(window.location.search)) { + url.searchParams.set('_public', '1'); + } } return url.toString(); From e755b4f8ab01377be0d9ddbac443dc16bc6b7047 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya <76986173+ShashiSubramanya@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:13:32 +0530 Subject: [PATCH 02/11] Update doc-configs.js --- src/configs/doc-configs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/configs/doc-configs.js b/src/configs/doc-configs.js index 99c3cd8e7..0e414264c 100644 --- a/src/configs/doc-configs.js +++ b/src/configs/doc-configs.js @@ -56,8 +56,8 @@ module.exports = { { label: '26.9.0.cl', link: '26.9.0.cl', - subLabel: 'Cloud (Latest)', - iframeUrl: 'https://developer-docs-26-10-0-cl.vercel.app/docs/', + subLabel: 'Cloud', + iframeUrl: 'https://developer-docs-26-9-0-cl.vercel.app/docs/', }, { label: '26.8.0.cl', From fb224dfd606f342562cfdc8bb97d4ef1c47a29cf Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Thu, 8 Oct 2026 16:10:44 +0530 Subject: [PATCH 03/11] fix: show hero gradient in embedded/in-product mode, drop _public flag Design confirmed the radiance gradient should show everywhere, so remove the .embedded-mode overrides that hid it and forced the flat #232F43 banner. This supersedes the _public=1 approach from 4bbf6892, so that plumbing (VersionIframe flag, embedded-public-hero class) is reverted. The walkthroughs hero keeps its navy override. Co-Authored-By: Claude Sonnet 5.5 --- src/assets/styles/index.scss | 35 +++---------------------- src/components/DevDocTemplate/index.tsx | 14 +--------- src/components/VersionIframe/index.tsx | 6 ----- 3 files changed, 4 insertions(+), 51 deletions(-) diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 9a3572d6d..b05a6e1e8 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -1522,23 +1522,10 @@ a.anchor { } } -// In-product presentation only: override banner color to match product sidebar (#232F43), -// and suppress the page-wide gradient entirely. +// In-product presentation only: the walkthroughs hero keeps the product sidebar color (#232F43). +// The docs header banner (.header-banner) intentionally keeps the same radiance gradient as the +// public site, so it is not overridden here. .embedded-mode { - .radiance-bg { - display: none; - } - - .header-banner { - background: #232F43; - border-bottom: none; - box-shadow: none; - - .header-banner-text { - color: #fff; - } - } - .walkthroughs-page { background: #232F43; @@ -1546,22 +1533,6 @@ a.anchor { color: #fff; } } - - // Nested version site shown inside the public docs wrapper (?_iframe=1&_public=1): keep the - // public hero, i.e. undo the in-product banner overrides above. - &.embedded-public-hero { - .radiance-bg { - display: block; - } - - .header-banner { - background: none; - - .header-banner-text { - color: var(--primary-color); - } - } - } } .introCard, diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index 74f0a41c2..e3760503f 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -663,20 +663,8 @@ if (isVersionedIframe) { return cName; }; - // Nested version site (?_iframe=1) whose wrapper is the public docs site (VersionIframe adds - // ?_public=1), as opposed to a wrapper embedded in the product. It is still "embedded" for - // layout/nav, but keeps the public hero (gradient) instead of the flat in-product banner. - const isPublicWrapperIframe = () => { - if (!isBrowser()) return false; - const urlParams = new URLSearchParams(location.search); - return urlParams.get('_iframe') === '1' && urlParams.get('_public') === '1'; - }; - const getWrapperClassName = () => { - if (!isEmbeddedContext()) return ''; - return isPublicWrapperIframe() - ? 'embedded-mode embedded-public-hero' - : 'embedded-mode'; + return isEmbeddedContext() ? 'embedded-mode' : ''; }; const getCloudLatestVersion = () => { diff --git a/src/components/VersionIframe/index.tsx b/src/components/VersionIframe/index.tsx index c90de598c..e6e23eaa3 100644 --- a/src/components/VersionIframe/index.tsx +++ b/src/components/VersionIframe/index.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { isPublicSite } from '../../utils/app-utils'; import './index.scss'; interface VersionIframeProps { @@ -39,11 +38,6 @@ const VersionIframe: React.FC = ({ url.hash = location?.hash; } url.searchParams.set('_iframe', '1'); - // Tell the nested site its wrapper is the public docs site (not the product), so it - // can show the public hero gradient instead of the in-product banner. - if (isPublicSite(window.location.search)) { - url.searchParams.set('_public', '1'); - } } return url.toString(); From 884c092979fded462c5a19fc57a065e2a3aa0b6b Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Fri, 9 Oct 2026 13:17:20 +0530 Subject: [PATCH 04/11] feat: use the Radiance home pattern for the docs hero gradient The hero used hand-written CSS blobs with keyframe loops, so light and dark looked like different gradients and did not match the product home page. Use the product's Radiance 'home' pattern instead: the unmodified v2.0.0 renderer and home recipe CSS (vendored, byte-identical to the Radiance language repo), with the site theme mapped to data-radiance-appearance. The renderer is loaded lazily, only on pages that contain a surface. The walkthroughs hero keeps its existing CSS blobs. Co-Authored-By: Claude Sonnet 5.5 --- modules/ROOT/pages/home.adoc | 14 +- src/assets/styles/radiance-home.css | 142 ++ src/components/DevDocTemplate/index.tsx | 5 + src/index.d.ts | 3 + src/utils/radiance.ts | 64 + static/radiance/radiance-renderer.js | 2514 +++++++++++++++++++++++ 6 files changed, 2731 insertions(+), 11 deletions(-) create mode 100644 src/assets/styles/radiance-home.css create mode 100644 src/utils/radiance.ts create mode 100644 static/radiance/radiance-renderer.js diff --git a/modules/ROOT/pages/home.adoc b/modules/ROOT/pages/home.adoc index f0f150e82..f6e036d4a 100644 --- a/modules/ROOT/pages/home.adoc +++ b/modules/ROOT/pages/home.adoc @@ -7,17 +7,9 @@ ++++
-
-
-
-
-
-
-
-
-
-
-
+
+

ThoughtSpot Embedded Documentation

diff --git a/src/assets/styles/radiance-home.css b/src/assets/styles/radiance-home.css new file mode 100644 index 000000000..1c8c3947b --- /dev/null +++ b/src/assets/styles/radiance-home.css @@ -0,0 +1,142 @@ +/* + * Radiance product pattern "home" - the Home page hero recipe (FIXED, admin-owned). + * + * The finished Home page (Spotter home) draws its hero from two layers: + * .radiance-home the Radiance surface's wrapper - bleeds past the hero, + * fades toward the content, and carries the fine noise + * and tone lift at the language's values (GRAIN-2) + * .radiance-home-stage the hero's container: a faint glow layer (::before) + * The grain is the language's one grain per theme (GRAIN-6): the renderer draws + * it, over Home's colour at the hero's strength (PRODUCT_PATTERNS.home). + * Markup (see README.md): + *
+ * + * ...hero content... + *
+ * The renderer draws the top wash itself (subtle, fast; locked). Nothing here is + * tunable - these values are the product pattern (RULES.md PRODUCT-1..5). The + * glow layer follows the clean-blend order (COLOR-5): pink - violet - blue - mint. + * Theme follows the surface's own data-radiance-appearance (read with :has()). + */ + +.radiance-home-stage { + --radiance-home-base: #ffffff; + position: relative; +} + +.radiance-home-stage:has(.radiance-local[data-radiance-appearance="dark"]) { + --radiance-home-base: #131416; +} + +/* The glow layer under the hero. */ +.radiance-home-stage::before { + content: ""; + position: absolute; + z-index: 0; + top: 0; + left: -16vw; + right: -16vw; + height: 640px; + pointer-events: none; + background: + radial-gradient(ellipse 58% 36% at 22% -4%, rgba(255, 118, 154, 0.072), rgba(255, 118, 154, 0.028) 44%, rgba(255, 118, 154, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 66% 38% at 38% -2%, rgba(165, 102, 255, 0.066), rgba(165, 102, 255, 0.026) 38%, rgba(165, 102, 255, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 72% 42% at 62% 2%, rgba(88, 150, 255, 0.076), rgba(88, 150, 255, 0.032) 42%, rgba(88, 150, 255, 0.006) 66%, transparent 90%), + radial-gradient(ellipse 70% 44% at 84% 4%, rgba(80, 220, 180, 0.048), rgba(80, 220, 180, 0.017) 40%, transparent 78%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.16) 42%, rgba(255, 255, 255, 0.56) 78%, var(--radiance-home-base) 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.94) 26%, rgba(0, 0, 0, 0.54) 58%, rgba(0, 0, 0, 0.16) 84%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.94) 26%, rgba(0, 0, 0, 0.54) 58%, rgba(0, 0, 0, 0.16) 84%, transparent 100%); +} + +.radiance-home-stage:has(.radiance-local[data-radiance-appearance="dark"])::before { + height: 560px; + background: + radial-gradient(ellipse 58% 34% at 20% -4%, rgba(255, 93, 142, 0.06), rgba(255, 93, 142, 0.024) 42%, rgba(255, 93, 142, 0.006) 62%, transparent 86%), + radial-gradient(ellipse 66% 38% at 38% -2%, rgba(138, 93, 255, 0.068), rgba(138, 93, 255, 0.028) 36%, rgba(138, 93, 255, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 72% 42% at 62% 2%, rgba(39, 140, 255, 0.078), rgba(39, 140, 255, 0.032) 38%, rgba(39, 140, 255, 0.007) 66%, transparent 90%), + radial-gradient(ellipse 70% 44% at 84% 4%, rgba(45, 220, 177, 0.026), rgba(45, 220, 177, 0.01) 40%, transparent 78%), + linear-gradient(to bottom, rgba(26, 27, 30, 0.08) 0%, rgba(26, 27, 30, 0.24) 38%, rgba(26, 27, 30, 0.62) 70%, var(--radiance-home-base) 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 24%, rgba(0, 0, 0, 0.5) 54%, rgba(0, 0, 0, 0.14) 78%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 24%, rgba(0, 0, 0, 0.5) 54%, rgba(0, 0, 0, 0.14) 78%, transparent 100%); +} + +/* The surface's wrapper: bleeds past the hero, fades toward the content, and + carries the tone lift and the 56px fine noise at the language's values. The + renderer draws Home's colour at the hero's strength and the grain on top. */ +.radiance-home { + position: absolute; + z-index: 0; + inset: -96px -12vw -220px; + overflow: hidden; + pointer-events: none; + filter: saturate(1.16) contrast(1.06); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.92) 22%, rgba(0, 0, 0, 0.52) 54%, rgba(0, 0, 0, 0.18) 78%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.92) 22%, rgba(0, 0, 0, 0.52) 54%, rgba(0, 0, 0, 0.18) 78%, transparent 100%); +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { + inset: -88px -12vw -150px; + filter: saturate(1.2) contrast(1.08); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.86) 20%, rgba(0, 0, 0, 0.42) 48%, rgba(0, 0, 0, 0.12) 70%, transparent 90%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.86) 20%, rgba(0, 0, 0, 0.42) 48%, rgba(0, 0, 0, 0.12) 70%, transparent 90%); +} + +.radiance-home::before { + content: ""; + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; + opacity: 0.22; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.48' numOctaves='3' seed='21' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.58' intercept='-0.18'/%3E%3CfeFuncG type='linear' slope='1.22' intercept='-0.12'/%3E%3CfeFuncB type='linear' slope='1.5' intercept='-0.16'/%3E%3CfeFuncA type='linear' slope='0.64'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='56' height='56' filter='url(%23n)'/%3E%3C/svg%3E"); + background-size: 56px 56px; + mix-blend-mode: multiply; +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"])::before { + opacity: 0.27; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.48' numOctaves='3' seed='21' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.64' intercept='-0.2'/%3E%3CfeFuncG type='linear' slope='1.14' intercept='-0.1'/%3E%3CfeFuncB type='linear' slope='1.58' intercept='-0.18'/%3E%3CfeFuncA type='linear' slope='0.7'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='56' height='56' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: screen; +} + +.radiance-home::after { + content: ""; + position: absolute; + z-index: 2; + inset: 0; + background: + radial-gradient(ellipse 72% 34% at 50% 8%, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.034) 48%, rgba(255, 255, 255, 0) 78%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.16) 38%, rgba(255, 255, 255, 0.48) 70%, #ffffff 100%); +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"])::after { + background: + radial-gradient(ellipse 72% 34% at 50% 8%, rgba(26, 27, 30, 0.12), rgba(26, 27, 30, 0.052) 46%, rgba(26, 27, 30, 0) 76%), + linear-gradient(to bottom, rgba(26, 27, 30, 0.06) 0%, rgba(26, 27, 30, 0.22) 34%, rgba(26, 27, 30, 0.56) 62%, #131416 100%); +} + +.radiance-home > .radiance-local { + position: absolute; + inset: 0; + overflow: hidden; +} + +.radiance-home > .radiance-local canvas { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +@media (max-width: 780px) { + .radiance-home { inset: -82px -18vw -176px; } + .radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { inset: -76px -18vw -132px; } +} + +@media (max-width: 520px) { + .radiance-home { inset: -72px -28vw -162px; } + .radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { inset: -68px -28vw -126px; } +} diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..0af542a6c 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -12,6 +12,7 @@ import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; import { Analytics } from '@vercel/analytics/react'; import { Seo } from '../Seo'; import { queryStringParser, isPublicSite } from '../../utils/app-utils'; +import { useRadianceTheme } from '../../utils/radiance'; import { passThroughHandler, fetchChild, getPageIdFromHref } from '../../utils/doc-utils'; import Header from '../Header'; import SecondaryHeader, { DocCategory, CATEGORY_PAGEIDS, CATEGORY_NAV_ID } from '../SecondaryHeader'; @@ -20,6 +21,7 @@ import Docmap from '../Docmap'; import Document from '../Document'; import Search from '../Search'; import '../../assets/styles/index.scss'; +import '../../assets/styles/radiance-home.css'; import { getAlgoliaIndex } from '../../configs/algolia-search-config'; import RenderPlayGround from './playGround/RESTAPI'; import GraphQLPlayGround from './playGround/GraphQL'; @@ -130,6 +132,9 @@ const DevDocTemplate: FC = (props) => { }); const [key, setKey] = useState(''); + // Keeps the Radiance hero surface (home.adoc) in the site's current theme. + useRadianceTheme(isDarkMode); + // Pre-process all category nav HTMLs once ({{navprefix}} substitution applied to each) const processedNavMap = React.useMemo(() => Object.fromEntries( diff --git a/src/index.d.ts b/src/index.d.ts index 44350b3aa..dce9face6 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -2,3 +2,6 @@ declare module '*.svg' { const content: string; export default content; } + +// Injected by Gatsby at build time (the site's pathPrefix, e.g. '/docs'). +declare const __PATH_PREFIX__: string; diff --git a/src/utils/radiance.ts b/src/utils/radiance.ts new file mode 100644 index 000000000..be0bfa25d --- /dev/null +++ b/src/utils/radiance.ts @@ -0,0 +1,64 @@ +import { useEffect } from 'react'; + +/** + * Glue for the Radiance "home" product pattern used by the docs hero (home.adoc). + * + * The renderer (static/radiance/radiance-renderer.js) and the hero recipe + * (src/assets/styles/radiance-home.css) are unmodified copies from the Radiance + * language repo and must stay that way (RULES.md IMPL-1, PRODUCT-3). The only thing the + * page owns is the theme: it sets data-radiance-appearance on the surface, and the + * renderer repaints on change. + */ + +const RENDERER_SRC = '/radiance/radiance-renderer.js'; +const SURFACE_SELECTOR = '.radiance-local'; + +let rendererRequested = false; + +// The renderer scans for .radiance-local elements on load and watches the DOM for new +// ones, so it is loaded once, and only when a page actually contains a surface. +const ensureRendererLoaded = () => { + if (rendererRequested || typeof document === 'undefined') return; + rendererRequested = true; + const script = document.createElement('script'); + // __PATH_PREFIX__ is injected by Gatsby (e.g. '/docs' in production, '' locally). + const prefix = typeof __PATH_PREFIX__ === 'string' ? __PATH_PREFIX__ : ''; + script.src = `${prefix}${RENDERER_SRC}`; + script.async = true; + document.body.appendChild(script); +}; + +const applyAppearance = (isDarkMode: boolean) => { + const appearance = isDarkMode ? 'dark' : 'light'; + const surfaces = document.querySelectorAll(SURFACE_SELECTOR); + surfaces.forEach((surface) => { + if (surface.dataset.radianceAppearance !== appearance) { + surface.dataset.radianceAppearance = appearance; + } + }); + if (surfaces.length) ensureRendererLoaded(); +}; + +/** + * Keeps every Radiance surface on the page in the site's current theme. Page content is + * injected after render, so new surfaces are picked up through a body observer too. + */ +export const useRadianceTheme = (isDarkMode: boolean) => { + useEffect(() => { + if (typeof document === 'undefined') return undefined; + applyAppearance(isDarkMode); + if (!('MutationObserver' in window)) return undefined; + const observer = new MutationObserver((mutations) => { + const added = mutations.some((mutation) => + Array.from(mutation.addedNodes).some( + (node) => + node instanceof Element && + (node.matches(SURFACE_SELECTOR) || node.querySelector(SURFACE_SELECTOR)), + ), + ); + if (added) applyAppearance(isDarkMode); + }); + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [isDarkMode]); +}; diff --git a/static/radiance/radiance-renderer.js b/static/radiance/radiance-renderer.js new file mode 100644 index 000000000..81386fffe --- /dev/null +++ b/static/radiance/radiance-renderer.js @@ -0,0 +1,2514 @@ +(() => { + /* + * Radiance Language renderer - v2. + * + * One file for every Radiance surface: the five templates (right-wash, + * left-wash, neutral, top-wash, bottom-wash) and the fixed product + * patterns (signup, home, spotterx, liveboard). v1 is frozen at + * renderer/v1/radiance-renderer.js for reference and comparison. + * + * v2 carries the system-level upgrades first built for SpotterX and + * Liveboard (see RULES.md and CHANGELOG.md): + * F1 film Built on Home's first-launch film: grain that reaches + * through faint colour, a fine second noise layer, and a + * gentle tone lift - all confined to the colour field + * (GRAIN-4), so the bare surface stays clean. + * F2 motion Organic drift: every orbit, size, stretch and tilt is 2-3 + * sines at incommensurate per-blob frequencies, and every blob + * breathes on its own rhythm (MOTION-2). + * F3 blend Clean blends: overlapping colours are wheel neighbours + * (mint - blue - violet - pink - peach); peach and mint stay + * compact accents (COLOR-5). + * Base A surface sits on its host's own background colour, per + * theme (data-radiance-base / --radiance-base, SURFACE-1). + */ + const RADIANCE_VERSION = "2.0.0"; + const LIGHT_SURFACE = "#ffffff"; + const DARK_SURFACE = "#1a1b1e"; + const RADIANCE_FILM_GRAIN_SIZE = 1.0; + const MAX_DPR = 2; + const TIME_ORIGIN_KEY = "radianceLanguageTimeOrigin"; + const MAX_TEXTURE_CACHE_ITEMS = 24; + const TAU = Math.PI * 2; + // GRAIN-4: how hard the colour field is stacked before it masks the film. + // 12 brings the film to ~80% by 12% colour and ~93% by 20% - the texture + // carries through faint colour the way Home's hero does - while the bare + // surface (under ~0.5% colour) keeps under ~6% of it. v1 used 4. + const GRAIN_MASK_SATURATION = 12; + // The mask is the blurred colour field, so it is smooth: it is built at a + // quarter of the canvas resolution and upscaled. The film stays full-res. + const MASK_SCALE = 0.25; + // F1 - the film stack per theme (built on Home's hero, as SpotterX and + // Liveboard carry it): grain strength, the dark dither, the fine noise layer + // and its blend, and the tone lift. The renderer draws all of it, masked to + // the colour field - consumers still add no CSS filter (BLEND-3). This is + // the language's one film per theme: every surface, template or product, + // draws exactly this grain (GRAIN-6). Light is at 1.0's full strength and + // dark at 0.76, so the grain still reads where the colour is subtle (the + // admin's 2.0 review). + const FILM = { + light: { grain: 1, dither: 0, overlay: 0.22, overlayBlend: "multiply", saturate: 1.16, contrast: 1.06 }, + dark: { grain: 0.76, dither: 0.42 * 0.76, overlay: 0.27, overlayBlend: "screen", saturate: 1.2, contrast: 1.08 } + }; + // The fine noise layer: SVG fractal noise rendered by the browser at the + // surface's pixel density and tiled every 56 CSS px - the same layer + // Home's hero draws over its canvas. + const FILM_OVERLAY_TILE = 56; + const FILM_OVERLAY_SVG = { + light: "", + dark: "" + }; + // F2 - every blob breathes: size and brightness swell a few per cent on the + // blob's own rhythm (ms at speed 1; the tempo follows the square root of the + // speed, so a fast pattern breathes a little quicker, never frantically). + const BREATH = { size: 0.05, glow: 0.05, periodMs: [5000, 8000] }; + + // Percentage adjustments. 100% is always the pattern's shipped value, so a + // surface that sets none of these is byte-identical to one that sets 100%. + // Ranges are what the language still reads as itself; outside them the + // renderer warns (RULES.md ADJUST-2). + const ADJUST_RANGES = { + prominence: { min: 0.5, max: 1.5, rule: "ADJUST-3" }, + spread: { min: 0.75, max: 1.25, rule: "ADJUST-4" }, + speed: { min: 0.5, max: 1.5, rule: "ADJUST-5" } + }; + + // Accepts "120%", "120" or "1.2" — all mean the same thing. + function parseAdjustment(raw) { + if (raw === undefined || raw === null || raw === "") return 1; + const text = String(raw).trim(); + const value = Number(text.replace("%", "").trim()); + if (!Number.isFinite(value) || value <= 0) return 1; + if (text.includes("%") || value > 5) return value / 100; + return value; + } + + // ------------------------------------------------------------------ + // Named patterns. These five are the only sanctioned entry points into + // the language; every Radiance surface starts from one of them. + // Canonical values are mirrored in radiance.tokens.json — change both or + // neither. See RULES.md (PLACE-1, MOTION-6). + // ------------------------------------------------------------------ + const RADIANCE_PATTERNS = { + "right-wash": { layout: "right-wash", speeds: { slow: 0.62, fast: 1.02 } }, + "left-wash": { layout: "left-wash", speeds: { slow: 0.62, fast: 1.02 } }, + "neutral": { layout: "neutral", speeds: { slow: 0.86, fast: 1.34 } }, + "top-wash": { layout: "top-wash", speeds: { slow: 0.92, fast: 1.72 } }, + "bottom-wash": { layout: "bottom-wash", speeds: { slow: 0.92, fast: 1.72 } } + }; + + const PLACEMENT_ALIASES = { + right: "right-wash", + left: "left-wash", + top: "top-wash", + bottom: "bottom-wash", + neutral: "neutral", + modal: "neutral", + developer: "neutral" + }; + + // Shorthand fallbacks are the product-safe values, not the legacy + // attribute defaults. Agents still spell the full four-part name out. + const PATTERN_FALLBACKS = { intensity: "subtle", motion: "dynamic", speed: "slow" }; + + // ------------------------------------------------------------------ + // Product patterns (RULES.md section 12). Fixed, admin-owned patterns for + // ThoughtSpot's finished product screens. They take no adjustment, no + // override and no base colour from the page: every value is locked here, + // and only the Radiance admin changes them, in this repository. + // "template" products are a locked template configuration; + // "spotter" products run the Spotter radiance engine (below). + // Mirrored in radiance.tokens.json and products/manifest.json. + // ------------------------------------------------------------------ + const PRODUCT_PATTERNS = { + signup: { + name: "Sign up", + engine: "template", + layout: "right-wash", + intensity: "prominent", + speed: { light: 1.02, dark: 1.02 }, + seed: "signin-v2", + film: "renderer", + // Under reduced motion Sign up shows right-wash's curated static + // frame, as the product always has (MOTION-7). + reducedMotion: "static", + base: { light: "#ffffff", dark: "#1a1b1e" } + }, + home: { + name: "Home page", + engine: "template", + layout: "top-wash", + intensity: "subtle", + speed: { light: 1.86, dark: 1.72 }, + seed: "home-hero", + // Home's page recipe carries the fine noise and the tone lift + // (products/home/radiance-home.css); the renderer draws the grain over + // the whole hero field, as Home was built. Home's colour sits at the + // hero's strength (76% light / 66% dark over the base), drawn here so + // the grain on top is the language's one grain at full value (GRAIN-6). + film: "page", + fieldOpacity: { light: 0.76, dark: 0.66 }, + base: { light: "#ffffff", dark: "#131416" } + }, + spotterx: { + name: "SpotterX", + engine: "spotter", + // The page recipe (products//radiance-.css) carries the film. + film: "recipe", + base: { light: "#ffffff", dark: "#131416" } + }, + liveboard: { + name: "Liveboard", + engine: "spotter", + // The page recipe (products//radiance-.css) carries the film. + film: "recipe", + base: { light: "#ffffff", dark: "#131416" } + } + }; + // data-radiance-state on a spotter product: [reasoning energy, after-answer calm]. + const PRODUCT_STATES = { idle: [0, 0], reasoning: [1, 0], answered: [0, 1] }; + // Everything a product pattern ignores (PRODUCT-2). + const PRODUCT_LOCKED_ATTRIBUTES = [ + ["radianceProminence", "data-radiance-prominence"], + ["radianceSpread", "data-radiance-spread"], + ["radianceSpeedScale", "data-radiance-speed-scale"], + ["radianceSpeed", "data-radiance-speed"], + ["radianceBase", "data-radiance-base"], + ["radianceIntensity", "data-radiance-intensity"], + ["radianceLayout", "data-radiance-layout"], + ["radianceVariant", "data-radiance-variant"], + ["radianceMotion", "data-radiance-motion"], + ["radianceDarkGlow", "data-radiance-dark-glow"], + ["radianceSeed", "data-radiance-seed"] + ]; + // The Spotter engine's locked tuning: the values SpotterX and Liveboard ship. + const SBG_TUNING = { gain: 1, lift: 0, spread: 1, wash: 1, flow: 2.5, breath: 1, calmGain: 1.5, pace: 1 }; + // The Radiance admin's Spotter lab (spotter-lab/) previews other values on + // one surface through RadianceLanguageRenderer.admin; nothing else sets them. + const ADMIN_SPOTTER = new WeakMap(); + + /* + * The Spotter radiance engine - product patterns "spotterx" and "liveboard" + * (formerly the RADIANCE-OVERRIDE proposal "Spotter radiance BG" v5). + * Radiance blobs gathered along the product's top bar, spilling soft light + * below it - drifting organically at rest, breathing while reasoning, and + * retreating to a faint leak at the top after an answer: + * + * - Blobs: ~6.4 across the view (radius 1.6 spacings, hanging 2x taller + * than wide), drifting across the width so no colour owns a region. The + * colour sequence is a random walk along the wheel (see SBG_PATH), so + * neighbours are wheel-neighbours and overlaps stay clean tints; a blob + * that wraps off-screen is reborn, so the sequence never recurs. + * - Motion that never loops (MOTION-2): every blob's position, size, + * stretch and tilt is a sum of 2-3 sines at incommensurate per-blob + * frequencies, on top of a drift whose own speed swells and eases. + * - Breathing: every blob breathes - swells and brightens - on its own + * random rhythm, so many regions inhale and exhale out of step, never in + * one spot. Barely there at rest; while reasoning the swings deepen and + * quicken (1.8-3.4s), the whole glow breathes along (3.2s, starting + * exhaled at ignition), and a second stream of light flows the other way + * along the edge, taking the colour of the light beneath it - so the + * crossings brighten colours instead of muddying them. + * - States: ONE surface; data-radiance-state = idle | reasoning | answered + * (reasoning energy, and the calm after an answer). Both ease + * on critically damped springs; the clock is INTEGRATED (clock += dt * + * speed), so every speed change is live and phase-continuous. Clock rate + * per unit of Motion energy (2.5 by default): default 1.15 (2.88), + * reasoning 1.7 (4.25), after an answer 0.32 (0.8 - a slow, visible + * drift). + * - After an answer the colour drops to a faint leak (x0.5) and the blobs + * rise behind the bar; the page contracts its CSS fade toward the top, so + * colour and grain retreat together. + * - Shape: the vertical fade is the wrapper's CSS mask (the page builds it + * from the rig), exactly as on Home - colour, grain and overlay fade + * together. The renderer adds only reasoning's extra brightness, confined + * to the top band: reasoning never brings colour further down. + * - Grain: the language's one grain per theme (FILM, GRAIN-6), over the + * whole lit canvas, under the page's CSS fine noise and tone filter. + * - Rendering: blobs at quarter resolution with the canonical blur + * (BLEND-2, scaled), padded past every edge so the blur never darkens an + * edge, upscaled onto a dpr-1 canvas shown pixelated, so the 1.0px grain + * stays crisp (GRAIN-1/3). + * + * Sanctioned product exceptions (RULES.md PRODUCT-3): state-bound energy + * (MOTION-1), an integrated per-surface clock (MOTION-4), speeds above 2.0 + * (MOTION-5), per-load randomness (IMPL-4), and the page-level film in + * products//radiance-.css (GRAIN-4/5, BLEND-3). Everything is + * locked: the values below are the only ones these products ever run. + */ + const SBG_PALETTE = { + light: { pink: [255, 120, 160], violet: [140, 100, 255], blue: [40, 140, 255], peach: [255, 180, 100], mint: [80, 220, 180] }, + dark: { pink: [255, 93, 142], violet: [138, 93, 255], blue: [39, 140, 255], peach: [255, 180, 90], mint: [45, 220, 177] } + }; + /* The colour sequence is a random walk with momentum along the wheel - + mint, blue, violet, pink, peach - so neighbours are always + wheel-neighbours (blue never meets peach, pink never meets mint: every + blend a clean tint). Cool colour always leads (COLOR-4): the walk heads + into peach only 40% of the time it reaches pink, must pass back through + violet before any second peach, never runs more than 3-4 warm blobs, + and after a warm run stays cool for at least two - so every six-blob + screenful holds at least two blues/violets and nothing ever sits warm + (the yellow-red cast pink + peach makes in dark). A blob that wraps + off-screen is reborn with the next step of the walk and fresh size, + timing and shape, so the sequence never recurs. Long-run balance + (simulated over 1M steps): blue 29%, violet 35%, pink 24%, peach 7% + (warmth), mint 5% (quiet support). */ + const SBG_PATH = ["mint", "blue", "violet", "pink", "peach"]; + /* Peach and mint stay compact accents so their glow never reaches a hue + two steps away (peach + violet = mauve, the muddiest blend). */ + const SBG_ROLE = { blue: 1.04, violet: 0.84, pink: 0.92, peach: 0.85, mint: 0.5 }; + const SBG_SIZE = { blue: 1.05, violet: 0.96, pink: 0.98, peach: 0.84, mint: 0.78 }; + /* Blob centre opacity at rest and while reasoning (before the page's + saturate/contrast filter, which carries dark a little further); `calm` + is the after-answer leak as a fraction of rest (x the rig's After-answer + intensity, 1.5 by default). + `floor` is what reasoning keeps below its bright top band; light's rest + was raised on its own, so its reasoning keeps the earlier floor and + reads exactly as before. */ + const SBG_STATES = { + // 2.0: reasoning +15% (0.235 -> 0.27, floor 0.14 -> 0.155), calm +10% (0.5 -> 0.55). + light: { rest: 0.165, reason: 0.27, calm: 0.55, floor: 0.155 }, + dark: { rest: 0.12, reason: 0.28, calm: 0.5, floor: 0.12 } + }; + const SBG_MOTION = { + speedDefault: 1.15, // clock rate per unit of Motion energy (x2.5 = 2.88) + speedReason: 1.7, // x2.5 = 4.25 + speedSettled: 0.32, // x2.5 = 0.8 - the calm after an answer: a slow, visible drift + speedCap: 8, + flow: 0.016, // drift, px per clock-ms: ~46 px/s default, ~68 reasoning, ~13 calm + wanderReason: 0.6, // blobs wander 1.6x as far while reasoning + /* Each blob breathes on its own rhythm (ms, random per blob in these + ranges) with these size / brightness swings - rest, reasoning, calm. */ + breathMs: { rest: [5000, 8000], reason: [1800, 3400], calm: [5500, 8500] }, + breathSize: { rest: 0.05, reason: 0.22, calm: 0.06 }, + breathGlow: { rest: 0.05, reason: 0.2, calm: 0.06 }, + /* While reasoning the whole glow breathes along ... */ + chorusMs: 3200, + chorusSize: 0.06, + chorusGlow: 0.08, + /* ... and a stream of light flows the other way along the edge. */ + streamFlow: -1.6, + streamGlow: 0.7, + rise: 3.6, // spring rates (rad/s): ~1.1s into reasoning, + fall: 1.45, // ~2.7s back out, + settle: 1.3 // ~3s between the default and the after-answer calm + }; + const SBG_CALM_RISE = 0.1; // after an answer the blobs rise behind the bar (x radius) + /* The fade (mirrored by the page's CSS mask builder): a short fall - the + edge light, "spread" - plus a long faint tail - "wash coverage". The + reasoning gain lives only in the top `band` px. */ + const SBG_ENVELOPE = { spread: 70, wash: 320, tail: 0.45, band: 80 }; + const SBG_TALL = 2.0; + const SBG_BASE_LIFT = -8; + const SBG_BLUR = { light: 40, dark: 64 }; + const SBG_SCALE = 0.25; + const SBG_PAD = 96; + + + const warnedElements = new WeakMap(); + + function warnOnce(element, code, message) { + let codes = warnedElements.get(element); + if (!codes) { + codes = new Set(); + warnedElements.set(element, codes); + } + if (codes.has(code)) return; + codes.add(code); + console.warn(`[Radiance ${code}] ${message}`); + } + + function resolveRadiancePattern(raw) { + const tokens = String(raw || "").toLowerCase().split(/[^a-z]+/).filter(Boolean); + if (!tokens.length) return null; + + let layout = null; + for (const token of tokens) { + if (PLACEMENT_ALIASES[token]) { + layout = PLACEMENT_ALIASES[token]; + break; + } + } + if (!layout) return null; + + const has = (token) => tokens.includes(token); + const intensity = has("prominent") ? "prominent" : (has("subtle") ? "subtle" : PATTERN_FALLBACKS.intensity); + const motion = (has("static") || has("still")) ? "static" : PATTERN_FALLBACKS.motion; + const speedName = has("fast") ? "fast" : (has("slow") ? "slow" : PATTERN_FALLBACKS.speed); + const appearance = has("dark") ? "dark" : (has("light") ? "light" : null); + + return { + name: `${layout}-${intensity}-${motion}-${speedName}`, + layout, + intensity, + motion, + speedName, + speed: RADIANCE_PATTERNS[layout].speeds[speedName], + appearance + }; + } + + const reducedMotionQuery = typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : null; + let prefersReducedMotion = reducedMotionQuery ? reducedMotionQuery.matches : false; + + const LIGHT_BLOBS = [ + { anchorX: 0.9, anchorY: 0.4, color: [255, 120, 160], radius: 0.4, opacity: 0.6, orbitX: 60, orbitY: 80, freqX: 0.0007, freqY: 0.0005, stretchFreq: 0.001 }, + { anchorX: 0.85, anchorY: 0.7, color: [80, 220, 180], radius: 0.36, opacity: 0.32, orbitX: -70, orbitY: -90, freqX: 0.0008, freqY: 0.0006, stretchFreq: 0.0012 }, + { anchorX: 0.95, anchorY: 0.5, color: [140, 100, 255], radius: 0.45, opacity: 0.6, orbitX: 50, orbitY: 100, freqX: 0.0005, freqY: 0.0009, stretchFreq: 0.0008 }, + { anchorX: 0.75, anchorY: 0.3, color: [255, 180, 100], radius: 0.3, opacity: 0.6, orbitX: -60, orbitY: 120, freqX: 0.0009, freqY: 0.0007, stretchFreq: 0.0015 }, + { anchorX: 0.8, anchorY: 0.6, color: [40, 140, 255], radius: 0.4, opacity: 0.6, orbitX: 90, orbitY: -80, freqX: 0.0008, freqY: 0.0011, stretchFreq: 0.0009 }, + { anchorX: 0.3, anchorY: 0.3, color: [200, 220, 255], radius: 0.7, opacity: 0.15, orbitX: 100, orbitY: 50, freqX: 0.0003, freqY: 0.0004, stretchFreq: 0.0005 }, + { anchorX: 0.2, anchorY: 0.7, color: [255, 200, 220], radius: 0.8, opacity: 0.15, orbitX: -80, orbitY: -100, freqX: 0.0004, freqY: 0.0003, stretchFreq: 0.0006 }, + { anchorX: 0.5, anchorY: 0.6, color: [255, 240, 180], radius: 0.6, opacity: 0.15, orbitX: 120, orbitY: -60, freqX: 0.0005, freqY: 0.0004, stretchFreq: 0.0007 } + ]; + + function scaleBlobSet(blobs, opacityScale, orbitScale) { + return blobs.map((blob) => ({ + ...blob, + opacity: blob.opacity * opacityScale, + orbitX: (blob.orbitX || 0) * orbitScale, + orbitY: (blob.orbitY || 0) * orbitScale + })); + } + + function mirrorBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + anchorX: 1 - blob.anchorX, + orbitX: -(blob.orbitX || 0) + })); + } + + function topBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + anchorX: clamp(blob.anchorY, 0.08, 0.94), + anchorY: clamp(1 - blob.anchorX, -0.04, 0.34), + orbitX: (blob.orbitY || 0) * 1.18, + orbitY: -(blob.orbitX || 0) * 0.58, + radius: blob.radius * 1.06 + })); + } + + function bottomBlobSet(blobs) { + return topBlobSet(blobs).map((blob) => ({ + ...blob, + anchorY: 1 - blob.anchorY, + orbitY: -(blob.orbitY || 0) + })); + } + + function lockBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + orbitX: 0, + orbitY: 0, + freqX: 0, + freqY: 0, + stretchFreq: 0, + locked: true + })); + } + + function neutralBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + surfaceScaled: true + })); + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + const mix = (a, b, t) => a + (b - a) * t; + const wave = (phase) => 0.5 - 0.5 * Math.cos(phase); + + // SURFACE-1: a base colour is any opaque CSS colour. Parsed once through a + // canvas, so every syntax the browser accepts works. + const colourCache = new Map(); + let colourProbe = null; + + function parseColour(value) { + const text = String(value || "").trim(); + if (!text) return null; + if (colourCache.has(text)) return colourCache.get(text); + colourProbe = colourProbe || document.createElement("canvas").getContext("2d"); + let rgb = null; + for (const sentinel of ["#010203", "#fefdfc"]) { + colourProbe.fillStyle = sentinel; + colourProbe.fillStyle = text; + const normal = String(colourProbe.fillStyle); + if (normal === sentinel) continue; + if (normal.startsWith("#")) { + rgb = [1, 3, 5].map((i) => parseInt(normal.slice(i, i + 2), 16)); + } else { + const parts = (normal.match(/rgba?\(([^)]+)\)/) || [])[1]; + const values = parts ? parts.split(",").map((part) => Number(part.trim())) : []; + if (values.length >= 3 && (values.length < 4 || values[3] >= 0.99)) rgb = values.slice(0, 3).map(Math.round); + } + break; + } + if (!rgb && text.toLowerCase() === "#010203") rgb = [1, 2, 3]; + colourCache.set(text, rgb); + return rgb; + } + + function hashSeed(value) { + let hash = 2166136261; + + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; + } + + function createSeededRandom(seed) { + let state = hashSeed(seed) || 1; + + return () => { + state += 0x6d2b79f5; + let result = state; + result = Math.imul(result ^ (result >>> 15), result | 1); + result ^= result + Math.imul(result ^ (result >>> 7), result | 61); + return ((result ^ (result >>> 14)) >>> 0) / 4294967296; + }; + } + + function getAnimationTimeOrigin() { + const now = Date.now(); + + try { + const storedOrigin = Number(window.sessionStorage.getItem(TIME_ORIGIN_KEY)); + if (Number.isFinite(storedOrigin) && storedOrigin > 0) return storedOrigin; + + window.sessionStorage.setItem(TIME_ORIGIN_KEY, String(now)); + } catch { + return now; + } + + return now; + } + + function getBlobSurfaceScale(config, width, height) { + if (!config.surfaceScaled) return { radius: 1, orbitX: 1, orbitY: 1 }; + + const minSide = Math.max(1, Math.min(width, height)); + const maxSide = Math.max(width, height); + const aspect = maxSide / minSide; + const compactBoost = clamp((480 - minSide) / 280, 0, 1); + const fieldBoost = clamp((maxSide - 760) / 720, 0, 1); + + return { + radius: 1.06 + clamp(aspect - 1, 0, 1.1) * 0.13 + compactBoost * 0.14 + fieldBoost * 0.05, + orbitX: clamp(width / 920, 0.28, 1.38), + orbitY: clamp(height / 680, 0.28, 1.38) + }; + } + + const LIGHT_SUBTLE_BLOBS = scaleBlobSet(LIGHT_BLOBS, 0.44, 0.62); + + const STATIC_LIGHT_BLOBS = [ + { anchorX: 1.02, anchorY: 0.14, color: [255, 120, 160], radius: 0.48, opacity: 0.58, locked: true }, + { anchorX: 0.96, anchorY: 0.42, color: [140, 100, 255], radius: 0.54, opacity: 0.6, locked: true }, + { anchorX: 0.8, anchorY: 0.62, color: [40, 140, 255], radius: 0.54, opacity: 0.58, locked: true }, + { anchorX: 0.78, anchorY: 0.28, color: [255, 180, 100], radius: 0.36, opacity: 0.44, locked: true }, + { anchorX: 1.02, anchorY: 0.82, color: [80, 220, 180], radius: 0.38, opacity: 0.1, locked: true }, + { anchorX: 0.34, anchorY: 0.34, color: [200, 220, 255], radius: 0.76, opacity: 0.1, locked: true }, + { anchorX: 0.2, anchorY: 0.74, color: [255, 200, 220], radius: 0.84, opacity: 0.075, locked: true }, + { anchorX: 0.52, anchorY: 0.62, color: [255, 240, 180], radius: 0.62, opacity: 0.075, locked: true } + ]; + + const STATIC_LIGHT_SUBTLE_BLOBS = scaleBlobSet(STATIC_LIGHT_BLOBS, 0.48, 1); + + const NEUTRAL_LIGHT_BLOBS = neutralBlobSet([ + { anchorX: 0.12, anchorY: 0.12, color: [255, 180, 100], radius: 0.39, opacity: 0.34, orbitX: 90, orbitY: 68, freqX: 0.00072, freqY: 0.00056, stretchFreq: 0.00108 }, + { anchorX: 0.28, anchorY: 0.28, color: [255, 120, 160], radius: 0.46, opacity: 0.5, orbitX: -118, orbitY: 84, freqX: 0.00086, freqY: 0.00068, stretchFreq: 0.00118 }, + { anchorX: 0.14, anchorY: 0.78, color: [40, 140, 255], radius: 0.54, opacity: 0.56, orbitX: 126, orbitY: -110, freqX: 0.00074, freqY: 0.00092, stretchFreq: 0.00102 }, + { anchorX: 0.44, anchorY: 0.18, color: [140, 100, 255], radius: 0.45, opacity: 0.48, orbitX: 106, orbitY: 92, freqX: 0.00064, freqY: 0.00086, stretchFreq: 0.00112 }, + { anchorX: 0.68, anchorY: 0.32, color: [40, 140, 255], radius: 0.49, opacity: 0.54, orbitX: 108, orbitY: -92, freqX: 0.00084, freqY: 0.00064, stretchFreq: 0.00114 }, + { anchorX: 0.84, anchorY: 0.18, color: [255, 120, 160], radius: 0.42, opacity: 0.45, orbitX: -98, orbitY: 98, freqX: 0.00078, freqY: 0.00088, stretchFreq: 0.00106 }, + { anchorX: 0.88, anchorY: 0.62, color: [140, 100, 255], radius: 0.56, opacity: 0.58, orbitX: -122, orbitY: -104, freqX: 0.00058, freqY: 0.00094, stretchFreq: 0.00098 }, + { anchorX: 0.68, anchorY: 0.86, color: [40, 140, 255], radius: 0.54, opacity: 0.54, orbitX: 108, orbitY: -110, freqX: 0.0009, freqY: 0.00072, stretchFreq: 0.0012 }, + { anchorX: 0.36, anchorY: 0.84, color: [255, 120, 160], radius: 0.42, opacity: 0.38, orbitX: 98, orbitY: -88, freqX: 0.0007, freqY: 0.00082, stretchFreq: 0.00104 }, + { anchorX: 0.48, anchorY: 0.48, color: [80, 220, 180], radius: 0.3, opacity: 0.04, orbitX: 78, orbitY: -72, freqX: 0.00068, freqY: 0.00072, stretchFreq: 0.00092 } + ]); + + const NEUTRAL_LIGHT_SUBTLE_BLOBS = scaleBlobSet(NEUTRAL_LIGHT_BLOBS, 0.52, 0.86); + const STATIC_NEUTRAL_LIGHT_BLOBS = lockBlobSet(NEUTRAL_LIGHT_BLOBS); + const STATIC_NEUTRAL_LIGHT_SUBTLE_BLOBS = lockBlobSet(NEUTRAL_LIGHT_SUBTLE_BLOBS); + + const DARK_SUBTLE_BLOBS = [ + { anchorX: 0.9, anchorY: 0.4, color: [255, 93, 142], radius: 0.42, opacity: 0.34, orbitX: 60, orbitY: 80, freqX: 0.0007, freqY: 0.0005, stretchFreq: 0.001 }, + { anchorX: 0.86, anchorY: 0.72, color: [45, 220, 177], radius: 0.38, opacity: 0.12, orbitX: -70, orbitY: -90, freqX: 0.0008, freqY: 0.0006, stretchFreq: 0.0012 }, + { anchorX: 0.95, anchorY: 0.5, color: [138, 93, 255], radius: 0.48, opacity: 0.36, orbitX: 50, orbitY: 100, freqX: 0.0005, freqY: 0.0009, stretchFreq: 0.0008 }, + { anchorX: 0.74, anchorY: 0.3, color: [255, 180, 90], radius: 0.29, opacity: 0.2, orbitX: -60, orbitY: 120, freqX: 0.0009, freqY: 0.0007, stretchFreq: 0.0015 }, + { anchorX: 0.8, anchorY: 0.62, color: [39, 140, 255], radius: 0.42, opacity: 0.32, orbitX: 90, orbitY: -80, freqX: 0.0008, freqY: 0.0011, stretchFreq: 0.0009 }, + { anchorX: 0.3, anchorY: 0.3, color: [160, 196, 255], radius: 0.62, opacity: 0.055, orbitX: 100, orbitY: 50, freqX: 0.0003, freqY: 0.0004, stretchFreq: 0.0005 }, + { anchorX: 0.2, anchorY: 0.72, color: [255, 170, 210], radius: 0.66, opacity: 0.055, orbitX: -80, orbitY: -100, freqX: 0.0004, freqY: 0.0003, stretchFreq: 0.0006 }, + { anchorX: 0.5, anchorY: 0.6, color: [255, 232, 150], radius: 0.52, opacity: 0.045, orbitX: 120, orbitY: -60, freqX: 0.0005, freqY: 0.0004, stretchFreq: 0.0007 } + ]; + + const DARK_PROMINENT_BLOBS = [ + { anchorX: 0.9, anchorY: 0.4, color: [255, 93, 142], radius: 0.46, opacity: 0.44, orbitX: 60, orbitY: 80, freqX: 0.0007, freqY: 0.0005, stretchFreq: 0.001 }, + { anchorX: 0.86, anchorY: 0.72, color: [45, 220, 177], radius: 0.42, opacity: 0.18, orbitX: -70, orbitY: -90, freqX: 0.0008, freqY: 0.0006, stretchFreq: 0.0012 }, + { anchorX: 0.95, anchorY: 0.5, color: [138, 93, 255], radius: 0.52, opacity: 0.46, orbitX: 50, orbitY: 100, freqX: 0.0005, freqY: 0.0009, stretchFreq: 0.0008 }, + { anchorX: 0.74, anchorY: 0.3, color: [255, 180, 90], radius: 0.32, opacity: 0.28, orbitX: -60, orbitY: 120, freqX: 0.0009, freqY: 0.0007, stretchFreq: 0.0015 }, + { anchorX: 0.8, anchorY: 0.62, color: [39, 140, 255], radius: 0.46, opacity: 0.42, orbitX: 90, orbitY: -80, freqX: 0.0008, freqY: 0.0011, stretchFreq: 0.0009 }, + { anchorX: 0.3, anchorY: 0.3, color: [160, 196, 255], radius: 0.78, opacity: 0.12, orbitX: 100, orbitY: 50, freqX: 0.0003, freqY: 0.0004, stretchFreq: 0.0005 }, + { anchorX: 0.2, anchorY: 0.72, color: [255, 170, 210], radius: 0.82, opacity: 0.12, orbitX: -80, orbitY: -100, freqX: 0.0004, freqY: 0.0003, stretchFreq: 0.0006 }, + { anchorX: 0.5, anchorY: 0.6, color: [255, 232, 150], radius: 0.64, opacity: 0.1, orbitX: 120, orbitY: -60, freqX: 0.0005, freqY: 0.0004, stretchFreq: 0.0007 } + ]; + + const STATIC_DARK_PROMINENT_BLOBS = [ + { anchorX: 1.02, anchorY: 0.14, color: [255, 93, 142], radius: 0.5, opacity: 0.42, locked: true }, + { anchorX: 0.96, anchorY: 0.44, color: [138, 93, 255], radius: 0.56, opacity: 0.46, locked: true }, + { anchorX: 0.8, anchorY: 0.64, color: [39, 140, 255], radius: 0.56, opacity: 0.42, locked: true }, + { anchorX: 0.76, anchorY: 0.28, color: [255, 180, 90], radius: 0.36, opacity: 0.24, locked: true }, + { anchorX: 1.02, anchorY: 0.84, color: [45, 220, 177], radius: 0.38, opacity: 0.07, locked: true }, + { anchorX: 0.34, anchorY: 0.34, color: [160, 196, 255], radius: 0.76, opacity: 0.08, locked: true }, + { anchorX: 0.2, anchorY: 0.74, color: [255, 170, 210], radius: 0.82, opacity: 0.075, locked: true }, + { anchorX: 0.52, anchorY: 0.62, color: [255, 232, 150], radius: 0.62, opacity: 0.055, locked: true } + ]; + + const STATIC_DARK_SUBTLE_BLOBS = scaleBlobSet(STATIC_DARK_PROMINENT_BLOBS, 0.54, 1); + + const NEUTRAL_DARK_PROMINENT_BLOBS = neutralBlobSet([ + { anchorX: 0.12, anchorY: 0.12, color: [255, 180, 90], radius: 0.39, opacity: 0.22, orbitX: 90, orbitY: 68, freqX: 0.00072, freqY: 0.00056, stretchFreq: 0.00108 }, + { anchorX: 0.28, anchorY: 0.28, color: [255, 93, 142], radius: 0.46, opacity: 0.36, orbitX: -118, orbitY: 84, freqX: 0.00086, freqY: 0.00068, stretchFreq: 0.00118 }, + { anchorX: 0.14, anchorY: 0.78, color: [39, 140, 255], radius: 0.54, opacity: 0.4, orbitX: 126, orbitY: -110, freqX: 0.00074, freqY: 0.00092, stretchFreq: 0.00102 }, + { anchorX: 0.44, anchorY: 0.18, color: [138, 93, 255], radius: 0.45, opacity: 0.36, orbitX: 106, orbitY: 92, freqX: 0.00064, freqY: 0.00086, stretchFreq: 0.00112 }, + { anchorX: 0.68, anchorY: 0.32, color: [39, 140, 255], radius: 0.49, opacity: 0.38, orbitX: 108, orbitY: -92, freqX: 0.00084, freqY: 0.00064, stretchFreq: 0.00114 }, + { anchorX: 0.84, anchorY: 0.18, color: [255, 93, 142], radius: 0.42, opacity: 0.32, orbitX: -98, orbitY: 98, freqX: 0.00078, freqY: 0.00088, stretchFreq: 0.00106 }, + { anchorX: 0.88, anchorY: 0.62, color: [138, 93, 255], radius: 0.56, opacity: 0.42, orbitX: -122, orbitY: -104, freqX: 0.00058, freqY: 0.00094, stretchFreq: 0.00098 }, + { anchorX: 0.68, anchorY: 0.86, color: [39, 140, 255], radius: 0.54, opacity: 0.4, orbitX: 108, orbitY: -110, freqX: 0.0009, freqY: 0.00072, stretchFreq: 0.0012 }, + { anchorX: 0.36, anchorY: 0.84, color: [255, 93, 142], radius: 0.42, opacity: 0.28, orbitX: 98, orbitY: -88, freqX: 0.0007, freqY: 0.00082, stretchFreq: 0.00104 }, + { anchorX: 0.48, anchorY: 0.48, color: [45, 220, 177], radius: 0.3, opacity: 0.024, orbitX: 78, orbitY: -72, freqX: 0.00068, freqY: 0.00072, stretchFreq: 0.00092 } + ]); + + const NEUTRAL_DARK_SUBTLE_BLOBS = scaleBlobSet(NEUTRAL_DARK_PROMINENT_BLOBS, 0.52, 0.86); + const STATIC_NEUTRAL_DARK_PROMINENT_BLOBS = lockBlobSet(NEUTRAL_DARK_PROMINENT_BLOBS); + const STATIC_NEUTRAL_DARK_SUBTLE_BLOBS = lockBlobSet(NEUTRAL_DARK_SUBTLE_BLOBS); + + const NEUTRAL_VEIL_BLOBS = [ + { anchorX: 0.08, anchorY: 0.18, radius: 0.56, opacity: 0.28, orbitX: 52, orbitY: 44, freqX: 0.00048, freqY: 0.0004, phase: 0.32 }, + { anchorX: 0.52, anchorY: 0.08, radius: 0.48, opacity: 0.2, orbitX: -56, orbitY: 38, freqX: 0.00038, freqY: 0.00046, phase: 1.18 }, + { anchorX: 0.94, anchorY: 0.26, radius: 0.54, opacity: 0.24, orbitX: -48, orbitY: 54, freqX: 0.00044, freqY: 0.00036, phase: 2.42 }, + { anchorX: 0.22, anchorY: 0.82, radius: 0.6, opacity: 0.22, orbitX: 62, orbitY: -46, freqX: 0.00034, freqY: 0.00042, phase: 3.36 }, + { anchorX: 0.74, anchorY: 0.78, radius: 0.58, opacity: 0.24, orbitX: -64, orbitY: -52, freqX: 0.00042, freqY: 0.00038, phase: 4.72 }, + { anchorX: 0.48, anchorY: 0.5, radius: 0.72, opacity: 0.16, orbitX: 46, orbitY: -42, freqX: 0.0003, freqY: 0.00034, phase: 5.58 } + ]; + + const surfaces = new Set(); + const surfaceByElement = new WeakMap(); + const textureCache = new Map(); + const timeOrigin = getAnimationTimeOrigin(); + let isDocumentVisible = document.visibilityState !== "hidden"; + let mutationObserver; + let raf = 0; + + function getTimelineTime() { + return Math.max(0, Date.now() - timeOrigin); + } + + function rememberTexture(key, texture) { + if (textureCache.has(key)) return textureCache.get(key); + textureCache.set(key, texture); + + if (textureCache.size > MAX_TEXTURE_CACHE_ITEMS) { + const oldestKey = textureCache.keys().next().value; + textureCache.delete(oldestKey); + } + + return texture; + } + + class RadianceBlob { + constructor(config, random) { + this.config = config; + this.phaseX = random() * Math.PI * 2; + this.phaseY = random() * Math.PI * 2; + this.phaseStretch = random() * Math.PI * 2; + this.phaseDrift = random() * Math.PI * 2; + // F2: the extra sines' ratios and phases, a slow tilt, and the blob's + // own breath - all seeded (IMPL-4), so a surface is stable on reload. + this.phases = Array.from({ length: 8 }, () => random() * TAU); + this.ratios = [ + 1.52 + random() * 0.2, + 2.3 + random() * 0.3, + 1.6 + random() * 0.3, + 1.4 + random() * 0.3, + 1.5 + random() * 0.3, + 1.5 + random() * 0.3, + 1.6 + random() * 0.3 + ]; + this.tiltFreq = 0.00022 + random() * 0.00022; + this.breathMs = BREATH.periodMs[0] + random() * (BREATH.periodMs[1] - BREATH.periodMs[0]); + this.breathPhase = random() * TAU; + this.x = 0; + this.y = 0; + this.r = 0; + this.stretchX = 1; + this.stretchY = 1; + this.tilt = 0; + this.hasState = false; + } + + update(t, width, height, mouse, breathT = 0) { + const config = this.config; + const baseX = width * config.anchorX; + const baseY = height * config.anchorY; + const minSide = Math.min(width, height); + const surfaceScale = getBlobSurfaceScale(config, width, height); + const baseRadius = minSide * config.radius * surfaceScale.radius; + + if (config.locked) { + this.x = baseX; + this.y = baseY; + this.r = baseRadius; + this.stretchX = 1; + this.stretchY = 1; + this.tilt = 0; + this.opacity = config.opacity; + this.hasState = true; + return; + } + + const freqX = config.freqX || 0.00022; + const freqY = config.freqY || 0.00022; + const stretchFreq = config.stretchFreq || 0.00032; + let targetX; + let targetY; + let targetR; + let targetStretchX; + let targetStretchY; + + // F2 (MOTION-2): each motion is 2-3 sines at incommensurate per-blob + // ratios - the same reach as v1's single sines, but a path that + // never retraces - and the blob breathes on its own rhythm. + const p = this.phases; + const k = this.ratios; + const orbitX = (config.orbitX || 0) * surfaceScale.orbitX; + const orbitY = (config.orbitY || 0) * surfaceScale.orbitY; + const breath = wave(TAU * breathT / this.breathMs + this.breathPhase); + targetX = baseX + orbitX * ( + 0.72 * Math.sin(t * freqX + this.phaseX) + + 0.2 * Math.sin(t * freqX * k[0] + p[0]) + + 0.08 * Math.sin(t * freqX * k[1] + p[1])); + targetY = baseY + orbitY * ( + 0.78 * Math.cos(t * freqY + this.phaseY) + + 0.22 * Math.sin(t * freqY * k[2] + p[2])); + targetR = baseRadius + * (1 + 0.1 * Math.sin(t * 0.001 + this.phaseX) + 0.05 * Math.sin(t * 0.001 * k[3] + p[3])) + * (1 + BREATH.size * (breath - 0.4)); + targetStretchX = 1 + 0.3 * (0.72 * Math.sin(t * stretchFreq + this.phaseX) + 0.28 * Math.sin(t * stretchFreq * k[4] + p[4])); + targetStretchY = 1 + 0.3 * (0.72 * Math.cos(t * stretchFreq + this.phaseY) + 0.28 * Math.sin(t * stretchFreq * k[5] + p[5])); + this.tilt = 0.16 * Math.sin(t * this.tiltFreq + p[6]) + 0.08 * Math.sin(t * this.tiltFreq * k[6] + p[7]); + this.opacity = config.opacity * (1 + BREATH.glow * (breath - 0.5)); + + const ease = 1; + this.x += (targetX - this.x) * ease; + this.y += (targetY - this.y) * ease; + this.r += (targetR - this.r) * ease; + this.stretchX += (targetStretchX - this.stretchX) * ease; + this.stretchY += (targetStretchY - this.stretchY) * ease; + this.hasState = true; + } + + draw(ctx) { + const [r, g, b] = this.config.color; + const opacity = this.opacity ?? this.config.opacity; + ctx.save(); + ctx.translate(this.x, this.y); + if (this.tilt) ctx.rotate(this.tilt); + ctx.scale(this.stretchX, this.stretchY); + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, this.r); + gradient.addColorStop(0, `rgba(${r},${g},${b},${opacity})`); + gradient.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, this.r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + class RadianceSurface { + constructor(element) { + this.element = element; + this.canvas = document.createElement("canvas"); + this.ctx = this.canvas.getContext("2d", { alpha: false }); + this.mouse = { x: -9999, y: -9999 }; + this.width = 0; + this.height = 0; + this.dpr = 1; + this.textureKey = ""; + this.ready = false; + this.isInViewport = true; + this.needsResize = true; + this.staticFrameKey = ""; + this.blobCanvas = document.createElement("canvas"); + this.blobCtx = this.blobCanvas.getContext("2d"); + this.fieldCanvas = document.createElement("canvas"); + this.fieldCtx = this.fieldCanvas.getContext("2d"); + this.grainCanvas = document.createElement("canvas"); + this.grainMaskCtx = this.grainCanvas.getContext("2d"); + this.maskCanvas = document.createElement("canvas"); + this.maskCtx = this.maskCanvas.getContext("2d"); + this.liftCanvas = document.createElement("canvas"); + this.liftCtx = this.liftCanvas.getContext("2d"); + this.blobs = []; + this.lastConfigKey = ""; + this.appliedPatternSource = null; + this.product = null; + this.productName = ""; + this.cssBase = ""; + this.baseKey = ""; + this.baseRgb = [255, 255, 255]; + this.baseFill = LIGHT_SURFACE; + this.element.appendChild(this.canvas); + this.element.dataset.radianceReady = "false"; + + if ("ResizeObserver" in window) { + this.resizeObserver = new ResizeObserver(() => { + this.needsResize = true; + }); + this.resizeObserver.observe(this.element); + } + + if ("IntersectionObserver" in window) { + this.intersectionObserver = new IntersectionObserver((entries) => { + this.isInViewport = entries.some((entry) => entry.isIntersecting); + if (this.isInViewport) startLoop(); + }); + this.intersectionObserver.observe(this.element); + } + } + + destroy() { + this.resizeObserver?.disconnect(); + this.intersectionObserver?.disconnect(); + this.canvas.remove(); + } + + get appearance() { + return this.element.dataset.radianceAppearance === "dark" ? "dark" : "light"; + } + + // A template product pattern (signup, home), or null. + get productTemplate() { + return this.product && this.product.engine === "template" ? this.product : null; + } + + get isSpotter() { + return Boolean(this.product && this.product.engine === "spotter"); + } + + get speed() { + const product = this.productTemplate; + if (product) return product.speed[this.appearance]; + const value = Number(this.element.dataset.radianceSpeed || "1"); + const base = Number.isFinite(value) ? value : 1; + return base * this.adjustment("speed", this.element.dataset.radianceSpeedScale); + } + + // ADJUST-1: every adjustment is a percentage of the pattern's own value, + // never an absolute. 100% means "exactly what the pattern ships". + adjustment(kind, raw) { + const scale = parseAdjustment(raw); + const range = ADJUST_RANGES[kind]; + if (range && (scale < range.min || scale > range.max)) { + warnOnce( + this.element, + range.rule, + `${kind} adjusted to ${Math.round(scale * 100)}% — the Radiance range is ` + + `${Math.round(range.min * 100)}%-${Math.round(range.max * 100)}%. ` + + "Outside it this surface stops matching the rest of the product. See RULES.md." + ); + } + return scale; + } + + get prominenceScale() { + if (this.product) return 1; + return this.adjustment("prominence", this.element.dataset.radianceProminence); + } + + get spreadScale() { + if (this.product) return 1; + return this.adjustment("spread", this.element.dataset.radianceSpread); + } + + get variant() { + const product = this.productTemplate; + if (product) { + const motion = prefersReducedMotion && product.reducedMotion === "static" ? "static" : "dynamic"; + return `${motion}-${product.intensity}`; + } + return this.element.dataset.radianceVariant || ""; + } + + get darkGlow() { + const product = this.productTemplate; + if (product) return product.intensity; + return this.element.dataset.radianceDarkGlow || "subtle"; + } + + get seed() { + const product = this.productTemplate; + if (product) return product.seed; + return this.element.dataset.radianceSeed || ""; + } + + // "renderer": the v2 film stack (F1). "page": the product's own page + // recipe carries the film; the renderer draws plain full-surface grain. + get filmMode() { + const product = this.productTemplate; + return product ? product.film : "renderer"; + } + + get layout() { + const product = this.productTemplate; + if (product) return product.layout; + const layout = this.element.dataset.radianceLayout || "right-wash"; + if (layout === "left" || layout === "left-wash") return "left-wash"; + if (layout === "top" || layout === "top-wash") return "top-wash"; + if (layout === "bottom" || layout === "bottom-wash") return "bottom-wash"; + if (layout === "neutral" || layout === "home-corners" || layout === "modal-balanced" || layout === "developer-sides") return "neutral"; + return "right-wash"; + } + + get isSubtle() { + const product = this.productTemplate; + if (product) return product.intensity === "subtle"; + return this.variant.endsWith("subtle") || this.element.dataset.radianceIntensity === "subtle"; + } + + get isDark() { + return this.appearance === "dark"; + } + + get isStill() { + // MOTION-7: a reduced-motion request freezes the surface at its + // resting frame. Same pattern, same composition, no drift. + if (prefersReducedMotion) return true; + if (this.product) return false; + return this.element.dataset.radianceMotion === "still" || this.variant.startsWith("static"); + } + + applyPatternShorthand() { + const raw = (this.element.dataset.radiancePattern || "").trim(); + if (raw === this.appliedPatternSource) { + if (this.product) this.guardProduct(); + return; + } + this.appliedPatternSource = raw; + + // PRODUCT-1: a product name selects that product's fixed pattern. + const productKey = raw.toLowerCase(); + if (Object.prototype.hasOwnProperty.call(PRODUCT_PATTERNS, productKey)) { + this.product = PRODUCT_PATTERNS[productKey]; + this.productName = productKey; + this.needsResize = true; + this.lastConfigKey = ""; + this.staticFrameKey = ""; + this.baseKey = ""; + this.guardProduct(); + return; + } + if (this.product) { + this.product = null; + this.productName = ""; + this.needsResize = true; + this.lastConfigKey = ""; + this.baseKey = ""; + } + if (!raw) return; + + const pattern = resolveRadiancePattern(raw); + if (!pattern) { + warnOnce( + this.element, + "PLACE-1", + `Unknown pattern "${raw}". Templates are: ${Object.keys(RADIANCE_PATTERNS).join(", ")}; ` + + `product patterns are: ${Object.keys(PRODUCT_PATTERNS).join(", ")}. ` + + "Keeping the data-radiance-* attributes already on this element." + ); + return; + } + + const data = this.element.dataset; + data.radianceLayout = pattern.layout; + data.radianceIntensity = pattern.intensity; + data.radianceDarkGlow = pattern.intensity; + data.radianceVariant = `${pattern.motion}-${pattern.intensity}`; + data.radianceSpeed = String(pattern.speed); + if (pattern.appearance) data.radianceAppearance = pattern.appearance; + if (pattern.motion === "static") data.radianceMotion = "still"; + else delete data.radianceMotion; + } + + // PRODUCT-2: a product pattern takes no adjustment or override. The + // attributes stay on the element (the page owns them) but are ignored. + guardProduct() { + const data = this.element.dataset; + const present = PRODUCT_LOCKED_ATTRIBUTES.filter(([key]) => data[key] !== undefined).map(([, attr]) => attr); + if (!present.length) return; + warnOnce( + this.element, + "PRODUCT-2", + `data-radiance-pattern="${this.productName}" is a fixed product pattern, so ${present.join(", ")} ` + + `${present.length === 1 ? "is" : "are"} ignored. Product patterns take no adjustment or override; ` + + "only the Radiance admin changes them. See RULES.md section 12." + ); + } + + // SURFACE-1: the base is the host's own background colour. Product + // patterns lock theirs; templates take data-radiance-base, then the + // --radiance-base custom property, then the language default. + resolveBase() { + const theme = this.appearance; + const raw = this.product + ? this.product.base[theme] + : (this.element.dataset.radianceBase || this.cssBase || ""); + const key = `${theme}|${raw}`; + if (key === this.baseKey) return this.baseRgb; + this.baseKey = key; + let rgb = raw ? parseColour(raw) : null; + if (raw && !rgb) { + warnOnce( + this.element, + "SURFACE-1", + `base colour "${raw}" is not an opaque colour, so the default ${theme} base is used. ` + + "Set data-radiance-base (or --radiance-base) to the exact, opaque background of the area this surface sits in." + ); + } + if (!rgb) rgb = theme === "dark" ? [26, 27, 30] : [255, 255, 255]; + this.baseRgb = rgb; + this.baseFill = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + this.staticFrameKey = ""; + return rgb; + } + + get baseTone() { + return `${this.baseRgb[0]},${this.baseRgb[1]},${this.baseRgb[2]}`; + } + + checkConsistency() { + const data = this.element.dataset; + if (this.product) return; + + if (data.radianceIntensity && data.radianceDarkGlow && data.radianceIntensity !== data.radianceDarkGlow) { + warnOnce( + this.element, + "INT-3", + `data-radiance-dark-glow="${data.radianceDarkGlow}" does not match ` + + `data-radiance-intensity="${data.radianceIntensity}". Dark mode draws a different falloff ` + + "when these disagree, so the surface will not match the rest of the product. Set both to the same value." + ); + } + + // MOTION-5 is an absolute guardrail: it applies to the EFFECTIVE speed, + // after any data-radiance-speed-scale percentage has been applied. + const effective = this.speed; + if (Number.isFinite(effective) && (effective < 0.4 || effective > 2)) { + const scale = parseAdjustment(data.radianceSpeedScale); + const via = scale === 1 + ? "" + : ` (${data.radianceSpeed} x ${Math.round(scale * 100)}%)`; + warnOnce( + this.element, + "MOTION-5", + `effective speed ${Math.round(effective * 1000) / 1000}${via} is outside the Radiance ` + + "range 0.4-2.0. Below 0.4 the drift reads as dead, above 2.0 it reads as nervous. " + + "See RULES.md (MOTION-5)." + ); + } + } + + isRenderable() { + if (!isDocumentVisible || !this.element.isConnected || !this.isInViewport) return false; + if (document.documentElement.dataset.radiancePaused === "true" || document.body?.classList.contains("is-proof-paused")) return false; + const slide = this.element.closest(".slide"); + if (slide && !slide.classList.contains("is-active")) return false; + const proofView = this.element.closest(".proof-view"); + if (proofView && !proofView.classList.contains("is-active")) return false; + const style = window.getComputedStyle(this.element); + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false; + this.cssBase = style.getPropertyValue("--radiance-base").trim(); + const rect = this.element.getBoundingClientRect(); + return rect.width > 2 && rect.height > 2; + } + + getConfig() { + const layout = this.layout; + + if (!this.isDark) { + if (layout === "neutral") { + if (this.variant.startsWith("static")) return this.isSubtle ? STATIC_NEUTRAL_LIGHT_SUBTLE_BLOBS : STATIC_NEUTRAL_LIGHT_BLOBS; + return this.isSubtle ? NEUTRAL_LIGHT_SUBTLE_BLOBS : NEUTRAL_LIGHT_BLOBS; + } + const base = this.variant.startsWith("static") + ? (this.isSubtle ? STATIC_LIGHT_SUBTLE_BLOBS : STATIC_LIGHT_BLOBS) + : (this.isSubtle ? LIGHT_SUBTLE_BLOBS : LIGHT_BLOBS); + if (layout === "top-wash") return topBlobSet(base); + if (layout === "bottom-wash") return bottomBlobSet(base); + return layout === "left-wash" ? mirrorBlobSet(base) : base; + } + + if (layout === "neutral") { + if (this.variant.startsWith("static")) return this.isSubtle ? STATIC_NEUTRAL_DARK_SUBTLE_BLOBS : STATIC_NEUTRAL_DARK_PROMINENT_BLOBS; + return this.isSubtle ? NEUTRAL_DARK_SUBTLE_BLOBS : NEUTRAL_DARK_PROMINENT_BLOBS; + } + const base = this.variant.startsWith("static") + ? (this.isSubtle ? STATIC_DARK_SUBTLE_BLOBS : STATIC_DARK_PROMINENT_BLOBS) + : (this.darkGlow === "subtle" || this.isSubtle ? DARK_SUBTLE_BLOBS : DARK_PROMINENT_BLOBS); + if (layout === "top-wash") return topBlobSet(base); + if (layout === "bottom-wash") return bottomBlobSet(base); + return layout === "left-wash" ? mirrorBlobSet(base) : base; + } + + ensureSize() { + if (this.resizeObserver && !this.needsResize && this.width > 0 && this.height > 0) return; + + const rect = this.element.getBoundingClientRect(); + const nextWidth = Math.max(1, Math.round(this.element.clientWidth || rect.width)); + const nextHeight = Math.max(1, Math.round(this.element.clientHeight || rect.height)); + // The spotter engine renders one canvas pixel per CSS pixel and is + // shown pixelated, so its 1.0px film stays crisp (GRAIN-1/3). + const nextDpr = this.isSpotter ? 1 : Math.min(window.devicePixelRatio || 1, MAX_DPR); + this.canvas.style.imageRendering = this.isSpotter ? "pixelated" : ""; + + if (nextWidth === this.width && nextHeight === this.height && nextDpr === this.dpr) { + this.needsResize = false; + return; + } + + this.width = nextWidth; + this.height = nextHeight; + this.dpr = nextDpr; + this.canvas.width = Math.max(1, Math.floor(this.width * this.dpr)); + this.canvas.height = Math.max(1, Math.floor(this.height * this.dpr)); + this.canvas.style.width = `${this.width}px`; + this.canvas.style.height = `${this.height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.ctx.imageSmoothingEnabled = true; + this.ctx.imageSmoothingQuality = "high"; + this.textureKey = ""; + this.staticFrameKey = ""; + this.needsResize = false; + } + + ensureBlobs() { + const configKey = this.product + ? [this.appearance, this.productName, this.variant].join(":") + : [ + this.appearance, + this.darkGlow, + this.element.dataset.radianceLayout || "default", + this.element.dataset.radianceIntensity || "default", + this.variant || "default", + this.element.dataset.radianceMotion || "dynamic", + this.element.dataset.radianceProminence || "100%", + this.element.dataset.radianceSpread || "100%" + ].join(":"); + if (configKey === this.lastConfigKey && this.blobs.length) return; + this.lastConfigKey = configKey; + this.checkConsistency(); + this.staticFrameKey = ""; + const seed = this.seed || configKey; + const random = createSeededRandom(`radiance-blobs:${seed}`); + const prominence = this.prominenceScale; + const spread = this.spreadScale; + const configs = (prominence === 1 && spread === 1) + ? this.getConfig() + : this.getConfig().map((config) => ({ + ...config, + opacity: config.opacity * prominence, + radius: config.radius * spread + })); + this.blobs = configs.map((config) => new RadianceBlob(config, random)); + } + + createGrain(mode) { + const texture = document.createElement("canvas"); + texture.width = Math.max(1, Math.floor(this.width)); + texture.height = Math.max(1, Math.floor(this.height)); + const grainCtx = texture.getContext("2d"); + const isDark = mode === "dark"; + + const rawTexture = document.createElement("canvas"); + const rawWidth = Math.max(1, Math.ceil(texture.width / RADIANCE_FILM_GRAIN_SIZE)); + const rawHeight = Math.max(1, Math.ceil(texture.height / RADIANCE_FILM_GRAIN_SIZE)); + rawTexture.width = rawWidth; + rawTexture.height = rawHeight; + const rawCtx = rawTexture.getContext("2d"); + const image = rawCtx.createImageData(rawWidth, rawHeight); + const lightFilmBright = [ + [232, 248, 252], + [244, 229, 253], + [253, 228, 240], + [253, 244, 212], + [235, 241, 248] + ]; + const lightFilmChroma = [ + [120, 208, 230], + [194, 132, 232], + [244, 132, 196], + [246, 204, 104], + [116, 154, 246], + [128, 218, 194] + ]; + const lightFilmMid = [ + [174, 214, 220], + [204, 177, 224], + [224, 178, 202], + [224, 206, 150], + [184, 196, 210] + ]; + const lightFilmDark = [ + [122, 150, 156], + [150, 124, 168], + [168, 126, 148], + [150, 140, 116], + [132, 138, 146] + ]; + const darkFilmColors = [ + [76, 116, 164], + [122, 82, 140], + [148, 78, 112], + [118, 112, 92], + [132, 138, 148] + ]; + const darkFilmShadows = [ + [10, 12, 16], + [14, 15, 18], + [18, 16, 23], + [16, 18, 24] + ]; + + const writeFilmGrain = (data, alphaMultiplier, random) => { + for (let i = 0; i < data.length; i += 4) { + let color; + let alpha; + let sharedJitter; + let channelJitter; + + if (isDark) { + const toneRoll = random(); + const useShadowFleck = toneRoll < 0.16; + color = useShadowFleck + ? darkFilmShadows[Math.floor(random() * darkFilmShadows.length)] + : darkFilmColors[Math.floor(random() * darkFilmColors.length)]; + alpha = useShadowFleck + ? 7 + Math.floor(random() * 9) + : 14 + Math.floor(random() * 14); + sharedJitter = Math.floor((random() - 0.5) * (useShadowFleck ? 10 : 38)); + channelJitter = useShadowFleck ? 5 : 12; + } else { + const toneRoll = random(); + const palette = toneRoll < 0.68 + ? lightFilmBright + : toneRoll < 0.9 + ? lightFilmMid + : toneRoll < 0.97 + ? lightFilmChroma + : lightFilmDark; + color = palette[Math.floor(random() * palette.length)]; + alpha = toneRoll < 0.68 + ? 44 + Math.floor(random() * 28) + : toneRoll < 0.9 + ? 28 + Math.floor(random() * 22) + : toneRoll < 0.97 + ? 22 + Math.floor(random() * 20) + : 10 + Math.floor(random() * 14); + sharedJitter = Math.floor((random() - 0.5) * (toneRoll < 0.9 ? 28 : 16)); + channelJitter = toneRoll < 0.97 ? 12 : 8; + } + + data[i] = Math.max(0, Math.min(255, color[0] + sharedJitter + Math.floor((random() - 0.5) * channelJitter))); + data[i + 1] = Math.max(0, Math.min(255, color[1] + sharedJitter + Math.floor((random() - 0.5) * channelJitter))); + data[i + 2] = Math.max(0, Math.min(255, color[2] + sharedJitter + Math.floor((random() - 0.5) * channelJitter))); + data[i + 3] = Math.max(0, Math.min(255, Math.round(alpha * alphaMultiplier))); + } + }; + + writeFilmGrain( + image.data, + isDark ? 1.18 : 1.16, + createSeededRandom(`radiance-grain:${mode}:macro:${texture.width}x${texture.height}`) + ); + rawCtx.putImageData(image, 0, 0); + + grainCtx.save(); + grainCtx.imageSmoothingEnabled = false; + grainCtx.drawImage(rawTexture, 0, 0, texture.width, texture.height); + grainCtx.restore(); + + const microTexture = document.createElement("canvas"); + microTexture.width = texture.width; + microTexture.height = texture.height; + const microCtx = microTexture.getContext("2d"); + const microImage = microCtx.createImageData(texture.width, texture.height); + writeFilmGrain( + microImage.data, + isDark ? 0.36 : 0.3, + createSeededRandom(`radiance-grain:${mode}:micro:${texture.width}x${texture.height}`) + ); + microCtx.putImageData(microImage, 0, 0); + grainCtx.drawImage(microTexture, 0, 0); + + return texture; + } + + createDarkDither() { + const texture = document.createElement("canvas"); + texture.width = Math.max(1, Math.floor(this.width * this.dpr)); + texture.height = Math.max(1, Math.floor(this.height * this.dpr)); + const ditherCtx = texture.getContext("2d"); + const image = ditherCtx.createImageData(texture.width, texture.height); + const data = image.data; + const random = createSeededRandom(`radiance-dither:${texture.width}x${texture.height}`); + + for (let i = 0; i < data.length; i += 4) { + const isLightSpeck = random() > 0.44; + const alpha = isLightSpeck + ? 6 + Math.floor(random() * 8) + : 4 + Math.floor(random() * 5); + + data[i] = isLightSpeck ? 255 : 10; + data[i + 1] = isLightSpeck ? 255 : 12; + data[i + 2] = isLightSpeck ? 255 : 16; + data[i + 3] = alpha; + } + + ditherCtx.putImageData(image, 0, 0); + return texture; + } + + drawLightRightEdgeSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + + const intensity = (this.isSubtle ? 0.58 : 1) * this.prominenceScale; + const pulse = (0.92 + 0.08 * Math.sin(t * 0.0005)) * intensity; + const rightWash = ctx.createLinearGradient(w * 0.36, 0, w, 0); + rightWash.addColorStop(0, "rgba(255,255,255,0)"); + rightWash.addColorStop(0.42, `rgba(200,220,255,${0.045 * pulse})`); + rightWash.addColorStop(0.66, `rgba(140,100,255,${0.075 * pulse})`); + rightWash.addColorStop(0.84, `rgba(255,120,160,${0.085 * pulse})`); + rightWash.addColorStop(1, `rgba(40,140,255,${0.11 * pulse})`); + ctx.fillStyle = rightWash; + ctx.fillRect(0, 0, w, h); + + const supportRadius = Math.max(w, h) * 0.42; + const supportGlows = [ + { x: w * 1.02, y: h * 0.12, radius: supportRadius, color: [255, 120, 160], alpha: 0.18 }, + { x: w * 0.96, y: h * 0.42, radius: supportRadius * 1.05, color: [140, 100, 255], alpha: 0.16 }, + { x: w * 1.02, y: h * 0.86, radius: supportRadius * 1.08, color: [40, 140, 255], alpha: 0.18 }, + { x: w * 0.84, y: h * 0.98, radius: supportRadius * 0.9, color: [80, 220, 180], alpha: 0.035 } + ]; + + for (const glow of supportGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.58, `rgba(${r},${g},${b},${glow.alpha * 0.3 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawDarkRightEdgeSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + + const intensity = (this.isSubtle ? 0.58 : 1) * this.prominenceScale; + const pulse = (0.86 + 0.14 * Math.sin(t * 0.00062)) * intensity; + const rightWash = ctx.createLinearGradient(w * 0.5, 0, w, 0); + rightWash.addColorStop(0, "rgba(0,0,0,0)"); + rightWash.addColorStop(0.56, `rgba(138,93,255,${0.035 * pulse})`); + rightWash.addColorStop(0.82, `rgba(39,140,255,${0.05 * pulse})`); + rightWash.addColorStop(1, `rgba(255,93,142,${0.055 * pulse})`); + ctx.fillStyle = rightWash; + ctx.fillRect(0, 0, w, h); + + const cornerGlowRadius = Math.max(w, h) * 0.44; + const cornerGlows = [ + { x: w * 1.03, y: h * 0.06, radius: cornerGlowRadius, color: [255, 93, 142], alpha: 0.14 }, + { x: w * 1.04, y: h * 0.92, radius: cornerGlowRadius * 1.05, color: [39, 140, 255], alpha: 0.15 }, + { x: w * 0.9, y: h * 1.02, radius: cornerGlowRadius * 0.82, color: [45, 220, 177], alpha: 0.03 } + ]; + + for (const glow of cornerGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.58, `rgba(${r},${g},${b},${glow.alpha * 0.28 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawLightTopSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + + const intensity = (this.isSubtle ? 0.64 : 1) * this.prominenceScale; + const pulse = (0.9 + 0.1 * Math.sin(t * 0.0007)) * intensity; + const topWash = ctx.createLinearGradient(0, 0, 0, h); + topWash.addColorStop(0, `rgba(200,220,255,${0.105 * pulse})`); + topWash.addColorStop(0.18, `rgba(140,100,255,${0.062 * pulse})`); + topWash.addColorStop(0.34, `rgba(255,120,160,${0.052 * pulse})`); + topWash.addColorStop(0.58, `rgba(255,200,220,${0.024 * pulse})`); + topWash.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = topWash; + ctx.fillRect(0, 0, w, h); + + const supportRadius = Math.max(w, h) * 0.42; + const supportGlows = [ + { x: w * 0.16, y: h * -0.04, radius: supportRadius, color: [255, 120, 160], alpha: 0.13 }, + { x: w * 0.36, y: h * -0.05, radius: supportRadius * 1.04, color: [140, 100, 255], alpha: 0.13 }, + { x: w * 0.64, y: h * -0.02, radius: supportRadius * 1.08, color: [40, 140, 255], alpha: 0.15 }, + { x: w * 0.86, y: h * 0.02, radius: supportRadius * 1.12, color: [80, 220, 180], alpha: 0.06 } + ]; + + for (const glow of supportGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.52, `rgba(${r},${g},${b},${glow.alpha * 0.28 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawDarkTopSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + + const intensity = (this.isSubtle ? 0.68 : 1) * this.prominenceScale; + const pulse = (0.86 + 0.14 * Math.sin(t * 0.00072)) * intensity; + const topWash = ctx.createLinearGradient(0, 0, 0, h); + topWash.addColorStop(0, `rgba(138,93,255,${0.082 * pulse})`); + topWash.addColorStop(0.18, `rgba(39,140,255,${0.058 * pulse})`); + topWash.addColorStop(0.34, `rgba(255,93,142,${0.043 * pulse})`); + topWash.addColorStop(0.58, `rgba(255,170,210,${0.018 * pulse})`); + topWash.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = topWash; + ctx.fillRect(0, 0, w, h); + + const supportRadius = Math.max(w, h) * 0.42; + const supportGlows = [ + { x: w * 0.18, y: h * -0.04, radius: supportRadius, color: [255, 93, 142], alpha: 0.105 }, + { x: w * 0.34, y: h * -0.05, radius: supportRadius * 1.04, color: [138, 93, 255], alpha: 0.12 }, + { x: w * 0.62, y: h * -0.02, radius: supportRadius * 1.08, color: [39, 140, 255], alpha: 0.13 }, + { x: w * 0.86, y: h * 0.02, radius: supportRadius * 1.12, color: [45, 220, 177], alpha: 0.035 } + ]; + + for (const glow of supportGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.54, `rgba(${r},${g},${b},${glow.alpha * 0.3 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawNeutralSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + const cycle = t * 0.00044; + const focusX = w * (0.5 + Math.sin(cycle) * 0.04); + const focusY = h * (0.48 + Math.cos(cycle * 0.86) * 0.035); + const radius = Math.max(w, h) * 0.74; + const washAlpha = (this.isSubtle ? 1.18 : 0.88) / this.prominenceScale; + + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + + const tone = this.baseTone; + const centerWash = ctx.createRadialGradient(focusX, focusY, radius * 0.06, focusX, focusY, radius); + if (this.isDark) { + centerWash.addColorStop(0, `rgba(${tone},${0.18 * washAlpha})`); + centerWash.addColorStop(0.48, `rgba(${tone},${0.08 * washAlpha})`); + centerWash.addColorStop(1, `rgba(${tone},0)`); + } else { + centerWash.addColorStop(0, `rgba(${tone},${0.34 * washAlpha})`); + centerWash.addColorStop(0.46, `rgba(${tone},${0.18 * washAlpha})`); + centerWash.addColorStop(1, `rgba(${tone},0)`); + } + ctx.fillStyle = centerWash; + ctx.fillRect(0, 0, w, h); + + const crossWash = ctx.createLinearGradient(0, h * 0.1, w, h * 0.92); + if (this.isDark) { + crossWash.addColorStop(0, `rgba(${tone},${0.08 * washAlpha})`); + crossWash.addColorStop(0.52, `rgba(${tone},0)`); + crossWash.addColorStop(1, `rgba(${tone},${0.065 * washAlpha})`); + } else { + crossWash.addColorStop(0, `rgba(${tone},${0.08 * washAlpha})`); + crossWash.addColorStop(0.5, `rgba(${tone},0)`); + crossWash.addColorStop(1, `rgba(${tone},${0.07 * washAlpha})`); + } + ctx.fillStyle = crossWash; + ctx.fillRect(0, 0, w, h); + + const minSide = Math.min(w, h); + const veilAlpha = (this.isSubtle ? 1.34 : 0.32) / this.prominenceScale; + const veilTone = tone; + + for (const veil of NEUTRAL_VEIL_BLOBS) { + const x = w * veil.anchorX + Math.sin(t * veil.freqX + veil.phase) * veil.orbitX; + const y = h * veil.anchorY + Math.cos(t * veil.freqY + veil.phase) * veil.orbitY; + const veilRadius = minSide * veil.radius; + const alpha = veil.opacity * veilAlpha; + const neutralBlob = ctx.createRadialGradient(x, y, veilRadius * 0.06, x, y, veilRadius); + neutralBlob.addColorStop(0, `rgba(${veilTone},${alpha})`); + neutralBlob.addColorStop(0.54, `rgba(${veilTone},${alpha * 0.42})`); + neutralBlob.addColorStop(1, `rgba(${veilTone},0)`); + ctx.fillStyle = neutralBlob; + ctx.fillRect(0, 0, w, h); + } + ctx.restore(); + } + + // GRAIN-4 (experiment): keep grain inside the colour field. + // The draw methods all write to this.ctx, so swap it for the offscreen + // field context rather than threading a target through every method. + renderColourField(t) { + const realCtx = this.ctx; + this.fieldCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.fieldCtx.clearRect(0, 0, this.width, this.height); + + this.fieldCtx.save(); + this.fieldCtx.filter = `blur(${this.layout === "neutral" ? 22 : 40}px)`; + for (const blob of this.blobs) { + blob.update(t, this.width, this.height, this.mouse, this.breathT); + blob.draw(this.fieldCtx); + } + this.fieldCtx.restore(); + + this.ctx = this.fieldCtx; + try { + this.drawSupport(t); + } finally { + this.ctx = realCtx; + } + } + + // Dark keeps its own colour compositing (each layer screened onto the + // base separately). This pass exists only to collect the field's ALPHA, + // so the colours it produces here are never shown. + renderDarkColourField(t) { + const realCtx = this.ctx; + this.fieldCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.fieldCtx.globalCompositeOperation = "source-over"; + this.fieldCtx.clearRect(0, 0, this.width, this.height); + + this.fieldCtx.save(); + this.fieldCtx.filter = `blur(${this.layout === "neutral" ? 40 : 64}px)`; + this.fieldCtx.drawImage(this.blobCanvas, 0, 0, this.width, this.height); + this.fieldCtx.restore(); + + this.ctx = this.fieldCtx; + try { + this.drawSupport(t); + } finally { + this.ctx = realCtx; + } + } + + // Stack the colour field onto itself so the mask reads closer to + // present/absent than to the field's own opacity. Without this the + // texture would be thinned everywhere in proportion to colour strength. + buildColourMask(saturation) { + const maskCtx = this.maskCtx; + const mw = this.maskCanvas.width; + const mh = this.maskCanvas.height; + maskCtx.setTransform(1, 0, 0, 1, 0, 0); + maskCtx.globalCompositeOperation = "source-over"; + maskCtx.imageSmoothingEnabled = true; + maskCtx.clearRect(0, 0, mw, mh); + // Stacking n copies gives 1 - (1 - a)^n; drawing the mask onto itself + // squares that, so 3 copies doubled twice is exactly the 12th power. + let power = saturation; + let doublings = 0; + while (power % 2 === 0 && power > 3) { + power /= 2; + doublings += 1; + } + for (let pass = 0; pass < power; pass += 1) { + maskCtx.drawImage(this.fieldCanvas, 0, 0, mw, mh); + } + for (let pass = 0; pass < doublings; pass += 1) { + maskCtx.drawImage(this.maskCanvas, 0, 0); + } + } + + // GRAIN-4: draw a texture through the colour mask, so it never lands on + // the bare surface. Call buildColourMask() first. + drawMaskedTexture(texture, alpha = 1) { + const grainCtx = this.grainMaskCtx; + grainCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + grainCtx.globalCompositeOperation = "source-over"; + grainCtx.clearRect(0, 0, this.width, this.height); + grainCtx.imageSmoothingEnabled = false; + grainCtx.drawImage(texture, 0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "destination-in"; + grainCtx.imageSmoothingEnabled = true; + grainCtx.drawImage(this.maskCanvas, 0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "source-over"; + + this.ctx.save(); + this.ctx.globalAlpha = alpha; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage(this.grainCanvas, 0, 0, this.width, this.height); + this.ctx.restore(); + } + + // F1: the fine noise layer, through the colour mask (GRAIN-4). + drawMaskedOverlay(theme) { + const pattern = getOverlayPattern(theme, this.dpr, this.grainMaskCtx); + if (!pattern) return; + const film = FILM[theme]; + const grainCtx = this.grainMaskCtx; + grainCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + grainCtx.globalCompositeOperation = "source-over"; + grainCtx.clearRect(0, 0, this.width, this.height); + grainCtx.fillStyle = pattern; + grainCtx.fillRect(0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "destination-in"; + grainCtx.imageSmoothingEnabled = true; + grainCtx.drawImage(this.maskCanvas, 0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "source-over"; + + this.ctx.save(); + this.ctx.globalAlpha = film.overlay; + this.ctx.globalCompositeOperation = film.overlayBlend; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage(this.grainCanvas, 0, 0, this.width, this.height); + this.ctx.restore(); + } + + // F1: the tone lift (Home's saturate/contrast), applied to the colour + // field only - the bare surface keeps the host's exact base colour. + applyToneLift(theme) { + const film = FILM[theme]; + const liftCtx = this.liftCtx; + const w = this.canvas.width; + const h = this.canvas.height; + liftCtx.setTransform(1, 0, 0, 1, 0, 0); + liftCtx.globalCompositeOperation = "copy"; + liftCtx.filter = `saturate(${film.saturate}) contrast(${film.contrast})`; + liftCtx.drawImage(this.canvas, 0, 0); + liftCtx.filter = "none"; + liftCtx.globalCompositeOperation = "destination-in"; + liftCtx.imageSmoothingEnabled = true; + liftCtx.drawImage(this.maskCanvas, 0, 0, w, h); + liftCtx.globalCompositeOperation = "source-over"; + + this.ctx.save(); + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.globalAlpha = 1; + this.ctx.globalCompositeOperation = "source-over"; + this.ctx.drawImage(this.liftCanvas, 0, 0); + this.ctx.restore(); + } + + drawSupport(t) { + if (this.layout === "neutral") { + this.drawNeutralSupport(t); + return; + } + + if (this.layout === "top-wash") { + if (this.isDark) this.drawDarkTopSupport(t); + else this.drawLightTopSupport(t); + return; + } + + if (this.layout === "bottom-wash") { + this.ctx.save(); + this.ctx.translate(0, this.height); + this.ctx.scale(1, -1); + if (this.isDark) this.drawDarkTopSupport(t); + else this.drawLightTopSupport(t); + this.ctx.restore(); + return; + } + + const draw = () => { + if (this.isDark) { + this.drawDarkRightEdgeSupport(t); + } else { + this.drawLightRightEdgeSupport(t); + } + }; + + if (this.layout === "left-wash") { + this.ctx.save(); + this.ctx.translate(this.width, 0); + this.ctx.scale(-1, 1); + draw(); + this.ctx.restore(); + } else { + draw(); + } + } + + drawDarkFalloff() { + const ctx = this.ctx; + if (this.layout === "top-wash" || this.layout === "bottom-wash") { + const startY = this.layout === "bottom-wash" ? this.height : 0; + const endY = this.layout === "bottom-wash" ? 0 : this.height; + const verticalFalloff = ctx.createLinearGradient(0, startY, 0, endY); + const tone = this.baseTone; + verticalFalloff.addColorStop(0, `rgba(${tone},0.26)`); + verticalFalloff.addColorStop(0.28, `rgba(${tone},0.16)`); + verticalFalloff.addColorStop(0.58, `rgba(${tone},0.06)`); + verticalFalloff.addColorStop(1, `rgba(${tone},0)`); + ctx.fillStyle = verticalFalloff; + ctx.fillRect(0, 0, this.width, this.height); + return; + } + const startX = this.layout === "left-wash" ? this.width : 0; + const endX = this.layout === "left-wash" ? 0 : this.width; + const falloff = ctx.createLinearGradient(startX, 0, endX, this.height); + const falloffTone = this.baseTone; + falloff.addColorStop(0, `rgba(${falloffTone},0.38)`); + falloff.addColorStop(0.42, `rgba(${falloffTone},0.2)`); + falloff.addColorStop(0.78, `rgba(${falloffTone},0.04)`); + falloff.addColorStop(1, `rgba(${falloffTone},0)`); + ctx.fillStyle = falloff; + ctx.fillRect(0, 0, this.width, this.height); + } + + ensureTextures() { + const nextKey = `${this.width}x${this.height}@${this.dpr}:${this.appearance}`; + if (nextKey === this.textureKey) return; + this.textureKey = nextKey; + + if (this.isDark) { + this.lightGrain = undefined; + this.darkGrain = this.getCachedTexture("dark-grain", () => this.createGrain("dark")); + this.darkDither = this.getCachedTexture("dark-dither", () => this.createDarkDither()); + } else { + this.lightGrain = this.getCachedTexture("light-grain", () => this.createGrain("light")); + this.darkGrain = undefined; + this.darkDither = undefined; + } + + this.blobCanvas.width = Math.max(1, Math.floor(this.width * this.dpr)); + this.blobCanvas.height = Math.max(1, Math.floor(this.height * this.dpr)); + this.blobCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + for (const target of [this.fieldCanvas, this.grainCanvas, this.liftCanvas]) { + target.width = this.blobCanvas.width; + target.height = this.blobCanvas.height; + } + this.maskCanvas.width = Math.max(1, Math.ceil(this.blobCanvas.width * MASK_SCALE)); + this.maskCanvas.height = Math.max(1, Math.ceil(this.blobCanvas.height * MASK_SCALE)); + this.fieldCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.grainMaskCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.staticFrameKey = ""; + } + + getCachedTexture(kind, factory) { + const key = `${kind}:${this.width}x${this.height}@${this.dpr}`; + const cachedTexture = textureCache.get(key); + if (cachedTexture) return cachedTexture; + return rememberTexture(key, factory()); + } + + // "page" film (the Home product): the full-surface grain Home was built + // on; its page recipe fades and tones it. + /* A product whose colour sits below full strength (Home's hero) mixes it + toward the base here, before the grain, so the grain keeps its one + value (GRAIN-6). */ + drawFieldOpacity() { + const opacity = this.product && this.product.fieldOpacity ? this.product.fieldOpacity[this.isDark ? "dark" : "light"] : 1; + if (!(opacity < 1)) return; + this.ctx.save(); + this.ctx.globalCompositeOperation = "source-over"; + this.ctx.globalAlpha = 1 - opacity; + this.ctx.fillStyle = this.baseFill; + this.ctx.fillRect(0, 0, this.width, this.height); + this.ctx.restore(); + } + + drawPlainTexture(texture, alpha) { + if (!texture) return; + this.ctx.save(); + this.ctx.globalAlpha = alpha; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage(texture, 0, 0, this.width, this.height); + this.ctx.restore(); + } + + /* ---- the Spotter radiance engine (product patterns spotterx, liveboard) ---- */ + + /* Locked: the values SpotterX and Liveboard ship (SBG_TUNING). Only the + state comes from the page; the admin lab may preview other values on + one surface (RadianceLanguageRenderer.admin). */ + get sbgTuning() { + const raw = (this.element.dataset.radianceState || "idle").trim().toLowerCase(); + let state = PRODUCT_STATES[raw]; + if (!state) { + warnOnce(this.element, "PRODUCT-4", `data-radiance-state="${raw}" is not a state. Use idle, reasoning or answered.`); + state = PRODUCT_STATES.idle; + } + const tuning = { ...SBG_TUNING }; + const admin = ADMIN_SPOTTER.get(this.element); + if (admin) { + Object.keys(SBG_TUNING).forEach((key) => { + if (Number.isFinite(admin[key])) tuning[key] = admin[key]; + }); + } + return { energy: state[0], settled: state[1], ...tuning }; + } + + /* The state levels for a theme: SBG_STATES, or the admin lab's preview. */ + sbgLevels(theme) { + const admin = ADMIN_SPOTTER.get(this.element); + const levels = admin && admin.levels && admin.levels[theme]; + return levels ? { ...SBG_STATES[theme], ...levels } : SBG_STATES[theme]; + } + + /* The grain strength for a theme: the language's one grain (FILM, + GRAIN-6), or the admin lab's preview of a new one. */ + sbgGrainStrength(theme) { + const admin = ADMIN_SPOTTER.get(this.element); + const grain = admin && admin.grain && admin.grain[theme]; + return Number.isFinite(grain) ? clamp(grain, 0, 1) : FILM[theme].grain; + } + + ensureSbg(tune) { + const key = `${this.width}x${this.height}`; + if (this.sbg && this.sbg.key === key) return this.sbg; + const prev = this.sbg; + const W = this.width; + const H = this.height; + const spacing = clamp(W / 6.4, 170, 300); + const radius = spacing * 1.6; + /* A blob wraps from one edge to the other only when fully out of + view: widest horizontal extent of the tilted, stretched, breathing + ellipse (~2.4 radii) + wander + blur. */ + const reachX = radius * 2.4 + spacing * 0.5 + 80; + const count = Math.max(6, Math.ceil((W + reachX * 2) / spacing)); + const band = count * spacing; + const rand = (lo, hi) => lo + Math.random() * (hi - lo); + const blobs = []; + let walk = { i: 1, d: 1, hold: false, cool: false, warm: 0, run: 1 }; + for (let i = 0; i < count; i += 1) { + const blob = { slot: i, lap: null }; + RadianceSurface.reseedBlob(blob, walk, spacing, band, W); + blobs.push(blob); + walk = RadianceSurface.walkHue(walk); + } + /* The reasoning stream: fewer, smaller lights flowing the other way. */ + const streamSpacing = spacing * 1.7; + const streamCount = Math.max(4, Math.ceil((W + reachX * 2) / streamSpacing)); + const streamBand = streamCount * streamSpacing; + const stream = []; + for (let i = 0; i < streamCount; i += 1) { + stream.push({ + x0: -(streamBand - W) / 2 + (i + 0.5 + rand(-0.25, 0.25)) * streamSpacing, + sig: rand(0.8, 1.2), + f: [rand(0.0006, 0.001), rand(0.0011, 0.0017), rand(0.0005, 0.0011), rand(0.0007, 0.0013)], + p: [rand(0, TAU), rand(0, TAU), rand(0, TAU), rand(0, TAU)], + bp: rand(0, TAU), + bMs: rand(1500, 2800) + }); + } + const lw = Math.ceil((W + SBG_PAD * 2) * SBG_SCALE); + const lh = Math.ceil((H + SBG_PAD) * SBG_SCALE); + const layer = () => { + const canvas = document.createElement("canvas"); + canvas.width = lw; + canvas.height = lh; + return { canvas, ctx: canvas.getContext("2d") }; + }; + this.sbg = { + key, W, H, spacing, radius, band, blobs, lw, lh, + margin: (band - W) / 2, + stream, streamSpacing, streamBand, + streamMargin: (streamBand - W) / 2, + ax: new Float32Array(count), + ah: new Array(count), + blob: layer(), + field: layer(), + grainKey: "", grain: null, + clock: prev ? prev.clock : rand(0, 600000), + chorus: prev ? prev.chorus : 0, + energy: prev ? prev.energy : tune.energy, + vel: prev ? prev.vel : 0, + target: prev ? prev.target : tune.energy, + settled: prev ? prev.settled : tune.settled, + settledVel: prev ? prev.settledVel : 0, + last: 0, + cost: prev ? prev.cost : 0, + drawnH: H, + drawnTheme: "" + }; + return this.sbg; + } + + /* Home's hero grain: the canonical passes at the hero's opacity. */ + sbgGrain(S) { + const strength = this.sbgGrainStrength(this.isDark ? "dark" : "light"); + const key = `${S.W}x${S.H}:${this.appearance}:${strength}`; + if (S.grainKey === key && S.grain) return S.grain; + const texture = document.createElement("canvas"); + texture.width = S.W; + texture.height = S.H; + const tctx = texture.getContext("2d"); + tctx.imageSmoothingEnabled = false; + if (this.isDark) { + tctx.globalAlpha = 0.42 * strength; + tctx.drawImage(this.darkDither, 0, 0, S.W, S.H); + tctx.globalAlpha = strength; + tctx.drawImage(this.darkGrain, 0, 0, S.W, S.H); + } else { + tctx.globalAlpha = strength; + tctx.drawImage(this.lightGrain, 0, 0, S.W, S.H); + } + S.grain = texture; + S.grainKey = key; + return texture; + } + + /* One step of the colour walk (see SBG_PATH). State: i (index on the + path), d (direction), hold (repeat once), cool (peach seen - no second + peach before violet), warm / run (length of the current warm / cool + stretch). */ + static walkHue(state) { + const isWarm = (index) => index >= 3; + let next; + if (SBG_PATH[state.i] === "pink" && state.warm >= 3) { + next = { i: 2, d: -1, hold: false, cool: true }; + } else if (!isWarm(state.i) && state.run >= 5) { + next = { i: state.i + 1, d: 1, hold: false, cool: false }; + } else { + next = RadianceSurface.walkStep(state); + if (!isWarm(state.i) && isWarm(next.i) && state.run < 2) { + next = { i: 1, d: -1, hold: false, cool: next.cool }; + } + } + next.warm = isWarm(next.i) ? (isWarm(state.i) ? state.warm + 1 : 1) : 0; + next.run = isWarm(next.i) ? 0 : (isWarm(state.i) ? 1 : state.run + 1); + return next; + } + + static walkStep(state) { + const { i, hold } = state; + let { d, cool } = state; + if (hold) return { i: i + d, d, hold: false, cool }; + const roll = Math.random(); + const hue = SBG_PATH[i]; + if (hue === "peach") return { i: i - 1, d: -1, hold: false, cool: true }; + if (hue === "mint") return { i: i + 1, d: 1, hold: false, cool }; + if (hue === "blue") { + if (d === -1) { + if (roll < 0.3) return { i: i - 1, d: -1, hold: false, cool }; + if (roll < 0.72) return { i, d: 1, hold: true, cool }; + return { i: i + 1, d: 1, hold: false, cool }; + } + if (roll < 0.1) return { i: i - 1, d: -1, hold: false, cool }; + if (roll < 0.18) return { i, d, hold: false, cool }; + return { i: i + 1, d, hold: false, cool }; + } + if (hue === "violet") { + cool = false; + if (roll < 0.14) d = -d; + else if (roll < 0.22) return { i, d, hold: false, cool }; + return { i: i + d, d, hold: false, cool }; + } + if (d === 1 && !cool) { + if (roll < 0.4) return { i: i + 1, d: 1, hold: false, cool }; + if (roll < 0.5) return { i, d, hold: false, cool }; + return { i: i - 1, d: -1, hold: false, cool }; + } + if (roll < 0.1) return { i, d: -1, hold: false, cool }; + return { i: i - 1, d: -1, hold: false, cool }; + } + + /* A blob's identity: hue, slot jitter, size, and per-blob frequencies + (rad per clock-ms) - every motion is 2-3 sines at incommensurate + ratios, so no blob ever retraces a path. */ + static reseedBlob(blob, walk, spacing, band, W) { + const rand = (lo, hi) => lo + Math.random() * (hi - lo); + const wx = rand(0.00045, 0.0008); + const wy = rand(0.0004, 0.0009); + const wr = rand(0.0006, 0.0011); + const ws = rand(0.0005, 0.0012); + const wt = rand(0.0005, 0.0012); + const wq = rand(0.00025, 0.0005); + blob.walk = walk; + blob.hue = SBG_PATH[walk.i]; + blob.x0 = -(band - W) / 2 + (blob.slot + 0.5 + rand(-0.18, 0.18)) * spacing; + blob.sig = rand(0.92, 1.08); + blob.amp = rand(0.9, 1.1); + blob.f = [ + wx, wx * rand(1.52, 1.72), wx * rand(2.3, 2.6), + wy, wy * rand(1.6, 1.9), + wr, wr * rand(1.4, 1.7), + ws, ws * rand(1.5, 1.8), + wt, wt * rand(1.5, 1.8), + wq, wq * rand(1.6, 1.9) + ]; + blob.p = Array.from({ length: 13 }, () => rand(0, TAU)); + const B = SBG_MOTION.breathMs; + blob.bRest = rand(B.rest[0], B.rest[1]); + blob.bReason = rand(B.reason[0], B.reason[1]); + blob.bCalm = rand(B.calm[0], B.calm[1]); + blob.bp = rand(0, TAU); + } + + /* Exact solution of a critically damped spring step: stable at any dt. */ + static spring(value, velocity, target, omega, dt) { + const e0 = value - target; + const k = velocity + omega * e0; + const decay = Math.exp(-omega * dt); + return [target + (e0 + k * dt) * decay, (velocity - omega * k * dt) * decay]; + } + + renderSbg(timestamp) { + const started = performance.now(); + const tune = this.sbgTuning; + const S = this.ensureSbg(tune); + const theme = this.isDark ? "dark" : "light"; + const still = this.isStill; + const M = SBG_MOTION; + + const dtMs = S.last ? clamp(timestamp - S.last, 0, 250) : 0; + S.last = timestamp; + const dt = dtMs / 1000; + if (tune.energy > 0.5 && S.target <= 0.5) S.chorus = 0; + S.target = tune.energy; + [S.energy, S.vel] = RadianceSurface.spring(S.energy, S.vel, tune.energy, + (tune.energy > S.energy ? M.rise : M.fall) / tune.pace, dt); + [S.settled, S.settledVel] = RadianceSurface.spring(S.settled, S.settledVel, tune.settled, M.settle / tune.pace, dt); + const E = clamp(S.energy, 0, 1); + const calm = clamp(S.settled, 0, 1); + + const m = tune.flow; + const rateRest = mix(M.speedDefault, M.speedSettled, calm) * m; + const rate = Math.min(M.speedCap, mix(rateRest, M.speedReason * m, E)); + const tempo = Math.sqrt(Math.max(m, 0.05) / 2.5); + if (!still) { + S.clock += dtMs * rate; + S.chorus += (TAU * dtMs / M.chorusMs) * tempo * (1 + 0.06 * Math.sin(S.clock * 0.00013)); + } + const t = S.clock; + const breath = still ? 0 : tune.breath; + const sizeSwing = mix(mix(M.breathSize.rest, M.breathSize.calm, calm), M.breathSize.reason, E) * breath; + const glowSwing = mix(mix(M.breathGlow.rest, M.breathGlow.calm, calm), M.breathGlow.reason, E) * breath; + const chorus = wave(S.chorus); + const chorusSize = 1 + M.chorusSize * E * breath * (chorus - 0.3); + const chorusGlow = 1 + M.chorusGlow * E * breath * (chorus - 0.5); + const state = this.sbgLevels(theme); + const restLevel = mix(state.rest, Math.min(1, state.calm * tune.calmGain) * state.rest, calm); + const level = mix(restLevel, state.reason, E); + const alphaNow = level * tune.gain; + const boost = level / mix(restLevel, state.floor, E); + const palette = SBG_PALETTE[theme]; + const W = S.W; + const H = S.H; + const baseLift = SBG_BASE_LIFT + tune.lift; + const lift = baseLift - S.radius * SBG_CALM_RISE * calm; + const wander = S.spacing * (1 + M.wanderReason * E); + + /* Blobs, at quarter resolution, in CSS-pixel coordinates. The drift's + own speed swells and eases on two slow sines (never reversing). */ + const bctx = S.blob.ctx; + bctx.setTransform(1, 0, 0, 1, 0, 0); + bctx.globalCompositeOperation = "source-over"; + bctx.clearRect(0, 0, S.lw, S.lh); + bctx.setTransform(SBG_SCALE, 0, 0, SBG_SCALE, SBG_PAD * SBG_SCALE, SBG_PAD * SBG_SCALE); + const flowX = t * M.flow + S.spacing * (0.5 * Math.sin(t * 0.000061) + 0.3 * Math.sin(t * 0.000097 + 1.3)); + /* A blob that has just wrapped (it is off-screen, entering on the + left) is reborn: the next step of the colour walk from its + right-hand neighbour, fresh size, timing and shape. */ + for (const blob of S.blobs) { + const lap = Math.floor((blob.x0 + flowX + S.margin) / S.band); + if (blob.lap !== null && lap !== blob.lap) { + const right = S.blobs[(blob.slot + 1) % S.blobs.length]; + RadianceSurface.reseedBlob(blob, RadianceSurface.walkHue(right.walk), S.spacing, S.band, W); + blob.lap = Math.floor((blob.x0 + flowX + S.margin) / S.band); + } else { + blob.lap = lap; + } + } + const draw = (x, y, r, sx, sy, tilt, rgb, opacity) => { + bctx.save(); + bctx.translate(x, y); + bctx.rotate(tilt); + bctx.scale(sx, sy); + const gradient = bctx.createRadialGradient(0, 0, 0, 0, 0, r); + gradient.addColorStop(0, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${opacity})`); + gradient.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`); + bctx.fillStyle = gradient; + bctx.beginPath(); + bctx.arc(0, 0, r, 0, TAU); + bctx.fill(); + bctx.restore(); + }; + let placed = 0; + for (const blob of S.blobs) { + const f = blob.f; + const q = blob.p; + let x = (blob.x0 + flowX + S.margin) % S.band; + if (x < 0) x += S.band; + x -= S.margin; + x += wander * (0.24 * Math.sin(t * f[0] + q[0]) + 0.13 * Math.sin(t * f[1] + q[1]) + 0.07 * Math.sin(t * f[2] + q[2])); + S.ax[placed] = x; + S.ah[placed] = blob.hue; + placed += 1; + if (!still) blob.bp += (TAU * dtMs / mix(mix(blob.bRest, blob.bCalm, calm), blob.bReason, E)) * tempo; + const own = wave(blob.bp); + const r = S.radius * SBG_SIZE[blob.hue] * blob.sig + * (1 + 0.1 * Math.sin(t * f[5] + q[5]) + 0.05 * Math.sin(t * f[6] + q[6])) + * (1 + sizeSwing * (own - 0.4)) * chorusSize; + const sx = 1 + 0.16 * Math.sin(t * f[7] + q[7]) + 0.06 * Math.sin(t * f[8] + q[8]); + const sy = SBG_TALL * (1 + 0.18 * Math.cos(t * f[9] + q[9]) + 0.07 * Math.sin(t * f[10] + q[10])); + const tilt = 0.3 * Math.sin(t * f[11] + q[11]) + 0.16 * Math.sin(t * f[12] + q[12]); + const cos = Math.cos(tilt); + const sin = Math.sin(tilt); + const extent = r * Math.sqrt(sx * sx * cos * cos + sy * sy * sin * sin); + if (x + extent < -SBG_PAD || x - extent > W + SBG_PAD) continue; + const y = lift + S.radius * (-0.1 + 0.05 * Math.cos(t * f[3] + q[3]) + 0.03 * Math.sin(t * f[4] + q[4])); + const opacity = Math.min(1, alphaNow * SBG_ROLE[blob.hue] * blob.amp * (1 + glowSwing * (own - 0.5)) * chorusGlow); + draw(x, y, r, sx, sy, tilt, palette[blob.hue], opacity); + } + + /* The reasoning stream: lights flowing the other way along the edge, + each taking the colour of the light beneath it (never a new hue, + so crossings brighten instead of muddying). */ + if (E > 0.01) { + const flowS = t * M.flow * M.streamFlow + S.streamSpacing * 0.4 * Math.sin(t * 0.00011 + 0.7); + const reach = S.spacing * 0.9; + for (const light of S.stream) { + const f = light.f; + const q = light.p; + let x = (light.x0 + flowS + S.streamMargin) % S.streamBand; + if (x < 0) x += S.streamBand; + x -= S.streamMargin; + x += S.streamSpacing * (0.2 * Math.sin(t * f[0] + q[0]) + 0.1 * Math.sin(t * f[1] + q[1])); + if (!still) light.bp += (TAU * dtMs / light.bMs) * tempo; + const lb = wave(light.bp); + const r = S.spacing * 0.8 * light.sig * (1 + 0.2 * lb); + const sx = 1.1 + 0.15 * Math.sin(t * f[2] + q[2]); + const sy = 1.4 + 0.2 * Math.cos(t * f[3] + q[3]); + if (x + r * sx < -SBG_PAD || x - r * sx > W + SBG_PAD) continue; + let cr = 0, cg = 0, cb = 0, cw = 0; + for (let k = 0; k < placed; k += 1) { + const u = (x - S.ax[k]) / reach; + if (u > 3 || u < -3) continue; + const w = Math.exp(-u * u); + const rgb = palette[S.ah[k]]; + cr += w * rgb[0]; + cg += w * rgb[1]; + cb += w * rgb[2]; + cw += w; + } + if (cw < 1e-4) continue; + const y = lift + r * (-0.2 + 0.1 * Math.cos(t * f[2] + q[3])); + const opacity = Math.min(1, alphaNow * M.streamGlow * E * (0.55 + 0.45 * lb)); + draw(x, y, r, sx, sy, 0, [Math.round(cr / cw), Math.round(cg / cw), Math.round(cb / cw)], opacity); + } + } + + /* Blur (BLEND-2); while reasoning, fold the extra brightness back to + the resting level below the top band. */ + const fctx = S.field.ctx; + fctx.setTransform(1, 0, 0, 1, 0, 0); + fctx.globalCompositeOperation = "source-over"; + fctx.clearRect(0, 0, S.lw, S.lh); + fctx.filter = `blur(${SBG_BLUR[theme] * SBG_SCALE}px)`; + fctx.drawImage(S.blob.canvas, 0, 0); + fctx.filter = "none"; + if (boost > 1.001) { + fctx.setTransform(SBG_SCALE, 0, 0, SBG_SCALE, SBG_PAD * SBG_SCALE, SBG_PAD * SBG_SCALE); + fctx.globalCompositeOperation = "destination-in"; + const band = fctx.createLinearGradient(0, 0, 0, SBG_ENVELOPE.band * 5); + for (let i = 0; i <= 10; i += 1) { + const y = (i / 10) * SBG_ENVELOPE.band * 5; + band.addColorStop(i / 10, `rgba(0,0,0,${(1 + (boost - 1) * Math.exp(-y / SBG_ENVELOPE.band)) / boost})`); + } + fctx.fillStyle = band; + fctx.fillRect(-SBG_PAD, -SBG_PAD, W + SBG_PAD * 2, H + SBG_PAD); + fctx.globalCompositeOperation = "source-over"; + } + + /* Compose: base -> field (BLEND-1) -> grain. Only the rows the page's + fade reaches change; below them the canvas is bare base, left as + drawn (a theme change repaints it once). */ + const y0 = Math.max(0, baseLift); + const litH = Math.min(H, Math.ceil(y0 + SBG_ENVELOPE.wash * tune.wash * 4.5 + 8)); + const paintKey = `${this.appearance}|${this.baseKey}`; + const drawH = S.drawnTheme === paintKey ? Math.max(litH, S.drawnH) : H; + S.drawnTheme = paintKey; + const inv = 1 / SBG_SCALE; + const ctx = this.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, W, drawH); + ctx.clip(); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = this.baseFill; + ctx.fillRect(0, 0, W, drawH); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "low"; + ctx.globalCompositeOperation = this.isDark ? "screen" : "source-over"; + ctx.drawImage(S.field.canvas, 0, 0, S.lw, S.lh, -SBG_PAD, -SBG_PAD, S.lw * inv, S.lh * inv); + ctx.globalCompositeOperation = "source-over"; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(this.sbgGrain(S), 0, 0, W, drawH, 0, 0, W, drawH); + ctx.restore(); + S.drawnH = litH; + + S.cost = S.cost ? S.cost * 0.92 + (performance.now() - started) * 0.08 : performance.now() - started; + } + + markReady() { + if (this.ready) return; + this.ready = true; + this.element.dataset.radianceReady = "true"; + this.element.dispatchEvent(new CustomEvent("radiance-local-ready", { bubbles: true })); + } + + getFrameKey() { + return [ + this.textureKey, + this.lastConfigKey, + this.baseKey, + overlayGeneration, + this.width, + this.height, + this.dpr, + prefersReducedMotion ? "reduced" : "full" + ].join(":"); + } + + render(timestamp) { + if (!this.isRenderable()) return; + this.applyPatternShorthand(); + this.ensureSize(); + if (this.isSpotter) { + this.ensureTextures(); + this.resolveBase(); + this.renderSbg(timestamp || performance.now()); + this.markReady(); + return; + } + this.ensureBlobs(); + this.ensureTextures(); + this.resolveBase(); + + const frameKey = this.getFrameKey(); + if (this.isStill && this.staticFrameKey === frameKey) return; + + const time = this.isStill ? 0 : getTimelineTime(); + const t = time * this.speed; + // F2: breathing runs on the shared timeline at sqrt(speed). + this.breathT = time * Math.sqrt(Math.max(this.speed, 0.05)); + const ctx = this.ctx; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = this.baseFill; + ctx.fillRect(0, 0, this.width, this.height); + const theme = this.isDark ? "dark" : "light"; + const film = FILM[theme]; + const pageFilm = this.filmMode === "page"; + + if (this.isDark) { + this.blobCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.blobCtx.clearRect(0, 0, this.width, this.height); + for (const blob of this.blobs) { + blob.update(t, this.width, this.height, this.mouse, this.breathT); + blob.draw(this.blobCtx); + } + + ctx.save(); + ctx.filter = `blur(${this.layout === "neutral" ? 40 : 64}px)`; + ctx.globalCompositeOperation = "screen"; + ctx.drawImage(this.blobCanvas, 0, 0, this.width, this.height); + ctx.restore(); + + this.drawSupport(t); + + if (this.darkGlow === "subtle") { + this.drawDarkFalloff(); + } + + if (pageFilm) { + this.drawFieldOpacity(); + this.drawPlainTexture(this.darkDither, film.dither); + this.drawPlainTexture(this.darkGrain, film.grain); + } else { + this.renderDarkColourField(t); + this.buildColourMask(GRAIN_MASK_SATURATION); + this.drawMaskedTexture(this.darkDither, film.dither); + this.drawMaskedTexture(this.darkGrain, film.grain); + this.drawMaskedOverlay(theme); + this.applyToneLift(theme); + } + } else { + this.renderColourField(t); + ctx.drawImage(this.fieldCanvas, 0, 0, this.width, this.height); + if (pageFilm) { + this.drawFieldOpacity(); + this.drawPlainTexture(this.lightGrain, film.grain); + } else { + this.buildColourMask(GRAIN_MASK_SATURATION); + this.drawMaskedTexture(this.lightGrain, film.grain); + this.drawMaskedOverlay(theme); + this.applyToneLift(theme); + } + } + + this.markReady(); + if (this.isStill) this.staticFrameKey = frameKey; + } + } + + // F1: the fine noise layer, rasterised once per theme and pixel density. + // The SVG loads asynchronously; surfaces repaint once it is ready. + const overlaySources = {}; + const overlayPatterns = new Map(); + let overlayGeneration = 0; + + function getOverlaySource(theme) { + if (overlaySources[theme]) return overlaySources[theme]; + const image = new Image(); + const entry = { image, ready: false }; + image.onload = () => { + entry.ready = true; + overlayGeneration += 1; + surfaces.forEach((surface) => { + surface.staticFrameKey = ""; + }); + }; + image.src = `data:image/svg+xml,${encodeURIComponent(FILM_OVERLAY_SVG[theme])}`; + overlaySources[theme] = entry; + return entry; + } + + function getOverlayPattern(theme, dpr, ctx) { + const key = `${theme}@${dpr}`; + if (overlayPatterns.has(key)) return overlayPatterns.get(key); + const source = getOverlaySource(theme); + if (!source.ready) return null; + const tile = document.createElement("canvas"); + const size = Math.max(1, Math.round(FILM_OVERLAY_TILE * dpr)); + tile.width = size; + tile.height = size; + tile.getContext("2d").drawImage(source.image, 0, 0, size, size); + const pattern = ctx.createPattern(tile, "repeat"); + if (pattern && pattern.setTransform && typeof DOMMatrix === "function") { + pattern.setTransform(new DOMMatrix().scaleSelf(1 / dpr, 1 / dpr)); + } + overlayPatterns.set(key, pattern); + return pattern; + } + + function scan(root = document) { + root.querySelectorAll?.(".radiance-local").forEach((element) => { + if (surfaceByElement.has(element)) return; + const surface = new RadianceSurface(element); + surfaceByElement.set(element, surface); + surfaces.add(surface); + }); + + surfaces.forEach((surface) => { + if (surface.element.isConnected) return; + surface.destroy(); + surfaces.delete(surface); + }); + + startLoop(); + } + + function frame(timestamp) { + if (!isDocumentVisible) { + raf = 0; + return; + } + + surfaces.forEach((surface) => surface.render(timestamp)); + raf = window.requestAnimationFrame(frame); + } + + function startLoop() { + if (raf || !isDocumentVisible) return; + raf = window.requestAnimationFrame(frame); + } + + function stopLoop() { + if (!raf) return; + window.cancelAnimationFrame(raf); + raf = 0; + } + + function handleVisibilityChange() { + isDocumentVisible = document.visibilityState !== "hidden"; + if (isDocumentVisible) { + startLoop(); + } else { + stopLoop(); + } + } + + function observeDomChanges() { + if (mutationObserver || !document.body || !("MutationObserver" in window)) return; + mutationObserver = new MutationObserver((mutations) => { + const needsScan = mutations.some((mutation) => ( + [...mutation.addedNodes].some((node) => node.nodeType === Node.ELEMENT_NODE) + || [...mutation.removedNodes].some((node) => node.nodeType === Node.ELEMENT_NODE) + )); + if (needsScan) scan(); + }); + mutationObserver.observe(document.body, { childList: true, subtree: true }); + } + + function renderGrainPreview(canvas, mode = "light") { + if (!(canvas instanceof HTMLCanvasElement)) return; + const safeMode = mode === "dark" ? "dark" : "light"; + const rect = canvas.getBoundingClientRect(); + const maxWidth = Number(canvas.dataset.grainPreviewMaxWidth || "0"); + const maxHeight = Number(canvas.dataset.grainPreviewMaxHeight || "0"); + const measuredWidth = Math.max(1, Math.round(rect.width || canvas.clientWidth || canvas.width || 280)); + const measuredHeight = Math.max(1, Math.round(rect.height || canvas.clientHeight || canvas.height || 120)); + const width = maxWidth > 0 ? Math.min(measuredWidth, maxWidth) : measuredWidth; + const height = maxHeight > 0 ? Math.min(measuredHeight, maxHeight) : measuredHeight; + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + canvas.width = Math.max(1, Math.floor(width * dpr)); + canvas.height = Math.max(1, Math.floor(height * dpr)); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = safeMode === "dark" ? DARK_SURFACE : "#ffffff"; + ctx.fillRect(0, 0, width, height); + + if (safeMode === "dark") { + const dither = RadianceSurface.prototype.createDarkDither.call({ width, height, dpr: 1 }); + ctx.save(); + ctx.globalAlpha = 0.42; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(dither, 0, 0, width, height); + ctx.restore(); + } + + const grain = RadianceSurface.prototype.createGrain.call({ width, height }, safeMode); + ctx.save(); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(grain, 0, 0, width, height); + ctx.restore(); + } + + function handleReducedMotionChange(event) { + prefersReducedMotion = event.matches; + surfaces.forEach((surface) => { + surface.staticFrameKey = ""; + }); + startLoop(); + } + + /* Read-only snapshot of every surface (tests and tooling). */ + function inspect() { + return [...surfaces].map((surface) => ({ + pattern: surface.productName || surface.element.dataset.radiancePattern || surface.layout, + product: surface.productName || null, + engine: surface.product ? surface.product.engine : "template", + appearance: surface.appearance, + base: surface.baseFill, + width: surface.width, + height: surface.height, + energy: surface.sbg ? surface.sbg.energy : null, + settled: surface.sbg ? surface.sbg.settled : null, + clock: surface.sbg ? surface.sbg.clock : null, + costMs: surface.sbg ? surface.sbg.cost : null + })); + } + + window.RadianceLanguageRenderer = { + version: RADIANCE_VERSION, + refresh() { + scan(); + startLoop(); + }, + renderGrainPreview, + grainSize: RADIANCE_FILM_GRAIN_SIZE, + patterns: RADIANCE_PATTERNS, + products: PRODUCT_PATTERNS, + resolvePattern: resolveRadiancePattern, + inspect, + /* For the Radiance admin's Spotter lab (spotter-lab/) and nothing else: + preview a change to the Spotter engine on one surface before it is + made in this file. Product patterns stay fixed (PRODUCT-2), nothing + persists, and the linter fails any use outside spotter-lab/ (PRODUCT-5). */ + admin: Object.freeze({ + spotterDefaults() { + return JSON.parse(JSON.stringify({ ...SBG_TUNING, levels: SBG_STATES, grain: { light: FILM.light.grain, dark: FILM.dark.grain } })); + }, + tuneSpotter(element, values) { + if (element) ADMIN_SPOTTER.set(element, JSON.parse(JSON.stringify(values || {}))); + }, + resetSpotter(element) { + if (element) ADMIN_SPOTTER.delete(element); + } + }), + get prefersReducedMotion() { + return prefersReducedMotion; + } + }; + + scan(); + observeDomChanges(); + document.addEventListener("visibilitychange", handleVisibilityChange); + reducedMotionQuery?.addEventListener?.("change", handleReducedMotionChange); + window.addEventListener("beforeunload", () => { + stopLoop(); + mutationObserver?.disconnect(); + surfaces.forEach((surface) => surface.destroy()); + surfaces.clear(); + textureCache.clear(); + }); +})(); From 6dd0a3a835ca013fe69c57cc95197d0a41225a9b Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 14 Oct 2026 07:03:16 +0530 Subject: [PATCH 05/11] 404 page updates --- src/assets/fonts/BB-Roller-Mono-Regular.otf | Bin 0 -> 133304 bytes src/assets/svg/not-found-illustration.svg | 110 +++++++++++++++ src/components/Page404/index.scss | 145 ++++++++++++++++++-- src/components/Page404/index.tsx | 71 ++++++++-- src/intl/en.json | 11 +- 5 files changed, 313 insertions(+), 24 deletions(-) create mode 100755 src/assets/fonts/BB-Roller-Mono-Regular.otf create mode 100644 src/assets/svg/not-found-illustration.svg diff --git a/src/assets/fonts/BB-Roller-Mono-Regular.otf b/src/assets/fonts/BB-Roller-Mono-Regular.otf new file mode 100755 index 0000000000000000000000000000000000000000..f9d13b23413804f84ce38326148f098ab1878072 GIT binary patch literal 133304 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�M9?Eqi7M`1a+pT;Y0(~#U59*c15>;}`c zp4?pbz0E88-6jilWnH|HSR0lsngGEDZj_$y5meFyh&F8iaTo|h1~~Ym_;|nD(D$8^ zE{i3WyNGY&+;U1s%!Fv*xmAHp0Yi~} z-$b1`aPah*dIu^yalLZAdX=45Jf~GyRtQSKQdy!XU<-=v#v|+~C1QtvNi!ARP86+2 z57I*pcQ9_k$>785w&3$Zdfl5SRsx#7)b~(vdbg7$B5!uPPJ?>(aW3i8DW;tr!Fo@l z;KW@QuNB)o6UU7mKW@UI0hL z5Q$irYnSzC&t_`tmMvZDw6drh9KDMAt`%uH8hTZj-KmB)aYlx`uc32fF_4GrI0(&^6CNblnwn&7GiY_Ejoj zv#&te##e*1@pTF~K`L=4gShFNN!`hyZV)c3ga+{dkTqzU2pR%7bcP=YnrB?1@o=JC z9!``y)l1v6Kc?*_M8yL^#d&C@+5|4r8pa6xc0X-AJCIT~v%{;~GM{*|)v+5%)dFp*7 zWh#|wxfSnAHe0?=Wjz)CPG<3%=N-NB6n|&3E_Q<-MPP~M86)af<&92>`grZTQ^x%B zc>Yd3(K| z_Y41C?k=83e_iad!lg$FeLVH(6nf4j^qhA%<>}zhsA#Wtg0&=Hwiw_OVBw&kSy05FVwCV26{eL)r-9CPv^<+6WpqQ%CUVDR$f#!3Sw0 z*r6K8C3OVN`*{9A+6Wr=KaAi28o|=W2oCU$VCnxhf;gJ{uI7vVRIsW^=I3&p{ThIR?p+!#yWt6=JF!=Rrz8&et0rLcp8Jfw)P?E9)Uv0XgR~ zU+it$1GMcja#EU&!?%BJT&lw02Jm;b-tm39pKVFP+t?bF?eOFPCK>Z64PL6na^@`d zBG>I_&u_!24CawpZg{KsUURcR`rnWJ!Jn$tq#sXuykO~?oEg7C#BN|>Q{D( zqjIMn-8**dv23-2Mb{iG&l}WtW*>W%`t_?ggvCgn5RlA0D>LYnCouvu0_Jjt=ta)*0lvfqKw!92)Q!3YM*W{Zn;pj>FRoYMWNdyb+oR6oE1@mjl@V3N`k<#a3vy_;=>9|BPN4RXk#o z6qYMxsh<2)bf;i+xHaj?J*b)O;YCjBo;yJ4-mhMcta$iGdt1qRfz0_mr}UoL54ccW zJNIwV)RyN&jW^vLH~U#G4p`T&wym4iR%+OC-LB!ziv28Q;?LK)2^H4UYgeBw8MAN% zP^vDhT(w}^F58nz$8#=r{E%QNx1jy{!#4Q!ZlrS)oL4rB?&_i$iF4x|OM9;yxP92o zexWQ=*NN5o*?N!cGh~3H$D;Oe&1V-+2o;%DOgx!r3v?%4OJcS5Ur+MAm~?#~YnkNN zPh<00PIcXg&P#r?M@r4xw`=YcA#(F|?Y2AOr46f=texp>6k{1Oe%SCKxIbv<+!!Yd zkz?l0iNXDOb4CoAuj&NqW)+s`qvYHR<&#X~$QV!%{;gTNcfWUAn>+ zFK=(RuDK(hlmvkHcJ(; zDT&pQmgcf-la@TQh&oAZ6LtdwE$vGkel33NOTI=ecCON&x6huv{b!|f#lEjt@q33J zyko(l}V^02la-nmCxd$V>Mb~sr>^1*TJ+BNZV(_LXr zca@pgw5fmZ-ov_dwuy!^+~T3No;!8!tXYou_*E0*BjY0{c8-h$9xD!2<)Ns30atH8 z1`v)Qw*u(tAJQP=axg1u>RpTKqA1URdoDs25I}Mowv`Rp$_8)soyf95OOy?;Cj5$u zKy@g|@w+%M!oyhcm-mvcCOPZFpSDtmGWM!97H?_pgk7eC91V;>ltroH4)|*?!%v~6YwOo=!0NY~uj(9EClA5qhqA_cn_i>Y?psPujlJ}vU9NYZ4bLShBKYZvU=id32wIe&F zuACN)*Y{f%Lh&JOu)wU$IHOY_Jh=qF;;^J~r1C?zRqonR5dkpDItP{ry)?E+! zL$5Or#zDSqqgv??%e1)3aq%`*bia-`=dJ`UT@w+-&GrpgwZX=kDf_mxZ#tVWW-mon z0N-N%O5KGmH#7%C-mjgz)~{h}gyY0sDQV}zb*r68wJeAmR~vyH?v*zttXUcFV08g` z*}H!iAYg+F+)ir1^4?qfr@NilP^Hb=}v?&MofjWp_KCyUgG5WHE4St!X4;zg1LZrQ69>-M*J0 z1~`(PC5<6G={p07rt|Q>)RSdpBs!A=bFdWsQl#%;aLtsA)1}h~0ot;}HC7viSzzZi zOoE^2Pa}@>BEC0H`b{sHn)o>fWj%QmG>b-K?ymA{? z*gu*__Pn&*lDN!YLJDJwr(McK`ubh z4U)vRL^O#%>4=iwr0~f&mfOz^P@z^*&6UI?lwSK zAaZ_QhBB(oMm|-9om5ZWSP}sQU%_^5?Zbiq>HSe{lq7Wx+o@O^uSp(_#ncgtsc{+2 z+F)lns&+mK*H2xpn30p1^Wijyjgk{Ec$q~FP$Ny%3GaSy_fLb?R&LAh?g^2iJ5#l{gxaFKfceLX=3I8UC{&d z%$fDfS@_@C->4g{8Jrn>e0?(e1p5^BDdkhcr>;*^pSeB@e3tmE@JaGH;-mXq_RZpJ z^$qte?E9l{Ip3PTvA#!qPx^`sc`_8pFeXEEhQth;GHlPVJH!4Ahcg_@@HoRi8It{S z`xW#n=GWD)mtTLsL4L#hV*C>P*7T#*7&=Wz3o}B%>o^fs8dXw#~RaXtT^&X!)5A(r8mXv=iV8p}q@Wy_zIN0zsicTz@4mF&{j zQXc6$sjO5{Y9O_eev`ULkL7Ig1UXJVFF%x@$#3Mh{)&H~f3UyiU&_C+e{=t#{uBJC z`fu{z>3_yw_kW+sFH@mREi$#w)IHN5ijVTO(pnh|n1{v6YGs4+JfLhqlYl7!^8?}o z76&{F5Shzou9CTW=24kfWImetM&{>P%4VsarFE7?S$1bRndSE^H?q9UA_Bt#a|C`J zm@lwWV70*BfujN^1WpT_9k?&>c;NZKi-Ddme7>-J;rOEE7rnn2^+oI#%fC4E#g#Aa zWtFljSwpgZnKf_LB3X-Pt(vuQ)*e~=W*we&eby~mcV^v})t&WZ)^k}eXT6d2udMg8 z1!c>T?b~emvK7kKC|i$gk=cf18<}lfwi($LXIqhNeYP#x?q+)*6c7{?R5qx5P}`vK zLDPa_f)az41+5O+5VR%eV9>Fk^FcR)?gu>$dSkU%Gg~!lK5IeiFV?!&#?~n7XzMKN z66`~drWS^LQTK1Uii?grEzA5{*>`B=VWj~(%ZuW=S->Q-tpk`H7)vo4Hzg3H>HPtq1 z2erF8NR3is)I@cqx?bI)o>2c%AE=(-jKP_L!-5^b-v$>6E*@MqxMJ`x!S#Zh1h)!q zAKWdtZ}7n2VZq~r7YDBhUK@Nk_E> z{W-K&Xrs_hp*=$Tg$@oK8#+03MrcfELg@0)wV|6rw}m(A%N+LZ62I z6Z$sHFD!GIBP>r?!LaYcN`;jRs~q-ASnaUpVQs^@gbfLs95yX1F>G1bwlH_tsj%x| zkHVgZy$O47lWbXRA+~Va*S37NLbmU1rEKMGRc*Cw4Q$P9t!(XVooxec<88BSi)|Zh zTWx!7M{E~tw`~t>Pi)*S*#qoZ?b+>N_8j&+_5$`|_A+*ty{f&Iy}rGPy@kDly{~5pzx95c7F<^bdonxd=!8gB2+p;lQ5Sk@yRf5xgfLdKCuLK}TlLm8GXsDyrBUF5V39X)bHq`jo;nzU#2(v85jht`t1Q$TQ$pK(h-gn}%L0ZU@z{$F?R z0$+Ls%}L!bt_8TMI(&})zqj_MOD*OQ4~ed z)TpAUs!`OSDn`{9RaI4E)Tlb)q`cqf*_R|8)0y}6|IO$3f5rOl?^%1Twbx#kXRp22 zv!1oK0JF>oQ$OnF%XU4E6?xO>%MF{xUdp;f{^y|B1VhqK^l%xUm`1E8nL1fq@+)Tg zAI;JGE!;X1I^t~pdYQW*dwu#6luAk?uMxpXzu?N4IDKlie=ohYt@SO3O4a1dm;IkE z)<1gg$%4L7S*~uWPe3mVrmmN}KHG&>&|_BwDwr;udN70Tx4$dSW4JqdjQuCP2K260K?%&! zRlN+=&})`<>18N}UhD70RUY=N-{3dg@gAe}J~*vCZ@PYa`y1nm@6AA{$Ef9*>oxAC zFZrJ+OB!7;xo!ludZ(czdwSgMy+*3Hw5@}pDxOjn4mx_bAlzmwC+*RH$9`DHO817l zAN3eBVf}lZd)1QVE9d*(dtU$Gx%?>;BTj^YhJ6lfXos(a1Km1}&9U=uyv zKh-@kCoAg(-?%(|Twd1Vm5~Of`e~o)j$ZWI{ymK1*W7{oalL(SxKg|ZRmii*-nd}{ zbA3y@>R-J1@t;mc8ixZdBJxd*zD4G{dal`Tr<&I_3S29eW@k>!o}B3)*`oj}azFf| z-?*E?;Qw27L3@*_ny@~KeTcsH8nEL(+G9NJ@fk1E9;618sI-76*7R_?sSz%N^e~_> z1T~E7rW!_(rG|m;hQYDWzsO+A4y6?@0xsGV~B7X1fSp1sn8(KXJV}5=E zL)l9h%3@~rigD~;haq)Qh4Kzk&&3j`iE*_0b8hf!T89a^GR>m_}Ea7vFreRqqxdJnhp z(rk|beM)MBc=AC%PB(s~OhScMqDNvtJ0W}OqNQ&xTD;W%XV=9OCocLoxZho~w!pU> z&3soN+-nqi*@V<1w0k&P3J%WrocnXka zM{Vqfh8g!jE&K|@3c#A^gJHp1KI4-YJ{Z(H(n$H*-Fx)l)aQK5JL)HwetPWd$gkYS zV!R>M38v|Rv=rkMH1hVyDc3~jf4Ry^W0?a9ahqg{c1kq*+7J6&qu2^ZSW6&bJNow zM)3rLKX`8HKF>{0L@jR`dJgSIH5)x)jeBG6yV+}er%t8rN=nj~rjN=`i%NH;jmphV z_a~i7O+A&QXQ$_mN{dW)r{#}Yn(j+V+Le0hl%Boj-Q0~)Yg`-O&0o64e=2F$uB21? z(lz<-Zj1~LgCgx*?;0}tvF3(19rN2?m)Oky+6BAcSMP3L8{aSLkxBbyF8UaFw+*Li zp<&R@9vzAv@)kV-%MJYoWse!-$I@5mi3Og5oTVK+#(CL7&BeeUy}`FF8#Na>#7R%q zTkpW^`sbhQIpSZOGQ~Y1Gjqa(Y0Fkj^cm1n=;?`Q@MYWYsquW@_R9th+q7wjZ|%Fc z6xH+;)$ro~mfgN^Qq~B>cn#R2yJ?U{#0)Z)1sjq*Bg(Gx#5%gERm(0Hg^mbHUIgC` zyyCss1G>JgVFI%`~UF5f(v9G5upX8hN?DILl&knTpRE74ya#t|&rW&ydAIge% z+KMRo*;&EI&-o$3M^xL%s2R_?2l>=S_ujtVE@)Pm-~ z{hs3cHL~4#5%X#9nNqO<_F6dWfqE&a_L^ov?`ke^``Y!p_eU=8R3g z_k)1nQ#a)wc+5pYM*ezRd&Azdu&BI;j9t~lqb;kWzIw*ejPa4d1#YN^_yUtsf|E?P zz`b_as<#*TCLPs#PZ{!fx5(h@?vzg-E!?tk+4_~fF)KGt+7eax>8FMM!0YbMhJM_8 zu`j$(Jr$@J5m@Uz>}rl7NitTpTATc4S2l?ek-95B-tOq%dF^f;AHOU0@@2bQ|KPC& z!;oM_JR8#EobDWs+gj!ht}Ryd6l}^H{DV8Nwn)(n-53r)Vm?Q=yNUl^?*ADgjsprwa59nh1P9i#0bi#%A|>>iJh zLBBg#ZObzq1Jwq%4C2TQZLZs}6=maktTck{A+*@^%PhQ11-C ziu}6`nw*-AF%dT4H82PK2yCcNs_-q@1hSkyU+yT;VEdChZj4@>MVLHEYaf^r!*lH1jvB#r^1&(+=cx%J9HU3Wv-rsX9 z^39aV?x9(uhdk};nl`Lghe&S!n_OQX+xyir*h4Av2Du}Mrz*^dqpjkNvPxm_^P(cR z0UK6Jio81Q;#Osuy-xc#&88m`DAclQXmT>wyeQ}Nf|{qT!DIcT}S1@&~0(y^hrpz z+jKK`ONnC>-;~I11)E$i}9U^_P zYt374ty!}e$_73IyRvvyB;Owm9N&$pmEIh51j90Xu-;@^Xn?j5$`-l?Y^8oCQW@q7 zZ{6juQtn$W)`7}n% zfeToJUVsnY0R@DfBq#B;#aw1*(0WWG;;9jn$4~UH%^LmY@Tl~spU;`(uQ>HhGX3ku zcAS;m1#8w6yt_7iq_4OVMtyF>GI8w+U{^q_;aQLZ{a~`Po$Rp;FJD5f8warF8QmOu zD69X9SyU_>uDJ1V<)We3z3mZbC*m{6BgIAfiOQHMYE-luMip1<;Zr=}ojjvm%{n}q zOgf2>$Q!16M?eocv15m(G{f!RhSmjx<4?F@5xTRUa@ZVcr=XO2rR@E|Q|P|X=|tk| zP^sxodngne<`*TXG8!&g&uTP*5n@QzjKBj(XJet^I_x!i_V3>_bx6UETvD<&Zg{gk zLv#4M2fGo@)s3Pq?$2KNX!oBY--b+iq$mmItTtd|yBK2^dM~+^TN71D;s9TaNE`&- z^R?d z{n#ByE$XMkEOIy|OS^gc8>GX5I@*;QsEEHx!AiS!1uJ1Yol1dmXZskmPWducDjK+0nDBRBJD@6@FK9nqG z)T=$Y6*GK)-sm;t-$tS{V(G7e?))A~ZNJBFz_4$E$N1*+&%Zg_>1b1bufQN9@D~yS zZDHalxEUr=zM&5B0-ngUO9Oo=#0rufczX&&(cmdC&A6HO1b-|INUw1eRwJ;{CmA?5 zXjLw%{?ACQs3966Ci4SnMO|Qgg-x%7V+*Q_g~lhZf3R~OwPh&OBY%ZO6^|Ra`OoD& z`)bJIrFkn?V0gwQzY4(`Dsygh+%{iM4%6s1(h9qz&x zIodN0I|E2W+!QE>FMRkejK#lw_8Z->n6uk1EOYJD9%9g>Q!Y>BVYBL?E9=dTUY8m@I{<^}s&@c@Z#A z(gmBBZZ?9?V(qkHtqa{-`D?5Q#iya>uJ9-}s2}`(GM>^Ez32r7E%`0-pnmh{iT&7q z13JX8SsIvGXcRT^#1_c(cd)1>+}(?{MC6 z?QxGk{QdP~o-&Fqm|qZ}9ynpNZ%RtwjO*Be9f!!M^*2~nObup|$Jpo{i+6^imuU~J z7MSTbibA|WUy6ar|91@RT^ENNHzo!Cj!Qm=N#5s>K@4Xqp!Ju>rHhIt8G~uNc~sI3 z=oq80^UzGM8rbIuLmgpOy%7lYQf5x_$e7MAdEm?RCD z>Tq6Fj6eX3dNAPo8>t2uyWZLKVZJZ>pxz; zt=!_XfwmRT-a2hO?+J9ljSd)|HqbXY`tKKksVFmAPLpH~ z!MPG*1EE;tguy-R5dAWTG1*9k1H^hf{@tL!hrDr6%E-x*LwwF zKP$xGu4g|3OOhTK1Cs@u92Pwl?kKFd%YfngU=v>K9BhK-mp*dTcr?VKN+|}-X$96s z3`AYr^3an#!|r`ifafy2)+lahem{H+L~TF zjGjIcHCSo2ZzrC%m^mwgrVXZ=U+HHII%%>_?rwDgCxb}t&P zR7eaw2zEso_?Ef|PX-9`qV|x$M&PLT^+E$RNjn4?hj;9sa@d#zTkGiHP{Bm@u|gTW zapgq(Zh+QWuENDPJQc5?7uTjI(sj)DxzMXIviSlg9t>>YQyJ|KE&0TLgRHVlhu=+! zuZK6)fX9f;@8p??%6iok-q;&1@K3^pG1)B?(HXznz&(UNDN^t!MF)`&TqITl-x2$P zpNWIO!{Q5Iq4WzWtIJxz2V_HFW7!ngTs8-`l>LDN>HG)X=vYT7lD^FoYj8R{vo73Sbt8a z`pf!4p`y-o{C-*}`a~VM(I@GXfiLSX!{-%*P^>!kh||h0p|$VWwU3B=YI4R{QD;QP z@G+v<*ddwYMSG!98@O|a!-tjnB}KVXzZ4NJ5=F>E{W`>mfG2os0(=PcmL$yiO<;#`26l#(Lw%j2k0cF?MF` z%Q%cNgE5zJ9^(?mb&T5>_c0zvG3s3A3TlT+^?&m(UATl>gosei=l{-MRNp>PTQm^O zM6&1z)vmr`5SOeJJ?Kvn8)&0%wyAI!UX{*n_UwwbOhOasCjHz)jHlXkFC)E z9=x3tgBlAH>%;?)J_s!#D`XfPirLJ;r>-xr`Z#MqwCp6gT*4c^lY_F_ZBn z1(ILPQ(}Snj8iz|6h8Y2<6OqBiU(o3GoQxz5r0%o@^Rd29OXeKC>sa` z`6MWi#xjQeA7K9)%xh3Axu1RNvrm2IO_?V$k26vB#kJ_4Y}K<>8hT}z*d*eF;!cKZ z57z~*CtP2+0dPa%M#7DU%Y@5;%eCB09BnrTe1Q<|NVpid+CsWp;D}OXhqmcn2e%P! z3*1iUNa?EXRftb%LsmQ2pzGQWDva)EcdWZk=-JS-?gnto_*W};A|2t^)?w`D?&$97 z?!{+_rJA8Au6q!07~E(+8%=%=o4_oK!%YPeo9-r7He4O|EcZO*Dv#p2=b5RyXIbGE zIqB7LFE`J+SDRrFVuQ`VH(4?3xVPE9l#1oM>n?1sbO7>X86I)@e%YVHnapkn zAwK?%>xR^{nVlZ$7colLG}pQ1hZyk`gZ@GqIjot(l5FNaXV($78{GMHHdq>^U(g^Uf!AVz;7*>b+5 zz7u0b$PzOzAuCGxtu=W_L5R(^hita|cH0c`W>H+^4{<~Gndiwbbc z(7hbL&%^Q6m=%(CPM~93JZBM_MbupQpE2`Ddo*ce!=z!3n4jajg(iv~pZ|Hc(FVn77oJIXo z4boZ4m0g#m{HRQGC@$4jXr{wBJ@(5bKmHqU*IP+TG_T9G#dThW%{1FC{N~uTYUdzy z0a9GZsS;btY!z@FvyGu!LU%fB4>3-KTN5$!x*;=g<^YGIRE{|8xWi5n!?W_YM+i0P zNh8I=Ci&whyNq{r0~=K36;iEE!pQ3V+;0gp{B$CHv8bM0=%+0 zT2S|a^C$vi8uD+$Dg4Mhj>Zy76@gKWHA?Bm{!Nud6ieLBl?6Z^z*P8u?=L;hH>Kq>U*Fs<)|fcIp)#s2fzzXj*BGRKWmn;=XK^QLNb@TTfO zV5-^$n8dM?I95C(zZuGSP9dISz0CeUaL8ITPLmHZKFa=e+26!^>|c-G!Q~h1A4fh) zvdL8%Ez`(X%481Nhf|2-(OP}>F-L0&943K%THL|=wJc(v3yeRqe?!I?Ml=0y&6JhX zls@G_e1GR*@GSBXv-w(&aQG*<#IAApTAZs|oacJXA7K9nIHZMqHqOEqkY)_?u1W#; z4|nj5M(*QSad+l`H|My`Ikf@o-U3nV4@L}Te68rd-Z19%+rdXc&U)a9}^Hv

WWQ*w;9HxxU&#S>P-Y^oDaBd7Di3g&MRZoSWSLY(DzGFlB_hjCY06YDhcu;y zm%cB`4(wyf+79f~j(m_C{3Ll=e02NY|k2 z+O9|`h{}qZ8a2IUV$DQmvm7?hVR;T)JK?a? z4m;@Ax`3!9bPTeQpeiw<*G1(VfGjIQc1AF=3~C=o7~=y-=Yeo%wb6!w_TaDot?AY`D`DY9UR?{N*ru#bXsW^O=mOcY;^WNhl`$O`$f-i*zD-} z(fOP*u_X>$;jp#L3d)4r?3~^1u-y*Z=deQ#JLa&H4m-o_e3_IlIcKjr?1sZQr%@B4 zi@l2N=M5p|4Tr1but`IyRt~+OM+01U8Wx^pZbRXXK(N3GR zbA?>;b-g`N=Ujf?z789JvqS0Lyu?N_8}ImKIxNRwB{6axznNeQymQQwEV-^-8dPd# z{=lpf0^@S=F0{{jm)eYL!77b1ekG|m%)VdmD!aD8W|=hzHqT+39A?$8ca>GESqQhw zIlIka2OW0QVJ94R+F|FIT`7};>(1F*4s)(+IqPVuA+fnUPPR&-rj9Urk-sz1#%?=lp<@4BW4a{n5W&Esu2|ue}`aE{O zgrC(f;b-+rpT}38a!)DyD%&jT9J4EBQoin-z2z{gog$o>mwy|>ZqLZUIy)7+$CtjE zFWNbKmmY85IZDowIk*Gs-oUJ!QmL7#fLUdAml#~mPPme|)?K1)2>Yy^`@5%XmpIN| z|&@ z*UMq44jbgKVGbM34E?uMDifTuSq__OvyyOkV?M408Z-E~7MNM>w2V~wO}TbgX;vb0 z{;!i}cfa$0EyH|ycB^B?CCQBYxo?qkb~(yR5wFA(jhMLmWK5NFc+eWG9mMQD-oc3{^DF^>7CrhNj-O?-Z^KJ9( zvhH%5Z?D4+I_#*!O8ibZewMUAD(4)(iw;B2#g(#f_PWDL{A~HR&2LEt-!{L?4(AVZ zm?gPzwkrEwDV=A(&k2W~L09v~`x{xTnavP4$qrZQew{n_x3kasI}`JU`@7q5YbKUr zeay4)>qpE#*kNg4{A~0S%iy!bvYAaQb9RPvb~dy5IJ?B34_pCP;9m>e?D&h1f}lopIQCW|zu@yXu_1;jm(x#V8I7aag#+Dmg5YSWHamlw)c;XX`mE z!DePoV_L){$F#ToV!9B6Ur*Do6zhw-w5OPLJ3|hi!4#PKWJr*a3$faoBN(ndzM> zlk!>T>;;Eic9X}D9X7~eB{7DT@f+==Vx~6%XHot@VpEBcjIBPM7)i(Kvm7>$ z!{wFnTf~0L9lyQ~qqwX2?1nOaCFzy0O%#qs1!he%>q9v$>sNi7?N@!5!}dDtpu>(j z?1aNkJM5gpE;{TAGmI2Ua!~!cbM}_aO3wPt{QcV)vBHiU>vC9_&7z!KnT(z!u@!!+ zG)kpcl~RfI!J)>0@pvBXV%7-1nmH`VVeOc8w$D<}i|tN+%y4asagV1_eW|m9=`6kb zS$RD;m$Z`&pYgr!>%&B zff&WasFj${4`xWo^5eMV7Zb^TC|Nt4zho>BZ{LwW-mXD^d=14+1uVp2;SQ^0Gy6RX zajiNBv&seRpdGIIQHPyy*lCBIbC^|{+s)Q>%80OOLZkpRZEPs}VKW_-WQ_GxHpS=TVIo zY=FZ!y&6MHr(9#Cb9TJLG98xVFefiHa>=j8Oy}O_ zIBWs@m)2MaB(}<7rr$c}>_&%`#Mp8dKj&=Or-mh|@pSo*pC2X9l>f_~AOGskfBn>` zu@mLDhiit|0f!x7cD&5lQ|x!v@w;HNsH_^7ZFvROhr*Thv-^3C%XY5?vwAI<)dy=_ zwk0*#5<8VxOHw18*+Z|CzM9=Han|ma|D2b98^h_Bmu*Rn99Vt2^wnxyb8^M9y+)DK ziiz=^lb?BS%=q4F+%6r?%rl?mJQFjo#*9-YDOyvbtHq#CT4QhOogUk-W_gEICRQ`r z4u`n0W_{56ul0VE1~!($`RBu#?>NpIOUbC7r8keqOUBtH{47y?lhw@k>gmKq_NhDc`~rE)|PP`u)YR8U~B#}PQAf= zHX$_3rL3Y-$T$@%H-Mz45~p?mwo!j#PAfx9jh5A{XH%Q!T5EG$QxCW^`zNTB6H^bS zmD&OE_t`qsL6X~u+Hs{mFIZRaPu1jkz z*XQfj=j%4*E7rgLK84{l8?z5<6iIVVyN%|>S#xH)DTi!L>5FANKl}mTOMOXNGOhXE zTU%kM1p0GMI`LfhN1UHH4iiUVEZwHA>Hvg{og59LI`NNr$_NrqZ?4X~1|chkD!&<1M7t7_U)lOyG19 zxGoa7781A?8gMO`noRNB0^_;ds&XyFb1lSk+l%LtH?^JK=6v?#ob=?p^=00W@8z$| zYcv0WxuqAyKDCrQT)^M<-4}DwK%t?wpLvQC4g0D z&^(iIcX|P9F?!j@%Q^9KPHJ%;yehRHFJFZokt*v@zFdW3JQsT&I>69k=Z&Tqbc`CUKma zsZkfloJ@MgYVST4m5RS8nSa9@8@ zW+9zfN_pU0+!}NE?s7P84&Pl4x92uogK_Gk2w8>J{y~@YC?Q|hlvJjMVr%v>^)fr~ zwOVuQZE3ytqLjPqb>3`O!?yvVhGjdP-LPBhMm#_dYu{LtDYtWe@|NMznp zc7l(osnmnpRU(&Fq7{auN@*R(L7bD$oQL0W+4kg|4B+qsxNHw_$`6<&$r_V!cc|~j zK?@NroKvXJDbzQ6<(*Xcvo;o1S5Xel6-S^#hI9<33vib$cMV6|6@lMIx+GblQ?lo+7|iLI>cP=J^jeXb=6H$CrspsD1=@Iw-Ig&+)lVXaMT`-z#WG> z1$P$i0vvSC*j>ZXcBBbR^>Y>uVNeexVJW`lP!C?QLaY@9Vzbx|N5%C`t$Yie(C=08Y=^ zIJz#7o+!=fy0W%{UIHH)m&8%};7DIj(1#&4yY@0r!Zcc)1~&tao;vg4^5K@it$Elq~|CZ4s1XTLkyBErJNPMG(cd2%^~*K@GM= zP?K#Dv}Riby;QPA@EF@77^sphg5hk7V5Ca62u86jg3)Y?;Ayr+kioVHa@ZEZi?BuT zo>+$580toKlMrl`KxL~0p=_1FV5_f9NCAERXD52-aXd3Giy~eX9*uS2iL$NiDpTb!IYCZEdFILGa)aC^_sXNC(v`6EP)*dr z_9?B!!{RY9M2r*9i@D+r@wWJb_z1Q7XYq~rJKidEdB5~xWZV?nsC1K0pyr;I&&n6& zYw|7muKZAbiZwwR@@eLmY(_+4ERulL`(3bhf2bHQri*#pVm66guqAQ=tK_eUThb*f zNS}jHXUbeTM=q4BpV{}8{(P#J;VR3AO51A5Ou^qObTd*;eF zn{3krN8|l;x}1j|xLR(KyU_biSgDe&4!@`?nnM=!z?M5B#6&Sg{9e2Ysk2^e z6@L_;i@%8P#ZTf6jM-F?H6Q~Xl$~U6`J@~pC&}N*m*rx)27UZv`6v0Ml`7f&h=E;# z7NR|DJPZ&cMW&c4W{EtpTx<~A#9nb!oQ7S6>*!KpkSR4~Bbg*S%RX|j94oUSJ?3M3 zpS5zc+>I@MPFksw4Uy`izGw-V^{5yqMu}&{v*JbZns`gRD?SvT!lKPz#Sh|VDX~>h zHOQ>SvbB6zJ|>4i4o#LX$XDc>@*T*cPvl|wm6a;lr-;R9x)sLJycwMX%?};6-!&4}}7Jq{!E>+$qqaa0_$Tsp3`8Z^0Iwa}u z<*Ra;TraoEKg!SLU*zc_L&jzr@r((Ktr*)ec46$r*pG22<7mbV#vI1!Pffh29>xQV zM;MPYo*G8!oMpVoc$Kk;u$*8FVJy#Bl`)2~4r3$67Q@D;k1N-fu_I$w#$Jr6jDr}5 zF^*=Oz?j82m2rAn#*nAV&0@@BT+Xov{yNzp>+=85cg7F^zF7V+Lb3<21$@jI$Z%Gv+fcVO%jb{i(6x zYZ(g|H#2T$+|9U;@et!NTs9;8B;y&z^Ng1muQJ|XEGE29VGLmmXRO2+nU2GKF^sht z>oF!UwqQ(VY|q$*u_t3+#sQ2&(=&#Rzi%Yt1jcN}T*g_9^BEU1E@xcJxRG%i_faVm!-uf$=iqHO3;w+l1vcM$g2FjT)CP&sdo;nlYBK4r2qxM8;N(Z5cZ< zc4h1ZOemkqIEZl=<7mbSj9H9R8K*POVw}gA$G8aCr2KNm)r=b$H!*Hw+{L(;@gU<- z#uJRE8P5R|%U@)?!g!tW7UBJZ(Zv|XSb?!BqmQvBV?40w{f!u#F(xs#W9-b>ov{yN zKgPj~X^dkTGl0$R&t{y)ID>IE<9x<^#wCm^7}qiuFm7ht4s3q^ZpM9#hZv7Bo@6}3 zc%JbR<5k8RjKzc%6kv-AA&lXSl^7!#V;E~Q)?-XyY{8h!*q*Tquw{jwjC~mgFb-uL z$vB=dlQD-emvJWJ9L5C`CpKde@ku@7TE#=(qfjAI!y7_%9tG0tF| z4QyU%K4U)P62=vbYZ(g|H#2T$+|9U;@et!NV2es88P71DXS~FCmGK5+F=2$l7{VCN zScx$b*fJu9u{L8p#stO|jLD4c8M`p{WbDg0fN|)=i3yD&Mlz0P%w)`A%w?R(IEQfo z<3h%zjH?*eO?+nP#E6ZITNrmT?qNK@c!cpd<0;0oj29R$GhUnc%!G*%MU1xzD{G7% z#`27n8KW6v8S5}MU`%9el{qSNY~{9$9T~ea_F_zB9K<+`aWvxu#w^CEjMFpm45~bf zaUNqH<08i8jH?+pFm7Vp#<+`dFXO?S;Th?bk20QMJk5BH@gn0D#_No?2&)K27h@P> z1-!N@aP&_8KXsJ+ef<9fx;UrziYne=y?8go=b^hWm*wZj8(H;#5$103{!>4R_3KpL z_lmZ?#s8_J#G51K^IlQIyJACpy&+o!{~I{INAZ2#J0@~E_l~SvfG=EB+VPC^)l1?V z5@9XII*dFoYuPWX$3FLh%{Ya7MFn5FnjoeBsiVZV7Al8(M>nT)?-;`A+&hMHI`@tq zPUqf{H4k8KlS=zuFpN{UcPz*0+&hMII`@vS=8KZKcP!88+&jXOF!FQnNMis=)`;&B zVZ|6J+&fm{bnYEl2L<0iDOdM`l{tlb$10r8y<=5Q=iZSuWibar<#R6>$tm1Bw%~N` z9b0lb_l~ig&b?z(PUqf{H5c)foa*cUE4IeG!VJuw;|u;Go}=1=xsiSNihn|$kr(6@ zc|+b-TuQi76*H3YN`lf#X{U5idMW*2i;J`#kaJ@-M%_4uwct53DM{A|zhV8J=FlWG z^UNbOpZWK8^QgvJN7C9rI29VQe9W_s3#{X6^QhIfj-(re@R}7*qgiqKJ=HqezxP_d zNkfCqpSF%x{6atettSFe_Xb zYCVi2^$ZaeCL#Jl{3oMh&L8-!ZG(7 zixIEHnJ-a>?=brcaeroZ+F^e+8FHZDN}X{>;kcS*NnP)TRu`kaulK-lA&%W~T#O_9 zl_J>yN53ShjkJ#*w*dGeT*ejZ9?`Z3^_1XsQU2U`Wfwo24 zs%_Ig)IQR7X&-9`wLfWxw8Pp@dU^eRy@Fm*ucSxlmGvrmRlS-XsYmHvJzDppO(p0} z^hCWW+EjDBh2Bzcr9Y@A>8qIm;oE{J(Qlx zqv*H2l|Jy9E-Imp`YMkrsmc@R%l(xB%0Ok1@}x3Y8G@eulrjv~2G6K#)V1i}S(vR; zjBH^jBVa9YlrmZwql{I?DdVA^@D1u!L5PRMFVJ^h+sZACTcP2?-D!>-~R z$|7YkbSK_~mBwYta^)>$g|bpvrM#`IR@NwMm3NeN(5cv;)rkid!Nmiq(hI z_G$-MTKqyirXJT|f6jA7gkWAY8k!0|=~q+Kcl3eqMIYB(T4n74t)(_h8x5aJ@NsKZ zv^rWVZMZfDJ{RE=qE*%6vKTyIIHQy9ftfvvrj<}4%3lOeW;$!KGL(`o?;KibwW^g zd3p|VlFCx1UWruGVY3w1dX287L&HiRqrVQEP%6X%4)F?yK>Z`cs~kdlJpTt%Q$qKO zWavo5LieC1=Jac0uDlMkSK^`JQ4g9E4WN_Z15To>&Tt*h2e>#p_CdTNhqy|muwBg4@LMxc$4(q6@N zI*U*|fp&>AtgF)*x*^l#COpUJnN)gzjx=cbkRHTL^znSLR6|?SR;$0L#khB7D39lb z3cuUhbUxnEX3$X++Dtx5Z8jej4OS=NuWEDosA;e8QP-a4qf5)+qg%sn3J4dXP3B{$ zHieHK4ZG#Q-#}T0Ag5Ke-_dXNj`n-@m)a0My4j9qC4B9yDe7r2u%FOoSz+e!QPYO< zF+_Wck2G4*v=_}V+_Eb`*C|$eiG399Wj<w^!P-~<$ z))KTPTB6oeYo;~VT0ri%f?Q96+)nD49=cr9pmj74dUZ>o&$R&>UEA>9vKLxiN1=bD$m+^@ zU_-1NQ6K})qt0GaK9!I;kb2*V5XiU&kSSfzm%XTqhLCL!LBc$u{-XI%bB*x39pp_{ z^lU%su`zz9K>Bn;i;h99Cg68_$fEA(>D5ukP4K$|BvTJfYO$#CMEvduIn`5Bv>K@Y zruf|nQtMGjfSPCx&G5T3WLPgv(`un@G{^6UA<=qkx>g%41tWGyw_#{8Ey2b>epSI* zK@HL_4K3(FuyK%Q)wCqA@sMkgT5GU$$hRmh8LU3;i|$Ir{m|X0xDUDu75S&!tH?Ly zT19>-w<_{Uxr8Zc&Yg;UQLa?vhjOEEP880C!Z}d*>Iz?3;j1cC>$r9v>Xn!R)&=auis{U=f#!%kXN zped)6zhLD04X6JOvuvOhL?gYI-Wy-ap^*!X zz#b3;z2p;|@AJZ|c2YYDpE_He1MNcS;-bAmqCj_b4)R7Z^5k3iy;82iDDxEFpvvPt z>72MMVJLN!IN??YsZWYf^$m3~zAfW9j_(8Y zS!OC?jl2#YH*z11(6pmnv>K1Ki(cq(Zxk`=c6Ga`4vp;tkZ@G zElAB?+5rAdExI;jXm4#GeoMUzp?r`~`;ri~22_T#KYfWP2W{vf7zYm1)ASMgNPUz( z8sofi`UKju4lOl9pNK8ep3$GBGQbmpLgGBfUkOwYYZQ*ja9*^0+6SyX^f$XggL5$C z-Y{rCk4H@|M9nQ0%ke(^2WavhhR$W7IE{AIkG0E3Lw9o~G)?oM6}t{CVk@+XKZP#& zMfn4@acGTKfeyJJ^%JiaW0Z0mC54WKo=v@3$F4z4Wl>v$LLNGA9DzhoDDDrQCPZ2{D#y(|H1nPMg)}FYQ|g&f$I!J zh|zeL@IW5qnelM_-tZl!je_s17?V+I5~-W>r{G*4ipZ9qQd%e-_(4+B>{&@ZyqpgS zjqHD2s&<*KM5%5lQ?6x8wWLh#61|EtEh=5Acwe4RQWd%X?Xlb+0_yPB&acSzfs|F$h<9WGZBP}%@HGYBP`DrcIweZOEMIz7 Nq(f>^`Qi)v{{yFAbiV)q literal 0 HcmV?d00001 diff --git a/src/assets/svg/not-found-illustration.svg b/src/assets/svg/not-found-illustration.svg new file mode 100644 index 000000000..7340bc549 --- /dev/null +++ b/src/assets/svg/not-found-illustration.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Page404/index.scss b/src/components/Page404/index.scss index 299fcbe01..c42cb9ace 100644 --- a/src/components/Page404/index.scss +++ b/src/components/Page404/index.scss @@ -1,6 +1,19 @@ @import '../../assets/styles/variables.scss'; +// BB Roller Mono is the large "404" numerals in the design. +@font-face { + font-family: 'BB-Roller-Mono'; + src: url('../../assets/fonts/BB-Roller-Mono-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +$not-found-text: var(--rd-sys-color-content-primary, #1d232f); +$not-found-brand: #2770ef; + .pageStyles { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -8,22 +21,136 @@ text-align: center; height: auto; min-height: 100vh; - padding: 96px; - color: var(--rd-sys-color-content-primary); - background-color: var(--rd-sys-color-background-base); + padding: 96px 24px; + overflow: hidden; + color: $not-found-text; + background-color: var(--rd-sys-color-background-base, #fff); font-family: 'Optimo-Plain', sans-serif; } +// Radiance top wash across the top of the page, faded out into the page background. +// Size is the container's (RULES.md ADJUST-6), so only the box is set here. +.radianceTop { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 320px; + pointer-events: none; + -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); + + .radiance-local { + position: absolute; + inset: 0; + overflow: hidden; + } +} + +.pageMissing, +.docMissing { + position: relative; + z-index: 1; +} + +.pageMissing { + display: flex; + flex-direction: column; + align-items: center; +} + +.codeStyles { + margin: 0 0 40px; + font-family: 'BB-Roller-Mono', 'Roboto Mono', monospace; + font-size: 96px; + font-weight: 400; + line-height: 1; + letter-spacing: 0; +} + .headingStyles { - margin-top: 0; - margin-bottom: 64px; - font-size: 28px; + margin: 0 0 8px; + font-size: 24px; + font-weight: $font-weight-bold; + line-height: 32px; } .paragraphStyles { - margin: 0 0 16px; + margin: 0; + font-size: 16px; + line-height: 24px; +} + +.homeButton { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 24px; + padding: 4px 12px; + border-radius: 999px; + background: #eaedf2; + color: $not-found-text; + font-size: 13px; + line-height: 16px; + text-decoration: none; + transition: background-color 0.15s ease; + + &:hover { + background: #dde2ea; + text-decoration: none; + } +} + +// In-product presentation: illustration beside the text, primary button. +.pageStyles--embedded { + .docMissing { + display: flex; + align-items: center; + gap: 48px; + text-align: left; + } + + .docMissingIllustration { + flex: none; + width: 140px; + height: 118px; + } + + .docMissingText { + max-width: 440px; + } + + .paragraphStyles { + max-width: 420px; + } + + .homeButton { + padding: 8px 16px; + background: $not-found-brand; + color: #fff; + font-size: 14px; + line-height: 16px; + + &:hover { + background: #1f5fd6; + } + } +} + +@media (max-width: 560px) { + .codeStyles { + font-size: 72px; + } + + .pageStyles--embedded .docMissing { + flex-direction: column; + gap: 24px; + text-align: center; - a { - color: var(--rd-sys-color-content-brand); + .docMissingText { + display: flex; + flex-direction: column; + align-items: center; + } } } diff --git a/src/components/Page404/index.tsx b/src/components/Page404/index.tsx index 40e563eba..7820160d0 100644 --- a/src/components/Page404/index.tsx +++ b/src/components/Page404/index.tsx @@ -2,22 +2,71 @@ import * as React from 'react'; import { Link } from 'gatsby'; import { NOT_FOUND_GO_HOME_PAGE_ID } from '../../configs/doc-configs'; import t from '../../utils/lang-utils'; +import { isPublicSite } from '../../utils/app-utils'; +import { useRadianceTheme } from '../../utils/radiance'; +import notFoundIllustration from '../../assets/svg/not-found-illustration.svg'; import './index.scss'; const Page404 = () => { + // The page is prerendered once (404.html), so it always starts with the public layout and + // switches to the in-product one on mount when it is opened inside the product iframe. + const [isEmbedded, setIsEmbedded] = React.useState(false); + React.useEffect(() => { + setIsEmbedded(!isPublicSite(window.location.search)); + }, []); + + // The 404 designs only have a light variant. + useRadianceTheme(false); + + const homeLink = ( + + {t('404_GO_HOME_LINK_TEXT')} + + ); + return ( -

+
{t('404_PAGE_TITLE')} -

{t('404_PAGE_HEADING')}

-

- {t('404_PAGE_MSG_PRETEXT')} {t('404_PAGE_MSG')} -

-

- - {t('404_GO_HOME_LINK_TEXT')} - - . -

+
); }; diff --git a/src/intl/en.json b/src/intl/en.json index 77f58424b..3354e0ec3 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -3,10 +3,13 @@ "DOCS": "DOCS", "NAV_BACK_BTN_TEXT": "Back", "404_PAGE_TITLE": "Not found", - "404_PAGE_HEADING": "404 - Page Not Found", - "404_PAGE_MSG_PRETEXT": "Sorry,", - "404_PAGE_MSG": "we couldn’t find the page you’re looking for. It may have been removed, renamed, or is temporarily unavailable.", - "404_GO_HOME_LINK_TEXT": "Go to Homepage", + "404_PAGE_CODE": "404", + "404_PAGE_HEADING": "Page not found", + "404_PAGE_MSG": "Sorry, we couldn’t find the page you’re looking for.", + "404_PAGE_MSG_DETAIL": "It may have been removed, renamed, or is temporarily unavailable.", + "404_DOC_HEADING": "This document doesn’t exist", + "404_DOC_MSG": "We couldn’t find this document. It may have been moved, renamed, or the URL is incorrect.", + "404_GO_HOME_LINK_TEXT": "Go to home", "SEARCH_PLACEHOLDER": "Search", "CODE_COPY_BTN_TEXT": "Copy", "CODE_COPY_BTN_HOVER_TEXT": "Copy to clipboard", From 2245f248cbce8a2d27ab7d057dfe84578746e665 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 14 Oct 2026 19:03:35 +0530 Subject: [PATCH 06/11] nav edits --- src/components/DevDocTemplate/index.tsx | 88 ++++++++++++++++++++----- src/components/LeftSidebar/index.tsx | 30 ++++++--- 2 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index 0af542a6c..cc3209413 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -59,6 +59,25 @@ import VersionIframe from '../VersionIframe'; // Not a real DocCategory/tab — used only to pick the left sidebar content when embedded in-product. const IN_PRODUCT_NAV_KEY = 'in-product-help'; +// Session key remembering which sidebar category the visitor was last in. Pages like the +// changelogs are listed in several category navs, and every in-site navigation is a full page +// reload, so this is how the next page knows which sidebar the link was clicked from. +const ACTIVE_CATEGORY_STORAGE_KEY = 'docsActiveCategory'; + +/* The sidebar category the visitor was in on the page they just came from, or null when the + * page was opened directly (typed URL, bookmark, search engine, other site). Only trusted for a + * same-origin referrer, so a direct visit keeps the default category for the page. */ +const readPreviousCategory = (): DocCategory | null => { + if (typeof window === 'undefined') return null; + try { + if (!document.referrer) return null; + if (new URL(document.referrer).origin !== window.location.origin) return null; + return window.sessionStorage.getItem(ACTIVE_CATEGORY_STORAGE_KEY) as DocCategory | null; + } catch (e) { + return null; + } +}; + const DevDocTemplate: FC = (props) => { const { data, @@ -99,6 +118,15 @@ const DevDocTemplate: FC = (props) => { const [navContent, setNavContent] = useState(initialNavContentData || ''); // breadcrumsData is derived after processedNavMap is built (see useMemo below) const [activeCategory, setActiveCategory] = useState('guides'); + // Read once on mount, before the persistence effect below overwrites the stored value. + const [previousCategory] = useState(readPreviousCategory); + useEffect(() => { + try { + window.sessionStorage.setItem(ACTIVE_CATEGORY_STORAGE_KEY, activeCategory); + } catch (e) { + // storage unavailable (private mode etc.): the default category is used next time + } + }, [activeCategory]); const [showSearch, setShowSearch] = useState(false); const [leftNavOpen, setLeftNavOpen] = useState(false); const [keyword, updateKeyword] = useState(''); @@ -205,24 +233,34 @@ const isVersionedIframe = VERSION_DROPDOWN.some( const isAskDocsPage = params[TS_PAGE_ID_PARAM] === CUSTOM_PAGE_ID.ASK_DOCS; - /* Build pageId → category map by parsing hrefs from each category's nav HTML. + /* Build pageId → categories map by parsing hrefs from each category's nav HTML. * This means writers only need to update nav-*.adoc — no TypeScript changes needed. - * Skips any merged nav key that isn't a real tab/category (e.g. the in-product nav, - * or a content-only nav file like nav-release-notes.adoc that a category points to - * via CATEGORY_NAV_ID rather than being a category itself) — otherwise `cat` ends up - * as a bogus DocCategory and CATEGORY_NAV_ID[activeCategory] later resolves to - * undefined, crashing on the `.startsWith` call in activeNavContent below. */ - const pageIdToCategoryMap = React.useMemo(() => { + * A page can be listed in more than one nav (e.g. the changelogs are in both + * nav-release-notes.adoc and their product's nav), so each page maps to a list. + * Nav keys are resolved to categories through CATEGORY_NAV_ID (nav-release-notes.adoc + * belongs to 'whats-new'). Anything that isn't a real tab/category (e.g. the in-product + * nav) is skipped — otherwise `cat` ends up as a bogus DocCategory and + * CATEGORY_NAV_ID[activeCategory] later resolves to undefined, crashing on the + * `.startsWith` call in activeNavContent above. */ + const pageIdToCategories = React.useMemo(() => { if (typeof window === 'undefined') return {}; - const knownCategories = new Set(Object.keys(CATEGORY_NAV_ID)); - const map: Record = {}; - Object.entries(processedNavMap).forEach(([cat, html]) => { - if (cat === IN_PRODUCT_NAV_KEY || !knownCategories.has(cat)) return; + // nav-*.adoc key (pageid minus 'nav-') -> category, e.g. 'release-notes' -> 'whats-new' + const navKeyToCategory: Record = {}; + (Object.entries(CATEGORY_NAV_ID) as [DocCategory, string][]).forEach(([cat, navId]) => { + if (cat !== 'all' && navId.startsWith('nav-')) { + navKeyToCategory[navId.slice(4)] = cat; + } + }); + const map: Record = {}; + Object.entries(processedNavMap).forEach(([navKey, html]) => { + const cat = navKeyToCategory[navKey]; + if (navKey === IN_PRODUCT_NAV_KEY || !cat) return; const doc = new DOMParser().parseFromString(html as string, 'text/html'); doc.querySelectorAll('a[href]').forEach((a) => { const pageId = getPageIdFromHref(a.getAttribute('href')); if (!pageId) return; - map[pageId] = cat as DocCategory; + if (!map[pageId]) map[pageId] = []; + if (!map[pageId].includes(cat)) map[pageId].push(cat); }); }); return map; @@ -245,8 +283,18 @@ const isVersionedIframe = VERSION_DROPDOWN.some( return; } // First try the auto-derived map from nav files - if (pageIdToCategoryMap[currentPageId]) { - setActiveCategory(pageIdToCategoryMap[currentPageId]); + const categories = pageIdToCategories[currentPageId]; + if (categories?.length) { + // A page listed in several category navs (e.g. the changelogs, which sit in both + // "What's new" and their product's nav) stays in the sidebar the visitor came from. + // A direct visit has no previous category and gets the product nav, not "What's new". + const cameFrom = previousCategory && categories.includes(previousCategory) + ? previousCategory + : undefined; + const productNav = categories.filter((cat) => cat !== 'whats-new'); + setActiveCategory( + cameFrom || productNav[productNav.length - 1] || categories[0], + ); return; } // Fall back to the static CATEGORY_PAGEIDS for any pages not yet in a nav file @@ -254,7 +302,7 @@ const isVersionedIframe = VERSION_DROPDOWN.some( ([, pageIds]) => pageIds.includes(currentPageId), ); if (found) setActiveCategory(found[0]); - }, [curPageNode.pageAttributes.pageid, pageIdToCategoryMap, isVersionedIframe, location.search]); + }, [curPageNode.pageAttributes.pageid, pageIdToCategories, previousCategory, isVersionedIframe, location.search]); useEffect(() => { // based on query params set if public site is open or not @@ -414,7 +462,15 @@ const isVersionedIframe = VERSION_DROPDOWN.some( const found = (Object.entries(CATEGORY_PAGEIDS) as [DocCategory, string[]][]).find( ([, pageIds]) => pageIds.includes(e.data.params.pageid), ); - if (found) setActiveCategory(found[0]); + // Keep the current tab when it already lists the page (e.g. a changelog opened + // from "What's new"), instead of jumping to the first category that does. + if (found) { + setActiveCategory((current) => + CATEGORY_PAGEIDS[current]?.includes(e.data.params.pageid) + ? current + : found[0], + ); + } } } }; diff --git a/src/components/LeftSidebar/index.tsx b/src/components/LeftSidebar/index.tsx index 891fb64b6..50eef1abe 100644 --- a/src/components/LeftSidebar/index.tsx +++ b/src/components/LeftSidebar/index.tsx @@ -101,17 +101,27 @@ const LeftSideBar = (props: { // marking this navigation as handled. const isAsideVisible = isMaxMobileResolution || props.leftNavOpen; if (lastScrolledPageIdRef.current !== props.curPageid && isAsideVisible) { - const activeLink = (ref.current as HTMLDivElement)?.querySelector( - 'a.active', - ); - if (activeLink) { + const aside = ref.current as HTMLDivElement; + const activeLink = aside?.querySelector('a.active'); + if (aside && activeLink) { lastScrolledPageIdRef.current = props.curPageid; - // 'nearest' is a no-op once the link is already visible. 'center' isn't — - // it nudges toward exact-center even when already in view, and since this - // sidebar has no scroll room to spare on most pages (content fits exactly), - // that nudge has nowhere to go but the window itself, which is what was - // sliding the fixed header out of view on some walkthrough pages. - activeLink.scrollIntoView({ block: 'nearest' }); + // Scroll only the sidebar itself, never the window: scrollIntoView() also + // scrolls the window, which slid the fixed header out of view on some + // walkthrough pages. 'nearest' avoided that but parks a link that starts + // below the fold flush against the bottom edge, so the user lands on a nav + // that looks jumped. Instead, leave the nav alone when the link is already + // comfortably in the upper part of it, and otherwise bring it to about a + // third of the way down (scrollTop clamps at the ends). + const asideRect = aside.getBoundingClientRect(); + const linkRect = activeLink.getBoundingClientRect(); + const linkTop = linkRect.top - asideRect.top; + const isComfortablyVisible = + linkTop >= 0 && + linkRect.bottom <= asideRect.bottom && + linkTop <= aside.clientHeight * 0.6; + if (!isComfortablyVisible) { + aside.scrollTop += linkTop - aside.clientHeight * 0.3; + } } } }, [props.curPageid, isMaxMobileResolution, navContent, props.leftNavOpen]); From f505821e2034ffe26511156959ec1676fea0d86d Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Mon, 26 Oct 2026 17:38:20 +0530 Subject: [PATCH 07/11] feat: dark mode for the 404 page and use the Radiance home pattern The 404 now follows the site theme (isDarkMode param, stored choice, OS preference on the public site) in both layouts, with a dark illustration and the docs' dark colours. The gradient moves from the generic top-wash template to the Radiance 'home' product pattern with its recipe, the same as the docs home hero. The template moved much more in dark than light and read grainier on the near-empty page. Co-Authored-By: Claude Sonnet 5.5 --- .../svg/not-found-illustration-dark.svg | 110 ++++++++++++++++++ src/components/Page404/index.scss | 45 +++++-- src/components/Page404/index.tsx | 34 +++--- src/utils/app-utils.ts | 22 ++++ 4 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 src/assets/svg/not-found-illustration-dark.svg diff --git a/src/assets/svg/not-found-illustration-dark.svg b/src/assets/svg/not-found-illustration-dark.svg new file mode 100644 index 000000000..793456da4 --- /dev/null +++ b/src/assets/svg/not-found-illustration-dark.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Page404/index.scss b/src/components/Page404/index.scss index c42cb9ace..b535269de 100644 --- a/src/components/Page404/index.scss +++ b/src/components/Page404/index.scss @@ -28,23 +28,18 @@ $not-found-brand: #2770ef; font-family: 'Optimo-Plain', sans-serif; } -// Radiance top wash across the top of the page, faded out into the page background. -// Size is the container's (RULES.md ADJUST-6), so only the box is set here. -.radianceTop { +// Hero box for the Radiance "home" pattern (radiance-home.css): 336px at the top of the page. +// The recipe bleeds its field past this box and fades it into the page, and the page clips the +// bleed (overflow: hidden above). The page background is the area the field fades into. +.radianceHero { position: absolute; top: 0; left: 0; right: 0; - height: 320px; + height: 336px; + z-index: 1; + isolation: isolate; pointer-events: none; - -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); - mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); - - .radiance-local { - position: absolute; - inset: 0; - overflow: hidden; - } } .pageMissing, @@ -137,6 +132,32 @@ $not-found-brand: #2770ef; } } +// Dark presentation. The colours are the docs' own dark tokens (body #1F2024, border #34363C, +// accent #71A1F4), which match the dark designs. The Radiance home pattern fades into the page +// background, as it does on the docs home hero. +.pageStyles--dark { + color: #dfe0e2; + background-color: #1f2024; + + .homeButton { + background: #34363c; + color: #dfe0e2; + + &:hover { + background: #41444b; + } + } + + &.pageStyles--embedded .homeButton { + background: #71a1f4; + color: #1f2024; + + &:hover { + background: #86b0f6; + } + } +} + @media (max-width: 560px) { .codeStyles { font-size: 72px; diff --git a/src/components/Page404/index.tsx b/src/components/Page404/index.tsx index 7820160d0..208545366 100644 --- a/src/components/Page404/index.tsx +++ b/src/components/Page404/index.tsx @@ -2,21 +2,25 @@ import * as React from 'react'; import { Link } from 'gatsby'; import { NOT_FOUND_GO_HOME_PAGE_ID } from '../../configs/doc-configs'; import t from '../../utils/lang-utils'; -import { isPublicSite } from '../../utils/app-utils'; +import { isPublicSite, getInitialDarkMode } from '../../utils/app-utils'; import { useRadianceTheme } from '../../utils/radiance'; import notFoundIllustration from '../../assets/svg/not-found-illustration.svg'; +import notFoundIllustrationDark from '../../assets/svg/not-found-illustration-dark.svg'; +import '../../assets/styles/radiance-home.css'; import './index.scss'; const Page404 = () => { - // The page is prerendered once (404.html), so it always starts with the public layout and - // switches to the in-product one on mount when it is opened inside the product iframe. + // The page is prerendered once (404.html), so it always starts with the public light layout + // and switches to the in-product and/or dark presentation on mount. const [isEmbedded, setIsEmbedded] = React.useState(false); + const [isDark, setIsDark] = React.useState(false); React.useEffect(() => { setIsEmbedded(!isPublicSite(window.location.search)); + setIsDark(getInitialDarkMode(window.location.search)); }, []); - // The 404 designs only have a light variant. - useRadianceTheme(false); + // Keeps the Radiance gradient in the same theme as the page. + useRadianceTheme(isDark); const homeLink = ( { return (
{t('404_PAGE_TITLE')} -