diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1695418 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: UTC + open-pull-requests-limit: 3 + groups: + workflow-dependencies: + patterns: ["*"] + commit-message: + prefix: "chore(actions)" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index e29b329..10b8f85 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,10 +14,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + - name: Check out canonical core sources + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: graphabi/graphabi + path: .graphabi-core - name: Check tracked text run: python3 scripts/check_text.py - name: Check local links and semantics run: python3 scripts/check_site.py + - name: Check public proof and generated assets + run: python3 scripts/check_core_sync.py .graphabi-core - name: Check JavaScript syntax run: | node --check field.js diff --git a/README.md b/README.md index 29365cf..3d24a68 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,6 @@ GitHub Pages serves `index.html` directly from the `main` branch root. Keep this small: semantic HTML, local CSS and JavaScript, local SVG/PNG assets, and no framework, analytics, cookies, web fonts, CDN, or build service. Motion must explain semantic flow and support `prefers-reduced-motion`. + +The site quality workflow checks public proof metrics and generated brand assets against the core +repository so the two public surfaces cannot drift silently. diff --git a/field.js b/field.js index ce2346f..df45776 100644 --- a/field.js +++ b/field.js @@ -22,7 +22,12 @@ const dark = window.matchMedia("(prefers-color-scheme: dark)"); const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)"); const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + // Coarse-pointer devices get a deliberately cheaper simulation. The field + // remains alive and touch-aware, but avoids spending a mobile frame budget + // on forces whose detail is only visible during fine-pointer exploration. + const coarseField = !finePointer.matches && window.innerWidth <= 900; const constrained = Boolean( + coarseField || (navigator.hardwareConcurrency && navigator.hardwareConcurrency <= 4) || (navigator.deviceMemory && navigator.deviceMemory <= 4) || (connection && connection.saveData) @@ -346,6 +351,55 @@ } }; + // Mobile equilibrium keeps the graph cohesive with edge springs and a slow + // drift, then adds the same short touch convergence used by the full field. + // It intentionally omits pairwise repulsion, obstacle routing, and topology + // mutation. Those details reward a fine pointer but are costly under mobile + // CPU throttling and unnecessary behind compact foreground layouts. + const stepCoarse = (dt, t) => { + pointerEnergy += ((pointerTouch ? 1 : 0) - pointerEnergy) * Math.min(1, dt * 0.09); + pointerSpeed *= Math.pow(0.89, dt); + + for (const e of edges) { + if (e.life < 0.02) continue; + const a = nodes[e.a], b = nodes[e.b]; + const dx = b.x - a.x, dy = b.y - a.y; + const d = Math.hypot(dx, dy) || 1; + const f = (d - REST) * 0.0012 * e.life; + const ux = dx / d * f, uy = dy / d * f; + a.vx += ux; a.vy += uy; + b.vx -= ux; b.vy -= uy; + } + + for (let i = 0; i < nodes.length; i++) { + const p = nodes[i]; + if (pointerEnergy > 0.01) { + const dx = p.x - pointerX, dy = p.y - pointerY; + const d2 = dx * dx + dy * dy; + if (d2 < 40000 && d2 > 1) { + const d = Math.sqrt(d2); + const near = 1 - d / 200; + const ring = 30 + p.z * 22; + const radial = (d - ring) * 0.0038 * near * pointerEnergy; + p.vx -= dx / d * radial; + p.vy -= dy / d * radial; + } + } + + const w = 0.005 + p.z * 0.008; + p.vx += Math.cos(t * p.ps + p.px) * w; + p.vy += Math.sin(t * p.ps + p.py) * w; + p.vx *= 0.88; p.vy *= 0.88; + p.x += p.vx * dt; p.y += p.vy * dt; + + const m = 32; + if (p.x < m) p.vx += (m - p.x) * 0.018; + if (p.x > W - m) p.vx -= (p.x - (W - m)) * 0.018; + if (p.y < m) p.vy += (m - p.y) * 0.018; + if (p.y > H - m) p.vy -= (p.y - (H - m)) * 0.018; + } + }; + /* ---------------------------------------------------------- pulses --- */ const spawn = () => { @@ -474,12 +528,51 @@ } }; + // Batch coarse-field paths to keep Canvas calls and style changes bounded. + // Depth is preserved in node radius, while the full field keeps per-edge + // depth, transient topology, and pulse trails for desktop exploration. + const drawCoarse = () => { + ctx.clearRect(0, 0, W, H); + ctx.lineCap = "round"; + ctx.beginPath(); + for (const e of edges) { + if (e.life < 0.02) continue; + const a = nodes[e.a], b = nodes[e.b]; + const c = control(a, b, e.bow); + ctx.moveTo(a.x, a.y); + ctx.quadraticCurveTo(c.x, c.y, b.x, b.y); + } + ctx.strokeStyle = rgba(C.mesh, C.isDark ? 0.28 : 0.22); + ctx.lineWidth = 0.9; + ctx.stroke(); + + ctx.beginPath(); + for (const p of nodes) { + ctx.moveTo(p.x + p.r, p.y); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + } + ctx.fillStyle = rgba(C.mesh, C.isDark ? 0.48 : 0.40); + ctx.fill(); + + for (const p of pulses) { + const hop = p.path[p.i]; + const a = nodes[hop.from], b = nodes[hop.to]; + if (!a || !b) continue; + const head = at(a, control(a, b, hop.edge.bow), b, p.t); + ctx.beginPath(); + ctx.arc(head.x, head.y, 2.2, 0, Math.PI * 2); + ctx.fillStyle = rgba(C.pulse, 0.72); + ctx.fill(); + } + }; + /* ------------------------------------------------------------ loop --- */ const frame = (now) => { if (!running) return; raf = requestAnimationFrame(frame); - if (constrained && now - lastFrameAt < 30) return; + const cadence = coarseField ? 48 : constrained ? 30 : 0; + if (cadence && now - lastFrameAt < cadence) return; lastFrameAt = now; if (!last) last = now; // Clamp dt so a backgrounded tab never resumes with an exploded step. @@ -487,9 +580,11 @@ last = now; const t = now / 1000; // Forces are tuned per 60 fps frame, so dt is expressed in frame units. - step(dt * 60, t); + if (coarseField) stepCoarse(dt * 60, t); + else step(dt * 60, t); stepPulses(dt, t); - draw(); + if (coarseField) drawCoarse(); + else draw(); }; const start = () => { @@ -518,16 +613,20 @@ ctx.setTransform(dpr, 0, 0, dpr, 0, 0); build(); measure(); - if (reduced.matches) settle(); }; // Reduced motion: run the simulation to rest off-screen, paint one frame, // and never start a loop. The field becomes a still topology, not nothing. const settle = () => { stop(); - for (let i = 0; i < 220; i++) step(1, 0); + const iterations = constrained ? 48 : 120; + for (let i = 0; i < iterations; i++) { + if (coarseField) stepCoarse(1, 0); + else step(1, 0); + } pulses = []; - draw(); + if (coarseField) drawCoarse(); + else draw(); }; /* --------------------------------------------------------- observe --- */ @@ -548,6 +647,7 @@ if (Math.abs(w - lastW) < 2 && canvas.width === Math.round(w * dpr)) { measure(); return; } lastW = w; resize(); + if (reduced.matches) settle(); }, 180); }, { passive: true }); @@ -624,7 +724,10 @@ document.hidden ? stop() : start(); }); - dark.addEventListener("change", () => { readPalette(); if (reduced.matches) draw(); }); + dark.addEventListener("change", () => { + readPalette(); + if (reduced.matches) coarseField ? drawCoarse() : draw(); + }); reduced.addEventListener("change", () => { if (reduced.matches) settle(); diff --git a/index.html b/index.html index d6d2f5e..1aada8c 100644 --- a/index.html +++ b/index.html @@ -376,8 +376,8 @@

Your schema passed.

Measured technical proof

Small surface.
Serious checks.

Current branch measurements, linked to the source that enforces them.

-
Tests
112passing locally
-
Coverage
92.94%85% CI floor
+
Tests
115passing locally
+
Coverage
93.08%85% CI floor
Python CI
3.12 ยท 3.13both required
Evaluators
7 typesdeterministic registry
Runtime
Local-firstno required API key
diff --git a/proof.json b/proof.json index ee33713..3f3bd5d 100644 --- a/proof.json +++ b/proof.json @@ -1,6 +1,6 @@ { - "tests": 112, - "coverage_percent": 92.94, + "tests": 115, + "coverage_percent": 93.08, "python_versions": [ "3.12", "3.13" diff --git a/scripts/check_core_sync.py b/scripts/check_core_sync.py new file mode 100644 index 0000000..8a4dc1a --- /dev/null +++ b/scripts/check_core_sync.py @@ -0,0 +1,53 @@ +"""Verify public proof and generated brand assets against the core repository.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +ROOT = Path(__file__).parents[1] +ASSETS = ("favicon.svg", "logo-mark.svg", "open-graph.png") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "core", + nargs="?", + type=Path, + default=ROOT.parent / "graphabi", + help="path to a GraphABI core checkout", + ) + core = parser.parse_args().core.resolve() + errors: list[str] = [] + + site_proof = json.loads((ROOT / "proof.json").read_text(encoding="utf-8")) + core_proof_path = core / "docs/public-proof.json" + if not core_proof_path.is_file(): + errors.append(f"missing core proof file: {core_proof_path}") + else: + core_proof = json.loads(core_proof_path.read_text(encoding="utf-8")) + if site_proof != core_proof: + errors.append("proof.json does not match core docs/public-proof.json") + + for name in ASSETS: + site_asset = ROOT / "assets" / name + core_asset = core / "docs/assets/brand" / name + if not core_asset.is_file(): + errors.append(f"missing core brand asset: {core_asset}") + elif site_asset.read_bytes() != core_asset.read_bytes(): + errors.append(f"assets/{name} does not match the generated core asset") + + if errors: + print("Core synchronization failed:") + for error in errors: + print(f"- {error}") + return 1 + print("Public proof and generated brand assets match the core repository.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/styles.css b/styles.css index 1c5d976..e4fb42d 100644 --- a/styles.css +++ b/styles.css @@ -456,7 +456,7 @@ h1 { margin-bottom: var(--s4) } flex: 0 0 auto; display: grid; place-items: center; - width: 40px; height: 40px; + width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); @@ -601,7 +601,7 @@ h1 { margin-bottom: var(--s4) } display: inline-flex; align-items: center; gap: 7px; - min-height: 40px; + min-height: 44px; padding: 0 var(--s2); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -1013,7 +1013,7 @@ h1 { margin-bottom: var(--s4) } .playground-choice { position: relative; z-index: 1; - min-height: 38px; + min-height: 44px; padding: 0 var(--s2); border: 0; border-radius: 7px; @@ -1776,10 +1776,10 @@ h1 { margin-bottom: var(--s4) } .modal-close { position: sticky; top: 0; - flex: 0 0 40px; + flex: 0 0 44px; display: grid; place-items: center; - width: 40px; height: 40px; + width: 44px; height: 44px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); @@ -1859,7 +1859,7 @@ h1 { margin-bottom: var(--s4) } } .copy-button { flex: 0 0 auto; - min-height: 36px; + min-height: 44px; padding: 0 var(--s2); border: 1px solid var(--border); border-radius: 6px; @@ -2118,7 +2118,7 @@ body.modal-open { overflow: hidden } .setup-panel { padding: var(--s3) } .command-line { flex-direction: column; align-items: stretch; padding: var(--s2) } .command-line .install-prompt { display: none } - .copy-button { width: 100%; min-height: 40px } + .copy-button { width: 100% } .footer-inner { flex-direction: column; align-items: flex-start; gap: var(--s3); padding: var(--s5) 0 } } @@ -2151,6 +2151,20 @@ body.modal-open { overflow: hidden } .playground.is-running .playground-pulse { opacity: 1 } .playground.is-running .playground-edge .rail-live { transform: scaleX(1) } .playground.is-running.is-candidate .playground-edge .rail-live { transform: scaleX(var(--break-stop)) } - .playground.is-running.is-candidate .playground-break { opacity: 1 } + .playground.is-running.is-candidate .playground-break { + opacity: 1; + transition: none !important; + } + .playground.is-running.is-candidate .playground-edge-impact .rail { + opacity: .78; + animation: none !important; + } .button::after { display: none } } + +@media (max-width: 700px) and (prefers-reduced-motion: reduce) { + .playground.is-running .playground-edge .rail-live { transform: scaleY(1) } + .playground.is-running.is-candidate .playground-edge .rail-live { + transform: scaleY(var(--break-stop)); + } +}