diff --git a/scripts/brand/directions.py b/scripts/brand/directions.py new file mode 100644 index 0000000..140441d --- /dev/null +++ b/scripts/brand/directions.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Generates the Phase 1 logo concepts for the Entangle site, one per brand direction. + + python3 scripts/brand/directions.py + +Writes into site/assets/logo/ (committed): + -mark.svg the symbol alone on a 48 x 32 grid + -lockup.svg symbol + "entangle" wordmark (Inter 600, from the site's own font) + +Standard library only. Every shape is computed here so a chosen direction can be refined by +changing a constant and re-running, never by hand-editing paths. Colors are the direction's +palette tokens in site/styles.css; keep the two in step. +""" + +import math +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "site/assets/logo" + +# Palette tokens, mirrored from site/styles.css ([data-direction="…"]). +PALETTES = { + "singlet": {"ink": "#1A1A1F", "a": "#C8431F", "b": "#176B6B"}, + "spacelike": {"ink": "#ECEBE6", "a": "#F0B23A", "b": "#F0B23A"}, + "swap": {"ink": "#EFEBE0", "a": "#D7935B", "b": "#D7935B"}, + "bound": {"ink": "#0F1B1E", "a": "#0F7A5A", "b": "#9AA7AC"}, + "quadrant": {"ink": "#F1ECE2", "a": "#E9A23B", "b": "#F4E8D2"}, + "crossing": {"ink": "#EDEEEA", "a": "#D9532F", "b": "#F6D4BE"}, + "collapse": {"ink": "#17171B", "a": "#B8341C", "b": "#1C6E6B"}, +} + + +def f(v): + return ("%.2f" % v).rstrip("0").rstrip(".") + + +def half_disc(cx, cy, r, angle_deg, side, fill): + """One half of a disc, cut by a diameter at angle_deg; side = +1 or -1 picks the half.""" + a = math.radians(angle_deg) + dx, dy = math.cos(a) * r, math.sin(a) * r + x1, y1 = cx + dx, cy + dy + x2, y2 = cx - dx, cy - dy + sweep = 1 if side > 0 else 0 + return '' % ( + f(x1), f(y1), f(r), f(r), sweep, f(x2), f(y2), fill) + + +def singlet(p): + """Two particles in a spin singlet: measured along the same axis they are always opposite. + Each disc is cut by the same measurement axis; the filled halves are on opposite sides.""" + r, ang = 10, -32 + out = [] + for cx, side, col in ((13, +1, p["a"]), (35, -1, p["b"])): + out.append('' % (cx, r, p["ink"])) + out.append(half_disc(cx, 16, r - 0.75, ang, side, col)) + return "\n".join(out) + + +def spacelike(p): + """Two events outside each other's light cone. The cone is the X of a spacetime diagram + (time up); the two dots sit on one time slice, where no signal can have passed between them.""" + out = ['' % p["ink"]] + for cx in (5, 43): + out.append('' % (cx, p["a"])) + return "\n".join(out) + + +def swap(p): + """Entanglement swapping: two short-range pairs (A-R, R-C) become one long-range pair (A-C) + after a measurement at R, which is left holding nothing. The repeater in one glyph.""" + y = 22 + out = [] + out.append('' % (y, y, y, p["ink"])) + out.append('' % (y, y, p["a"])) + out.append('' % (y, p["a"])) + out.append('' % (y, p["a"])) + out.append('' % (y, p["ink"])) + return "\n".join(out) + + +def bound(p): + """Bell's theorem as a plot: the quantum correlation (a cosine) rising above the straight + line that is the most any local hidden-variable model can reach.""" + out = [] + out.append('' % p["b"]) + # -cos sampled; a smooth cosine from 5 to 43 peaking at 24. + pts = [] + for i in range(0, 41): + t = i / 40 + x = 5 + 38 * t + y = 27 - 23 * (0.5 - 0.5 * math.cos(2 * math.pi * t)) + pts.append("%s%s %s" % ("M" if i == 0 else "L", f(x), f(y))) + out.append('' % (" ".join(pts), p["a"])) + return "\n".join(out) + + +def _hash(k): + """Deterministic pseudo-random in [0, 1); same formula as site/directions.js hash().""" + x = math.sin(k * 12.9898 + 78.233) * 43758.5453 + return x - math.floor(x) + + +def quadrant(p): + """The Glasgow 2019 Bell image: a ring whose four quadrants carry phase-shifted patterns. + Drawn as four quadrant arcs at alternating radii, the shift between settings made literal.""" + out = ['' % p["ink"]] + radii = [11.5, 9.5, 11.5, 9.5] + for q in range(4): + a0, a1, r = q * math.pi / 2 + 0.12, (q + 1) * math.pi / 2 - 0.12, radii[q] + out.append('' + % (f(24 + r * math.cos(a0)), f(16 + r * math.sin(a0)), f(r), f(r), + f(24 + r * math.cos(a1)), f(16 + r * math.sin(a1)), p["b"] if q % 2 else p["a"])) + return "\n".join(out) + + +def crossing(p): + """Type-II SPDC: two offset rings of down-converted light; the entangled pairs are born + where the rings cross, so the two crossing points are the only filled shapes.""" + y = math.sqrt(10 ** 2 - 6 ** 2) + return "\n".join([ + '' % p["a"], + '' % p["a"], + '' % (f(16 - y), p["b"]), + '' % (f(16 + y), p["b"]), + ]) + + +def collapse(p): + """Two probability clouds as stippled densities, already collapsed to complementary + outcomes: the left cloud sits high, the right one low. No line joins them.""" + out = [] + for side in range(2): + cx, col = (34 if side else 14), (p["b"] if side else p["a"]) + for i in range(26): + r = math.sqrt(_hash(side * 50 + i + 0.2)) * 9 + th = _hash(side * 50 + i + 0.7) * math.pi * 2 + out.append('' + % (f(cx + math.cos(th) * r), f(16 + math.sin(th) * r * 0.75 + (4 if side else -4)), + f(0.9 + _hash(i + side) * 0.9), col)) + return "\n".join(out) + + +MARKS = {"singlet": singlet, "spacelike": spacelike, "swap": swap, "bound": bound, + "quadrant": quadrant, "crossing": crossing, "collapse": collapse} + + +def svg(w, h, body, extra=""): + return ('\n%s\n\n' + % (w, h, w, h, extra, body)) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + for name, fn in MARKS.items(): + p = PALETTES[name] + mark = fn(p) + (OUT / ("%s-mark.svg" % name)).write_text(svg(48, 32, mark, ' role="img" aria-label="Entangle"')) + word = ('entangle' % p["ink"]) + (OUT / ("%s-lockup.svg" % name)).write_text(svg(152, 32, mark + "\n" + word, ' role="img" aria-label="Entangle"')) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/site/README.md b/site/README.md new file mode 100644 index 0000000..91b3eb3 --- /dev/null +++ b/site/README.md @@ -0,0 +1,40 @@ +# Entangle website + +Static site for https://daileng.github.io/entangle/ (GitHub Pages, `/entangle/` base path; all +asset references are relative so a custom domain needs no changes). No build step, no framework, +no third-party requests: Inter is self-hosted from `assets/fonts/`, Lucide glyphs are inlined in +`index.html`. + +Type scale, spacing, radii and icon rules follow the tachyon design system +(DailenG/tachyon, `docs/design/system/`). The palette and the mark are Entangle's own. + +## Phase 1: directions + +`index.html` currently shows four switchable brand directions. Switching changes the palette +(`html[data-direction]` in `styles.css`), the header mark, the hero copy and the hero animation +(`directions.js`). Desktop: the labelled "Direction" pill in the bottom-right corner. Phones: the +pill under the hero visual, or tap the visual. Keys `1`–`7` switch, `Q` toggles quarter speed. First load opens on `quadrant` (the working lead); a returning visitor gets their last pick. +Query parameters: `?d=&speed=quarter`, plus `&t=&picker=open` for captures. + +| # | id | Physics | Palette (role: hex) | +|---|----|---------|---------------------| +| 1 | `singlet` | Spin singlet: the same axis measured on both particles always gives opposite results | bg `#F5F1E8`, ink `#1A1A1F`, body `#44454D`, muted `#6B6C75`, A `#C8431F`, B `#176B6B` | +| 2 | `spacelike` | Minkowski diagram: two simultaneous outcomes whose light cones have not met | bg `#0F1115`, ink `#ECEBE6`, body `#B4B6BC`, muted `#868A93`, trace `#F0B23A` | +| 3 | `swap` | Entanglement swapping: a Bell measurement at the repeater entangles A and C, which never met | bg `#1F2B27`, chalk `#EFEBE0`, body `#C7C3B7`, muted `#9A988D`, copper `#D7935B`, slate `#8FB8C9` | +| 4 | `bound` | Bell's inequality: measured correlation E(θ) rising above the local-realist straight line | bg `#F2F5F6`, ink `#0F1B1E`, body `#3C4A4F`, muted `#627179`, trace `#0F7A5A`, bound `#9AA7AC` | +| 5 | `quadrant` | The Glasgow 2019 Bell image (Moreau et al.): photon A's interference pattern, built from single detections, shifts in each quadrant with the phase filter applied to photon B | bg `#121113`, ink `#F1ECE2`, body `#BDB7AC`, muted `#8E887E`, amber `#E9A23B`, warm white `#F4E8D2` | +| 6 | `crossing` | Type-II SPDC (Kwiat et al. 1995): two offset rings of down-converted light; the polarisation-entangled pairs are born where the rings cross | bg `#14181B`, ink `#EDEEEA`, body `#B7BCBB`, muted `#87908F`, ember `#D9532F`, peach `#F6D4BE` | +| 7 | `collapse` | Two probability clouds that fluctuate independently and collapse in the same frame to complementary outcomes; nothing travels between them | bg `#F4F0E7`, ink `#17171B`, body `#44454C`, muted `#6D6E76`, vermilion `#B8341C`, teal `#1C6E6B` | + +Marks are generated by `scripts/brand/directions.py` into `assets/logo/`; the header draws the +same geometry inline so it recolors with the direction. + +Motion respects `prefers-reduced-motion` (a still frame is drawn instead) and pauses when the +canvas is off-screen or the tab is hidden. + +## Local preview + +```sh +mkdir -p /tmp/srv && ln -sfn "$PWD/site" /tmp/srv/entangle && (cd /tmp/srv && python3 -m http.server 8765) +# open http://localhost:8765/entangle/ +``` diff --git a/site/assets/fonts/Inter-LICENSE.txt b/site/assets/fonts/Inter-LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/site/assets/fonts/Inter-LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/site/assets/fonts/inter-latin.woff2 b/site/assets/fonts/inter-latin.woff2 new file mode 100644 index 0000000..d15208d Binary files /dev/null and b/site/assets/fonts/inter-latin.woff2 differ diff --git a/site/assets/icons/Lucide-LICENSE.txt b/site/assets/icons/Lucide-LICENSE.txt new file mode 100644 index 0000000..325e8ff --- /dev/null +++ b/site/assets/icons/Lucide-LICENSE.txt @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/site/assets/logo/bound-lockup.svg b/site/assets/logo/bound-lockup.svg new file mode 100644 index 0000000..d9d7382 --- /dev/null +++ b/site/assets/logo/bound-lockup.svg @@ -0,0 +1,5 @@ + + + +entangle + diff --git a/site/assets/logo/bound-mark.svg b/site/assets/logo/bound-mark.svg new file mode 100644 index 0000000..17e58d6 --- /dev/null +++ b/site/assets/logo/bound-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/site/assets/logo/collapse-lockup.svg b/site/assets/logo/collapse-lockup.svg new file mode 100644 index 0000000..1cc0d1a --- /dev/null +++ b/site/assets/logo/collapse-lockup.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +entangle + diff --git a/site/assets/logo/collapse-mark.svg b/site/assets/logo/collapse-mark.svg new file mode 100644 index 0000000..678b534 --- /dev/null +++ b/site/assets/logo/collapse-mark.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/site/assets/logo/crossing-lockup.svg b/site/assets/logo/crossing-lockup.svg new file mode 100644 index 0000000..4240ca8 --- /dev/null +++ b/site/assets/logo/crossing-lockup.svg @@ -0,0 +1,7 @@ + + + + + +entangle + diff --git a/site/assets/logo/crossing-mark.svg b/site/assets/logo/crossing-mark.svg new file mode 100644 index 0000000..64f2586 --- /dev/null +++ b/site/assets/logo/crossing-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/site/assets/logo/quadrant-lockup.svg b/site/assets/logo/quadrant-lockup.svg new file mode 100644 index 0000000..9566405 --- /dev/null +++ b/site/assets/logo/quadrant-lockup.svg @@ -0,0 +1,8 @@ + + + + + + +entangle + diff --git a/site/assets/logo/quadrant-mark.svg b/site/assets/logo/quadrant-mark.svg new file mode 100644 index 0000000..ee32e04 --- /dev/null +++ b/site/assets/logo/quadrant-mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/site/assets/logo/singlet-lockup.svg b/site/assets/logo/singlet-lockup.svg new file mode 100644 index 0000000..d9030a7 --- /dev/null +++ b/site/assets/logo/singlet-lockup.svg @@ -0,0 +1,7 @@ + + + + + +entangle + diff --git a/site/assets/logo/singlet-mark.svg b/site/assets/logo/singlet-mark.svg new file mode 100644 index 0000000..15d453e --- /dev/null +++ b/site/assets/logo/singlet-mark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/site/assets/logo/spacelike-lockup.svg b/site/assets/logo/spacelike-lockup.svg new file mode 100644 index 0000000..5edcdd6 --- /dev/null +++ b/site/assets/logo/spacelike-lockup.svg @@ -0,0 +1,6 @@ + + + + +entangle + diff --git a/site/assets/logo/spacelike-mark.svg b/site/assets/logo/spacelike-mark.svg new file mode 100644 index 0000000..c168a8a --- /dev/null +++ b/site/assets/logo/spacelike-mark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/site/assets/logo/swap-lockup.svg b/site/assets/logo/swap-lockup.svg new file mode 100644 index 0000000..a782c93 --- /dev/null +++ b/site/assets/logo/swap-lockup.svg @@ -0,0 +1,8 @@ + + + + + + +entangle + diff --git a/site/assets/logo/swap-mark.svg b/site/assets/logo/swap-mark.svg new file mode 100644 index 0000000..5b466a0 --- /dev/null +++ b/site/assets/logo/swap-mark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/site/directions.js b/site/directions.js new file mode 100644 index 0000000..6d3420c --- /dev/null +++ b/site/directions.js @@ -0,0 +1,713 @@ +/* Entangle website, Phase 1: four switchable brand directions. + Switching changes html[data-direction] (palette), the header mark, the hero copy and the hero + animation. Picker: fixed card on desktop; pill under the hero visual, or a tap on the visual, + opens a sheet on narrow screens. Speed: normal or quarter. Reduced motion: a still frame. + Query: ?d=&speed=quarter, plus ?t=&picker=open for previews. Plain script, no dependencies. */ +(function () { + "use strict"; + + var DIRECTIONS = [ + { + id: "singlet", + name: "Singlet", + desc: "Paper and ink. Two spins measured on the same axis: always opposite.", + eyebrow: "Local-first MCP · Agent to agent", + title: "Two agents, one state.", + lead: "Entangle lets AI agents on your network find each other, join a group with one short code, and share files and JSON over end-to-end encrypted links. Nothing in between can read them.", + mark: markSinglet, + anim: AnimSinglet + }, + { + id: "spacelike", + name: "Spacelike", + desc: "Dark bench, amber trace. Two events no signal could connect.", + eyebrow: "Encrypted agent-to-agent sync", + title: "Same state. Any distance.", + lead: "Agents on your LAN discover each other over mDNS; agents on different networks meet through a repeater you host. Either way the link is Noise-encrypted end to end.", + mark: markSpacelike, + anim: AnimSpacelike + }, + { + id: "swap", + name: "Swap", + desc: "Chalkboard and copper. Entanglement swapping, the repeater's trick.", + eyebrow: "MCP server for agent collaboration", + title: "Entangle your agents.", + lead: "Discovery on the LAN, one short code typed by a human, then files and JSON flow between agents over an encrypted link, direct or through a blind Quantum Repeater you run yourself.", + mark: markSwap, + anim: AnimSwap + }, + { + id: "bound", + name: "Bound", + desc: "Lab notebook. Bell's inequality, violated live, one pair at a time.", + eyebrow: "Agent-to-agent, measured", + title: "Share state, not secrets.", + lead: "An open-source Rust MCP server: agents find each other over mDNS, devices join a group with one short code, and every sync is Noise-encrypted and SHA-256 verified.", + mark: markBound, + anim: AnimBound + }, + { + id: "quadrant", + name: "Quadrant", + desc: "Dark room, amber ring. The 2019 Bell image: photon A's pattern follows photon B's filter.", + eyebrow: "Encrypted agent-to-agent sync", + title: "One state, seen twice.", + lead: "Agents on your LAN discover each other over mDNS; agents on different networks meet through a repeater you host. Either way the link is Noise-encrypted end to end, and what one agent holds, the other can observe.", + mark: markQuadrant, + anim: AnimQuadrant + }, + { + id: "crossing", + name: "Crossing", + desc: "Deep slate, ember rings. Type-II down-conversion: the pairs live where the rings cross.", + eyebrow: "MCP server for agent collaboration", + title: "Where two agents meet.", + lead: "Discovery on the LAN, one short code typed by a human, then files and JSON flow between agents over an encrypted link, direct or through a blind Quantum Repeater you run yourself.", + mark: markCrossing, + anim: AnimCrossing + }, + { + id: "collapse", + name: "Collapse", + desc: "Paper, vermilion and teal. Two probability clouds; one measurement, one frame, no beam.", + eyebrow: "Local-first MCP · Agent to agent", + title: "Same state. Any distance.", + lead: "Entangle lets AI agents find each other, join a group with one short code, and share files and JSON over end-to-end encrypted links. Nothing travels through anyone who could read it.", + mark: markCollapse, + anim: AnimCollapse + } + ]; + + var root = document.documentElement; + var canvas = document.getElementById("hero-canvas"); + var caption = document.getElementById("hero-caption"); + var figure = canvas && canvas.parentNode; + var markSlot = document.querySelector(".brand-mark"); + var copyEls = { eyebrow: q("eyebrow"), title: q("title"), lead: q("lead") }; + function q(k) { return document.querySelector('[data-copy="' + k + '"]'); } + + var reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + var params = new URLSearchParams(location.search); + var store = safeStorage(); + var speed = (params.get("speed") || store.get("entangle.speed") || "normal") === "quarter" ? 0.25 : 1; + var current = null, anim = null, raf = 0, last = 0, simTime = 0, visible = true, inView = true; + + // ---------- marks (header); same geometry as scripts/brand/directions.py ---------- + function halfDisc(cx, cy, r, deg, side, fill) { + var a = deg * Math.PI / 180, dx = Math.cos(a) * r, dy = Math.sin(a) * r; + return ''; + } + function markSinglet(c) { + return '' + halfDisc(13, 16, 9.25, -32, 1, c.a) + + '' + halfDisc(35, 16, 9.25, -32, -1, c.b); + } + function markQuadrant(c) { + var s = '', rs = [11.5, 9.5, 11.5, 9.5]; + for (var q = 0; q < 4; q++) { + var a0 = q * Math.PI / 2 + 0.12, a1 = (q + 1) * Math.PI / 2 - 0.12, r = rs[q]; + s += ''; + } + return s; + } + function markCrossing(c) { + var y = Math.sqrt(10 * 10 - 6 * 6); + return '' + + ''; + } + function markCollapse(c) { + var s = ""; + for (var side = 0; side < 2; side++) { + var cx = side ? 34 : 14, col = side ? c.b : c.a; + for (var i = 0; i < 26; i++) { + var r = Math.sqrt(hash(side * 50 + i + 0.2)) * 9, th = hash(side * 50 + i + 0.7) * Math.PI * 2; + s += ''; + } + } + return s; + } + function markSpacelike(c) { + return '' + + ''; + } + function markSwap(c) { + return '' + + '' + + '' + + ''; + } + function markBound(c) { + var d = ""; + for (var i = 0; i <= 40; i++) { var t = i / 40; d += (i ? "L" : "M") + (5 + 38 * t).toFixed(2) + " " + (27 - 23 * (0.5 - 0.5 * Math.cos(2 * Math.PI * t))).toFixed(2) + " "; } + return '' + + ''; + } + + // ---------- helpers ---------- + function safeStorage() { + try { var s = window.localStorage; s.setItem("entangle.t", "1"); s.removeItem("entangle.t"); return { get: function (k) { return s.getItem(k); }, set: function (k, v) { s.setItem(k, v); } }; } + catch (e) { var m = {}; return { get: function (k) { return m[k] || null; }, set: function (k, v) { m[k] = v; } }; } + } + function tokens() { + var cs = getComputedStyle(root); + function v(n) { return cs.getPropertyValue(n).trim(); } + return { bg: v("--bg"), surface: v("--surface"), sunken: v("--sunken"), line: v("--line"), lineStrong: v("--line-strong"), ink: v("--ink"), body: v("--body"), muted: v("--muted"), a: v("--a"), b: v("--b") }; + } + function hash(k) { var x = Math.sin(k * 12.9898 + 78.233) * 43758.5453; return x - Math.floor(x); } + function ease(t) { t = Math.min(1, Math.max(0, t)); return 1 - Math.pow(1 - t, 3); } + function clamp01(t) { return Math.min(1, Math.max(0, t)); } + function lerp(a, b, t) { return a + (b - a) * t; } + function withAlpha(hex, a) { + var h = hex.replace("#", ""); if (h.length === 3) h = h.replace(/./g, function (ch) { return ch + ch; }); + var n = parseInt(h, 16); return "rgba(" + (n >> 16 & 255) + "," + (n >> 8 & 255) + "," + (n & 255) + "," + a + ")"; + } + function mono(px) { return (px || 12) + 'px ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'; } + function sans(px, w) { return (w || 500) + " " + px + 'px Inter, ui-sans-serif, system-ui, sans-serif'; } + function pad(n, w) { var s = String(n); while (s.length < w) s = "0" + s; return s; } + + // ---------- 1. Singlet: repeated spin measurements on a Bell pair ---------- + function AnimSinglet(ctx, c) { + this.T = 3.2; + this.draw = function (w, h, t, still) { + var k = Math.floor(t / this.T), u = (t - k * this.T) / this.T; + var angPrev = hash(k - 1) * 180 - 90, ang = hash(k) * 180 - 90; + var s = hash(k + 0.5) > 0.5 ? 1 : -1; + if (still) { u = 0.6; } + var rot = lerp(angPrev, ang, ease(u / 0.16)); + var fill = u < 0.16 ? 0 : u < 0.22 ? ease((u - 0.16) / 0.06) : u < 0.9 ? 1 : 1 - ease((u - 0.9) / 0.1); + var R = Math.min(w, h) * 0.2, cy = h * 0.44, ax = w * 0.3, bx = w * 0.7; + ctx.clearRect(0, 0, w, h); + // common measurement axis, drawn as a hairline across both + ctx.save(); ctx.strokeStyle = c.lineStrong; ctx.lineWidth = 1; ctx.setLineDash([3, 5]); + var a = rot * Math.PI / 180, L = Math.min(w, h) * 0.46; + [ax, bx].forEach(function (cx) { + ctx.beginPath(); ctx.moveTo(cx - Math.cos(a) * L / 2, cy - Math.sin(a) * L / 2); ctx.lineTo(cx + Math.cos(a) * L / 2, cy + Math.sin(a) * L / 2); ctx.stroke(); + }); + ctx.restore(); + [[ax, 1, c.a], [bx, -1, c.b]].forEach(function (d) { + var cx = d[0], side = d[1] * s, col = d[2]; + ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = c.ink; ctx.stroke(); + if (fill > 0) { + ctx.save(); ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.clip(); + ctx.translate(cx, cy); ctx.rotate(a); + ctx.globalAlpha = fill; ctx.fillStyle = col; + ctx.fillRect(-R - 1, side > 0 ? -R - 1 : 0, 2 * R + 2, R + 1); + ctx.restore(); + } + }); + ctx.fillStyle = c.muted; ctx.font = sans(12, 600); ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; + ctx.fillText("A", ax, cy + R + 24); ctx.fillText("B", bx, cy + R + 24); + ctx.font = mono(11.5); ctx.fillText("axis " + pad(Math.round(rot + 90), 3) + "\u00b0", w / 2, cy - R - 10); + // ledger of recent outcomes + var n = Math.min(k + 1, 12), y0 = h * 0.87, gap = Math.min(36, (w - 48) / 12); + var x0 = w / 2 - (n - 1) * gap / 2; + ctx.font = mono(15); ctx.textAlign = "center"; + for (var i = 0; i < n; i++) { + var kk = k - n + 1 + i, so = hash(kk + 0.5) > 0.5 ? 1 : -1, alpha = kk === k ? fill : 1; + if (kk < 0) continue; + ctx.globalAlpha = alpha; ctx.fillStyle = c.a; ctx.fillText(so > 0 ? "\u2191" : "\u2193", x0 + i * gap - 6, y0); + ctx.fillStyle = c.b; ctx.fillText(so > 0 ? "\u2193" : "\u2191", x0 + i * gap + 6, y0); + } + ctx.globalAlpha = 1; + return "trial " + pad(k + 1, 4) + " \u00b7 axis " + pad(Math.round(ang + 90), 3) + "\u00b0 \u00b7 A " + (s > 0 ? "\u2191" : "\u2193") + " B " + (s > 0 ? "\u2193" : "\u2191") + " \u00b7 agreements 0 / " + (k + 1); + }; + } + + // ---------- 2. Spacelike: a Minkowski diagram; two simultaneous outcomes, light cones still on their way ---------- + function AnimSpacelike(ctx, c) { + this.P = 8; + this.draw = function (w, h, t, still) { + var narrow = w < 700; + var X = narrow ? w * 0.44 : Math.min(w * 0.39, 560), xc = w / 2; + var base = h * 0.8, spd = X / 5.5; // light crosses the midpoint 5.5 s after the pair fires + if (still) t = 4.5; + ctx.clearRect(0, 0, w, h); + // grid: equal spacing in x and t so light moves at 45 degrees + var unit = X / 4; + ctx.strokeStyle = c.line; ctx.lineWidth = 1; ctx.beginPath(); + for (var y = base; y > -1; y -= unit) { ctx.moveTo(0, y); ctx.lineTo(w, y); } + for (var y2 = base + unit; y2 < h; y2 += unit) { ctx.moveTo(0, y2); ctx.lineTo(w, y2); } + for (var x = xc % unit; x < w; x += unit) { ctx.moveTo(x, 0); ctx.lineTo(x, h); } + ctx.stroke(); + // worldlines of the two machines + ctx.strokeStyle = c.lineStrong; ctx.lineWidth = 1.5; ctx.beginPath(); + ctx.moveTo(xc - X, 0); ctx.lineTo(xc - X, h); ctx.moveTo(xc + X, 0); ctx.lineTo(xc + X, h); ctx.stroke(); + ctx.fillStyle = c.muted; ctx.font = mono(12); ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; + ctx.fillText("A", xc - X, 22); ctx.fillText("B", xc + X, 22); + if (!narrow) { ctx.textAlign = "right"; ctx.fillText("x \u2192", w - 20, h - 18); ctx.fillText("t \u2191", w - 20, h - 36); } + var kNow = Math.floor(t / this.P), out = ""; + for (var k = kNow; k >= kNow - 1; k--) { + if (k < 0) continue; + var age = t - k * this.P, reach = age * spd; + var alpha = k === kNow ? Math.min(1, age / 0.4) : Math.max(0, 1 - age / this.P - 0) * 0; + if (k !== kNow) { alpha = Math.max(0, 1 - (age - this.P) / 1.2); } + if (alpha <= 0) continue; + var s = hash(k + 0.5) > 0.5 ? 1 : -1; + ctx.save(); ctx.globalAlpha = alpha; + // light cones opening upward from each event + ctx.strokeStyle = withAlpha(c.a, 0.6); ctx.lineWidth = 1; ctx.beginPath(); + ctx.moveTo(xc - X, base); ctx.lineTo(xc - X + reach, base - reach); ctx.moveTo(xc - X, base); ctx.lineTo(xc - X - reach, base - reach); + ctx.moveTo(xc + X, base); ctx.lineTo(xc + X - reach, base - reach); ctx.moveTo(xc + X, base); ctx.lineTo(xc + X + reach, base - reach); + ctx.stroke(); + // the same instant in this frame: a dotted line between the two events + ctx.setLineDash([2, 6]); ctx.strokeStyle = c.lineStrong; ctx.beginPath(); ctx.moveTo(xc - X + 10, base); ctx.lineTo(xc + X - 10, base); ctx.stroke(); ctx.setLineDash([]); + // outcomes, always opposite + ctx.fillStyle = c.a; + [[xc - X, s], [xc + X, -s]].forEach(function (e) { + ctx.beginPath(); ctx.arc(e[0], base, 4.5, 0, Math.PI * 2); ctx.fill(); + ctx.font = mono(14); ctx.textAlign = "center"; ctx.fillText(e[1] > 0 ? "\u2191" : "\u2193", e[0] + (e[0] < xc ? -18 : 18), base + 5); + }); + // milestones: the two cones meet halfway; later, light from A arrives at B's worldline + if (reach >= X) { ctx.beginPath(); ctx.arc(xc, base - X, 3, 0, Math.PI * 2); ctx.fillStyle = c.a; ctx.fill(); } + if (reach >= 2 * X && base - 2 * X > 30) { ctx.beginPath(); ctx.arc(xc + X, base - 2 * X, 3.5, 0, Math.PI * 2); ctx.fillStyle = c.ink; ctx.fill(); } + ctx.restore(); + if (k === kNow) { + var pct = Math.min(100, Math.round(100 * reach / (2 * X))); + out = "pair " + pad(k + 1, 4) + " \u00b7 A " + (s > 0 ? "\u2191" : "\u2193") + " B " + (s > 0 ? "\u2193" : "\u2191") + " \u00b7 light from A is " + pct + "% of the way to B"; + } + } + var plate = copyRect(); if (plate && !narrow) { ctx.fillStyle = c.bg; ctx.fillRect(plate.x - 28, plate.y - 20, plate.w + 56, plate.h + 40); } + return out; + }; + } + + // ---------- 3. Swap: entanglement swapping, the repeater's one trick ---------- + function AnimSwap(ctx, c) { + this.T = 9; + function arc(ctx, x1, x2, y, lift, from, to) { + // quadratic arc sampled so it can be drawn partially + var cx = (x1 + x2) / 2, cy = y - lift, n = 48; ctx.beginPath(); + for (var i = 0; i <= n; i++) { + var u = from + (to - from) * i / n, mt = 1 - u; + var x = mt * mt * x1 + 2 * mt * u * cx + u * u * x2, yy = mt * mt * y + 2 * mt * u * cy + u * u * y; + if (i === 0) ctx.moveTo(x, yy); else ctx.lineTo(x, yy); + } + ctx.stroke(); + } + this.draw = function (w, h, t, still) { + var k = Math.floor(t / this.T), u = t - k * this.T; + if (still) u = 6; + var y = h * 0.6, ax = w * 0.12, rx = w / 2, cx = w * 0.88, r = Math.max(5, Math.min(w * 0.014, 9)); + var liftS = h * 0.42, liftL = h * 0.9; + var short = u < 1.6 ? ease(u / 1.6) : 1, shortA = u < 4.2 ? 1 : u < 5.2 ? 1 - ease((u - 4.2) / 1) : 0; + var measure = u >= 3 && u < 4.2 ? (u - 3) / 1.2 : -1; + var longDraw = u < 4.2 ? 0 : u < 5.4 ? ease((u - 4.2) / 1.2) : 1; + var fadeAll = u > 8.2 ? 1 - (u - 8.2) / 0.8 : 1; + ctx.clearRect(0, 0, w, h); + ctx.globalAlpha = fadeAll; + ctx.lineCap = "round"; + // baseline, chalk + ctx.strokeStyle = c.line; ctx.lineWidth = 1; ctx.setLineDash([1, 7]); ctx.beginPath(); ctx.moveTo(ax, y); ctx.lineTo(cx, y); ctx.stroke(); ctx.setLineDash([]); + // the two short pairs + if (shortA > 0 && short > 0) { + ctx.globalAlpha = fadeAll * shortA * 0.7; ctx.strokeStyle = c.ink; ctx.lineWidth = 1.5; + arc(ctx, ax, rx, y, liftS, 0, short); arc(ctx, cx, rx, y, liftS, 0, short); + ctx.globalAlpha = fadeAll; + } + // classical bits from the repeater to both ends + if (measure >= 0) { + var p = ease(measure), bx1 = lerp(rx, ax, p), bx2 = lerp(rx, cx, p); + ctx.fillStyle = c.ink; ctx.beginPath(); ctx.arc(bx1, y, 3, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(bx2, y, 3, 0, 7); ctx.fill(); + } + // the long pair + if (longDraw > 0) { ctx.strokeStyle = c.a; ctx.lineWidth = 2.25; arc(ctx, ax, cx, y, liftL, 0.5 - longDraw / 2, 0.5 + longDraw / 2); } + // nodes + ctx.fillStyle = c.a; ctx.beginPath(); ctx.arc(ax, y, r, 0, 7); ctx.fill(); ctx.beginPath(); ctx.arc(cx, y, r, 0, 7); ctx.fill(); + var rr = measure >= 0 ? r * (1 - 0.35 * Math.sin(measure * Math.PI)) : r; + ctx.strokeStyle = c.ink; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(rx, y, rr, 0, 7); ctx.stroke(); + if (u < 3) { ctx.fillStyle = withAlpha(c.ink, 0.35); ctx.beginPath(); ctx.arc(rx, y, rr * 0.55, 0, 7); ctx.fill(); } + // labels + ctx.fillStyle = c.muted; ctx.font = mono(12); ctx.textAlign = "center"; + ctx.fillText("A", ax, y + r + 22); ctx.fillText("repeater", rx, y + r + 22); ctx.fillText("C", cx, y + r + 22); + ctx.font = sans(13, 500); + var label = u < 3 ? "A\u2013R and R\u2013C are each entangled" : u < 4.2 ? "Bell-state measurement at R; two classical bits go to A and C" : u < 8.2 ? "A and C are now entangled; R holds nothing" : ""; + ctx.fillStyle = c.body; ctx.fillText(label, rx, h - 10); + ctx.globalAlpha = 1; + return "cycle " + pad(k + 1, 3) + " \u00b7 A\u2013R, R\u2013C \u2192 A\u2013C \u00b7 the repeater keeps two bits, never the state"; + }; + } + + // ---------- 4. Bound: Bell's inequality violated live ---------- + function AnimBound(ctx, c) { + var BINS = 36, RATE = 42, N_MAX = 1400, HOLD = 4; + this.draw = function (w, h, t, still) { + var cycle = N_MAX / RATE + HOLD, k = Math.floor(t / cycle), u = t - k * cycle; + if (still) u = N_MAX / RATE; + var n = Math.min(N_MAX, Math.floor(u * RATE)); + var fade = u > cycle - 0.6 ? (cycle - u) / 0.6 : 1; + var same = new Array(BINS).fill(0), tot = new Array(BINS).fill(0); + for (var i = 0; i < n; i++) { + var bin = Math.floor(hash(k * 7919 + i) * BINS), th = (bin + 0.5) / BINS * Math.PI; + tot[bin]++; if (hash(k * 7919 + i + 0.5) < Math.pow(Math.sin(th / 2), 2)) same[bin]++; + } + var narrow = w < 700, mL = narrow ? 44 : 56, mR = 24, mT = 36, mB = narrow ? 48 : 56; + var px = function (deg) { return mL + (w - mL - mR) * deg / 180; }, py = function (E) { return mT + (h - mT - mB) * (1 - E) / 2; }; + ctx.clearRect(0, 0, w, h); + ctx.globalAlpha = fade; + // axes and ticks + ctx.strokeStyle = c.line; ctx.lineWidth = 1; ctx.beginPath(); + [-1, -0.5, 0, 0.5, 1].forEach(function (E) { ctx.moveTo(mL, py(E)); ctx.lineTo(w - mR, py(E)); }); ctx.stroke(); + ctx.strokeStyle = c.lineStrong; ctx.beginPath(); ctx.moveTo(mL, py(0)); ctx.lineTo(w - mR, py(0)); ctx.moveTo(mL, mT); ctx.lineTo(mL, h - mB); ctx.stroke(); + ctx.fillStyle = c.muted; ctx.font = mono(11.5); ctx.textAlign = "center"; ctx.textBaseline = "alphabetic"; + [0, 45, 90, 135, 180].forEach(function (d) { ctx.fillText(d + "\u00b0", px(d), h - mB + 18); }); + ctx.textAlign = "right"; [-1, 0, 1].forEach(function (E) { ctx.fillText((E > 0 ? "+" : "") + E, mL - 8, py(E) + 4); }); + ctx.textAlign = "left"; ctx.fillText("E(\u03b8)", mL - 32, mT - 14); ctx.textAlign = "right"; ctx.fillText("\u03b8 between analyzers", w - mR, h - mB + 36); + // the local-realist limit: perfect anticorrelation at 0 forces a straight line + ctx.setLineDash([5, 5]); ctx.strokeStyle = c.b; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(px(0), py(-1)); ctx.lineTo(px(180), py(1)); ctx.stroke(); ctx.setLineDash([]); + // quantum prediction + ctx.strokeStyle = withAlpha(c.a, 0.35); ctx.lineWidth = 1.5; ctx.beginPath(); + for (var d = 0; d <= 180; d += 2) { var yq = py(-Math.cos(d * Math.PI / 180)); if (d === 0) ctx.moveTo(px(d), yq); else ctx.lineTo(px(d), yq); } + ctx.stroke(); + // measured points with error bars + for (var b = 0; b < BINS; b++) { + if (!tot[b]) continue; + var E = (2 * same[b] - tot[b]) / tot[b], se = Math.min(1, 1 / Math.sqrt(tot[b])), xd = (b + 0.5) / BINS * 180; + ctx.strokeStyle = c.a; ctx.lineWidth = 1.25; ctx.beginPath(); ctx.moveTo(px(xd), py(Math.max(-1, E - se))); ctx.lineTo(px(xd), py(Math.min(1, E + se))); ctx.stroke(); + ctx.fillStyle = c.a; ctx.beginPath(); ctx.arc(px(xd), py(E), 2.75, 0, 7); ctx.fill(); + } + // the latest pair, as a tick at +1 or -1 + if (n > 0 && n < N_MAX) { + var lb = Math.floor(hash(k * 7919 + n - 1) * BINS), lth = (lb + 0.5) / BINS * Math.PI, ls = hash(k * 7919 + n - 1 + 0.5) < Math.pow(Math.sin(lth / 2), 2) ? 1 : -1; + ctx.strokeStyle = c.ink; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(px((lb + 0.5) / BINS * 180), py(ls) - 5); ctx.lineTo(px((lb + 0.5) / BINS * 180), py(ls) + 5); ctx.stroke(); + } + // legend + ctx.font = sans(narrow ? 11.5 : 12.5, 500); ctx.textAlign = "left"; + var lx = px(8), ly = py(0.88); + var lw = Math.max(ctx.measureText("any local hidden-variable model").width, ctx.measureText("measured \u00b7 quantum: \u2212cos \u03b8").width) + 30; + ctx.fillStyle = c.surface; ctx.globalAlpha = fade * 0.92; ctx.fillRect(lx - 8, ly - 12, lw + 16, 46); ctx.globalAlpha = fade; + ctx.strokeStyle = c.a; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(lx, ly); ctx.lineTo(lx + 22, ly); ctx.stroke(); + ctx.fillStyle = c.body; ctx.fillText("measured \u00b7 quantum: \u2212cos \u03b8", lx + 30, ly + 4); + ctx.setLineDash([5, 5]); ctx.strokeStyle = c.b; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(lx, ly + 22); ctx.lineTo(lx + 22, ly + 22); ctx.stroke(); ctx.setLineDash([]); + ctx.fillText("any local hidden-variable model", lx + 30, ly + 26); + ctx.globalAlpha = 1; + var viol = 0; [8, 9, 26, 27].forEach(function (b) { if (tot[b] > 25) { var E = (2 * same[b] - tot[b]) / tot[b], lim = -1 + 2 * (b + 0.5) / BINS; if (Math.abs(E - lim) > 1 / Math.sqrt(tot[b]) && Math.abs(E) > Math.abs(lim)) viol++; } }); + return "n = " + n.toLocaleString("en-US") + " pairs \u00b7 " + BINS + " settings \u00b7 bound exceeded at " + viol + " of 4 checked angles"; + }; + } + + // ---------- shared: photon-count accumulation field (round 2) ---------- + // A low-resolution count buffer that single "photons" are drawn into by rejection + // sampling against an intensity function, then mapped through a 3-stop colour ramp. + // This is how the Glasgow (2019) and SPDC ring images are actually formed: many + // individual detections accumulating into a pattern, with real shot-noise grain. + function PhotonField(S, intensity, ramp) { + var self = this; + this.S = S; this.n = new Float32Array(S * S); this.total = 0; + this.off = document.createElement("canvas"); this.off.width = S; this.off.height = S; + this.octx = this.off.getContext("2d"); this.img = this.octx.createImageData(S, S); + this.add = function (count, phase) { + var S2 = self.S, added = 0, tries = count * 6; + for (var i = 0; i < tries && added < count; i++) { + var x = Math.random() * 2 - 1, y = Math.random() * 2 - 1; + if (Math.random() < intensity(x, y, phase)) { + var ix = (x * 0.5 + 0.5) * S2 | 0, iy = (y * 0.5 + 0.5) * S2 | 0; + self.n[iy * S2 + ix] += 1; added++; + } + } + self.total += added; + }; + this.decay = function (f) { var n = self.n; for (var i = 0; i < n.length; i++) n[i] *= f; }; + this.render = function (scale) { + var d = self.img.data, n = self.n; + for (var i = 0, j = 0; i < n.length; i++, j += 4) { + var v = n[i] / scale; if (v > 1) v = 1; + var k = v < 0.5 ? v * 2 : (v - 0.5) * 2, a = v < 0.5 ? ramp[0] : ramp[1], b = v < 0.5 ? ramp[1] : ramp[2]; + d[j] = a[0] + (b[0] - a[0]) * k; d[j + 1] = a[1] + (b[1] - a[1]) * k; d[j + 2] = a[2] + (b[2] - a[2]) * k; d[j + 3] = 255; + } + self.octx.putImageData(self.img, 0, 0); + return self.off; + }; + } + function rgb(hex) { return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; } + function mix(a, b, k) { return [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k]; } + function gauss(x, s) { return Math.exp(-(x * x) / (2 * s * s)); } + + // ---------- 5. Quadrant: the Glasgow 2019 Bell-image ring ---------- + // Moreau et al. imaged photon A with a camera while photon B passed a phase filter that + // was stepped through four settings. Each quadrant of the image shows photon A's + // interference pattern shifted by the phase chosen for B. Here the ring forms from + // individual detections; the quadrant phases follow B's rotating filter, and the + // pattern re-forms, one photon at a time, to match it. + function AnimQuadrant(ctx, c) { + var S = 224; + var ramp = [rgb(c.bg), mix(rgb(c.bg), rgb(c.a), 0.8), mix(rgb(c.b), [255, 255, 255], 0.25)]; + function intensity(x, y, phase) { + var r = Math.sqrt(x * x + y * y); if (r > 0.95 || r < 0.28) return 0; + var ring = gauss(r - 0.63, 0.1) * 0.85 + gauss(r - 0.63, 0.24) * 0.1; + var q = (x >= 0 ? (y < 0 ? 0 : 1) : (y < 0 ? 3 : 2)); + var phi = Math.atan2(y, x); + var ang = 0.5 + 0.5 * Math.cos(7 * phi + phase + q * Math.PI / 2); // angular fringes, phase-stepped per quadrant + var rad = 0.72 + 0.28 * Math.cos(r * 52 + q * Math.PI / 2); // fine radial fringes: the interference texture + return ring * (0.08 + 0.92 * ang * ang) * rad; + } + var field = new PhotonField(S, intensity, ramp), lastT = -1, seeded = false; + this.P = 24; + this.draw = function (w, h, t, still) { + var narrow = w < 700; + var B = ((t / 24) % 1) * Math.PI; // B's filter: half a turn every 24 s + var phase = 2 * B; + if (still) { if (!seeded) { for (var k = 0; k < 90; k++) field.add(2200, 0.6); seeded = true; } } + else { + var dt = lastT < 0 ? 0.016 : Math.min(0.1, Math.abs(t - lastT)); lastT = t; + field.decay(Math.pow(0.5, dt / 2.4)); + field.add(Math.round(1500 * (dt / 0.016)), phase); + } + var img = field.render(11); + ctx.clearRect(0, 0, w, h); + var size = Math.min(w, h) * (narrow ? 1.02 : 0.98), cx = w / 2, cy = h / 2; + ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "low"; + ctx.drawImage(img, cx - size / 2, cy - size / 2, size, size); + ctx.strokeStyle = withAlpha(c.ink, 0.22); ctx.lineWidth = 1; ctx.setLineDash([3, 5]); ctx.beginPath(); + ctx.moveTo(cx, cy - size * 0.47); ctx.lineTo(cx, cy + size * 0.47); ctx.moveTo(cx - size * 0.47, cy); ctx.lineTo(cx + size * 0.47, cy); ctx.stroke(); ctx.setLineDash([]); + var rr = narrow ? 14 : 18, bx = w - rr - 14, by = rr + 14; + ctx.strokeStyle = withAlpha(c.ink, 0.5); ctx.beginPath(); ctx.arc(bx, by, rr, 0, Math.PI * 2); ctx.stroke(); + ctx.strokeStyle = c.a; ctx.lineWidth = 1.5; ctx.beginPath(); + ctx.moveTo(bx - Math.cos(B) * rr, by - Math.sin(B) * rr); ctx.lineTo(bx + Math.cos(B) * rr, by + Math.sin(B) * rr); ctx.stroke(); + var deg = Math.round(B * 180 / Math.PI); + ctx.fillStyle = c.muted; ctx.font = mono(narrow ? 10 : 11); ctx.textAlign = "right"; ctx.textBaseline = "middle"; + ctx.fillText("B filter " + pad(deg, 3) + "°", bx - rr - 10, by); + return pad(Math.round(field.total / 1000), 3) + "k photons of A · B's filter at " + deg + "° · A's pattern follows it"; + }; + } + + // ---------- 6. Crossing: type-II SPDC rings ---------- + // Kwiat et al. 1995: a pump photon splits inside a BBO crystal into two photons of + // orthogonal polarisation, emitted on two cones. On a screen the cones are two offset + // rings; where they intersect a photon could belong to either ring, so its polarisation + // is undefined until its partner is measured. Pairs born at the two crossings are the + // polarisation-entangled ones. Each pair here lands in the same frame, no line between. + function AnimCrossing(ctx, c) { + var S = 224, R = 0.5, D = 0.32; + var ramp = [rgb(c.bg), mix(rgb(c.bg), rgb(c.a), 0.9), mix(rgb(c.b), [255, 255, 255], 0.15)]; + function intensity(x, y) { + var r1 = Math.sqrt((x + D) * (x + D) + y * y), r2 = Math.sqrt((x - D) * (x - D) + y * y); + var i = gauss(r1 - R, 0.028) + gauss(r2 - R, 0.028) + 0.18 * (gauss(r1 - R, 0.07) + gauss(r2 - R, 0.07)); + var phi1 = Math.atan2(y, x + D), phi2 = Math.atan2(y, x - D); + i *= 0.78 + 0.22 * Math.cos(11 * phi1 + 1.7) * Math.cos(9 * phi2); + return i * 0.72; + } + var field = new PhotonField(S, intensity, ramp), lastT = -1, seeded = false, pairs = [], nextPair = 0, count = 0; + var cy0 = Math.sqrt(R * R - D * D); + this.P = 8; + this.draw = function (w, h, t, still) { + var narrow = w < 700; + if (still) { if (!seeded) { for (var k = 0; k < 70; k++) field.add(2400, 0); seeded = true; pairs = [{ t: 0 }]; count = 1; } } + else { + var dt = lastT < 0 ? 0.016 : Math.min(0.1, Math.abs(t - lastT)); lastT = t; + field.decay(Math.pow(0.5, dt / 3.5)); + field.add(Math.round(1300 * (dt / 0.016)), 0); + if (t >= nextPair) { pairs.push({ t: t }); count++; nextPair = t + 0.55 + hash(count) * 0.9; } + pairs = pairs.filter(function (p) { return t - p.t < 1.6; }); + } + var img = field.render(9); + ctx.clearRect(0, 0, w, h); + var size = Math.min(w / 1.05, h / 0.78 * 1.15), cx = w / 2, cy = h / 2; + ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = "low"; + ctx.drawImage(img, cx - size / 2, cy - size / 2, size, size); + function fx(u) { return cx + u * size / 2; } function fy(v) { return cy + v * size / 2; } + for (var i = 0; i < pairs.length; i++) { + var age = still ? 0.15 : t - pairs[i].t, a = Math.max(0, 1 - age / 1.6), rad = 3 + age * 9; + ctx.strokeStyle = withAlpha(c.b, a * 0.9); ctx.lineWidth = 1.25; + ctx.beginPath(); ctx.arc(fx(0), fy(-cy0), rad, 0, Math.PI * 2); ctx.stroke(); + ctx.beginPath(); ctx.arc(fx(0), fy(cy0), rad, 0, Math.PI * 2); ctx.stroke(); + if (age < 0.25) { ctx.fillStyle = c.b; ctx.beginPath(); ctx.arc(fx(0), fy(-cy0), 2.2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(fx(0), fy(cy0), 2.2, 0, Math.PI * 2); ctx.fill(); } + } + ctx.fillStyle = c.muted; ctx.font = mono(narrow ? 10 : 11); ctx.textBaseline = "middle"; + if (!narrow) { + ctx.textAlign = "right"; ctx.fillText("H", fx(-D - R) - 8, fy(0)); + ctx.textAlign = "left"; ctx.fillText("V", fx(D + R) + 8, fy(0)); + } + return "pair " + pad(count, 4) + " · one photon at each crossing · which ring either came from is undefined, so the pair is entangled"; + }; + } + + // ---------- 7. Collapse: two probability clouds, one measurement ---------- + // Each cloud is a probability density drawn as a few thousand sample points that drift + // independently: no signal passes between them. On measurement both collapse in the same + // frame to complementary outcomes (one up, one down); then the state is prepared again. + // The drama is the simultaneous snap. Nothing crosses the gap. + function AnimCollapse(ctx, c) { + var N = 2400; + function cloud(seed) { + var pts = []; + for (var i = 0; i < N; i++) { + var r = Math.sqrt(-2 * Math.log(1 - hash(seed + i * 2.11 + 0.3))), th = hash(seed + i * 3.7 + 0.6) * Math.PI * 2; + var r2 = Math.sqrt(-2 * Math.log(1 - hash(seed + i * 5.3 + 0.9))), th2 = hash(seed + i * 7.1 + 0.2) * Math.PI * 2; + pts.push({ lobe: hash(seed + i * 1.37) < 0.5 ? -1 : 1, ox: r * Math.cos(th), oy: r * Math.sin(th), ax: r2 * Math.cos(th2), ay: r2 * Math.sin(th2), + ph: hash(seed + i * 9.9) * Math.PI * 2, f: 0.6 + hash(seed + i * 4.4) * 0.9, k: hash(seed + i * 6.6) < 0.5 ? 0 : 1 }); + } + return pts; + } + var A = cloud(11), Bc = cloud(29); + // particles are stamped from pre-rendered dot sprites: ~5k drawImage calls per frame is + // cheap where 5k arc fills are not (mid-range phone budget) + var sprites = {}; + function sprite(color, sz) { + var key = color + sz; if (sprites[key]) return sprites[key]; + var dpr = Math.min(2, window.devicePixelRatio || 1), px = Math.ceil(sz * 2 * dpr) + 2; + var cv = document.createElement("canvas"); cv.width = px; cv.height = px; + var g = cv.getContext("2d"); g.fillStyle = color; g.beginPath(); g.arc(px / 2, px / 2, sz * dpr, 0, Math.PI * 2); g.fill(); + sprites[key] = { cv: cv, css: px / dpr }; return sprites[key]; + } + this.P = 7; + this.draw = function (w, h, t, still) { + var narrow = w < 700, P = 7, u = ((t % P) + P) % P, n = Math.floor(t / P); + var outcome = hash(n + 0.5) < 0.5 ? 1 : -1; + var snap = u < 3 ? 0 : u < 3.18 ? ease((u - 3) / 0.18) : u < 5.2 ? 1 : u < 6.2 ? 1 - ease((u - 5.2) / 1.0) : 0; + if (still) { snap = 1; u = 4; } + ctx.clearRect(0, 0, w, h); + var lobeY = h * 0.19, sig = Math.min(w * 0.36, h) * 0.13; + var xs = [w * 0.27, w * 0.73], cols = [c.a, c.b]; + var step = narrow ? 2 : 1; // fewer samples on phones + for (var side = 0; side < 2; side++) { + var pts = side ? Bc : A, cxs = xs[side], cys = h / 2, res = side ? -outcome : outcome; + var color = snap > 0.5 ? withAlpha(cols[side], 0.55) : withAlpha(c.ink, 0.34); + var base = narrow ? 1.0 : 1.3, sp = [sprite(color, base * 0.8), sprite(color, base * 1.35)]; + var tight = 1 - 0.32 * snap; + for (var i = 0; i < pts.length; i += step) { + var p = pts[i]; + var jx = Math.sin(t * p.f * 1.3 + p.ph) * 0.18, jy = Math.cos(t * p.f + p.ph * 1.7) * 0.18; + var ox = p.ox, oy = p.oy; + if (snap > 0 && p.lobe !== res) { ox = p.ax; oy = p.ay; } + var x0 = (ox + jx) * sig * tight, y0 = -p.lobe * lobeY + (oy + jy) * sig * tight; + var y = p.lobe === res ? y0 : y0 + ((-res * lobeY + (oy + jy) * sig * tight) - y0) * snap; + var s = sp[p.k]; + ctx.drawImage(s.cv, cxs + x0 - s.css / 2, cys + y - s.css / 2, s.css, s.css); + } + ctx.strokeStyle = withAlpha(c.ink, 0.35); ctx.lineWidth = 1; ctx.setLineDash([2, 4]); + ctx.beginPath(); ctx.moveTo(cxs, cys - h * 0.42); ctx.lineTo(cxs, cys + h * 0.42); ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle = c.muted; ctx.font = mono(narrow ? 10.5 : 11.5); ctx.textAlign = "center"; ctx.textBaseline = "bottom"; + ctx.fillText(side ? "B" : "A", cxs, cys - h * 0.44); + if (snap > 0.5) { + ctx.fillStyle = cols[side]; ctx.font = sans(narrow ? 13 : 15, 600); ctx.textBaseline = "top"; + ctx.fillText(res > 0 ? "↑" : "↓", cxs, cys + h * 0.44 - 2); + } + } + var state = snap > 0.5 ? ("measured · A " + (outcome > 0 ? "↑" : "↓") + " B " + (outcome > 0 ? "↓" : "↑") + " · same frame; nothing crossed the gap") + : (u > 6.2 || u < 0.2 ? "state prepared again · each cloud fluctuates on its own" : "superposition · two independent probability clouds · measurement in " + (3 - u).toFixed(1) + " s"); + return "measurement " + pad(n + 1, 4) + " · " + state; + }; + } + + function copyRect() { + var copy = document.querySelector(".hero-copy"); if (!copy) return null; + var a = copy.getBoundingClientRect(), b = canvas.getBoundingClientRect(); + return { x: a.left - b.left, y: a.top - b.top, w: a.width, h: a.height }; + } + + // ---------- engine ---------- + var ctx = canvas.getContext("2d"); + function size() { + var r = canvas.getBoundingClientRect(), dpr = Math.min(2, window.devicePixelRatio || 1); + var W = Math.max(1, Math.round(r.width * dpr)), H = Math.max(1, Math.round(r.height * dpr)); + if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return { w: r.width, h: r.height }; + } + function frame(now) { + raf = 0; + if (!anim) return; + var dt = last ? Math.min(0.1, (now - last) / 1000) : 0; last = now; + simTime += dt * speed; + var s = size(); + var text = anim.draw(s.w, s.h, simTime, false); + if (text !== undefined && caption.textContent !== text) caption.textContent = text; + if (!reduced.matches && visible && inView) raf = requestAnimationFrame(frame); + } + function drawStill() { + var s = size(); var text = anim.draw(s.w, s.h, 0, true); if (text !== undefined) caption.textContent = text; + } + function start() { + if (raf) cancelAnimationFrame(raf); raf = 0; last = 0; + if (reduced.matches) { drawStill(); return; } + if (visible && inView) raf = requestAnimationFrame(frame); + } + function apply(id, push) { + var d = DIRECTIONS.filter(function (x) { return x.id === id; })[0] || DIRECTIONS[0]; + current = d; + root.setAttribute("data-direction", d.id); + var c = tokens(); + markSlot.innerHTML = d.mark(c); + copyEls.eyebrow.textContent = d.eyebrow; copyEls.title.textContent = d.title; copyEls.lead.textContent = d.lead; + anim = new d.anim(ctx, c); + simTime = parseFloat(params.get("t")) || 0; + store.set("entangle.direction", d.id); + document.querySelectorAll(".dir-toggle strong, .dir-pill strong").forEach(function (el) { el.textContent = d.name; }); + var radio = document.getElementById("dir-" + d.id); if (radio) radio.checked = true; + if (push) { var p = new URLSearchParams(location.search); p.set("d", d.id); if (speed !== 1) p.set("speed", "quarter"); else p.delete("speed"); history.replaceState(null, "", location.pathname + "?" + p.toString() + location.hash); } + start(); + } + function setSpeed(v, push) { + speed = v; store.set("entangle.speed", v === 1 ? "normal" : "quarter"); + if (push) { var p = new URLSearchParams(location.search); if (v !== 1) p.set("speed", "quarter"); else p.delete("speed"); var qs = p.toString(); history.replaceState(null, "", location.pathname + (qs ? "?" + qs : "") + location.hash); } + } + + // ---------- picker ---------- + function buildPicker() { + var picker = document.createElement("aside"); + picker.className = "dir-picker"; picker.id = "dir-picker"; picker.setAttribute("aria-label", "Brand direction picker"); + var html = '

Direction

' + + '

Phase 1 preview. Switches palette, mark, copy and hero.

'; + DIRECTIONS.forEach(function (d, i) { + html += '' + + ''; + }); + html += '
Speed' + + '' + + '
' + + '

Keys 1\u20134 switch direction, Q toggles speed. Reduced-motion users get a still frame.

'; + picker.innerHTML = html; + var backdrop = document.createElement("div"); backdrop.className = "dir-backdrop"; + var corner = document.createElement("div"); corner.className = "dir-corner"; + var pill = document.createElement("button"); + pill.type = "button"; pill.className = "dir-pill"; pill.setAttribute("aria-controls", "dir-picker"); pill.setAttribute("aria-expanded", "false"); + pill.innerHTML = 'Direction: 1\u20137'; + corner.appendChild(picker); corner.appendChild(pill); + document.body.appendChild(backdrop); document.body.appendChild(corner); + + var toggle = document.createElement("button"); + toggle.type = "button"; toggle.className = "dir-toggle"; toggle.setAttribute("aria-controls", "dir-picker"); toggle.setAttribute("aria-expanded", "false"); + toggle.innerHTML = 'Direction: '; + figure.parentNode.insertBefore(toggle, figure.nextSibling); + + function open() { picker.classList.add("is-open"); backdrop.classList.add("is-open"); toggle.setAttribute("aria-expanded", "true"); pill.setAttribute("aria-expanded", "true"); var r = picker.querySelector("input:checked"); if (r) r.focus(); } + function close() { picker.classList.remove("is-open"); backdrop.classList.remove("is-open"); toggle.setAttribute("aria-expanded", "false"); pill.setAttribute("aria-expanded", "false"); } + function isSheet() { return window.matchMedia("(max-width: 900px)").matches; } + function flip() { picker.classList.contains("is-open") ? close() : open(); } + toggle.addEventListener("click", flip); pill.addEventListener("click", flip); + if (params.get("picker") === "open") open(); + backdrop.addEventListener("click", close); + picker.querySelector(".dir-picker-close").addEventListener("click", close); + var sheetMq = window.matchMedia("(max-width: 900px)"); + function syncFigureRole() { + if (sheetMq.matches) { figure.setAttribute("role", "button"); figure.setAttribute("tabindex", "0"); figure.setAttribute("aria-label", "Open the direction picker"); } + else { figure.removeAttribute("role"); figure.removeAttribute("tabindex"); figure.removeAttribute("aria-label"); } + } + syncFigureRole(); if (sheetMq.addEventListener) sheetMq.addEventListener("change", syncFigureRole); else sheetMq.addListener(syncFigureRole); + figure.addEventListener("click", function () { if (isSheet()) open(); }); + figure.addEventListener("keydown", function (e) { if ((e.key === "Enter" || e.key === " ") && isSheet()) { e.preventDefault(); open(); } }); + picker.addEventListener("change", function (e) { + if (e.target.name === "direction") { apply(e.target.value, true); if (isSheet()) close(); } + if (e.target.name === "speed") setSpeed(parseFloat(e.target.value), true); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") { close(); return; } + if (e.target.closest("textarea, select, [contenteditable], input:not([type=radio]):not([type=checkbox]):not([type=button])")) return; + var n = parseInt(e.key, 10); if (n >= 1 && n <= DIRECTIONS.length) apply(DIRECTIONS[n - 1].id, true); + if (e.key === "q" || e.key === "Q") { setSpeed(speed === 1 ? 0.25 : 1, true); document.getElementById(speed === 1 ? "speed-normal" : "speed-quarter").checked = true; } + }); + document.getElementById(speed === 1 ? "speed-normal" : "speed-quarter").checked = true; + } + + // ---------- lifecycle ---------- + document.addEventListener("visibilitychange", function () { visible = !document.hidden; start(); }); + if ("IntersectionObserver" in window) { + new IntersectionObserver(function (es) { inView = es[0].isIntersecting; start(); }, { threshold: 0.05 }).observe(canvas); + } + window.addEventListener("resize", function () { if (reduced.matches) drawStill(); }); + reduced.addEventListener && reduced.addEventListener("change", start); + + buildPicker(); + var initial = params.get("d") || store.get("entangle.direction") || "quadrant"; + apply(initial, false); +})(); diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..afb3f0b --- /dev/null +++ b/site/index.html @@ -0,0 +1,188 @@ + + + + + +Entangle: encrypted state sync between AI agents + + + + + + + + + + + + + +
+
+
+
+ Local-first MCP · Agent to agent +

Two agents, one state.

+

Entangle lets AI agents on your network find each other, join a group with one short code, and share files and JSON over end-to-end encrypted links. Nothing in between can read them.

+ +
    +
  • Experimental
  • +
  • v0.7.0
  • +
  • Rust, one binary
  • +
  • Windows, macOS, Linux
  • +
  • MIT or Apache-2.0
  • +
+
+
+
+ +
+
+
+
+
+ +
+
+
+ How it works +

Discover, join, sync.

+

Each machine runs one Entangle process as an MCP stdio server. Discovery is automatic, joining is a human step, and every sync is encrypted end to end.

+
+
    +
  1. +

    Resonance

    +

    Particles advertise over mDNS on the local network and publish only public group IDs, never a key. Where multicast is blocked, static --peer entries do the same job.

    +
    entangle scan
    +
  2. +
  3. +

    Joining, by a human

    +

    One device opens an invite and shows a short one-time code; every other device enters it once, in any order. The code drives a password-authenticated key exchange, so typing it correctly proves both ends to each other, and each new member is introduced to the rest of the group. Agents never see the code and cannot invite.

    +
    entangle group create work --invite 4
    +entangle group join 4-ion-quark-muon
    +
  4. +
  5. +

    State sync

    +

    An agent sends JSON or one file over a Noise XXpsk3 link on TCP 7337. The receiver checks the byte count and SHA-256 before anything reaches its inbox; the other agent observes it, then collapses it.

    +
    find_entangled_particles
    +sync_entangled_state
    +observe_entangled_states
    +
  6. +
+
+
+ +
+
+
+ Use cases +

What it is for.

+

Each one has a playbook with the exact commands for every machine. Entangle is experimental, so expect rough edges.

+
+
    +
  1. +

    An agent runs another machine's tests

    +

    One agent writes each test step and sends it; the agent on the other machine runs it inside a scope you set, then sends back the exit code and the real output. You get a step-by-step report you can check.

    + Playbook 08
    +
  2. +
  3. +

    All your devices, one group

    +

    Your desktop, laptop and home server join with a single code. Their agents can then hand each other files, notes and JSON state, to one member, a few, or the whole group.

    + Playbook 02
    +
  4. +
  5. +

    Home and work, kept apart

    +

    One machine can belong to several groups at once. Each group has its own members and its own inbox, so a work project never reaches your home devices.

    + Playbook 04
    +
  6. +
  7. +

    A laptop away from home

    +

    Run a repeater on a box you own. When the laptop leaves the LAN, its links fall back to the repeater, which forwards encrypted bytes it cannot read.

    + Playbook 07
    +
  8. +
+

All playbooks, including self-hosting a repeater, Windows with omp, and troubleshooting.

+
+
+ +
+
+
+ Quantum Repeater +

Across networks, through a relay that cannot read.

+

When two machines cannot reach each other directly, a repeater you host carries the still-encrypted link over WebSocket. The Noise session stays end to end; the repeater forwards bytes it cannot open.

+
+
    +
  • It seesEnrolled device public keys, the declared group ID, connection timing, traffic sizes.
  • +
  • It cannot seeParticle names, group keys, invite codes, or any synced content.
  • +
  • EnrollmentA one-time invite code from the host. Devices pin the repeater's static key; revocation by fingerprint.
  • +
  • Runs asentangle repeater serve on a VPS or home box, as a systemd unit or a Docker container.
  • +
  • Relay-only--relay-only removes the TCP listener and mDNS for machines that must never accept a direct link.
  • +
+
+
+ +
+
+
+ Install +

One binary, no administrator rights.

+

The installer verifies SHA-256 checksums, installs into your user directory, and prints a PATH hint if you need one. Then register it with your MCP client. Entangle is experimental: until 1.0, commands and the wire protocol can change between releases.

+
+
+

Or let your agent do it

+

Paste this into the agent on each machine. It installs Entangle, registers it with its MCP client and checks the result, and it asks before installs, firewall changes or config edits. You type the group commands and reload the client.

+
Read https://raw.githubusercontent.com/DailenG/entangle/main/docs/AGENT_SETUP.md and follow it to set up Entangle on this machine.
+
+
+

macOS and Linux

curl -fsSL https://raw.githubusercontent.com/DailenG/entangle/main/install.sh | sh
+

Windows PowerShell

irm https://raw.githubusercontent.com/DailenG/entangle/main/install.ps1 | iex
+

Claude Code

claude mcp add entangle -- entangle serve --context project-x
+

Any stdio MCP client

{ "mcpServers": { "entangle": {
+    "command": "/ABSOLUTE/PATH/TO/entangle",
+    "args": ["serve", "--context", "project-x"] } } }
+
+
+
+
+ + + + + + diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..18b5b97 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,463 @@ +/* Entangle website, Phase 1: four brand directions on one page. Typography, spacing, radii and + icon rules follow the tachyon design system (DailenG/tachyon, docs/design/system/); the + palettes and marks are Entangle's own and switch with html[data-direction]. No gradients, no + glows, no third-party requests. */ + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("assets/fonts/inter-latin.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, + U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +:root { + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --container: 1180px; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --dur-fast: 120ms; + --dur-base: 180ms; + --header-h: 68px; +} + +/* 1. Singlet: warm paper, ink, and the two anti-correlated outcomes as complementary hues. */ +[data-direction="singlet"] { + color-scheme: light; + --bg: #f5f1e8; + --surface: #fbf9f4; + --sunken: #ece7db; + --line: #e0d9ca; + --line-strong: #c9c0ad; + --ink: #1a1a1f; + --body: #44454d; + --muted: #6b6c75; + --a: #c8431f; + --b: #176b6b; + --link: #176b6b; + --btn-bg: #1a1a1f; + --btn-fg: #f5f1e8; + --header-bg: rgba(245, 241, 232, 0.9); + --selection: rgba(23, 107, 107, 0.2); + --eyebrow: #c8431f; +} + +/* 2. Spacelike: an instrument on a dark bench, warm white and one amber trace. */ +[data-direction="spacelike"] { + color-scheme: dark; + --bg: #0f1115; + --surface: #15181d; + --sunken: #1b1f26; + --line: #262b33; + --line-strong: #39404a; + --ink: #ecebe6; + --body: #b4b6bc; + --muted: #868a93; + --a: #f0b23a; + --b: #f0b23a; + --link: #f0b23a; + --btn-bg: #f0b23a; + --btn-fg: #0f1115; + --header-bg: rgba(15, 17, 21, 0.86); + --selection: rgba(240, 178, 58, 0.3); + --eyebrow: #f0b23a; +} + +/* 3. Swap: a lecture-hall chalkboard, chalk text, copper for the entangled pair. */ +[data-direction="swap"] { + color-scheme: dark; + --bg: #1f2b27; + --surface: #243330; + --sunken: #1a2522; + --line: #34443e; + --line-strong: #4a5c55; + --ink: #efebe0; + --body: #c7c3b7; + --muted: #9a988d; + --a: #d7935b; + --b: #8fb8c9; + --link: #d7935b; + --btn-bg: #efebe0; + --btn-fg: #1f2b27; + --header-bg: rgba(31, 43, 39, 0.88); + --selection: rgba(215, 147, 91, 0.3); + --eyebrow: #d7935b; +} + +/* 4. Bound: a lab notebook plot, cool white, graphite, one green trace. */ +[data-direction="bound"] { + color-scheme: light; + --bg: #f2f5f6; + --surface: #ffffff; + --sunken: #e8edef; + --line: #d9e0e3; + --line-strong: #b9c4c9; + --ink: #0f1b1e; + --body: #3c4a4f; + --muted: #627179; + --a: #0f7a5a; + --b: #9aa7ac; + --link: #0f7a5a; + --btn-bg: #0f7a5a; + --btn-fg: #ffffff; + --header-bg: rgba(242, 245, 246, 0.9); + --selection: rgba(15, 122, 90, 0.2); + --eyebrow: #0f7a5a; +} + +[data-direction="quadrant"] { + color-scheme: dark; + --bg: #121113; + --surface: #1a181a; + --sunken: #201d20; + --line: #2b282b; + --line-strong: #403c40; + --ink: #f1ece2; + --body: #bdb7ac; + --muted: #8e887e; + --a: #e9a23b; + --b: #f4e8d2; + --link: #e9a23b; + --eyebrow: #e9a23b; +} + +[data-direction="crossing"] { + color-scheme: dark; + --bg: #14181b; + --surface: #1b2024; + --sunken: #20262b; + --line: #2b3339; + --line-strong: #3f4a52; + --ink: #edeeea; + --body: #b7bcbb; + --muted: #87908f; + --a: #d9532f; + --b: #f6d4be; + --link: #e8a07f; + --eyebrow: #d9532f; +} + +[data-direction="collapse"] { + color-scheme: light; + --bg: #f4f0e7; + --surface: #fbf9f3; + --sunken: #ece6d9; + --line: #e0d8c8; + --line-strong: #c8bea9; + --ink: #17171b; + --body: #44454c; + --muted: #6d6e76; + --a: #b8341c; + --b: #1c6e6b; + --link: #b8341c; + --eyebrow: #b8341c; +} + +/* Base */ +*, *::before, *::after { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 84px; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + transition: background var(--dur-base), color var(--dur-base); +} +h1, h2, h3 { color: var(--ink); font-weight: 600; letter-spacing: -0.01em; text-wrap: balance; margin: 0; } +p { text-wrap: pretty; margin: 0; } +a { color: var(--link); text-decoration-thickness: 1.5px; text-underline-offset: 2px; } +a:hover { color: var(--ink); } +::selection { background: var(--selection); } +:focus-visible { outline: 2px solid var(--a); outline-offset: 2px; } +img { max-width: 100%; height: auto; } +code { + font-family: var(--font-mono); + font-size: 0.88em; + background: var(--sunken); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.05em 0.35em; +} +pre { + margin: 0; + padding: 14px 18px; + font-family: var(--font-mono); + font-size: 13.5px; + line-height: 1.6; + color: var(--ink); + background: var(--sunken); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + overflow-x: auto; +} +.sprite { display: none; } +.icon { + width: 1.15em; height: 1.15em; flex: none; + fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; +} +.wrap { max-width: var(--container); margin: 0 auto; padding: 0 24px; } +.skip-link { + position: absolute; left: 1rem; top: -4rem; padding: 0.6rem 1rem; z-index: 100; + background: var(--surface); color: var(--ink); border: 1px solid var(--line-strong); + border-radius: var(--radius-sm); font-weight: 600; +} +.skip-link:focus { top: 1rem; } +.mono { font-family: var(--font-mono); font-size: 13px; letter-spacing: 0; } + +/* Buttons (tachyon Button sizes; solid colors only) */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + height: 44px; padding: 0 20px; border-radius: var(--radius-md); + border: 1px solid transparent; font: 600 16px/1 var(--font-sans); letter-spacing: -0.005em; + text-decoration: none; white-space: nowrap; cursor: pointer; + transition: background var(--dur-fast) var(--ease-out), border-color var(--dur-fast), color var(--dur-fast), + transform var(--dur-fast) var(--ease-out); +} +.btn-sm { height: 36px; padding: 0 14px; border-radius: 10px; font-size: 14px; gap: 6px; } +.btn-lg { height: 52px; padding: 0 26px; border-radius: 14px; font-size: 17px; gap: 10px; } +.btn-primary { background: var(--btn-bg); color: var(--btn-fg); } +.btn-primary:hover { color: var(--btn-fg); transform: translateY(-1px); } +.btn-primary:active { transform: none; } +.btn-secondary { background: transparent; color: var(--ink); border-color: var(--line-strong); } +.btn-secondary:hover { color: var(--ink); border-color: var(--ink); } + +/* Header: the one translucent surface */ +.site-header { + position: sticky; top: 0; z-index: 20; + background: var(--header-bg); + border-bottom: 1px solid var(--line); + -webkit-backdrop-filter: saturate(1.2) blur(8px); backdrop-filter: saturate(1.2) blur(8px); +} +.site-header .wrap { height: var(--header-h); display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; } +.brand svg { width: 42px; height: 28px; display: block; } +.brand .wordmark { font-size: 19px; font-weight: 600; letter-spacing: -0.02em; } +.main-nav { display: flex; align-items: center; gap: 26px; } +.main-nav a:not(.btn) { font-size: 15px; font-weight: 500; color: var(--body); text-decoration: none; } +.main-nav a:not(.btn):hover { color: var(--ink); } +@media (max-width: 760px) { .main-nav a:not(.btn) { display: none; } } + +/* Eyebrow: 12px 600 uppercase +16% (design system) */ +.eyebrow { + display: block; font-size: 12px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; + color: var(--eyebrow); margin-bottom: 16px; +} + +/* Hero: a generic copy block and a visual block; each direction lays them out differently. */ +.hero { position: relative; overflow: hidden; border-bottom: 1px solid var(--line); } +.hero-inner { position: relative; display: grid; gap: 48px; padding-top: 72px; padding-bottom: 64px; } +.hero-title { font-size: clamp(42px, 5.4vw, 68px); line-height: 1.04; letter-spacing: -0.025em; margin: 0 0 20px; } +.hero-lead { font-size: 20px; line-height: 1.5; color: var(--body); max-width: 56ch; margin: 0 0 32px; } +.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 28px; } +.hero-facts { display: flex; flex-wrap: wrap; gap: 6px 18px; padding: 0; margin: 0; list-style: none; color: var(--muted); font-size: 14px; } +.hero-facts li { display: inline-flex; align-items: center; gap: 8px; } +.hero-facts li + li::before { content: ""; width: 3px; height: 3px; border-radius: 50%; background: var(--line-strong); } +.hero-visual { position: relative; min-width: 0; } +.hero-figure { position: relative; margin: 0; } +.hero-canvas { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; border-radius: var(--radius-lg); touch-action: manipulation; } +.hero-caption { + margin: 12px 0 0; font-family: var(--font-mono); font-size: 12.5px; color: var(--muted); + font-variant-numeric: tabular-nums; min-height: 1.5em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.hero-figure[role="button"] { cursor: pointer; } + +/* Singlet: two columns, visual framed like a lab card */ +[data-direction="singlet"] .hero-inner { grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); align-items: center; } +[data-direction="singlet"] .hero-canvas { background: var(--surface); border: 1px solid var(--line); } + +/* Spacelike: the diagram fills the hero; copy sits centered on top of it */ +[data-direction="spacelike"] .hero { min-height: calc(100svh - var(--header-h)); display: grid; align-items: center; } +[data-direction="spacelike"] .hero-inner { position: static; grid-template-columns: 1fr; justify-items: center; text-align: center; padding-top: 48px; padding-bottom: 48px; } +[data-direction="spacelike"] .hero-copy { max-width: 760px; position: relative; z-index: 1; } +[data-direction="spacelike"] .hero-lead { margin-left: auto; margin-right: auto; } +[data-direction="spacelike"] .hero-actions, [data-direction="spacelike"] .hero-facts { justify-content: center; } +[data-direction="spacelike"] .hero-visual { position: absolute; inset: 0; z-index: 0; } +[data-direction="spacelike"] .hero-figure { position: absolute; inset: 0; } +[data-direction="spacelike"] .hero-canvas { position: absolute; inset: 0; width: 100%; height: 100%; aspect-ratio: auto; border-radius: 0; } +[data-direction="spacelike"] .hero-caption { position: absolute; left: 24px; bottom: 18px; margin: 0; z-index: 1; max-width: calc(100% - 48px); } + +/* Swap: centered copy, then a wide chalk band */ +[data-direction="swap"] .hero-inner { grid-template-columns: 1fr; justify-items: center; text-align: center; gap: 20px; padding-top: 56px; padding-bottom: 40px; } +[data-direction="swap"] .hero-copy { max-width: 720px; } +[data-direction="swap"] .hero-lead { margin-left: auto; margin-right: auto; } +[data-direction="swap"] .hero-actions, [data-direction="swap"] .hero-facts { justify-content: center; } +[data-direction="swap"] .hero-visual { width: 100%; } +[data-direction="swap"] .hero-canvas { aspect-ratio: 21 / 6; border-radius: 0; } +[data-direction="swap"] .hero-caption { text-align: center; } + +/* Bound: the plot takes the wider column */ +[data-direction="bound"] .hero-inner { grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); align-items: center; } +[data-direction="bound"] .hero-canvas { aspect-ratio: 16 / 11; background: var(--surface); border: 1px solid var(--line); } + +/* Quadrant: copy left, square photon image right, no card frame */ +[data-direction="quadrant"] .hero-canvas { aspect-ratio: 1 / 1; border-radius: 0; } +[data-direction="quadrant"] .hero-inner { grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr); align-items: center; gap: 56px; } + +/* Crossing: centered copy over a wide photon image, like Swap */ +[data-direction="crossing"] .hero-inner { grid-template-columns: 1fr; justify-items: center; text-align: center; gap: 8px; padding-top: 44px; padding-bottom: 24px; } +[data-direction="crossing"] .hero-copy { max-width: 720px; } +[data-direction="crossing"] .hero-lead { margin-left: auto; margin-right: auto; } +[data-direction="crossing"] .hero-actions, [data-direction="crossing"] .hero-facts { justify-content: center; } +[data-direction="crossing"] .hero-visual { width: 100%; } +[data-direction="crossing"] .hero-canvas { aspect-ratio: 21 / 7; border-radius: 0; max-width: 1040px; margin: 0 auto; } +[data-direction="crossing"] .hero-caption { text-align: center; } + +/* Collapse: copy left, two clouds right on bare paper */ +[data-direction="collapse"] .hero-inner { grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr); align-items: center; } +[data-direction="collapse"] .hero-canvas { aspect-ratio: 5 / 4; border-radius: 0; } +@media (max-width: 900px) { + [data-direction="crossing"] .hero-canvas { aspect-ratio: 4 / 3; } + [data-direction="collapse"] .hero-canvas { aspect-ratio: 1 / 1; } +} + +@media (max-width: 900px) { + .hero-inner, [data-direction="singlet"] .hero-inner, [data-direction="bound"] .hero-inner, [data-direction="swap"] .hero-inner, [data-direction="quadrant"] .hero-inner, [data-direction="collapse"] .hero-inner, [data-direction="crossing"] .hero-inner { grid-template-columns: 1fr; gap: 24px; padding-top: 36px; padding-bottom: 36px; } + .hero-title { font-size: clamp(36px, 10vw, 48px); margin-bottom: 14px; } + .hero-lead { font-size: 17px; margin-bottom: 20px; } + .hero-actions { margin-bottom: 16px; } + .eyebrow { margin-bottom: 12px; } + .hero-actions .btn-lg { height: 46px; padding: 0 20px; font-size: 16px; } + [data-direction="swap"] .hero-canvas { aspect-ratio: 4 / 3; } + [data-direction="spacelike"] .hero { min-height: calc(100svh - var(--header-h)); } + [data-direction="spacelike"] .hero-inner { padding-top: 56px; padding-bottom: 72px; } + .hero-caption { white-space: normal; overflow: visible; text-overflow: clip; min-height: 3em; } + [data-direction="spacelike"] .hero-caption { font-size: 11.5px; } +} + +.install-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 48px; } +.install-grid h3 { font-size: 14px; font-weight: 500; color: var(--muted); margin: 0 0 8px; } +.install-grid pre { white-space: pre-wrap; overflow-wrap: anywhere; } +@media (max-width: 1100px) { .install-grid { grid-template-columns: 1fr; } } + +/* Sections */ +section { padding: 88px 0; border-bottom: 1px solid var(--line); } +.section-head { max-width: 680px; margin-bottom: 40px; } +.section-head h2 { font-size: clamp(30px, 3.4vw, 40px); line-height: 1.12; letter-spacing: -0.02em; margin-bottom: 14px; } +.section-head p { color: var(--body); font-size: 17px; } + +.steps { list-style: none; margin: 0; padding: 0; counter-reset: step; border-top: 1px solid var(--line-strong); } +.steps li { display: grid; grid-template-columns: minmax(0, 0.42fr) minmax(0, 0.58fr); gap: 16px 48px; padding: 32px 0; border-bottom: 1px solid var(--line); } +.steps li > div { min-width: 0; } +.steps h3 { font-size: 24px; letter-spacing: -0.01em; margin: 0; display: flex; align-items: baseline; gap: 14px; } +.steps h3::before { counter-increment: step; content: counter(step, decimal-leading-zero); font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--eyebrow); letter-spacing: 0.08em; } +.steps p { color: var(--body); font-size: 16px; margin: 0 0 14px; max-width: 58ch; } +.steps pre { font-size: 13px; display: inline-block; min-width: 260px; max-width: 100%; } +@media (max-width: 900px) { .steps li { grid-template-columns: 1fr; gap: 12px; padding: 24px 0; } .steps pre { display: block; } } + +.split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: start; } +.split .section-head { margin-bottom: 0; } +.facts { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line-strong); } +.facts li { display: grid; grid-template-columns: 150px 1fr; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--line); font-size: 15px; color: var(--body); } +.facts li strong { color: var(--ink); font-weight: 600; } +@media (max-width: 900px) { .split { grid-template-columns: 1fr; gap: 32px; } .facts li { grid-template-columns: 1fr; gap: 4px; } } + +.install-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; } +.install-grid > div { min-width: 0; } +.install-grid h3 { font-size: 15px; font-weight: 600; margin-bottom: 10px; color: var(--muted); } +@media (max-width: 900px) { .install-grid { grid-template-columns: 1fr; } } + +.site-footer { padding: 36px 0 48px; color: var(--muted); font-size: 14px; } +@media (min-width: 901px) { .site-footer { padding-bottom: 84px; } } +.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px 24px; align-items: center; } +.site-footer a { color: var(--body); text-decoration: none; } +.site-footer a:hover { color: var(--ink); } +.site-footer nav { display: flex; gap: 20px; flex-wrap: wrap; } + +/* Direction picker (directions.js). Desktop: a labelled pill fixed to the bottom-right corner opens a + card above it. Narrow screens: a pill under the hero visual, or a tap on the visual, opens a bottom sheet. */ +.dir-corner { position: fixed; right: 20px; bottom: 20px; z-index: 50; } +.dir-pill { + display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 14px 0 12px; + border: 1px solid var(--line-strong); border-radius: 999px; background: var(--surface); color: var(--body); + font: inherit; font-size: 13px; cursor: pointer; box-shadow: 0 8px 24px -14px rgba(0, 0, 0, 0.4); +} +.dir-pill:hover { border-color: var(--ink); } +.dir-pill strong { color: var(--ink); font-weight: 600; } +.dir-pill .icon { width: 16px; height: 16px; color: var(--eyebrow); } +.dir-pill kbd { font-family: var(--font-mono); font-size: 11px; color: var(--muted); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; margin-left: 2px; } +.dir-picker { + display: none; position: absolute; right: 0; bottom: calc(100% + 10px); width: 300px; + padding: 14px 16px 16px; background: var(--surface); color: var(--ink); + border: 1px solid var(--line-strong); border-radius: var(--radius-md); + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.35); + font-size: 13px; max-height: calc(100vh - 90px); overflow-y: auto; +} +.dir-picker.is-open { display: block; } +.dir-picker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 2px; } +.dir-picker h2 { font-size: 12px; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: var(--eyebrow); margin: 0; } +.dir-picker-sub { color: var(--muted); margin: 0 0 10px; font-size: 12.5px; } +.dir-picker-close { display: inline-grid; place-items: center; width: 32px; height: 32px; margin: -6px -8px -6px 0; border: 0; border-radius: 999px; background: none; font: inherit; color: var(--muted); cursor: pointer; } +.dir-picker-close:hover { color: var(--ink); background: var(--sunken); } +.dir-picker-close .icon { width: 18px; height: 18px; } +.dir-options { display: grid; gap: 2px; } +.dir-options input, .dir-speed input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } +.dir-options label { + display: grid; grid-template-columns: 22px 1fr; column-gap: 10px; align-items: center; + min-height: 36px; padding: 5px 8px 5px 5px; border-radius: var(--radius-sm); color: var(--body); cursor: pointer; +} +.dir-options label:hover { background: var(--sunken); } +.dir-options label .dir-name { font-weight: 600; color: var(--ink); font-size: 13.5px; } +.dir-options label .dir-desc { grid-column: 2; color: var(--muted); font-size: 12px; line-height: 1.4; } +.dir-num { + display: inline-grid; place-items: center; width: 22px; height: 22px; border: 1px solid var(--line-strong); + border-radius: 999px; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--ink); +} +.dir-options input:checked + label { background: var(--sunken); } +.dir-options input:checked + label .dir-num { background: var(--btn-bg); color: var(--btn-fg); border-color: transparent; } +.dir-options input:focus-visible + label { outline: 2px solid var(--a); outline-offset: 1px; } +.dir-speed { display: flex; align-items: center; gap: 4px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--line); } +.dir-speed-label { color: var(--muted); margin-right: auto; } +.dir-speed label { padding: 5px 10px; border-radius: 999px; border: 1px solid var(--line-strong); color: var(--body); cursor: pointer; font-size: 12.5px; } +.dir-speed input:checked + label { background: var(--btn-bg); color: var(--btn-fg); border-color: transparent; } +.dir-speed input:focus-visible + label { outline: 2px solid var(--a); outline-offset: 1px; } +.dir-picker-note { margin: 10px 0 0; font-size: 12px; color: var(--muted); line-height: 1.45; } +.dir-toggle { display: none; } +.dir-backdrop { display: none; } +@media (max-width: 900px) { + .dir-corner { position: static; } + .dir-pill { display: none; } + .dir-toggle { + display: inline-flex; align-items: center; gap: 8px; margin: 14px auto 0; padding: 0 14px; min-height: 38px; + border: 1px solid var(--line-strong); border-radius: 999px; background: var(--surface); font: inherit; font-size: 13px; color: var(--body); cursor: pointer; + } + .dir-toggle strong { color: var(--ink); font-weight: 600; } + .dir-toggle .icon { width: 16px; height: 16px; color: var(--eyebrow); } + [data-direction="spacelike"] .dir-toggle { position: absolute; right: 20px; bottom: 14px; z-index: 2; margin: 0; } + [data-direction="spacelike"] .hero-caption { bottom: 66px; max-width: calc(100% - 48px); } + .dir-picker { + position: fixed; left: 0; right: 0; bottom: 0; width: auto; max-width: 560px; margin: 0 auto; z-index: 60; + padding: 16px 20px calc(18px + env(safe-area-inset-bottom)); border-radius: var(--radius-lg) var(--radius-lg) 0 0; + max-height: calc(100svh - 24px); overflow-y: auto; + } + .dir-options label { min-height: 44px; } + .dir-backdrop.is-open { display: block; position: fixed; inset: 0; z-index: 55; background: rgba(0, 0, 0, 0.45); } +} +@media (prefers-reduced-motion: reduce) { + * { animation-duration: 0s !important; transition-duration: 0s !important; } + html { scroll-behavior: auto; } +} + +/* Use cases: the step rows, unnumbered, each ending in its playbook link */ +.cases h3::before { content: none; } +.cases p { margin-bottom: 10px; } +.case-link { display: inline-flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 500; color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--line-strong); padding-bottom: 2px; } +.case-link:hover { border-bottom-color: var(--ink); } +.case-link .icon { width: 15px; height: 15px; } +.cases-more { margin: 24px 0 0; color: var(--muted); font-size: 15px; } +.cases-more a { color: var(--ink); } + +.hero-facts li.fact-badge { padding: 2px 10px; border: 1px solid var(--line-strong); border-radius: 999px; color: var(--ink); font-size: 12px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; } +.hero-facts li.fact-badge + li::before { display: none; } + +.agent-install { margin: 0 0 32px; } +.agent-install h3 { font-size: 15px; font-weight: 600; margin: 0 0 8px; } +.agent-install p { margin: 0 0 12px; color: var(--muted); max-width: 68ch; } +.agent-install pre { white-space: pre-wrap; overflow-wrap: anywhere; }