Cipher Wheel
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A letter-shift puzzle you can turn with your hands: two lettered rings, one message, and a number that says how far the alphabet slid. It opens on a scrambled riddle and immediately sweeps all 26 shifts to find the one that reads like English — the bar chart underneath is the reason it works, snapping the message's letter counts into line with the shape of ordinary English text. Drag the inner ring to feel words fall apart and come back together, then paste in your own sentence, turn the wheel to scramble it, and hit Crack it to watch the counts give it away in about a second.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cipher Wheel</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* Native UI (range track/thumb, textarea caret + scrollbar) has to follow the theme too.
Kept outside the token block above so that block stays verbatim. */
:root { color-scheme: light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { color-scheme: dark; } }
:root[data-theme="dark"] { color-scheme: dark; }
:root[data-theme="light"] { color-scheme: light; }
/* Accent and green sit at only 4.2:1 on their own soft tints in the light palette, so
text placed on those tints uses a slightly deeper draw of the same hue. Same hue,
same feel, legible. In dark the tints are dark enough that the base colours already
clear AA, so these simply resolve back to the tokens. */
:root { --accent-strong: #B04F00; --run-strong: #16752E; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --accent-strong: var(--accent); --run-strong: var(--run); }
}
:root[data-theme="dark"] { --accent-strong: var(--accent); --run-strong: var(--run); }
/* ---------- base ---------- */
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body { margin: 0; padding: 22px 18px 40px; line-height: 1.5; overflow-x: hidden; }
.wrap { max-width: 1000px; margin: 0 auto; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
button { font: inherit; color: inherit; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---------- header ---------- */
.eyebrow {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .14em;
text-transform: uppercase; color: var(--accent-strong); margin: 0 0 6px;
display: flex; align-items: center; gap: 8px;
}
.eyebrow .led {
width: 7px; height: 7px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft); flex: none;
}
h1 { font-size: clamp(21px, 4.6vw, 29px); line-height: 1.15; margin: 0 0 8px; letter-spacing: -.02em; }
.lede { margin: 0 0 20px; color: var(--muted); font-size: 15px; max-width: 64ch; }
/* ---------- panels ---------- */
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); margin-bottom: 14px; overflow: hidden;
}
.phead {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 9px 12px; border-bottom: 1px solid var(--line);
}
.ptitle {
font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); margin: 0; font-weight: 600;
}
.spacer { flex: 1 1 auto; min-width: 0; }
.pbody { padding: 14px 12px; }
.cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; margin-bottom: 14px; }
.cols > .panel { margin-bottom: 0; }
@media (min-width: 830px) { .cols { grid-template-columns: 360px minmax(0, 1fr); } }
/* Side by side the two panels are the same height, so let the output box take up the
slack instead of leaving a dead strip under it. */
.panel.grow { display: flex; flex-direction: column; }
.panel.grow > .pbody { flex: 1 1 auto; display: flex; flex-direction: column; }
.panel.grow .out { flex: 1 1 auto; }
/* ---------- controls ---------- */
.btn {
font-family: var(--font-mono); font-size: 12px; line-height: 1;
padding: 7px 10px; border: 1px solid var(--line); border-radius: 7px;
background: transparent; cursor: pointer; white-space: nowrap;
transition: background .12s, border-color .12s, color .12s;
}
.btn:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-strong); }
.btn.ok { color: var(--run-strong); border-color: var(--run); background: var(--run-soft); }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: var(--surface);
font-weight: 700; padding: 9px 14px; letter-spacing: .03em;
}
.btn-primary:hover { background: var(--accent); border-color: var(--accent); color: var(--surface); filter: brightness(1.08); }
.btn-primary[disabled] { opacity: .55; cursor: default; filter: none; }
.btn-primary.ok { background: var(--run); border-color: var(--run); color: var(--surface); }
textarea {
width: 100%; min-height: 96px; resize: vertical; padding: 12px;
font-family: var(--font-mono); font-size: 15px; line-height: 1.6;
color: var(--ink); background: var(--bg);
border: 1px solid var(--line); border-radius: 8px;
}
textarea:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }
.samples { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 11px; }
.slabel {
font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em;
text-transform: uppercase; color: var(--muted); margin-right: 2px;
}
.chipbtn {
font-family: var(--font-mono); font-size: 11.5px; line-height: 1;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
background: var(--bg); color: var(--muted); cursor: pointer; white-space: nowrap;
transition: color .12s, border-color .12s, background .12s;
}
.chipbtn:hover { color: var(--accent-strong); border-color: var(--accent); background: var(--accent-soft); }
.chipbtn[aria-pressed="true"] { color: var(--accent-strong); border-color: var(--accent); background: var(--accent-soft); }
.stats {
display: flex; flex-wrap: wrap; gap: 5px 18px; margin-top: 11px;
font-family: var(--font-mono); font-size: 11.5px; color: var(--muted);
}
.stats b { color: var(--ink); font-weight: 600; }
.stats .hi { color: var(--accent); font-weight: 700; }
.stats .dim { opacity: .72; }
.badge {
font-family: var(--font-mono); font-size: 11px; padding: 3px 7px; border-radius: 5px;
border: 1px solid var(--line); color: var(--muted); white-space: nowrap;
}
.badge.ok { color: var(--run-strong); border-color: var(--run); background: var(--run-soft); }
/* ---------- wheel ---------- */
.wheelwrap { display: block; width: 100%; max-width: 320px; margin: 0 auto; }
#wheel { display: block; width: 100%; height: auto; touch-action: none; cursor: grab; }
#wheel:active { cursor: grabbing; }
#wheel:focus { outline: none; }
#wheel:focus-visible .focusring { stroke: var(--accent); stroke-width: 2; }
.ringface { fill: var(--bg); stroke: var(--line); }
.hubface { fill: var(--surface); stroke: var(--line); }
.tick { stroke: var(--line); stroke-width: 1; }
.wedge { fill: var(--accent); fill-opacity: .14; stroke: var(--accent); stroke-opacity: .6; stroke-width: 1; }
.rl { font-family: var(--font-mono); font-weight: 600; text-anchor: middle; font-size: 13px; }
.rl.outer { fill: var(--muted); }
.rl.inner { fill: var(--ink); }
.rl.on { fill: var(--accent-strong); font-weight: 700; }
#innerRing, #markOut, #markIn {
transform-box: view-box; transform-origin: 150px 150px;
transition: transform .3s cubic-bezier(.33,.9,.3,1);
}
.spinning { transition: none !important; }
.hubnum { font-family: var(--font-mono); font-size: 38px; font-weight: 700; fill: var(--accent); text-anchor: middle; }
.hublab { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .18em; fill: var(--muted); text-anchor: middle; }
.hubmap { font-family: var(--font-mono); font-size: 12.5px; fill: var(--ink); text-anchor: middle; }
.wcap {
display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; margin: 10px 0 0;
font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .04em;
}
.shiftbar { display: flex; align-items: center; gap: 8px; margin-top: 14px; }
.shiftbar .lb { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
input[type=range] { flex: 1 1 auto; min-width: 50px; accent-color: var(--accent); height: 28px; }
.step {
font-family: var(--font-mono); font-size: 15px; line-height: 1; width: 32px; height: 32px;
border: 1px solid var(--line); border-radius: 7px; background: transparent; cursor: pointer; flex: none;
transition: background .12s, border-color .12s, color .12s;
}
.step:hover { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-strong); }
.crackrow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 13px; }
.crackrow .note { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
.crackrow .note.done { color: var(--run); }
/* ---------- output ---------- */
.out {
font-family: var(--font-mono); font-size: 16px; line-height: 1.65;
white-space: pre-wrap; overflow-wrap: break-word; word-break: break-word;
min-height: 104px; padding: 12px; border-radius: 8px;
background: var(--bg); border: 1px solid var(--line);
}
.out mark {
background: var(--accent-soft); color: var(--accent-strong); border-radius: 3px;
padding: 0 1px; box-shadow: inset 0 -2px 0 var(--accent);
}
.out .ph { color: var(--muted); font-style: italic; }
.cands { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; align-items: center; }
.cand {
font-family: var(--font-mono); font-size: 11.5px; line-height: 1; cursor: pointer;
padding: 6px 9px; border: 1px solid var(--line); border-radius: 7px; background: var(--bg); color: var(--muted);
transition: color .12s, border-color .12s, background .12s;
}
.cand:hover { color: var(--accent-strong); border-color: var(--accent); background: var(--accent-soft); }
.cand .s { color: var(--ink); font-weight: 700; }
.cand[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-strong); }
.cand[aria-pressed="true"] .s { color: var(--accent-strong); }
/* ---------- frequency chart ---------- */
.chart { display: flex; align-items: flex-end; gap: 2px; }
.colbtn {
flex: 1 1 0; min-width: 0; padding: 0 0 2px; background: transparent; border: 0;
border-radius: 4px; cursor: pointer; display: block; text-align: center; transition: background .12s;
}
.colbtn:hover, .colbtn[aria-pressed="true"] { background: var(--accent-soft); }
.track { position: relative; height: 112px; display: block; }
.bar {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
background: var(--accent); border-radius: 2px 2px 0 0;
transition: height .18s linear;
}
.eng { position: absolute; left: -1px; right: -1px; z-index: 2; height: 2px; background: var(--muted); opacity: .85; }
.cl { font-family: var(--font-mono); font-size: 10px; color: var(--muted); margin-top: 4px; display: block; }
.colbtn[aria-pressed="true"] .cl { color: var(--accent-strong); font-weight: 700; }
.legend { display: flex; gap: 8px 16px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 11px; color: var(--muted); margin-top: 11px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.sw { width: 12px; height: 10px; border-radius: 2px; background: var(--accent); flex: none; }
.sw.line { height: 2px; background: var(--muted); opacity: .85; }
.readout { font-family: var(--font-mono); font-size: 11.5px; color: var(--muted); }
.readout b { color: var(--accent); font-weight: 700; }
footer {
margin-top: 22px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 11px; color: var(--muted);
display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
@media (max-width: 430px) {
body { padding: 18px 12px 32px; }
.pbody { padding: 12px 10px; }
.out { font-size: 15px; }
.cl { font-size: 8.5px; }
.chart { gap: 1px; }
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div class="wrap">
<p class="eyebrow"><span class="led" aria-hidden="true"></span>Puzzle desk</p>
<h1>Cipher Wheel</h1>
<p class="lede">Slide every letter the same number of steps along the alphabet and readable words turn to
mush. Turn the wheel to slide them back — or let the letter counts work out the shift for you.</p>
<!-- ============ MESSAGE IN ============ -->
<section class="panel">
<div class="phead">
<h2 class="ptitle">Message in</h2>
<div class="spacer"></div>
<span class="badge" id="inbadge">scrambled</span>
</div>
<div class="pbody">
<label class="sr" for="src">Message to shift</label>
<textarea id="src" maxlength="2000" spellcheck="false" autocapitalize="off" autocomplete="off"
placeholder="Type or paste anything">P zwlhr dpaovba h tvbao huk olhy dpaovba lhyz. P ohcl uv ivkf, iba P jvtl hspcl dpao dpuk. Doha ht P?</textarea>
<div class="samples" id="samples"><span class="slabel">Try</span></div>
<div class="stats" id="stats"></div>
</div>
</section>
<div class="cols">
<!-- ============ WHEEL ============ -->
<section class="panel">
<div class="phead">
<h2 class="ptitle">The wheel</h2>
<div class="spacer"></div>
<span class="badge" id="shiftbadge">shift 0</span>
</div>
<div class="pbody">
<div class="wheelwrap">
<svg id="wheel" viewBox="0 0 300 300" role="slider" tabindex="0"
aria-label="Cipher wheel. Drag it or use the arrow keys to change the shift."
aria-valuemin="0" aria-valuemax="25" aria-valuenow="0" aria-valuetext="shift 0">
<circle class="focusring" cx="150" cy="150" r="147.5" fill="none" stroke="none"></circle>
<circle class="ringface" cx="150" cy="150" r="145"></circle>
<circle class="ringface" cx="150" cy="150" r="119" id="face119"></circle>
<g id="markOut" aria-hidden="true"></g>
<g id="ticks" aria-hidden="true"></g>
<g id="outerRing" aria-hidden="true"></g>
<circle class="ringface" cx="150" cy="150" r="112" id="face112"></circle>
<g id="markIn" aria-hidden="true"></g>
<g id="innerRing" aria-hidden="true"></g>
<circle class="hubface" cx="150" cy="150" r="66"></circle>
<text class="hublab" x="150" y="126">SHIFT</text>
<text class="hubnum" x="150" y="164" id="hubnum">0</text>
<text class="hubmap" x="150" y="185" id="hubmap">A → A</text>
</svg>
</div>
<p class="wcap"><span>outer ring · message in</span><span>inner ring · message out</span></p>
<div class="shiftbar">
<button class="step" type="button" id="dec" aria-label="Shift down one">−</button>
<span class="lb" aria-hidden="true">Shift</span>
<label class="sr" for="shift">Shift amount, 0 to 25</label>
<input type="range" id="shift" min="0" max="25" step="1" value="0" autocomplete="off">
<button class="step" type="button" id="inc" aria-label="Shift up one">+</button>
</div>
<div class="crackrow">
<button class="btn btn-primary" type="button" id="crack">Crack it</button>
<span class="note" id="cracknote">26 shifts to try</span>
</div>
</div>
</section>
<!-- ============ MESSAGE OUT ============ -->
<section class="panel grow">
<div class="phead">
<h2 class="ptitle">Message out</h2>
<span class="badge" id="fitbadge">—</span>
<div class="spacer"></div>
<button class="btn" type="button" id="copy">Copy</button>
<button class="btn" type="button" id="swap" title="Move this text up into the input box and reset the shift to zero">Feed back in</button>
</div>
<div class="pbody">
<div class="out" id="out"></div>
<div class="cands" id="cands"><span class="slabel">Closest fits</span></div>
</div>
</section>
</div>
<!-- ============ FREQUENCY ============ -->
<section class="panel">
<div class="phead">
<h2 class="ptitle">Letter counts</h2>
<div class="spacer"></div>
<span class="readout" id="readout"></span>
</div>
<div class="pbody">
<div class="chart" id="chart" role="group"
aria-label="Letter frequency of the outgoing message compared with typical English"></div>
<div class="legend">
<span><i class="sw" aria-hidden="true"></i> message out</span>
<span><i class="sw line" aria-hidden="true"></i> typical English</span>
<span id="fitline"></span>
</div>
</div>
</section>
<p class="sr" id="live" role="status" aria-live="polite"></p>
<footer>
<span>cipher wheel · sloprun.dev</span>
<span>26 shifts · nothing leaves this tab</span>
</footer>
</div>
<script>
(function () {
"use strict";
/* ---------- platform theme bridge ---------- */
addEventListener("message", function (e) {
var d = e && e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
var A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
/* Letter frequencies of ordinary written English, in percent (sums to ~100). */
var ENG = [8.167, 1.492, 2.782, 4.253, 12.702, 2.228, 2.015, 6.094, 6.966, 0.153,
0.772, 4.025, 2.406, 6.749, 7.507, 1.929, 0.095, 5.987, 6.327, 9.056,
2.758, 0.978, 2.360, 0.150, 1.974, 0.074];
var STEP = 360 / 26;
var SAMPLES = [
{ label: "riddle", text: "P zwlhr dpaovba h tvbao huk olhy dpaovba lhyz. P ohcl uv ivkf, iba P jvtl hspcl dpao dpuk. Doha ht P?" },
{ label: "the fox", text: "Gur dhvpx oebja sbk whzcf bire gur ynml qbt, naq gur qbt oneryl abgvprf." },
{ label: "pancakes", text: "Xli fiwx tergeoiw riih gsph fyxxivqmpo, e lsx ter, erh qsvi texmirgi xler csy xlmro." },
{ label: "the oak", text: "Iaap ia qjzan pda khz kwg pnaa wp jkkj. Xnejc pda xhqa qixnahhw, ep whswuo nwejo." },
{ label: "plain", text: "Nothing is scrambled here yet. Turn the wheel and watch these words fall apart." }
];
var el = function (id) { return document.getElementById(id); };
var srcEl = el("src"), shiftEl = el("shift"), outEl = el("out"), chartEl = el("chart");
var wheelEl = el("wheel"), innerRing = el("innerRing"), markOut = el("markOut"), markIn = el("markIn");
var hubNum = el("hubnum"), hubMap = el("hubmap"), statsEl = el("stats");
var fitBadge = el("fitbadge"), shiftBadge = el("shiftbadge"), inBadge = el("inbadge");
var candsEl = el("cands"), readoutEl = el("readout"), fitLine = el("fitline");
var crackBtn = el("crack"), crackNote = el("cracknote"), liveEl = el("live"), samplesEl = el("samples");
var rotors = [innerRing, markOut, markIn];
var reduced = false;
try { reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; } catch (err) { reduced = false; }
var state = {
shift: 0,
focus: 4, /* letter index spotlighted on wheel + chart */
focusPinned: false,
counts: null, total: 0,
rank: [], best: 0, decisive: false,
pcts: [],
pinned: 0,
cracking: false, timer: 0, autoPending: 0, noteTimer: 0
};
/* ---------- the cipher ---------- */
function shiftText(t, s) {
var out = "", i, c;
for (i = 0; i < t.length; i++) {
c = t.charCodeAt(i);
if (c >= 65 && c <= 90) out += A[(c - 65 + s) % 26];
else if (c >= 97 && c <= 122) out += A[(c - 97 + s) % 26].toLowerCase();
else out += t.charAt(i);
}
return out;
}
function tally(t) {
var c = new Array(26), i, ch, n = 0;
for (i = 0; i < 26; i++) c[i] = 0;
for (i = 0; i < t.length; i++) {
ch = t.charCodeAt(i);
if (ch >= 65 && ch <= 90) { c[ch - 65]++; n++; }
else if (ch >= 97 && ch <= 122) { c[ch - 97]++; n++; }
}
state.counts = c; state.total = n;
}
/* Chi-squared distance between this shift's letter mix and English.
Lower means "shifting by this much produces something that counts like English". */
function scoreFor(s) {
var n = state.total, j, obs, exp, sum = 0;
if (!n) return 0;
for (j = 0; j < 26; j++) {
obs = state.counts[(j - s + 26) % 26];
exp = ENG[j] / 100 * n;
sum += (obs - exp) * (obs - exp) / exp;
}
return sum;
}
function rescore() {
var s, sc = [];
for (s = 0; s < 26; s++) sc.push({ s: s, v: scoreFor(s) });
sc.sort(function (a, b) { return a.v - b.v; });
state.rank = sc;
state.best = sc.length ? sc[0].s : 0;
/* Only call it a win when there is enough text AND the winner wins clearly.
Below that the score is noise, and a green badge would be a lie. */
state.decisive = state.total >= 24 && sc[1].v > sc[0].v * 1.8;
}
function topLetter() {
var i, bi = -1, bv = 0;
for (i = 0; i < 26; i++) if (state.counts[i] > bv) { bv = state.counts[i]; bi = i; }
return bi;
}
/* ---------- build the wheel ---------- */
var NS = "http://www.w3.org/2000/svg";
function pol(r, deg) {
var a = (deg - 90) * Math.PI / 180;
return [150 + r * Math.cos(a), 150 + r * Math.sin(a)];
}
function pt(p) { return p[0].toFixed(2) + " " + p[1].toFixed(2); }
function wedgePath(r0, r1) {
var a0 = -STEP / 2, a1 = STEP / 2;
return "M " + pt(pol(r0, a0)) + " L " + pt(pol(r1, a0)) +
" A " + r1 + " " + r1 + " 0 0 1 " + pt(pol(r1, a1)) +
" L " + pt(pol(r0, a1)) +
" A " + r0 + " " + r0 + " 0 0 0 " + pt(pol(r0, a0)) + " Z";
}
var outerTexts = [], innerTexts = [];
(function buildWheel() {
var ticks = el("ticks"), outer = el("outerRing"), i, ang, p, t, ln;
var w1 = document.createElementNS(NS, "path");
w1.setAttribute("class", "wedge"); w1.setAttribute("d", wedgePath(120, 144));
markOut.appendChild(w1);
var w2 = document.createElementNS(NS, "path");
w2.setAttribute("class", "wedge"); w2.setAttribute("d", wedgePath(69, 111));
markIn.appendChild(w2);
for (i = 0; i < 26; i++) {
ang = i * STEP - STEP / 2;
ln = document.createElementNS(NS, "line");
ln.setAttribute("class", "tick");
ln.setAttribute("x1", pol(119, ang)[0].toFixed(2)); ln.setAttribute("y1", pol(119, ang)[1].toFixed(2));
ln.setAttribute("x2", pol(145, ang)[0].toFixed(2)); ln.setAttribute("y2", pol(145, ang)[1].toFixed(2));
ticks.appendChild(ln);
}
for (i = 0; i < 26; i++) {
ang = i * STEP;
p = pol(132, ang);
t = document.createElementNS(NS, "text");
t.setAttribute("class", "rl outer");
t.setAttribute("x", p[0].toFixed(2)); t.setAttribute("y", (p[1] + 4.6).toFixed(2));
t.setAttribute("transform", "rotate(" + ang.toFixed(2) + " " + pt(p) + ")");
t.textContent = A[i];
outer.appendChild(t); outerTexts.push(t);
p = pol(90, ang);
t = document.createElementNS(NS, "text");
t.setAttribute("class", "rl inner");
t.setAttribute("x", p[0].toFixed(2)); t.setAttribute("y", (p[1] + 4.6).toFixed(2));
t.setAttribute("transform", "rotate(" + ang.toFixed(2) + " " + pt(p) + ")");
t.textContent = A[i];
innerRing.appendChild(t); innerTexts.push(t);
}
})();
/* ---------- build the chart ---------- */
var cols = [];
(function buildChart() {
var i, b, tr, bar, eng, lab;
for (i = 0; i < 26; i++) {
b = document.createElement("button");
b.type = "button"; b.className = "colbtn"; b.setAttribute("data-i", String(i));
b.setAttribute("aria-pressed", "false"); b.tabIndex = i === 0 ? 0 : -1;
tr = document.createElement("span"); tr.className = "track";
bar = document.createElement("span"); bar.className = "bar";
eng = document.createElement("span"); eng.className = "eng";
lab = document.createElement("span"); lab.className = "cl"; lab.textContent = A[i];
tr.appendChild(bar); tr.appendChild(eng);
b.appendChild(tr); b.appendChild(lab);
chartEl.appendChild(b);
cols.push({ btn: b, bar: bar, eng: eng });
}
})();
/* ---------- build the sample chips ---------- */
(function buildChips() {
var i, b;
for (i = 0; i < SAMPLES.length; i++) {
b = document.createElement("button");
b.type = "button"; b.className = "chipbtn"; b.setAttribute("data-i", String(i));
b.setAttribute("aria-pressed", "false");
b.textContent = SAMPLES[i].label;
samplesEl.appendChild(b);
}
})();
function markChips() {
var list = samplesEl.querySelectorAll(".chipbtn"), i;
for (i = 0; i < list.length; i++) {
list[i].setAttribute("aria-pressed", SAMPLES[i].text === srcEl.value ? "true" : "false");
}
}
/* ---------- rendering ---------- */
function esc(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); }
function outHTML(text, letter) {
var html = "", run = "", i, ch, up;
for (i = 0; i < text.length; i++) {
ch = text.charAt(i); up = ch.toUpperCase();
if (up === letter && up !== ch.toLowerCase()) {
if (run) { html += esc(run); run = ""; }
html += "<mark>" + esc(ch) + "</mark>";
} else { run += ch; }
}
return html + (run ? esc(run) : "");
}
function fmt(v) { return v >= 100 ? v.toFixed(0) : v.toFixed(1); }
function pad(n) { return n < 10 ? "0" + n : String(n); }
function render() {
var text = srcEl.value, s = state.shift, f = state.focus, i;
/* f is the spotlighted letter of the OUTGOING message (the chart is the outgoing
message, and so is the highlight in the text). On the wheel that letter lives on
the inner ring; the outer ring shows the incoming letter it came from. */
var fin = ((f - s) % 26 + 26) % 26;
/* output text */
if (!text) outEl.innerHTML = '<span class="ph">Type something above and it lands here, shifted.</span>';
else outEl.innerHTML = outHTML(shiftText(text, s), A[f]);
/* wheel */
innerRing.style.transform = "rotate(" + (-s * STEP).toFixed(3) + "deg)";
markOut.style.transform = markIn.style.transform = "rotate(" + (fin * STEP).toFixed(3) + "deg)";
hubNum.textContent = String(s);
hubMap.textContent = A[fin] + " → " + A[f];
if (shiftEl.value !== String(s)) shiftEl.value = String(s);
wheelEl.setAttribute("aria-valuenow", String(s));
wheelEl.setAttribute("aria-valuetext", "shift " + s + ", " + A[fin] + " becomes " + A[f]);
shiftBadge.textContent = s === 0 ? "shift 0 · unchanged" : "shift +" + s + " · back " + (26 - s);
for (i = 0; i < 26; i++) {
outerTexts[i].setAttribute("class", "rl outer" + (i === fin ? " on" : ""));
innerTexts[i].setAttribute("class", "rl inner" + (i === f ? " on" : ""));
}
/* fit */
var cur = scoreFor(s);
var isBest = state.total > 0 && s === state.best;
var good = isBest && state.decisive;
fitBadge.className = "badge" + (good ? " ok" : "");
if (!state.total) fitBadge.textContent = "no letters";
else if (good) fitBadge.textContent = "reads like English · fit " + fmt(cur);
else fitBadge.textContent = "fit " + fmt(cur) + " · " + (isBest ? "closest of 26" : "not English-like");
inBadge.className = "badge";
inBadge.textContent = !state.total ? (text ? "no letters" : "empty")
: (state.best === 0 && state.decisive ? "plain to start with" : "scrambled");
/* chart */
var maxv = 12.702, pct;
state.pcts = [];
for (i = 0; i < 26; i++) {
pct = state.total ? state.counts[(i - s + 26) % 26] / state.total * 100 : 0;
state.pcts.push(pct);
if (pct > maxv) maxv = pct;
}
maxv = Math.ceil(maxv / 2) * 2;
for (i = 0; i < 26; i++) {
cols[i].bar.style.height = (state.pcts[i] / maxv * 100).toFixed(2) + "%";
cols[i].eng.style.bottom = "calc(" + (ENG[i] / maxv * 100).toFixed(2) + "% - 1px)";
cols[i].btn.setAttribute("aria-pressed", i === f ? "true" : "false");
cols[i].btn.setAttribute("aria-label", A[i] + ": message out " + state.pcts[i].toFixed(1) +
" percent, typical English " + ENG[i].toFixed(1) + " percent");
}
readoutEl.innerHTML = "<b>" + A[f] + "</b> out " + state.pcts[f].toFixed(1) +
"% · English " + ENG[f].toFixed(1) + "%";
fitLine.textContent = state.total ? "fit " + fmt(cur) + " · lower is closer" : "";
fitLine.style.color = good ? "var(--run)" : "";
/* stats */
var tl = topLetter(), parts = [];
parts.push("<span>letters <b>" + state.total + "</b></span>");
if (tl >= 0) parts.push("<span>most used <b>" + A[tl] + "</b> → <span class='hi'>" + A[(tl + s) % 26] + "</span></span>");
parts.push("<span>shift <b>+" + s + "</b>" + (s ? " <span class='dim'>(same as −" + (26 - s) + ")</span>" : "") + "</span>");
statsEl.innerHTML = parts.join("");
renderCands();
}
/* The ranking only moves when the text changes, so the buttons are rebuilt only then.
Turning the wheel just re-marks them — otherwise activating a candidate would delete
the very button under the keyboard's focus, and the sweep would churn the DOM 26 times. */
var candSig = "";
function dropCands() {
var old = candsEl.querySelectorAll(".cand"), i;
for (i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
}
function renderCands() {
var i, b, top, sig;
if (!state.total) {
candsEl.style.display = "none";
if (candSig !== "") { candSig = ""; dropCands(); }
return;
}
candsEl.style.display = "";
top = state.rank.slice(0, 3);
sig = "";
for (i = 0; i < top.length; i++) sig += top[i].s + ":" + fmt(top[i].v) + "|";
if (sig !== candSig) {
candSig = sig;
dropCands();
for (i = 0; i < top.length; i++) {
b = document.createElement("button");
b.type = "button"; b.className = "cand";
b.setAttribute("data-shift", String(top[i].s));
b.innerHTML = "<span class='s'>shift " + top[i].s + "</span> · " + fmt(top[i].v);
b.title = "Jump to shift " + top[i].s + " (fit " + fmt(top[i].v) + ")";
candsEl.appendChild(b);
}
}
var list = candsEl.querySelectorAll(".cand");
for (i = 0; i < list.length; i++) {
list[i].setAttribute("aria-pressed",
parseInt(list[i].getAttribute("data-shift"), 10) === state.shift ? "true" : "false");
}
}
/* ---------- state changes ---------- */
/* The note beside the button always falls back to this resting line, so it never
strands the reader on a stale status. */
function restNote() {
clearNoteTimer();
crackNote.className = "note";
crackNote.textContent = state.total ? "26 shifts to try" : "nothing to crack";
}
function clearNoteTimer() {
if (state.noteTimer) { clearTimeout(state.noteTimer); state.noteTimer = 0; }
}
function clearWin() {
crackBtn.classList.remove("ok");
if (crackNote.className.indexOf("done") >= 0) restNote();
}
/* Spotlight the tallest bar: the most-used incoming letter, seen where it lands. */
function autoFocus() { var t = topLetter(); if (t >= 0) state.focus = (t + state.shift) % 26; }
function setShift(s) {
state.shift = ((s % 26) + 26) % 26;
if (!state.focusPinned) autoFocus();
render();
}
function setFocus(i, pin) {
state.focus = ((i % 26) + 26) % 26;
if (pin) { state.focusPinned = true; state.pinned = state.focus; }
render();
}
function reload(text) {
cancelAuto(); stopCrack();
srcEl.value = text;
state.shift = 0; state.focusPinned = false;
tally(text); rescore(); autoFocus();
markChips();
crackBtn.classList.remove("ok");
/* A stale announcement is worse than none: an unchanged live region is silent, so a
second crack landing on the same shift would say nothing at all. */
liveEl.textContent = "";
restNote();
render();
}
/* ---------- cracking ---------- */
function spin(on) {
var i;
for (i = 0; i < rotors.length; i++) {
if (on) rotors[i].classList.add("spinning"); else rotors[i].classList.remove("spinning");
}
}
function cancelAuto() { if (state.autoPending) { clearTimeout(state.autoPending); state.autoPending = 0; } }
/* Disabling a focused button drops focus to the body, which strands a keyboard user
who just pressed Enter on it. Hand the focus back when the sweep releases the button. */
var crackHadFocus = false;
function setBusy(on) {
if (on) {
crackHadFocus = document.activeElement === crackBtn;
crackBtn.disabled = true;
} else {
crackBtn.disabled = false;
if (crackHadFocus) {
crackHadFocus = false;
try { crackBtn.focus({ preventScroll: true }); } catch (e) { /* focus is a nicety */ }
}
}
}
function stopCrack() {
if (state.timer) { clearTimeout(state.timer); state.timer = 0; }
if (state.cracking) {
state.cracking = false;
spin(false);
setBusy(false);
clearNoteTimer();
crackNote.className = "note";
crackNote.textContent = "stopped";
/* "stopped" is a moment, not a resting state — hand the line back shortly. */
state.noteTimer = setTimeout(function () {
state.noteTimer = 0;
if (!state.cracking && crackNote.textContent === "stopped") restNote();
}, 1200);
}
}
function crack() {
cancelAuto(); stopCrack();
tally(srcEl.value); rescore();
if (state.total < 4) {
crackBtn.classList.remove("ok");
clearNoteTimer();
crackNote.className = "note";
crackNote.textContent = "need a few more letters";
liveEl.textContent = "Not enough letters to test a shift.";
render();
return;
}
var best = state.best;
if (reduced) { land(best); return; }
state.cracking = true;
setBusy(true);
crackBtn.classList.remove("ok");
spin(true);
var i = 1, start = state.shift;
clearNoteTimer();
function tick() {
if (!state.cracking) return;
var s = (start + i) % 26;
state.shift = s;
if (!state.focusPinned) autoFocus();
crackNote.className = "note";
crackNote.textContent = "testing shift " + pad(s) + " · fit " + fmt(scoreFor(s));
render();
i++;
if (i <= 26) state.timer = setTimeout(tick, 38);
else {
state.timer = 0;
state.cracking = false;
spin(false);
setBusy(false);
land(best);
}
}
state.timer = setTimeout(tick, 38);
}
function land(best) {
clearNoteTimer();
state.shift = best;
state.focusPinned = false;
autoFocus();
render();
if (state.decisive) {
crackBtn.classList.add("ok");
crackNote.className = "note done";
crackNote.textContent = "cracked · shift " + best;
liveEl.textContent = "Cracked at shift " + best + ". " + shiftText(srcEl.value, best).slice(0, 140);
} else {
crackBtn.classList.remove("ok");
crackNote.className = "note";
crackNote.textContent = "closest is shift " + best + " · weak match";
liveEl.textContent = "Closest shift is " + best + ", but the match is weak. Try a longer message.";
}
}
/* ---------- wiring ---------- */
srcEl.addEventListener("input", function () {
cancelAuto(); stopCrack();
tally(srcEl.value); rescore();
if (!state.focusPinned) autoFocus();
markChips();
crackBtn.classList.remove("ok");
liveEl.textContent = "";
restNote();
render();
});
shiftEl.addEventListener("input", function () {
cancelAuto(); stopCrack();
var v = parseInt(shiftEl.value, 10);
clearWin();
setShift(isNaN(v) ? 0 : v);
});
el("inc").addEventListener("click", function () { cancelAuto(); stopCrack(); clearWin(); setShift(state.shift + 1); });
el("dec").addEventListener("click", function () { cancelAuto(); stopCrack(); clearWin(); setShift(state.shift - 1); });
crackBtn.addEventListener("click", function () { crack(); });
function flash(btn, msg, good) {
if (!btn._ft) btn._label = btn.textContent;
else clearTimeout(btn._ft);
btn.textContent = msg;
if (good) btn.classList.add("ok");
btn._ft = setTimeout(function () {
btn.textContent = btn._label; btn.classList.remove("ok"); btn._ft = 0;
}, 1100);
}
function legacyCopy(txt) {
var ta = document.createElement("textarea"), ok = false;
ta.value = txt; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { ok = document.execCommand("copy"); } catch (e) { ok = false; }
ta.parentNode.removeChild(ta);
return ok;
}
el("copy").addEventListener("click", function () {
var btn = this, txt = shiftText(srcEl.value, state.shift), settled = false;
if (!txt) { flash(btn, "Nothing yet"); return; }
/* A sandboxed frame can leave the clipboard promise hanging instead of rejecting.
The watchdog guarantees the button always answers rather than sitting dead. */
var wd = setTimeout(function () { fail(); }, 1000);
function done() { if (settled) return; settled = true; clearTimeout(wd); flash(btn, "Copied", true); }
function fail() { if (settled) return; settled = true; clearTimeout(wd); flash(btn, "Copy blocked"); }
function fallback() { if (settled) return; if (legacyCopy(txt)) done(); else fail(); }
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(txt).then(done, fallback);
} else { fallback(); }
} catch (e) { fail(); }
});
el("swap").addEventListener("click", function () {
var txt = shiftText(srcEl.value, state.shift);
if (!txt) { flash(this, "Nothing yet"); return; }
reload(txt);
});
samplesEl.addEventListener("click", function (ev) {
var b = ev.target.closest ? ev.target.closest(".chipbtn") : null;
if (!b) return;
reload(SAMPLES[parseInt(b.getAttribute("data-i"), 10)].text);
});
candsEl.addEventListener("click", function (ev) {
var b = ev.target.closest ? ev.target.closest(".cand") : null;
if (!b) return;
cancelAuto(); stopCrack(); clearWin();
setShift(parseInt(b.getAttribute("data-shift"), 10));
});
/* ---------- chart: one tab stop, arrows walk the alphabet ---------- */
var rov = 0;
function setRov(i, focusIt) {
rov = ((i % 26) + 26) % 26;
var j;
for (j = 0; j < 26; j++) cols[j].btn.tabIndex = j === rov ? 0 : -1;
if (focusIt) cols[rov].btn.focus();
setFocus(rov, true);
}
chartEl.addEventListener("click", function (ev) {
var b = ev.target.closest ? ev.target.closest(".colbtn") : null;
if (b) setRov(parseInt(b.getAttribute("data-i"), 10), false);
});
chartEl.addEventListener("mouseover", function (ev) {
var b = ev.target.closest ? ev.target.closest(".colbtn") : null;
if (b) setFocus(parseInt(b.getAttribute("data-i"), 10), false);
});
chartEl.addEventListener("mouseleave", function () {
/* Hovering is a peek; a clicked column is a choice, so put it back on the way out. */
if (state.focusPinned) setFocus(state.pinned, false);
else { autoFocus(); render(); }
});
chartEl.addEventListener("keydown", function (ev) {
var k = ev.key;
if (k === "ArrowRight" || k === "ArrowDown") setRov(rov + 1, true);
else if (k === "ArrowLeft" || k === "ArrowUp") setRov(rov - 1, true);
else if (k === "Home") setRov(0, true);
else if (k === "End") setRov(25, true);
else return;
ev.preventDefault();
});
/* ---------- wheel: drag or arrow keys ---------- */
wheelEl.addEventListener("keydown", function (ev) {
var k = ev.key, d = 0;
if (k === "ArrowRight" || k === "ArrowUp") d = 1;
else if (k === "ArrowLeft" || k === "ArrowDown") d = -1;
else if (k === "PageUp") d = 5;
else if (k === "PageDown") d = -5;
else if (k === "Home") { cancelAuto(); stopCrack(); clearWin(); setShift(0); ev.preventDefault(); return; }
else if (k === "End") { cancelAuto(); stopCrack(); clearWin(); setShift(25); ev.preventDefault(); return; }
else return;
cancelAuto(); stopCrack(); clearWin();
setShift(state.shift + d);
ev.preventDefault();
});
var drag = null;
function angleAt(ev) {
var r = wheelEl.getBoundingClientRect();
return Math.atan2(ev.clientY - (r.top + r.height / 2), ev.clientX - (r.left + r.width / 2)) * 180 / Math.PI;
}
wheelEl.addEventListener("pointerdown", function (ev) {
if (ev.button !== undefined && ev.button > 0) return;
cancelAuto(); stopCrack(); clearWin();
drag = { a0: angleAt(ev), s0: state.shift };
spin(true);
try { wheelEl.setPointerCapture(ev.pointerId); } catch (e) { /* capture is a nicety */ }
try { wheelEl.focus({ preventScroll: true }); } catch (e) { /* focus is a nicety */ }
ev.preventDefault();
});
wheelEl.addEventListener("pointermove", function (ev) {
if (!drag) return;
var d = angleAt(ev) - drag.a0;
while (d > 180) d -= 360;
while (d < -180) d += 360;
/* The inner disc follows your finger, so turning it clockwise walks the shift down. */
var s = drag.s0 - Math.round(d / STEP);
if (((s % 26) + 26) % 26 !== state.shift) setShift(s);
});
function endDrag(ev) {
if (!drag) return;
drag = null;
spin(false);
if (ev && ev.pointerId !== undefined) {
try { wheelEl.releasePointerCapture(ev.pointerId); } catch (e) { /* already gone */ }
}
}
wheelEl.addEventListener("pointerup", endDrag);
wheelEl.addEventListener("pointercancel", endDrag);
wheelEl.addEventListener("lostpointercapture", endDrag);
document.addEventListener("visibilitychange", function () {
if (document.hidden) { cancelAuto(); stopCrack(); }
});
/* ---------- boot ---------- */
/* Read the control rather than trusting the markup: a reload can restore form state. */
var boot = parseInt(shiftEl.value, 10);
state.shift = (boot >= 0 && boot <= 25) ? boot : 0;
tally(srcEl.value); rescore(); autoFocus();
markChips();
restNote();
render();
/* Open with the demo already doing its thing: sweep all 26 shifts and settle on the
one that reads like English. Touching any control calls it off. */
state.autoPending = setTimeout(function () {
state.autoPending = 0;
if (srcEl.value === SAMPLES[0].text && state.shift === 0) crack();
}, reduced ? 200 : 500);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.