From c5fa3a29e2ac2ace9cd98f5a5aca6da1cf4dc7ca Mon Sep 17 00:00:00 2001 From: Claudiu Schuster Date: Sun, 30 Aug 2026 14:35:12 +0200 Subject: [PATCH] feat: add reactive signal field --- README.md | 4 +- docs/product-requirements.md | 10 +- scripts/build-site.sh | 2 + scripts/check-site.py | 17 ++- site/.htaccess | 8 +- site/assets/scripts/reactive-field.js | 182 ++++++++++++++++++++++++++ site/assets/styles/site-v0.css | 13 +- site/index.html | 3 + 8 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 site/assets/scripts/reactive-field.js diff --git a/README.md b/README.md index 5ebeb13..29da015 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Source repository for [oss-singularity.io](https://oss-singularity.io/). -Launch Pad v0 is a dependency-free static site shaped around the “Signal Observatory” visual direction: a precise cosmic shell, authentic project interfaces, and an intentionally human open-source voice. GitHub remains canonical; `dist/` is a reproducible, allowlisted production artifact. +Launch Pad is a dependency-free static site shaped around the “Signal Observatory” visual direction: a precise cosmic shell, an adaptive pointer-reactive signal field, authentic project interfaces, and an intentionally human open-source voice. GitHub remains canonical; `dist/` is a reproducible, allowlisted production artifact. ## Development @@ -12,7 +12,7 @@ Build and validate the complete site with: ./scripts/check-repository.sh ``` -The build uses authored HTML and CSS plus local optimized assets. It requires a POSIX shell and Python 3 for validation, installs no packages, executes no client-side JavaScript, and writes only to ignored `dist/`. +The build uses authored HTML and CSS, one small dependency-free Canvas enhancement, and local optimized assets. It requires a POSIX shell and Python 3 for validation, installs no packages, makes no runtime network requests, and writes only to ignored `dist/`. To preview the production tree locally after a successful build: diff --git a/docs/product-requirements.md b/docs/product-requirements.md index bf449de..3aa5aaa 100644 --- a/docs/product-requirements.md +++ b/docs/product-requirements.md @@ -73,7 +73,7 @@ Project documentation, releases, issue tracking, and installation instructions r - Minimum 44×44 CSS-pixel primary interactive targets where practical - Text contrast of at least 4.5:1 and non-text contrast of at least 3:1 - No information conveyed by color alone -- Motion is decorative, modest, and removed for `prefers-reduced-motion: reduce` +- Motion is decorative, modest, pointer-reactive, and removed for `prefers-reduced-motion: reduce` - Layout remains usable at 320 CSS pixels, 200% zoom, and with long translated text even though v0 ships in English ## Performance budget @@ -82,7 +82,7 @@ Measured against a clean production build before launch: | Budget | Target | | --- | --- | -| Executable JavaScript | 0 bytes | +| Executable JavaScript | under 8 KB, local and dependency-free | | Initial HTML, uncompressed | under 35 KB | | CSS, uncompressed | under 40 KB | | Initial page transfer, excluding social preview | under 350 KB | @@ -90,7 +90,7 @@ Measured against a clean production build before launch: | Lighthouse mobile | Performance, Accessibility, Best Practices and SEO each at least 95 | | Core stability goals | LCP under 2.0 s on a fast 4G profile; CLS under 0.05 | -System fonts, responsive local images, explicit image dimensions, immutable asset caching, and zero client-side framework code support the budget. +System fonts, responsive local images, explicit image dimensions, immutable asset caching, and zero client-side framework code support the budget. The optional reactive signal field makes no network requests, stores no state, stops in background tabs, caps display density, and is disabled when reduced motion is requested. ## SEO and social sharing @@ -110,9 +110,9 @@ System fonts, responsive local images, explicit image dimensions, immutable asse ## Technology decision -Use authored HTML and CSS with a small POSIX-shell build script that copies an explicit allowlist into `dist/` and verifies the required output. There are no package-manager dependencies. +Use authored HTML, CSS and one small progressive-enhancement script with a POSIX-shell build that copies an explicit allowlist into `dist/` and verifies the required output. There are no package-manager dependencies. -This is the simplest fit because the content is editorial, the interactions are links and anchors, and the verified default Stellar shell exposes no Node/npm runtime. PHP 8.2 is available but adds no value to v0. A static output is reproducible locally and in GitHub Actions, cheap to cache, easy to stage exactly, and easy to roll back. +This remains the simplest fit because the content is editorial, the primary interactions are links and anchors, and the verified default Stellar shell exposes no Node/npm runtime. A small local Canvas enhancement adds visual reactivity without a framework, runtime service, tracking or third-party request. PHP 8.2 is available but adds no value. The static output remains reproducible locally and in GitHub Actions, cheap to cache, easy to stage exactly, and easy to roll back. Reconsider a static-site generator only when repeated content, localization, or a larger editorial surface makes hand-authored pages demonstrably harder to maintain. Reconsider a server runtime only for a concrete server-side requirement. diff --git a/scripts/build-site.sh b/scripts/build-site.sh index 53af12f..932dca8 100755 --- a/scripts/build-site.sh +++ b/scripts/build-site.sh @@ -21,6 +21,7 @@ rm -rf -- "$output" mkdir -p \ "$output/assets/brand" \ "$output/assets/projects" \ + "$output/assets/scripts" \ "$output/assets/social" \ "$output/assets/styles" @@ -44,6 +45,7 @@ copy_file assets/brand/oss-singularity-mark.svg assets/brand/oss-singularity-mar copy_file assets/projects/pdrive-control-center-v080.webp assets/projects/pdrive-control-center-v080.webp copy_file assets/projects/chatgpt-usage-v030.webp assets/projects/chatgpt-usage-v030.webp copy_file assets/projects/nemo-action-bar.webp assets/projects/nemo-action-bar.webp +copy_file assets/scripts/reactive-field.js assets/scripts/reactive-field.js copy_file assets/social/oss-singularity-social-preview.png assets/social/oss-singularity-social-preview.png copy_file assets/styles/site-v0.css assets/styles/site-v0.css diff --git a/scripts/check-site.py b/scripts/check-site.py index 2ca165a..2e31d1d 100755 --- a/scripts/check-site.py +++ b/scripts/check-site.py @@ -77,6 +77,7 @@ def main() -> int: "assets/projects/chatgpt-usage-v030.webp", "assets/projects/nemo-action-bar.webp", "assets/projects/pdrive-control-center-v080.webp", + "assets/scripts/reactive-field.js", "assets/social/oss-singularity-social-preview.png", "assets/styles/site-v0.css", } @@ -84,9 +85,6 @@ def main() -> int: if actual != required: fail(f"build allowlist mismatch: missing={sorted(required - actual)} extra={sorted(actual - required)}") - if any(path.suffix == ".js" for path in root.rglob("*")): - fail("executable JavaScript is outside the v0 budget") - html_bytes = 0 for document in (root / "index.html", root / "404.html"): parser = Document() @@ -99,8 +97,9 @@ def main() -> int: fail(f"{document.name} has no title") if parser.h1_count != 1: fail(f"{document.name} must contain exactly one h1") - if parser.scripts: - fail(f"{document.name} contains script elements") + expected_scripts = 1 if document.name == "index.html" else 0 + if parser.scripts != expected_scripts: + fail(f"{document.name} must contain {expected_scripts} script elements") if len(parser.ids) != len(set(parser.ids)): fail(f"{document.name} contains duplicate ids") @@ -147,6 +146,9 @@ def main() -> int: css_bytes = (root / "assets/styles/site-v0.css").stat().st_size if css_bytes > 40_000: fail(f"CSS budget exceeded: {css_bytes} bytes") + script_bytes = (root / "assets/scripts/reactive-field.js").stat().st_size + if script_bytes > 8_000: + fail(f"JavaScript budget exceeded: {script_bytes} bytes") for image in (root / "assets/projects").iterdir(): if image.stat().st_size > 180_000: fail(f"project image budget exceeded: {image.name} is {image.stat().st_size} bytes") @@ -163,7 +165,10 @@ def main() -> int: ElementTree.parse(root / "sitemap.xml") ElementTree.parse(root / "assets/brand/oss-singularity-mark.svg") - print(f"site checks passed: html={html_bytes} css={css_bytes} transfer={transfer}") + print( + f"site checks passed: html={html_bytes} css={css_bytes} " + f"js={script_bytes} transfer={transfer}" + ) return 0 diff --git a/site/.htaccess b/site/.htaccess index 6eaed4c..781057a 100644 --- a/site/.htaccess +++ b/site/.htaccess @@ -10,7 +10,7 @@ ErrorDocument 404 /404.html - Header always set Content-Security-Policy "default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'self'; upgrade-insecure-requests" + Header always set Content-Security-Policy "default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; upgrade-insecure-requests" Header always set Cross-Origin-Opener-Policy "same-origin" Header always set Cross-Origin-Resource-Policy "same-origin" Header always set Permissions-Policy "camera=(), geolocation=(), microphone=(), payment=(), usb=()" @@ -22,7 +22,7 @@ ErrorDocument 404 /404.html Header set Cache-Control "no-cache" - + Header set Cache-Control "public, max-age=31536000, immutable" @@ -31,9 +31,9 @@ ErrorDocument 404 /404.html - AddOutputFilterByType DEFLATE text/html text/plain text/css application/json application/manifest+json application/xml image/svg+xml + AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript application/json application/manifest+json application/xml image/svg+xml - AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css application/json application/manifest+json application/xml image/svg+xml + AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css application/javascript application/json application/manifest+json application/xml image/svg+xml diff --git a/site/assets/scripts/reactive-field.js b/site/assets/scripts/reactive-field.js new file mode 100644 index 0000000..a48692e --- /dev/null +++ b/site/assets/scripts/reactive-field.js @@ -0,0 +1,182 @@ +(() => { + const canvas = document.querySelector("[data-signal-field]"); + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + + if (!(canvas instanceof HTMLCanvasElement) || reducedMotion.matches) { + return; + } + + const context = canvas.getContext("2d", { alpha: true }); + + if (!context) { + return; + } + + const pointer = { active: false, x: 0, y: 0, easedX: 0, easedY: 0 }; + let particles = []; + let width = 0; + let height = 0; + let pixelRatio = 1; + let frame = 0; + let visible = true; + + const random = (() => { + let seed = 0x51a1f13d; + return () => { + seed |= 0; + seed = seed + 0x6d2b79f5 | 0; + let value = Math.imul(seed ^ seed >>> 15, 1 | seed); + value = value + Math.imul(value ^ value >>> 7, 61 | value) ^ value; + return ((value ^ value >>> 14) >>> 0) / 4294967296; + }; + })(); + + const createParticles = () => { + const density = Math.floor(width * height / 26000); + const count = Math.max(28, Math.min(72, density)); + particles = Array.from({ length: count }, (_, index) => ({ + x: random() * width, + y: random() * height, + depth: .28 + random() * .72, + size: .45 + random() * 1.15, + phase: random() * Math.PI * 2, + accent: index % 7 === 0, + })); + }; + + const resize = () => { + width = window.innerWidth; + height = window.innerHeight; + pixelRatio = Math.min(window.devicePixelRatio || 1, 1.5); + canvas.width = Math.round(width * pixelRatio); + canvas.height = Math.round(height * pixelRatio); + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + createParticles(); + }; + + const positionParticle = (particle, time) => { + let x = particle.x + pointer.easedX * particle.depth * 15; + let y = particle.y + pointer.easedY * particle.depth * 10; + x += Math.sin(time * .00016 + particle.phase) * 2.5 * particle.depth; + y += Math.cos(time * .00012 + particle.phase) * 2 * particle.depth; + + if (pointer.active) { + const offsetX = x - pointer.x; + const offsetY = y - pointer.y; + const distance = Math.hypot(offsetX, offsetY) || 1; + const reach = 190; + + if (distance < reach) { + const lens = (1 - distance / reach) * 18 * particle.depth; + x += offsetX / distance * lens; + y += offsetY / distance * lens; + } + } + + return { x, y }; + }; + + const drawGlow = () => { + if (!pointer.active) { + return; + } + + const glow = context.createRadialGradient(pointer.x, pointer.y, 0, pointer.x, pointer.y, 230); + glow.addColorStop(0, "rgba(101, 229, 255, .075)"); + glow.addColorStop(.48, "rgba(255, 99, 200, .035)"); + glow.addColorStop(1, "rgba(3, 8, 17, 0)"); + context.fillStyle = glow; + context.fillRect(pointer.x - 230, pointer.y - 230, 460, 460); + }; + + const draw = (time) => { + if (!visible) { + return; + } + + pointer.easedX += ((pointer.active ? pointer.x / width - .5 : 0) - pointer.easedX) * .045; + pointer.easedY += ((pointer.active ? pointer.y / height - .5 : 0) - pointer.easedY) * .045; + + context.clearRect(0, 0, width, height); + drawGlow(); + + const positions = particles.map((particle) => positionParticle(particle, time)); + + if (pointer.active) { + positions.forEach((point) => { + const distance = Math.hypot(point.x - pointer.x, point.y - pointer.y); + + if (distance < 165) { + context.beginPath(); + context.moveTo(pointer.x, pointer.y); + context.lineTo(point.x, point.y); + context.strokeStyle = `rgba(255, 99, 200, ${(1 - distance / 165) * .16})`; + context.lineWidth = .7; + context.stroke(); + } + }); + } + + for (let first = 0; first < positions.length; first += 1) { + for (let second = first + 1; second < positions.length; second += 1) { + const distance = Math.hypot( + positions[first].x - positions[second].x, + positions[first].y - positions[second].y, + ); + + if (distance < 108) { + context.beginPath(); + context.moveTo(positions[first].x, positions[first].y); + context.lineTo(positions[second].x, positions[second].y); + context.strokeStyle = `rgba(101, 229, 255, ${(1 - distance / 108) * .085})`; + context.lineWidth = .6; + context.stroke(); + } + } + } + + particles.forEach((particle, index) => { + const point = positions[index]; + const pulse = .58 + Math.sin(time * .001 + particle.phase) * .22; + context.beginPath(); + context.arc(point.x, point.y, particle.size, 0, Math.PI * 2); + context.fillStyle = particle.accent + ? `rgba(255, 99, 200, ${pulse})` + : `rgba(185, 243, 255, ${pulse * .72})`; + context.fill(); + }); + + frame = window.requestAnimationFrame(draw); + }; + + const updatePointer = (event) => { + if (event.pointerType === "touch" && !event.isPrimary) { + return; + } + pointer.active = true; + pointer.x = event.clientX; + pointer.y = event.clientY; + }; + + const releasePointer = () => { + pointer.active = false; + }; + + const updateVisibility = () => { + visible = !document.hidden; + window.cancelAnimationFrame(frame); + if (visible) { + frame = window.requestAnimationFrame(draw); + } + }; + + window.addEventListener("resize", resize, { passive: true }); + window.addEventListener("pointermove", updatePointer, { passive: true }); + window.addEventListener("pointerdown", updatePointer, { passive: true }); + window.addEventListener("pointerup", releasePointer, { passive: true }); + document.documentElement.addEventListener("pointerleave", releasePointer, { passive: true }); + document.addEventListener("visibilitychange", updateVisibility); + + resize(); + frame = window.requestAnimationFrame(draw); +})(); diff --git a/site/assets/styles/site-v0.css b/site/assets/styles/site-v0.css index 8bcb5fd..f582e0a 100644 --- a/site/assets/styles/site-v0.css +++ b/site/assets/styles/site-v0.css @@ -37,6 +37,16 @@ body { overflow-x: clip; } +.signal-field { + height: 100%; + inset: 0; + opacity: .72; + pointer-events: none; + position: fixed; + width: 100%; + z-index: 0; +} + body::before { background-image: linear-gradient(var(--line-soft) 1px, transparent 1px), @@ -76,7 +86,7 @@ a { color: inherit; } outline-offset: 5px; } -.site-shell { position: relative; } +.site-shell { position: relative; z-index: 1; } .site-header, .hero, @@ -376,5 +386,6 @@ a { color: inherit; } @media (prefers-reduced-motion: reduce) { :root { scroll-behavior: auto; } + .signal-field { display: none; } *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } } diff --git a/site/index.html b/site/index.html index dd74636..b81cb8b 100644 --- a/site/index.html +++ b/site/index.html @@ -11,6 +11,7 @@ + @@ -28,6 +29,8 @@ + +