---
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 3 — Carrying
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #18</p><p class="rule-text">“Always arrive early. Latecomers are, by definition, followers.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. ASHCROFT HOUSE — THE CORRIDOR — MONDAY, 11:00 P.M.</p></div><div class="screenplay"><p class="action">PELL is on the floor outside 2C with his back against the door and a machine on his knees. He is second year. He has been on this corridor since <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> arrived and they have said about forty words to each other, all of them about the shower.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">You're the one that does the six o'clock bucket.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Right. The list one. The one that has to still be</p><p class="line">there on Tuesday.</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">Assignment three.</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">I've done it four times and it's empty every time.</p><p class="line">I close it and it's gone. I've read the sheet nine</p><p class="line">times.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> sits down on the floor because it is easier than standing up talking to somebody on the floor.</p><p class="action beat">It takes eleven minutes. Pell has written all of it and none of it goes anywhere that is not inside the program, which is the only thing the assignment is about.</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">You have to put it somewhere that isn't the</p><p class="line">program.</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Where?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">A file.</p></div></div></div><div class="screenplay"><p class="action beat">Pell looks at him.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Show me on yours.</p></div></div></div><div class="screenplay"><p class="action">That is the moment, and it does not look like one.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> turns the machine round and shows him the four lines that save the list and the four that read it back.</p><p class="action">Pell reads them twice and copies them out by hand onto the back of a docket letter, which takes six minutes, and does not ask for the file and is not offered it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Is that cheating?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I don't know.</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">No. Me neither.</p></div></div></div><div class="screenplay"><p class="action beat">He goes on copying.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">How many are you carrying?</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Two.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody on that corridor has said the word carrying out loud since the first week.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">From last year. One of them's this course and one</p><p class="line">of them's Bennett's, which is a reading list, and</p><p class="line">I can read.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">So read it.</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">I do read it. I read all of it. Then I sit in the</p><p class="line">room and he asks what it's doing to me and I</p><p class="line">haven't got anything, and everybody else has got</p><p class="line">something, and I've read it and they haven't.</p></div></div></div><div class="screenplay"><p class="action">He finishes copying and squares the docket letter on his knee.</p></div><div class="screenplay"><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Thanks. That's the third one done.</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">Out of six.</p></div></div><div class="dialogue"><p class="character">PELL</p><div class="lines"><p class="line">Out of six.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — CS 101 — THE FOLLOWING MONDAY</p></div><div class="screenplay"><p class="action">Reyes takes the register the way she takes the register.</p><p class="action beat">She reads out thirty-one names.</p><p class="action">One of them is not answered and she does not read it twice and does not look up, and goes on to the next one, and starts the class four seconds later than she started it last week.</p><p class="action beat">It is not Pell's name. Pell is not in this class.</p><p class="action">It is a girl in the second row who kept her Help in her lap in September until she was looked at.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ASHCROFT HOUSE — 2C — THE SAME EVENING</p></div><div class="screenplay"><p class="action">The door of 2C is open, which it has not been all term.</p><p class="action">The room is empty. Not tidied. Empty. The bed is stripped and there is a chair in the middle of the floor at an angle that no chair is ever left at.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><p class="paren">(into the corridor)</p><div class="lines"><p class="line">Has anyone seen Pell?</p></div></div><div class="dialogue"><p class="character">A VOICE (O.S.)</p><div class="lines"><p class="line">Who?</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">Two C. Pell. He was on the floor out here on</p><p class="line">Monday.</p></div></div><div class="dialogue"><p class="character">A SECOND VOICE (O.S.)</p><div class="lines"><p class="line">Oh. He got reviewed.</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 the sixth week.</p></div></div><div class="dialogue"><p class="character">A SECOND VOICE (O.S.)</p><div class="lines"><p class="line">He was carrying two.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody comes out into the corridor.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="action">He asks eleven people over four days and gets nine answers.</p></div><div class="artifact aichat"><p class="ai-model">HELP · personal · v9</p><div class="ai-body"><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>what happens to a student who is repossessed</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>Repossession is the standard end-of-term process for students</span></p><p><span>who do not pass review.</span></p><p><span>Would you like me to set a study reminder?</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>where do they go</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>Repossession is the standard end-of-term process for students</span></p><p><span>who do not pass review.</span></p></div></div><div class="ai-turn ai-user"><p class="ai-role">USER</p><div class="ai-text"><p><span>you said that</span></p></div></div><div class="ai-turn ai-assistant"><p class="ai-role">ASSISTANT</p><div class="ai-text"><p><span>Dining Dollars do not roll over.</span></p></div></div></div></div><div class="screenplay"><p class="action">The corridor says home. A boy on the second floor says there is a transfer scheme and names an institution that Lena has been at and which Lena says does not take transfers. Ashley from the Senate says it is a financial thing and is quite sure. Somebody's roommate says his brother was repossessed four years ago and is fine, but cannot say where he is now.</p><p class="action">Miles says the word is a legal term about property and has been used wrongly by this institution for ninety years and that this annoys him more than anything else on the campus. Derek says it is Roman. Victor says it is a matter for #general (which it really isn't), because there is no other channel dedicated to it.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. WHITLOCK HALL — LOWER GROUND — ROOM 4 — THURSDAY, 7:00 P.M.</p></div><div class="screenplay"><p class="action">Below the level of the quad. No window. One door. A long table with five chairs down one side, five down the other, and one at the end.</p><p class="action">PROFESSOR HALE is at a lectern with a tablet. He has moderated this event every Thursday for six years and could do it without the tablet if he absolutely had to..</p></div><div class="screenplay"><div class="sign"><p>DOCKET · ROOM 4 · 7:00</p><p>ATTENDANCE IS RECORDED</p></div></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Eleven. Five, five, and the eleventh. Attendance</p><p class="line">is recorded and is not a component of review.</p><p class="line">Standings first, in the order you're sitting.</p></div></div></div><div class="screenplay"><p class="action">The five on the near side sit up. Nobody on the far side does anything, because nobody on the far side is going to be asked.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BOURNE</p><div class="lines"><p class="line">Bourne. Second year, metallurgy. Placed, Array</p><p class="line">Four, from June. No service assignment. Review, no</p><p class="line">action. Intends two.</p></div></div></div><div class="screenplay"><p class="action beat">Somebody on the far side says two.</p><p class="action">Not a question. She does not look up, and he does not turn his head.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Thank you.</p></div></div><div class="dialogue"><p class="character">ALEX</p><div class="lines"><p class="line">Alex. Third year, transit. Placed. Review, no</p><p class="line">action. Intends three.</p></div></div></div><div class="screenplay"><p class="action beat">Three comes back from further down.</p></div><div class="screenplay"><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">Carter. Second year, food systems. Unplaced,</p><p class="line">applied. Service assignment, the glasshouses.</p><p class="line">Review, no action. Intends one.</p></div></div></div><div class="screenplay"><p class="action beat">Nothing comes back.</p><p class="action">SIMON is fourth. He starts before Hale looks up.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Simon. Second year, unplaced, but—</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Thank you, Simon.</p></div></div></div><div class="screenplay"><p class="action beat">Hale did'nt need to hear any further, nor did the rest of the room, apparently. Two people find it funny, while Simon finds it funny about a second or two later than they do.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Next.</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">First year, computer science. Provisional. Service</p><p class="line">assignment pending. No review yet.</p></div></div></div><div class="screenplay"><p class="action beat">He stops.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">And.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Sorry?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">How many do you intend.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I don't know.</p></div></div></div><div class="screenplay"><p class="action beat">Nothing comes back from the far side, and it is a longer nothing than Carter's was.</p><p class="action">MAYA is third along on the far side and does not look at him, which is a thing she is good at and has had to get used to.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">That's a permitted answer. Questions.</p></div></div></div><div class="screenplay"><p class="action">He puts a printed card face up in the middle of the table. It has been laminated and the lamination has gone soft at one corner.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">One each, from the card, and I'd ask you to keep</p><p class="line">to the card, because the card is there so that</p><p class="line">nobody has to think of anything.</p></div></div></div><div class="screenplay"><p class="direction">The card is below.</p></div><div class="artifact handout"><p class="handout-title">DOCKET · ROOM 4 · PERMITTED QUESTIONS</p><p><span>One question per person. Questions are asked in the order seated.</span></p><p><span>1. What is your placement?</span></p><p><span>2. When does it begin?</span></p><p><span>3. What is your service assignment?</span></p><p><span>4. How many do you intend?</span></p><p><span>5. Is there a nomination on your file?</span></p><p><span>6. Have you been assessed?</span></p><p><span>Questions not on this card are not permitted and are not recorded.</span></p><p><span>Outcomes are notified. Outcomes are not discussed in the room.</span></p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — CONTINUOUS</p></div><div class="screenplay"><div class="dialogue"><p class="character">ASHLEY</p><p class="paren">(to Bourne)</p><div class="lines"><p class="line">When does it begin?</p></div></div><div class="dialogue"><p class="character">BOURNE</p><div class="lines"><p class="line">June.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Mine's June.</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">That isn't on the card.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">No. Sorry.</p></div></div></div><div class="screenplay"><p class="action beat">She is not sorry and Hale does not write it down.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MADISON</p><p class="paren">(to Alex)</p><div class="lines"><p class="line">What is your placement?</p></div></div><div class="dialogue"><p class="character">ALEX</p><div class="lines"><p class="line">Transit. Northern.</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">Freight or people?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">One each.</p></div></div><div class="dialogue"><p class="character">ALYX</p><p class="paren">(to <span class="pc-name" data-case="pc" data-pc="-first">Owen</span>)</p><div class="lines"><p class="line">Is there a nomination on your file?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div></div><div class="screenplay"><p class="action beat">She writes it down. It takes her longer to write than it took him to say.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BRITTANY</p><p class="paren">(to Simon)</p><div class="lines"><p class="line">Have you been assessed?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Yes.</p></div></div><div class="dialogue"><p class="character">BRITTANY</p><div class="lines"><p class="line">Recently?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">One each, please.</p></div></div></div><div class="screenplay"><p class="action">She stops. Nobody in the room reacts to the answer, because a great many people have been assessed, and it is on the card.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><p class="paren">(to Carter)</p><div class="lines"><p class="line">What is your service assignment?</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">Glasshouses. Six till eight, Tuesdays and Fridays.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Doing what?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">One each.</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">Carrying things.</p></div></div></div><div class="screenplay"><p class="action beat">Maya writes that down and Hale lets her.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:25 P.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">The table. Five minutes each and I'll say when.</p><p class="line">You'll speak to all of them and all of them will</p><p class="line">speak to you, and everybody speaks to Wren.</p></div></div></div><div class="screenplay"><p class="action">WREN is in the chair at the end and has been in it since their first term, which was two years ago.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Go on, then.</p></div></div></div><div class="screenplay"><p class="action beat">Everybody stands up at once and it is briefly a room full of people rather than a docket.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — THE TABLE — 7:30 P.M.</p></div><div class="screenplay"><p class="action">ASHLEY, who everybody says is very nice, and who is.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Provisional. That's the hard one. My cousin was</p><p class="line">Provisional and she said the first term is the</p><p class="line">whole of 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">Is she still here?</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">She's placed. Array Six. She likes it and she</p><p class="line">isn't saying that for anybody's benefit, because</p><p class="line">she'd tell me.</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're going in June.</p></div></div><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Array Four. I've had the seat number since</p><p class="line">November.</p></div></div></div><div class="screenplay"><p class="action beat">She says the seat number.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ASHLEY</p><div class="lines"><p class="line">Anyway. If you're stuck on anything with the</p><p class="line">Senate, come and find me, and I mean that, and</p><p class="line">people don't.</p></div></div></div><div class="screenplay"><p class="action beat">She does mean it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Move.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:35 P.M.</p></div><div class="screenplay"><p class="action">MADISON has a card with her name on it and puts it on the table between them before she sits down.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">Computer science. What's your throughput?</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">My what?</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">Per week. What comes out of you per week.</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">Six programs in three weeks. That's the</p><p class="line">assignment.</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">Two a week. That's a figure. Can you say two a</p><p class="line">week to somebody?</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 who?</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">I'll find you somebody. That's what I do, that's</p><p class="line">the whole of what I do, and I'm good at it, and</p><p class="line">I'd want a percentage.</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">Of what?</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">Of whatever it turns into. Look, the quarterback's</p><p class="line">got three and he's no use to me, because he's</p><p class="line">already the product.</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">Chad?</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">You know him?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">He threw a ball at me.</p></div></div><div class="dialogue"><p class="character">MADISON</p><div class="lines"><p class="line">Right. Well. Keep that.</p></div></div></div><div class="screenplay"><p class="action">She takes the card back off the table on her way out of the chair.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Move.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:40 P.M.</p></div><div class="screenplay"><p class="action">ALYX sits down and does not do any of the front part of a conversation.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ALYX</p><div class="lines"><p class="line">You didn't have a number.</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">ALYX</p><div class="lines"><p class="line">Everybody has a number. The number's free. You say</p><p class="line">two and nobody writes anything and you go home.</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 didn't know it was going to be asked.</p></div></div><div class="dialogue"><p class="character">ALYX</p><div class="lines"><p class="line">That's worse, actually. That's the thing they were</p><p class="line">listening for.</p></div></div></div><div class="screenplay"><p class="action beat">She is not being unkind. She has been doing this for two years.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ALYX</p><div class="lines"><p class="line">Have you done Wren yet?</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">ALYX</p><div class="lines"><p class="line">You don't repeat what they say. Not to me, not to</p><p class="line">anybody in here, not after.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Is that a rule?</p></div></div><div class="dialogue"><p class="character">ALYX</p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Move.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:45 P.M.</p></div><div class="screenplay"><p class="action">BRITTANY sits down and puts her hands flat on the table and does not say anything for a moment.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BRITTANY</p><div class="lines"><p class="line">Say the standing again.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">The whole of it?</p></div></div><div class="dialogue"><p class="character">BRITTANY</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action">He says it again. She is recording it and is not pretending she isn't.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BRITTANY</p><div class="lines"><p class="line">Thanks.</p></div></div></div><div class="screenplay"><p class="action beat">That is four of the five minutes and neither of them fills the rest of it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Move.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:50 P.M.</p></div><div class="screenplay"><p class="action">MAYA sits down opposite him for the second time this week. On Tuesday she gave him a pair of goggles.</p><p class="action beat">Neither of them says anything about that.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Pending doing 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">It doesn't say. It says pending.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Mine says the benches and the benches means one</p><p class="line">bench and one flask, and I found that out by going</p><p class="line">and looking on a Sunday.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Is that allowed?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Nobody's said. That's not the same as no.</p></div></div></div><div class="screenplay"><p class="action beat">Four seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I'm going to enter nothing.</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're allowed to?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I'm going to find out.</p></div></div></div><div class="screenplay"><p class="action">It is the only time in the room that either of them says anything that is not about a placement.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Move.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — THE TABLE — CONTINUOUS</p></div><div class="screenplay"><p class="action">BOURNE has done this before and has an order he does it in.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BOURNE</p><div class="lines"><p class="line">Array Four, June, seat's allocated, and I've seen</p><p class="line">the floor plan. It's a good floor.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What makes a floor good?</p></div></div><div class="dialogue"><p class="character">BOURNE</p><div class="lines"><p class="line">Aftercall.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Whose?</p></div></div></div><div class="screenplay"><p class="action beat">He looks at her.</p></div><div class="screenplay"><div class="dialogue"><p class="character">BOURNE</p><div class="lines"><p class="line">The floor's.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Right.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:35 P.M.</p></div><div class="screenplay"><p class="action">ALEX sits down. ALYX is two chairs along and neither of them looks over.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Is that going to be a problem?</p></div></div><div class="dialogue"><p class="character">ALEX</p><div class="lines"><p class="line">It was a problem in first year. Now it's a thing</p><p class="line">people say to us at the start and we get it out of</p><p class="line">the way.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Have you got it out of the way?</p></div></div><div class="dialogue"><p class="character">ALEX</p><div class="lines"><p class="line">Yeah.</p></div></div></div><div class="screenplay"><p class="action beat">He has not got it out of the way.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ALEX</p><div class="lines"><p class="line">Transit's northern. I'll be gone in June. I say</p><p class="line">that early because it's the thing that matters and</p><p class="line">people leave it till the fourth minute.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's fair.</p></div></div><div class="dialogue"><p class="character">ALEX</p><div class="lines"><p class="line">It isn't, but it's honest.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:40 P.M.</p></div><div class="screenplay"><p class="action">CARTER has an opinion and has been waiting all evening to put it somewhere.</p></div><div class="screenplay"><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">Do you know what this used to be? Docket. It was a</p><p class="line">list of things a court was going to get to.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I know.</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">So we're the things it's going to get to.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Or we're the court.</p></div></div></div><div class="screenplay"><p class="action beat">Carter had not thought of that and does not like it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">I carry seed trays from one end of a glasshouse to</p><p class="line">the other end of a glasshouse. Six till eight.</p><p class="line">Twice a week.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And then?</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">And then somebody carries them back, and I've</p><p class="line">asked, and there's a reason.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">What is it?</p></div></div><div class="dialogue"><p class="character">CARTER</p><div class="lines"><p class="line">There's a reason.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 7:45 P.M.</p></div><div class="screenplay"><p class="action">SIMON sits down and pushes his chair in properly, which nobody else has done.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Right. So I'm on this every week.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I'd gathered.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Every week since term started. Nobody's got a</p><p class="line">reason for it. I asked at the office and they said</p><p class="line">unattached students in good standing are placed on</p><p class="line">the docket.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">And you're unattached and in good standing.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Yes. So it's working.</p></div></div></div><div class="screenplay"><p class="action beat">He says it cheerfully and it is the correct analysis.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Doesn't that get old?</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">It's a warm room and there's eleven people in it</p><p class="line">and nobody's on their Help. That's Thursdays. I've</p><p class="line">got worse Thursdays.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's bleak, Simon.</p></div></div><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">No, I mean it. Where else does that happen.</p></div></div></div><div class="screenplay"><p class="action beat">Maya does not have an answer to that and it stays with her into Act II.</p></div><div class="screenplay"><div class="dialogue"><p class="character">SIMON</p><div class="lines"><p class="line">Anyway. Intends one. That's the good bit.</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Move.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — THE END OF THE TABLE — 7:55 P.M.</p></div><div class="screenplay"><p class="action">They go to Wren one at a time and the rest of the room gets quieter each time somebody does.</p><p class="action">Nobody comes back from that end of the table saying anything. Bourne comes back and sits down and looks at the card for a while.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> is last. Nobody told him there was an order, and there is one.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Sit down. You get one that isn't on the card.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">One what?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Question. From me. There's no column for me so</p><p class="line">there's nothing for me to keep to.</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">Okay.</p></div></div></div><div class="screenplay"><p class="action beat">Wren takes a moment over it, which is not for effect.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">What do you do when nobody's asked you to do</p><p class="line">anything?</p></div></div></div><div class="screenplay"><p class="action beat">It is the first question anybody has asked him since he got here that he cannot answer off a form.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I don't know yet.</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">No. You've been here five days.</p></div></div></div><div class="screenplay"><p class="action">They sit with it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">All right. Somebody's going to leave you something</p><p class="line">that was meant for somebody else, and you're going</p><p class="line">to keep 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">Is that it?</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">That's it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">How do you—</p></div></div><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">I don't. You don't repeat it, and I don't say it</p><p class="line">twice, and those are the two halves of the same</p><p class="line">thing.</p></div></div></div><div class="screenplay"><p class="action">They look past him at the room.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">Go on. He'll want the preferences in before eight.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. ROOM 4 — 8:00 P.M.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Preferences. In your own time, privately, and</p><p class="line">nothing said out loud.</p></div></div></div><div class="screenplay"><p class="action">Eleven people stop looking at each other at the same moment. It is the quietest the room has been and it goes on for four minutes.</p><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> enters nothing, unsure whether entering nothing is permitted. It was accepted, as it is permitted.</p><p class="action">Across the table Maya finishes before he does and puts her hands in her lap.</p><p class="action">It is somewhere in the middle of the four minutes that he notices the smell.</p><p class="action">It is not in the room. It is like a coin held too long, and then like a room that has been shut, and then it is not like anything and it is very strong.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">That's the docket. Outcomes are notified.</p></div></div></div><div class="screenplay"><p class="action">Chairs go back.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Provisional.</p></div></div></div><div class="screenplay"><p class="action beat">He does not answer.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Son.</p></div></div></div><div class="screenplay"><p class="action beat">Three seconds.</p><p class="action">Then he is back, and Hale is halfway round the table, and there is blood on the card.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Sit down. Head forward, not back.</p></div></div></div><div class="screenplay"><p class="action">He has tissues in the lectern. He does not go and look for them. He knows where they are.</p><p class="action">WREN puts one in his hand on the way past.</p></div><div class="screenplay"><div class="dialogue"><p class="character">WREN</p><div class="lines"><p class="line">It happens in this room.</p></div></div></div><div class="screenplay"><p class="action">They go. Hale taps the tablet once, at the top, where the standing is, and not at the bottom, where the outcome is.</p><p class="action">MAYA is at the door with her coat over her arm and stops there for a moment.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Do you want somebody to stay?</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">All right.</p></div></div></div><div class="screenplay"><p class="action beat">She goes, and she counts the stairs on the way up, and does not know she is doing it until she gets to the top.</p></div><div class="screenplay"><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">Sit there till it stops. Don't go up the stairs</p><p class="line">till it stops.</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">Does it happen a lot?</p></div></div><div class="dialogue"><p class="character">HALE</p><div class="lines"><p class="line">It's a dry room.</p></div></div></div><div class="screenplay"><p class="action">It stops on the landing. The corridor at the top of the stairs smells of nothing at all, and he stands in it for a moment working out whether that is a thing he has noticed.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div><div class="artifact email"><table class="email-head"><tbody><tr><td>From:</td><td><span>Office of Student Life <no-reply@newcarthage.edu></span></td></tr><tr><td>To:</td><td><span><span class="pc-name" data-case="pc" data-pc="-full">Owen Mercer</span> <<span class="pc-name" data-case="pc" data-pc="-first">Owen</span>@newcarthage.edu></span></td></tr><tr><td>Date:</td><td>Friday, 4:00 AM</td></tr><tr><td>Subject:</td><td><strong><span>Docket — outcome</span></strong></td></tr></tbody></table><div class="email-body"><p><span>Attendance recorded.</span></p><p><span>No preference was entered. No outcome is indicated on this occasion.</span></p><p><span>You remain on the docket. Your next assignment will be notified.</span></p><p><span>Docket attendance is not a component of review. Docket attendance is recorded.</span></p><p><span>Please arrive presentable.</span></p></div></div><div class="screenplay"><p class="action">Nothing in it mentions the card, or the tissues, or the four minutes. He reads it twice and there is one button and he presses it.</p></div><div class="artifact discord"><div class="discord-head"><span class="discord-server">FEDORA SOCIETY</span><span class="discord-channel">#records</span></div><div class="discord-body"><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">11:49 PM</span></p><div class="discord-text"><p><span>sorry i was missing, i had docket...</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">11:49 PM</span></p><div class="discord-text"><p><span>I said I would not name you.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">11:52 PM</span></p><div class="discord-text"><p><span>the docket is a Roman institution actually</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">11:53 PM</span></p><div class="discord-text"><p><span>it isnt</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">11:53 PM</span></p><div class="discord-text"><p><span>the word is</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">11:58 PM</span></p><div class="discord-text"><p><span>im on it again next week</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Derek</span></span><span class="discord-at">11:59 PM</span></p><div class="discord-text"><p><span>youre on it every week</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">12:01 AM</span></p><div class="discord-text"><p><span>yeah</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Miles</span></span><span class="discord-at">12:02 AM</span></p><div class="discord-text"><p><span>thats not what the docket is</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">12:04 AM</span></p><div class="discord-text"><p><span>i know what the docket is miles</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">12:05 AM</span></p><div class="discord-text"><p><span>the new guy was on it. he did alright</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Victor</span></span><span class="discord-role">PRESIDENT</span><span class="discord-at">12:31 AM</span></p><div class="discord-text"><p><span>He is promising.</span></p></div></div><div class="discord-msg"><p class="discord-meta"><span class="discord-who"><span>Simon</span></span><span class="discord-at">12:33 AM</span></p><div class="discord-text"><p><span>i meant like. as a person</span></p></div></div></div></div><div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>/* ---------------------------------------------------------------
TODO.CLJS
A list, and the only one in this book that a person can take a thing
off. The cross is not styled to look dangerous, because it is not.
--------------------------------------------------------------- */
.widget-todo { max-width: 34rem; }
.todo-list {
margin: 0.4rem 0 0.2rem 0;
max-height: calc(var(--todo-rows, 8) * 1.85rem);
overflow-y: auto;
}
.todo-row {
display: grid;
grid-template-columns: 1.1rem minmax(0, 1fr) 1.4rem;
align-items: center;
gap: 0.55rem;
padding: 0.18rem 0.2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.18);
font-size: 0.86rem;
line-height: 1.5;
cursor: pointer;
}
.todo-row:last-child { border-bottom: none; }
.todo-row:hover { background: rgba(var(--ink-rgb), 0.06); }
.todo-box {
width: 0.95rem;
height: 0.95rem;
margin: 0;
accent-color: var(--violet);
cursor: pointer;
}
.todo-what {
min-width: 0;
overflow-wrap: anywhere;
}
/* ticked, and still on the list, because ticking a thing is not the
same as taking it off and the count knows the difference */
.todo-done .todo-what {
opacity: 0.5;
text-decoration: line-through;
}
.todo-del {
font: inherit;
font-size: 0.95rem;
line-height: 1;
padding: 0 0.2rem;
border: none;
background: none;
color: var(--ink);
opacity: 0.25;
cursor: pointer;
}
.todo-row:hover .todo-del { opacity: 0.6; }
.todo-del:hover { opacity: 1; }
.todo-input {
font: inherit;
font-size: 0.86rem;
flex: 1;
min-width: 8rem;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
color: var(--ink);
}
.todo-note {
margin: 0.45rem 0 0 0 !important;
font-size: 0.78rem;
opacity: 0.6;
min-height: 1.1em;
}
/* the still version, for a chapter that shows a list without running
one. no checkbox, no cross, nothing to press. */
.widget-todo .todo-tick {
width: 1.1rem;
text-align: center;
opacity: 0.55;
}</style><script type="application/x-scittle">(ns fedoras.cs-class.todo.todo-ui
"A list of things to do that survives being closed.
KEYWORD KEYS. The items used to be written with string keys, from
back when storage went through JSON by hand. The reader keywordizes
on the way out, so `(get item \"done\")` was nil every single time: no
row ever showed as ticked, and ticking one added a second key beside
the real one. There is no migration here because there does not need
to be — old items come back keywordized already, and it was only ever
the code that was looking for the wrong thing.
NO innerHTML IN THIS FILE. Every other widget builds its markup as a
string, because every other widget's content is ours. This one's
content is whatever the reader typed, and a list that renders what it
is given is a list that will run it. The rows are built as elements
and the text goes in through textContent, which cannot execute
anything.
ON THE RECORD. `:todo` is one of the few things that travels between
chapters, because it is small and because a to-do list that forgot
itself between chapters would be arguing against the scene it is in."
(:require [clojure.string :as str]))
(def settings (atom {:seed [] :rows 8 :sink-done false :placeholder "add a thing"}))
;; ---------------------------------------------------------------------
;; The list
;; ---------------------------------------------------------------------
(defn items [] (vec (or (fedoras.ui.widget-ui/kept :todo) [])))
(defn- put! [v]
(fedoras.ui.widget-ui/keep! :todo-seq
(inc (or (fedoras.ui.widget-ui/kept :todo-seq) 0)))
(fedoras.ui.widget-ui/keep! :todo (vec v)))
(defn add!
"Anything the reader puts on the list is theirs, and putting one thing
on it is a deed. Nothing says so."
[what]
(when-not (str/blank? what)
(fedoras.ui.widget-ui/did! :kept)
(put! (conj (items) {:what (str/trim what) :done false}))))
(defn toggle! [i]
(put! (update (items) i (fn [it] (update it :done not)))))
(defn remove-at! [i]
(let [v (items)]
(when (< -1 i (count v))
(put! (into (subvec v 0 i) (subvec v (inc i)))))))
(defn- ordered
"What order to show them in. The index goes with each one, because
everything that acts on a row acts on where it is in the file rather
than where it is on the screen."
[v]
(let [pairs (map-indexed vector v)]
(if (:sink-done @settings)
(concat (remove (fn [[_ it]] (:done it)) pairs)
(filter (fn [[_ it]] (:done it)) pairs))
pairs)))
;; ---------------------------------------------------------------------
;; Rows
;;
;; Built rather than written, and the reader's own words go in through
;; textContent. `row-node` is above `paint!` because it has to be — the
;; page evaluates this file from the top and a function cannot call one
;; that has not happened yet.
;; ---------------------------------------------------------------------
(defn- row-node [i it]
(let [row (.createElement js/document "div")
box (.createElement js/document "input")
text (.createElement js/document "span")
del (.createElement js/document "button")]
(set! (.-className row) (str "todo-row" (when (:done it) " todo-done")))
(.setAttribute row "data-i" (str i))
(set! (.-type box) "checkbox")
(set! (.-className box) "todo-box")
(set! (.-checked box) (boolean (:done it)))
(set! (.-className text) "todo-what")
(set! (.-textContent text) (str (:what it)))
(set! (.-className del) "todo-del")
(set! (.-textContent del) "\u00d7")
(.setAttribute del "title" "take it off the list")
(doto row (.appendChild box) (.appendChild text) (.appendChild del))))
(defn paint! [node]
(let [v (items)
left (count (remove :done v))
list (fedoras.ui.widget-ui/q! node ".todo-list")]
(when list
(set! (.-innerHTML list) "")
(doseq [[i it] (ordered v)]
(.appendChild list (row-node i it))))
(.setProperty (.-style node) "--todo-rows" (str (:rows @settings)))
(fedoras.ui.widget-ui/state!
node
(cond
(zero? (count v)) "nothing on it"
(zero? left) (str "all " (count v) " done")
:else (str left " of " (count v) " outstanding")))
(fedoras.reader/text!
node ".todo-note"
(if (zero? left) "" (str left " still on it.")))
(when-let [field (fedoras.reader/q node ".todo-input")]
(set! (.-placeholder field) (:placeholder @settings)))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- add-from-field! [node]
(when-let [field (fedoras.reader/q node ".todo-input")]
(add! (.-value field))
(set! (.-value field) "")
(.focus field)
(paint! node)))
(defn- wire-add! [node]
(fedoras.reader/on-click! node ".todo-add" (fn [_] (add-from-field! node)))
(when-let [field (fedoras.ui.widget-ui/q! node ".todo-input")]
(set! (.-onkeydown field)
(fn [e] (when (= "Enter" (.-key e)) (add-from-field! node))))))
(defn- wire-list!
"One handler on the list rather than two on every row, so a row is a
thing that was drawn and not a thing that was wired."
[node]
(when-let [list (fedoras.ui.widget-ui/q! node ".todo-list")]
(set! (.-onclick list)
(fn [e]
(when-let [row (.closest (.-target e) ".todo-row")]
(let [i (js/parseInt (.getAttribute row "data-i") 10)
hit (.-target e)]
(cond
(.matches hit ".todo-del") (remove-at! i)
(.matches hit ".todo-box") (toggle! i)
:else (toggle! i))
(paint! node)))))))
(defn- seed!
"Written once, the first time a reader ever opens the book, and never
again. A reader who has cleared the whole list gets it back, because
an empty list and a list that has never existed look the same from
here and only one of those wants filling."
[]
(when (empty? (items))
(put! (mapv (fn [w] {:what w :done false}) (:seed @settings)))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current
(select-keys setting [:seed :rows :sink-done :placeholder]))))
(seed!)
(paint! node))))
(defn setup! [node]
(let [id (.-id node)]
(fedoras.ui.widget-ui/wire! node "add" wire-add!)
(fedoras.ui.widget-ui/wire! node "list" wire-list!)
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::todo (or id "todo") setup! paint!))</script><div class="widget widget-todo" id="todo"><div class="roll-head"><span class="roll-label">THINGS TO DO</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">todo_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="14" spellcheck="false">;; ---------------------------------------------------------------------
;; TODO_SETTINGS.CLJS — what was on it to begin with.
;;
;; Change a line and press Run. Break it and the line underneath will
;; tell you what it thought you meant. Nothing you do here can lose the
;; original: `Put it back` is the file as it shipped.
;;
;; THE SEED ONLY LANDS ON AN EMPTY LIST. Editing it will not overwrite
;; anything you have written, and running this with items on the list
;; does nothing to them. Clear the list and press Run and you will get
;; whatever is in here now, which is the only way to get his back once
;; you have taken it off.
;; ---------------------------------------------------------------------
{;; What was on it in September. Four of these are his and the last one
;; is the one that is never marked done.
:seed ["hand in set two"
"ask Hollis what the buckets are for"
"vapour tables — one copy, Beckwith 2"
"tell Maya about the fourth one"
"page 219"]
;; How many rows before it starts scrolling rather than growing. The
;; widget does not get taller than this and the list does not get
;; shorter, which is the arrangement most lists end up in.
:rows 8
;; Ticked things sink to the bottom. Off by default, because a list
;; where the done things stay where you put them is a list you can
;; still read as a record of the order you did them in.
;;
;; Turn it on and it becomes a much better to-do list and a much worse
;; account of a term.
:sink-done false
;; What the empty box says.
:placeholder "add a thing"}</textarea><div class="live-bar"><button class="roll-go widget-settings-run">Run</button><button class="roll-rewrite widget-settings-reset">Put it back</button></div><p class="widget-settings-said">Press Run.</p></div><div class="widget-body"><div class="todo-list"></div><div class="live-bar"><input class="todo-input" spellcheck="false" type="text" /><button class="roll-go todo-add">Add</button></div><p class="todo-note"></p></div><p class="roll-foot">It has to still be there on Tuesday.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.todo.todo-ui/mount!))] (mount {:id "todo"}) (js/console.error "fedoras: fedoras.cs-class.todo.todo-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div></div>
```