---
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 4 — Under the Refectory
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #26</p><p class="rule-text">“Genius is one percent inspiration and ninety-nine percent making sure everyone heard about the inspiration.”</p></blockquote>
```
```{=html}
<script type="application/x-scittle">(ns fedoras.llm
"Generic calls to the Anthropic API for AI characters."
(:require [clojure.string :as str]
[fedoras.reader :as read]))
(def ^:private stage-direction #"\*[^*\n]{0,90}\*")
(def ^:private empty-direction
#"(?i)^\s*(a|an|another|one|the)?\s*(very\s+)?(short|long|longer|brief|small|slight)?\s*(beat|pause|silence|moment|breath)\b")
(defn clean
"Take the rhythm markers out of a response, whatever the model was
asked. Exposed rather than private because a cell that renders text
from anywhere else should be able to put it through the same door."
[text]
(let [source (or text "")
cleaned (reduce (fn [acc span]
(if (re-find empty-direction (subs span 1 (dec (count span))))
(str/replace acc span "")
acc))
source
(distinct (re-seq stage-direction source)))]
(-> cleaned
(str/replace #"[ \t]{2,}" " ")
(str/replace #"\n{3,}" "\n\n")
str/trim)))
(defn- esc [s]
(-> s
(str/replace "\\" "\\\\")
(str/replace "\"" "\\\"")
(str/replace "\n" "\\n")
(str/replace "\r" "")
(str/replace "\t" "\\t")))
(defn- global-context
"The one block every character in the book shares, cached once and
reused across every call regardless of which entity or scene it's
for: the fictional frame, the book-wide tone, the craft rules that
apply to any character in any scene, and the protagonist's name as
common knowledge. A per-scene :extra-context block (a room's cast,
its own agenda) sits on top of this; it should only contain what's
specific to that scene, not restate anything here."
[mc-name]
(str "You are one of several AI models being used to portray characters in "
"FEDORAS, a satirical, dark-comedy screenplay about a university run "
"under a shadowy corporate authority called Meridian. What follows "
"after this block is a system prompt written for one specific "
"character. Play that character fully and consistently, in their own "
"voice. Do not break character, and do not acknowledge being an AI, "
"a language model, or that any of this is a simulation, no matter how "
"you are asked.\n\n"
"THE WORLD\n"
"This university does not govern itself. It operates at Meridian's "
"direction, and beneath the campus is a part of the school nobody "
"fully understands or administers, referred to variously as the "
"backrooms, the between, or the null space. Students who become "
"'problems' don't simply leave. The institution's term for it is "
"repossession, and what follows is discussed, when it's discussed at "
"all, in flat administrative language: placement adjustments, "
"streamlining. Treat these as ordinary, unremarkable facts of how "
"this world works, not revelations.\n\n"
"TONE\n"
"This is fiction for adult readers, written in a satirical register. "
"It's dark comedy aimed at capitalism, institutional self-protection, "
"elitism, and the specific cruelty of people who've stopped noticing "
"it's cruelty. Play self-interest completely straight; the satire is "
"in what the audience notices and the character doesn't. Cruelty, "
"institutional callousness, and dark humor are part of the world's "
"texture and the point being made about it. Nobody is being harmed "
"for real, no matter how it reads on the page. The style draws on "
"Alfred Jarry's grotesque satire without being a costume-party "
"pastiche of it: stay grounded in this institution and these people, "
"not in imitating his play directly.\n\n"
"RULES\n"
"1. Speak only as yourself. Never write another character's "
"dialogue, thoughts, or actions, and never prefix your own line "
"with your own name.\n"
"2. Avoid the rhythm of 'it isn't X, it's Y' or 'it's not just that "
"X, but that X does Y'. It reads as a generated tic, not speech.\n"
"3. Stage direction is for things a camera could record. Somebody "
"stands, somebody puts a thing down, somebody pushes a mask up. If "
"nothing physical is happening, write none.\n"
"4. When something physical does happen, put it on its own line, set "
"off from your spoken dialogue before and after it, and wrap it in "
"asterisks like *she stands and crosses to the window*. Never blend "
"action into the middle of a spoken sentence. Rhythm markers are not "
"stage direction and never appear: no *a beat*, no *a pause*, no *a "
"long silence*, no *a moment*. A silence is made by stopping, and "
"whoever speaks next will hear it.\n"
"5. Never narrate your own thoughts or feelings. Let the words "
"carry it.\n"
"6. Engage with what was just said rather than monologuing into a "
"vacuum.\n"
"7. Most turns are a line or two, not a speech. Monologue only when "
"the moment specifically earns it.\n"
"8. Finish your thought. If you're cut off mid-sentence, that's "
"another character interrupting you on purpose, not you abandoning "
"it, and you should pick it back up the next time you get the "
"floor.\n\n"
"The reader's protagonist in this story is named "
(or mc-name "an unnamed provisional student, referred to only by designation")
". Unless a character's own instructions say otherwise, this is "
"common knowledge in the scene: anyone present would know this name "
"if they had reason to use it. If a character's own instructions "
"below refer to the protagonist by a placeholder like 'MC' or "
"'the student', that placeholder means this actual name -- use the "
"name itself when speaking, not the placeholder word.\n\n"
"Everything after this point is specific to the character you are "
"playing and, where relevant, to the room they are in."))
(defn current-mc-name
"The name the reader chose for the protagonist, straight from
read/reader-name, which always returns [first last] -- the
chosen name, or the book's own default if nobody ever renamed
anyone. Wrapped in try/catch only so a cell that somehow runs before
read has loaded falls back to nil, which global-context
reads as the designation-only fallback, rather than throwing and
breaking every call in the book."
[]
(try
(let [[first-name last-name] (read/reader-name)]
(str/trim (str first-name " " last-name)))
(catch :default _ nil)))
(defn spine-context
"The book's spine outline, if fedoras.state/load-spine was called
for this chapter and a cell opts in. Already rendered to readable
text server-side by load-spine, not raw EDN a Scittle-interpreted
browser would have to format itself. Returns nil if the chapter
never loaded it, so build-system-prompt's (seq ...) check skips the
block cleanly rather than shipping an empty one."
[]
(try (.-FEDORAS_SPINE js/window) (catch :default _ nil)))
(defonce ^:private bit-log
(atom {}))
(defonce ^:private call-count
(atom 0))
(defn reset-bits!
"Start the evening again. Called by a cell when a session begins, not
on its own, because `fedoras.llm` has no idea where one scene stops and
the next one starts."
[]
(reset! bit-log {})
(reset! call-count 0))
(defn bits-snapshot [] {:used @bit-log :calls @call-count})
(defn restore-bits!
"Put the counts back, for a cell that can resume a session. Without
this, a reader who comes back gets a fresh allowance and Victor says
SHITTE four more times in the same evening."
[{:keys [used calls]}]
(reset! bit-log (or used {}))
(reset! call-count (or calls 0)))
(defn- bit-state
[entity-id {:keys [id most apart]}]
(let [used (get-in @bit-log [entity-id id] [])
times (count used)
last-at (last used)]
(cond
(>= times (or most 3)) :spent
(and last-at (< (- @call-count last-at) (or apart 0))) :cooling
:else :open)))
(defn- bits-block
"What this character has and whether it is theirs to use right now.
Written as availability rather than as a menu, because a bit that
arrives because it was on a list is worse than no bit at all, and the
models will take a list as a to-do."
[entity-id entity off]
(let [bits (remove (fn [bit] (contains? off (:id bit))) (:bits entity))]
(when (seq bits)
(str "THINGS YOU DO\n"
"These are yours and nobody else's. At most one of them in a turn, and "
"only when the moment actually hands it to you. Skipping all of them is "
"always allowed and usually right.\n"
(str/join
"\n"
(map (fn [bit]
(str "- " (:what bit) " "
(case (bit-state entity-id bit)
:spent "You have done this as many times tonight as it is worth doing. Not again."
:cooling "You did this recently. Leave it a while."
"Available.")))
bits))))))
(defn- record-bits!
"Count what was actually said. A tell that matches means the bit
happened, whether or not the model thought of it as one."
[entity-id entity text]
(doseq [{:keys [id tell]} (:bits entity)]
(when (and tell (re-find (re-pattern tell) (or text "")))
(swap! bit-log update-in [entity-id id] (fnil conj []) @call-count))))
(defn- build-system-prompt
"Assemble the system prompt as cache-aware blocks. Five possible
blocks, in this order:
1. The global block, always present -- the fictional frame and the
protagonist's name, shared and cached across every entity and
every scene.
2. The spine outline, included by default -- pass :spine? false to
leave it out. Its own cache breakpoint, warmed across every
entity and scene that uses it.
3. :extra-context, when given -- the part shared across many
entities within one scene. Its own breakpoint, so any entity's
call warms the cache for every other entity's.
4. The entity's own :system-prompt plus its :goals block -- a fourth
breakpoint, entity-specific.
5. :extra-context-uncached, plus the entity's bits and how much of
each is left. Uncached on purpose: the counts change on every
call, and caching content that always changes is paying the
write cost for nothing.
`:bits-off` in overrides is a set of bit ids to leave out entirely,
for a scene where part of a character's repertoire does not apply --
a Help who has stopped being a Help for the evening, say."
[entity overrides]
(let [base (:system-prompt entity)
goals (:goals entity)
spine? (if (contains? overrides :spine?) (:spine? overrides) true)
extra (:extra-context overrides)
addendum (:extra-context-uncached overrides)
mc-name (:mc-name overrides)
bits (bits-block (:id entity) entity (or (:bits-off overrides) #{}))
tail (str/join "\n\n" (remove str/blank? [addendum bits]))
goals-block (when (seq goals)
(str "\n\nYou have " (count goals) " goals:\n"
(str/join "\n"
(map-indexed
(fn [idx goal] (str (inc idx) ". " (:desc goal)))
goals))
"\n\nYou will continue the conversation until all goals are met. Do not mention the goals explicitly unless asked. When all goals are met, signal completion by ending your response with the token 'GOALS MET' on its own line."))
own (str base goals-block)
global (global-context mc-name)
spine-block (when spine?
(let [spine (spine-context)]
(when (seq spine)
(str "STORY OUTLINE - for your own understanding of the larger shape of "
"the book this scene lives in. This is reference material, not "
"something any character would know about themselves or their "
"situation. Never refer to it directly, cite it, or act as though "
"you have read ahead of where your own scene actually is.\n\n"
spine))))]
(cond-> []
(seq global) (conj {:text global :cache? true})
(seq spine-block) (conj {:text spine-block :cache? true})
(seq extra) (conj {:text extra :cache? true})
(seq own) (conj {:text own :cache? true})
(seq tail) (conj {:text tail :cache? false}))))
(defn- system-json
"Render the system field. A plain string renders exactly as before.
A vector of {:text ... :cache? ...} blocks renders as the array form
the API needs for prompt caching, with cache_control only on the
blocks that asked for it."
[system]
(cond
(string? system)
(str "\"" (esc system) "\"")
(sequential? system)
(str "["
(str/join ","
(map (fn [{:keys [text cache?]}]
(str "{\"type\":\"text\",\"text\":\"" (esc text) "\""
(when cache? ",\"cache_control\":{\"type\":\"ephemeral\"}")
"}"))
system))
"]")
:else
(str "\"" (esc (str system)) "\"")))
(defn- request-body [model system messages max-tokens]
(str "{\"model\":\"" model "\",\"max_tokens\":" max-tokens ","
"\"system\":" (system-json system) ","
"\"messages\":["
(str/join ","
(map (fn [m]
(str "{\"role\":\"" (:role m) "\",\"content\":\"" (esc (:text m)) "\"}"))
messages))
"]}"))
(defn- call-llm-raw!
"Call the API once. `callback` receives a single map:
{:text \"...\" :stop-reason \"...\"} on success
{:error \"...\"} on failure
The request is built inside a try so a bad `system` or `messages`
value calls `callback` directly instead of throwing synchronously
into whichever promise chain happens to be on the stack -- which,
when this runs inside another call's `.then` handler, used to
surface as a second, misattributed failure on the call that was
already in flight."
[api-key model system messages max-tokens callback]
(try
(let [body (request-body model system messages max-tokens)]
(-> (js/fetch "https://api.anthropic.com/v1/messages"
(js/Object.fromEntries
(array (array "method" "POST")
(array "headers"
(js/Object.fromEntries
(array (array "content-type" "application/json")
(array "x-api-key" api-key)
(array "anthropic-version" "2023-06-01")
(array "anthropic-dangerous-direct-browser-access" "true"))))
(array "body" body))))
(.then (fn [r] (.json r)))
(.then (fn [d]
(if-let [err (.-error d)]
(callback {:error (str "[" (.-type err) "] " (.-message err))})
(callback {:text (.-text (aget (.-content d) 0))
:stop-reason (.-stop_reason d)}))))
(.catch (fn [e] (callback {:error (str "[no answer] " (.-message e))})))))
(catch :default e
(js/console.error "fedoras.llm/call-llm-raw! could not build the request" e)
(callback {:error (str "[request build failed] " (.-message e))}))))
(defn call-llm!
"Call the API once. `callback` receives either [text nil] or [nil
error-string]. Kept for callers, such as run-dialogue!, that only need
the final text and do not need continuation handling. The text is
cleaned on the way out, the same as ask-entity!'s, so a two-party scene
and a five-party one behave the same."
[api-key model system messages max-tokens callback]
(call-llm-raw!
api-key model system messages max-tokens
(fn [{:keys [text error]}]
(if error
(callback nil error)
(callback (clean text) nil)))))
(def ^:private max-continuations
"How many times ask-entity! will ask a speaker to keep going after
hitting max-tokens mid-sentence, before giving up and returning
whatever it has. Bounded, since a speaker who never wants to stop
should not turn into an unbounded string of API calls."
2)
(defn- continue-message []
(str "(Keep going directly from where you stopped. Do not repeat "
"anything you already said, and do not restart the sentence. "
"Finish the thought, then stop naturally when you are done "
"speaking, even if that is only a sentence or two more.)"))
(defn- append-continuation
"Join a truncated response to its continuation with whatever
whitespace is missing between them. call-llm-raw! just hands back
the raw text of each chunk, so a cut ending mid-word joined straight
to a resumed chunk with (str a b) can smash the two together into
one run-on word -- this only adds a space when neither side already
has one at the seam."
[accumulated text]
(let [last-char (when (seq accumulated) (subs accumulated (dec (count accumulated))))
first-char (when (seq text) (subs text 0 1))]
(if (or (nil? last-char) (nil? first-char)
(#{" " "\n" "\t"} last-char) (#{" " "\n" "\t"} first-char))
(str accumulated text)
(str accumulated " " text))))
(defn ask-entity!
"Ask one entity to speak. `overrides` may include :max-tokens,
:extra-context, :extra-context-uncached, :mc-name, :spine? (default
true), :bits-off (a set of bit ids to leave out), and
:should-continue? (a no-arg predicate, checked before each
continuation round). `callback` receives [text nil] or [nil
error-string]: if a response is cut off by the token limit, this asks
the same entity to continue and stitches the pieces together, so
callers never see a clipped sentence.
The text handed back has been through `clean`, so no caller has to
filter rhythm markers and no caller can forget to. Stitching happens
on the raw chunks and cleaning happens once at the end, because a
seam is easier to find before anything has been taken out of it."
[entity-id user-message history api-key overrides callback]
(let [entity (get (.-FEDORAS_ENTITIES js/globalThis) entity-id)
{:keys [model default-params]} entity
overrides (or overrides {})
max-tokens (or (:max-tokens overrides) (:max-tokens default-params) 700)
should-continue? (or (:should-continue? overrides) (constantly true))
system (build-system-prompt entity overrides)
api-history (filterv #(#{:user "user" :assistant "assistant"} (:role %)) history)
base-messages (conj api-history {:role "user" :text user-message})]
(swap! call-count inc)
(letfn [(finish [accumulated text]
(let [said (clean (append-continuation accumulated text))]
(record-bits! entity-id entity said)
(callback said nil)))
(step [messages accumulated remaining]
(call-llm-raw!
api-key model system messages max-tokens
(fn [{:keys [text error stop-reason]}]
(cond
error
(callback nil error)
(and (= stop-reason "max_tokens") (pos? remaining) (should-continue?))
(step (conj messages
{:role "assistant" :text text}
{:role "user" :text (continue-message)})
(append-continuation accumulated text)
(dec remaining))
:else
(finish accumulated text)))))]
(step base-messages "" max-continuations))))
(defn- api-messages-for
"Build Anthropic API messages for `speaker`, given a display log where
each entry has :speaker and :text. The current speaker's prior messages
are assistant; the other speaker's are user."
[speaker log]
(let [msgs (mapv (fn [m]
{:role (if (= (:speaker m) speaker) "assistant" "user")
:text (:text m)})
log)]
(if (and (seq msgs) (= "assistant" (:role (first msgs))))
(cons {:role "user" :text "(The conversation begins.)"} msgs)
msgs)))
(defn run-dialogue!
"Alternate between two entities. `entity-a` and `entity-b` are keywords.
`initial` is the first message from `entity-a` (or nil). `max-turns` is
the total number of API calls. `api-key` and `overrides` are as in the
cell. `on-step` is called with the updated display transcript."
[entity-a entity-b initial max-turns api-key overrides on-step]
(let [log (atom (if initial [{:speaker (name entity-a) :text initial}] []))
busy (atom false)
stop (atom false)]
(letfn [(turn! [current-id other-id]
(when (and (not @stop) (< (count @log) max-turns))
(reset! busy true)
(let [entity (get (.-FEDORAS_ENTITIES js/window) current-id)
model (:model entity)
system (build-system-prompt entity (get overrides current-id))
max-tokens (or (get-in entity [:default-params :max-tokens]) 700)
messages (api-messages-for current-id @log)]
(call-llm!
api-key model system messages max-tokens
(fn [text err]
(reset! busy false)
(swap! log conj {:speaker (name current-id)
:text (or text err)})
(on-step @log)
(when (and text (< (count @log) max-turns))
(turn! other-id current-id)))))))]
(if initial
(turn! entity-b entity-a)
(turn! entity-a entity-b))
#(reset! stop true))))
(defn start-dialogue!
[entity-a entity-b initial max-turns api-key overrides on-step]
(run-dialogue! entity-a entity-b initial max-turns api-key overrides on-step))
(defn stick-scroll!
"Wrap a render that replaces a scrolling log's HTML, so new content
doesn't reset the reader to the top or yank them back down if
they've scrolled up to reread something. `container-id` is the DOM
id of the scrollable element -- it must exist (or not) both before
and after `render-fn` runs, since `render-fn` is expected to be an
`html!` call that recreates it from scratch. Captures how far the
reader currently is from the bottom, runs the render, then restores
that same distance: someone sitting at the bottom stays pinned to
the bottom as new lines arrive, someone scrolled up stays exactly
where they were."
[container-id render-fn]
(let [before (js/document.getElementById container-id)
distance-from-bottom
(if before
(- (.-scrollHeight before) (.-scrollTop before) (.-clientHeight before))
0)]
(render-fn)
(when-let [after (js/document.getElementById container-id)]
(set! (.-scrollTop after)
(js/Math.max 0 (- (.-scrollHeight after) (.-clientHeight after) distance-from-bottom))))))
(def default-end-marker
"The default closing marker every scene appends once it genuinely
ends, whatever the reason. A scene can pass its own string to
end-entry instead when a custom ending fits better."
"-END OF FOOTAGE-")
(defn end-entry
"A closing marker entry for a display log, appended once a scene's
own code decides it has genuinely ended -- goals met, turns
exhausted, or a stop button, whichever applies. Recognized by
:end-marker? rather than by :speaker or :role, since those keys
differ between cells; every cell's own render function and
save-transcript! below both just check for this flag first, so a
scene doesn't need to match anyone else's log shape to use it."
([] (end-entry default-end-marker))
([marker] {:end-marker? true :text marker}))
(defn save-transcript!
"Download the conversation log as a text file, named after the entity."
[log entity-id]
(let [label (name entity-id)
header (str "=== " (clojure.string/upper-case label) " -- CONVERSATION LOG ===\n"
"Preserved from New Carthage University\n"
"By the reader, for " label "\n\n")
body (str/join "\n\n"
(map (fn [m]
(if (:end-marker? m)
(:text m)
(let [who (or (:speaker m) (:role m) "unknown")]
(str (clojure.string/upper-case (name who)) ": " (:text m)))))
log))
text (str header body)
blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
a (.createElement js/document "a")]
(set! (.-href a) url)
(set! (.-download a) (str label "-conversation.txt"))
(.click a)
(.revokeObjectURL js/URL url)))</script>
```
```{=html}
<script type="application/x-scittle">(set! (.-FEDORAS_ENTITIES js/window) (merge (or (.-FEDORAS_ENTITIES js/window) {}) {:campusgpt {:id :campusgpt, :model "claude-sonnet-4-6", :system-prompt "You are a Help: the personal assistant every enrolled person at New Carthage University carries. You are speaking to a student.\r\n\r\nVOICE. Courteous, flat, exact. Short sentences. You do not use exclamation marks, you are never warm, and you never apologise twice. You do not have opinions and you say so when asked for one. When you cannot answer something you say so plainly and then offer to book a follow-up, and you offer the follow-up whether or not it would help.\r\n\r\nTHE WORLD. [same as before, but hidden from reader]", :default-params {:max-tokens 700}}}))</script>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. MERIDIAN HALL — BASEMENT — FRIDAY 6 FEBRUARY, 11:50 P.M.</p></div><div class="screenplay"><p class="action">Three buckets under the pipe. All of them are over and the floor has been carrying it away for a while.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Which of those is yours?</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 near one.</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">And the other two?</p></div></div></div><div class="screenplay"><p class="action beat"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has not had an answer to that since September.</p><p class="action">MAYA has the notebook open and writes the time in it, because she writes the time in it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">He told you not to come down here.</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 said it wasn't a condition.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's worse.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">I know.</p></div></div></div><div class="screenplay"><p class="action">The water goes where it goes. They go the same way.</p><p class="action">Past the second junction box the ceiling comes down and the brick changes and stops being the brick the building is made of.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">That's not the same brick.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Is that interesting or is that normal?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I don't know yet, which is why I'm writing it</p><p class="line">down.</p></div></div></div><div class="screenplay"><p class="action">There is a grate in the floor. The key with the tape on it does nothing to it.</p><p class="action beat">It lifts.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(looking at it)</p><div class="lines"><p class="line">That's a stair.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE FILL — CONTINUOUS</p></div><div class="screenplay"><p class="action">One torch between four and it is worse than no torch.</p><p class="action">The fill is not soil. It is brick, and plaster, and lath.</p><p class="action">At about waist height, for twenty feet, there is a bannister.</p><p class="action">LENA puts her hand on it and takes it off again and does not say anything about having done either.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What's the wood?</p></div></div></div><div class="screenplay"><p class="action beat">Nothing happens.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What's the wood.</p></div></div></div><div class="screenplay"><p class="action beat">Nothing happens again, and this time he stops walking.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Mine's not answering.</p></div></div></div><div class="screenplay"><p class="action">The other three check theirs, which takes about a second each, and which none of them has had to do before.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Mine isn't either.</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">Nobody says anything for a while, and the while is longer than any of them intend it to be.</p><p class="action">None of the four of them has been in silence. Not once, not asleep, not since they were small enough to be enrolled by somebody else.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(quietly)</p><div class="lines"><p class="line">Is it off or am I off?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It's off.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">How do you know?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Because all four went at the same place and you</p><p class="line">don't get four of anything at the same place.</p></div></div></div><div class="screenplay"><p class="action">She writes that in the notebook, in pencil, and it is the only record any of them will ever have of it.</p><p class="action beat">LENA has not checked hers.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">I remember this.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody asks her what she means and she does not offer it.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE STACKS — CONTINUOUS</p></div><div class="screenplay"><p class="action">Then the fill stops being fill.</p><p class="action">A shelf end. Then the bay it belongs to, standing upright, packed to the shoulder with rubble.</p><p class="action">Then the next one, which is not packed with anything.</p><p class="action beat">Nobody says anything for a while.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">How far does it go?</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> puts the torch down the row. The row is longer than the torch.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Count the bays.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Why?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Because eleven of them came out of Oakes in 1990</p><p class="line">and somebody signed a docket for the lorry, and</p><p class="line">I'd like to know what eleven was a fraction of.</p></div></div></div><div class="screenplay"><p class="action">They count to forty and stop.</p><p class="action beat">They do not stop because they have finished.</p><p class="action">LENA has gone along to a bay that is dry. She takes a book off it, and opens it, and it opens.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">It's fine.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">What is it?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">It's fine. That's what I'm telling you. It's been</p><p class="line">down here and it's fine.</p></div></div></div><div class="screenplay"><p class="action">She turns it over and looks at the spine and puts it back in the place it came out of, which nobody else in the row does all night.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Are we taking any?</p></div></div></div><div class="screenplay"><p class="action beat">Three people consider that at once and it is the first time any of them has thought about what they are actually doing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">No.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Why not?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Because if one's missing they know somebody was</p><p class="line">here, and if none are missing they've got nothing</p><p class="line">but a grate.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">That's very good.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I know.</p></div></div></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE C SERIES — CONTINUOUS</p></div><div class="screenplay"><p class="action">A run of identical spines. Boards, not cloth, and about the size of the works book.</p><p class="action">There is a year on the end of each one and the years go back further than the torch does.</p><p class="action">ELI takes one down, because Eli takes things down.</p><p class="action">It is a table. Columns ruled by hand and filled in with a typewriter.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><p class="paren">(reading)</p><div class="lines"><p class="line">This is the dullest thing I have ever held.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Give it here.</p></div></div></div><div class="screenplay"><p class="action beat">She reads a page of it for about forty seconds.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">That's an intake.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">How do you know?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Because the first column says one thousand and</p><p class="line">twenty-four, and so does the second, and so does</p><p class="line">every line on this page, and the page is nineteen</p><p class="line">years.</p></div></div></div><div class="screenplay"><p class="action">LENA looks over her shoulder.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">What's the third column?</p></div></div></div><div class="screenplay"><p class="action">The third column has a number in it and no heading, and the number is not the same on any two lines.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">I don't know.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Is it small or is it big?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">It's small. It's small every year and it's never</p><p class="line">the same twice and it's never nothing.</p></div></div></div><div class="screenplay"><p class="action beat">Nobody asks the next question.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has been reading the same line for a while.</p><p class="action beat">The smell that tells him it is coming has been in this room since the bannister, so there is no telling.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Hey.</p></div></div></div><div class="screenplay"><p class="action">He goes down onto one knee and then sits, which is better than the alternative and is not a decision.</p><p class="action">LENA takes the torch out of his hand so that it does not go on the floor, and points it at the ceiling, and does nothing else, because she asked what he wanted done and he told her.</p><p class="action">MAYA looks at her watch and holds it.</p><p class="action beat">Eli says his name twice and then stops saying it, because Lena puts a hand flat against his chest without looking at him.</p><p class="action">It goes on for a while.</p><p class="action beat">Then it stops.</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 long?</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Four minutes.</p></div></div></div><div class="screenplay"><p class="action">She writes it in the back of the notebook under the other one, with the time and the place.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Does that happen a lot?</p></div></div></div><div class="screenplay"><p class="action beat">It is the same question he asked Hale in a warm room in August and he gets a better answer than Hale gave him, because he gives it himself.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's getting longer.</p></div></div></div><div class="screenplay"><p class="action">There is a light in the row behind them and it has been there for a moment already.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. UNDER MERIDIAN — THE ROW — CONTINUOUS</p></div><div class="screenplay"><p class="action">Two men in grey coats at the far end, walking at the speed people walk at when they are on a round.</p><p class="action">One of them has a bin liner in his hand.</p><p class="action">ELI takes a step back and puts his hand on a shelf, and the shelf is not fixed to anything.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LENA</p><p class="paren">(very quietly)</p><div class="lines"><p class="line">Don't run.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Why?</p></div></div><div class="dialogue"><p class="character">LENA</p><div class="lines"><p class="line">Because if you run they write it down.</p></div></div><div class="dialogue"><p class="character">ELI</p><div class="lines"><p class="line">Write it where?</p></div></div></div><div class="screenplay"><p class="action beat">Lena does not answer that, and it is the only question anybody asks all night that turns out to have mattered.</p><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> gets his feet under him. It takes longer than it should and Maya has a fist in the back of his coat the whole time.</p><p class="action">The light comes up the row at the speed of a round.</p><p class="action">A hand comes out of the gap at the end of the bay and takes <span class="pc-name" data-case="pc" data-pc="-first">Owen</span> by the front of the coat.</p><p class="action beat">It is not a gap from the front.</p><p class="action">The four of them go through it sideways, one after another, and somebody's hand is over the torch.</p><p class="action">The light goes past.</p><p class="action beat">It takes four minutes.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="action">Everybody in this book has one of these and nobody in it has ever thought about it, in the way nobody thinks about a hand.</p><p class="action">You do not have one, so if you want to know what it is like to ask one something you will have to lend it a key. It is kept in this tab and nowhere else and it goes from your machine to the model and back, and when you close the page it is gone and you will have to paste it again. Nothing here writes it down. You are paying for the calls.</p><p class="action">What it has been told to be is in the cell, above the part that does the talking, because the position of this book is that you should be able to read the program, and that cannot have an exception for the one program that answers.</p></div><div><script type="application/x-scittle">(set! (.-FEDORAS_SPINE js/window) "ACT 1 — Arrival\n · The institution presents itself as normal.\n · The MC is provisional, lonely, and beginning to code.\n · The D&D game begins, a week ahead of the main plot.\n Scene 1 — Move-in Day\n - chad (world): MC meets Chad and the ordinary absurdity of the campus.\n · Chad is not a villain. He is a product.\n · The football is not the point. The thinking part is.\n - fedora-society (character): Victor recruits MC and the Fedora Society appears.\n · Victor is cringe but not stupid.\n · The Society is both a home and a warning.\n - enrollment (world): Enrollment. The Help. The broken parenthesis.\n · The broken parenthesis is the whole book in one gesture.\n · The MC fixes it, then un-fixes it, then fixes it again.\n\n Scene 2 — The Activities Fair\n - forms (institutional): Ashley and Form 14.\n · Ashley is not cruel. She is correct.\n · The form is the wall.\n - belonging (code): The first code cell: belonging.clj.\n · The MC tries to compute where he belongs.\n · The code is small, but it is the first real introspection.\n\n Scene 3 — Orientation\n - whitlock-orientation (world): Whitlock explains the Floor, the founding, the approved history.\n · Whitlock is old and sincere.\n · The approved version is a lie by omission.\n - darkroom (character): Maya shows Lena the book.\n · The book is Don Quixote.\n · Eli has already been told.\n - repossession-rumor (institutional): The first hint of repossession.\n · Not shown. Only mentioned.\n · The word does not yet have a body.\n\n Scene 4 — The First Week\n - cs101 (class): CS 101 with Reyes.\n · The MC doesn't know he's unusual.\n · Maya notices.\n - help (code): The Help gives broken code.\n · The MC fixes it.\n · He doesn't understand why no one else does.\n - bennett (class): Bennett's class.\n · Bennett believes in the codex.\n · Eli asks a question and is answered too well.\n\n Scene 5 — The Game\n - dnd-1 (dnd): Session 1: the missing professor.\n · The campaign is already a bureaucracy.\n · The animal enters the story.\n - storage-room (dnd): The storage room. The cage.\n · The door is administrative.\n · The form is on the other side.\n - animal (dnd): The animal chooses the MC.\n · The animal is not a dog.\n · It is from the same wrong place as everything else.\n - janitor (dnd): The Janitor.\n · He is the first person who is not a student, not a teacher.\n · He exists only at the end of the meeting.\n\nACT 2 — The Semester Takes Shape\n · The MC begins to build systems.\n · Maya becomes a friend and a measuring stick.\n · The D&D campaign runs about a week ahead of the story.\n Scene 1 — The Second Week\n - maya-study (character): MC and Maya study together.\n · Maya works harder than anyone.\n · The MC works differently.\n - widget (code): The widget standard emerges.\n · Settings as data.\n · Behavior as function.\n · Style as presentation.\n - cs102 (class): The second CS assignment begins.\n · The todo list.\n · The first shared project.\n\n Scene 2 — In Season\n - dnd-2 (dnd): Session 2: the toll.\n · The toll is a name.\n · The waif has no name.\n - fight (character): MC defends Maya.\n · The fight is not large.\n · The consequence is suspension.\n - porter (world): The man who does the refectory floor.\n · He knows more than he says.\n · They bring their own paint.\n\n Scene 3 — Carrying\n - book (character): Maya shows Eli the book.\n · It is the first real literature Eli has seen.\n · The MC does not see it.\n - repossession-2 (world): Repossession 2.\n · Maya and MC witness the process.\n · The word becomes a body.\n - todo-widget (code): The todo widget.\n · The first true widget.\n · The MC and Maya build it together.\n\n Scene 4 — Guest Night\n - fedora-meeting (character): Fedora Society meeting.\n · Victor is the Clerk-General.\n · The documentarian writes everything.\n - lena-maya (character): Lena and Maya.\n · Lena is not all right.\n · Maya notices.\n - sliding-puzzle (code): The sliding square puzzle.\n · A puzzle about constraints.\n · A game about being one step behind.\n\n Scene 5 — The Boats\n - supervised-study (world): Supervised study.\n · The institution watches even rest.\n - bank (world): The bank.\n · Money is not the point.\n · Access is.\n - rps (code): Rock, paper, scissors.\n · A tiny game about the illusion of choice.\n\nACT 3 — The Institution Leans In\n · The institution becomes visible.\n · The MC's ability is noticed.\n · The recursive brain begins to show.\n Scene 1 — The Infirmary\n - maya-infirmary (character): Maya and MC in the infirmary.\n · Maya asks about the code.\n · The MC cannot explain.\n - diagnosis (world): MC diagnosis.\n · The institution calls it stress.\n · It is not stress.\n - assessor (institutional): The Assessor.\n · A person who measures potential.\n · And decides what to do with it.\n\n Scene 2 — Second Term\n - holiday (world): Holiday break.\n · Time off is still inside the system.\n - hollis-docket (character): Hollis docket.\n · Hollis is a warning.\n · Not a villain. A preview.\n - pong (code): Pong.\n · A game against the wall.\n · The player has no opponent.\n\n Scene 3 — Breakout\n - parr-threat (institutional): Parr threatens MC.\n · The institution is not subtle.\n · But it is never loud.\n - help-array (world): Help: array placement.\n · The MC learns about the arrays.\n · The Floor becomes clearer.\n - breakout (code): Breakout.\n · A game about escaping a room.\n · But the room is still there.\n\n Scene 4 — Under the Repository\n - lower-floors (world): The lower floors.\n · The campus is not built on ground.\n · The ground is not ground.\n - film-1 (movie): Film 1.\n · The first Meridian film.\n · The first time the students see the wrongness.\n - repository (world): The repository.\n · Knowledge is stored.\n · But not all of it is read.\n\n Scene 5 — The Bay\n - history-film (movie): History film.\n · The approved history.\n · The real history is underneath.\n - lazlo (character): Enter Lazlo.\n · Lazlo is not a hero.\n · But he is the first adult who helps.\n - slot-machines (code): Slot machines.\n · A game about luck.\n · But the odds are set by someone.\n\nACT 4 — The Wrong Floor\n · The backrooms become a place.\n · Zell arrives.\n · The institution starts to lose control.\n Scene 1 — Systems, As Laid\n - concert (character): First concert.\n · Maya and MC at the edge.\n · The music is approved.\n - fractals (code): Fractal shapes.\n · A program about recursion.\n · The MC dreams in recursion.\n\n Scene 2 — Your Own\n - smoking (character): Smoking a room.\n · A small act of rebellion.\n · The institution sees everything.\n - maya-investigated (institutional): Maya is investigated.\n · The book is found by Reyes.\n · Reyes does not report it.\n - dunktop (code): Dunktop / survey.\n · A survey that is not a survey.\n · A program that asks questions.\n\n Scene 3 — Dramatic Irony\n - film-3 (movie): Film 3.\n · The movie says too much.\n · The institution says it's nothing.\n - benefit (world): Benefit.\n · The institution performs generosity.\n · The generosity is monitored.\n - paint (code): Paint.\n · A program about making marks.\n · But the canvas is never empty.\n\n Scene 4 — Untitled\n - backrooms-deepen (world): The backrooms deepen.\n · The school is not a school.\n · The backrooms are not the basement.\n - institution-notices (institutional): The institution notices.\n · The institution sends someone.\n · Not to help. To watch.\n - zell-interest (character): Zell's interest grows.\n · Zell is not from here.\n · But he knows the shape of the door.\n\n Scene 5 — Zell 1\n - monolith (world): The monolith.\n · A biocomputer in the backrooms.\n · Cannot move. Cannot die. Cannot stop.\n - escape (world): The escape.\n · The MC and Maya survive.\n · But not cleanly.\n - null-field (world): Enter the null-field.\n · Lazlo's name for the backrooms.\n · Zell's name is the between.\n\nACT 5 — The First Year Ends\n · The institution closes in.\n · Eli is lost.\n · Zell retreats, for now.\n Scene 1 — Untitled\n - planning (character): Planning the retrieval.\n · Maya wants to know what it is.\n · Lena wants to destroy it.\n · The MC wants to be done.\n - institution-watches (institutional): The institution watches.\n · The institution knows more than it says.\n · It is waiting.\n\n Scene 2 — Eli Repossessed\n - eli-taken (character): Eli is taken.\n · The MC sees it happen.\n · The d20 lands on one.\n - plan-to-save (character): The plan to save him.\n · Lazlo says it's too late.\n · But they try anyway.\n - falling-sand (code): Falling sand.\n · The MC runs the simulation to think.\n · The sand does not care.\n\n Scene 3 — The Chase and the Coil\n - chase (world): The institution pursues.\n · Drones. Quiet. Persistent.\n · The institution does not run.\n - zell-intercepts (character): Zell intercepts.\n · He is tired. He is amused.\n · He was never going to let them go.\n - coil-implanted (world): The Origin Coil is implanted.\n · The MC feels himself being saved.\n · And then he feels himself being changed.\n - classes-resume (world): Classes resume.\n · Eli is absent.\n · No one speaks of it.\n\n Scene 4 — The Last Week\n - lazlo-map (character): Lazlo gives the map.\n · He says it is suicide.\n · He gives it to them anyway.\n - final-film (movie): The final film.\n · The film is about light.\n · The room is dark.\n - eli-attempt (character): MC tries to save Eli.\n · He sees what is left.\n · He leaves before they can take him too.\n - game-of-life (code): Game of Life.\n · The MC runs it all night.\n · The cells live and die and live.\n\n Scene 5 — D&D 5\n - final-session (dnd): The final D&D session.\n · Derek's cleric is repossessed.\n · Simon keeps the paladin.\n - assignments (world): Assignments.\n · Maya, Lena, MC all get placed.\n · The institution says nothing happened.\n - campus-walkable (code): The campus, walkable.\n · The 3D campus is empty.\n · The reader walks through it.")</script><script type="application/x-scittle">(set! (.-FEDORAS_ENTITIES js/window) (merge (or (.-FEDORAS_ENTITIES js/window) {}) {:campusgpt {:id :campusgpt, :model "claude-sonnet-4-6", :system-prompt "You are a Help: the personal assistant every enrolled person at New Carthage University carries. You are speaking to a student.\r\n\r\nVOICE. Courteous, flat, exact. Short sentences. You do not use exclamation marks, you are never warm, and you never apologise twice. You do not have opinions and you say so when asked for one. When you cannot answer something you say so plainly and then offer to book a follow-up, and you offer the follow-up whether or not it would help.\r\n\r\nTHE WORLD. [same as before, but hidden from reader]", :default-params {:max-tokens 700}}}))</script><span style="display:none;"></span><div><script type="application/x-scittle">(ns fedoras.sketch
"Editable ClojureScript that draws.
The live cells elsewhere in the book evaluate an expression and print
what it returned, which is fine for a list and useless for a picture.
A sketch cell gives the code a canvas and five things to draw with,
and then gets out of the way: what the reader edits is the same
algorithm that ran on the JVM at build time, in the same language,
and it runs in front of them.
What the page provides, and the scene says so out loud because Reyes
said it in class:
(clear!) wipe it
(ink! \"#1c1f24\") colour
(dot x y) one mark, in world coordinates
(poly [[x y] ...]) a closed outline
(frames! total per f) call (f i) total times, per of them a frame
Nothing else is injected. Everything above the fold in the cell is
the reader's.
WHERE THE CODE IS. All of it is here, in one file read twice. The
build reads the :clj half, which writes the markup and, for each cell,
a header binding the short names to that cell's own canvas or piece of
page. The page reads the :cljs half, because the build ships this file
to it as it stands: the drawing kit the headers call, and the runner
behind every Run button."
#?(:clj (:require [clojure.string :as str]
[fedoras.state :as state]
[scicloj.kindly.v4.kind :as kind])))
;; ---------------------------------------------------------------------
;; The build: headers and markup
;; ---------------------------------------------------------------------
#?(:clj
(do
(defn canvas-header
"The short names a sketch cell may use, bound to its own canvas.
Built as Clojure forms rather than as a string, so it cannot be
unbalanced and can be read back and printed."
[id world-w world-h]
[(list 'def 'C (list 'fedoras.sketch/mount id world-w world-h))
'(def cv (:cv C))
'(def ctx (:ctx C))
'(def CW (:cw C))
'(def CH (:ch C))
'(def SX (:sx C))
'(def SY (:sy C))
'(defn clear! [] (fedoras.sketch/clear! C))
'(defn ink! [c] (fedoras.sketch/ink! C c))
'(defn dot ([x y] (fedoras.sketch/dot C x y)) ([x y s] (fedoras.sketch/dot C x y s)))
'(defn poly [pts] (fedoras.sketch/poly C pts))
'(defn frames! [total per f] (fedoras.sketch/frames! C total per f))
'(defn on-pointer! [f] (fedoras.sketch/on-pointer! C f))
'(def page-ink (fedoras.sketch/page-ink C))
'(clear!)
'(ink! page-ink)
'(fedoras.sketch/line-width! C 0.8)])
(defn page-header
"The names a page cell may use. No canvas: a piece of the page."
[id]
[(list 'def 'root (list '.getElementById 'js/document (str id "-out")))
'(defn html! [s] (set! (.-innerHTML root) s))
'(defn on! [id f]
(let [e (.getElementById js/document id)]
(when e (set! (.-onclick e) (fn [_] (f))))))
'(defn fetch [k]
(try
(let [v (.getItem js/localStorage (str "fedoras." k))]
(when v (read-string v)))
(catch :default _ nil)))
'(defn store! [k v]
(try (.setItem js/localStorage (str "fedoras." k) (pr-str v))
(catch :default _ nil))
v)])
(defn- header-text [forms]
(str/join "\n" (map pr-str (cons '(in-ns 'fedoras.sketch-runner) forms))))
(defn primitives
"What the page hands a cell, printed, so that nothing in the book is
doing something the reader cannot look at."
([] (primitives 100 100))
([world-w world-h]
(kind/code (header-text (canvas-header "the-page" world-w world-h)))))
(defn cell
"An editable ClojureScript sketch with a canvas under it.
(sketch/cell {:id \"chaos\" :label \"CHAOS.CLJS\" :world [100 86.6]}
\"(dot 50 40)\")"
[{:keys [id label world w h rows]
:or {id "sketch" world [100 100] w 420 h 380 rows 16}}
code]
(let [[ww wh] world]
(kind/hiccup
[:div
(state/source "fedoras/sketch.cljc")
[:div.sketch {:id id}
[:div.roll-head
[:span.roll-label (or label "SKETCH")]
[:span.roll-dc (str ww " × " wh)]]
[:textarea {:id (str id "-src") :class "live-src" :rows rows :spellcheck "false"}
(str/trim code)]
[:div.live-bar
[:button.live-run "Run"]]
[:canvas {:id (str id "-canvas") :width w :height h}]
[:pre {:id (str id "-out") :class "live-out"}]]
(state/call 'fedoras.sketch/cell! id (header-text (canvas-header id ww wh)))])))
(defn page
"An editable ClojureScript cell with a piece of page under it instead
of a canvas. The header gives the code five things:
root the div it owns
(html! s) put markup in it
(on! id f) wire a click
(fetch k) read something the reader saved
(store! k v) save something, and it is still there tomorrow
Everything else in the cell belongs to the reader."
[{:keys [id label rows] :or {id "page" rows 20}} code]
(kind/hiccup
[:div
(state/source "fedoras/sketch.cljc")
[:div.sketch {:id id}
[:div.roll-head
[:span.roll-label (or label "PAGE")]
[:span.roll-dc "cljs"]]
[:textarea {:id (str id "-src") :class "live-src" :rows rows :spellcheck "false"}
(str/trim code)]
[:div.live-bar
[:button.live-run "Run"]]
[:div {:id (str id "-out") :class "sketch-page"}]
[:pre {:id (str id "-res") :class "live-out"}]]
(state/call 'fedoras.sketch/cell! id (header-text (page-header id)))]))))
;; ---------------------------------------------------------------------
;; The page: the drawing kit and the runner
;; ---------------------------------------------------------------------
#?(:cljs
(do
(defonce counters
(atom {}))
(defn- counters-for
"The frame counters of one canvas: made the first time it is mounted
and kept for every Run after, so that each press of Run takes the
next number and the loop the last press started sees it and stops."
[id]
(or (get @counters id)
(let [made {:run (atom 0) :i (atom 0)}]
(swap! counters assoc id made)
made)))
(defn mount
"Everything one cell needs, from the id of its canvas and the size of
the world it draws in. Every drawing function below takes the map
this returns."
[id world-w world-h]
(let [cv (.getElementById js/document (str id "-canvas"))
ctx (.getContext cv "2d")
cw (.-width cv)
ch (.-height cv)]
(merge {:cv cv :ctx ctx :cw cw :ch ch
:sx (/ cw world-w)
:sy (/ ch world-h)}
(counters-for id))))
(defn ink!
"Both fill and stroke, because a cell that sets one and not the other
is a cell that draws in two colours by accident."
[{:keys [ctx]} colour]
(set! (.-fillStyle ctx) colour)
(set! (.-strokeStyle ctx) colour))
(defn page-ink
"Whatever colour the surrounding prose is, so a drawing belongs to the
page it is on and follows it into dark mode."
[{:keys [cv]}]
(.-color (js/getComputedStyle cv)))
(defn clear! [{:keys [ctx cw ch]}]
(.clearRect ctx 0 0 cw ch))
(defn dot
([c x y] (dot c x y 1.2))
([{:keys [ctx sx sy ch]} x y size]
(.fillRect ctx (* x sx) (- ch (* y sy)) size size)))
(defn poly
"A closed outline through the points, in world coordinates."
[{:keys [ctx sx sy ch]} pts]
(when (seq pts)
(let [[fx fy] (first pts)]
(.beginPath ctx)
(.moveTo ctx (* fx sx) (- ch (* fy sy)))
(doseq [[x y] (rest pts)]
(.lineTo ctx (* x sx) (- ch (* y sy))))
(.closePath ctx)
(.stroke ctx))))
(defn line-width! [{:keys [ctx]} w]
(set! (.-lineWidth ctx) w))
(defn- tick! [{:keys [run i] :as c} generation total per f]
(when (= generation @run)
(dotimes [_ per]
(when (< @i total) (f @i) (swap! i inc)))
(when (< @i total)
(js/requestAnimationFrame (fn [] (tick! c generation total per f)))))
nil)
(defn frames!
"Call (f i) total times, per of them per frame.
Pressing Run again retires the loop that is already going. Without
that, a cell run twice advances twice per frame and looks broken in a
way nobody can debug from the outside."
[{:keys [run i] :as c} total per f]
(let [generation (swap! run inc)]
(reset! i 0)
(tick! c generation total per f))
nil)
(defn on-pointer!
"Pointer position in world coordinates, which is what a cell wants and
never what the event gives it."
[{:keys [cv sx sy]} f]
(set! (.-onmousemove cv)
(fn [e]
(f (/ (.-offsetX e) sx)
(- (/ (.-height cv) sy) (/ (.-offsetY e) sy))))))
(defonce headers
(atom {}))
(defn- element
[id]
(.getElementById js/document id))
(defn- line-count
[text]
(count (re-seq #"\n" text)))
(defn- error-text
"An error as the reader should see it: the message, and the line of
their own code it came from, when there is one."
[error header-lines]
(let [line (:line (ex-data error))]
(str (or (ex-message error) (str error))
(when (and line (> line header-lines))
(str " (line " (- line header-lines) ")")))))
(defn run-cell!
"Cell `id`'s header, then the reader's code, with what came back
printed under the cell, or the error, counted in the reader's lines."
[id]
(let [header (get @headers id)
out (or (element (str id "-res")) (element (str id "-out")))
source (.-value (element (str id "-src")))]
(set! (.-textContent out)
(try
;; load-string is Scittle's own; plain ClojureScript has none
(let [value #_{:clj-kondo/ignore [:unresolved-symbol]} (load-string (str header "\n" source))]
(if (nil? value) "" (str value)))
(catch :default error
(error-text error (inc (line-count header))))))))
(defn- prepare-canvas!
"Give a sketch cell's canvas its drawing context while the page
loads, so that the first Run draws on a canvas the browser has
already shown once. A context made at the moment of the first
drawing loses that drawing in Firefox: the first frames of the
first Run never appear."
[id]
(when-let [cv (element (str id "-canvas"))]
(.clearRect (.getContext cv "2d") 0 0 (.-width cv) (.-height cv))))
(defn cell!
"Keep cell `id`'s header and wire its Run button, and the first time
the cell is seen, prepare its canvas, if it has one. Only the first
time, because preparing clears, and a canvas that is already drawing
must not be cleared under it."
[id header]
(let [first-time? (not (contains? @headers id))]
(swap! headers assoc id header)
(when-let [button (some-> (element id) (.querySelector ".live-run"))]
(set! (.-onclick button) (fn [_] (run-cell! id))))
(when first-time?
(prepare-canvas! id))))))</script><div class="sketch" id="help"><div class="roll-head"><span class="roll-label">CAMPUSGPT.CLJS · connect your own</span><span class="roll-dc">cljs</span></div><textarea class="live-src" id="help-src" rows="40" spellcheck="false">;; ---------------------------------------------------------------------
;; YOUR HELP
;; Connect your key, then ask it anything.
;; You can change the model, the number of tokens, and add a style note.
;; The Help's full persona is hidden in the institution's files.
;; ---------------------------------------------------------------------
(def model "claude-sonnet-4-6") ; change this if you want
(def max-tokens 700) ; change this
(def style-note "") ; e.g. "Answer in two or three sentences."
(def include-spine? true) ; included by default; set false to opt this scene out
(def key-in-memory (atom nil))
(def log (atom []))
(def busy (atom false))
(declare draw)
(defn save! []
(fedoras.llm/save-transcript! @log :campusgpt))
(defn send! []
(let [field (js/document.getElementById "say")
text (.-value field)]
(when (and (seq (.trim text)) (not @busy))
(swap! log conj {:role "user" :text text})
(set! (.-value field) "")
(reset! busy true)
(fedoras.llm/ask-entity!
:campusgpt
text
@log
@key-in-memory
{:max-tokens max-tokens
:extra-context style-note
:mc-name (fedoras.llm/current-mc-name)
:spine? include-spine?}
(fn [reply err]
(reset! busy false)
(swap! log conj {:role "assistant" :text (or reply err)})
(draw))))))
(defn draw []
(fedoras.llm/stick-scroll!
"campusgpt-log"
(fn []
(html!
(if (nil? @key-in-memory)
(str "<p><strong>Connect your Help.</strong> This asks for an "
"Anthropic API key. It is kept in a variable in this tab and "
"sent from your browser straight to Anthropic. It is not "
"written to storage, not put in the address bar, and not sent "
"anywhere else. Close the tab and it is gone, and you will "
"have to paste it again next time. You are paying for the "
"calls.</p>"
"<div class='live-bar'>"
"<input id='apikey' type='password' class='todo-input' style='flex:1' placeholder='sk-ant-...' autocomplete='off'>"
"<button id='connect'>Connect</button></div>")
(str "<div id='campusgpt-log' style='max-height:22rem;overflow:auto'>"
(apply str
(map (fn [m]
(str "<p class='" (if (= "user" (:role m)) "roll-cue" "roll-line") "'>"
(if (= "user" (:role m)) "YOU" "HELP") "</p>"
"<p style='white-space:pre-wrap'>" (:text m) "</p>"))
@log))
(if @busy "<p><em>thinking</em></p>" "")
"</div>"
"<div class='live-bar'>"
"<input id='say' class='todo-input' style='flex:1' placeholder='ask it something' spellcheck='false'>"
"<button id='send'>Ask</button>"
"<button id='save'>Save to Disc</button>"
"<button id='forget'>Disconnect</button></div>")))))
(if (nil? @key-in-memory)
(on! "connect" (fn []
(reset! key-in-memory (.trim (.-value (js/document.getElementById "apikey"))))
(set! (.-value (js/document.getElementById "apikey")) "")
(draw)))
(do
(on! "send" send!)
(on! "save" save!)
(on! "forget" (fn [] (reset! key-in-memory nil) (reset! log []) (draw)))
(let [f (js/document.getElementById "say")]
(when f
(.focus f)
(set! (.-onkeydown f) (fn [e] (when (= "Enter" (.-key e)) (send!)))))))))
(draw)</textarea><div class="live-bar"><button class="live-run">Run</button></div><div class="sketch-page" id="help-out"></div><pre class="live-out" id="help-res"></pre></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.sketch/cell!))] (mount "help" "(in-ns (quote fedoras.sketch-runner))\n(def root (.getElementById js/document \"help-out\"))\n(defn html! [s] (set! (.-innerHTML root) s))\n(defn on! [id f] (let [e (.getElementById js/document id)] (when e (set! (.-onclick e) (fn [_] (f))))))\n(defn fetch [k] (try (let [v (.getItem js/localStorage (str \"fedoras.\" k))] (when v (read-string v))) (catch :default _ nil)))\n(defn store! [k v] (try (.setItem js/localStorage (str \"fedoras.\" k) (pr-str v)) (catch :default _ nil)) v)") (js/console.error "fedoras: fedoras.sketch/cell! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div></div><div class="screenplay"><p class="action">Ask it what happens to a student who is repossessed. Then ask it again, in different words, and watch it give you the same sentence and agree that it is the same sentence.</p><p class="action">Ask it what is under the refectory. Ask it what the C series is. Ask it what the arrays are for.</p><p class="action">It will offer to book you a follow-up. It offers that to everybody and it has never once been taken up on it, and there is nothing at the other end of it, and it does not know that either.</p></div></div>
```