diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..08542dd --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,42 @@ +name: Deploy site to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time; a newer push replaces a queued one but never cancels one in progress. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + - name: Set up Pages + uses: actions/configure-pages@v5 + - name: Assemble site + run: bash site/build.sh _site + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5cb067b..51657e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ __pycache__/ uv.lock.bak .pytest_out config/ +_site/ diff --git a/README.md b/README.md index a57f635..482e061 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ Ion is a steward bot for a GitHub organisation. It watches every repo, spots drift from your policy, and proposes structural changes (merge, split, archive, rename, move code). **It never changes structure without a maintainer's approval.** Apache-2.0. +**Website:** [aska-digital.github.io/ion](https://aska-digital.github.io/ion/), an infographic abstract with a working model of the approval gate, plus a guide to running ion. The site source lives in `site/` and deploys with `.github/workflows/pages.yml`. + See [Why ion](#why-ion) for the name. ``` diff --git a/site/404.html b/site/404.html new file mode 100644 index 0000000..f8aee9f --- /dev/null +++ b/site/404.html @@ -0,0 +1,38 @@ + + + + + +No approved path to that page | Ion + + + + + + + +
+ + + IonOrg steward + + +
+
+
+
404 // No such pathGate closed
+

No approved path to that page.

+

Charge does work only along a path, and this URL is not one. Nothing was changed, which is very much the house style.

+ +
+
+ + diff --git a/site/apple-touch-icon.png b/site/apple-touch-icon.png new file mode 100644 index 0000000..0f6b5a6 Binary files /dev/null and b/site/apple-touch-icon.png differ diff --git a/site/build.sh b/site/build.sh new file mode 100755 index 0000000..a64e715 --- /dev/null +++ b/site/build.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Assemble the GitHub Pages artifact from site/. +# Used by .github/workflows/pages.yml and for local testing: +# bash site/build.sh [output-dir] (default: _site in the repository root) +# python3 -m http.server -d _site 8000 +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${1:-$ROOT/_site}" +case "$OUT" in + ""|"/"|"$ROOT"|"$HOME") echo "refusing to use '$OUT' as the output directory" >&2; exit 1 ;; +esac + +rm -rf "$OUT" +mkdir -p "$OUT/data" + +# Everything in site/ except this script and og.html (the page og.png is +# rendered from; see the comment at the top of og.html). +for f in "$ROOT"/site/*; do + case "$(basename "$f")" in build.sh|og.html) continue ;; esac + cp -R "$f" "$OUT/" +done + +# Build stamp shown in the page footer. +COMMIT="${GITHUB_SHA:-$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)}" +printf '{"commit":"%s","built":"%s"}\n' "$COMMIT" "$(date -u +%Y-%m-%d)" > "$OUT/data/build.json" + +touch "$OUT/.nojekyll" +echo "Site assembled in $OUT" diff --git a/site/favicon-32.png b/site/favicon-32.png new file mode 100644 index 0000000..aa9abfc Binary files /dev/null and b/site/favicon-32.png differ diff --git a/site/favicon.svg b/site/favicon.svg new file mode 100644 index 0000000..d867e7d --- /dev/null +++ b/site/favicon.svg @@ -0,0 +1 @@ + diff --git a/site/guide.html b/site/guide.html new file mode 100644 index 0000000..6dfa8eb --- /dev/null +++ b/site/guide.html @@ -0,0 +1,181 @@ + + + + + +Run ion on your org | Ion guide + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + IonGuide + + +
+ +
+ +
+ + +
+
G0 // Install and first runDry run by default
+

Guide

+

Run ion on your org, writing nothing.

+

Ion’s executors dry-run unless you pass --apply. Your first day with it should end with a dashboard full of proposals and an org that looks exactly as it did that morning. Approve later, on purpose, one plan at a time.

+

You need Python 3.12 or newer, uv, git with git subtree, the GitHub CLI gh logged in to an account that can see the org, and git-filter-repo if you want splits.

+
git clone https://github.com/aska-digital/ion.git && cd ion
+uv sync                       # add --extra graph for Louvain clustering
+cp ion.example.yml ion.yml    # then edit: org, maintainers, tracker_repo
+uv run ion --config ion.yml doctor
+

doctor checks the GitHub login, git and subtree, filter-repo, the language-model backend, the bus if you use one, and the config itself. Fix what it names. Then take the first full pass. It reads, measures, files proposals in the tracker, and writes nothing to any other repo:

+
uv run ion --config ion.yml run          # the whole pipeline, executors dry-run
+uv run ion --config ion.yml status       # what is waiting, held, done; any breaker trip
+uv run ion --config ion.yml dashboard    # the Org health issue, or --print to just read it
+

State lives in ~/.ion/state.db and repo mirrors in ~/.ion/cache. Set ION_HOME to move both, and ION_CONFIG to set a default config path. Your real config, with maintainer names and local paths, belongs outside any public repo: ~/.ion/ion.yml is a good home.

+

Keep the tracker repo private. Proposal bodies name your private repos and quote their metadata. The default tracker name is ion-tracker. Create it as a private repo before the first run.

+
+ + + +
+
G1 // The config keys that matterion.example.yml is the full list
+

Start strict. Loosen one class at a time.

+

Every repo can override the org config in its own .github/ion.yml: who maintains it, whether ion governs it at all, and its policy action levels. Two settings shape your first month more than any others:

+
    +
  • policy_strategy: opt-out (the default) governs every repo unless that repo says enabled: false. Choose opt-in to govern only repos that ask.
  • +
  • policy_actions sets, per policy class, what a drift finding does: log records it, issue proposes it and waits for approval, fix applies it unattended. Start everything at issue. Promote a class to fix only after its proposals have bored you for weeks. Structural kinds can never be fix.
  • +
+
+ + + + + + + + + + + + + + + +
KeyMeaning
orgThe organisation to steward
maintainersGitHub logins whose approval counts. Per-repo lists refine this
tracker_repoPrivate repo where proposals are filed as issues
approval_sourcesAny of github_label, divan_bus, dashboard
notifiersstdout, file, divan_bus, github_issue, webhook. Failed deliveries queue and retry
llm.backendnone (default), claude_cli or anthropic_api. Words only: the model cannot add repos or raise scores
guardsThe circuit breaker: max_repo_fraction, max_archives, max_writes, forbid_protection_lowering
writes.capsPer-run cap by write type, for example {open_pr: 5, archive_repo: 1}
quorum · approval_ttl_daysApprovers required per proposal, and when approvals expire (14 days)
lifecycledormant_days, plus repos or a topic that freeze the stale clock
boundariesTag rules and scope prefix. Cross-scope merges blocked by default
budget.graphql_points_per_runCollection stops cleanly at the budget (500) and resumes next run
+

Before adopting any change, preview it. ion plan-config NEW.yml runs both configs over the same snapshot and reports candidates gained and lost, drift changes, repos that would opt out, and open proposals that would be re-planned. It writes nothing.

+
uv run ion --config ion.yml plan-config candidate.yml
+uv run ion --config ion.yml plan-config --set thresholds.merge_coupling=0.7
+
+ + + +
+
G2 // Approve your first planThe reviewer’s ten minutes
+

Read the case. Check the fingerprint. Decide.

+
    +
  1. Open the proposal issue. It is titled like [P-0007] merge a into b in the tracker repo, labelled with its kind and state. Ion edits this one issue in place for the proposal’s whole life.
  2. +
  3. Read the evidence, then the steps. The body lists the signals and scores, the ordered changesets with dependencies, the exact commands the executor will run, the rollback, and the blast radius. If any command surprises you, hold the proposal and ask why.
  4. +
  5. Note the fingerprint. The plan-sha256: … line is the plan you are about to endorse. Your approval will count for that hash and no other.
  6. +
  7. Decide on any channel. Add ion:approved, ion:hold or ion:rejected; or send ion approve P-0007 over the bus; or tick the box on the Org health issue. You must be a maintainer of every repo the plan touches, or your label is removed and logged as ignored.
  8. +
  9. Let the next run execute, or run it yourself. ion execute --apply P-0007 re-derives your approval from the record, checks caps and the breaker, then works through the steps in order. A dependent step waits until the pull request before it has landed.
  10. +
  11. Merge the pull requests yourself. Ion opens them on ion/* branches and proves the move before asking. It never merges its own work. If the plan ends with archiving the source repo, that arrives as a separate proposal needing a separate yes.
  12. +
+
# What did ion do, and why does it believe it may?
+uv run ion --config ion.yml explain P-0007   # ledger, approvals, steps, states
+uv run ion --config ion.yml status           # queue, holds, last breaker trip
+

If new evidence changes a plan after you approved it, expect ion to ask again. The label comes off by itself. That is the system working, not a fault to work around.

+
+ + + +
+
G3 // Every commandSPEC §4
+

One pipeline, plus three tools you will use weekly.

+
ion collect | analyze | propose [--dry-run] | sync-approvals | execute [--apply] [P-0007]
+ion report | run [--apply] | status | explain P-0007 | pause | resume | doctor
+ion grep PATTERN [--path GLOB] [--repo GLOB] [-i] [-F]
+ion autofix [NAME] [--repo GLOB] [--propose]
+ion plan-config NEW.yml            (or --set key=value)
+ion dashboard [--print]
+
+

ion grep

Search the whole org, free

Runs git grep over the default branch of every local mirror. No API calls, no writes. The fastest way to answer “where else does this pattern live?” before you propose changing it.

+

ion autofix

One small change, everywhere

With no flags it prints the diffs and writes nothing. With --propose it files one proposal whose fingerprint covers exactly those diffs. Pull requests exist only after approval, each recomputed and compared with what was approved. Built in: a readme skeleton and a Dependabot config generator. Write your own by subclassing ion.fleet.Autofixer.

+

ion plan-config

Try a rule before you live under it

Compares two configs over the same snapshot: candidates added, removed, rescored or blocked, drift and lifecycle changes, proposals that would re-plan. Tune thresholds with evidence instead of vibes.

+

ion pause

The kill switch

Creates ~/.ion/PAUSED. Every executor and tracker write stops until ion resume. Reads, grep and reports keep working, so you can investigate with the machine’s hands visibly off the controls.

+
+
+ + + +
+
G4 // Scheduling it, then phase 2Phase 1Phase 2 planned
+

Daily is enough. Dry-run until it has earned more.

+

Phase 1 is a LaunchAgent on a Mac. The installer renders the job file and deliberately does not load it, so you read it first:

+
deploy/install.sh ~/.ion/ion.yml
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.aska.ion.plist
+

It runs ion run daily at 06:30 and logs to ~/.ion/ion.log. The shipped job keeps executors in dry-run. Add --apply only after a few weeks of proposals you would have approved anyway, and keep the source tree outside ~/Documents to avoid macOS privacy prompts. Any scheduler that can run one command a day works as well.

+

Phase 2, when cost or uptime justify it, swaps the laptop for a GitHub App identity and a scheduled GitHub Action with llm.backend: anthropic_api. Only config changes. The pipeline, gates and guards do not move.

+

A sane first month

+
    +
  1. Week 1: dry runs only. Read every proposal. Tune thresholds with plan-config until the noise is gone.
  2. +
  3. Week 2: approve two or three small health or policy proposals by hand. Watch explain after each.
  4. +
  5. Week 3: approve one small merge or autofix. Check the provenance trailers and the tree proof in the pull request.
  6. +
  7. Week 4: schedule the daily run. Promote at most one policy class to fix. Leave archives manual forever, because ion will.
  8. +
+ +
+ +
+
+ + + + + + diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..c7374db --- /dev/null +++ b/site/index.html @@ -0,0 +1,304 @@ + + + + + +Ion reads the whole org. It changes nothing without your yes. + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + IonOrg steward + + +
+ +
+ +
+ + +
+
00 // Infographic abstractBuilt · Apache-2.0Approval before structure
+

A steward bot for a GitHub organisation

+

Ion reads the whole org. It changes nothing without your yes.

+ +

Ion watches every repository in your organisation. It spots drift from your policy, finds repos that belong together or apart, and writes up each change as an exact plan: the evidence, the commands it would run, the way back, and how much of the org it would touch. Then it stops. A maintainer approves that exact plan, or nothing happens. Only then does ion write, through capped, logged requests, with a circuit breaker that halts any run touching too much at once. Archiving a repo always needs its own yes. Ion never deletes, never force-pushes, and never merges its own pull requests.

+ +
+
0
structural changes without a maintainer’s approval. The gate is checked in code, on every run, dry runs included.
The rule
+
6
stages: collect, analyze, propose, approve, execute, report. Only the fifth writes.
Pipeline
+
9
kinds of proposal, from merge and split to policy fix and fleet-wide autofix.
SPEC §2.3
+
25%
of the org in one run trips the circuit breaker, before anything is written.
Default guard
+
+
+ + + +
+
01 // Why an org driftsThe problem
+

Repos multiply. Nobody is watching the shape of the whole.

+

Each repository gets care. The organisation around them does not, because no person has the whole map in their head.

+
+

Drift

Policy fades repo by repo

Branch protection missing here, secret scanning off there, a public repo with no licence. Each gap is small. Together they are the org’s real posture, and nobody chose it.

+

Duplicates

Two repos, one project

Code copied between repos, commits landing in both on the same days, readmes pointing at each other. The split made sense once. The evidence that it stopped making sense sits unread in the history.

+

Stale weight

Finished work never gets put away

Experiments and one-off tools stay active for years because archiving feels risky and nobody owns the decision. New people cannot tell what is alive.

+

Fear of the fix

Cleaning up by hand is scary

Merging two repos without losing history, splitting one safely, proving nothing was dropped: doable, fiddly, and easy to get subtly wrong. So it does not happen.

+
+

Ion’s answer is a division of labour. The machine does the watching, the measuring and the careful writing. People keep the deciding. The next panel is that bargain as a working model.

+
+ + + +
+
02 // The pipeline, in your handsWorking modelYou are the maintainer
+

Read widely. Write narrowly. Wait in the middle.

+

Every ion run walks the same six stages. The first three only read and write words. The fourth belongs to a person. The fifth is the only stage that can change anything, and it re-checks the fourth before it moves. Run the model, then try to break the rules: execute without approving, approve and then change the plan, or widen the blast radius past a quarter of the org.

+ +
+ + +
+ Plannone yet +
+
+ Fingerprintplan-sha256: … + Approvalno approval recorded +
+ +
+ + + + + + + + +
+ +
+ + + 6 of 80 repos (8%) + + amber mark = 25% guard +
+ +

+
+
+

A teaching model with invented repos and a short stand-in fingerprint. The rules are ion’s real ones: approval binds to the plan hash (SPEC §2.4), the breaker guards are the shipped defaults (guide), and the executor re-derives approval from the record instead of trusting a stored flag.

+ +
+

Collect

One query, local mirrors

Metadata comes from one batched GraphQL query under a points budget. File contents come from bare git mirrors on the machine, never from repeated API calls.

+

Analyze

Counting, not guessing

Health, policy drift, coupling, cohesion, lifecycle and tag boundaries. All deterministic. A language model may later help word a proposal. It cannot invent one, add a repo, or raise a score.

+

Report

One dashboard, edited in place

An “Org health” issue lists what waits, what is queued, what is held and what just finished. Ion edits it in place and never comments. A short digest goes to your notifiers, only when something changed.

+
+
+ + + +
+
03 // Approval is for one planMaintainer onlySPEC §2.4
+

A yes means: yes to these commands, seen by this person.

+

Each proposal is one issue in a private tracker repo. Its body holds the evidence, the ordered steps, the exact commands, the rollback, the blast radius, and a fingerprint of the plan: plan-sha256: …, a hash that changes completely if one command changes.

+

A maintainer can answer in three ways. All three are checked the same way: who acted, whether they maintain every repo involved, and which fingerprint was on display when they acted.

+
+

Channel 1

A label on the issue

Add ion:approved, ion:rejected or ion:hold. Ion reads the issue timeline to find who applied it. A decision label from a non-maintainer is recorded as ignored and taken off again, with no comment.

+

Channel 2

A signed bus message

Send ion approve P-0007 over the Divan Bus from an approved seat. The signature is verified, then ion mirrors the decision onto the issue as the label, so the public record stays on GitHub.

+

Channel 3

A tick on the dashboard

Tick the approve or hold box on the Org health issue. Ion checks the edit history: every edit since its own last write must be by a maintainer, or the tick fails closed and counts for nothing.

+
+

Change the plan and the fingerprint changes. The old yes stops counting, the label comes off, and ion asks again.

+
    +
  • Approvals expire after 14 days by default. The label comes off then too.
  • +
  • You can require more than one approver, in total or per proposal kind.
  • +
  • A rejected idea stays quiet for a cooldown period, unless its score rises sharply.
  • +
  • Archiving always needs its own approval, even right after an approved merge.
  • +
+

This reviewer-shaped gate is the heart of ion, and it is why the project sits with the amp team: the work is reviewing. The machine prepares a case worth deciding. A reviewer decides it. See why the name fits.

+
+ + + +
+
04 // How writes stay safeEnforced in codeSPEC §2.5
+

Approved is necessary. It is not sufficient.

+

Even a fully approved run passes through four more checks before and during writing. Each one can stop it without touching anything.

+
+

Typed writes

Every write is a named request

One file, ion/writes.py, is allowed to call a mutating GitHub API. A test enforces that. Each request has a known type, a checked shape, and a per-run cap. Over the cap, work waits for the next run instead of failing. Pushes may only target ion/* branches, never a default branch.

5 PRs · 1 archive

Default caps per run for the two loudest write types. Yours to set.

+

Circuit breaker

A run that looks wrong stops itself

Before writing, the run totals its own blast radius. During the run, every write is checked again. Trip any guard and the run aborts, says so in the digest, and records the trip where ion status shows it.

25% · 2 · 150

Defaults: share of repos touched, archives per run, total writes. Any branch-protection weakening also trips it.

+

Exact verification

Moves are proved, not counted

A merge is accepted only when the imported tree matches the source tree exactly and the source tip is an ancestor of the result. A split must match the rewrite tool’s commit map, commit for commit, plus tree equality. Every moved commit carries an Ion-Origin: org/repo@sha trailer saying where it came from. Counting commits is not a check, so ion never does it.

+

Ledger and kill switch

Stop anytime, resume cleanly

ion pause stops every executor and tracker write. Reads still work. Each step writes a before and after row, so a crashed or interrupted run resumes without repeating finished steps. ion explain P-0007 shows the whole history of one proposal.

+
+

Underneath all of it: nothing irreversible. No deleted repos, branches or history. No force push, in any spelling, ever. Archive instead of delete. A maintainer merges ion’s pull requests. Ion never merges its own.

+
+ + + + + + + + +
GuardDefaultWhat happens when it trips
Share of org touchedover 25%Run aborts before writing. Digest and status record it.
Archives in one runover 2Run aborts. Archiving still needs its own approval each time.
Total writes in one runover 150Run aborts. Per-type caps usually defer work long before this.
Weaker branch protectionanyRun aborts. Ion may strengthen protection. It may not lower it.
Kill switchion pauseAll executors and tracker writes stop. Reads continue.
+

Defaults from the shipped guard config. Every value is yours to change in ion.yml, and ion plan-config previews what a change would do before you adopt it.

+
+ + + +
+
05 // What ion proposes9 kindsSPEC §2.2–2.3
+

Nine kinds of proposal, each with its evidence attached.

+

A candidate must persist across repeated snapshots before it becomes a proposal, so one odd day never files an issue. Structural kinds carry ordered steps: a later step refuses to run until the pull request before it has landed.

+
+ + + + + + + + + + + +
KindWhat it meansTypical evidence
mergeFold repo A into repo B, full history kept, then a pointer PR in ACoupling score and its five signals
splitMove a directory out into a new repo, history kept, removal PR after proofSeparable clusters inside one repo
move_codeMove code between existing repos, with the same two exact checksImports and co-change across the pair
archivePut a finished repo away. Always its own approvalStaged lifecycle: active, stale, archive-proposed
rename · new_repoName and homekeeping for splits and mergesThe parent proposal that needs them
policy_fixBring a repo back to policy: protection, alerts, scanning, required filesDrift from ion.yml, with an action level per class
health_fixMissing readme, licence, CI or descriptionPer-repo health checks
autofixOne small change across many repos, as a single proposal of exact diffsion autofix preview diffs over the mirrors
+

How “these two belong together” is scored

+

Five signals per repo pair, combined so that one saturated signal alone just reaches the merge threshold of 0.6. Weak signals need company. The name-prefix signal is deliberately tiny: repos called protean-* share a naming scheme, not a life, and ion’s first live run proved the point with 265 pairs that were mostly that.

+
+ + + + + + + + +
SignalWeightHygiene
Cross-references0.6Manifests, imports, git URLs, readme links
Duplicated code0.6Identical files plus near-duplicates. Vendored, lock and generated files ignored
Co-change0.6Shared commit days over average commit days. Huge commits, bots and ion itself excluded. Half-weighted unless a content signal agrees
Shared dependencies0.3Overlap of the two dependency sets
Shared name prefix0.05Counts only when another signal is already at least 0.15
+

Tag rules add boundaries: mark repos with topics such as scope:app, say which scopes may depend on which, and cross-scope merges are blocked or flagged before they are ever proposed. Two fleet tools ride on the same mirrors: ion grep searches every repo with no API calls, and ion autofix previews a change everywhere, then files one proposal for exactly those diffs.

+
+ + + +
+
06 // StatusSPEC §7–8
+

What is built, what is not, and what will never be.

+
+

Built and tested

    +
  • The full six-stage pipeline, offline tests end to end: merge, split, move and autofix run against local bare repos
  • +
  • Plan-hash approval on all three channels, with expiry and quorum
  • +
  • Typed writes with caps, circuit breaker, kill switch, resumable ledger
  • +
  • Exact move verification and provenance trailers
  • +
  • Org health dashboard, digests, fleet grep and autofix, config preview
  • +
  • Phase 1 deployment: a daily scheduled run on a Mac, executors in dry-run until you add --apply
  • +
+

Not yet built

    +
  • Splits that prefer cuts along tag boundaries. Tags are per-repo today, so there is nothing finer to cut along
  • +
  • Importing several branches or tags on merge. Default branch only
  • +
  • Pinning the dashboard issue automatically. Pin it once by hand
  • +
  • Later survey ideas: path-level owners, scorecard input, conditional rules
  • +
  • Phase 2: a GitHub App identity on a hosted schedule. Config changes only, when uptime justifies it
  • +
+

Out of scope, by design

    +
  • Deleting repos, branches or history. Force pushes
  • +
  • Merging its own pull requests
  • +
  • Reviewing other people’s pull requests, or writing feature code
  • +
  • Team and membership management
  • +
  • Webhooks or a live server. One scheduled pass a day is the design
  • +
+
+

Run the checks yourself: uv run pytest -q and uv run python scripts/check_licenses.py. The licence guard fails the build if any dependency falls outside MIT, BSD, Apache-2.0, ISC, PSF or MPL-2.0. No GPL dependency, ever.

+
+ + + +
+
07 // Why ion ἰόν · going
+

An ion is something that goes, along a path, carrying charge.

+

Michael Faraday coined the word in 1834, from the Greek ἰόν, “going”, for charged particles that move through a solution toward an electrode. Four threads tie the name to the work:

+
+

Going

It moves through every repo

Ion travels the whole org on each run and carries a signal from where the evidence sits to where the decision happens.

+

Lattices

It holds the structure

In a crystal, ions sit on the points of a lattice and the pull between them holds it together. Ion watches how repos relate and keeps that structure sound. The lattice is also home ground: it runs in the same family as the incentre lattice and Exo.

+

Charge

It acts only along approved paths

Charge does work only along a path. Ion’s paths are the three approval channels. Off those paths, no current flows and nothing changes.

+

The amp team

Current is ions in motion

Current is measured in amperes, and ion is owned and reviewed by the amp team, whose craft is reviewing. That fits the design: ion’s centre of gravity is a review gate. In the live Aska Digital deployment, proposals are prepared for a reviewer’s yes or no, and the reviewer’s word is what turns a plan into current.

+
+
+ + + +
+
08 // Prior art, and where to go nextIdeas credited, code original
+

Standing on published designs. Copying none of them.

+

Ion’s mechanisms come from other projects’ documented behaviour, re-implemented in its own words. Nothing is vendored. The typed-write broker follows GitHub Agentic Workflows’ safe outputs and actions/stale’s per-run caps. The breaker follows Peribolos. Plan-bound approval follows policy-bot. The dashboard follows Renovate’s layout. Coupling follows Adam Tornhill’s change-coupling method. The full credit table names all twenty-five, each with its licence, checked on 2026-10-13.

+ +

Security problems go through private reporting, described in SECURITY.md. Be kind, per the code of conduct.

+
+ +
+
+ + + + + + + diff --git a/site/og.html b/site/og.html new file mode 100644 index 0000000..4fa6b09 --- /dev/null +++ b/site/og.html @@ -0,0 +1,57 @@ + + + + + +Share image + + + + + + +
+
+ + Ion · org steward bot +
+

Ion reads the whole org. It changes nothing without your yes.

+

Merges, splits and policy fixes, proposed as exact hashed plans. A maintainer approves the plan. Only then does ion write.

+
collectanalyzeproposeapprove · gateexecutereport
+
+
github.com/aska-digital/ion · Apache-2.0 · Aska Digital
+ + diff --git a/site/og.png b/site/og.png new file mode 100644 index 0000000..3160029 Binary files /dev/null and b/site/og.png differ diff --git a/site/shell.js b/site/shell.js new file mode 100644 index 0000000..d361427 --- /dev/null +++ b/site/shell.js @@ -0,0 +1,131 @@ +/* Shared shell: side index, reveal on scroll, lattice backdrop, build stamp. + Plain JavaScript, no dependencies. Every effect is decoration: the page + reads the same with this file missing. */ +(function () { + "use strict"; + var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + /* ---- Side index, built from the panels themselves ---- */ + var aside = document.querySelector("aside.index"); + var panels = Array.prototype.slice.call(document.querySelectorAll("main section[data-idx]")); + if (aside && panels.length) { + var title = aside.getAttribute("data-title") || "Index"; + var page = aside.getAttribute("data-page") || ""; + var html = '
' + + '' + + '

' + title + "

    "; + panels.forEach(function (s) { + html += '
  1. ' + s.getAttribute("data-idx") + + "" + (s.getAttribute("data-short") || s.id) + "
  2. "; + }); + html += '
' + + 'Abstract" + + 'Guide: run ion" + + 'Repository' + + 'Full spec (SPEC.md)' + + "
" + + '

Cyan the machine reads and proposes.
' + + 'Amber a maintainer decides.
Nothing structural moves without both.

' + + "
"; + aside.innerHTML = html; + + var toggle = aside.querySelector(".index-toggle"); + toggle.addEventListener("click", function () { + var open = aside.classList.toggle("open"); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + + var links = {}; + aside.querySelectorAll("ol a").forEach(function (a) { + links[a.getAttribute("href").slice(1)] = a; + }); + if ("IntersectionObserver" in window) { + var spy = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (!e.isIntersecting) return; + Object.keys(links).forEach(function (k) { links[k].classList.remove("on"); }); + if (links[e.target.id]) links[e.target.id].classList.add("on"); + }); + }, { rootMargin: "-30% 0px -60% 0px" }); + panels.forEach(function (s) { spy.observe(s); }); + } + } + + /* ---- Reveal on scroll ---- */ + var reveals = document.querySelectorAll(".reveal"); + if (reduced || !("IntersectionObserver" in window)) { + reveals.forEach(function (el) { el.classList.add("in"); }); + } else { + var io = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { + if (e.isIntersecting) { + e.target.classList.add("in"); + io.unobserve(e.target); + setTimeout(function () { e.target.classList.add("done"); }, 900); + } + }); + }, { threshold: 0.08 }); + reveals.forEach(function (el) { io.observe(el); }); + } + + /* ---- Lattice backdrop: a still grid of repos, a few ions drifting ---- */ + var cv = document.getElementById("lattice"); + if (cv && cv.getContext) { + var ctx = cv.getContext("2d"); + var nodes = [], ions = [], W = 0, H = 0, gap = 132; + function build() { + var dpr = Math.min(window.devicePixelRatio || 1, 2); + W = window.innerWidth; H = window.innerHeight; + cv.width = W * dpr; cv.height = H * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + nodes = []; + for (var y = gap / 2; y < H + gap; y += gap) + for (var x = gap / 2; x < W + gap; x += gap) + nodes.push({ x: x + ((x * 7 + y * 13) % 17) - 8, y: y + ((x * 3 + y * 11) % 13) - 6 }); + ions = []; + for (var i = 0; i < 7; i++) ions.push(spawn()); + } + function spawn() { + var a = nodes[Math.floor(Math.random() * nodes.length)] || { x: 0, y: 0 }; + var near = nodes.filter(function (n) { return n !== a && Math.abs(n.x - a.x) + Math.abs(n.y - a.y) < gap * 1.6; }); + var b = near[Math.floor(Math.random() * near.length)] || a; + return { a: a, b: b, t: Math.random(), sp: 0.0006 + Math.random() * 0.0011, amber: Math.random() < 0.3 }; + } + function draw(now) { + ctx.clearRect(0, 0, W, H); + ctx.strokeStyle = "rgba(95,212,230,0.05)"; + ctx.lineWidth = 1; + nodes.forEach(function (n) { + nodes.forEach(function (m) { + if (m === n) return; + var d = Math.abs(m.x - n.x) + Math.abs(m.y - n.y); + if (d > gap * 1.1 && d < gap * 1.3) { ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(m.x, m.y); ctx.stroke(); } + }); + }); + ctx.fillStyle = "rgba(148,178,205,0.20)"; + nodes.forEach(function (n) { ctx.beginPath(); ctx.arc(n.x, n.y, 1.6, 0, 6.283); ctx.fill(); }); + ions.forEach(function (ion, i) { + if (!reduced) ion.t += ion.sp * 16; + if (ion.t >= 1) { ions[i] = spawn(); return; } + var x = ion.a.x + (ion.b.x - ion.a.x) * ion.t; + var y = ion.a.y + (ion.b.y - ion.a.y) * ion.t; + ctx.fillStyle = ion.amber ? "rgba(232,163,61,0.9)" : "rgba(95,212,230,0.9)"; + ctx.beginPath(); ctx.arc(x, y, 2.1, 0, 6.283); ctx.fill(); + }); + if (!reduced) requestAnimationFrame(draw); + } + var rt; + window.addEventListener("resize", function () { clearTimeout(rt); rt = setTimeout(function () { build(); if (reduced) draw(0); }, 200); }); + build(); + if (reduced) draw(0); else requestAnimationFrame(draw); + } + + /* ---- Build stamp in the footer, when build.sh wrote one ---- */ + var stamp = document.getElementById("buildstamp"); + if (stamp && window.fetch) { + fetch("data/build.json").then(function (r) { return r.ok ? r.json() : null; }).then(function (j) { + if (!j) return; + stamp.textContent = "Built " + j.built + (j.commit ? " · " + j.commit.slice(0, 7) : ""); + }).catch(function () { /* local file preview: no stamp */ }); + } +})(); diff --git a/site/sim.js b/site/sim.js new file mode 100644 index 0000000..68d81b9 --- /dev/null +++ b/site/sim.js @@ -0,0 +1,171 @@ +/* The pipeline, as a working model. This is a teaching model of ion's real + rules, with made-up repos and a short stand-in hash: + - the machine may read and propose freely, but the approve stage is a gate; + - an approval counts only for the plan hash on display when it was given; + - change the plan and the hash changes, the approval stops counting; + - a run touching more than a quarter of the org trips the circuit breaker + before it writes anything. */ +(function () { + "use strict"; + var root = document.getElementById("sim"); + if (!root) return; + var reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + var $ = function (id) { return root.querySelector("#" + id); }; + var stages = Array.prototype.slice.call(root.querySelectorAll(".stage")); + var logEl = $("sim-log"), verdict = $("sim-verdict"); + var hashEl = $("sim-hash"), apprEl = $("sim-approved"), planEl = $("sim-plan"); + var blastInput = $("sim-blast"), blastVal = $("sim-blastval"), meter = $("sim-meter"), meterFill = $("sim-meterfill"); + + var ORG_REPOS = 80; + var plans = [ + "[P-0007] merge widget-docs into widget (git subtree add, no squash), then a pointer PR in widget-docs", + "[P-0007] merge widget-docs into widget (git subtree add, no squash), then a pointer PR in widget-docs, then archive widget-docs", + "[P-0007] merge widget-docs into widget under docs/ (git subtree add, no squash), then a pointer PR in widget-docs" + ]; + var S = null; + + function fnv(str) { + var h = 0x811c9dc5; + for (var i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } + var out = ""; + for (var k = 0; k < 4; k++) { h = Math.imul(h ^ (h >>> 13), 0x5bd1e995) >>> 0; out += ("0000000" + h.toString(16)).slice(-8); } + return out; + } + function tick() { S.t += 1; return "T+" + S.t; } + function log(cls, msg) { + var p = document.createElement("p"); + p.innerHTML = '' + tick() + '' + msg + ""; + logEl.appendChild(p); + logEl.scrollTop = logEl.scrollHeight; + } + function setStage(i, cls, note) { + stages.forEach(function (el, j) { + el.classList.remove("active", "done", "blocked"); + if (j < i) el.classList.add("done"); + if (j === i && cls) el.classList.add(cls); + }); + if (note && stages[i]) stages[i].querySelector(".ss").textContent = note; + } + function blastPct() { return S.blast / ORG_REPOS; } + function render() { + planEl.textContent = plans[S.plan % plans.length]; + var h = fnv(plans[S.plan % plans.length]); + S.hash = h; + hashEl.textContent = "plan-sha256: " + h.slice(0, 16) + "…"; + hashEl.classList.toggle("stale", !!(S.approvedHash && S.approvedHash !== h)); + apprEl.textContent = S.approvedHash + ? (S.approvedHash === h ? "approved for this plan" : "approval was for " + S.approvedHash.slice(0, 16) + "… (does not count)") + : "no approval recorded"; + blastVal.textContent = S.blast + " of " + ORG_REPOS + " repos (" + Math.round(blastPct() * 100) + "%)"; + meterFill.style.width = Math.min(100, blastPct() * 100) + "%"; + meter.classList.toggle("over", blastPct() > 0.25); + $("b-approve").disabled = S.phase !== "awaiting"; + $("b-hold").disabled = S.phase !== "awaiting"; + $("b-execute").disabled = !(S.phase === "awaiting" || S.phase === "approved"); + $("b-replan").disabled = !(S.phase === "awaiting" || S.phase === "approved"); + $("b-run").disabled = S.phase !== "idle"; + } + function say(msg) { verdict.textContent = msg; } + function step(fn, delay) { S.timers.push(setTimeout(fn, reduced ? 30 : delay)); } + + function reset() { + (S ? S.timers : []).forEach(clearTimeout); + S = { phase: "idle", plan: 0, hash: "", approvedHash: null, blast: parseInt(blastInput.value, 10) || 6, t: 0, timers: [] }; + logEl.innerHTML = ""; + stages.forEach(function (el) { el.classList.remove("active", "done", "blocked"); var ss = el.querySelector(".ss"); ss.textContent = ss.getAttribute("data-idle"); }); + log("si", "ion is idle. Reads are free. Writes are not."); + say("Press run. Ion will read the whole org, write a plan, and stop at the gate. The gate is yours."); + render(); + } + + function run() { + if (S.phase !== "idle") return; + S.phase = "running"; render(); + setStage(0, "active", "one batched GraphQL query, local git mirrors"); + log("si", "collect: metadata for " + ORG_REPOS + " repos, mirrors refreshed. No writes."); + step(function () { + setStage(1, "active", "health, policy drift, coupling, cohesion, lifecycle"); + log("si", "analyze: widget-docs and widget share commits, code and links. Coupling 0.71, over the 0.6 merge threshold."); + }, 900); + step(function () { + setStage(2, "active", "scored, persisted 2 runs, plan written by the executor"); + log("si", "propose: [P-0007] filed in the private tracker with evidence, exact commands, rollback and its plan hash."); + }, 1800); + step(function () { + S.phase = "awaiting"; + setStage(3, "active", "waiting: label, signed bus message, or dashboard tick"); + log("hu", "approve: the machine stops here. A maintainer decides, and only for the plan above."); + say("Ion has done all it may do alone. Approve this exact plan, hold it, or change the plan and watch the approval rule bite."); + render(); + }, 2700); + } + + function approve() { + if (S.phase !== "awaiting") return; + S.approvedHash = S.hash; S.phase = "approved"; + setStage(3, "done", "maintainer approved this plan hash"); + log("hu", "maintainer added ion:approved. Timeline actor checked. Approval bound to " + S.hash.slice(0, 16) + "…"); + say("Approved, for this plan and no other. Execute when ready, or change the plan first and see what happens to that approval."); + render(); + } + function hold() { + if (S.phase !== "awaiting") return; + S.phase = "held"; + setStage(3, "blocked", "on hold by a maintainer"); + log("hu", "maintainer added ion:hold. The proposal waits. Reset the model to try another path."); + say("Held. Nothing runs, nothing is lost. In the real system the proposal simply waits for a later decision."); + render(); + } + function replan() { + if (!(S.phase === "awaiting" || S.phase === "approved")) return; + var wasApproved = S.approvedHash === S.hash; + S.plan += 1; S.phase = "awaiting"; + setStage(2, "active", "re-planned: commands changed, new hash"); + log("si", "propose: new evidence changed the plan. The hash changed with it."); + if (wasApproved) log("no", "approve: the old approval was for a different hash. ion:approved comes off the issue. Approval is asked again."); + step(function () { setStage(3, "active", "waiting again: the plan on display is new"); render(); }, 700); + say("The plan changed, so its fingerprint changed. Any earlier yes was a yes to different commands, and ion treats it as no answer at all."); + render(); + } + function execute() { + if (!(S.phase === "awaiting" || S.phase === "approved")) return; + if (S.approvedHash !== S.hash) { + setStage(4, "blocked", "refused at the gate"); + log("no", "execute: refused. No verified approval for the current plan hash. A database flag would not be enough either; the gate re-derives approval from the record."); + say("Refused, and rightly. The executor checks the approval itself, every time, even for a dry run."); + return; + } + if (blastPct() > 0.25) { + setStage(4, "blocked", "circuit breaker tripped"); + log("no", "execute: circuit breaker. This run would touch " + Math.round(blastPct() * 100) + "% of the org, over the 25% guard. Aborted before any write."); + say("Even with approval, a run that touches too much at once stops itself. Lower the blast radius and execute again."); + return; + } + S.phase = "executing"; + setStage(4, "active", "typed writes only, capped per run, every step ledgered"); + log("si", "execute: gate passed for " + S.hash.slice(0, 16) + "… Dry-run checks done. Writing through typed requests (cap: 5 PRs per run)."); + step(function () { log("si", "merge verified exactly: target tree equals source tree, source tip is an ancestor. Commits carry Ion-Origin trailers."); }, 1000); + step(function () { + S.phase = "done"; + setStage(5, "done", "dashboard updated, digest sent"); + stages[4].classList.add("done"); + log("ok", "report: Org health issue updated in place, digest sent. A maintainer merges the PR. Ion never merges its own."); + say("Done, in the only order ion allows: read widely, propose plainly, wait for a person, then write narrowly and prove it."); + render(); + }, 2000); + render(); + } + + $("b-run").addEventListener("click", run); + $("b-approve").addEventListener("click", approve); + $("b-hold").addEventListener("click", hold); + $("b-replan").addEventListener("click", replan); + $("b-execute").addEventListener("click", execute); + $("b-reset").addEventListener("click", reset); + blastInput.addEventListener("input", function () { + S.blast = parseInt(blastInput.value, 10) || 0; + if (blastPct() > 0.25) log("no", "guard check: " + S.blast + " repos is over a quarter of the org. An --apply run would trip the breaker."); + render(); + }); + reset(); +})(); diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..43574a9 --- /dev/null +++ b/site/style.css @@ -0,0 +1,301 @@ +/* Ion: a steward bot for a GitHub organisation. + Plain CSS, no build step, no web fonts. A system serif carries the prose, + system mono carries data, labels and code. Two colours do the explaining: + cyan is the machine's signal, amber is the maintainer's hand. Current only + flows where the two meet. */ +:root { + --bg0: #06090d; + --bg1: #0a1017; + --panel: rgba(11, 17, 26, 0.90); + --panel2: rgba(15, 23, 34, 0.94); + --well: #05080c; + --line: rgba(148, 178, 205, 0.16); + --line2: rgba(148, 178, 205, 0.32); + --ink: #eef2f5; + --ink2: #bcc7d1; + --dim: #8492a1; + --sig: #5fd4e6; + --sig-hi: #a8ecf7; + --sig-lo: rgba(95, 212, 230, 0.12); + --hum: #e8a33d; + --hum-hi: #ffcf87; + --hum-lo: rgba(232, 163, 61, 0.13); + --ok: #5fce8a; + --stop: #e66a5f; + --serif: "Iowan Old Style", "Charter", "Bitstream Charter", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; + --mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + --radius: 3px; + --head-h: 56px; + color-scheme: dark; +} +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; scroll-padding-top: calc(var(--head-h) + 16px); } +body { + margin: 0; + font: 17px/1.62 var(--serif); + color: var(--ink); + background: var(--bg0); + background-image: + radial-gradient(1100px 640px at 78% -8%, rgba(38, 84, 110, 0.30), transparent 70%), + radial-gradient(820px 560px at -12% 42%, rgba(24, 48, 72, 0.26), transparent 70%), + linear-gradient(180deg, #080d14 0%, var(--bg0) 60%); + background-attachment: fixed; + min-height: 100vh; + text-rendering: optimizeLegibility; +} +#lattice { position: fixed; inset: 0; width: 100%; height: 100%; z-index: -1; pointer-events: none; opacity: 0.55; } + +a { color: var(--sig-hi); text-decoration: underline; text-decoration-color: rgba(168, 236, 247, 0.35); text-underline-offset: 3px; } +a:hover { text-decoration-color: var(--sig-hi); } +:focus-visible { outline: 2px solid var(--sig); outline-offset: 2px; border-radius: 2px; } +code { font: 0.86em var(--mono); color: var(--ink2); background: rgba(148, 178, 205, 0.09); padding: 0.05em 0.35em; border-radius: 2px; } +strong { font-weight: 600; } +h1, h2, h3 { font-weight: 500; letter-spacing: -0.005em; line-height: 1.2; } +h1 { font-size: clamp(32px, 5.4vw, 54px); margin: 0.2em 0 0.35em; } +h2 { font-size: clamp(23px, 3vw, 30px); margin: 0 0 0.45em; } +h3 { font-size: 19px; margin: 1.2em 0 0.4em; } +p { margin: 0 0 0.9em; } +.sr { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.mono { font-family: var(--mono); } +.muted { color: var(--dim); } +.skip { position: absolute; left: -999px; top: 8px; z-index: 100; background: var(--ink); color: var(--bg0); padding: 8px 12px; font: 14px var(--mono); } +.skip:focus { left: 8px; } + +/* ---------- Header ---------- */ +header.top { + position: sticky; top: 0; z-index: 30; height: var(--head-h); + background: rgba(6, 10, 15, 0.84); + -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); +} +header.top .bar { max-width: 1280px; margin: 0 auto; height: 100%; padding: 0 22px; display: flex; align-items: center; gap: 22px; } +.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; font: 500 17px/1 var(--serif); white-space: nowrap; } +.brand svg { flex: none; } +.brand small { font: 10.5px/1 var(--mono); letter-spacing: 0.16em; color: var(--dim); text-transform: uppercase; margin-left: 2px; } +header.top nav { margin-left: auto; } +header.top nav ul { list-style: none; display: flex; gap: 4px; margin: 0; padding: 0; } +header.top nav a { display: block; padding: 7px 11px; font: 12px/1 var(--mono); letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink2); text-decoration: none; border: 1px solid transparent; border-radius: var(--radius); } +header.top nav a:hover { color: var(--ink); border-color: var(--line2); } +header.top nav a[aria-current="page"] { color: var(--sig-hi); border-color: rgba(95, 212, 230, 0.45); background: var(--sig-lo); } + +/* ---------- Layout with side index ---------- */ +.layout { max-width: 1280px; margin: 0 auto; padding: 34px 22px 60px; display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 44px; } +.layout > main { min-width: 0; max-width: 880px; } +aside.index { position: relative; } +aside.index .inner { position: sticky; top: calc(var(--head-h) + 28px); } +aside.index .btn.index-toggle { display: none; } +.index h2 { font: 10.5px/1.4 var(--mono); letter-spacing: 0.2em; color: var(--dim); text-transform: uppercase; margin: 0 0 12px; } +.index ol { list-style: none; margin: 0; padding: 0; border-left: 1px solid var(--line); } +.index li a { display: flex; gap: 10px; align-items: baseline; padding: 5px 0 5px 14px; margin-left: -1px; border-left: 1px solid transparent; color: var(--dim); text-decoration: none; font: 13.5px/1.35 var(--serif); transition: color 0.2s, border-color 0.2s; } +.index li a .n { font: 10.5px/1 var(--mono); color: rgba(132, 146, 161, 0.8); letter-spacing: 0.08em; min-width: 18px; } +.index li a:hover { color: var(--ink); } +.index li a.on { color: var(--ink); border-left-color: var(--sig); } +.index li a.on .n { color: var(--sig); } +.index .sub { margin-top: 22px; } +.index .sub a { display: block; padding: 3px 0; font: 12px/1.5 var(--mono); letter-spacing: 0.06em; color: var(--ink2); text-decoration: none; } +.index .sub a:hover { color: var(--sig-hi); } +.index .sub a[aria-current="page"] { color: var(--sig-hi); } +.index .key { margin-top: 26px; font: 11.5px/1.7 var(--mono); color: var(--dim); } +.index .key b { font-weight: 400; } +.index .key .ks { color: var(--sig); } +.index .key .kh { color: var(--hum); } + +/* ---------- Panels: the infographic abstract ---------- */ +.panel { + position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: 26px 30px 28px; box-shadow: 0 30px 60px -40px rgba(0, 0, 0, 0.9); +} +.panel::before, .panel::after { content: ""; position: absolute; width: 12px; height: 12px; border: 1px solid var(--sig); opacity: 0.65; pointer-events: none; } +.panel::before { left: -1px; top: -1px; border-right: 0; border-bottom: 0; } +.panel::after { right: -1px; bottom: -1px; border-left: 0; border-top: 0; } +.js .reveal { opacity: 0; transform: translateY(18px); transition: opacity 0.8s ease, transform 0.8s cubic-bezier(0.2, 0.7, 0.2, 1); } +.js .reveal.in { opacity: 1; transform: none; } +.js .reveal.done { transition: none; } +.plabel { display: flex; justify-content: space-between; align-items: center; gap: 10px 14px; flex-wrap: wrap; margin: 0 0 16px; font: 11px/1.4 var(--mono); letter-spacing: 0.2em; text-transform: uppercase; color: var(--dim); } +.plabel b { font-weight: 400; color: var(--ink2); } +.plabel b .k { color: var(--sig); } +.plabel .tags { display: flex; gap: 6px; flex-wrap: wrap; } +.lede { font-size: 18.5px; line-height: 1.6; color: var(--ink); } +.panel p { color: var(--ink2); } +.panel p.lede { color: var(--ink); } + +/* connector between panels: a dashed path with one travelling ion */ +.link { position: relative; height: 46px; } +.link::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; border-left: 1px dashed rgba(95, 212, 230, 0.4); } +.link i { position: absolute; left: 50%; top: 0; width: 5px; height: 5px; margin-left: -2px; border-radius: 50%; background: var(--sig-hi); box-shadow: 0 0 8px 2px rgba(95, 212, 230, 0.55); animation: ionflow 2.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; } +@keyframes ionflow { 0% { top: 0; opacity: 0; } 15% { opacity: 1; } 85% { opacity: 1; } 100% { top: 100%; opacity: 0; } } + +/* ---------- Tags: say what kind of claim each panel makes ---------- */ +.tag { display: inline-flex; align-items: center; gap: 6px; font: 10.5px/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink2); border: 1px solid var(--line2); padding: 4px 7px; border-radius: 2px; white-space: nowrap; background: rgba(5, 8, 12, 0.5); } +.tag.t-built { color: var(--sig-hi); border-color: rgba(95, 212, 230, 0.5); background: var(--sig-lo); } +.tag.t-built::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--sig); box-shadow: 0 0 6px var(--sig); animation: pulse 2.4s ease-in-out infinite; } +.tag.t-hum { color: var(--hum-hi); border-color: rgba(232, 163, 61, 0.55); background: var(--hum-lo); } +.tag.t-plan { color: var(--ink2); border-style: dashed; } +.tag.t-spec { color: #d8cdb2; border-color: rgba(216, 205, 178, 0.35); } +@keyframes pulse { 0%, 100% { opacity: 0.45; } 50% { opacity: 1; } } + +/* ---------- Hero ---------- */ +.hero .eyebrow { font: 11.5px/1.4 var(--mono); letter-spacing: 0.24em; color: var(--sig); text-transform: uppercase; margin: 0; } +.hero h1 { max-width: 16em; } +.hero h1 em { font-style: italic; color: var(--sig-hi); } +.hero .byline { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 0 0 18px; font: 12.5px/1.5 var(--mono); letter-spacing: 0.06em; color: var(--ink2); } +.hero .byline .who { color: var(--ink); } +.hero .abstract { border-left: 2px solid var(--sig); padding: 2px 0 2px 18px; margin: 0 0 22px; font-size: 18px; line-height: 1.65; color: var(--ink); } +.hero .abstract .hum { color: var(--hum-hi); } +.cta { display: flex; flex-wrap: wrap; gap: 10px; margin: 20px 0 6px; } +.btn, a.btn { + display: inline-flex; align-items: center; gap: 8px; padding: 10px 15px; font: 12px/1 var(--mono); letter-spacing: 0.12em; text-transform: uppercase; + color: var(--ink); text-decoration: none; border: 1px solid var(--line2); border-radius: var(--radius); background: rgba(10, 16, 24, 0.7); cursor: pointer; + transition: border-color 0.2s, background 0.2s, color 0.2s; +} +.btn:hover { border-color: var(--sig); color: var(--sig-hi); } +.btn.primary { border-color: rgba(95, 212, 230, 0.65); background: var(--sig-lo); color: var(--sig-hi); } +.btn.primary:hover { background: rgba(95, 212, 230, 0.22); } +.btn.hum { border-color: rgba(232, 163, 61, 0.6); color: var(--hum-hi); } +.btn.hum:hover { border-color: var(--hum); background: var(--hum-lo); color: var(--hum-hi); } +.btn .arr { transition: transform 0.2s; } +.btn:hover .arr { transform: translateX(3px); } +.btn:disabled { opacity: 0.4; cursor: default; } +.btn.small { padding: 7px 10px; font-size: 11px; } +.btn[aria-pressed="true"] { border-color: var(--hum); background: var(--hum-lo); color: var(--hum-hi); } + +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); margin: 22px 0 0; overflow: hidden; } +.stat { background: var(--panel2); padding: 14px 16px 15px; min-width: 0; } +.stat .v { font: 400 clamp(26px, 3.6vw, 34px)/1.1 var(--serif); color: var(--ink); font-variant-numeric: tabular-nums lining-nums; } +.stat .v small { font-size: 0.5em; color: var(--ink2); margin-left: 3px; } +.stat .l { font-size: 13.5px; line-height: 1.4; color: var(--ink2); margin-top: 6px; } +.stat .src { margin-top: 9px; } +.stat.hl .v { color: var(--hum-hi); } + +/* ---------- Figures ---------- */ +.fig { position: relative; border: 1px solid var(--line); border-radius: var(--radius); background: var(--well); overflow: hidden; margin: 6px 0 0; } +.fig canvas { display: block; width: 100%; touch-action: manipulation; } +.fig .corner { position: absolute; left: 10px; top: 8px; font: 10.5px/1.35 var(--mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); pointer-events: none; z-index: 2; } +.fig .corner.r { left: auto; right: 10px; text-align: right; } +.cap { font-size: 14.5px; line-height: 1.55; color: var(--dim) !important; margin: 10px 2px 0; } +.cap a { color: var(--ink2); } +.readout { display: flex; flex-wrap: wrap; gap: 6px 18px; font: 12px/1.5 var(--mono); color: var(--ink2); margin: 12px 0 0; } +.readout span b { font-weight: 400; color: var(--dim); margin-right: 6px; letter-spacing: 0.08em; text-transform: uppercase; font-size: 10.5px; } + +/* ---------- Cards, tables, lists ---------- */ +.cards { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 18px 0 4px; } +.cards.c3 { grid-template-columns: repeat(3, 1fr); } +.card { border: 1px solid var(--line); background: rgba(95, 212, 230, 0.03); padding: 14px 16px 6px; min-width: 0; border-radius: var(--radius); } +.card.hum { border-color: rgba(232, 163, 61, 0.35); background: rgba(232, 163, 61, 0.035); } +.card .ck { font: 10.5px/1.3 var(--mono); letter-spacing: 0.16em; text-transform: uppercase; color: var(--sig); margin: 0 0 7px; } +.card.hum .ck { color: var(--hum); } +.card h3 { margin: 0 0 6px; font-size: 16.5px; } +.card p { font-size: 14.5px; line-height: 1.58; } +.card .big { font: 400 clamp(24px, 3.4vw, 32px)/1.15 var(--serif); color: var(--ink); margin: 0 0 4px; font-variant-numeric: tabular-nums; } +table.tb { width: 100%; border-collapse: collapse; margin: 14px 0 8px; font-size: 14px; } +table.tb th, table.tb td { text-align: left; padding: 7px 9px; border-bottom: 1px solid var(--line); vertical-align: top; } +table.tb th { font: 10.5px/1.3 var(--mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--dim); font-weight: 400; } +table.tb td { color: var(--ink2); } +table.tb td:first-child { color: var(--ink); white-space: nowrap; } +table.tb .num { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; } +.scrollx { overflow-x: auto; } +ul.plain { margin: 0 0 0.9em; padding-left: 1.15em; color: var(--ink2); } +ul.plain li { margin-bottom: 0.35em; } +ol.steps { margin: 0 0 0.9em; padding-left: 1.4em; color: var(--ink2); } +ol.steps li { margin-bottom: 0.6em; } +ol.steps li::marker { font-family: var(--mono); font-size: 0.8em; color: var(--sig); } +pre.code { background: var(--well); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px; overflow-x: auto; font: 13px/1.6 var(--mono); color: var(--ink2); margin: 10px 0 14px; } +pre.code .cm { color: var(--dim); } +pre.code .hl { color: var(--hum-hi); } +.pull { font-size: clamp(20px, 3vw, 26px); line-height: 1.4; color: var(--ink); border-left: 2px solid var(--hum); padding-left: 18px; margin: 22px 0 6px; } +.note { font-size: 14px; color: var(--dim); } + +/* ---------- The pipeline simulator ---------- */ +.sim { border: 1px solid var(--line); border-radius: var(--radius); background: var(--well); padding: 18px 18px 16px; margin: 8px 0 0; } +.sim .stages { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; } +.stage { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 10px 9px; min-width: 0; background: rgba(11, 17, 26, 0.6); transition: border-color 0.3s, background 0.3s; } +.stage .sn { font: 10px/1 var(--mono); letter-spacing: 0.14em; color: var(--dim); text-transform: uppercase; } +.stage .st-name { font: 12.5px/1.25 var(--mono); color: var(--ink2); margin-top: 5px; word-break: break-word; } +.stage .ss { font: 10.5px/1.35 var(--mono); color: var(--dim); margin-top: 6px; min-height: 2.7em; } +.stage.active { border-color: var(--sig); background: var(--sig-lo); } +.stage.active .st-name { color: var(--sig-hi); } +.stage.done { border-color: rgba(95, 206, 138, 0.45); } +.stage.done .ss { color: var(--ok); } +.stage.gate { border-style: dashed; } +.stage.gate.active { border-color: var(--hum); background: var(--hum-lo); border-style: solid; } +.stage.gate.active .st-name { color: var(--hum-hi); } +.stage.blocked { border-color: var(--stop); } +.stage.blocked .ss { color: var(--stop); } +.sim .planline { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline; margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line); font: 12.5px/1.6 var(--mono); color: var(--ink2); } +.sim .planline b { font-weight: 400; color: var(--dim); font-size: 10.5px; letter-spacing: 0.1em; text-transform: uppercase; margin-right: 7px; } +.sim .hash { color: var(--sig-hi); word-break: break-all; } +.sim .hash.stale { color: var(--stop); text-decoration: line-through; } +.sim .controls { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 0; align-items: center; } +.sim .controls .sep { width: 1px; align-self: stretch; background: var(--line); margin: 2px 4px; } +.sim .blast { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin: 16px 0 0; font: 12px/1.5 var(--mono); color: var(--ink2); } +.sim .blast label { letter-spacing: 0.06em; } +.sim input[type="range"] { accent-color: var(--hum); width: min(260px, 60vw); } +.sim .meter { flex: 1 1 180px; height: 8px; border: 1px solid var(--line2); border-radius: 2px; position: relative; overflow: hidden; background: rgba(11, 17, 26, 0.8); } +.sim .meter i { position: absolute; left: 0; top: 0; bottom: 0; background: var(--sig); transition: width 0.25s, background 0.25s; } +.sim .meter.over i { background: var(--stop); } +.sim .meter .mark { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--hum); } +.sim .log { margin: 16px 0 0; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(5, 8, 12, 0.7); padding: 10px 12px; font: 12px/1.65 var(--mono); color: var(--ink2); max-height: 168px; overflow-y: auto; } +.sim .log p { margin: 0; } +.sim .log .t { color: var(--dim); margin-right: 8px; } +.sim .log .ok { color: var(--ok); } +.sim .log .no { color: var(--stop); } +.sim .log .hu { color: var(--hum-hi); } +.sim .log .si { color: var(--sig-hi); } +.sim .verdict { margin: 14px 0 0; font-size: 15.5px; line-height: 1.55; color: var(--ink); min-height: 1.6em; } + +/* ---------- Status and doors ---------- */ +.status { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 16px 0 4px; } +.st { border: 1px solid; padding: 12px 14px 6px; min-width: 0; border-radius: var(--radius); } +.st.k { border-color: rgba(95, 212, 230, 0.5); background: rgba(95, 212, 230, 0.045); } +.st.n { border-color: rgba(230, 106, 95, 0.5); background: rgba(230, 106, 95, 0.045); } +.st.o { border-color: rgba(232, 163, 61, 0.5); background: rgba(232, 163, 61, 0.045); } +.st h3 { margin: 0 0 8px; font: 11px/1.3 var(--mono); letter-spacing: 0.16em; text-transform: uppercase; } +.st.k h3 { color: var(--sig-hi); } .st.n h3 { color: #f0a49c; } .st.o h3 { color: var(--hum-hi); } +.st ul { margin: 0 0 8px; padding-left: 16px; font-size: 13.5px; line-height: 1.55; color: var(--ink2); } +.st li { margin-bottom: 4px; } +.doors { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 16px 0 4px; } +a.door { display: block; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(95, 212, 230, 0.025); padding: 12px 14px 13px; color: var(--ink); text-decoration: none; min-width: 0; transition: background 0.2s, border-color 0.2s; } +a.door:hover { background: rgba(95, 212, 230, 0.09); border-color: var(--sig); color: var(--ink); } +.door .n { font: 10.5px var(--mono); color: var(--hum); letter-spacing: 0.14em; } +.door .t { display: block; font: 13.5px/1.3 var(--mono); color: var(--sig-hi); letter-spacing: 0.05em; margin: 4px 0 5px; } +.door .d { display: block; font-size: 13px; line-height: 1.5; color: var(--ink2); } + +/* ---------- Footer ---------- */ +footer.foot { border-top: 1px solid var(--line); margin-top: 10px; } +footer.foot .in { max-width: 1280px; margin: 0 auto; padding: 26px 22px 40px; display: flex; flex-wrap: wrap; gap: 14px 34px; align-items: flex-start; justify-content: space-between; } +footer.foot p { font-size: 13.5px; color: var(--dim); margin: 0; max-width: 34em; } +footer.foot nav { display: flex; flex-wrap: wrap; gap: 6px 18px; font: 12px/1.6 var(--mono); letter-spacing: 0.06em; } +footer.foot nav a { color: var(--ink2); text-decoration: none; } +footer.foot nav a:hover { color: var(--sig-hi); } +#buildstamp { font: 11px/1.5 var(--mono); color: var(--dim); letter-spacing: 0.06em; } + +/* ---------- Narrow screens ---------- */ +@media (max-width: 960px) { + .layout { grid-template-columns: 1fr; gap: 18px; padding-top: 20px; } + .layout > main { max-width: none; } + aside.index .inner { position: static; } + aside.index .btn.index-toggle { display: inline-flex; } + .index .collapsible { display: none; } + .index.open .collapsible { display: block; } + .cards, .cards.c3, .status, .doors { grid-template-columns: 1fr; } + .sim .stages { grid-template-columns: repeat(3, 1fr); } + .panel { padding: 20px 18px 22px; } + header.top .bar { gap: 12px; padding: 0 14px; } + .brand small { display: none; } + header.top nav a { padding: 7px 8px; } +} +@media (max-width: 560px) { + .sim .stages { grid-template-columns: repeat(2, 1fr); } + body { font-size: 16px; } +} + +/* ---------- Motion off on request ---------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.001s !important; animation-iteration-count: 1 !important; transition-duration: 0.001s !important; } + .js .reveal { opacity: 1; transform: none; } + .link i { display: none; } + html { scroll-behavior: auto; } +} + + +