---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
# Scene 2 — Your Own
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #7</p><p class="rule-text">“Do not correct a superior. Suggest. Let the suggestion become his.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. HALLORAN UNION — THE SENATE OFFICE — TUESDAY 10 MARCH, 1:15 P.M.</p></div><div class="screenplay"><p class="action">A room with a counter across the middle of it and three people behind the counter and a queue of nobody.</p><p class="action">ASHLEY is at the end with a screen and a stack of forms squared off beside her.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">You're Bennett's back row.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I need a room.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Which one and what for?</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">Beckwith. Any room on the third floor with the</p><p class="line">cabinets in it. Reading.</p></div></div></div><div class="screenplay"><p class="action beat">She is already typing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Reading isn't a use. Quiet study is a use.</p><p class="line">Tuesdays and Thursdays, six till nine?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That'd do it.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">It's yours to the end of term. You want it under</p><p class="line">your name or under a society?</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">Mine.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Mine's better. A society booking gets looked at in</p><p class="line">April when they do the room audit and a student</p><p class="line">booking never does.</p></div></div></div><div class="screenplay"><p class="action">She turns the screen round for a second so that he can see it is done, which nobody in this university has ever done for him.</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">Thanks.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">I said come and find me if you were stuck with the</p><p class="line">Senate.</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">You did.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">People don't. I've been saying it since second</p><p class="line">year and about six people have.</p></div></div></div><div class="screenplay"><p class="action beat">She squares the forms off again.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Array Four in June. Have I told you that?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You have.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Yes. Sorry.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">Beckwith 3 is a teaching room with nine cabinets along the back wall and bags on four of them at any hour of the day, because people put their bags on them.</p><p class="action">The drawers come out about the width of a brick and there is no way of doing this quickly, and after the first evening he stops trying to do it quickly.</p></div><div class="screenplay"><p class="slug">INT. BECKWITH 3 — THURSDAY 12 MARCH, 6:40 P.M.</p></div><div class="screenplay"><p class="action">LENA comes because he asked her and not because anybody made a plan about it.</p><p class="action">She looks at what he is doing for about a minute and then takes the Help out of his hand.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">You're shooting down at it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's on a table.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Then the light's behind you and your own head is</p><p class="line">in every one of them. Stand it up.</p></div></div></div><div class="screenplay"><p class="action">She props a drawer open against a book, at an angle, facing the window, and shoots along it instead of down at it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Front, turn it, back, put it down in the same</p><p class="line">place. Front, turn it, back. If you break the</p><p class="line">rhythm you'll lose one and you won't know which.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">How do you know all this?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Because a poster is a thing you photograph badly</p><p class="line">eleven times before somebody tells you where to</p><p class="line">stand.</p></div></div></div><div class="screenplay"><p class="action beat">They do a drawer together and it takes forty minutes.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">How many drawers?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">In this room, about a hundred and forty.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">And in the building?</p></div></div></div><div class="screenplay"><p class="action beat">He does not answer that and she does not ask again.</p><p class="action">She stays until nine and comes back on the Tuesday without being asked, and on the Thursday after that, and neither of them ever calls it a shift.</p></div><div class="screenplay"><p class="transition">Blackout.</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</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/S — 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">The photographs are useless as photographs. Two thousand of them by the second week and no way of asking any of them anything.</p><p class="action">Assignment six is YOUR OWN, and in week six he showed Reyes a thing that drew clocks and she looked at it for four seconds and said that is not one, and gave him to the end of term.</p></div><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — REYES 204 — MONDAY 16 MARCH</p></div><div class="screenplay"><p class="action">He puts it on her desk on paper because she will not take anything any other way.</p><p class="action">She reads it the way she reads things, which is fast and twice.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">This is one.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's short.</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">It's short because you found out what the job was</p><p class="line">before you started, which is the thing I have been</p><p class="line">trying to get out of thirty-one people since</p><p class="line">September.</p></div></div></div><div class="screenplay"><p class="action beat">She turns the page over. There is nothing on the back.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">What's it for?</p></div></div></div><div class="screenplay"><p class="action beat">Two seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's a catalogue.</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Whose?</p></div></div></div><div class="screenplay"><p class="action beat">Three.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Right. Is it finished?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Then bring it when it is. Set two's closed and</p><p class="line">you're five of six and I've written down that the</p><p class="line">sixth is coming, which is a thing I do roughly</p><p class="line">once a year.</p></div></div></div><div class="screenplay"><p class="action">He is at the door when she says the other thing, and she does not look up for it either.</p></div><div class="screenplay"><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Whatever it's a catalogue of, don't put it on a</p><p class="line">university machine.</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">Why?</p></div></div><div class="dialogue"><p class="character">REYES</p><div class="lines"><p class="line">Because they're university machines.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="slug">THE FILM — THE KITCHEN — SEVEN WEEKS LATER</p></div><div class="screenplay"><p class="action">Two people. Her and one friend, and the friend has come over rather than her going out, and neither of them says anything about that.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE FRIEND</p><div class="lines"><p class="line">You're doing the voice.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">What voice?</p></div></div><div class="dialogue"><p class="character">THE FRIEND</p><div class="lines"><p class="line">That one. You're doing it now.</p></div></div></div><div class="screenplay"><p class="action">She is not doing it on purpose. She tries the next sentence in her own voice and the film lets her fail at it twice.</p><p class="action beat">The friend leaves at nine.</p><p class="action">She washes up two cups and puts them away and stands in the kitchen. Forty seconds of standing in a kitchen with nothing to do in it.</p><p class="action">The line feeds. She has stopped noticing when it happens. She notices now, because there is nothing else to notice.</p><p class="action">She has the thought that she is being fed, and that the person feeding her is not there, and that this is the closest thing to company she has had in three weeks.</p><p class="action">She goes to bed at twenty past nine because she is on at six.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — HER MOTHER'S FLAT — AN AFTERNOON</p></div><div class="screenplay"><p class="action">Her mother has the floor on. Not the programme. The floor, which runs continuously, and which people have on the way people used to have a window. She has it tuned to Array Nine, Line Four, and the camera is angled so she can see her daughter's desk.</p><p class="action">She is on it. Middle distance, at a desk, for about two seconds.</p><p class="action">Her mother rings four people.</p><p class="action">The film cuts between the four calls. Her mother is delighted in all four of them.</p><p class="action beat">Tess does not know. She has never seen the floor. She has been on it since June, and her mother has been watching her the whole time.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — DR. BECK'S OFFICE — MONTH THREE</p></div><div class="screenplay"><p class="action">DR. RAINER BECK's office is clean. It is the cleanest room in the film so far. There are no personal effects.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">Your numbers are excellent.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">Thank you.</p></div></div><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">There are some upgrades available to colleagues at</p><p class="line">your level. They're optional. Most colleagues at your</p><p class="line">level take them.</p></div></div></div><div class="screenplay"><p class="action">He shows her a brochure. The paper is thick. The photos show people who look like her, smiling, with small improvements circled in red.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BECK</p><p class="paren">(The eyes are the most popular. Better low-light)</p><div class="lines"><p class="line">vision. No more squinting at the screen by hour</p><p class="line">four.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I don't squint.</p></div></div><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">You will.</p></div></div></div><div class="screenplay"><p class="action">She agrees to the eyes. She does not know why she agrees. She is revolted by the idea and she agrees anyway, and the revulsion and the agreement sit in her chest together like two things that have learned to share the space.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — DR. BECK'S OFFICE — MONTH FOUR</p></div><div class="screenplay"><p class="action">The eyes are done. They are the same color as her old eyes, but they do not water when she is tired, and she has stopped being tired.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BECK</p><p class="paren">(The ears next. They filter out the misters. Most)</p><div class="lines"><p class="line">colleagues find it helps them focus.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I've stopped noticing the misters.</p></div></div><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">Then you won't miss them.</p></div></div></div><div class="screenplay"><p class="action">She agrees to the ears. The revulsion is still there, but it is smaller now, or she is larger, and she cannot tell which.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — DR. BECK'S OFFICE — MONTH FIVE</p></div><div class="screenplay"><p class="action">Beck is pleased with her progress. He says the word progress and she does not flinch.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BECK</p><p class="paren">(There's a new port. Better throughput. You'd notice)</p><div class="lines"><p class="line">the difference immediately.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">What's wrong with the old one?</p></div></div><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">Nothing. The new one is better.</p></div></div></div><div class="screenplay"><p class="action">She agrees. She does not know why she agrees. She has stopped asking herself that question.</p><p class="action">The revulsion is a familiar thing now, like the damp on her forearms that she no longer feels.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — THE BOARD</p></div><div class="screenplay"><p class="action">One minute thirty.</p><p class="action">Her name is at the top. The number against it is the smallest number on the board.</p><p class="action">Melvin announces it in front of the pod and puts a hand on the back of her chair and does not touch her, and is proud.</p><p class="action">She has not seen her own face in a mirror in six weeks.</p><p class="action">When she does, in the reflection of a dark screen at the end of a shift, she does not recognize the woman looking back.</p><p class="action beat">There is nobody left who would want to be told.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — THE POD — A CONVERSATION</p></div><div class="screenplay"><p class="action">Somebody new, four desks down, at the end of a shift.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE NEW ONE</p><div class="lines"><p class="line">What do you do on the two weeks?</p></div></div></div><div class="screenplay"><p class="action">The film gives her nine seconds and does not cut. It is the longest anybody looks at anything in the whole film.</p></div><div class="screenplay"><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I don't know. What do you do?</p></div></div><div class="dialogue"><p class="character">THE NEW ONE</p><div class="lines"><p class="line">I've got people at home.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">That's nice.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — THE FLOOR — THE CALL</p></div><div class="screenplay"><p class="action">She takes a call. A woman needs a procedure. The procedure has not been approved.</p><p class="action">The woman is calm at first. She has been told to be calm.</p></div><div class="screenplay"><div class="dialogue"><p class="character">THE WOMAN ON THE PHONE</p><p class="paren">(I'm calling about the procedure.)</p><div class="lines"><p class="line">The one I asked about last week.</p></div></div><div class="dialogue"><p class="character">TESS</p><p class="paren">(I can see that here. The request was reviewed and)</p><div class="lines"><p class="line">the answer was no.</p></div></div><div class="dialogue"><p class="character">THE WOMAN ON THE PHONE</p><div class="lines"><p class="line">I know. I know it was no. I'm</p><p class="line">asking if there's anything else. Any other way to do it.</p><p class="line">Anything at all.</p></div></div></div><div class="screenplay"><p class="action">Tess knows what the woman is asking. It is not on the list. It has never been on the list. The word for it is not on the list either, and she is not permitted to say it, and she does not know how to talk around it in a way that is not a lie.</p><p class="action">She thinks about the sac. She thinks about the rows of them, and the children inside them, and the way they are lifted out when they are ready. She thinks about what it would mean to carry one and not want it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I'm sorry. There's nothing I can do.</p></div></div><div class="dialogue"><p class="character">THE WOMAN ON THE PHONE</p><div class="lines"><p class="line">Please. There has to be</p><p class="line">something. I can't—I can't do this. I can't go through</p><p class="line">with it. Please.</p></div></div></div><div class="screenplay"><p class="action">The woman is crying now. Tess is very good at her job. She is warm and she is exact. She does not pretend to have any power.</p></div><div class="screenplay"><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I'm sorry. The account has been noted.</p></div></div></div><div class="screenplay"><p class="action">The woman says something that is not on the list. Tess does not react. She is very good at her job.</p><p class="action">The call ends.</p><p class="action beat">She does not take the next one.</p><p class="action">The light on the pole goes from one state to another and stays there for a count of about forty. The two nearest desks notice. Neither of them looks up.</p><p class="action">Melvin comes down the aisle at the speed of somebody on a four-minute cycle.</p><p class="action">He is very gentle. He is gentler than anybody has been to her in the whole film.</p><p class="action">She is smiling.</p><p class="action beat">The misters come on.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — A ROOM OFF THE FLOOR</p></div><div class="screenplay"><p class="action">Beck has her file open. He is younger than she expected, and he has been her doctor for two years, and she has never seen him outside this room.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">You stopped mid-shift.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I know.</p></div></div><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">For four minutes.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">I know.</p></div></div></div><div class="screenplay"><p class="action">He turns the file round so she can see the graph. Her aftercall over five months. The line goes down and down and there is one spike in it and the spike is today.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">There's an update for the response set. It's</p><p class="line">optional. It takes the edge off the ones that sit with</p><p class="line">you.</p></div></div><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">Which ones sit with me?</p></div></div><div class="dialogue"><p class="character">BECK</p><div class="lines"><p class="line">You just showed me.</p></div></div></div><div class="screenplay"><p class="action beat">She looks at the spike.</p></div><div class="screenplay"><div class="dialogue"><p class="character">TESS</p><div class="lines"><p class="line">All right.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div></div>
```
---
::: {.printedClojure}
```clojure
[:p.action
"The last fifty minutes of the film are thirty-four years and there is no dialogue in any of it."]
```
:::
::: {.printedClojure}
```clojure
[:p.action
"The updates come every eight or nine months and they stop being surgical after the fourth one. There is nothing to open. She sits in the chair in the room off the floor and Beck says the name of it and she says all right, and there is a period of about forty seconds where she is looking at nothing."]
```
:::
::: {.printedClojure}
```clojure
[:p.action "After the sixth she stops asking what they are called."]
```
:::
::: {.printedClojure}
```clojure
[:p.action
"The film marks the years by the board. Her name is at the top of it for thirty-one of them. The names underneath it change completely four times over and hers does not move."]
```
:::
::: {.printedClojure}
```clojure
[:p.action
"Somewhere in the second decade the calls stop having any effect on her face. The film is careful about when: it is not sudden, and there is no scene where it happens, and by the time you notice it you cannot say which year it was."]
```
:::
::: {.printedClojure}
```clojure
[:p.action
"She is at the desk in the same posture for thirty-four years. The chair is replaced twice and she is not off it for either."]
```
:::
::: {.printedClojure}
```clojure
[:p.action
"Her mother is at the flat with the floor on, and then her mother is not at the flat, and the film does not say what happened and neither does anybody in it."]
```
:::
::: {.printedClojure}
```clojure
[:p.action
"The woman on the phone is at the flat. She is not at the flat. The film does not say what happened to her either."]
```
:::
---
```{=html}
<div class="artifact handout"><p class="handout-title">ARRAY NINE · SEAT 4,412 · notice of discontinuation</p><p><span>The components at this seat are no longer manufactured.</span></p><p><span>Substitution has been evaluated and is not recommended.</span></p><p><span>The seat will be released at the end of the current shift.</span></p><p><span>Aftercall at release: 1:30.</span></p><p><span>Thank you for thirty-four years of continuous service.</span></p></div>
```
---
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">THE FILM — THE FLOOR — THE LAST SHIFT</p></div><div class="screenplay"><p class="action">She takes calls. She is very good at it.</p><p class="action">At the fifth hour a submanager who was not born when she started comes down the aisle and stands at the end of her row and waits for her to finish.</p><p class="action">She finishes. The account has been noted.</p><p class="action beat">The light on the pole goes amber.</p><p class="action">The film holds on her face.</p><p class="action">For about a second and a half there is something in it.</p><p class="action">Relief.</p><p class="action beat">It is gone before anybody in Room 3C can name it.</p><p class="action">She is smiling. The film holds on that for a long time.</p><p class="action beat">The misters come on.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">THE FILM — A CEMETERY</p></div><div class="screenplay"><p class="action">A stone with her name on it and one line under the name.</p><p class="action beat">1:30</p><p class="action">Four people from the pod are there and they are on their break and go back.</p><p class="action">Then a card, white on black.</p></div><div class="screenplay"><div class="sign"><p>The pod's aftercall average rose by four seconds</p><p>in the quarter following.</p></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 3C — CONTINUOUS</p></div><div class="screenplay"><p class="action">Victor lets the credits run. It is twenty past ten.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">Thirty-one years at the top of an array board.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">Is that a record?</p></div></div><div class="dialogue"><p class="character">MILES</p><p class="paren">(It has to be. There are four thousand seats on)</p><div class="lines"><p class="line">Nine and they turn over about every nine years.</p></div></div></div><div class="screenplay"><p class="action">He has worked that out during the credits and is pleased with it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MILES</p><p class="paren">(And Beck never once made her do anything. Every)</p><div class="lines"><p class="line">single one of those was offered and she took it.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">She'd have been about sixty at the end.</p></div></div><div class="dialogue"><p class="character">MILES</p><p class="paren">(Fifty-seven. And still at one thirty. That is not)</p><div class="lines"><p class="line">a job any more, that's a—</p></div></div></div><div class="screenplay"><p class="action beat">He looks for the word.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">That's a calling.</p></div></div><div class="dialogue"><p class="character">DEREK</p><p class="paren">(The Romans had four of those and they were all)</p><div class="lines"><p class="line">priesthoods and every one of them was for life.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">There you are, then.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">No, listen, because the interesting part is that</p><p class="line">you could not leave. Not that you would not. There was no</p><p class="line">procedure for it. A man went in at six and came out dead</p><p class="line">and the state did not have a form for anything else.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><p class="paren">(Then it is a good arrangement and it lasted a)</p><div class="lines"><p class="line">thousand years.</p></div></div><div class="dialogue"><p class="character">DEREK</p><p class="paren">(It lasted four hundred and then they had to start)</p><div class="lines"><p class="line">buying the priests.</p></div></div></div><div class="screenplay"><p class="action beat">Victor lets that go, which he does not usually do.</p><p class="action">SIMON has been on the arm of the couch since the second hour and has moved twice.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">It was a bit long.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">It was three and a half hours.</p></div></div><div class="dialogue"><p class="character">SIMON</p><p class="paren">(I'm not saying it was bad. It was well made. The)</p><div class="lines"><p class="line">bit with the eyes was well made.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody asks him which bit with the eyes.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">I just think if you're going to do thirty-four</p><p class="line">years you could do it in ninety minutes and get the same</p><p class="line">across.</p></div></div><div class="dialogue"><p class="character">VICTOR</p><div class="lines"><p class="line">You could not.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Probably not.</p></div></div></div><div class="screenplay"><p class="action">He says that agreeably and goes back to looking at the screen, which is black.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MILES</p><p class="paren">(The one that gets me is the young woman on the)</p><div class="lines"><p class="line">phone at the end of the first half.</p></div></div><div class="dialogue"><p class="character">DEREK</p><div class="lines"><p class="line">The one who was crying.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">The way she went on. Tess had told her twice and</p><p class="line">she went on for quarter of an hour, and Tess stayed on the</p><p class="line">line the whole time, and did not raise her voice once.</p></div></div><div class="dialogue"><p class="character">MILES</p><p class="paren">(I would not have been able to do it. I would have)</p><div class="lines"><p class="line">said something.</p></div></div></div><div class="screenplay"><p class="action beat">He does not say what he would have said.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">She got through to somebody, though. That's the</p><p class="line">thing about the floor. You ring up and there is an actual</p><p class="line">person on the end of it who stays on for as long as you</p><p class="line">want.</p></div></div><div class="dialogue"><p class="character">MILES</p><div class="lines"><p class="line">She said no to her.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">She said no to her properly.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody follows that up.</p><p class="action">MILES puts the mask on.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has not said anything since the room off the floor with the chair in it.</p><p class="action">He is thinking about the call. The woman who needed a procedure that was not approved. The woman who cried and then thanked her, and meant it, and he does not know what to do with that.</p><p class="action">He is thinking about the sac. He is thinking about the rows of them, and the children inside them, and the way they are lifted out when they are ready. He is thinking about his mother, who carried him, and the eight years he did not know her, and the way she looked at him when he finally came home.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">Half past one. He gets in and there is something on the desk beside the tower, sitting up, tracked, the size of a large mouse, and it has been there for an unknown length of time.</p></div><div class="artifact terminal"><p class="term-out"><span>PIP</span></p><p class="term-out"><span>Don't say anything out loud.</span></p><p class="term-out"><span>Put it on this and I'll take it down.</span></p><p class="term-out"><span>He read the two thousand you sent on the ninth. He has been up for</span></p><p class="term-out"><span>most of three days and Harper has stopped arguing with him about it.</span></p><p class="term-out"><span>There are forty-one marks he wants and I have them here.</span></p><p class="term-in"><span>$</span></p></div><div class="screenplay"><p class="action">Forty-one marks. He runs them against the index, which is a thing he can do now, and which takes about a second.</p></div><div class="scene-code"><pre><code>(def wanted
["005.13" "016.09" "016.11" "016.4" "021.7"
"150.19" "301.4" "323.44" "331.88" "355.02"])</code></pre></div><div class="screenplay"><p class="action">That is ten of them and the other thirty-one are the same and it does not matter what they are called.</p><p class="action">Every one of them is held. Every one of them is C-3.</p><p class="action">Every one of them is S.</p><p class="action">South. Past the flooded end. About two hundred feet, and it is not a wall and it is not water and there is nothing there to look at.</p></div><div class="artifact terminal"><p class="term-out"><span>PIP</span></p><p class="term-out"><span>Yes.</span></p><p class="term-out"><span>He knows. That's why he's been up for three days.</span></p><p class="term-out"><span>He says he is not going to ask you and he says you already know that</span></p><p class="term-out"><span>he is not going to ask you, and he asked me to say both halves.</span></p><p class="term-in"><span>$</span></p></div><div class="screenplay"><p class="action">It goes off the desk and under the door in a way a door should not allow and he sits in his room with the light off for a while.</p><p class="action">Then he gets his Help out to look up when the Collegium visitation is, and stops, because there is no Collegium, because the Collegium is a thing a man in a felt hat made up in October.</p><p class="action">He puts it down and does not ask it anything.</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — LAZLO'S ROOM — A SATURDAY</p></div><div class="screenplay"><p class="action">Eleven rovers on the bench in various states and four of them are in pieces because he has had them open looking for something that is not in them.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I have got a hundred and eighty feet of run I</p><p class="line">cannot use and I do not know why, and I have been</p><p class="line">doing it by sending things at it and counting what</p><p class="line">comes back.</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 comes back?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">A number. If it gets there it tells me how many of</p><p class="line">the squares round it are bad. If it does not get</p><p class="line">there it does not tell me anything, and I have one</p><p class="line">fewer rover.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's enough.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Enough for what?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">To work out where they all are without sending</p><p class="line">anything else at them.</p></div></div></div><div class="screenplay"><p class="action beat">Lazlo looks at him for a moment.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">No, it isn't. I have done that. Show me anyway.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">It takes him an evening. There is nothing in it that is difficult and there is one thing in it that is nice, and the nice thing is what a square with nothing next to it does.</p><p class="action">A square with nothing next to it is not one piece of information. It is a piece of information about all eight of its neighbours, and each of those may be another one, so the honest way to write it is to let it call itself and stop when it has something to say.</p></div><div class="scene-code"><pre><code>(def width 9)
(def height 9)
(defn neighbours [[x y]]
(for [dx [-1 0 1] dy [-1 0 1]
:when (not= [0 0] [dx dy])
:let [p [(+ x dx) (+ y dy)]]
:when (and (< -1 (first p) width) (< -1 (second p) height))]
p))
(defn near [bad p] (count (filter bad (neighbours p))))
(defn probe
"Look at a square. If nothing is next to it, look at everything round
it as well, and keep going."
[bad seen p]
(cond
(seen p) seen
(bad p) (conj seen p)
:else (let [seen (conj seen p)]
(if (zero? (near bad p))
(reduce (fn [s q] (probe bad s q)) seen (neighbours p))
seen))))</code></pre></div><div class="screenplay"><p class="action">And then the part Lazlo asked for, which is the only two things anybody can say for certain from a number.</p></div><div class="scene-code"><pre><code>(defn deduce
"If a square's number equals the unknowns around it, all of them are
bad. If it equals what is already marked around it, everything else
round it is clear.
That is all. There is a third kind of deduction that takes two squares
at once and he does not find it this term."
[bad seen marked]
(reduce
(fn [acc p]
(let [unknown (remove (fn [q] (or (seen q) (marked q))) (neighbours p))
n (near bad p)
already (count (filter marked (neighbours p)))]
(cond
(empty? unknown) acc
(= n (+ already (count unknown))) (update acc :mark into unknown)
(= n already) (update acc :safe into unknown)
:else acc)))
{:mark #{} :safe #{}}
(filter seen (for [x (range width) y (range height)] [x y]))))</code></pre></div><div class="screenplay"><p class="action">Nine by nine and ten of them. Probe once in the middle and see what falls out.</p></div><div><script type="application/x-scittle">(ns fedoras.sketch
"Editable ClojureScript that draws.
The live cells elsewhere in the book evaluate an expression and print
what it returned, which is fine for a list and useless for a picture.
A sketch cell gives the code a canvas and five things to draw with,
and then gets out of the way: what the reader edits is the same
algorithm that ran on the JVM at build time, in the same language,
and it runs in front of them.
What the page provides, and the scene says so out loud because Reyes
said it in class:
(clear!) wipe it
(ink! \"#1c1f24\") colour
(dot x y) one mark, in world coordinates
(poly [[x y] ...]) a closed outline
(frames! total per f) call (f i) total times, per of them a frame
Nothing else is injected. Everything above the fold in the cell is
the reader's.
WHERE THE CODE IS. All of it is here, in one file read twice. The
build reads the :clj half, which writes the markup and, for each cell,
a header binding the short names to that cell's own canvas or piece of
page. The page reads the :cljs half, because the build ships this file
to it as it stands: the drawing kit the headers call, and the runner
behind every Run button."
#?(:clj (:require [clojure.string :as str]
[fedoras.state :as state]
[scicloj.kindly.v4.kind :as kind])))
;; ---------------------------------------------------------------------
;; The build: headers and markup
;; ---------------------------------------------------------------------
#?(:clj
(do
(defn canvas-header
"The short names a sketch cell may use, bound to its own canvas.
Built as Clojure forms rather than as a string, so it cannot be
unbalanced and can be read back and printed."
[id world-w world-h]
[(list 'def 'C (list 'fedoras.sketch/mount id world-w world-h))
'(def cv (:cv C))
'(def ctx (:ctx C))
'(def CW (:cw C))
'(def CH (:ch C))
'(def SX (:sx C))
'(def SY (:sy C))
'(defn clear! [] (fedoras.sketch/clear! C))
'(defn ink! [c] (fedoras.sketch/ink! C c))
'(defn dot ([x y] (fedoras.sketch/dot C x y)) ([x y s] (fedoras.sketch/dot C x y s)))
'(defn poly [pts] (fedoras.sketch/poly C pts))
'(defn frames! [total per f] (fedoras.sketch/frames! C total per f))
'(defn on-pointer! [f] (fedoras.sketch/on-pointer! C f))
'(def page-ink (fedoras.sketch/page-ink C))
'(clear!)
'(ink! page-ink)
'(fedoras.sketch/line-width! C 0.8)])
(defn page-header
"The names a page cell may use. No canvas: a piece of the page."
[id]
[(list 'def 'root (list '.getElementById 'js/document (str id "-out")))
'(defn html! [s] (set! (.-innerHTML root) s))
'(defn on! [id f]
(let [e (.getElementById js/document id)]
(when e (set! (.-onclick e) (fn [_] (f))))))
'(defn fetch [k]
(try
(let [v (.getItem js/localStorage (str "fedoras." k))]
(when v (read-string v)))
(catch :default _ nil)))
'(defn store! [k v]
(try (.setItem js/localStorage (str "fedoras." k) (pr-str v))
(catch :default _ nil))
v)])
(defn- header-text [forms]
(str/join "\n" (map pr-str (cons '(in-ns 'fedoras.sketch-runner) forms))))
(defn primitives
"What the page hands a cell, printed, so that nothing in the book is
doing something the reader cannot look at."
([] (primitives 100 100))
([world-w world-h]
(kind/code (header-text (canvas-header "the-page" world-w world-h)))))
(defn cell
"An editable ClojureScript sketch with a canvas under it.
(sketch/cell {:id \"chaos\" :label \"CHAOS.CLJS\" :world [100 86.6]}
\"(dot 50 40)\")"
[{:keys [id label world w h rows]
:or {id "sketch" world [100 100] w 420 h 380 rows 16}}
code]
(let [[ww wh] world]
(kind/hiccup
[:div
(state/source "fedoras/sketch.cljc")
[:div.sketch {:id id}
[:div.roll-head
[:span.roll-label (or label "SKETCH")]
[:span.roll-dc (str ww " × " wh)]]
[:textarea {:id (str id "-src") :class "live-src" :rows rows :spellcheck "false"}
(str/trim code)]
[:div.live-bar
[:button.live-run "Run"]]
[:canvas {:id (str id "-canvas") :width w :height h}]
[:pre {:id (str id "-out") :class "live-out"}]]
(state/call 'fedoras.sketch/cell! id (header-text (canvas-header id ww wh)))])))
(defn page
"An editable ClojureScript cell with a piece of page under it instead
of a canvas. The header gives the code five things:
root the div it owns
(html! s) put markup in it
(on! id f) wire a click
(fetch k) read something the reader saved
(store! k v) save something, and it is still there tomorrow
Everything else in the cell belongs to the reader."
[{:keys [id label rows] :or {id "page" rows 20}} code]
(kind/hiccup
[:div
(state/source "fedoras/sketch.cljc")
[:div.sketch {:id id}
[:div.roll-head
[:span.roll-label (or label "PAGE")]
[:span.roll-dc "cljs"]]
[:textarea {:id (str id "-src") :class "live-src" :rows rows :spellcheck "false"}
(str/trim code)]
[:div.live-bar
[:button.live-run "Run"]]
[:div {:id (str id "-out") :class "sketch-page"}]
[:pre {:id (str id "-res") :class "live-out"}]]
(state/call 'fedoras.sketch/cell! id (header-text (page-header id)))]))))
;; ---------------------------------------------------------------------
;; The page: the drawing kit and the runner
;; ---------------------------------------------------------------------
#?(:cljs
(do
(defonce counters
(atom {}))
(defn- counters-for
"The frame counters of one canvas: made the first time it is mounted
and kept for every Run after, so that each press of Run takes the
next number and the loop the last press started sees it and stops."
[id]
(or (get @counters id)
(let [made {:run (atom 0) :i (atom 0)}]
(swap! counters assoc id made)
made)))
(defn mount
"Everything one cell needs, from the id of its canvas and the size of
the world it draws in. Every drawing function below takes the map
this returns."
[id world-w world-h]
(let [cv (.getElementById js/document (str id "-canvas"))
ctx (.getContext cv "2d")
cw (.-width cv)
ch (.-height cv)]
(merge {:cv cv :ctx ctx :cw cw :ch ch
:sx (/ cw world-w)
:sy (/ ch world-h)}
(counters-for id))))
(defn ink!
"Both fill and stroke, because a cell that sets one and not the other
is a cell that draws in two colours by accident."
[{:keys [ctx]} colour]
(set! (.-fillStyle ctx) colour)
(set! (.-strokeStyle ctx) colour))
(defn page-ink
"Whatever colour the surrounding prose is, so a drawing belongs to the
page it is on and follows it into dark mode."
[{:keys [cv]}]
(.-color (js/getComputedStyle cv)))
(defn clear! [{:keys [ctx cw ch]}]
(.clearRect ctx 0 0 cw ch))
(defn dot
([c x y] (dot c x y 1.2))
([{:keys [ctx sx sy ch]} x y size]
(.fillRect ctx (* x sx) (- ch (* y sy)) size size)))
(defn poly
"A closed outline through the points, in world coordinates."
[{:keys [ctx sx sy ch]} pts]
(when (seq pts)
(let [[fx fy] (first pts)]
(.beginPath ctx)
(.moveTo ctx (* fx sx) (- ch (* fy sy)))
(doseq [[x y] (rest pts)]
(.lineTo ctx (* x sx) (- ch (* y sy))))
(.closePath ctx)
(.stroke ctx))))
(defn line-width! [{:keys [ctx]} w]
(set! (.-lineWidth ctx) w))
(defn- tick! [{:keys [run i] :as c} generation total per f]
(when (= generation @run)
(dotimes [_ per]
(when (< @i total) (f @i) (swap! i inc)))
(when (< @i total)
(js/requestAnimationFrame (fn [] (tick! c generation total per f)))))
nil)
(defn frames!
"Call (f i) total times, per of them per frame.
Pressing Run again retires the loop that is already going. Without
that, a cell run twice advances twice per frame and looks broken in a
way nobody can debug from the outside."
[{:keys [run i] :as c} total per f]
(let [generation (swap! run inc)]
(reset! i 0)
(tick! c generation total per f))
nil)
(defn on-pointer!
"Pointer position in world coordinates, which is what a cell wants and
never what the event gives it."
[{:keys [cv sx sy]} f]
(set! (.-onmousemove cv)
(fn [e]
(f (/ (.-offsetX e) sx)
(- (/ (.-height cv) sy) (/ (.-offsetY e) sy))))))
(defonce headers
(atom {}))
(defn- element
[id]
(.getElementById js/document id))
(defn- line-count
[text]
(count (re-seq #"\n" text)))
(defn- error-text
"An error as the reader should see it: the message, and the line of
their own code it came from, when there is one."
[error header-lines]
(let [line (:line (ex-data error))]
(str (or (ex-message error) (str error))
(when (and line (> line header-lines))
(str " (line " (- line header-lines) ")")))))
(defn run-cell!
"Cell `id`'s header, then the reader's code, with what came back
printed under the cell, or the error, counted in the reader's lines."
[id]
(let [header (get @headers id)
out (or (element (str id "-res")) (element (str id "-out")))
source (.-value (element (str id "-src")))]
(set! (.-textContent out)
(try
;; load-string is Scittle's own; plain ClojureScript has none
(let [value #_{:clj-kondo/ignore [:unresolved-symbol]} (load-string (str header "\n" source))]
(if (nil? value) "" (str value)))
(catch :default error
(error-text error (inc (line-count header))))))))
(defn- prepare-canvas!
"Give a sketch cell's canvas its drawing context while the page
loads, so that the first Run draws on a canvas the browser has
already shown once. A context made at the moment of the first
drawing loses that drawing in Firefox: the first frames of the
first Run never appear."
[id]
(when-let [cv (element (str id "-canvas"))]
(.clearRect (.getContext cv "2d") 0 0 (.-width cv) (.-height cv))))
(defn cell!
"Keep cell `id`'s header and wire its Run button, and the first time
the cell is seen, prepare its canvas, if it has one. Only the first
time, because preparing clears, and a canvas that is already drawing
must not be cleared under it."
[id header]
(let [first-time? (not (contains? @headers id))]
(swap! headers assoc id header)
(when-let [button (some-> (element id) (.querySelector ".live-run"))]
(set! (.-onclick button) (fn [_] (run-cell! id))))
(when first-time?
(prepare-canvas! id))))))</script><div class="sketch" id="survey"><div class="roll-head"><span class="roll-label">SURVEY.CLJS · click to probe · shift-click to mark</span><span class="roll-dc">cljs</span></div><textarea class="live-src" id="survey-src" rows="34" spellcheck="false">(def width 9)
(def height 9)
(def how-many 10)
(defn neighbours [[x y]]
(for [dx [-1 0 1] dy [-1 0 1]
:when (not= [0 0] [dx dy])
:let [p [(+ x dx) (+ y dy)]]
:when (and (< -1 (first p) width) (< -1 (second p) height))]
p))
(defn survey [first-square]
(let [safe (conj (set (neighbours first-square)) first-square)]
(loop [found (hash-set)]
(if (= how-many (count found))
found
(let [p [(rand-int width) (rand-int height)]]
(recur (if (safe p) found (conj found p))))))))
(defn near [bad p] (count (filter bad (neighbours p))))
(defn probe [bad seen p]
(cond
(seen p) seen
(bad p) (conj seen p)
:else (let [seen (conj seen p)]
(if (zero? (near bad p))
(reduce (fn [s q] (probe bad s q)) seen (neighbours p))
seen))))
;; ---------------------------------------------------------------------
(def bad (atom nil))
(def seen (atom (hash-set)))
(def marked (atom (hash-set)))
(def lost (atom 0))
(def note (atom "Probe anywhere. The first one is always clear."))
(declare draw)
(defn tap [p]
(when (nil? @bad) (reset! bad (survey p)))
(cond
(@marked p) (reset! note "That one is marked. Unmark it first.")
(@bad p) (do (swap! seen conj p)
(swap! lost inc)
(reset! note (str "Rover " @lost " stopped at "
(first p) "," (second p) ".")))
:else (do (swap! seen (fn [s] (probe @bad s p)))
(reset! note (str (count @seen) " of "
(* width height) " squares surveyed."))))
(draw))
(defn mark [p]
(swap! marked (fn [m] (if (m p) (disj m p) (conj m p))))
(draw))
;; the only two things anybody can say for certain
(defn deduce []
(let [b @bad]
(if (nil? b)
(reset! note "Probe something first.")
(let [found (reduce
(fn [acc p]
(let [unknown (remove (fn [q] (or (@seen q) (@marked q)))
(neighbours p))
n (near b p)
already (count (filter @marked (neighbours p)))]
(cond
(empty? unknown) acc
(= n (+ already (count unknown)))
(update acc :mark into unknown)
(= n already) (update acc :safe into unknown)
:else acc)))
{:mark (hash-set) :safe (hash-set)}
(filter @seen (for [x (range width) y (range height)] [x y])))]
(swap! marked into (:mark found))
(doseq [p (:safe found)] (swap! seen (fn [s] (probe b s p))))
(reset! note (str (count (:mark found)) " certain, "
(count (:safe found)) " certainly clear."
(when (and (empty? (:mark found)) (empty? (:safe found)))
" Nothing follows. You will have to guess."))))))
(draw))
;; the point of the whole thing
(defn resurvey []
(reset! bad nil)
(reset! seen (hash-set))
(reset! note "Same ground. Your marks are still where you left them.")
(draw))
;; ---------------------------------------------------------------------
(defn face [p]
(cond
(@marked p) "x"
(not (@seen p)) "·"
(and @bad (@bad p)) "!"
:else (let [n (near @bad p)] (if (zero? n) "" (str n)))))
(defn draw []
(html!
(str "<div style='display:grid;grid-template-columns:repeat(" width
",1.7rem);gap:2px;font-family:ui-monospace;justify-content:start'>"
(apply str
(for [y (reverse (range height)) x (range width)
:let [p [x y] id (str "c" x "_" y)]]
(str "<button id='" id "' style='height:1.7rem;padding:0'>"
(face p) "</button>")))
"</div><p>" @note "</p>"
"<div class='live-bar'>"
"<button id='deduce'>What follows</button>"
"<button id='again'>Survey it again</button></div>"
"<p class='roll-foot'>Click to probe. Shift-click to mark. "
"Rovers stopped: " @lost "</p>"))
(doseq [y (range height) x (range width)]
(let [p [x y] el (js/document.getElementById (str "c" x "_" y))]
(when el
(set! (.-onclick el)
(fn [e] (if (.-shiftKey e) (mark p) (tap p)))))))
(on! "deduce" deduce)
(on! "again" resurvey))
(draw)</textarea><div class="live-bar"><button class="live-run">Run</button></div><div class="sketch-page" id="survey-out"></div><pre class="live-out" id="survey-res"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "survey" "(in-ns (quote fedoras.sketch-runner))\n(def root (.getElementById js/document \"survey-out\"))\n(defn html! [s] (set! (.-innerHTML root) s))\n(defn on! [id f] (let [e (.getElementById js/document id)] (when e (set! (.-onclick e) (fn [_] (f))))))\n(defn fetch [k] (try (let [v (.getItem js/localStorage (str \"fedoras.\" k))] (when v (read-string v))) (catch :default _ nil)))\n(defn store! [k v] (try (.setItem js/localStorage (str \"fedoras.\" k) (pr-str v)) (catch :default _ nil)) v)") (js/console.error "fedoras: fedoras.sketch/cell! 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. LAZLO'S ROOM — THE FOLLOWING SATURDAY</p></div><div class="screenplay"><p class="action">It works. Lazlo runs it for about forty minutes without saying anything, which from him is a review.</p><p class="action">Then he clears it and runs the survey again over the same ground.</p><p class="action beat">It is a different board.</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's the same hundred and eighty feet.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Then something's wrong with how you're seeding it.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Nothing is wrong with how I am seeding it. I sent</p><p class="line">four rovers down that run in March and I sent four</p><p class="line">more down it in April and they did not stop in the</p><p class="line">same places, and I have the numbers, and I have</p><p class="line">had them a long time.</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.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Then what is it that's moving?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I don't know that it is a thing and I don't know</p><p class="line">that it is moving. Those are two assumptions and I</p><p class="line">have got neither of them.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">What I have got is that a square is bad on Tuesday</p><p class="line">and clear on Thursday and bad again in May, and</p><p class="line">that a rover that goes into one does not come out,</p><p class="line">and that is the whole of nine years.</p></div></div></div><div class="screenplay"><p class="action">He presses it again. It is a different board again.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">Your program is correct. That is not the same</p><p class="line">thing as it being any use, and I would like you to</p><p class="line">hold both of those at once, because I have not</p><p class="line">managed it.</p></div></div></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="screenplay"><p class="action">Survey it again. Your marks stay where you put them, because a mark is a thing you wrote down and writing it down does not make it so.</p></div></div>
```