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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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)"
7 changes: 7 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
117 changes: 110 additions & 7 deletions field.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -474,22 +528,63 @@
}
};

// 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.
const dt = Math.min(0.05, (now - last) / 1000);
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 = () => {
Expand Down Expand Up @@ -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 --- */
Expand All @@ -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 });

Expand Down Expand Up @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,8 @@ <h1 id="hero-title"><span class="line">Your schema passed.</span><span class="li
<div class="shell">
<div class="proof-intro"><p class="eyebrow"><i aria-hidden="true"></i>Measured technical proof</p><h2 id="proof-title">Small surface.<br>Serious checks.</h2><p>Current branch measurements, linked to the source that enforces them.</p></div>
<dl class="proof-grid">
<div><dt>Tests</dt><dd><strong data-proof="tests">112</strong><small>passing locally</small></dd></div>
<div><dt>Coverage</dt><dd><strong data-proof="coverage">92.94%</strong><small>85% CI floor</small></dd></div>
<div><dt>Tests</dt><dd><strong data-proof="tests">115</strong><small>passing locally</small></dd></div>
<div><dt>Coverage</dt><dd><strong data-proof="coverage">93.08%</strong><small>85% CI floor</small></dd></div>
<div><dt>Python CI</dt><dd><strong data-proof="python">3.12 · 3.13</strong><small>both required</small></dd></div>
<div><dt>Evaluators</dt><dd><strong data-proof="evaluators">7 types</strong><small>deterministic registry</small></dd></div>
<div><dt>Runtime</dt><dd><strong>Local-first</strong><small>no required API key</small></dd></div>
Expand Down
4 changes: 2 additions & 2 deletions proof.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"tests": 112,
"coverage_percent": 92.94,
"tests": 115,
"coverage_percent": 93.08,
"python_versions": [
"3.12",
"3.13"
Expand Down
53 changes: 53 additions & 0 deletions scripts/check_core_sync.py
Original file line number Diff line number Diff line change
@@ -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())
30 changes: 22 additions & 8 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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));
}
}