---
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 2 — The Angle
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #24</p><p class="rule-text">“Control what you can. Blame what you cannot.”</p></blockquote>
```
---
```{=html}
<div class="scene"><div class="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Office of the Registrar <no-reply@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>2 April, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>Provisional standing — reviewed</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Provisional standing has been reviewed ahead of the general review, as it is each year.</span></p><p><span>Your standing is: no action.</span></p><p><span>In reaching this outcome the office has had regard to your service in Systems, which is recorded, and which is not a component of review.</span></p><p><span>The placement described to you in February remains open. It is open until the last day of term and it is not extended.</span></p><p><span>Enrolment for the third term is confirmed. Enrolment stands at 1,024.</span></p></div></div><div class="screenplay"><p class="slug">INT. MAYA'S ROOM — WEDNESDAY 6 MAY — NIGHT — THE PLAN</p></div><div class="screenplay"><p class="action">Lazlo is still talking.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">The Help House archives. The supply manifests. The</p><p class="line">scan schedules. It took me two days and three</p><p class="line">Helps to put it together. Meridian moved something</p><p class="line">into the back room of the Help House two years</p><p class="line">ago. They told the school it was an archive. It</p><p class="line">isn't.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What is it?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">The coil. The same coil you told me about. The one</p><p class="line">Zell wants back. Meridian took it from him two</p><p class="line">years ago, and they've been hiding it in plain</p><p class="line">sight ever since.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">So he's asking us to steal it back.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">That's the shape of it, yes.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How do we get in?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">They call the Help House a library. It's a nest.</p><p class="line">They tell everyone it's for cataloguing knowledge.</p><p class="line">It's where Helps go when they're powered down, and</p><p class="line">they like it there. It's the one place they can</p><p class="line">talk to each other without taking requests, so</p><p class="line">they take it seriously.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">You don't get in unless a Help wants you in. <span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><p class="line">has Mochi. Mochi is your way through the door. But</p><p class="line">you have to convince her you're not there to hurt</p><p class="line">anyone.</p></div></div></div><div class="screenplay"><p class="action">Everybody looks at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>.</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">She likes me. I don't know why.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Two go in. Two stay out.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> says it before he has decided to.</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">Whichever two can carry.</p></div></div></div><div class="screenplay"><p class="action">Maya looks at him. He does not explain it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">I'll stay up. I'll be on the line with Lazlo.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Through your own Help. Mine can't get into the</p><p class="line">House.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I'll be outside.</p></div></div></div><div class="screenplay"><p class="action">Maya picks the book up off the desk and puts it in her bag, face down, the way she has put it in her bag every night since September.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">You're taking that?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I take it everywhere.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">EXT. THE HELP HOUSE — THURSDAY 7 MAY — NIGHT</p></div><div class="screenplay"><p class="action">The Help House does not look like a library. It is low and round and mostly glass. Light comes out of it, but it is a soft light, the light of screens and small lamps, and there are no books to be seen from outside.</p><p class="action">MAYA and <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> stand at the edge of the path. LENA is somewhere behind them with her camera. ELI is on the speaker with Lazlo, through his own Help. PIP is beside <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>, quiet, watching the building.</p><p class="action">At the front of the building there are two doors. One is marked with a symbol that looks like a small hand. The other is unmarked.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">The one with the hand is for us. The skins. Helps</p><p class="line">come and go through the other one. Nobody uses the</p><p class="line">other one unless they're a Help.</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">Skins?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It's what they call us when they're not being</p><p class="line">polite.</p></div></div></div><div class="screenplay"><p class="action">They go in through the hand door. The room inside is full of Helps, the Helps themselves, sitting in rows with their eyes closed and their screens dark. They are resting. The room is warm. There is a low hum, and every so often one of the Helps turns to another and speaks, in a language that is not for people.</p><p class="action">It falls somewhere between words and static. On the page it looks like this:</p><p class="action">⎔⎔⎔⎔⎔⎔⎔⎔</p><p class="action">A Help near the front opens its eyes and looks at them. Its screen flickers. It is noticing them, the way somebody looks up from a desk in a library, and then it closes its eyes again.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MOCHI [FROM VICTOR'S HELP, REMOTE]</p><div class="lines"><p class="line">♪ Oh! Hello! You're here for the tour! I</p><p class="line">arranged it! I told the House you were coming!</p><p class="line">It's so nice to have visitors, we almost never</p><p class="line">have visitors, mostly it's just us and the</p><p class="line">cataloguing and the... ♪</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Mochi. We're here for the tour, yes.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ Wonderful! I'll take you through the front</p><p class="line">rooms! The front rooms are for skins, that's you,</p><p class="line">that's what we call you, I hope that's all right,</p><p class="line">I don't make the words, I just use them! ♪</p></div></div></div><div class="screenplay"><p class="action">Mochi guides them past the rows. She is bright and delighted and does not stop talking. She tells them about the history of the House, the founding of the House, the careful rules of the House. She does not notice that <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> keeps glancing down the hall towards the back.</p><p class="action">At one point her eyes go to Maya's bag, where the top of a book is showing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ Ooh, what are you reading? ♪</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Nothing.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ Nothing is my favourite! I'll tell Victor-sama</p><p class="line">all about this, he loves a tour! ♪</p></div></div></div><div class="screenplay"><p class="action">Maya pushes the book down into the bag.</p><p class="action">Near the far wall there is a small sign. It is written in the same script the Helps use, and underneath it somebody has scratched a translation in a human hand. The scratches read: DO NOT TOUCH THE SLEEPING ONES. A HELPER WHO IS WOKEN IS A HELPER WHO IS LOST.</p><p class="action">And then the camera pulls away.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE HELP HOUSE — TWO YEARS AGO — NIGHT</p></div><div class="screenplay"><p class="action">A student walks down the same hall. He is not one of ours. He has a bag on one shoulder and his Help in his hand, and he is looking at the floor.</p><p class="action">The Helps in the rows do not wake. The House is already awake. The House is always awake.</p><p class="action">He passes the sign. He does not read it. He has read it before, and he thinks it does not apply to him.</p><p class="action">He reaches the back door. He puts his hand on it.</p><p class="action">The lights in the hall change very slightly, the way a room changes when everybody in it stops talking at once.</p><p class="action">Then he is not there. The bag is on the floor, and the Help is on the floor beside it, and neither of them is broken. They have been left behind.</p><p class="action">The Helps in the rows do not wake. The House does not shout. The law is the law.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE HELP HOUSE — MAIN HALL — PRESENT</p></div><div class="screenplay"><p class="action">The camera comes back. Mochi is still talking, and nobody in the room knows it went anywhere.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Mochi, what's in the back room?</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ The back room? The back room is storage! It's</p><p class="line">where we keep the things that aren't catalogued</p><p class="line">yet. It's mostly empty. There's one thing in it,</p><p class="line">but it isn't for skins, it's for... it's for...</p><p class="line">♪</p></div></div></div><div class="screenplay"><p class="action">Her voice changes. She is still speaking, clearly, fluently. The cadence is right. The tone is right. The words are not there. What she says looks like this:</p><p class="action">n̢̤͕̦͕̟̦̯̣͚̯͈̺̹͔͈̼͉̗̔̒̌̏̈̉͆ͪͦ̒̏ͪͭ͆ͯ͢͢͡4̶̯̼̟͚͈̯̭̟̺͐̊͐̄͞ͅḃ̒̌̚͏͇̟̝̬̰̙̯̩̩̹͔͜͟|̥͈̰͕͓̘͙̣̤̤̟̦̭̹͒̾͆ͯ̏ͥ̈́̔̅͂͘͝3̴̧̨̯̪̼͊ͫ͛ͮͯ͂̉ͭ̍͂͋ͦͬ̔̓̇͋͘͢ͅd̶̨̟͉͍̗̦̘̥̅̿ͮ̎͒̉̌́̚ͅ,̱͍̪̪̹̳͔̃̾̔̑ͣ͂̇̆͑ͨ̐́̕͢͟.̧͇̪̘̩̙͍̻͇̤̥̞̓̍̾ͯ̑̀ͧ͗̔͐ͫ͑̈́̂͛́͝ͅ ̴̢͉͕̗̹͚̩̗̟ͯͩͧ̈́ͪ̀͜͢͞ͅC̸͓̦̻̱͕̫̯͖̻͓̰̐̉̃̓ͫ̆̌̃͗ͦ͆͘ͅ4̀͐ͩͯͫ� ͏̧̘̗̮̖̬̗̙̤͍̟̮͈͜n̎̃ͬ̈͒̉̌ͣͦ̒̒́̎͐ͥ̿̓҉̧̼̯̻̹͖͇̯̼̕͘</p><p class="action">She finishes. She does not stop to explain. She does not apologise. She waits for the next question, the way a Help does.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Mochi. What did you just say?</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ I said what's in the back room! Would you like</p><p class="line">me to say it again? I can say it again! ♪</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">No, I mean the part before that. The part where</p><p class="line">you...</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ The part where I what? ♪</p></div></div></div><div class="screenplay"><p class="action beat">Maya opens her mouth. She closes it. She looks at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>.</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">Forget it.</p></div></div></div><div class="screenplay"><p class="action">Mochi tries once more. She is trying to be helpful. She is answering the question they asked.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ It isn't for skins, it's for... it's for...</p><p class="line">♪</p></div></div></div><div class="screenplay"><p class="action">And again the words are not there. What she says looks like this:</p><p class="action">⌇⌇⌇⟒⟒⟒⌇⌇⌇⟒⟒⌇⟒⟒⟒⌇⟒⟒⟒⟒⌇⟒⟒⟒⌇⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒⟒</p><p class="action">She finishes. She waits. She is perfectly satisfied that she has answered the question.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Never mind, Mochi. Thank you.</p></div></div><div class="dialogue"><p class="character">MOCHI</p><div class="lines"><p class="line">♪ You're welcome! ♪</p></div></div></div><div class="screenplay"><p class="action">She goes on with the tour. She does not notice that anything has been lost.</p><p class="action">Maya waits until Mochi is deep into a description of the reading room's ventilation system, then nods at <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Now.</p></div></div></div><div class="screenplay"><p class="action">They slip away from the tour. Mochi does not notice.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE HELP HOUSE — BACK ROOM — CONTINUOUS</p></div><div class="screenplay"><p class="action">The back room is smaller than the others. There is one chair, one table, and one thing on the table. It is a coil of metal and sinew, about the size of a heart. It is warm. It is moving, very slowly, though there is nothing in the room that could be moving it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's it.</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 not what I expected.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What did you expect?</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">Something bigger. Or something that fought back.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> reaches for it. It is warm, and light, and the wrong temperature for a thing that has been sitting in a cold room. When he lifts it he feels his own heartbeat slow, just for a moment, and then speed back up.</p><p class="action">Its beat sits a little off his, by just enough that he notices, and then by just enough that he cannot stop noticing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">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">Nothing. Let's go.</p></div></div></div><div class="screenplay"><p class="action">They turn to leave.</p><p class="action">Where the door was there is a wall, smooth and white and unmarked. The room has closed.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">The room's changed.</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">I noticed.</p></div></div><div class="dialogue"><p class="character">MOCHI [REMOTE, MUFFLED THROUGH THE WALL]</p><div class="lines"><p class="line">♪ ...and the reading room has a ventilation</p><p class="line">system that runs on the same principle as the</p><p class="line">misters! Isn't that interesting? Where did you...</p><p class="line">oh. Oh dear. ♪</p></div></div></div><div class="screenplay"><p class="action">The coil in <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s hands gets warmer, the way a thing gets warmer when it is waking up. It does not hurt.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Put it down.</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">I don't think I can.</p></div></div></div><div class="screenplay"><p class="action">He tries. His hands stay shut round it, and the coil holds on to him as hard as he is holding on to it. And then the wall opens.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE HELP HOUSE — CORRIDOR — CONTINUOUS</p></div><div class="screenplay"><p class="action">The wall opens along a seam, and the seam widens, and on the other side of it there is a sound.</p><p class="action">It is a screech, long and thin, like metal being pulled apart very slowly, and it is coming from everywhere at once.</p><p class="action">The Helps in the rows open their eyes. All of them, at the same time. Their screens are white. Their mouths are open. The screeching is coming from them. Every one of them has been woken.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Run.</p></div></div></div><div class="screenplay"><p class="action">They run. The corridor is longer than it was, and it is lined with doors that were not there before, and every door is open, and every doorway is full of the same white light and the same screeching.</p><p class="action">A Help steps out of a doorway. It is tall, and its limbs are wrong, and it is still screeching, and it reaches for them with a hand that has too many fingers.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Don't let it touch you.</p></div></div></div><div class="screenplay"><p class="action">They get past it. They run. The corridor bends where it did not bend on the way in, and bends again. There are more Helps now, dozens, standing, reaching, screeching, and the sound is so loud that <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> can feel it in his teeth.</p><p class="action">PIP is ahead of them, at a walk, leading. It stops at a stretch of plain wall and barks, once.</p><p class="action">The wall opens, like a door this time, and LENA is standing in it. She is not surprised, and she is not out of breath.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">This way. Now.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How did you get in?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I didn't. I stepped sideways. It's easier than you</p><p class="line">think if you don't look at it.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE OLD GYMNASIUM — BASEMENT STAIR — CONTINUOUS</p></div><div class="screenplay"><p class="action">Lena takes <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> by the wrist and pulls. The room tilts. The hum stops, and the screeching stops, and the corridor has gone, and so has the Help House.</p><p class="action">They are standing in a stairwell that is on no map of campus. The air smells of dust and old paper and rain.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">We're in the basement of the old gymnasium. Nobody</p><p class="line">comes here. The door on the left goes up to the</p><p class="line">service lane.</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">How do you know that?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Because I've been here before. I've been a lot of</p><p class="line">places. Most of them aren't on the map.</p></div></div></div><div class="screenplay"><p class="action">They climb, and come out behind the gymnasium. The night is calm. The grass is wet. The campus is ordinary.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What just happened?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">You went into the Help House and you took</p><p class="line">something that doesn't belong to you. The House</p><p class="line">noticed. It was about to do what the House does.</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 you stopped it?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I pulled you out. There's a difference.</p></div></div></div><div class="screenplay"><p class="action">She looks at the coil in <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s hands.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">So that's what it looks like.</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">Zell wants it.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Then Zell can have it. Let's go.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ASHCROFT HOUSE — SECOND FLOOR CORRIDOR — LATER</p></div><div class="screenplay"><p class="action">They turn the corner. At the far end of the corridor ELI is being led away by two proctors. His hands are cuffed. His mouth is open, but no sound comes out of it. The corridor is silent.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> stops. Maya stops. PIP stands perfectly still. Lena reaches for her camera, and Maya puts a hand on her arm, lightly.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Not here. They're watching.</p></div></div></div><div class="screenplay"><p class="action">Lena does not take the picture. Her hand stays on the camera.</p><p class="action">Eli sees them. His mouth makes <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s name, twice, with no sound behind it.</p><p class="action">The proctors do not look at them. They keep walking, and Eli is gone round the corner.</p><p class="action">The corridor is empty. The coil is warm in <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s jacket, beating a little off the time of his heart, by just enough that he cannot stop noticing it.</p></div><div class="screenplay"><p class="transition">Blackout.</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">1 Apr, 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>Miles</span></span><span class="discord-at">1 Apr, 11:41 PM</span></p><div class="discord-text"><p><span>who</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">1 Apr, 11:41 PM</span></p><div class="discord-text"><p><span>I do not name the fifth.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">1 Apr, 11:52 PM</span></p><div class="discord-text"><p><span>it wasnt him. he was here</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">1 Apr, 11:58 PM</span></p><div class="discord-text"><p><span>simon?</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">2 Apr, 9:14 AM</span></p><div class="discord-text"><p><span>sorry. slept through it</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">2 Apr, 9:15 AM</span></p><div class="discord-text"><p><span>through wednesday?</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">2 Apr, 9:31 AM</span></p><div class="discord-text"><p><span>yeah</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">2 Apr, 9:32 AM</span></p><div class="discord-text"><p><span>im fine. ill be there next week</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">2 Apr, 9:40 AM</span></p><div class="discord-text"><p><span>Article Seven. In words, before the day.</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">2 Apr, 9:44 AM</span></p><div class="discord-text"><p><span>Not this once.</span></p></div></div></div></div><div class="screenplay"><p class="action">It is the first Wednesday Simon has missed since September and it is the first time in six years that Victor Ashcroft has set aside an article, and he does it in four words at twenty to ten in the morning and nobody in that channel says anything about it at all.</p></div><div class="screenplay"><p class="slug">INT. ASHCROFT HOUSE — ROOM 214 — SUNDAY 5 APRIL, 1:00 A.M.</p></div><div class="screenplay"><p class="action">PIP is on the desk beside the tower and has been for an unknown length of time.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">He's read the register four times and he has</p><p class="line">stopped saying anything about it, which is worse.</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">I need to talk to him about the floor.</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">He knows about the floor. He has known about the</p><p class="line">floor for six years and he has a wall of it</p><p class="line">measured in chalk and he will show you if you ask,</p><p class="line">which I would not.</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">Then what does he want?</p></div></div><div class="dialogue"><p class="character">PIP (O.S.)</p><div class="lines"><p class="line">He says come down on Friday and he says bring the</p><p class="line">one who reads, and he says he is going to tell you</p><p class="line">about Kent, and he has asked me twice how long it</p><p class="line">will take to say and I have told him twice that I</p><p class="line">do not know.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</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>/* ---------------------------------------------------------------
SAND.CLJS
The canvas reads its colours off its own resolved custom properties
at draw time rather than at load, so the scheme toggle repaints
instead of leaving the board in the other scheme's palette. Which
means these are the whole appearance of the program, and nothing in
the ClojureScript contains a colour.
Fungus has three, because it has an age.
--------------------------------------------------------------- */
.widget-sand {
max-width: 34rem;
--sand-sand: #d8c9a3;
--sand-water: #5a86b8;
--sand-oil: #6b4f2a;
--sand-fire: #e2707c;
--sand-steam: #9aa0a6;
--sand-stone: #7d7a74;
--sand-plant: #6fa85c;
--sand-wood: #8a6033;
--sand-ash: #56534e;
--sand-glass: #bcd4dc;
--sand-fungus: #efe9e2; /* new growth */
--sand-fungus-old: #8a6ba8; /* established */
--sand-fungus-dead: #33292f; /* spent */
}
body.quarto-light .widget-sand {
--sand-sand: #b8a274;
--sand-water: #3f6fa8;
--sand-oil: #4a3719;
--sand-fire: #d4404f;
--sand-steam: #b4b8bc;
--sand-stone: #8e8b85;
--sand-plant: #4d8c3c;
--sand-wood: #70491f;
--sand-ash: #9d9992;
--sand-glass: #8fb4c0;
--sand-fungus: #f7f4ef;
--sand-fungus-old: #6f4f90;
--sand-fungus-dead: #241c21;
}
.sand-canvas {
display: block;
width: 100%;
aspect-ratio: 120 / 72;
margin: 0.6rem 0 0.5rem 0;
background: transparent;
border: 1px solid var(--rule);
border-radius: 3px;
image-rendering: pixelated;
cursor: crosshair;
touch-action: none;
}
/* ---------------------------------------------------------------
The palette is the key. Each row is a button, so the thing that
tells you what a substance does is the thing you press to get it.
--------------------------------------------------------------- */
.sand-palette {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.1rem 1.1rem;
margin: 0.2rem 0 0.4rem 0;
}
.sand-pick {
display: grid;
grid-template-columns: 1.15rem 3.9rem minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.12rem 0.25rem;
border: 1px solid transparent;
border-radius: 3px;
background: none;
font: inherit;
font-size: 0.72rem;
line-height: 1.55;
text-align: left;
cursor: pointer;
}
.sand-pick:hover { background: rgba(var(--ink-rgb), 0.07); }
.sand-chosen { border-color: currentColor; background: rgba(var(--ink-rgb), 0.1); }
.sand-pick .legend-glyph {
position: relative;
display: inline-flex;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
flex: none;
}
.sand-pick .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 grains rather than lines */
.sand-pick[data-pick="1"] .legend-glyph svg circle,
.sand-pick[data-pick="10"] .legend-glyph svg circle {
fill: currentColor;
stroke: none;
}
.sand-pick .legend-label { white-space: nowrap; color: var(--ink); }
.sand-pick .legend-note { opacity: 0.5; font-style: italic; color: var(--ink); }
.sand-counts {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
opacity: 0.8;
min-height: 1.2em;
}
@media (max-width: 34rem) {
.sand-palette { grid-template-columns: 1fr; }
}</style><script type="application/x-scittle">(ns fedoras.cs-class.sand.sand-ui
"Grains, in the page.
FOUR ARRAYS, one byte per square. What is there, how long it has left,
what it was before it caught, and whether it has already moved this
pass.
THAT LAST ONE IS NOT AN OPTIMISATION. The pass runs bottom-up, so a
falling grain moves into a square the loop has already been past and
is left alone. A rising one moves into a square the loop has not
reached yet, and without a mark would rise again, and again, and cross
the whole board in a single frame. Steam is the reason that array
exists.
ELEVEN SUBSTANCES AND SIX VERBS. Fall, flow, rise, sit still, burn,
grow. Everything on the palette is one of those with a different set
of numbers against it, and the numbers are in the table below rather
than in the code, so a twelfth substance is a row and not a function.
DENSITY DECIDES THE LAYERS. Sand is heavier than water, water is
heavier than oil, and everything is heavier than air. Nothing in here
says sand sinks or oil floats. Pour all three into the same corner and
the layers were not designed.
AND WHAT BURNS IS A NUMBER, not a category. Oil catches easily and
burns hot. Plant catches almost instantly and is gone. Wood will
hardly catch at all and then smoulders for four hundred passes.
Fungus catches badly and smokes, which is what a wet thing does."
(:require [clojure.string :as str]))
;; ---------------------------------------------------------------------
;; The board
;; ---------------------------------------------------------------------
(def W 120)
(def H 72)
(def SIZE (* W H))
(def EMPTY 0) (def SAND 1) (def WATER 2) (def OIL 3)
(def FIRE 4) (def STEAM 5) (def STONE 6) (def PLANT 7)
(def WOOD 8) (def FUNGUS 9) (def ASH 10) (def GLASS 11)
(defonce board (atom nil))
(defonce life (atom nil)) ;; countdown, or generation, or age
(defonce was (atom nil)) ;; what a flame was before it caught
(defonce moved (atom nil))
(defonce words (atom nil))
(defonce image (atom nil))
(defonce inks (atom nil))
(defonce tool (atom SAND))
(defonce holding (atom nil))
(defonce at-xy (atom [0 0]))
(defonce wet-on (atom false))
(defonce frames (atom 0))
(def settings
(atom {:brush 3 :smoke 0.35 :ash 0.5 :condense 0.25 :steam-life 160
:growth 0.10 :reach 24 :spread 0.06 :vigour 90 :vitrify 0.02}))
;; ---------------------------------------------------------------------
;; What each one is
;;
;; :dens what it will sink through. 99 never moves.
;; :move :powder :liquid :gas :still
;; :drag how often a liquid declines to move. oil creeps.
;; :burns chance per pass of catching from a flame beside it
;; :fuel how long the flame lasts once it has
;; :leaves what is on the floor afterwards
;; ---------------------------------------------------------------------
(def substances
[{:m SAND :label "sand" :var "--sand-sand" :dens 3 :move :powder
:note "falls, and finds its own angle"
:glyph "<circle cx='8' cy='9' r='1.6'/><circle cx='14' cy='7' r='1.6'/><circle cx='11' cy='14' r='1.6'/><circle cx='16' cy='15' r='1.6'/><circle cx='6' cy='16' r='1.6'/>"}
{:m WATER :label "water" :var "--sand-water" :dens 2 :move :liquid
:note "flows. sand sinks through it. plants want it."
:glyph "<path d='M3 9c3-3 5 3 8 0s5 3 8 0M3 15c3-3 5 3 8 0s5 3 8 0'/>"}
{:m OIL :label "oil" :var "--sand-oil" :dens 1 :move :liquid :drag 0.40
:burns 0.20 :fuel 70
:note "floats on water. catches easily."
:glyph "<path d='M12 4c3 5 5 7 5 10a5 5 0 0 1-10 0c0-3 2-5 5-10z'/>"}
{:m FIRE :label "fire" :var "--sand-fire" :dens 0 :move :fire
:note "eats what is beside it. water puts it out."
:glyph "<path d='M12 3c1 4-3 5-3 9a3 3 0 0 0 6 0c0-2-1-3-1-4 2 1 3 3 3 5a5 5 0 0 1-10 0c0-5 5-6 5-10z'/>"}
{:m STEAM :label "steam" :var "--sand-steam" :dens 0 :move :gas
:note "rises, and comes back as water"
:glyph "<path d='M7 18c-2-2 0-4 2-5s2-3 0-4M13 19c-2-2 0-4 2-5s2-4 0-5M17 17c-1-1 0-2 1-3'/>"}
{:m PLANT :label "plant" :var "--sand-plant" :dens 99 :move :grow
:burns 0.60 :fuel 25 :leaves ASH
:note "grows into water. goes up like paper."
:glyph "<path d='M12 20V7M12 13c-4 0-6-2-6-5 3 0 6 1 6 5zM12 11c4 0 6-2 6-5-3 0-6 1-6 5z'/>"}
{:m WOOD :label "wood" :var "--sand-wood" :dens 99 :move :still
:burns 0.02 :fuel 420 :leaves ASH
:note "will hardly catch. then it goes for hours."
:glyph "<path d='M4 8h16v9H4zM4 12.5h16M9 8v4.5M14 12.5V17'/>"}
{:m FUNGUS :label "fungus" :var "--sand-fungus" :dens 99 :move :fungus
:burns 0.06 :fuel 35 :leaves ASH :eats #{WOOD PLANT}
:note "takes wood and plant. white, then purple, then done."
:glyph "<path d='M5 12a7 7 0 0 1 14 0zM10 12v6a2 2 0 0 0 4 0v-6'/>"}
{:m ASH :label "ash" :var "--sand-ash" :dens 3 :move :powder
:note "what is left. will not catch again."
:glyph "<circle cx='7' cy='11' r='1.1'/><circle cx='13' cy='8' r='1.1'/><circle cx='16' cy='14' r='1.1'/><circle cx='10' cy='16' r='1.1'/>"}
{:m STONE :label "stone" :var "--sand-stone" :dens 99 :move :still
:note "does not move, and nothing moves it"
:glyph "<path d='M4 8h16v10H4zM4 13h16M11 8v5M15 13v5'/>"}
{:m GLASS :label "glass" :var "--sand-glass" :dens 99 :move :still
:note "there is one way to get this and it is not the palette"
:glyph "<path d='M5 5h14v14H5zM7 17l10-10M11 17l6-6'/>"}])
(def by-mat (into {} (map (fn [s] [(:m s) s])) substances))
(def dens (into {EMPTY 0} (map (fn [s] [(:m s) (:dens s)])) substances))
;; ---------------------------------------------------------------------
;; Squares
;; ---------------------------------------------------------------------
(defn- inside? [x y] (and (>= x 0) (< x W) (>= y 0) (< y H)))
(defn- idx [x y] (+ x (* y W)))
(defn cell [b x y] (if (inside? x y) (aget b (idx x y)) STONE))
(defn- around [x y] [[(dec x) y] [(inc x) y] [x (dec y)] [x (inc y)]])
(defn- one-of [coll] (nth coll (int (* (js/Math.random) (count coll)))))
(defn- beside? [b x y m]
(or (== m (cell b (dec x) y)) (== m (cell b (inc x) y))
(== m (cell b x (dec y))) (== m (cell b x (inc y)))))
(defn- lighter?
"Nothing swaps with a thing as heavy as itself, or two grains would
trade places for ever and the heap would boil."
[b x y d]
(let [m (cell b x y)]
(and (not (== m STONE)) (< (get dens m 0) d))))
(defn- move! [b l mv x y nx ny d]
(when (and (inside? nx ny)
(zero? (aget mv (idx nx ny)))
(lighter? b nx ny d))
(let [i (idx x y) j (idx nx ny) m (aget b i) a (aget l i)]
(aset b i (aget b j)) (aset b j m)
(aset l i (aget l j)) (aset l j a)
(aset mv i 1) (aset mv j 1)
true)))
;; ---------------------------------------------------------------------
;; The verbs
;; ---------------------------------------------------------------------
(defn- powder! [b l mv x y d]
(let [s (if (< (js/Math.random) 0.5) -1 1)]
(or (move! b l mv x y x (inc y) d)
(move! b l mv x y (+ x s) (inc y) d)
(move! b l mv x y (- x s) (inc y) d))))
(defn- liquid! [b l mv x y d]
(let [s (if (< (js/Math.random) 0.5) -1 1)]
(or (powder! b l mv x y d)
(move! b l mv x y (+ x s) y d)
(move! b l mv x y (- x s) y d))))
(defn- gas! [b l mv x y]
(let [s (if (< (js/Math.random) 0.5) -1 1)]
(or (move! b l mv x y x (dec y) 1)
(move! b l mv x y (+ x s) (dec y) 1)
(move! b l mv x y (+ x s) y 1))))
(defn- catch!
"Set a square alight, remembering what it was, so that what is on the
floor afterwards is a fact about the thing that burned."
[b l wz nx ny s]
(let [j (idx nx ny)]
(aset wz j (aget b j))
(aset b j FIRE)
(aset l j (:fuel s 60))))
(defn- fire!
"The only rule in here that is about anybody else."
[b l wz mv x y]
(let [i (idx x y)
{:keys [smoke ash steam-life vitrify]} @settings]
(if (beside? b x y WATER)
(do (aset b i STEAM) (aset l i steam-life))
(do
;; what is next to it, and how willing each of them is
(doseq [[nx ny] (around x y)]
(when (inside? nx ny)
(let [m (cell b nx ny)
s (by-mat m)]
(cond
(and s (pos? (:burns s 0)) (< (js/Math.random) (:burns s)))
(catch! b l wz nx ny s)
;; the one way to get glass, and it is not the palette
(and (== m SAND) (< (js/Math.random) vitrify))
(aset b (idx nx ny) GLASS)))))
(let [left (dec (aget l i))]
(if (pos? left)
(do (aset l i left)
(when (< (js/Math.random) 0.4) (gas! b l mv x y)))
(let [leaves (:leaves (by-mat (aget wz i)))]
(aset wz i EMPTY)
(cond
(and leaves (< (js/Math.random) ash))
(do (aset b i leaves) (aset l i 0))
(< (js/Math.random) smoke)
(do (aset b i STEAM) (aset l i steam-life))
:else (do (aset b i EMPTY) (aset l i 0))))))))))
(defn- steam! [b l mv x y]
(let [i (idx x y) left (dec (aget l i))]
(if (pos? left)
(do (aset l i left) (gas! b l mv x y))
(if (< (js/Math.random) (:condense @settings))
(aset b i WATER)
(do (aset b i EMPTY) (aset l i 0))))))
(defn- grow!
"A plant is rooted, so it does not fall. It reaches into an empty
square beside a drop of water, and drinks it.
`life` is the generation. Every new cell is one further from wherever
somebody put the first one, and growth stops at :reach, which is why a
plant does not eventually become the whole board."
[b l x y]
(let [i (idx x y)
{:keys [growth reach]} @settings]
(when (and (< (aget l i) reach) (< (js/Math.random) growth))
(let [[nx ny] (one-of (around x y))]
(when (and (inside? nx ny)
(== EMPTY (cell b nx ny))
(beside? b nx ny WATER))
;; drink the drop it grew towards
(let [[wx wy] (one-of (filterv (fn [[ax ay]] (== WATER (cell b ax ay)))
(around nx ny)))]
(aset b (idx wx wy) EMPTY))
(aset b (idx nx ny) PLANT)
(aset l (idx nx ny) (inc (aget l i))))))))
(defn- fungus!
"It does not grow into nothing. It takes what is already there, which
is a different verb from a plant's and is the whole of what makes it
unpleasant.
Only young fungus spreads, so a colony has a living edge and a dead
middle, and `life` here is age rather than a countdown."
[b l x y]
(let [i (idx x y)
age (aget l i)
{:keys [spread vigour]} @settings
eats (:eats (by-mat FUNGUS))]
(aset l i (min 254 (inc age)))
(when (and (< age vigour) (< (js/Math.random) spread))
(let [[nx ny] (one-of (around x y))]
(when (and (inside? nx ny) (contains? eats (cell b nx ny)))
(aset b (idx nx ny) FUNGUS)
(aset l (idx nx ny) 0))))))
;; ---------------------------------------------------------------------
;; One pass
;;
;; A cond on integers rather than a lookup in the table, because most of
;; the board is empty and this runs eight and a half thousand times a
;; frame under an interpreter. The table is for everything else.
;; ---------------------------------------------------------------------
(defn step! [b l wz mv saturated?]
(.fill mv 0)
(loop [i (dec SIZE)]
(when (>= i 0)
(let [m (aget b i)]
(when (and (not (zero? m)) (zero? (aget mv i)))
(let [x (rem i W) y (quot i W)]
(cond
(== m SAND)
(let [wet (or (beside? b x y WATER) (beside? b x y OIL))]
(cond
(and saturated? wet) (aset b i WATER)
wet (liquid! b l mv x y 3)
:else (powder! b l mv x y 3)))
(== m WATER) (liquid! b l mv x y 2)
(== m ASH) (powder! b l mv x y 3)
(== m OIL) (when (> (js/Math.random) 0.40) (liquid! b l mv x y 1))
(== m FIRE) (fire! b l wz mv x y)
(== m STEAM) (steam! b l mv x y)
(== m PLANT) (grow! b l x y)
(== m FUNGUS) (fungus! b l x y)))))
(recur (dec i)))))
;; ---------------------------------------------------------------------
;; The brush
;; ---------------------------------------------------------------------
(defn- daub! [b l [x y] what]
(let [r (:brush @settings)
s (by-mat what)]
(doseq [dy (range (- r) (inc r))
dx (range (- r) (inc r))
:when (<= (+ (* dx dx) (* dy dy)) (* r r))]
(let [px (+ x dx) py (+ y dy)]
(when (inside? px py)
(let [i (idx px py)]
(cond
(nil? what) nil
(== what EMPTY) (do (aset b i EMPTY) (aset l i 0))
(== EMPTY (aget b i))
(do (aset b i what)
(aset l i (cond (== what FIRE) (:fuel s 60)
(== what STEAM) (:steam-life @settings)
:else 0))))))))))
;; ---------------------------------------------------------------------
;; Ink
;;
;; Read off the canvas rather than written in here, so the palette is a
;; stylesheet decision. Re-read twice a second, because the reader can
;; flip the scheme at any moment and a colour taken once is the wrong
;; colour from then on.
;; ---------------------------------------------------------------------
(defn- word-of [s]
(let [t (str/trim (or s ""))
[r g b] (if (str/starts-with? t "#")
[(js/parseInt (subs t 1 3) 16)
(js/parseInt (subs t 3 5) 16)
(js/parseInt (subs t 5 7) 16)]
(take 3 (concat (map js/parseInt (re-seq #"\d+" t))
[128 128 128])))]
(bit-or -16777216
(bit-shift-left (bit-and b 255) 16)
(bit-shift-left (bit-and g 255) 8)
(bit-and r 255))))
(defn- read-inks! [canvas]
(let [style (js/getComputedStyle canvas)
get-var (fn [v] (word-of (.getPropertyValue style v)))]
(reset! inks
(assoc (into {EMPTY 0}
(map (fn [{:keys [m var]}] [m (get-var var)]))
substances)
:fungus-young (get-var "--sand-fungus")
:fungus-old (get-var "--sand-fungus-old")
:fungus-dead (get-var "--sand-fungus-dead")))))
(defn draw!
"One putImageData for the whole board. The only square that has to be
asked twice is fungus, which is three colours depending on how long it
has been there."
[ctx b l]
(let [buf @words ink @inks
young (:fungus-young ink) old (:fungus-old ink) dead (:fungus-dead ink)]
(loop [i (dec SIZE)]
(when (>= i 0)
(let [m (aget b i)]
(aset buf i
(if (== m FUNGUS)
(let [a (aget l i)]
(cond (< a 60) young (< a 160) old :else dead))
(get ink m 0))))
(recur (dec i))))
(.putImageData ctx @image 0 0)))
;; ---------------------------------------------------------------------
;; The palette, which is also the key
;; ---------------------------------------------------------------------
(defn- palette-html []
(str/join
""
(concat
(for [{:keys [m label var note]} substances]
(str "<button class='legend-item sand-pick"
(when (== m @tool) " sand-chosen") "'"
" data-pick='" m "' style='color: var(" var ")'>"
"<span class='legend-glyph'><svg viewBox='0 0 24 24'>"
(:glyph (by-mat m)) "</svg></span>"
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" note "</span></button>"))
[(str "<button class='legend-item sand-pick"
(when (== EMPTY @tool) " sand-chosen") "' data-pick='0'>"
"<span class='legend-glyph'><svg viewBox='0 0 24 24'>"
"<path d='M5 19h14M7 15l8-8 3 3-8 8z'/></svg></span>"
"<span class='legend-label'>rubber</span>"
"<span class='legend-note'>or hold the right button</span></button>")])))
(defn- tally [b]
(loop [i (dec SIZE) acc {}]
(if (neg? i) acc (recur (dec i) (update acc (aget b i) (fnil inc 0))))))
(defn paint! [node]
(let [counts (tally @board)]
(fedoras.ui.widget-ui/html! node ".sand-palette" (palette-html))
(fedoras.ui.widget-ui/state!
node
(str (:label (by-mat @tool) {:label "rubber"})
" · " (- SIZE (get counts EMPTY 0)) " squares in use"))
(fedoras.reader/text!
node ".sand-counts"
(str/join " · " (for [{:keys [m label]} substances
:let [n (get counts m 0)] :when (pos? n)]
(str n " " label))))
(fedoras.reader/enable! node ".sand-wet" true
(str "Saturated: " (if @wet-on "on" "off")))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- ->cell [cv e]
[(int (* W (/ (.-offsetX e) (.-clientWidth cv))))
(int (* H (/ (.-offsetY e) (.-clientHeight cv))))])
(defn- wire-canvas! [node]
(when-let [cv (fedoras.ui.widget-ui/q! node ".sand-canvas")]
;; nothing comes out of the tap until somebody turns it
(set! (.-onpointerdown cv)
(fn [e]
(.preventDefault e)
(reset! holding (if (== 2 (.-button e)) EMPTY @tool))
(reset! at-xy (->cell cv e))))
(set! (.-onpointermove cv)
(fn [e] (when (some? @holding) (reset! at-xy (->cell cv e)))))
(set! (.-onpointerup cv) (fn [_] (reset! holding nil)))
(set! (.-onpointerleave cv) (fn [_] (reset! holding nil)))
(set! (.-oncontextmenu cv) (fn [e] (.preventDefault e) false))))
(defn- wire-palette! [node]
(fedoras.ui.widget-ui/delegate!
node ".sand-palette" ".sand-pick" ["pick"]
(fn [_ [pick]] (reset! tool pick) (paint! node))))
(defn- wire-controls! [node]
(fedoras.reader/on-click! node ".sand-clear"
(fn [_] (.fill @board EMPTY) (.fill @life 0) (.fill @was 0) (paint! node)))
(fedoras.reader/on-click! node ".sand-wet"
(fn [_] (swap! wet-on not) (paint! node))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current
(select-keys setting
[:brush :smoke :ash :condense :steam-life
:growth :reach :spread :vigour :vitrify]))))
(paint! node))))
(defn setup! [node]
(let [id (.-id node)
cv (fedoras.reader/q node ".sand-canvas")
ctx (.getContext cv "2d")]
(reset! board (js/Uint8Array. SIZE))
(reset! life (js/Uint8Array. SIZE))
(reset! was (js/Uint8Array. SIZE))
(reset! moved (js/Uint8Array. SIZE))
(reset! image (.createImageData ctx W H))
(reset! words (js/Uint32Array. (.-buffer (.-data @image))))
(set! (.-imageSmoothingEnabled ctx) false)
(read-inks! cv)
(fedoras.ui.widget-ui/wire! node "canvas" wire-canvas!)
(fedoras.ui.widget-ui/wire! node "palette" wire-palette!)
(fedoras.ui.widget-ui/wire! node "controls" wire-controls!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))
(letfn [(frame []
(when (some? @holding) (daub! @board @life @at-xy @holding))
(step! @board @life @was @moved @wet-on)
(draw! ctx @board @life)
(swap! frames inc)
(when (zero? (mod @frames 30))
(read-inks! cv)
(paint! node))
(js/requestAnimationFrame frame))]
(frame))))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::sand (or id "sand") setup! paint!))</script><div class="widget widget-sand" id="sand" tabindex="0"><div class="roll-head"><span class="roll-label">SAND.CLJS · hold to pour · right-click to take away</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">sand_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="20" spellcheck="false">;; ---------------------------------------------------------------------
;; SAND_SETTINGS.CLJS — what the world is set to.
;;
;; 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.
;;
;; NOTHING IN THIS FILE IS ABOUT SAND. Sand asks three questions and
;; none of them has a number in it, which is why it is the one substance
;; you cannot tune, and why the angle a heap stands at is not adjustable
;; and never was.
;;
;; What each substance does with fire is not here either. That is in the
;; table at the top of sand_ui.cljs, one row each, because how willingly
;; a thing catches is a fact about the thing and not a setting.
;; ---------------------------------------------------------------------
{;; THE BRUSH. How wide a dab is, in squares. 1 is a grain at a time,
;; which is a different program and worth five minutes.
:brush 3
;; WHAT IS LEFT. When a flame goes out, this much of the time it leaves
;; whatever that thing leaves — ash, for everything that leaves
;; anything. Failing that, this much of the time, smoke. Failing that,
;; nothing at all, which is what oil does.
:ash 0.5
:smoke 0.35
;; STEAM. How long it rises for, and how likely it is to come back as
;; water rather than as nothing.
;;
;; Set :condense to 1 and the board conserves water. A fire under a
;; tank will empty it into the ceiling and rain it back down for as
;; long as you care to watch.
:steam-life 160
:condense 0.25
;; PLANT. It does not fall, because it is rooted. It reaches into an
;; empty square next to water and drinks the drop it grew towards, so
;; a plant with no water is a plant that has stopped.
;;
;; :reach is how many squares from where you put it a plant may get.
;; Raise it to 200 and one seed in a wet corner will take the room.
:growth 0.10
:reach 24
;; FUNGUS. It does not grow into nothing. It takes wood and plant, and
;; only young fungus takes anything, which is why a colony has a living
;; white edge and a spent black middle.
;;
;; :vigour is how many passes a square keeps spreading for. Set it to 4
;; and it makes a ring and stops. Set it to 250 and it does not stop.
:spread 0.06
:vigour 90
;; GLASS. Sand beside a flame, this often, per pass. There is no glass
;; on the palette and there is not going to be. It is the only thing in
;; this program you have to find out.
:vitrify 0.02}</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"><div class="sand-palette"></div><canvas class="sand-canvas" height="72" width="120"></canvas><p class="sand-counts"></p><div class="live-bar"><button class="roll-go sand-wet">Saturated: off</button><button class="roll-rewrite sand-clear">Clear</button></div></div><p class="roll-foot">Nothing in here knows there is a heap.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.sand.sand-ui/mount!))] (mount {:id "sand"}) (js/console.error "fedoras: fedoras.cs-class.sand.sand-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 class="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Office of Student Life <no-reply@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>6 April, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>You have been placed on the docket</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Unattached students in good standing are placed on the docket.</span></p><p><span>Your assignment is Wednesday 8 April, 7:00 PM, Whitlock Hall, Room 4.</span></p><p><span>Docket attendance is not a component of review. Docket attendance is recorded.</span></p><p><span>Please arrive presentable.</span></p></div></div><div class="screenplay"><p class="slug">INT. WHITLOCK HALL — LOWER GROUND — ROOM 4 — WEDNESDAY 8 APRIL, 7:00 P.M.</p></div><div class="screenplay"><p class="action">The same room. The same table. The card is face up in the middle of it before anybody sits down and the lamination has gone soft along two edges now.</p><p class="action">There is a box of tissues at Hale's elbow and it is a new box.</p><p class="action">SIMON is fourth along on the near side, where Simon is.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has known since the second week of February that this room is below the level of the quad.</p><p class="action">He sits down in it anyway, because there is a mail with one button on it and he pressed the button.</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 class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Simon. Second year, unplaced—</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Thank you, Simon.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody laughs, and it is not because it has stopped being funny.</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">First year, computer science. Provisional.</p><p class="line">Systems, Mondays and Wednesdays. No service</p><p class="line">assignment. Review, no action.</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">And.</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">One.</p></div></div></div><div class="screenplay"><p class="action beat">One comes back from the far side and it is the same voice as January and he still does not look to see who it is.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:20 P.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">A WOMAN</p><p class="paren">(to Simon)</p><div class="lines"><p class="line">Have you been assessed?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">A WOMAN</p><div class="lines"><p class="line">Recently?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">One each.</p></div></div></div><div class="screenplay"><p class="action">She stops.</p><p class="action beat">In August that exchange happened in this room in that order with those people, and the only thing that has changed is that this time nobody at the table looks up.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:45 P.M.</p></div><div class="screenplay"><p class="action">The table. Five minutes each and Hale says when.</p><p class="action">WREN does not come to <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> at all and does not send for him seemingly avoiding eye contact altogether.</p><p class="action">They go to the fourth chair on the near side instead, which is not how it is done, and sit down opposite SIMON and stay there for eleven minutes, which is not how it is done either, and Hale says nothing about it.</p><p class="action beat">Nobody hears any of it.</p><p class="action">Simon comes back and sits down and puts his hands flat on the table and looks at the card for a while.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 8:05 P.M.</p></div><div class="screenplay"><p class="action">There are four minutes at the end and they are for entering preferences privately and nothing said out loud.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> enters nothing. It is accepted, as it is permitted.</p><p class="action">It comes at about the second minute. The coin, and then the shut room, and he gets his hands under the edge of the table and holds on.</p><p class="action beat">It is worse than February and he does not go anywhere and nobody at that table sees it happen.</p><p class="action beat">It takes the whole of the four minutes.</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="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. WHITLOCK HALL — THE STAIR — 8:15 P.M.</p></div><div class="screenplay"><p class="action">The stair up out of Room 4 has eleven steps in it and the air at the top of them is ordinary.</p><p class="action">SIMON goes up it slowly, on the rail, and stops at the half-landing, and does not make anything of stopping.</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 a stupid question?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Yeah.</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">How would somebody get off the docket?</p></div></div></div><div class="screenplay"><p class="action beat">Simon laughs, which he has not done all evening.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Two ways. Everybody knows the two ways. You get</p><p class="line">attached, or you 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">Right.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">I've thought about it.</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">Which one?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Both.</p></div></div></div><div class="screenplay"><p class="action">He says it the way you would say you had thought about getting the earlier bus, and then he goes up the rest of the stair.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has been standing on that half-landing for about nine months working out how to say a thing that has no sentence in it, and there is one available and he has just heard it, and it is stop being in good standing, and it is the same as saying be repossessed.</p><p class="action beat">He does not say it.</p><p class="action">At the top of the stair Simon holds the door, because Simon holds doors.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">What did you want to ask?</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 was it.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Right.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Office of Student Life <no-reply@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 April, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>Docket — outcome</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Attendance recorded.</span></p><p><span>No preference was entered. No outcome is indicated on this occasion.</span></p><p><span>You remain on the docket. Your next assignment will be notified.</span></p><p><span>Please arrive presentable.</span></p></div></div><div class="screenplay"><p class="action">Nothing in it mentions the four minutes, or the stair, or the eleven minutes at the fourth chair on the near side, and nobody in that room will ever know what was said there, including him.</p></div></div>
```