---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
# Scene 1 — Something That Draws Itself
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #19</p><p class="rule-text">“The unexamined life is not worth living. Neither, statistically, is the examined one, but at least you'll know why.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Systems <systems@newcarthage.edu></span></td></tr><tr><td>To:</td><td><span><span class="pc-name" data-case="pc" data-pc="-full">Owen Mercer</span> <<span class="pc-name" data-case="pc" data-pc="-first">Owen</span>@newcarthage.edu></span></td></tr><tr><td>Date:</td><td>9 February, 8:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>Induction — fourth floor</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Welcome to Systems.</span></p><p><span>Hours are Mondays and Wednesdays, 9:00 AM to 1:00 PM, and are paid at the standard rate.</span></p><p><span>Work is issued as specifications. A specification tells you what a component must do. It does not tell you what the component is for, and you should not ask, because the person issuing it does not know either.</span></p><p><span>Helps are permitted and encouraged on all Systems work.</span></p><p><span>There is no dress requirement on the fourth floor.</span></p><p><span>Systems hours are not a component of review. Systems hours are recorded.</span></p></div></div><div class="screenplay"><p class="action">Systems is on the fourth floor of the Sterling Center and the fourth floor has carpet. Nothing else in this university has carpet.</p><p class="action">The work comes on a page. The page says what a thing has to do and does not say what it is for, and the induction letter had told him at the start that the man who signs the pages does not know either, which he had taken for a joke until he met the man.</p><p class="action">DUNN has been on that floor since before <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> was born. He is not unkind and he is not stupid and in the second week he says that the trick to Systems is not to be interested. He says it the way you pass on a recipe.</p></div><div class="artifact handout"><p class="handout-title">SPECIFICATION 4471-K · issued 2 March · M. DUNN</p><p><span>A function is required which accepts a list of readings and returns the same list with any reading outside the given band removed.</span></p><p><span>Readings are timestamped. Order must be preserved.</span></p><p><span>A removed reading is not an error and must not be reported.</span></p><p><span>The band is supplied at run time and must not be assumed.</span></p><p><span>Estimated: half a day.</span></p><p><span>Return by: 4 March.</span></p></div><div class="screenplay"><p class="action">It takes him twenty minutes. The estimate on the page says half a day. He sits with it afterwards for longer than it took to write, and then he does the next one, and there is nothing on the fourth floor to do once the page is done, so he goes and looks out of the window at the quad until one o'clock and gets paid for it.</p><p class="action">The specifications are all like that. Take a list and give back a shorter list. Put things in an order. Hold things until something asks for them. He is good at it in the way that anybody is good at a thing that has been cut down to the size of one morning.</p><p class="action">And there is the other thing, which he has not said to anybody, because saying it would mean saying the rest of it.</p><p class="action">He has not bled since the first week of February.</p><p class="action">No smell. No gap. Nothing since the night they came up. He is on a carpeted floor above four other floors twice a week, and he sleeps, and he eats, and the thing that has been happening to him since a warm room in August is not happening.</p><p class="action">He worked out why in about the second week, sitting in a chair with a page on his knee, and what he did about it was nothing, and what he said about it was nothing.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#general</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">18 Feb, 11:40 PM</span></p><div class="discord-text"><p><span>Attendance four of five.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">25 Feb, 11:38 PM</span></p><div class="discord-text"><p><span>Four of five.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">25 Feb, 11:41 PM</span></p><div class="discord-text"><p><span>he has a job victor</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">25 Feb, 11:44 PM</span></p><div class="discord-text"><p><span>Wednesdays are Wednesdays. That is Article Six and it was</span></p><p><span>ratified by five people, one of whom now has a job.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">4 Mar, 1:10 AM</span></p><div class="discord-text"><p><span>hes been on fridays. hes not been on wednesdays</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">4 Mar, 1:11 AM</span></p><div class="discord-text"><p><span>im not making a point im just saying which</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">4 Mar, 8:00 AM</span></p><div class="discord-text"><p><span>Noted.</span></p></div></div></div></div><div class="screenplay"><p class="slug">INT. WHITLOCK HALL — LOWER GROUND — ROOM 4 — WEDNESDAY 4 MARCH, 7:00 P.M.</p></div><div class="screenplay"><p class="action">The same room. The same table. The card is on it before anybody sits down.</p><p class="action">There is a box of tissues at Hale's elbow. In August they were in the lectern.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Eleven. Five, five, and the eleventh. Standings.</p></div></div></div><div class="screenplay"><p class="action">SIMON is fourth along and gets a line and a half out before Hale thanks him, and it lands the way it has landed since September, and Simon takes it the way he takes it.</p><p class="action">He is grey under the eyes and he has been grey under the eyes for a while and nobody has mentioned it because there is nothing to mention.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:50 P.M.</p></div><div class="screenplay"><p class="action">The end of the table.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's stopped.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">What has?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">The thing that happened in here in August.</p></div></div></div><div class="screenplay"><p class="action beat">Wren waits.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Five weeks. Nothing. I've been working upstairs.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">How far upstairs?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Fourth floor of Sterling.</p></div></div></div><div class="screenplay"><p class="action">Wren does not say anything for a moment, and it is not the kind of moment where somebody is deciding whether to tell you something.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">All right.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's it?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">You've told me a thing about a floor. I'm in this</p><p class="line">chair every week and I've been in it two years and</p><p class="line">I'm not going to be your control group.</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> does not have anything for that.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">How do you get off the docket?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Unattached students in good standing. That's the</p><p class="line">sentence. There's two ways off it and one of them</p><p class="line">is to stop being in good standing.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">And Simon's done neither.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Simon's been trying the other one since his first</p><p class="line">term and he's cheerful about it, which is worse</p><p class="line">than if he weren't.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 8:15 P.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Preferences. In your own time, privately, and</p><p class="line">nothing said out loud.</p></div></div></div><div class="screenplay"><p class="action beat">The room goes quiet.</p><p class="action">It comes at about the second minute and it comes the way it always came: a coin held too long, and then a room that has been shut.</p><p class="action beat">He has not smelled it since the sixth of February.</p><p class="action">He gets his hands under the edge of the table and holds on.</p><p class="action beat">It does not go further than that. It sits in the room with him for the rest of the four minutes and then it goes.</p><p class="action">Four along on the near side, Simon puts a hand to his face and Hale is already moving, and the box is on the table now, so there is nothing to fetch.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Head forward.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Yep.</p></div></div></div><div class="screenplay"><p class="action">Nobody stops what they are doing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">That's the docket. Outcomes are notified.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> goes up the stair and stands in the corridor at the top of it, where the air is ordinary, and works out what he has just proved.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="slug">INT. THE BLACK BOX — FRIDAY 6 MARCH — 9:40 P.M.</p></div><div class="screenplay"><p class="action">ELI has the room for the fortnight because somebody in Music booked it on a form that does not exist any more, and he has told everybody the story and it has got better each time.</p><p class="action">He has put four lamps on the floor pointing up, which is the whole of his design and is better than the ceiling rig.</p><p class="action">LENA made the poster. It is on the door and on both boards in Halloran and it is the best-looking object on this campus.</p><p class="action">The band has no name. There was an argument about it for three weeks and the poster has the date and the room on it and nothing else.</p><p class="action">MAYA has been playing a borrowed bass since January and is not good at it. She looks at her hands the entire time.</p><p class="action">They are not good, and it does not matter for two songs, and in the third one the drummer and the bass find each other for about forty seconds and everybody in the room feels it happen.</p><p class="action beat">She looks up once, at the end, at the back of the room.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. BEHIND THE BLACK BOX — 11:20 P.M.</p></div><div class="screenplay"><p class="action">Cold. A fire door propped with a chair. Music students carrying things past them the whole time.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It was good.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It was forty seconds of good.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's more than most things get.</p></div></div></div><div class="screenplay"><p class="action">She takes the notebook out of her coat. It is the same notebook and it has been in that coat since November.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Twelfth of November. Fourth of December, twice.</p><p class="line">Ninth of January. Twenty-second of January. Fourth</p><p class="line">of February. Sixth of February, four minutes,</p><p class="line">under the refectory.</p></div></div></div><div class="screenplay"><p class="action beat">She closes it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Nothing since.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Where have you been for five weeks?</p></div></div></div><div class="screenplay"><p class="action beat">He does not answer.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Sterling. Fourth floor. Carpet. As far off the</p><p class="line">ground as anybody puts anybody on this campus.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">When did you work that out?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">The second week.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And you didn't say.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div></div><div class="screenplay"><p class="action">A music student comes past with an amplifier and neither of them says anything until he has gone.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I'm not asking you to be ill. I want that said</p><p class="line">before anything else, because I can hear how the</p><p class="line">rest of it sounds.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">All right.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">You haven't started the cards.</p></div></div></div><div class="screenplay"><p class="action beat">There it is.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I haven't got a way into that building.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">You haven't looked for one. You've had five weeks</p><p class="line">and you haven't opened a drawer and you know the</p><p class="line">range you need, because you found it in October</p><p class="line">with a piece of paper in your hand.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">He told me not to come back down.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">He told you to photograph a catalogue on the third</p><p class="line">floor of Beckwith in the dry. He said those were</p><p class="line">the same sentence. You've kept the half of it that</p><p class="line">lets you sit still.</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's four hours twice a week and it's easy and I'm</p><p class="line">good at it and I like it.</p></div></div></div><div class="screenplay"><p class="action beat">It is the first time he has said that out loud and he hears it at the same moment she does.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Don't.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I'm not doing anything. In February you wrote a</p><p class="line">machine with three lines in it you didn't put</p><p class="line">there, and I wrote the same three lines, and we</p><p class="line">counted them at six in the morning. Then they put</p><p class="line">you upstairs and you stopped bleeding and started</p><p class="line">being good at something.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">So what am I supposed to do about it?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Ask what the readings are. That's it. That's the</p><p class="line">whole of what I'm asking for tonight and you</p><p class="line">haven't done it and it would take a minute.</p></div></div></div><div class="screenplay"><p class="action">She goes back in through the fire door. ELI is holding it open and has heard the end of it and does not say anything, and goes in after her.</p><p class="action">LENA is against the wall a little way down and has heard all of it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">She's right and it isn't what she's angry about.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What's she angry about?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">That it's working. And that they put it in front</p><p class="line">of you and not her, and she'd have taken it, and</p><p class="line">she knows she would.</p></div></div></div><div class="screenplay"><p class="action beat">She goes in.</p><p class="action">He stands out there on his own for a while and it is very cold and the low notes are coming through the wall from the practice rooms two corridors away.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="slug">INT. STERLING CENTER — FOURTH FLOOR — MONDAY 9 MARCH, 9:20 A.M.</p></div><div class="screenplay"><p class="action">Carpet. Eight desks and four people at them.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Can I ask you something about the spec?</p></div></div><div class="dialogue"><p class="character">DUNN</p><div class="lines"><p class="line">It's a bad spec?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's a fine spec. What are the readings?</p></div></div></div><div class="screenplay"><p class="action beat">Dunn looks up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DUNN</p><div class="lines"><p class="line">Readings of what?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's the question.</p></div></div></div><div class="screenplay"><p class="action">Dunn sits back. He is not annoyed. He is doing something he has not done for a long time, which is thinking about a page he has already signed.</p></div><div class="screenplay"><div class="dialogue"><p class="character">DUNN</p><div class="lines"><p class="line">Nobody's asked me that.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">In twenty-two years?</p></div></div><div class="dialogue"><p class="character">DUNN</p><div class="lines"><p class="line">In twenty-two years. I'll ask upstairs.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Is there an upstairs?</p></div></div><div class="dialogue"><p class="character">DUNN</p><div class="lines"><p class="line">There's always an upstairs.</p></div></div></div><div class="screenplay"><p class="action">He writes it on a slip and puts the slip in a tray, and the tray is emptied on Thursdays.</p><p class="action beat">He has not come back by the end of the week.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">Assignment five. Something that draws itself.</p><p class="action">A line. At the far end of the line, two shorter lines going off at an angle. At the far end of each of those, the same again.</p><p class="action">The whole of it is that the rule mentions itself. There is nothing else in it, and it took him nine minutes, and he has not told anybody that either.</p><p class="action">He did not tell it to be a tree. He told it to do the same thing again from where it got to.</p><p class="action">Change the angle and everything changes and nothing in the rule changes. Take it deeper and it gets slower and does not get any cleverer.</p><p class="action">He submits it on the Friday and it is marked full and there is one program left in the set and it is the one that is his own.</p></div><div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>/* ---------------------------------------------------------------
TREE.CLJS
Two colours now, and the line between them is depth: the trunk is
the first call and the tips are the last ones, and everything in
between is a mix worked out from how far down it was found. The
rule was never told about any of this. It does not know how deep
it is even while it is being it.
Swap either of these for var(--violet) or var(--gold) if you want
the thing to stop pretending to be a tree. It is not one, and the
restraint here is a choice rather than a fact about the drawing.
touch-action and overscroll-behavior are here so that scrolling on
the canvas zooms the drawing rather than the page. The handler
calls preventDefault, but these stop the browser deciding it knew
better before the handler is reached.
--------------------------------------------------------------- */
.widget-tree {
max-width: 30rem;
--tree-trunk: #7d5a3c;
--tree-tip: var(--ink);
}
body.quarto-light .widget-tree {
--tree-trunk: #6b4a2c;
}
.tree-canvas {
display: block;
width: 100%;
max-width: 26rem;
aspect-ratio: 1 / 1;
margin: 0.6rem 0 0.5rem 0;
background: rgba(var(--ink-rgb), 0.04);
border: 1px solid var(--rule);
border-radius: 3px;
cursor: crosshair;
touch-action: none;
overscroll-behavior: contain;
}
.tree-counts {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
opacity: 0.7;
min-height: 1.2em;
}</style><script type="application/x-scittle">(ns fedoras.cs-class.tree.tree-ui
(:require [clojure.string :as str]))
(def settings
(atom {:pattern :tree
:patterns
{:tree {:children [{:turn -0.42 :ratio 0.72} {:turn 0.42 :ratio 0.72}]}
:fern {:children [{:turn 0.03 :ratio 0.87}
{:turn -0.65 :ratio 0.42}
{:turn 0.65 :ratio 0.42}]}
:vine {:children [{:turn 0.22 :ratio 0.94}]}
:crook {:children [{:turn -0.24 :ratio 0.79} {:turn 0.62 :ratio 0.55}]}
:trie {:children [{:turn -0.52 :ratio 0.58}
{:turn 0 :ratio 0.63}
{:turn 0.52 :ratio 0.58}]}
;; grows out of both ends of itself instead of one
:mirror {:trunks 2
:children [{:turn 0.05 :ratio 0.84}
{:turn -0.72 :ratio 0.44}
{:turn 0.72 :ratio 0.44}]}
;; six trunks off one point, which is a hexagon before it is
;; anything else
:snowflake {:trunks 6
:children [{:turn -0.55 :ratio 0.56} {:turn 0.55 :ratio 0.56}]}
;; eight, each one a vine, so the whole thing turns without
;; anything in it turning
:rosette {:trunks 8
:children [{:turn 0.3 :ratio 0.88}]}
;; turns of exactly a right angle's half, and each child
;; 1/root-2 of its parent. that is the Levy C curve, which
;; somebody found in 1938 and is not a tree in any sense
:levy {:children [{:turn -0.7854 :ratio 0.7071}
{:turn 0.7854 :ratio 0.7071}]}
;; a spine that goes almost straight on, with teeth off one
;; side only, because nothing says the children have to be
;; arranged around anything
:comb {:children [{:turn 0 :ratio 0.93} {:turn 1.45 :ratio 0.3}]}}
:spread-scale 1
:ratio-scale 1
:length 26
:lean 0
:jitter 0
:sway 0.5
:sway-min-px 5
:draw-seconds 4
:taper 2.2
:tip-fade 0.78
:colour-gamma 3.5
:min-px 3.5
:log-timing? true
:zoom-step 1.6
:max-depth 150
:max-lines 60000}))
(defonce st (atom nil))
;; where the pointer is over the canvas, 0..1 each way, or nil when it
;; is not. read by child-steps, so moving the mouse bends the rule
;; itself rather than the picture of it.
(defonce sway (atom nil))
;; ---------------------------------------------------------------------
;; The rule
;;
;; One line from here. Then the same thing again from the far end of
;; it, shorter, and turned. That is the whole of it, and it is the same
;; size at depth four as it is at depth twelve.
;;
;; The accumulator is the only thing here that is not in the version on
;; the page above. It carries the lines gathered so far instead of
;; joining lists on the way back out, which the page does not need to
;; care about and a browser drawing twenty thousand of them does.
;; ---------------------------------------------------------------------
(defn- ms [] (.now js/performance))
(defn- log-timing! [label t0 extra]
(when (:log-timing? @settings)
(js/console.log (str "tree: " label " " (.toFixed (- (ms) t0) 1) "ms" extra))))
(defn- jit [j]
(if (pos? j) (* j 2 (- (rand) 0.5)) 0))
(defn- trunks
"How many of these start from the middle, evenly spaced around it.
One is a tree. Two grows out of both ends of itself. Six is a
hexagon before it is anything else, and the rule has not been told
about hexagons -- it only ever gets asked the same question from six
directions at once."
[]
(let [{:keys [pattern patterns]} @settings]
(max 1 (or (:trunks (get patterns pattern)) 1))))
(defn- child-steps
"What the rule does next, as a list: how far each new line turns off
the one it came from, and how much of it it is.
This is the only thing that changed to get every pattern below out
of the same four lines. It used to be one angle and one ratio for
everybody, which can only ever make a tree. Letting each child carry
its own two numbers gets a fern -- a long spine that barely turns,
with short branches off it -- and a vine, which is one child that
keeps almost all of its length and always turns the same way, and is
a spiral for no reason anybody had to write down.
:spread-scale multiplies every turn and :ratio-scale every length,
so the two dials from before still work, on any pattern. The 0.98
is not a style choice: a child that keeps all of its parent never
gets smaller, and the drawing never finishes."
[]
(let [{:keys [pattern patterns spread-scale ratio-scale lean]} @settings
[sx sy] (or @sway [0.5 0.5])
amt (:sway @settings)
;; left/right leans it, up/down opens and closes it
lean (+ lean (* amt (- sx 0.5) 1.2))
spread-scale (* spread-scale (+ 1 (* amt (- 0.5 sy) 1.2)))
kids (or (:children (get patterns pattern))
(:children (get patterns :tree))
[{:turn -0.42 :ratio 0.72} {:turn 0.42 :ratio 0.72}])]
(mapv (fn [{:keys [turn ratio]}]
{:turn (+ lean (* turn spread-scale))
:ratio (min 0.98 (max 0.02 (* ratio ratio-scale)))})
kids)))
;; refit! sits with the other view code, above the drawing it calls
(declare view->px clear! draw-range! paint!
css-var parse-colour mix-colour)
;; ---------------------------------------------------------------------
;; Where it stops
;;
;; Not at a depth. The rule has no depth in it and putting one there
;; was always a lie told for the browser's benefit.
;;
;; It stops at the two places a drawing actually stops: when the next
;; line would be smaller than a pixel, and when everything still to
;; come from here is somewhere the frame is not. Both are facts about
;; the screen, not about the rule, which is why zooming in produces
;; more of it. It was always there.
;;
;; The second one needs a bound on how far a line can possibly reach
;; before it dies out. Each one is :ratio of its parent, so the whole
;; remaining future of a point is at most length + length*r +
;; length*r^2 + ... which is length/(1-r), and a circle of that radius
;; that misses the frame contains nothing that could ever enter it.
;; ---------------------------------------------------------------------
;; ---------------------------------------------------------------------
;; Fitting it in the frame
;;
;; Nothing in the rule knows how large the result is, so the frame is
;; measured against the tree rather than the other way round. Change
;; the ratio to 0.9 and the thing is four times the height it was and
;; still exactly fills the box.
;; ---------------------------------------------------------------------
(defn- bounds [segs]
(reduce (fn [[lo-x lo-y hi-x hi-y] [[x1 y1] [x2 y2] _]]
[(min lo-x x1 x2) (min lo-y y1 y2)
(max hi-x x1 x2) (max hi-y y1 y2)])
[1e9 1e9 -1e9 -1e9]
segs))
(defn- fit-view [segs w h]
(let [[lo-x lo-y hi-x hi-y] (bounds segs)
pad (* w 0.06)
span-x (max 0.001 (- hi-x lo-x))
span-y (max 0.001 (- hi-y lo-y))
s (min (/ (- w (* 2 pad)) span-x)
(/ (- h (* 2 pad)) span-y))
off-x (+ pad (/ (- (- w (* 2 pad)) (* s span-x)) 2))
off-y (+ pad (/ (- (- h (* 2 pad)) (* s span-y)) 2))]
{:scale s :lo-x lo-x :lo-y lo-y :off-x off-x :off-y off-y :h h}))
(defn- px->view
"Back the other way, for zooming at the pointer rather than at the
middle of the frame."
[{:keys [scale lo-x lo-y off-x off-y h]} [px py]]
[(+ lo-x (/ (- px off-x) scale))
(+ lo-y (/ (- h off-y py) scale))])
(defn- zoom-at
"Multiply the scale and move the offsets so the world point under the
pointer stays under the pointer."
[view [px py] factor]
(let [[wx wy] (px->view view [px py])
v' (update view :scale * factor)
[nx ny] (view->px v' [wx wy])]
(-> v'
(update :off-x + (- px nx))
(update :off-y - (- py ny)))))
(defn view->px [{:keys [scale lo-x lo-y off-x off-y h]} [x y]]
;; y goes up in the rule and down on a screen, same as the drawing on
;; the page turning y into (- 100 y)
[(+ off-x (* scale (- x lo-x)))
(- h off-y (* scale (- y lo-y)))])
;; ---------------------------------------------------------------------
;; Building one
;; ---------------------------------------------------------------------
(defn- style-table
"Colour, transparency and width for every depth, worked out once when
the tree is built.
This used to happen inside the drawing, which meant twice per frame:
two getComputedStyle calls -- each one a forced style recalculation
-- and a hundred and fifty colour strings rebuilt, sixty times a
second, to produce exactly the same table every time. Nothing here
changes between frames, so nothing here belongs in a frame."
[node deepest]
(let [trunk (parse-colour (css-var node "--tree-trunk" "#7d5a3c"))
tip (parse-colour (css-var node "--tree-tip" "#e6e4df"))
{:keys [colour-gamma taper tip-fade]} @settings
n (+ deepest 2)
cols (array)
alpha (array)
width (array)]
(dotimes [d n]
(let [u (if (> deepest 1) (/ (dec (max 1 d)) (dec deepest)) 0)
u (min 1 (max 0 u))
t (- 1 u)]
(.push cols (mix-colour trunk tip (js/Math.pow u colour-gamma)))
(.push alpha (+ tip-fade (* (- 1 tip-fade) t)))
(.push width (max 0.4 (* taper (+ 0.25 (* 0.75 t)))))))
{:cols cols :alpha alpha :width width}))
(defn- regrow
"Build whatever is visible at the current view.
Everything the rule needs is pulled out of the settings once, here,
and closed over -- it used to be read again at every one of twenty
thousand nodes, which is twenty thousand map lookups to learn the
same four numbers. The lines come back as one flat array of plain
numbers, five per line, rather than eight thousand little vectors of
vectors, for the same reason: the work that can happen once should.
The rule itself is untouched. It is still a line, then the same
thing again from the end of it, and everything below is bookkeeping
around that."
[view w h]
(let [{:keys [min-px max-depth max-lines jitter length]} @settings
steps (mapv (fn [{:keys [turn ratio]}] [turn ratio]) (child-steps))
widest (apply max (map second steps))
nkids (count steps)
ntrunk (trunks)
scale (:scale view)
lo-x (:lo-x view) lo-y (:lo-y view)
off-x (:off-x view) off-y (:off-y view)
;; compared against the length itself, so the multiply that
;; used to happen at every node happens once here
min-len (/ min-px scale)
reach-k (/ scale (- 1 widest))
jitter? (pos? jitter)
out (array)
deepest (volatile! 0)]
(letfn [(walk [x y angle len d]
(when (and (<= d max-depth)
(< (.-length out) (* 5 max-lines))
(>= len min-len))
;; view->px and near-frame?, inlined: two function
;; calls and eight map lookups per node otherwise
(let [px (+ off-x (* scale (- x lo-x)))
py (- h off-y (* scale (- y lo-y)))
cx (if (< px 0) 0 (if (> px w) w px))
cy (if (< py 0) 0 (if (> py h) h py))
ex (- px cx)
ey (- py cy)
r (* len reach-k)]
(when (<= (+ (* ex ex) (* ey ey)) (* r r))
(let [x2 (+ x (* len (js/Math.cos angle)))
y2 (+ y (* len (js/Math.sin angle)))]
(.push out x) (.push out y)
(.push out x2) (.push out y2) (.push out d)
(when (> d @deepest) (vreset! deepest d))
(dotimes [i nkids]
(let [kid (nth steps i)
turn (nth kid 0)
ratio (nth kid 1)]
(walk x2 y2
(if jitter? (+ angle turn (jit jitter)) (+ angle turn))
(* len ratio) (inc d)))))))))]
(dotimes [i ntrunk]
(walk 50 0 (+ (/ js/Math.PI 2) (* i (/ (* 2 js/Math.PI) ntrunk))) length 1))
{:segments out
:count (js/Math.floor (/ (.-length out) 5))
:deepest @deepest})))
(defn- grow-rough
"The same rule with nothing but a depth limit, used once to find out
roughly how large the whole thing is so the frame can be fitted
around it. Nine deep is enough to know the shape and cheap enough
not to matter, so long as it is also stopped by a count: five
children nine deep is two million lines, and this runs before
anything is on screen to show for it. Four thousand is far more
than enough to know where the edges are.
No jitter here on purpose: this is measuring, and a frame that moved
every time you redrew would be worse than one slightly too big."
[out [x y] angle length d steps]
(if (or (> d 9) (< length 0.02) (>= (count out) 4000))
out
(let [x2 (+ x (* length (js/Math.cos angle)))
y2 (+ y (* length (js/Math.sin angle)))
out (conj out [[x y] [x2 y2] d])]
(reduce (fn [acc {:keys [turn ratio]}]
(grow-rough acc [x2 y2] (+ angle turn) (* length ratio) (inc d) steps))
out
steps))))
(defn- build!
"First build only. Fits the frame around a shallow, cheap version so
there is a sensible starting view, then grows properly into it."
[node cv]
(let [t0 (ms)
w (.-width cv) h (.-height cv)
steps (child-steps)
n (trunks)
rough (reduce (fn [acc i]
(grow-rough acc [50 0]
(+ (/ js/Math.PI 2) (* i (/ (* 2 js/Math.PI) n)))
(:length @settings) 1 steps))
[]
(range n))
view (fit-view rough w h)
t1 (ms)
{:keys [segments count deepest]} (regrow view w h)]
(log-timing! "fit" t0 (str " (" (clojure.core/count rough) " rough lines)"))
(log-timing! "grow" t1 (str " (" count " lines, " deepest " deep)"))
(reset! st (merge {:segments segments :count count :view view :shown 0
:deepest deepest :home view :playing? true}
(style-table node deepest)))))
(defn- refit!
"After a zoom or a pan: regrow into the new view and put the whole
thing straight up, rather than animating twenty thousand lines every
time the wheel moves a notch."
[cv ctx node view]
(let [w (.-width cv) h (.-height cv)
{:keys [segments count deepest]} (regrow view w h)]
(swap! st merge {:segments segments :count count :view view :deepest deepest
:shown count :playing? false}
(style-table node deepest))
(clear! cv ctx)
(draw-range! node ctx 0 count)
(paint! node)))
;; ---------------------------------------------------------------------
;; Drawing
;;
;; Only ever the lines that have not been drawn yet. Nothing on this
;; canvas moves once it is down, so clearing and redrawing all of it
;; sixty times a second would be twenty thousand lines of work to
;; produce a picture identical to the one already there.
;; ---------------------------------------------------------------------
(defn- css-var [node nm fallback]
(or (try (let [v (.getPropertyValue (js/getComputedStyle node) nm)]
(when-not (str/blank? v) (str/trim v)))
(catch :default _ nil))
fallback))
(defn- parse-colour
"A CSS colour into [r g b]. Both forms show up: a stylesheet may say
#e6e4df, but getComputedStyle usually hands back rgb(230, 228, 223),
and a var that resolves to another var can arrive as either."
[c]
(let [c (str/trim (or c ""))]
(cond
(str/starts-with? c "#")
(let [h (subs c 1)
h (if (= 3 (count h)) (apply str (mapcat (fn [ch] [ch ch]) h)) h)]
[(js/parseInt (subs h 0 2) 16)
(js/parseInt (subs h 2 4) 16)
(js/parseInt (subs h 4 6) 16)])
(str/starts-with? c "rgb")
(let [ns (map js/parseFloat (re-seq #"[0-9.]+" c))]
(if (>= (count ns) 3) (mapv js/Math.round (take 3 ns)) [230 228 223]))
:else [230 228 223])))
(defn- mix-colour [a b t]
(str "rgb(" (js/Math.round (+ (* (nth a 0) (- 1 t)) (* (nth b 0) t))) ","
(js/Math.round (+ (* (nth a 1) (- 1 t)) (* (nth b 1) t))) ","
(js/Math.round (+ (* (nth a 2) (- 1 t)) (* (nth b 2) t))) ")"))
(defn- draw-range!
"Draw lines [from,to) out of the flat array. Everything that does not
change per line -- the style table, the transform, the settings --
was worked out before this was called."
[_node ctx from to]
(let [{:keys [segments view cols alpha width]} @st
scale (:scale view)
lo-x (:lo-x view) lo-y (:lo-y view)
off-x (:off-x view) off-y (:off-y view)
h (:h view)
top (dec (.-length cols))]
(set! (.-lineCap ctx) "round")
(dotimes [k (- to from)]
(let [i (* 5 (+ from k))
d (aget segments (+ i 4))
j (if (> d top) top d)]
(set! (.-strokeStyle ctx) (aget cols j))
(set! (.-globalAlpha ctx) (aget alpha j))
(set! (.-lineWidth ctx) (aget width j))
(.beginPath ctx)
(.moveTo ctx
(+ off-x (* scale (- (aget segments i) lo-x)))
(- h off-y (* scale (- (aget segments (+ i 1)) lo-y))))
(.lineTo ctx
(+ off-x (* scale (- (aget segments (+ i 2)) lo-x)))
(- h off-y (* scale (- (aget segments (+ i 3)) lo-y))))
(.stroke ctx)))
(set! (.-globalAlpha ctx) 1)))
(defn- clear! [cv ctx]
(.clearRect ctx 0 0 (.-width cv) (.-height cv)))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn paint! [node]
(let [{:keys [count shown deepest view home playing?]} @st
n count
z (/ (:scale view) (:scale home))]
(fedoras.reader/text!
node ".tree-counts"
(str (name (:pattern @settings)) " · " n " lines on screen · " deepest " deep"
(when (< shown n) (str " · drawn " shown))
(when (> z 1.05) (str " · zoom " (.toFixed z 1) "×"))))
(fedoras.reader/text! node ".tree-pause"
(if playing? "Pause" "Go on"))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- on-button! [node sel f]
(when-let [el (fedoras.reader/q node sel)]
(set! (.-onclick el) (fn [e] (.preventDefault e) (f) false))))
(defn- canvas-px
"Mouse position in canvas pixels, which is not the same as page
pixels the moment CSS scales the canvas down to fit the column."
[cv e]
(let [r (.getBoundingClientRect cv)]
[(* (- (.-clientX e) (.-left r)) (/ (.-width cv) (.-width r)))
(* (- (.-clientY e) (.-top r)) (/ (.-height cv) (.-height r)))]))
(defn- sway-regrow!
"Regrow at a coarser cutoff while the pointer is driving it. Eight
thousand lines rebuilt sixty times a second is not something an
interpreter in a page is going to do, and at :sway-min-px it is
nearer two thousand, which it will."
[node cv ctx]
(let [real (:min-px @settings)]
(swap! settings assoc :min-px (:sway-min-px @settings))
(let [w (.-width cv) h (.-height cv)
{:keys [segments count deepest]} (regrow (:view @st) w h)]
(swap! settings assoc :min-px real)
(swap! st merge {:segments segments :count count :deepest deepest
:shown count :playing? false}
(style-table node deepest))
(clear! cv ctx)
(draw-range! node ctx 0 count))))
(defn- wire-view! [node cv ctx]
;; addEventListener with passive false, not (set! (.-onwheel cv) ...):
;; browsers are entitled to treat a wheel handler as passive, and a
;; passive handler's preventDefault does nothing, which is the page
;; scrolling out from under the thing you were trying to zoom.
(.addEventListener
cv "wheel"
(fn [e]
(.preventDefault e)
(.stopPropagation e)
(let [step (:zoom-step @settings)
f (if (neg? (.-deltaY e)) step (/ 1 step))]
(refit! cv ctx node (zoom-at (:view @st) (canvas-px cv e) f))))
#js {:passive false})
(let [dragging (atom nil)]
(set! (.-onmousedown cv)
(fn [e] (reset! dragging (canvas-px cv e)) false))
(set! (.-onmousemove cv)
(fn [e]
(let [[nx ny] (canvas-px cv e)]
(if-let [[ox oy] @dragging]
;; a held button moves the frame
(when (or (> (js/Math.abs (- nx ox)) 1) (> (js/Math.abs (- ny oy)) 1))
(reset! dragging [nx ny])
(refit! cv ctx node
(-> (:view @st)
(update :off-x + (- nx ox))
(update :off-y - (- ny oy)))))
;; a loose one bends the rule
(when (pos? (:sway @settings))
(reset! sway [(/ nx (.-width cv)) (/ ny (.-height cv))])
(swap! st assoc :swaying? true))))
false))
(set! (.-onmouseup cv) (fn [_] (reset! dragging nil) false))
(set! (.-onmouseleave cv)
(fn [_]
(reset! dragging nil)
;; let it fall back upright, and put the fine version back
(when @sway
(reset! sway nil)
(refit! cv ctx node (:view @st)))
false))))
(defn- wire-controls! [node cv ctx]
(on-button! node ".tree-again"
(fn [] (build! node cv) (clear! cv ctx) (paint! node)))
(on-button! node ".tree-pause"
(fn [] (swap! st update :playing? not) (paint! node)))
(on-button! node ".tree-all"
(fn []
(let [n (:count @st)]
(draw-range! node ctx (:shown @st) n)
(swap! st assoc :shown n :playing? false)
(paint! node))))
(on-button! node ".tree-home"
(fn [] (refit! cv ctx node (:home @st))))
(wire-view! node cv ctx))
(defn- wire-settings! [node id cv ctx]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current
(select-keys setting
[:pattern :patterns :spread-scale :ratio-scale
:length :lean :jitter :sway :sway-min-px
:draw-seconds :taper :tip-fade :colour-gamma
:min-px :zoom-step :max-depth :max-lines :log-timing?]))))
(build! node cv)
(clear! cv ctx)
(paint! node))))
(defn setup! [node]
(let [id (.-id node)
cv (fedoras.reader/q node ".tree-canvas")
ctx (.getContext cv "2d")]
;; the build is a few hundred thousand interpreted operations and
;; it used to run right here, which is the page sitting still until
;; it finished. a timeout of zero is enough: the browser gets to
;; finish loading and paint, and the tree turns up immediately
;; after rather than in front.
(reset! st {:segments (array) :count 0 :shown 0 :deepest 1
:view {:scale 1 :lo-x 0 :lo-y 0 :off-x 0 :off-y 0 :h (.-height cv)}
:home {:scale 1} :playing? false
:cols (array "#000") :alpha (array 1) :width (array 1)})
(js/setTimeout (fn [] (build! node cv) (clear! cv ctx) (paint! node)) 0)
(fedoras.ui.widget-ui/wire! node "controls" (fn [n] (wire-controls! n cv ctx)))
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id cv ctx)))
(letfn [(frame []
(try
;; one regrow per frame at most, however many times the
;; pointer moved since the last one
(when (:swaying? @st)
(swap! st assoc :swaying? false)
(sway-regrow! node cv ctx))
(let [{:keys [count shown playing?]} @st
n count]
(when (and playing? (< shown n))
;; lines per frame, worked out from how long the
;; whole thing should take rather than fixed --
;; otherwise a tree of thirty thousand lines takes
;; a minute and one of two thousand is over before
;; you have looked at it
(let [per (max 1 (js/Math.ceil (/ n (* 60 (:draw-seconds @settings)))))
to (min n (+ shown per))]
(draw-range! node ctx shown to)
(swap! st assoc :shown to)
;; the moment it finishes, rather than up to
;; twelve frames afterwards
(when (or (= to n) (zero? (mod to 60)))
(paint! node)))))
(catch :default e
(js/console.error "tree: frame failed, skipping" e)))
(js/requestAnimationFrame frame))]
(frame))
(paint! node)))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::tree (or id "tree") setup! paint!))</script><div class="widget widget-tree" id="tree"><div class="roll-head"><span class="roll-label">TREE.CLJS · one rule, mentioned twice</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">tree_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="24" spellcheck="false">;; ---------------------------------------------------------------------
;; TREE_SETTINGS.CLJS — what the rule is told.
;;
;; Change a number and press Run. Break it and the line underneath will
;; tell you what it thought you meant. Nothing you do here can lose the
;; original: `Put it back` is the file as it shipped.
;;
;; None of these are in the rule. The rule is four lines long and does
;; not mention trees, ferns, spirals, colour, or how big any of this is
;; going to be. Everything below is something the rule was handed.
;; ---------------------------------------------------------------------
{;; ---------------------------------------------------------------
;; WHICH ONE
;;
;; Every shape below comes out of the same four lines. The only
;; thing that differs is the list each one hands over: how far each
;; new line turns off the one it came from, and how much of it it
;; is.
;;
;; :tree — two, even, both a bit shorter. The original.
;; :fern — a spine that barely turns and keeps most of its
;; length, with two short branches off it. Twenty
;; deep before the screen runs out.
;; :vine — one child. It cannot branch, so it cannot be a
;; tree, and it turns the same way every time and is
;; a spiral, which nobody wrote down anywhere.
;; :crook — two, lopsided: one carries on, one goes off hard
;; and gives up early.
;; :trie — three, close together, all short.
;; :mirror — two trunks, opposite ways, so it grows out of both
;; ends of itself instead of standing on one.
;; :snowflake — six trunks off one point, which is a hexagon
;; before it is anything else.
;; :rosette — eight trunks, each of them a vine. The whole thing
;; turns without anything in it turning.
;; :levy — turns of exactly half a right angle, each child
;; one over root two of its parent. That is the Levy
;; C curve, which somebody found in 1938, and it is
;; not a tree in any sense: it folds in on itself
;; into a solid block with a coastline.
;; :comb — a spine going almost straight on, with teeth off
;; one side only. Nothing anywhere says the children
;; have to be arranged around anything.
;;
;; :trunks is the second thing that generalised. The rule has not
;; been told about hexagons and never will be. It only gets asked
;; the same question from six directions at once, and a hexagon is
;; what that looks like from outside.
;;
;; Add your own. Any list of turns and ratios works. A ratio at or
;; above 1 means a child that never gets smaller and a drawing that
;; never finishes, so it is quietly held to 0.98.
;; ---------------------------------------------------------------
:pattern :tree
:patterns
{:tree {:children [{:turn -0.42 :ratio 0.72} {:turn 0.42 :ratio 0.72}]}
:fern {:children [{:turn 0.03 :ratio 0.87}
{:turn -0.65 :ratio 0.42}
{:turn 0.65 :ratio 0.42}]}
:vine {:children [{:turn 0.22 :ratio 0.94}]}
:crook {:children [{:turn -0.24 :ratio 0.79} {:turn 0.62 :ratio 0.55}]}
:trie {:children [{:turn -0.52 :ratio 0.58}
{:turn 0 :ratio 0.63}
{:turn 0.52 :ratio 0.58}]}
:mirror {:trunks 2
:children [{:turn 0.05 :ratio 0.84}
{:turn -0.72 :ratio 0.44}
{:turn 0.72 :ratio 0.44}]}
:snowflake {:trunks 6
:children [{:turn -0.55 :ratio 0.56} {:turn 0.55 :ratio 0.56}]}
:rosette {:trunks 8
:children [{:turn 0.3 :ratio 0.88}]}
:levy {:children [{:turn -0.7854 :ratio 0.7071}
{:turn 0.7854 :ratio 0.7071}]}
:comb {:children [{:turn 0 :ratio 0.93} {:turn 1.45 :ratio 0.3}]}}
;; ---------------------------------------------------------------
;; THE TWO DIALS
;;
;; These work on whichever pattern is loaded, because they are
;; multipliers rather than values. :spread-scale 2 doubles every
;; turn in the list; 0 flattens all of them and everything grows in
;; one straight line. :ratio-scale 1.1 makes every child keep more
;; of its parent, which makes the whole thing very much larger and
;; costs nothing, because the frame is fitted afterwards.
;;
;; :lean is added to every turn in the same direction, so the whole
;; thing curls. 0 is upright. 0.08 is a tree on a hill. On :vine it
;; is the difference between a spiral and a wider spiral.
;;
;; :jitter is how far each turn is allowed to wander off its mark,
;; drawn fresh every time. 0 is the machine drawing. 0.15 is
;; something that grew, and `Draw it again` never gives you the same
;; one twice.
;; ---------------------------------------------------------------
:spread-scale 1
:ratio-scale 1
:lean 0
:jitter 0
:length 26
;; ---------------------------------------------------------------
;; THE POINTER
;;
;; Moving the mouse over it bends the rule while you are there:
;; left and right lean it, up and down open and close it. Nothing
;; is being pushed around — every line is regrown from a rule that
;; has been asked a slightly different question, sixty times a
;; second.
;;
;; Which is why it goes coarse while you do it. :sway-min-px is the
;; cutoff used only while the pointer is driving, because eight
;; thousand lines rebuilt every frame is not something a page is
;; going to manage; at 5 it is nearer two thousand, and the fine
;; version comes back the moment you leave.
;;
;; :sway 0 turns it off and the mouse only drags and zooms.
;; ---------------------------------------------------------------
:sway 0.5
:sway-min-px 5
;; ---------------------------------------------------------------
;; WHERE IT STOPS
;;
;; There is no depth here, because there is no depth in the rule.
;; It stops where a drawing stops: when the next line would be
;; thinner than :min-px pixels, and when everything still to come
;; from a point is somewhere the frame is not.
;;
;; Both of those are facts about your screen. Neither is a fact
;; about the rule. Which is why you can scroll into it and find
;; more, and why you will not find the bottom: every time you zoom
;; it throws away what has gone off the edges and grows what has
;; become large enough to see, so the count stays about the same
;; however far in you go.
;;
;; :min-px is the one number here that costs real time, and it does
;; not cost it smoothly. Every line is a fixed fraction of its
;; parent, so the whole tree gains or loses an entire generation at
;; once, and a generation is as many lines as everything above it
;; put together:
;;
;; min-px 2.0 — 16383 lines
;; min-px 2.5 — 8191
;; min-px 3.5 — 4095 (this)
;; min-px 5.0 — 2047
;;
;; The walk visits about twice those numbers, since it has to reach
;; a line before it can decide the line is too small to keep. At
;; 3.5 that is eight thousand visits, and every one of them is
;; interpreted in the page rather than compiled, which is the
;; difference between this appearing at once and appearing after a
;; pause.
;;
;; It matters less than it looks like it should, because the detail
;; you gave up is not gone. Scroll into it and it grows back.
;;
;; :max-depth and :max-lines are not part of any of that. They are
;; there so a mistake in the numbers above stops eventually instead
;; of taking the page with it. :vine legitimately goes sixty-six
;; deep, which is why the depth guard is not a small number.
;; ---------------------------------------------------------------
:min-px 3.5
:zoom-step 1.6
:max-depth 150
:max-lines 60000
;; Prints how long the fit and the growth took to the browser
;; console, which is the only way to find out rather than guess.
;; Set it false once you have stopped caring.
:log-timing? true
;; ---------------------------------------------------------------
;; THE DRAWING OF IT
;;
;; :draw-seconds is how long the whole drawing takes, however many
;; lines that turns out to be. Set it to 30 and watch it go all the
;; way down one side before it comes back for the other, which is
;; not a decision anybody made about drawing. It is only the order
;; the rule reaches them in, and it is the same order every time.
;;
;; Zooming does not animate. It puts the new lines up at once,
;; because waiting four seconds after every notch of the wheel is
;; not exploring anything.
;;
;; :taper is how much thicker the trunk is than the tips, and
;; :tip-fade is how much of the page shows through at the far end.
;; The colour goes from --tree-trunk to --tree-tip, both in the
;; stylesheet — but not evenly, and :colour-gamma is why.
;;
;; Half of every one of these is at its deepest level. A quarter is
;; one above that. Seven eighths of all the lines are in the last
;; three depths, so a colour spread evenly across the depths spends
;; nearly all of itself on the four lines nearest the trunk, and
;; hands the eight thousand you can actually see a seventeenth of
;; the range, which reads as one flat colour and looks like the
;; feature is broken.
;;
;; 3.5 holds the trunk colour further out and puts the change where
;; the lines actually are. Set it to 1 for the even version and see
;; the problem. Higher pushes the whole gradient into the outermost
;; twigs.
;;
;; The rule does not know about any of the three. It does not know
;; how deep it is even while it is being it — the depth is
;; something counted from outside while it worked, and everything
;; here is painted on afterwards by someone who wanted it to look
;; like a tree.
;; ---------------------------------------------------------------
:draw-seconds 4
:taper 2.2
:tip-fade 0.78
:colour-gamma 3.5}</textarea><div class="live-bar"><button class="roll-go widget-settings-run">Run</button><button class="roll-rewrite widget-settings-reset">Put it back</button></div><p class="widget-settings-said">Press Run.</p></div><div class="widget-body"><canvas class="tree-canvas" height="640" width="640"></canvas><p class="tree-counts"></p><div class="live-bar"><button class="roll-go tree-again">Draw it again</button><button class="roll-go tree-pause">Pause</button><button class="roll-go tree-all">All at once</button><button class="roll-go tree-home">Back out</button></div></div><p class="roll-foot">Drawn in the order it thought of them: all the way down one side before it comes back for the other. Scroll to zoom, drag to move. It keeps going.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.tree.tree-ui/mount!))] (mount {:id "tree"}) (js/console.error "fedoras: fedoras.cs-class.tree.tree-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div></div>
```