---
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 5 — The Boats
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #30</p><p class="rule-text">“”</p></blockquote>
```
```{=html}
<div class="scene"><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">9 Oct, 11:48 PM</span></p><div class="discord-text"><p><span>Attendance five of five. Friday was the one with the boats in the ice.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">16 Oct, 11:12 PM</span></p><div class="discord-text"><p><span>that one came back four minutes short</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">23 Oct, 11:50 PM</span></p><div class="discord-text"><p><span>Attendance five of five. Wednesday ran to the Janitor, as usual.</span></p><p><span>Nine weeks of the campaign and we have not left the third floor.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">30 Oct, 11:59 PM</span></p><div class="discord-text"><p><span>docket again. sorry</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">6 Nov, 1:02 AM</span></p><div class="discord-text"><p><span>the boy in 2 north didnt come back after review week</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">6 Nov, 1:03 AM</span></p><div class="discord-text"><p><span>someone else is in there now. hes fine. hes nice</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">6 Nov, 8:01 AM</span></p><div class="discord-text"><p><span>Article Fourteen.</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">20 Nov, 11:44 PM</span></p><div class="discord-text"><p><span>Attendance five of five. Twelve weeks. Nobody has missed a Thursday since September.</span></p></div></div></div></div><div class="screenplay"><p class="action">Twelve Thursdays. Twelve Wednesdays. Ten Fridays, because the projector was double-booked once and Victor took it to the counter and lost.</p><p class="action">Two dockets, both of which he attended, both of which were forty minutes in a room with nine other people and a woman with a clipboard, and neither of which is worth writing down, which is what everybody says about them.</p><p class="action">Sixteen mornings at six with Hollis. The pipe by the stair does not stop and the second bucket does not go away. In the middle of October somebody empties both of them, and it is not Hollis and it is not <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>, and neither of them mentions it.</p><p class="action">Set one goes in on the third Friday of October, six of six. Set two opens on the Monday. The four evenings of supervised study are spread across five weeks, because Beckwith 2 is only free when it is free.</p><p class="action">The number holds. Somebody on the second floor does not come back after the October review and the room has somebody in it by the Friday.</p></div><div class="screenplay"><p class="slug">INT. BECKWITH HALL — ROOM 2 — THE LAST EVENING, 20 NOVEMBER</p></div><div class="screenplay"><p class="action">Supervised study. Nine desks, four of them occupied, and a member of staff at the front who marks a sheet at six and a sheet at eight and does not look up in between.</p><p class="action">No Helps. There is a poster about it, and under the poster a radiator that comes on at ten past six and goes off at seven.</p><p class="action">The room was something else before it was this. Along the back wall there are nine wooden cabinets, chest height, in banks of drawers about the size of a brick, and people put their bags on them.</p><p class="action">He opened one in October because it was there, and because a drawer that has not been opened in a long time comes out in the way that tells you.</p><p class="action">They are full of cards. Thousands of them, upright, packed tight, each one typed, each one with a number in the top left corner.</p><p class="action beat">Tonight is the fourth evening and he has been along the same drawer three times.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE SUPERVISOR</p><p class="paren">(without looking up)</p><div class="lines"><p class="line">Don't take them out of order.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What are they?</p></div></div><div class="dialogue"><p class="character">THE SUPERVISOR</p><div class="lines"><p class="line">It's a catalogue. There's a cabinet of them in</p><p class="line">half the rooms in this building. People put their</p><p class="line">bags on them.</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">A catalogue of what?</p></div></div></div><div class="screenplay"><p class="action beat">The supervisor looks up for the first time in four evenings.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE SUPERVISOR</p><div class="lines"><p class="line">Of the books, love.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> takes the page out of his bag. It has been folded into eight since September and the folds have gone soft. On the reverse there is a paper pocket, and above the dates it says 005.13 · OAKES.</p><p class="action">He goes along the drawers until he finds the one with the range on the front that has 005 in it, and then along the cards.</p><p class="action beat">It takes forty minutes and he finds it at ten to eight.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="artifact handout"><p class="handout-title">005.13 — catalogue card — NEW CARTHAGE</p><p><span>STRUCTURE AND ORDER IN PROGRAMS · v.2 · ed. 4</span></p><p><span>acc. 11-4402 · accessioned, stamped, and stamped again nine years later</span></p><p><span>COPIES: 3</span></p><p><span>LOCATION: OAKES — withdrawn</span></p><p><span>LOCATION: OAKES — withdrawn</span></p><p><span>LOCATION: C-3 — held</span></p><p><span>In pencil, along the bottom edge, in a hand that is not the typist's: everything below C-2 went with the ground.</span></p></div><div class="screenplay"><p class="action">There is no building C. The current map goes B, D, and B and D are touching, and <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> sat in a room on a Wednesday night in October and heard a man in a felt hat say exactly that about a place he had invented.</p><p class="action">He puts the card back in order, because he was asked to, and takes a photograph of it on the way, and adds one line to a list that already has page 219 on it.</p></div><div class="screenplay"><p class="slug">INT. HALLORAN UNION — ROOM 3C — FRIDAY 21 NOVEMBER</p></div><div class="screenplay"><p class="action">Lights off. Two boxes of pizza. The projector.</p></div><div class="screenplay"><div class="sign"><p>THE BANKS</p><p>approved · 96 min · as supplied, 96 min</p></div></div><div class="screenplay"><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">It's the only one that comes whole.</p></div></div></div><div class="screenplay"><p class="action">It opens on a man on a slipway reading a noticeboard with a race on it. The film gives him forty seconds of that and then goes somewhere else and does not come back to it for an hour and a half.</p><p class="action">The race is an amateur thing. A dozen boats, run every year off a coast the film never names. The man and his wife sail one of the small ones.</p><p class="action">There is a long stretch in the middle where nothing goes wrong.</p><p class="action">They practise in flat water. They argue about a sail and stop arguing. She goes forward and does something to the mast with a spanner and comes back, and he does not ask.</p><p class="action">Twice she calls a turn before he does and he takes it, and once he does not, and they lose the boat they were beating, and neither of them says anything about it for the rest of the afternoon.</p><p class="action">In a kitchen she reads a tide table out loud and says a time. He is doing something else. The film does not go back to it.</p><p class="action beat">Miles stops eating at about the fortieth minute.</p><p class="action">The water on <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>'s block went off in February and stayed off for two days. It had never done that in his life. It had done it a great deal in his mother's, and she said so, and she said it the way you say a thing that is over. That is the entire reason he is in this building, on this couch, in the dark, watching two people rig a boat on a trailer.</p><p class="action">On the day the wind is up and they are late off the slipway, and the film does not say why and neither of them mentions it.</p><p class="action">Some of the fleet takes the long way round the head. Some of it goes inside the bank. The ones who go inside are ahead by the second mark and the film shows the gap opening.</p><p class="action">There is a shot of the hull from the water. There is a weed line on it and the weed line is out.</p><p class="action">They hit.</p><p class="action">There is no spectacle in it. A sound, the deck stops being level, and the mast goes over the side with the rigging and the mainsail attached to it.</p><p class="action">He goes over the bow.</p><p class="action">He comes up ten feet from the boat and finds his footing and the water is at his chest, and he has taken the coral across the shin and the inside of the leg on the way in and does not know it yet.</p><p class="action">She is still aboard. She gets a rope to him on the second throw.</p><p class="action">He gets a hand on it. He cannot use the leg and the bottom is coral heads and holes, and she takes his whole weight twice and holds it both times.</p><p class="action beat">For about forty seconds it is a film about a man being pulled out of the water by his wife.</p><p class="action">Then the wave comes through.</p><p class="action">It lifts the hull and puts it down in a different place, and she goes over the side with the rope still in both hands.</p><p class="action">The mainsail is in the water and full of it, and she comes up underneath it.</p><p class="action">He gets to her. It is twenty feet and he goes down twice in it.</p><p class="action">Her head is out. He gets the sail off her shoulder and her head was already out, and it does not help.</p><p class="action">Her leg is through the shrouds below the knee. The wire has taken a turn round a coral head and the hull is on the other end of it.</p><p class="action">The water is going.</p><p class="action">He works at the wire with both hands. The film stays on the hands for a long time and there is nothing else in the frame.</p><p class="action">The hull settles about four inches.</p><p class="action beat">She stops helping him and puts a hand flat on his chest.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE WOMAN</p><div class="lines"><p class="line">Under the thwart. Port side. There's a knife under</p><p class="line">the thwart.</p></div></div></div><div class="screenplay"><p class="action beat">It is the only line either of them has in the whole of it.</p><p class="action">He goes back. It is twenty feet and it takes him longer than it took him coming.</p><p class="action">The boat is on its side. He goes under twice and comes up twice and the second time he has it.</p><p class="action beat">The film goes with him and does not cut back to her once.</p><p class="action">He cuts the wire. It takes three goes and the third one is two-handed.</p><p class="action">He gets her free and gets her up onto the coral head and holds her there out of the water.</p><p class="action beat">He does not know yet.</p><p class="action">The film stays with him not knowing for a length of time that people walked out of cinemas over.</p><p class="action">The other boats are outside the bank. They cannot come in over the coral. Two of them go round the head. The film cuts to them four times and they are not much closer on the fourth.</p><p class="action">Two men from the first of them get to him.</p><p class="action">They take him. He is the one bleeding and he is the one they can reach, and he goes over the side of their boat with a man under each arm and one of them talking to him the whole way, kindly, in a voice you would use on a horse.</p><p class="action beat">He is shouting. It is the only shouting in the film.</p><p class="action">Then it cuts back. The slipway. The noticeboard. Forty seconds of a man reading a list of rules and putting his name on it.</p></div><div class="screenplay"><p class="slug">THE FILM — INT. A KITCHEN — SIX WEEKS EARLIER</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE MAN</p><div class="lines"><p class="line">I've put us in.</p></div></div><div class="dialogue"><p class="character">THE WOMAN</p><div class="lines"><p class="line">You've what?</p></div></div><div class="dialogue"><p class="character">THE MAN</p><div class="lines"><p class="line">It's forty boats. Half of them are worse than us</p><p class="line">and the other half will take the long way round</p><p class="line">because their insurance says to.</p></div></div><div class="dialogue"><p class="character">THE WOMAN</p><div class="lines"><p class="line">And the bank?</p></div></div><div class="dialogue"><p class="character">THE MAN</p><div class="lines"><p class="line">Everybody goes inside the bank. It's four feet at</p><p class="line">the top of the tide and we draw three.</p></div></div></div><div class="screenplay"><p class="action beat">She looks at him for a while.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE WOMAN</p><div class="lines"><p class="line">What time's the start?</p></div></div><div class="dialogue"><p class="character">THE MAN</p><div class="lines"><p class="line">Ten.</p></div></div><div class="dialogue"><p class="character">THE WOMAN</p><div class="lines"><p class="line">And the top of the tide?</p></div></div></div><div class="screenplay"><p class="action beat">He does not answer that one.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE MAN</p><div class="lines"><p class="line">It'll be fine.</p></div></div></div><div class="screenplay"><p class="action beat">She says all right. The film ends on her saying all right.</p><p class="action">Nobody in Room 3C says anything for a while. Victor lets the credits run.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MILES</p><p class="paren">(eventually)</p><div class="lines"><p class="line">He should have gone for the knife straight away.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">He had a leg open on coral, Miles.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">I know that. I'm saying he was in the water two</p><p class="line">minutes before she told him where it was. He'd</p><p class="line">have made it.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">You don't know that.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">You could time it.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody answers that.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">What it's about is the men who took him out of the</p><p class="line">water. He's standing there holding her and they</p><p class="line">decide for him. Nobody in that film asks him</p><p class="line">anything.</p></div></div></div><div class="screenplay"><p class="action">Victor sits back. Derek nods slowly, twice.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><p class="paren">(quietly)</p><div class="lines"><p class="line">It's about the kitchen.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">The kitchen's two minutes long.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action">Miles puts the mask on. Nobody has asked him anything and nobody asks him to take it off, and he keeps it on for the rest of the evening, including on the stairs, including outside.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. PRENTISS SCIENCE CENTER — THE BENCH ROOM — SATURDAY 22 NOVEMBER, LATE</p></div><div class="screenplay"><p class="action">The bench room is where researchers work and eat, one long bench each, name on the end of it. Two lamps on out of eight.</p><p class="action">MAYA has been here since four. There is a flask in a heated bath at the end of her bench with a thermometer in it, and it has to be read and written down every twenty minutes for nine hours, and between the readings it does not need anybody at all.</p><p class="action">LENA is on the counter with her boots off. The press prints onto sheets bigger than the poster, so every one comes out with two inches of white round the edge that has to come off. The machine that does that lives in the print shop and the print shop shuts at ten, so she has forty of them, a steel rule and a scalpel.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Twenty past. Go and read your flask.</p></div></div></div><div class="screenplay"><p class="action">Maya reads the thermometer and writes the number in a column of the same number going back to four o'clock.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(from the floor, where he is lying with a coat over him)</p><div class="lines"><p class="line">Somebody say something. I'm going under.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Say something, Lena.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">All right. The first time I ever saw weather</p><p class="line">coming I thought somebody had done it.</p></div></div><div class="dialogue"><p class="character">ELI</p><p class="paren">(still on the floor)</p><div class="lines"><p class="line">Done what?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">The weather. I thought somebody had put it on and</p><p class="line">I wanted to know who, and I asked, and it turns</p><p class="line">out that's a stupid question.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Where was this?</p></div></div></div><div class="screenplay"><p class="action beat">She does not answer, which is the fifth time.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> looks at the bench in front of him and does not say anything, because he knows the whole of it and was asked not to, and because he would like to keep the arrangement.</p><p class="action">At one o'clock Eli goes to bed and Lena goes with the poster tube under her arm and one boot in each hand.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE BENCH ROOM — 1:40 A.M.</p></div><div class="screenplay"><p class="action">Two of them and a run that has to be looked at every twenty minutes.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Rock, paper, scissors. Loser goes at two.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">All right.</p></div></div></div><div class="screenplay"><p class="action beat">She wins four out of five.</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">Again.</p></div></div></div><div class="screenplay"><p class="action beat">She wins three out of four.</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">That isn't possible. It's a third each. It's a</p><p class="line">third each every time.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It's a third each if you're a coin. You're not a</p><p class="line">coin, you're a person, and people do the same</p><p class="line">thing after they lose.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What do I do after I lose?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">You play the thing that would have beaten me.</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> sits with that for a moment.</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">Hang on.</p></div></div></div><div class="screenplay"><p class="action">He opens the machine and writes something with her reading over his shoulder, and neither of them looks at the run at two o'clock, and it is fine, and nobody ever finds out.</p></div><div class="screenplay"><p class="direction">The program he wrote is below.</p></div><div class="screenplay"><p class="action">A third each, if you are a coin.</p></div><div class="scene-code"><pre><code>(def beats-what
{:rock :scissors
:paper :rock
:scissors :paper})
(defn judge [mine theirs]
(cond
(= mine theirs) :draw
(= theirs (beats-what mine)) :win
:else :lose))
(judge :rock :scissors)
(defn coin [_history]
(rand-nth [:rock :paper :scissors]))</code></pre></div><div class="screenplay"><p class="action">That one cannot be beaten and cannot win. Over a long enough evening it is a third each, and the evening is the only thing that happens.</p><p class="action">Then the other one, which is hers, written down by him.</p></div><div class="scene-code"><pre><code>(defn what-beats [x]
(first (filter (fn [k] (= x (beats-what k))) (keys beats-what))))
(defn reader
"They played a thing last time and it lost. People play the thing that
would have beaten you. So play the thing that beats that."
[history]
(if-let [last-round (last history)]
(let [{:keys [theirs mine]} last-round]
(if (= :lose (judge theirs mine))
(what-beats (what-beats theirs))
(coin history)))
(coin history)))
(defn play-round [history theirs]
(let [mine (reader history)]
(conj history {:mine mine
:theirs theirs
:result (judge mine theirs)})))
(def evening
(reduce play-round [] [:rock :rock
:paper :paper
:scissors :rock
:rock]))
(map :result evening))</code></pre></div><div class="screenplay"><p class="action">It is not clever and against a coin it is worth nothing at all. Against a person who has just lost, it is worth about four points in ten, and four points in ten is the difference between a game and a habit.</p><p class="action">Here it is, playing. Throw against it. It has nothing but what you did last time, and for the first two or three throws that is nothing at all.</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>/* ---------------------------------------------------------------
SHOOT.CLJS
No canvas here, so no CSS-variable lookups from JS to worry about —
the read tag is just a class. It's coloured the same gold the
quests use, on purpose: it's the same idea, the one moment
something in this book is looking back at you.
--------------------------------------------------------------- */
.widget-shoot {
max-width: 30rem;
}
.shoot-tally,
.shoot-reads {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.shoot-reads {
opacity: 0.75;
}
.shoot-log {
margin: 0.5rem 0 0 0;
font-size: 0.8rem;
line-height: 1.6;
}
.shoot-log-line {
opacity: 0.85;
}
.shoot-read {
color: var(--gold);
font-weight: 600;
}</style><script type="application/x-scittle">(ns fedoras.cs-class.shoot.shoot-ui)
(def settings
(atom {:log-rows 6}))
(defonce record (atom []))
;; ---------------------------------------------------------------------
;; The throws
;; ---------------------------------------------------------------------
(def beats-what {:rock :scissors :paper :rock :scissors :paper})
(defn- judge [mine theirs]
(cond
(= mine theirs) :draw
(= theirs (beats-what mine)) :win
:else :lose))
(defn- what-beats [x]
(first (filter (fn [k] (= x (beats-what k))) (keys beats-what))))
;; ---------------------------------------------------------------------
;; The reader
;;
;; Everything it knows is in here, one round at a time, and it never
;; looks back further than the last one. It is not modeling you. It is
;; watching for the single moment you become predictable — right after
;; you lose — and doing nothing at all the rest of the time.
;; ---------------------------------------------------------------------
(defn- about-to-read? []
(let [last-round (last @record)]
(boolean (and last-round (= :lose (:their-result last-round))))))
(defn- reader []
(if (about-to-read?)
(what-beats (what-beats (:theirs (last @record))))
(rand-nth [:rock :paper :scissors])))
;; ---------------------------------------------------------------------
;; The tally
;; ---------------------------------------------------------------------
(defn- tally [k]
(count (filter (fn [r] (= k (:their-result r))) @record)))
(defn- reads []
(count (filter :read? @record)))
(defn- caught []
(count (filter (fn [r] (and (:read? r) (= :lose (:their-result r)))) @record)))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn paint! [node]
(fedoras.reader/text!
node ".shoot-tally"
(str "you " (tally :win) " · reader " (tally :lose)
" · drawn " (tally :draw)))
(fedoras.reader/text!
node ".shoot-reads"
(str "reads: " (caught) " of " (reads) " landed"))
;; the log needs its own coloured tag on the read rounds, which
;; text! alone cannot give it — built by hand, same as the legend
;; chips are in the other one
(when-let [el (fedoras.reader/q node ".shoot-log")]
(set! (.-innerHTML el) "")
(doseq [r (reverse (take-last (:log-rows @settings) @record))]
(let [line (.createElement js/document "div")]
(set! (.-className line) "shoot-log-line")
(set! (.-textContent line)
(str (name (:theirs r)) " v " (name (:mine r))
" — you " (name (:their-result r))))
(when (:read? r)
(let [tag (.createElement js/document "span")]
(set! (.-className tag) "shoot-read")
(set! (.-textContent tag) " · read")
(.appendChild line tag)))
(.appendChild el line)))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- play! [t]
(let [read-now? (about-to-read?)
mine (reader)]
(swap! record conj {:mine mine :theirs t
:their-result (judge t mine)
:read? read-now?})))
(defn- wire-controls! [node]
(fedoras.reader/on-click! node ".shoot-rock"
(fn [_] (play! :rock) (paint! node)))
(fedoras.reader/on-click! node ".shoot-paper"
(fn [_] (play! :paper) (paint! node)))
(fedoras.reader/on-click! node ".shoot-scissors"
(fn [_] (play! :scissors) (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 [:log-rows]))))
(paint! node))))
(defn setup! [node]
;; a clean board every time it mounts, same as a fresh drone
(reset! record [])
(fedoras.ui.widget-ui/wire! node "controls" wire-controls!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n (.-id node))))
(paint! node))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::shoot (or id "shoot") setup! paint!))</script><div class="widget widget-shoot" id="shoot"><div class="roll-head"><span class="roll-label">SHOOT.CLJS · rock, paper, scissors</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">shoot_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="10" spellcheck="false">;; ---------------------------------------------------------------------
;; SHOOT_SETTINGS.CLJS — what the reader keeps in view.
;;
;; Change a number and press Run. `Put it back` is the file as it
;; shipped.
;; ---------------------------------------------------------------------
{;; How many past rounds stay on the board, under the tally. Six is
;; enough to see a pattern forming and not so many it starts looking
;; like a ledger.
;;
;; This is the only number here, because it is the only thing about
;; this that was ever adjustable. What it does with your last round
;; is not a setting, and never was.
:log-rows 6}</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="live-bar"><button class="roll-go shoot-rock">rock</button><button class="roll-go shoot-paper">paper</button><button class="roll-go shoot-scissors">scissors</button></div><p class="shoot-tally"></p><p class="shoot-reads"></p><div class="shoot-log"></div></div><p class="roll-foot">The gold tag means it wasn't guessing that round. Everywhere else, it's a coin.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.shoot.shoot-ui/mount!))] (mount {:id "shoot"}) (js/console.error "fedoras: fedoras.cs-class.shoot.shoot-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="screenplay"><p class="slug">INT. THE BENCH ROOM — 2:20 A.M.</p></div><div class="screenplay"><p class="action">It beats her twice.</p><p class="action beat">Then she stops and looks at the ceiling for a moment.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">When you take one off me, it expects me to change.</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 expects you to play the thing that would have</p><p class="line">won.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Yes. So when you take one off me, I'll play the</p><p class="line">same thing again.</p></div></div></div><div class="screenplay"><p class="action beat">She does that, and wins, and does it again, and wins.</p><p class="action">She beats it for the rest of the run and is delighted about it and says so at length.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's the whole of it, though. You made a thing</p><p class="line">that watches somebody. It only works while they</p><p class="line">don't know it's there.</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">Four points in ten.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Four points in ten is enormous. Ask anybody here.</p></div></div></div><div class="screenplay"><p class="action">She turns the lamp off at her end because the run is finished, and does not go, and neither does he.</p><p class="action beat">Neither of them says anything for a while.</p><p class="action">She kisses him. Neither of them makes it complicated.</p><p class="action beat">Then she is talking and he is not answering.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Hey.</p></div></div></div><div class="screenplay"><p class="action">He is looking at a point somewhere past her left shoulder and his mouth is doing something small and repetitive that he does not appear to know about.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Hey. Look at me.</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p><p class="action">Then he goes sideways off the stool and takes the stool with him.</p><p class="action">He is already rigid before he lands. His head goes back and stays back. There is a sound that Maya will not describe to anybody afterwards, not to Lena, not to Eli, not to him.</p><p class="action">She gets the stool away, drags the bag over, puts it under his head, and clears the bench beside him with her arm.</p><p class="action">She does not hold him down and she does not put anything in his mouth, because there is a sheet on the wall of every teaching lab in this building and she has walked past it twice a week since September.</p><p class="action">She looks at the clock and holds it, and this is the part that costs her: she stands still with her hand on the floor beside his shoulder and does nothing at all for ninety seconds while it happens.</p><p class="action beat">Then it stops.</p><p class="action">He is breathing. His eyes are open. He is not there.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Okay. Okay.</p></div></div></div><div class="screenplay"><p class="action">She gets her Help out and it takes her two goes to make it listen, because her hands are not steady and the first thing she says to it is not a sentence.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><p class="paren">(into it, too loud)</p><div class="lines"><p class="line">Prentiss. The bench room, third floor. Somebody</p><p class="line">needs to come now.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Now. Now, please.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody comes for nineteen minutes.</p><p class="action">She stays on the floor with him for all nineteen of them and talks to him the entire time, and he does not answer any of it, and she keeps going.</p></div><div class="screenplay"><p class="transition">Curtain.</p></div></div>
```