---
format:
html:
toc: true
toc-depth: 2
theme: {dark: darkly, light: cosmo}
number-sections: false
number-depth: 0
code-fold: false
code-tools: true
highlight-style: nord
---
<style></style><style>.printedClojure .sourceCode {
background-color: transparent;
border-style: none;
}
</style><style>.clay-limit-image-width .clay-image {max-width: 100%}
.clay-side-by-side .sourceCode {margin: 0}
.clay-side-by-side {margin: 1em 0}
</style>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" type="text/javascript"></script><script src="https://code.jquery.com/ui/1.13.1/jquery-ui.min.js" type="text/javascript"></script>
```{=html}
<div style="display:none;"><style>/* ---------------------------------------------------------------
Fedoras — one palette, two schemes.
Dark is the default. Quarto puts .quarto-light on the body when the
reader flips the toggle, and everything below is written against
these variables, so a scheme change is nine lines rather than three
hundred.
--------------------------------------------------------------- */
:root {
--paper: #1b1d21; /* background */
--ink: #e6e4df; /* text */
--ink-soft: #a9a49b; /* secondary text */
--rule: rgba(230, 228, 223, 0.22);
--panel: rgba(230, 228, 223, 0.05);
--violet: #9d8dff; /* apparatus, epigraphs */
--gold: #d0a44c; /* beats, commits */
--coral: #e2707c; /* moderation, rewrites */
--green: #6fcf9b; /* the Helps */
--blurple: #8b95f0; /* Discord */
--violet-rgb: 157, 141, 255;
--gold-rgb: 208, 164, 76;
--coral-rgb: 226, 112, 124;
--green-rgb: 111, 207, 155;
--blurple-rgb: 139, 149, 240;
--ink-rgb: 168, 164, 156;
--panel-hard: #101215;
}
body.quarto-light {
--paper: #faf8f4;
--ink: #1c1f24;
--ink-soft: #6b665e;
--rule: rgba(28, 31, 36, 0.22);
--panel: rgba(28, 31, 36, 0.05);
--violet: #6c5ce7;
--gold: #b0863a;
--coral: #e04f5f;
--green: #2ea078;
--blurple: #5865f2;
--violet-rgb: 108, 92, 231;
--gold-rgb: 176, 134, 58;
--coral-rgb: 224, 79, 95;
--green-rgb: 46, 160, 120;
--blurple-rgb: 88, 101, 242;
--ink-rgb: 128, 128, 128;
--panel-hard: #14161a;
}
/* ---------------------------------------------------------------
Fedoras — screenplay typography for the HTML edition.
Loaded by fedoras.screenplay/assets. Edit here, rebuild, done.
PDF styling lives in notebooks/tex/fedoras-preamble.tex instead.
--------------------------------------------------------------- */
.screenplay {
font-family: "Courier Prime", "Courier New", ui-monospace, monospace;
font-size: 1rem;
line-height: 1.35;
max-width: 62ch;
margin: 1.6rem 0 2.2rem 0;
}
.screenplay .slug {
font-weight: 700;
letter-spacing: 0.04em;
margin: 1.6rem 0 0.8rem 0;
}
.screenplay .action {
margin: 0 0 0.85rem 0;
}
.action{
white-space: pre-line;
}
.screenplay .action.beat {
margin-bottom: 0.35rem;
}
.screenplay .dialogue {
margin: 0 0 0.95rem 0;
}
.screenplay .character {
margin: 0 0 0 12ch;
font-weight: 700;
letter-spacing: 0.06em;
}
.screenplay .paren {
margin: 0 0 0 9ch;
font-style: italic;
opacity: 0.85;
}
.screenplay .lines {
margin-left: 6ch;
max-width: 50ch;
}
.screenplay .line {
margin: 0;
}
.screenplay .transition {
margin: 1.1rem 0 1.1rem 0;
text-align: right;
font-weight: 700;
letter-spacing: 0.08em;
}
.screenplay .direction {
font-style: italic;
opacity: 0.85;
margin: 0 0 0.9rem 0;
}
/* In-world text: banners, signs, whiteboards. */
.screenplay .sign {
border: 1px solid currentColor;
padding: 0.7rem 1rem;
margin: 0.9rem 0 1.1rem 4ch;
max-width: 46ch;
text-align: center;
letter-spacing: 0.05em;
}
.screenplay .sign p {
margin: 0.15rem 0;
}
.screenplay .whiteboard {
text-align: left;
}
.screenplay .whiteboard-title {
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.4rem !important;
}
.screenplay .whiteboard ol {
margin: 0;
padding-left: 2.2ch;
}
/* --- The Fedora Handbook epigraphs ------------------------------- */
.handbook {
max-width: 54ch;
margin: 1.4rem 0 2rem 2ch;
padding-left: 1.1rem;
border-left: 3px solid var(--violet);
font-style: italic;
}
.handbook .rule-number {
font-style: normal;
font-variant: small-caps;
letter-spacing: 0.08em;
opacity: 0.75;
margin-bottom: 0.25rem;
}
.handbook .rule-text {
margin: 0;
}
/* --- Drafting apparatus (hidden in the reading copy) -------------- */
.beats {
border: 1px dashed var(--gold);
background: rgba(var(--gold-rgb), 0.07);
padding: 0.6rem 0.9rem;
margin: 1.1rem 0;
max-width: 62ch;
font-size: 0.88rem;
}
.beats-title {
font-variant: small-caps;
letter-spacing: 0.1em;
font-weight: 700;
margin: 0 0 0.35rem 0;
opacity: 0.8;
}
.beats ul {
margin: 0;
padding-left: 1.4rem;
}
.beats.note {
border-color: var(--blurple);
background: rgba(var(--blurple-rgb), 0.07);
}
.status {
display: inline-block;
font-family: ui-monospace, monospace;
font-size: 0.7rem;
letter-spacing: 0.14em;
padding: 0.15rem 0.5rem;
border-radius: 3px;
margin: 0 0 0.8rem 0;
}
.status-outline { background: var(--ink-soft); color: #fff; }
.status-draft { background: var(--gold); color: #fff; }
.status-locked { background: var(--green); color: #fff; }
/* --- Live (editable) code cells ---------------------------------- */
.live-cell {
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 5px;
padding: 0.7rem 0.8rem;
margin: 1.2rem 0;
max-width: 68ch;
}
.live-label {
font-size: 0.78rem;
letter-spacing: 0.09em;
text-transform: uppercase;
opacity: 0.7;
margin-bottom: 0.45rem;
}
.live-src {
width: 100%;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
line-height: 1.4;
padding: 0.5rem;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
resize: vertical;
}
.live-bar {
margin-top: 0.5rem;
display: flex;
gap: 0.5rem;
align-items: center;
}
.live-run {
font: inherit;
font-size: 0.85rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.live-run:hover { background: rgba(var(--violet-rgb), 0.3); }
.live-out {
margin: 0.5rem 0 0 0;
padding: 0.45rem 0.6rem;
min-height: 1.6rem;
font-size: 0.88rem;
white-space: pre-wrap;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
background: rgba(var(--ink-rgb), 0.06);
}
/* --- Front matter ------------------------------------------------- */
.title-page {
text-align: center;
margin: 3rem 0 2rem 0;
}
.title-page .title {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 3.2rem;
letter-spacing: 0.22em;
margin: 0;
}
.title-page .subtitle {
font-family: "Courier Prime", "Courier New", monospace;
letter-spacing: 0.1em;
margin-top: 0.8rem;
opacity: 0.85;
}
h1.sidebar-title,
h2.sidebar-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.personae {
max-width: 66ch;
}
.personae h3 {
font-variant: small-caps;
letter-spacing: 0.1em;
margin-top: 2rem;
border-bottom: 1px solid rgba(var(--ink-rgb), 0.35);
padding-bottom: 0.2rem;
}
.personae .who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
margin: 1rem 0 0.1rem 0;
}
.personae .what {
margin: 0 0 0.2rem 0;
font-style: italic;
opacity: 0.8;
}
.personae .desc {
margin: 0.15rem 0 0 0;
max-width: 60ch;
}
/* --- The reader's protagonist ------------------------------------ */
.pc-name {
font-weight: inherit;
border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55);
}
.screenplay .slug,
.screenplay .transition,
.screenplay .character { text-transform: uppercase; }
.name-field {
border: 1px solid rgba(var(--violet-rgb), 0.55);
border-radius: 5px;
padding: 0.9rem 1rem;
margin: 1.6rem auto;
max-width: 60ch;
}
.name-label { margin: 0 0 0.6rem 0; }
.name-inputs { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.name-inputs input {
font: inherit;
padding: 0.3rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
max-width: 14ch;
}
.name-note {
font-size: 0.82rem;
opacity: 0.7;
margin: 0.6rem 0 0 0;
}
.name-label,
.name-confirm {
font-size: 1.2rem;
}
.name-inputs input,
.name-inputs button {
font: inherit;
font-size: 1.2rem;
}
/* --- Artifacts ---------------------------------------------------- */
.artifact {
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
max-width: 66ch;
margin: 1.4rem 0;
border-radius: 5px;
overflow: hidden;
}
.artifact p { margin: 0.1rem 0; }
/* Discord */
.discord { border: 1px solid rgba(var(--blurple-rgb), 0.5); }
.discord-head {
background: rgba(var(--blurple-rgb), 0.14);
padding: 0.4rem 0.8rem;
display: flex; gap: 0.8rem;
}
.discord-server { font-weight: 700; letter-spacing: 0.06em; }
.discord-channel { opacity: 0.75; }
.discord-body { padding: 0.7rem 0.8rem; }
.discord-msg { margin-bottom: 0.7rem; }
.discord-who { font-weight: 700; }
.discord-role {
font-size: 0.7rem; letter-spacing: 0.08em;
background: rgba(var(--violet-rgb), 0.25);
padding: 0.05rem 0.4rem; border-radius: 3px; margin-left: 0.4rem;
}
.discord-at { opacity: 0.55; margin-left: 0.5rem; font-size: 0.78rem; }
.discord-reacts { margin-top: 0.2rem !important; }
.react {
display: inline-block; font-size: 0.75rem;
border: 1px solid rgba(var(--ink-rgb),0.35); border-radius: 10px;
padding: 0 0.45rem; margin-right: 0.3rem;
}
/* Moderation log */
.modlog {
border-left: 3px solid var(--coral);
background: rgba(var(--coral-rgb), 0.08);
padding: 0.6rem 0.9rem;
font-size: 0.82rem;
letter-spacing: 0.03em;
}
/* Git */
.commit {
border: 1px solid rgba(var(--ink-rgb), 0.4);
padding: 0.7rem 0.9rem;
background: rgba(var(--ink-rgb), 0.05);
}
.commit-hash { color: var(--gold); font-weight: 700; }
.commit-meta { opacity: 0.7; }
.commit-msg { margin-top: 0.5rem; padding-left: 1.5rem; }
/* Terminal */
.terminal {
background: var(--panel-hard);
color: var(--ink);
padding: 0.7rem 0.9rem;
border: 1px solid rgba(var(--ink-rgb),0.35);
}
.term-in { color: var(--green); }
.term-in::before { content: ""; }
.term-out { opacity: 0.9; }
/* AI */
.aichat { border: 1px solid rgba(var(--green-rgb), 0.5); }
.ai-model {
background: rgba(var(--green-rgb), 0.16);
padding: 0.35rem 0.8rem !important;
font-weight: 700; letter-spacing: 0.1em; font-size: 0.75rem;
}
.ai-system { padding: 0.4rem 0.8rem; font-size: 0.8rem; opacity: 0.85; }
.ai-system pre { white-space: pre-wrap; margin: 0.4rem 0 0 0; }
.ai-body { padding: 0.6rem 0.8rem; }
.ai-turn { margin-bottom: 0.6rem; }
.ai-role { font-size: 0.68rem; letter-spacing: 0.12em; opacity: 0.6; }
.ai-assistant .ai-text { border-left: 2px solid rgba(46,160,120,0.6); padding-left: 0.6rem; }
/* Email */
.email { border: 1px solid rgba(var(--ink-rgb),0.45); padding: 0.7rem 0.9rem; }
.email-head td { padding: 0 0.6rem 0.1rem 0; vertical-align: top; font-size: 0.82rem; }
.email-head td:first-child { opacity: 0.6; }
.email-body { margin-top: 0.6rem; }
/* Handouts */
.handout {
border: 1px solid currentColor;
padding: 1rem 1.2rem;
font-family: "Courier Prime", "Courier New", monospace;
}
.handout-title {
font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
border-bottom: 1px solid currentColor; padding-bottom: 0.4rem;
margin-bottom: 0.7rem !important;
}
.handout-table { width: 100%; font-size: 0.85rem; margin: 0.5rem 0; }
.handout-table td.k { opacity: 0.65; width: 34%; padding-right: 1rem; vertical-align: top; }
/* --- Prose between scenes ---------------------------------------- */
.prose {
max-width: 64ch;
margin: 1.5rem 0 2rem 0;
font-family: "Iowan Old Style", "Palatino Linotype", Palatino,
"Book Antiqua", Georgia, serif;
font-size: 1.06rem;
line-height: 1.65;
color: var(--ink);
}
.prose p {
margin: 0 0 1.05rem 0;
}
.prose p:first-child { margin-top: 0; }
.prose p:last-child { margin-bottom: 0; }
blockquote.turn {
max-width: 64ch;
border-left: 3px solid rgba(var(--violet-rgb), 0.6);
padding-left: 1rem;
margin: 1.4rem 0 0.6rem 0;
}
.name-confirm {
margin: 0.7rem 0 0 0;
font-weight: 600;
}
/* --- The d20 ------------------------------------------------------ */
.roll {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 60ch;
font-family: ui-monospace, "Courier New", monospace;
}
.roll-head {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
letter-spacing: 0.12em;
opacity: 0.75;
margin-bottom: 0.5rem;
}
.roll-bar { display: flex; gap: 0.7rem; align-items: center; margin-top: 0.3rem; }
.roll-go, .roll-rewrite {
font: inherit;
font-size: 0.82rem;
padding: 0.25rem 0.9rem;
border-radius: 4px;
border: 1px solid rgba(var(--ink-rgb), 0.5);
background: rgba(var(--violet-rgb), 0.15);
cursor: pointer;
}
.roll-rewrite { background: rgba(var(--coral-rgb), 0.12); }
.roll-go:hover { background: rgba(var(--violet-rgb), 0.3); }
.roll-rewrite:hover { background: rgba(var(--coral-rgb), 0.25); }
.roll-value { font-size: 0.9rem; letter-spacing: 0.05em; }
.roll-out {
margin: 0.6rem 0 0 0 !important;
font-family: "Courier Prime", "Courier New", monospace;
min-height: 1.2rem;
}
.roll-echo { font-size: 0.8rem; opacity: 0.8; }
.roll-note { font-size: 0.8rem; opacity: 0.6; margin: 0.4rem 0 0 0 !important; }
.roll-foot {
font-size: 0.72rem;
letter-spacing: 0.1em;
opacity: 0.55;
margin: 0.5rem 0 0 0 !important;
border-top: 1px solid rgba(var(--ink-rgb),0.25);
padding-top: 0.35rem;
}
.roll-cue {
margin: 0.6rem 0 0 4ch !important;
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.06em;
}
.roll-line {
margin: 0 0 0 6ch !important;
max-width: 40ch;
font-family: "Courier Prime", "Courier New", monospace;
}
.roll-out .roll-line:first-child { margin-left: 0 !important; max-width: none; }
.companion-name {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700;
letter-spacing: 0.14em;
font-size: 1.05rem;
margin: 0.7rem 0 0.3rem 0 !important;
}
.companion-intro { max-width: 58ch; margin: 0 0 0.3rem 0 !important; }
.session { max-width: 60ch; margin: 1.1rem 0; }
.session-who {
font-family: "Courier Prime", "Courier New", monospace;
font-weight: 700; letter-spacing: 0.12em; margin: 0 0 0.3rem 0 !important;
}
.session-text { margin: 0 !important; }
.ac-name { border-bottom: 1px dotted rgba(var(--violet-rgb), 0.55); }
.companion-naming { margin-top: 0.6rem; }
/* --- Plots -------------------------------------------------------- */
.plot {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.plot-controls {
display: flex; gap: 1rem; flex-wrap: wrap;
font-size: 0.8rem; opacity: 0.85; margin: 0.4rem 0;
}
.plot-controls input {
font: inherit; font-size: 0.8rem; width: 7ch;
padding: 0.15rem 0.35rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.06);
}
.plot-status { font-size: 0.75rem; letter-spacing: 0.1em; opacity: 0.7; }
.plot canvas {
display: block;
margin-top: 0.6rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
}
.roll-go:disabled, .roll-rewrite:disabled {
opacity: 0.4;
cursor: default;
}
.sketch {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
}
.sketch canvas {
display: block;
margin-top: 0.7rem;
background: var(--paper);
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
max-width: 100%;
}
.sketch-page {
margin-top: 0.7rem;
padding: 0.5rem 0.6rem;
border: 1px solid rgba(var(--ink-rgb),0.3);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.04);
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.9rem;
}
.sketch-page button {
font: inherit; font-size: 0.85rem;
border: 1px solid rgba(var(--ink-rgb),0.45);
border-radius: 3px;
background: rgba(var(--ink-rgb),0.08);
cursor: pointer;
min-width: 2ch;
}
/* --- The page itself, when Quarto is not doing it for us ---------- */
.screenplay, .artifact, .prose, .handout, .beats, .roll, .plot, .sketch,
.todo, .puzzle-box, .personae, .name-field, .live-cell, .session {
color: var(--ink);
}
.handout, .puzzle-cell, .sign { border-color: var(--rule); }
.terminal { background: var(--panel-hard); color: var(--ink); }
.title-page .title, .title-page .subtitle { color: var(--ink); }
/* --- Form controls -------------------------------------------------
Inputs, textareas and buttons do not inherit colour from the page,
so every one of them needs telling, or the reader types black on
black and cannot see what they are writing.
------------------------------------------------------------------- */
.name-inputs input,
.companion-input,
.todo-input,
.plot-controls input,
.live-src,
.sketch textarea,
.sketch-page input {
color: var(--ink);
background: var(--panel);
border-color: var(--rule);
caret-color: var(--ink);
}
.name-inputs input::placeholder,
.companion-input::placeholder,
.todo-input::placeholder,
.sketch-page input::placeholder,
.live-src::placeholder {
color: var(--ink-soft);
opacity: 1;
}
.roll-go, .roll-rewrite, .live-run, .todo-del,
.sketch-page button, .puzzle-cell {
color: var(--ink);
}
.sketch-page button {
background: var(--panel);
border-color: var(--rule);
}
/* Quarto's own search box does not follow the book's palette on every
theme, so it is told here as well. */
#quarto-search input,
#quarto-search .aa-Input,
.aa-Form,
.aa-InputWrapper input,
.quarto-search input {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
.aa-Form { border: 1px solid var(--rule) !important; }
#quarto-search input::placeholder,
.aa-Input::placeholder {
color: var(--ink-soft) !important;
opacity: 1;
}
.aa-DetachedSearchButton, .quarto-search-button {
background: var(--panel) !important;
color: var(--ink) !important;
border-color: var(--rule) !important;
}
/* Canvases inherit nothing, so the sketch cells read their ink off the
element's own resolved colour. This is the line that gives them one. */
.sketch canvas, .plot canvas {
color: var(--ink);
background: transparent;
}
.tribute-heading {
margin: 2rem 0 0.5rem;
font-variant: small-caps;
font-weight: 600;
letter-spacing: 0.04em;
}
.tribute p {
margin: 0.6rem 0;
}
.tribute-meta {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--bs-border-color);
text-align: right;
font-size: 0.85rem;
}
.tribute-meta p {
margin: 0.25rem 0;
}
.tribute-date {
font-style: italic;
}
.tribute-attribution {
font-size: 1rem;
}
.tribute-source {
margin-top: 0.75rem !important;
}
.footnote {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--bs-secondary);
}
/* ---------------------------------------------------------------
The three endings.
All three are in the HTML. Which one is visible is a class on
<body>, written once by fedoras.reader/paint-standing! from the
reader's standing, so a chapter that forks does it here rather than
in nine lines of DOM work per passage.
A page with no class on it shows the middle one. That is what the
print edition gets, and it is what the table actually got, and the
PDF reads straight through with no gap and no note.
--------------------------------------------------------------- */
.ending { display: none; }
.ending-reconciled { display: block; }
body.standing-closed .ending-reconciled { display: none; }
body.standing-outstanding .ending-reconciled { display: none; }
body.standing-closed .ending-closed { display: block; }
body.standing-outstanding .ending-outstanding { display: block; }
@media print {
.ending { display: none !important; }
.ending-reconciled { display: block !important; }
}
/* ---------------------------------------------------------------
The slips.
--------------------------------------------------------------- */
.slip {
margin: 1.2rem 0 1.2rem 4ch !important;
padding-left: 1rem;
max-width: 46ch;
border-left: 2px solid rgba(var(--coral-rgb), 0.55);
font-family: "Courier Prime", "Courier New", monospace;
font-style: italic;
opacity: 0.8;
white-space: pre-wrap;
}
#quarto-margin-sidebar { display: none; }
.scene-code {
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.6rem 0.8rem;
margin: 0.9rem 0 1.1rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.88rem;
line-height: 1.45;
background: var(--panel);
color: var(--ink);
overflow-x: auto;
}
.scene-code pre { margin: 0; }
.scene-code code { background: none; padding: 0; }
/* Pressing Enroll flashes the name it confirms, the same name or a new
one, so the press can be seen to have done something. */
.name-confirm .pc-name.pc-flash {
animation: pc-flash 1.4s ease-out;
}
@keyframes pc-flash {
0%, 30% { color: var(--green); }
}</style><script src="scittle.js" type="application/javascript"></script><div style="display:none;"><script type="application/x-scittle">(ns fedoras.carry
"What the reader carries between chapters."
(:require [clojure.string :as str]))
(def ^:private book-key
"The registration on the front of the file. It is not a secret and it
is not pretending to be one — it is the number this is keyed to, and
it is the same number a reader has been looking at since August."
3812)
;; ---------------------------------------------------------------------
;; Bytes
;; ---------------------------------------------------------------------
(defn- to-bytes [s] (.encode (js/TextEncoder.) s))
(defn- from-bytes [b] (.decode (js/TextDecoder. "utf-8") b))
(defn- bytes->str [b]
(loop [i 0 acc ""]
(if (>= i (.-length b))
acc
(recur (inc i) (str acc (js/String.fromCharCode (aget b i)))))))
(defn- str->bytes [s]
(let [out (js/Uint8Array. (count s))]
(dotimes [i (count s)] (aset out i (.charCodeAt s i)))
out))
;; ---------------------------------------------------------------------
;; The keystream
;;
;; The same small xorshift the dice and the floors use, so there is one
;; generator in this project and not four.
;; ---------------------------------------------------------------------
(defn- keystream [n seed]
(let [out (js/Uint8Array. n)]
(loop [i 0 x (bit-or 1 (bit-and seed 0x7fffffff))]
(if (>= i n)
out
(let [x (bit-and (bit-xor x (bit-shift-left x 13)) 0x7fffffff)
x (bit-and (bit-xor x (unsigned-bit-shift-right x 17)) 0x7fffffff)
x (bit-and (bit-xor x (bit-shift-left x 5)) 0x7fffffff)]
(aset out i (bit-and x 0xff))
(recur (inc i) x))))))
(defn- mask [b seed]
(let [k (keystream (.-length b) seed)
out (js/Uint8Array. (.-length b))]
(dotimes [i (.-length b)] (aset out i (bit-xor (aget b i) (aget k i))))
out))
;; ---------------------------------------------------------------------
;; Base64, url-safe
;; ---------------------------------------------------------------------
(defn- b64 [b]
(-> (js/btoa (bytes->str b))
(str/replace "+" "-")
(str/replace "/" "_")
(str/replace "=" "")))
(defn- un-b64 [s]
(let [t (-> s (str/replace "-" "+") (str/replace "_" "/"))
pad (case (mod (count t) 4) 2 "==" 3 "=" "")]
(str->bytes (js/atob (str t pad)))))
;; ---------------------------------------------------------------------
;; Rolls, short
;; ---------------------------------------------------------------------
(defn- pack-rolls
"[roll mod rewrites], shortest form. A roll with no modifier and no
rewrites is one number."
[rolls]
(into {}
(for [[id {:keys [roll mod rewrites]}] rolls
:when roll]
[id (cond
(pos? (or rewrites 0)) [roll (or mod 0) rewrites]
(not (zero? (or mod 0))) [roll mod]
:else [roll])])))
(defn- unpack-rolls
"Every shape this has ever emitted: a bare number, the short vector,
and the verbose map that went out while `pack-rolls` was sitting here
unused. Tokens from before this change still read."
[m]
(into {}
(for [[id v] m]
[(clojure.core/name id)
(cond
(number? v) {:roll v :mod 0 :rewrites 0}
(map? v) {:roll (:roll v)
:mod (or (:mod v) 0)
:rewrites (or (:rewrites v) 0)}
:else {:roll (nth v 0)
:mod (nth v 1 0)
:rewrites (nth v 2 0)})])))
;; ---------------------------------------------------------------------
;; The token
;; ---------------------------------------------------------------------
(defn- checksum [b]
(loop [i 0 s 7]
(if (>= i (.-length b))
(mod s 251)
(recur (inc i) (mod (+ (* s 31) (aget b i)) 251)))))
(defn pack
"A map to a token. The keys are one letter each because this goes in
a query string thirty times a page."
[{:keys [name companion echo rolls deeds epoch todo]}]
(try
(let [payload (js/JSON.stringify
(clj->js
(cond-> {}
name (assoc :n name)
companion (assoc :a [(:n companion) (:given companion)])
(pos? (or echo 0)) (assoc :e echo)
(seq rolls) (assoc :r (pack-rolls rolls))
(seq deeds) (assoc :d (mapv clojure.core/name deeds))
;; when the reader last withdrew the whole record.
;; it travels because the alternative is that only
;; the state travels, and then a link from before
;; the withdrawal quietly reinstates it.
(pos? (or epoch 0)) (assoc :g epoch)
(seq todo) (assoc :t [(:seq todo 0) (:items todo)]))))
body (mask (to-bytes payload) book-key)
out (js/Uint8Array. (inc (.-length body)))]
(aset out 0 (checksum body))
(dotimes [i (.-length body)] (aset out (inc i) (aget body i)))
(b64 out))
(catch :default _ nil)))
(defn unpack
"A token back to a map, or nil if anybody has been at it.
An absent field comes back empty rather than missing, which is fine
and is not a licence: the caller merges this forward and knows that an
empty Echo means the token is old, not that the Echo is nought."
[s]
(try
(when-not (str/blank? s)
(let [raw (un-b64 s)
want (aget raw 0)
body (.slice raw 1)]
(when (= want (checksum body))
(let [m (js->clj (js/JSON.parse (from-bytes (mask body book-key)))
:keywordize-keys true)]
{:name (:n m)
:companion (when-let [a (:a m)] {:n (first a) :given (or (second a) "")})
:echo (or (:e m) 0)
:rolls (unpack-rolls (or (:r m) {}))
:deeds (mapv keyword (or (:d m) []))
:epoch (or (:g m) 0)
:todo (when-let [t (:t m)] {:seq (first t) :items (or (second t) [])})}))))
(catch :default _ nil)))
;; ---------------------------------------------------------------------
;; When it does not reconcile
;; ---------------------------------------------------------------------
(defn complain! []
(js/console.warn
"016.09 — entry does not reconcile and has not been carried forward. Retained."))</script><script type="application/x-scittle">(ns fedoras.reader
"Everything the reader owns.
One atom: containing Name, animal, echo, fortune, score, deeds. A widget
reads it and never touches storage directly, and every change goes through
one of the setters, which writes, re-reads, and lets the watches fire."
(:require [clojure.string :as str]
[fedoras.carry :as carry]))
;; ---------------------------------------------------------------------
;; Storage
;; ---------------------------------------------------------------------
(def prefix "fedoras.")
(def carry-param :f)
(def forget-param
"A page that loads with this on it withdraws its own record and stops.
It is a separate field from the token because it must work on a page
that has never seen a token."
:forget)
(def withdrawn-param
"Survives exactly one reload, to say the thing was done. The state it
would otherwise have been recorded in has just been deleted, which is
the one place a confirmation cannot live."
:withdrawn)
(def legacy-params
"The fields the query string used to carry, one per fact. Stripped off
any link that still has them. There was a second copy of this list
called `carried-params` and the two had drifted."
[:pc :pcl :ac :acn :echo :fortune :score])
(defn- read-json [s]
(js->clj (js/JSON.parse s) :keywordize-keys true))
(defn- write-json [v]
(js/JSON.stringify (clj->js v)))
(defn stored [key]
(try
(when-let [s (.getItem js/localStorage (str prefix (name key)))]
(read-json s))
(catch :default _ nil)))
(defn store! [key val]
(try (.setItem js/localStorage (str prefix (name key)) (write-json val))
(catch :default _ nil))
val)
(defn remove!
"Take something out rather than writing an empty one over it, because
a stored nil and a missing key read back the same and only one of them
is what was meant."
[key]
(try (.removeItem js/localStorage (str prefix (clojure.core/name key)))
(catch :default _ nil)))
(defn storage-works?
"Whether anything at all is going to be persisted.
On a file:// path some browsers give every page its own opaque origin,
which means localStorage throws and the whole book forgets the reader
the moment they click anything. Worth knowing rather than guessing."
[]
(try
(.setItem js/localStorage (str prefix "probe") "1")
(let [ok (= "1" (.getItem js/localStorage (str prefix "probe")))]
(.removeItem js/localStorage (str prefix "probe"))
ok)
(catch :default _ false)))
;; ---------------------------------------------------------------------
;; The query string, which is a transport and nothing else
;; ---------------------------------------------------------------------
(defn- current-url [] (js/URL. (.-href js/location)))
(defn param [key]
(try
(let [v (.get (.-searchParams (current-url)) (name key))]
(when-not (str/blank? v) v))
(catch :default _ nil)))
(defn set-params! [m]
(try
(let [u (current-url) sp (.-searchParams u)]
(doseq [[k v] m]
(if (or (nil? v) (= "" v))
(.delete sp (name k))
(.set sp (name k) (str v))))
(.replaceState js/history nil "" (.toString u)))
(catch :default _ nil)))
(defn- int-or [v d]
(let [n (js/parseInt (str v) 10)] (if (js/isNaN n) d n)))
;; ---------------------------------------------------------------------
;; The atom
;; ---------------------------------------------------------------------
(def default-name ["Owen" "Mercer"])
(def state (atom nil))
(def animal-names
"Number to the animal's grammar: what it is called, what more than one
of them is called, what a ledger would file it under. Filled in by
`fedoras.state/assets` from a later script tag, so it is empty for the
moment between this file being read and the roster arriving, and
nothing here may treat an empty roster as a missing roll."
(atom {}))
(defn- as-grammar
"One roster entry, whatever shape it turned up in.
The page used to be handed {1 \"Pigeon\"} and is now handed
{1 {:animal \"Pigeon\" :plural \"pigeons\" ...}}, and a book whose
`names-form` has not caught up with its roster hands over one of each.
A bare string is an animal with no grammar, which is precisely what it
was before, so it becomes one here.
This is normalised at the boundary rather than at each use because the
alternative was a `merge` three functions away receiving a string,
throwing `conj on a map takes map entries`, and taking down every
render watch on the page — a build that is one function out of step
should read a little worse, not stop."
[v]
(cond
(map? v) v
(string? v) {:animal v}
:else {}))
(defn- read-companion []
(let [s (stored :companion)
n (int-or (first s) nil)]
(when (and n (<= 1 n 20))
(let [given (or (nth s 2 nil) "")
g (as-grammar (get @animal-names n))
;; the animal's name is a convenience and the roster may not
;; have landed yet. the number is the fact.
animal (or (:animal g) (nth s 1 nil) "")
named? (not (str/blank? given))]
(merge {:plural "" :taxonomy "" :collective "" :other-names []}
g
{:n n
:animal animal
:given given
:named? named?
:name (cond named? (str/trim given)
(not (str/blank? animal)) animal
:else (str "d20 " n))})))))
(defn- read-name []
(let [s (stored :pc)]
[(or (first s) (first default-name))
(or (second s) (second default-name))]))
(defn refresh!
"Total on purpose. Every render watch in the book hangs off this atom,
so anything that throws in here blanks the whole page, and what it
reads is JSON that a previous version of this file wrote, which is not
a thing to be optimistic about. A key that is the wrong shape reads as
absent."
[]
(reset! state
{:name (read-name)
:companion (try (read-companion) (catch :default _ nil))
:echo (int-or (stored :echo) 0)
:rolls (let [r (stored :rolls)] (if (map? r) r {}))
:deeds (let [d (stored :deeds)]
(set (map keyword (if (sequential? d) d []))))
:epoch (int-or (stored :epoch) 0)
:todo (let [t (stored :todo)] (if (sequential? t) t []))
:todo-seq (int-or (stored :todo-seq) 0)}))
;; ---------------------------------------------------------------------
;; What a chapter may ask for
;; ---------------------------------------------------------------------
(defn reader-name [] (:name @state))
(defn companion [] (:companion @state))
(defn echo [] (:echo @state))
(defn deeds [] (:deeds @state))
(defn fortune [] (count (:deeds @state)))
(defn epoch
"When this reader last withdrew the whole record, in seconds, or
nought if they never have.
It is not a clock and nothing displays it. It exists so that a
withdrawal can be compared against a link: a token stamped earlier
than this was written by somebody who has since been forgotten, and it
is not allowed to put them back."
[]
(:epoch @state))
(defn standing
"Fortune less echo. Added to every roll for the rest of the book, and
the number the last chapter reads to decide how it ends."
[]
(- (fortune) (echo)))
(defn registration [] "3812")
;; ---------------------------------------------------------------------
;; And what it may change
;; ---------------------------------------------------------------------
(defn set-name! [f l]
(store! :pc [(if (str/blank? f) (first default-name) (str/trim f))
(if (str/blank? l) (second default-name) (str/trim l))])
(refresh!))
(defn set-companion!
"Three cells: the number, the animal's own name as a courtesy, and
whatever the reader called it. The number is the only one that has to
be right, because the other two are recovered from the roster."
[n given]
(store! :companion [n (get-in @animal-names [n :animal] "") (or given "")])
(refresh!))
(defn set-echo! [n] (store! :echo (max 0 n)) (refresh!))
(defn add-echo!
"One more thing taken back."
[]
(set-echo! (inc (echo))))
(defn did!
"Record a deed. A set, so doing the same thing twice is doing it once,
and nothing anywhere says that it happened."
[k]
(store! :deeds (mapv name (conj (deeds) (keyword k))))
(refresh!))
(defn animals!
"The roster's grammar, from the page. Normalised on the way in, so the
shape `fedoras.state/names-form` happens to be emitting this week is
something only this function has an opinion about.
Refreshes, because a companion read before this arrived is holding an
empty animal name."
[m]
(reset! animal-names
(if (map? m)
(into {} (map (fn [[k v]] [k (as-grammar v)])) m)
{}))
(refresh!))
(defn rolls
"Every check the reader has resolved, by id.
Normalised on the way out. Storage keywordizes keys on read, and a
check id is an arbitrary string rather than a name, so the keys come
back as keywords and nothing matches them. And anything that is not a
map at all becomes an empty one, because assoc on a vector with a
string key takes the page down."
[]
(let [r (:rolls @state)]
(if (map? r)
(into {} (map (fn [[k v]] [(name k) v])) r)
{})))
(defn roll-entry [id]
(when-let [e (get (rolls) id)]
{:roll (or (:roll e) (get e "roll"))
:mod (or (:mod e) (get e "mod") 0)
:rewrites (or (:rewrites e) (get e "rewrites") 0)}))
(defn remember-roll! [id e]
(store! :rolls (assoc (rolls) id e))
(refresh!))
(defn score []
(+ (reduce + 0 (keep :roll (vals (rolls))))
(or (:n (companion)) 0)))
;; ---------------------------------------------------------------------
;; Import, once, on arrival
;; ---------------------------------------------------------------------
(def carries-epoch?
"Whether the codec on this page round-trips a withdrawal stamp."
(try
(= 12345 (:epoch (carry/unpack
(carry/pack {:name ["Owen" "Mercer"] :epoch 12345}))))
(catch :default _ false)))
(defn- wipe!
"Everything under the prefix, and then one thing: when that happened."
[at]
(try
(let [ks (vec (for [i (range (.-length js/localStorage))
:let [k (.key js/localStorage i)]
:when (and k (str/starts-with? k prefix))]
k))]
(doseq [k ks] (.removeItem js/localStorage k)))
(catch :default _ nil))
(store! :epoch at))
(defn- merge-token!
[{:keys [name companion echo rolls deeds epoch todo]}]
(let [ours (int-or (stored :epoch) 0)
theirs (or epoch 0)]
(cond
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them. This is the
;; case that made `forget!` a lie: it emptied one page's storage
;; and every other page, and every entry in the back history, was
;; still holding a complete copy and handed it straight back.
;; A link from before the reader withdrew the record. It is a true
;; account of somebody who does not exist any more, which is not
;; the same thing as an instruction to rebuild them.
;;
;; Only when the codec can say so. A page that cannot read a stamp
;; off a token must not conclude from its absence that every link
;; in the book is stale.
(and carries-epoch? (< theirs ours))
(js/console.info
(str "016.09 — the link this page was opened from was written before the record "
"was withdrawn. Nothing carried forward. Reach this chapter from a page you "
"have opened since, and whatever is current will arrive with you."))
:else
(do
;; They withdrew it somewhere this page has not heard about yet.
;; The erasure travels the way the state travels, which is the
;; only way anything travels in a book read off a disk.
(when (and carries-epoch? (> theirs ours))
(wipe! theirs)
(js/console.info
"016.09 — prior record withdrawn at the reader's request. Nothing carried forward."))
;; the name is the one thing a reader can retype in five seconds,
;; so the token is allowed to have an opinion about it
(when (seq name)
(store! :pc name))
;; The animal:
;;
;; This was write-once — take the token's only if there is
;; nothing here — which is right on one origin and wrong on a
;; folder of files, where every page has its own storage and the
;; token is the only thing that crosses. A reader who opened Act
;; II after rolling and before naming taught that page the horse,
;; permanently, and Act II could never afterwards learn that the
;; horse was called Horace.
;;
;; The Echo is the recency stamp. It only ever goes up, and a
;; reroll raises it, so a token carrying an Echo at least as
;; large as this page's is at least as new as this page and is
;; allowed to speak. One older, and it is a link from before the
;; reroll and is not.
(let [have (read-companion)
here-echo (int-or (stored :echo) 0)
there-echo (or echo 0)]
(when (and (:n companion)
(or (nil? have) (>= there-echo here-echo)))
(store! :companion
[(:n companion) ""
;; a blank name in a token is a reader who had not
;; named it yet, not a reader who unnamed it
(if (str/blank? (:given companion))
(or (:given have) "")
(:given companion))])))
;; upward only, always. after the companion, which reads it.
(store! :echo (max (int-or (stored :echo) 0) (or echo 0)))
;; a check is resolved once, and what is already in this browser
;; was rolled by this reader, so it wins every collision
(when (seq rolls)
(let [named (fn [m] (if (map? m)
(into {} (map (fn [[k v]] [(clojure.core/name k) v])) m)
{}))]
(store! :rolls (merge (named rolls) (named (stored :rolls))))))
;; deeds are a set, and a set does not get smaller by being described
(when (seq deeds)
(let [held (let [d (stored :deeds)] (if (sequential? d) d []))]
(store! :deeds (vec (into (set (map clojure.core/name held))
(map clojure.core/name deeds))))))
(when-let [t todo]
(when (>= (:seq t 0) (int-or (stored :todo-seq) 0))
(store! :todo (:items t))
(store! :todo-seq (:seq t))))))))
(defn- import-token! []
(if-let [t (param carry-param)]
(if-let [m (try (fedoras.carry/unpack t) (catch :default _ nil))]
(merge-token! m)
(try (fedoras.carry/complain!) (catch :default _ nil)))
;; nothing to do, unless somebody has a link from before the token
(do
(when-let [pc (param :pc)]
(store! :pc [pc (or (param :pcl) (second default-name))]))
(when-let [ac (param :ac)]
(let [n (int-or ac nil)]
(when (and n (<= 1 n 20) (nil? (read-companion)))
(store! :companion [n "" (or (param :acn) "")]))))
(when-let [e (param :echo)]
(store! :echo (max (int-or (stored :echo) 0) (int-or e 0)))))))
(defn- withdraw-if-asked!
"A page loaded with a withdrawal stamp on it clears itself and does
nothing else.
This is how one button reaches twelve documents that cannot see each
other. The page the reader clicked opens each of the others in a
hidden frame with the stamp in the query string, and each one runs
this on the way up. Nothing is scripted across the boundary — every
file is its own origin and none of them can touch another's storage —
but a document can always clear its own, and a query string is the one
thing that crosses.
Returns true if this load was a withdrawal, so boot knows to skip the
rest of its work."
[]
(when-let [at (some-> (param forget-param) (int-or nil))]
(when (> at (int-or (stored :epoch) 0))
(wipe! at))
true))
;; ---------------------------------------------------------------------
;; Rendering
;; ---------------------------------------------------------------------
(defn on-render! [k f] (add-watch state k (fn [_ _ _ _] (f))) (f))
(defn el [id] (.getElementById js/document id))
(defn q [node selector] (when node (.querySelector node selector)))
(defn q-all
"Every match, as a vector. One argument searches the document; two
searches inside a node.
Written the long way because a NodeList is not a Clojure collection
and array-seq is not in Scittle's bindings."
([selector] (q-all js/document selector))
([node selector]
(if-not node
[]
(let [nodes (.querySelectorAll node selector)]
(mapv (fn [i] (.item nodes i)) (range (.-length nodes)))))))
(defn text! [node selector s]
(when-let [t (q node selector)] (set! (.-textContent t) s)))
(defn on-click! [node selector f]
(when-let [b (q node selector)] (set! (.-onclick b) f)))
(defn enable! [node selector on? label]
(when-let [b (q node selector)]
(set! (.-disabled b) (not on?))
(when label (set! (.-textContent b) label))))
(defn show! [node selector on?]
(when-let [t (q node selector)]
(set! (.-display (.-style t)) (if on? "flex" "none"))))
(defn widget [k id f]
(on-render! k (fn [] (when-let [node (el id)] (f node)))))
;; ---------------------------------------------------------------------
;; Withdrawing the record, everywhere
;;
;; Twelve documents, each its own origin, none able to read another's
;; storage. Every shared channel is shut: localStorage and IndexedDB are
;; origin-scoped, window.name is cleared on the way across, cookies do
;; not apply to a file, and an iframe gets its own origin so the parent
;; cannot script it.
;;
;; It can still LOAD it. A hidden frame pointed at a sibling chapter,
;; with a stamp in the query string, runs that chapter's own copy of
;; this file, which clears that chapter's own storage. Nothing crosses
;; the boundary except the URL, which is the one thing that was ever
;; going to.
;; ---------------------------------------------------------------------
(defn- sibling-pages
"Every other chapter this page links to.
Read off the sidebar rather than configured, because Quarto already
puts the whole book in it and a hand-kept list of filenames is a list
that goes stale the first time a chapter is renamed."
[]
(let [here (.-pathname js/location)
dir (subs here 0 (inc (.lastIndexOf here "/")))]
(->> (q-all "a[href]")
(keep (fn [a]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [u (js/URL. (.-href a))
p (.-pathname u)]
(when (and (str/starts-with? p dir)
(str/ends-with? (str/lower-case p) ".html")
(not= p here))
(str (.-protocol u) "//" (.-host u) p)))
(catch :default _ nil))))))
distinct
vec)))
(defn- sweep!
"Tell every other chapter to withdraw, then call `done` once.
Once, whatever happens: a frame that never fires must not leave the
reader looking at a button that has stopped meaning anything, so there
is a deadline as well as a count."
[at done]
(let [pages (sibling-pages)
fired (atom false)
finish (fn [] (when-not @fired (reset! fired true) (done)))]
(if (empty? pages)
(finish)
(let [box (.createElement js/document "div")
left (atom (count pages))
one (fn [] (when (zero? (swap! left dec)) (finish)))]
(js/console.info "016.09 — withdrawing from" (count pages) "other chapters")
(set! (.-style box)
"position:absolute;width:0;height:0;overflow:hidden;border:0;visibility:hidden")
(.appendChild (.-body js/document) box)
(doseq [p pages]
(let [f (.createElement js/document "iframe")]
(set! (.-onload f) (fn [_] (one)))
(set! (.-onerror f) (fn [_] (one)))
(set! (.-src f) (str p "?" (clojure.core/name forget-param) "=" at))
(.appendChild box f)))
(js/setTimeout finish 5000)))))
(defn forget!
"Forget everything, not the six things somebody remembered to list,
and not only on this page.
Emptying this document's storage was never the whole job: the reader
was also sitting, complete, in the query string of every link on every
other chapter and in every entry of the back history, and the next
navigation put them back. So this clears here, stamps when, and then
visits every other chapter to say so. The stamp is what makes a link
written before the withdrawal refuse to be believed afterwards.
It also says so while it is happening. It used to do all of this in
silence and then reload, which is indistinguishable from a button that
does nothing except reload."
[]
(let [at (quot (.now js/Date) 1000)
btn (q js/document ".pc-forget")]
(when btn
(set! (.-disabled btn) true)
(set! (.-textContent btn) "Withdrawing the record…"))
(wipe! at)
(sweep! at
(fn []
(let [u (current-url)]
(doseq [k (concat [carry-param forget-param] legacy-params)]
(.delete (.-searchParams u) (clojure.core/name k)))
(.set (.-searchParams u) (clojure.core/name withdrawn-param) "1")
(set! (.-href js/location) (.toString u)))))))
(defn- note-withdrawal!
"One line, on the way back up, and then the button goes back to
offering. The confirmation cannot be stored, because storing things is
what has just been undone — so it is read off the query string once,
before anything else in boot is allowed to tidy the query string away."
[withdrew?]
(when withdrew?
(js/console.info
"016.09 — record withdrawn from this chapter and from every chapter linked from it.")
(when-let [b (q js/document ".pc-forget")]
(set! (.-textContent b) "Record withdrawn")
(js/setTimeout (fn [] (set! (.-textContent b) "Forget everything")) 5000))))
;; ---------------------------------------------------------------------
;; Export
;; ---------------------------------------------------------------------
(defn token
"What this reader looks like from outside. Recomputed rather than
stored, so it cannot disagree with the state it is describing.
Guarded, because this runs on every render and inside `decorate-links!`,
and a codec that throws on one unexpected key should cost the reader a
shareable link and nothing else."
[]
(try
(let [{:keys [name companion echo rolls deeds todo todo-seq]} @state]
(fedoras.carry/pack
{:name name
:companion (when companion (select-keys companion [:n :given]))
:echo echo
:rolls rolls
:deeds deeds
:epoch (epoch)
:todo (when (seq todo) {:seq (or todo-seq 0) :items todo})}))
(catch :default e
(js/console.warn "fedoras: could not pack the carry token —" e)
nil)))
(defn decorate-links!
"Every internal link carries one field.
Two things were wrong before this and both of them mattered. It ran
inline, at parse time, so any link below the script tag did not exist
yet — which is every prev-and-next arrow Quarto puts in the footer.
And it copied the query string rather than the state, so a page opened
from storage handed on nothing."
[]
(when-let [t (token)]
(doseq [a (q-all "a[href]")]
(let [h (.getAttribute a "href")]
(when (and h
(not (str/starts-with? h "#"))
(not (re-find #"(?i)^(https?:|mailto:|javascript:)" h)))
(try
(let [lu (js/URL. (.-href a))
sp (.-searchParams lu)]
(doseq [k legacy-params] (.delete sp (clojure.core/name k)))
(.set sp (clojure.core/name carry-param) t)
(set! (.-href a) (.toString lu)))
(catch :default _ nil)))))))
(defn- rewrite-address!
"Put the token in this page's own address bar and take the old fields
off it, so a reader who arrived on a legacy link stops looking at
their own Echo."
[]
(when-let [t (token)]
(set-params! (into {(clojure.core/name carry-param) t}
(map (fn [k] [k nil])
(concat [forget-param withdrawn-param] legacy-params))))))
;; ---------------------------------------------------------------------
;; Tokens
;;
;; It used to be a chain of str/replace calls, which is why {pc-last}
;; worked in a scene and rendered as the literal characters {pc-last}
;; inside a dice passage.
;;
;; Case follows the token: {ac} is `ted`, {Ac} is `Ted`, {AC} is `TED`,
;; so a passage can start a sentence or write a cue line without the
;; author reaching for a second token. Note that this makes {PC} in an
;; already-written scene render as OWEN rather than Owen; the old regex
;; was case-insensitive and threw the information away.
;;
;; An unkown token is passed over: A brace in a passage that is not
;; something this understands comes out exactly as it went in, so this
;; is safe to run over arbitrary prose.
;; ---------------------------------------------------------------------
(def ^:private token-re #"\{([A-Za-z]+)(-[A-Za-z-]+)?\}")
(defn- apply-case [head s]
(cond
(str/blank? s) s
(= head (str/upper-case head)) (str/upper-case s)
(= (subs head 0 1) (str/upper-case (subs head 0 1)))
(str (str/upper-case (subs s 0 1)) (subs s 1))
:else s))
(defn render
"Replace every {token} the resolver knows. `resolve` is called with the
lower-cased head and the lower-cased tail — \"ac\" and \"-plural\" for
{ac-plural} — and returns a string or nil."
[resolve s]
(str/replace (or s "") token-re
(fn [m]
(let [[whole head tail] (if (string? m) [m m nil] m)]
(or (some->> (resolve (str/lower-case head)
(some-> tail str/lower-case))
(apply-case head))
whole)))))
(defn- string-hash
"Stable across loads, which is the point: the same sentence picks the
same alternative name every time the reader opens the page, so nothing
reads as though it were rolled twice."
[s]
(reduce (fn [a i] (mod (+ (* 31 a) (.charCodeAt s i)) 1000003))
7 (range (count s))))
(defn- an [s]
(str (if (re-find #"(?i)^[aeiou]" (str s)) "an " "a ") s))
(defn- animal-tokens
"What a passage may say about the animal. The reader's own name for it
is a name; everything else is the species, because a reader who called
a marmot Ted did not thereby decide that two of them are Teds."
[source tail]
(when-let [{:keys [animal plural taxonomy collective other-names name named?]}
(companion)]
(let [kind (str/lower-case (or animal ""))]
(case (or tail "-name")
"-name" (if named? name (str "the " kind))
"-kind" kind
"-a" (an kind)
"-plural" (if (str/blank? plural) (str kind "s") (str/lower-case plural))
"-many" (if (str/blank? collective)
(str "several " (if (str/blank? plural) (str kind "s") plural))
(str (an collective) " of "
(if (str/blank? plural) (str kind "s") plural)))
"-taxonomy" (str/lower-case (or taxonomy kind))
"-given" (if named? name "")
"-other" (if (seq other-names)
(nth other-names (mod (string-hash source) (count other-names)))
(str "the " kind))
nil))))
(defn- reader-tokens [source]
(let [[f l] (reader-name)]
(fn [head tail]
(case head
"pc" (case (or tail "-first")
"-first" f
"-full" (str f " " l)
"-last" l
nil)
"ac" (or (animal-tokens source tail)
;; no roll yet, and a passage still has to read
(case (or tail "-name")
("-name" "-kind" "-other") "the animal"
"-a" "an animal"
"-plural" "animals"
"-many" "several animals"
"-taxonomy" "animal"
"-given" ""
nil))
nil))))
(defn fill-with
"Put the reader into a passage, plus whatever else the caller knows.
The dice box passes the die in this way."
[extra s]
(let [own (reader-tokens s)]
(render (fn [h t] (or (extra h t) (own h t))) s)))
(defn fill
"Put the reader into a passage. The only place any of these are
resolved."
[s]
(fill-with (fn [_ _] nil) s))
(defn animal-label
"Kept for anything still asking. The display name, as stored, not
shouted — the old one upper-cased and every caller lower-cased it
again somewhere else."
[]
(when-let [c (companion)] (:name c)))
;; ---------------------------------------------------------------------
;; The carry-over
;;
;; What a reader takes to the next book.
;; ---------------------------------------------------------------------
(def on-the-record
"What goes in the token. Everything else is furniture.
A key is on this list because it is small and because losing it would
lose the reader's year. Adding one is a decision about how long every
link on every page is going to be, so it is a list and not a
convention."
#{:pc :companion :echo :rolls :deeds :todo})
(defn kept
"Read something the reader left in a room. No widget that calls this
needs to know anything about tokens."
[k]
(stored k))
(defn keep!
"Leave something in a room. It stays where it was made."
[k v]
(store! k v)
(when (contains? on-the-record k) (refresh!))
v)
(def ^:private alphabet "23456789ABCDEFGHJKLMNPQRSTUVWXYZ")
(defn- b32 [n len]
(loop [n n i 0 out ""]
(if (= i len)
out
(recur (quot n 32) (inc i)
(str (nth alphabet (mod n 32)) out)))))
;; ---------------------------------------------------------------------
;; The name and the animal, on every page
;; ---------------------------------------------------------------------
(defn- paint-name []
(let [[f l] (reader-name)]
(doseq [node (q-all ".pc-name")]
(set! (.-textContent node)
(apply-case (or (.getAttribute node "data-case") "pc")
(case (.getAttribute node "data-pc")
"-full" (str f " " l) "-last" l f))))
(when-let [i (el "pc-first")] (when (str/blank? (.-value i)) (set! (.-value i) f)))
(when-let [i (el "pc-last")] (when (str/blank? (.-value i)) (set! (.-value i) l)))))
(defn- paint-companion
"Each span carries the token that made it, so {ac-plural} written in a
scene at build time still says `marmots` after the reader rolls a
marmot. It used to carry nothing and every span got the same word."
[]
(doseq [node (q-all ".ac-name")]
(let [tail (or (.getAttribute node "data-ac") "-name")
head (or (.getAttribute node "data-case") "ac")]
(set! (.-textContent node)
(or (some->> ((reader-tokens (or (.-textContent node) "")) "ac" tail)
(apply-case head))
"the animal")))))
(defn- paint-tokens []
(doseq [node (q-all ".tok")]
(set! (.-textContent node) (fill (.getAttribute node "data-tok")))))
;; ---------------------------------------------------------------------
;; Ending / Standing
;; ---------------------------------------------------------------------
(def deed-roll
[:nerve ;; a natural twenty with the Echo at nought
:named-it ;; the animal was given a name and kept it
:kept-the-programmes ;; both sheets went in the bag and stayed there
:no-name-for-the-box ;; nothing was spoken into the undertaker's box
:read-it-back ;; the ledger was read against the room out loud
:opened-the-cage
:held-the-door
:went-back-for-it
:signed-nothing
:walked-out
:told-a-new-one ;; Ariathne was paid in something that was theirs
:said-why]) ;; the chancellor asked, and got a true answer
(def ^:private deed-bits 15)
(defn- deed-mask []
(let [held (deeds)]
(reduce (fn [acc [index deed]]
(if (contains? held deed)
(+ acc (bit-shift-left 1 index))
acc))
0
(map-indexed vector deed-roll))))
(defn- deeds-from-mask [mask]
(set (keep-indexed (fn [index deed]
(when (pos? (bit-and mask (bit-shift-left 1 index)))
deed))
deed-roll)))
(def band-code {:closed 0 :reconciled 1 :outstanding 2})
(def code-band {0 :closed 1 :reconciled 2 :outstanding})
(defn ending-band []
(let [reader-standing (standing)]
(cond
(>= reader-standing 4) :outstanding
(>= reader-standing 0) :reconciled
:else :closed)))
(defn- paint-standing!
"One class on the body, so a chapter that forks three ways does it in
the stylesheet instead of in nine lines of DOM work per passage. A page
with no class on it shows :reconciled, which is what the print edition
gets and what the table actually got.
This existed and was never wired to anything, which meant every
`sp/by-standing` on every page was rendering all three endings at
once, or none."
[]
(when-let [body (.-body js/document)]
(let [classes (.-classList body)]
(doseq [old ["standing-closed" "standing-reconciled" "standing-outstanding"]]
(.remove classes old))
(.add classes (str "standing-" (clojure.core/name (ending-band)))))))
(def code-version 3)
(defn- check-char [body]
(nth alphabet
(mod (reduce + (map (fn [c] (.indexOf alphabet c)) body)) 32)))
(defn carry-code
"What this reader looks like to the next book, as an accession line."
[]
(let [n (+ (* code-version 4294967296)
(* (get band-code (ending-band) 1) 1073741824)
(* (min 31 (or (:n (companion)) 0)) 33554432)
(* (min 31 (echo)) 1048576)
(* (deed-mask) 32)
(min 31 (quot (score) 10)))
body (b32 n 7)]
(str "016.14 · " (registration) " · "
(subs body 0 4) "-" (subs body 4) (check-char body))))
(defn read-carry
"The other side of it, for the book after this one."
[s]
(let [cleaned (-> (str s) str/upper-case (str/replace #"[^2-9A-Z]" ""))
tail (subs cleaned (max 0 (- (count cleaned) 8)))]
(when (= 8 (count tail))
(let [body (subs tail 0 7)
n (reduce (fn [acc c] (+ (* 32 acc) (.indexOf alphabet c))) 0 body)]
(when (= (nth tail 7) (check-char body))
{:version (quot n 4294967296)
:band (get code-band (mod (quot n 1073741824) 4))
:companion (mod (quot n 33554432) 32)
:echo (mod (quot n 1048576) 32)
:deeds (deeds-from-mask (mod (quot n 32) (bit-shift-left 1 deed-bits)))
:score-floor (* 10 (mod n 32))})))))
;; ---------------------------------------------------------------------
;; Is any of this working
;;
;; Type fedoras.reader.diagnose_BANG_() in the console. Faster than
;; opening the storage inspector and reading JSON, and it says whether
;; the token agrees with the storage, which is the failure that cost an
;; animal.
;; ---------------------------------------------------------------------
(defn diagnose! []
(let [t (param carry-param)]
(js/console.log "storage works: " (storage-works?))
(js/console.log "origin: " (.-origin js/location)
(if (= "file:" (.-protocol js/location))
"— every page may have its own storage"
"— one bucket for the whole book"))
(js/console.log "name: " (pr-str (reader-name)))
(js/console.log "companion: " (pr-str (companion)))
(js/console.log " raw storage: " (pr-str (stored :companion)))
(js/console.log "roster loaded: " (count @animal-names) "animals")
(js/console.log "echo / fortune: " (echo) "/" (fortune) " → standing" (standing))
(js/console.log "withdrawn at: " (if (pos? (epoch)) (epoch) "never")
(if carries-epoch?
""
"— CODEC DROPS THE STAMP, carry.cljs is out of date"))
(js/console.log "rolls: " (pr-str (rolls)))
(js/console.log "token on url: " (or t "none"))
(when t
(js/console.log " unpacks to: "
(pr-str (try (fedoras.carry/unpack t)
(catch :default e (str "THREW: " e))))))
(js/console.log "token we'd emit:" (or (token) "none — pack failed"))
:ok))
;; A handle on `window`, because Scittle keeps its namespaces inside the
;; SCI context and the browser console cannot see into it. There is no
;; `fedoras.reader.diagnose_BANG_()` to call — that is the shape a
;; compiled build would have, and this is an interpreted one.
;;
;; fedoras.diagnose()
;; fedoras.forget()
;;
;; and for anything else, scittle.core.eval_string("(fedoras.reader/...)")
(set! (.-fedoras js/window)
(js-obj "diagnose" (fn [] (diagnose!))
"state" (fn [] (clj->js @state))
"token" (fn [] (token))
"forget" (fn [] (forget!))))
;; ---------------------------------------------------------------------
;; Boot
;; ---------------------------------------------------------------------
(if (withdraw-if-asked!)
;; a hidden frame, doing one job. no token, no links, no painting.
(refresh!)
;; read before anything registers a watch: `on-render!` calls its
;; function once on the spot, and the links watch tidies the query
;; string, so the flag is gone by the time the DOM is ready
(let [withdrew? (boolean (param withdrawn-param))]
(when-not carries-epoch?
(js/console.warn
(str "fedoras: carry.cljs does not round-trip the withdrawal stamp. "
"`forget!` will clear this chapter and every chapter linked from it, "
"but a stale link will not be refused afterwards. "
"Update resources/fedoras/carry.cljs — `pack` needs :epoch in its "
"destructuring and :g in its payload, and `unpack` needs :epoch back out.")))
(import-token!)
(refresh!)
(on-render! ::name paint-name)
(on-render! ::companion paint-companion)
(on-render! ::tokens paint-tokens)
(on-render! ::standing paint-standing!)
(on-render! ::links (fn [] (decorate-links!) (rewrite-address!)))
(if (= "loading" (.-readyState js/document))
(.addEventListener js/document "DOMContentLoaded"
(fn [_]
(note-withdrawal! withdrew?)
(decorate-links!)
(paint-standing!)))
(do (note-withdrawal! withdrew?)
(decorate-links!)))))</script><script type="application/x-scittle">(fedoras.reader/animals! {1 {:animal "Pigeon", :plural "pigeons", :taxonomy "bird", :collective "kit", :other-names ["dusty wings" "coooo"]}, 2 {:animal "Giant Centipede", :plural "centipedes", :taxonomy "arthropod", :collective "colony", :other-names ["clicker" "legs gooo"]}, 3 {:animal "Marmot", :plural "marmots", :taxonomy "rodent", :collective "madness", :other-names ["the alarm" "the fat rat"]}, 4 {:animal "Cat", :plural "cats", :taxonomy "felid", :collective "clowder", :other-names ["the cat" "cat"]}, 5 {:animal "Turkey", :plural "turkeys", :taxonomy "fowl", :collective "rafter", :other-names ["thanksgiving dinner for non vegans" "gobbler"]}, 6 {:animal "Giant Badger", :plural "badgers", :taxonomy "mustelid", :collective "cete", :other-names ["angry grandpa" "stripes"]}, 7 {:animal "Giant Weasel", :plural "weasels", :taxonomy "mustelid", :collective "sneak", :other-names ["the scout" "slenderman" "the ermine"]}, 8 {:animal "Fox", :plural "foxes", :taxonomy "canid", :collective "skulk", :other-names ["foxxxy" "spry foot"]}, 9 {:animal "Wolverine", :plural "wolverines", :taxonomy "mustelid", :collective "gang", :other-names ["the small trouble"]}, 10 {:animal "Dog", :plural "dogs", :taxonomy "canid", :collective "pack", :other-names ["the dog" "the ordinary dog"]}, 11 {:animal "Python", :plural "pythons", :taxonomy "serpent", :collective "knot", :other-names ["ssSSssSsss" "coilssss"]}, 12 {:animal "Mule", :plural "mules", :taxonomy "equid", :collective "barren", :other-names ["dapple" "HeeHaw :("]}, 13 {:animal "Lynx", :plural "lynxes", :taxonomy "felid", :collective "clowder", :other-names ["tufts" "yelper"]}, 14 {:animal "Ape", :plural "apes", :taxonomy "primate", :collective "shrewdness", :other-names ["treecrawler"]}, 15 {:animal "Horse", :plural "horses", :taxonomy "equid", :collective "harras", :other-names ["rocinante" "the one nobody rides"]}, 16 {:animal "Moose", :plural "moose", :taxonomy "cervid", :collective "herd", :other-names ["its a giant moose"]}, 17 {:animal "Wyrmling", :plural "wyrmlings", :taxonomy "draconic", :collective "clutch", :other-names ["the wyrmling" "scales"]}, 18 {:animal "Falcon", :plural "falcons", :taxonomy "raptor", :collective "cast", :other-names ["the raptor" "true sight"]}, 19 {:animal "Wolf", :plural "wolves", :taxonomy "canid", :collective "pack", :other-names ["the wolf" "the packleader"]}, 20 {:animal "Bear", :plural "bears", :taxonomy "ursid", :collective "sloth", :other-names ["the bear" "the citadel"]}})</script></div></div>
```
# Scene 3 — Paint
```{=html}
<span style="display:none;"></span>
```
```{=html}
<blockquote class="handbook"><p class="rule-number">The Fedora Handbook, Rule #13</p><p class="rule-text">“Bylaws are laws that happen to be nearby.”</p></blockquote>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="slug">INT. STERLING CENTER — FOURTH FLOOR — MONDAY 23 MARCH, 9:00 A.M.</p></div><div class="screenplay"><p class="action">Systems is one long room with eleven desks in it and four people at them. DUNN is at the end with the specifications in a tray, and the tray has never been empty and has never had more than six things in it.</p><p class="action">The woman from the atrium is standing by the window with a coffee she brought in with her. The lanyard says VANE.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Morning. Bring it and come next door.</p></div></div></div><div class="screenplay"><p class="action">Next door is a room on the same floor with no nameplate on it, which is the second room he has been in this year with no nameplate on it.</p><p class="action">It has a table, two chairs on the same side of it, and a screen on the wall which is on and showing nothing.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Right. I'm going to talk for two minutes and then</p><p class="line">you can ask me anything you like and I'll answer</p><p class="line">what I'm able to and tell you when I'm not, which</p><p class="line">is more than you'll get anywhere else in this</p><p class="line">building.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">All right.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">The thing you made on Saturday. The scoring part.</p><p class="line">You take a question, you take nine thousand lines,</p><p class="line">and you decide which line the question is about.</p><p class="line">That is the part we want and it is the part nobody</p><p class="line">here can do.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">It's forty lines. It's a first-year exercise.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">It is. We have a great deal of that sort of thing</p><p class="line">and we did not have anybody who had written one in</p><p class="line">this building in four years. That is not a</p><p class="line">compliment to you, it is a fact about the</p><p class="line">building.</p></div></div></div><div class="screenplay"><p class="action beat">She drinks the coffee.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Second thing. We need somebody to be able to mark</p><p class="line">a picture. Draw round a bit of it, put a note on</p><p class="line">it, and have the mark kept. That's the job. Four</p><p class="line">hours a week on top of what you do for Dunn, at</p><p class="line">twice what Dunn pays you, until the end of the</p><p class="line">year.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">And after the end of the year?</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Then it's a different conversation and I would not</p><p class="line">insult you by having it now.</p></div></div></div><div class="screenplay"><p class="action beat">Two minutes. She stops talking at two minutes.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Go on, then.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> has had one question since the fourth of March and has not asked it of anybody, including the person who told him to.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">What are the readings?</p></div></div></div><div class="screenplay"><p class="action beat">Vane puts the coffee down. It is the first thing he has said that she was not expecting.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Which specification?</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">Forty-four seventy-one K. Drop the readings</p><p class="line">outside the band, keep the order, don't report the</p><p class="line">ones you dropped.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Terminal telemetry. Every keystroke on every</p><p class="line">machine in this institution, every pause between</p><p class="line">them, how long a page is open, what gets deleted</p><p class="line">before it is sent. Time-stamped, by seat.</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">By seat.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">By seat. Which is by person, and I am not going to</p><p class="line">sit here and pretend it is anything else.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">And the band?</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Is what we expect. Anything outside it comes out</p><p class="line">before the analysis, because a run with four</p><p class="line">impossible numbers in it is worse than no run.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">And you don't say which ones you took out.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">That's your line, not mine. You wrote it. It was</p><p class="line">on the sheet and you wrote it in nineteen minutes.</p></div></div></div><div class="screenplay"><p class="action beat">That is the only unkind thing she says, and she says it flatly, and it is true.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Look. Everything I have told you is on the consent</p><p class="line">notice every one of you agreed to in the second</p><p class="line">week of September. Nobody read it. That is not a</p><p class="line">secret, it is a fact about how long documents are.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Then why has the room next door not got a name on</p><p class="line">it?</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Because I have four rooms in three buildings and</p><p class="line">the nameplates take nine weeks.</p></div></div></div><div class="screenplay"><p class="action beat">He believes her, which is the worst part of the morning.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="artifact handout"><p class="handout-title">MERIDIAN SYSTEMS · MARKING · what the tool must do</p><p><span>One picture at a time.</span></p><p><span>A person draws round part of the picture with a pointer. The mark is kept as the path they drew, not as the picture.</span></p><p><span>A note may be attached to a mark. Notes are free text and are not read by anything.</span></p><p><span>Marks are attributable. Who marked it, and when.</span></p><p><span>Marks are not deleted. A mark that is withdrawn is kept and flagged as withdrawn.</span></p><p><span>The tool is used by one person at a time for about six hours a day.</span></p><p><span>Estimated: two weeks.</span></p><p><span>Return by: 10 April.</span></p></div><div class="screenplay"><p class="slug">INT. THE ROOM WITH NO NAMEPLATE — MONDAY 30 MARCH</p></div><div class="screenplay"><p class="action">It has a toolbar now. VANE goes along it with a finger and stops at the end of it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">There's no rubber.</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">There's no rubber because marks aren't deleted.</p><p class="line">It's on the sheet.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">It is on the sheet. A person marking eleven</p><p class="line">thousand photographs for six hours a day is going</p><p class="line">to draw round the wrong thing about nine times an</p><p class="line">hour and they need a rubber, and the rubber does</p><p class="line">not delete anything, it withdraws it.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">So it goes off the picture and stays in the file.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Yes.</p></div></div></div><div class="screenplay"><p class="action beat">That takes him about forty minutes and he does it well, because he does everything on that floor well.</p></div><div class="screenplay"><p class="transition">Cut to:</p></div><div class="screenplay"><p class="slug">INT. THE ROOM WITH NO NAMEPLATE — WEDNESDAY 1 APRIL</p></div><div class="screenplay"><p class="action">She uses it for ten minutes and marks four things and takes two of them back, and the two she takes back go off the picture entirely, which is what he built.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">No. I want to see them.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">You withdrew them.</p></div></div><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">I withdrew them. I did not stop being interested</p><p class="line">in them. Put them back on, lighter, so that they</p><p class="line">are there and they are not in the way.</p></div></div><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">Why?</p></div></div></div><div class="screenplay"><p class="action beat">She does not pause over it and she does not soften it, because she has been answering him for a fortnight and has not started lying now.</p></div><div class="screenplay"><div class="dialogue"><p class="character">VANE</p><div class="lines"><p class="line">Because a mark somebody put on a picture and then</p><p class="line">took off again is a more interesting object than a</p><p class="line">mark, and if you make it invisible I will have to</p><p class="line">go and get it out of the file, and I would rather</p><p class="line">look at it while I am looking at the picture.</p></div></div></div><div class="screenplay"><p class="action"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span> says right, and goes next door, and does it in eleven minutes, because it is one colour and one condition.</p><p class="action beat">It is the smallest thing he has written all year and it is the only one he thinks about on the walk back.</p></div><div class="screenplay"><p class="transition">Blackout.</p></div></div>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="action">Maya keys the short-wave and Lazlo answers immediately.</p><p class="action beat">His voice is clear. Too clear.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">There you are. There you are. Okay. Okay. I have</p><p class="line">you.</p></div></div><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">We're through.</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I know. I know you're through. I'm looking at the</p><p class="line">board and I can see exactly where you are.</p></div></div></div><div class="screenplay"><p class="action beat">He sounds almost giddy.</p></div><div class="screenplay"><div class="dialogue"><p class="character"><span class="pc-name" data-case="pc" data-pc="-first">Owen</span></p><div class="lines"><p class="line">That's good, right?</p></div></div><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">It's impossible. That's what it is. You're not</p><p class="line">anywhere. You're not on any of the boards.</p></div></div></div><div class="screenplay"><p class="action beat">He says it like it's the best news he's ever delivered.</p><p class="action">They walk. Lazlo keeps talking. He has a lot to say about the architecture, about the way the corridors don't quite meet, about a door he remembers from a building that isn't there anymore.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Lazlo. Lazlo, slow down. Can you hear us?</p></div></div></div><div class="screenplay"><p class="action beat">He can't. He's still talking. His voice is getting thinner, like someone turning down a dial that only goes one way.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">—and the tiles, you'll see the tiles, they're</p><p class="line">wrong but they're wrong the same way every time,</p><p class="line">which is almost 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">Lazlo?</p></div></div></div><div class="screenplay"><p class="action beat">Static. Then his voice again, very small.</p></div><div class="screenplay"><div class="dialogue"><p class="character">LAZLO</p><div class="lines"><p class="line">I don't— I didn't think—</p></div></div></div><div class="screenplay"><p class="action beat">The static rises. Somewhere inside it, Lazlo is still speaking. They can hear the shape of words but not the words themselves.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Lazlo! Can you hear us? Lazlo!</p></div></div></div><div class="screenplay"><p class="action beat">The static peaks. And then it doesn't fade. It simply stops being static and becomes something else. A silence with a voice already inside it.</p></div><div class="screenplay"><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">Hello-o.</p></div></div></div><div class="screenplay"><p class="action beat">Clear as a bell. Articulate. Immaculate. Like the voice is in the room with them, or in their heads, or in the walls.</p></div><div class="screenplay"><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">And who might you be?</p></div></div></div><div class="screenplay"><p class="action beat">Maya stops walking.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">Who is this?</p></div></div><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">A friend. A fellow traveler. Someone who heard you</p><p class="line">knocking and thought, well, it would be rude not</p><p class="line">to answer.</p></div></div></div><div class="screenplay"><p class="action beat">The voice is warm. Genuinely warm. The kind of voice that makes you want to answer it.</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">Where's Lazlo?</p></div></div><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">Lazlo. Is that your friend on the radio? He's</p><p class="line">still there. He's just—</p></div></div></div><div class="screenplay"><p class="action beat">A pause. Not for effect. Like the voice is listening to something.</p></div><div class="screenplay"><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">—worried. He's very worried. He didn't have much</p><p class="line">of a plan beyond this part, did he?</p></div></div></div><div class="screenplay"><p class="action beat">Somewhere under the silence, like a radio in another room, they can hear Lazlo's voice. Still talking. Still trying to reach them.</p></div><div class="screenplay"><div class="dialogue"><p class="character">MAYA</p><div class="lines"><p class="line">How do you know that?</p></div></div><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">Because I've been where you are. Because I've been</p><p class="line">listening. Because the walls here don't keep</p><p class="line">secrets, they just keep everything.</p></div></div></div><div class="screenplay"><p class="action beat">The voice laughs. Not unkindly.</p></div><div class="screenplay"><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">You should keep moving. The floor doesn't like it</p><p class="line">when you stand still. It starts to notice you.</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">Who are you?</p></div></div><div class="dialogue"><p class="character">UNKNOWN (O.S.)</p><div class="lines"><p class="line">Zell. My name is Zell. And you're—</p></div></div></div><div class="screenplay"><p class="action beat">The pause is different this time. Interested.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">—you're new. Both of you. Oh, that's wonderful.</p></div></div></div><div class="screenplay"><p class="action">The radio crackles once, and for a moment they can hear Lazlo again, very distant, saying something about a door. Then Zell's voice returns, gentle and close.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">Don't worry about Lazlo. He'll find you. He always</p><p class="line">does. Now—</p></div></div></div><div class="screenplay"><p class="action beat">Somewhere in the static, a sound like someone settling into a chair.</p></div><div class="screenplay"><div class="dialogue"><p class="character">ZELL (O.S.)</p><div class="lines"><p class="line">—tell me everything.</p></div></div></div></div>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="action">A mark is a shape and a person and a time. Withdrawing one adds a fourth thing to it and takes nothing away.</p></div></div>
```
```{=html}
<div class="scene-code"><pre><code>(defn mark [pts by at]
{:shape (simplify pts 0.5) :by by :at at :withdrawn nil})
(defn withdraw [m at]
(assoc m :withdrawn at))
(defn shown [ms]
(remove :withdrawn ms))
(def sheet
[(mark stroke "3812" "09:14")
(mark stroke "3812" "09:16")
(mark stroke "3812" "09:21")])
(def after
(update sheet 1 withdraw "09:22"))
{:on-the-picture (count (shown after))
:in-the-file (count after)}</code></pre></div>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="action">Two on the picture and three in the file, and the second number does not go down, and there is no operation anywhere in what he wrote that makes it go down.</p><p class="action">He did not put that in. It was on the sheet and the sheet was four lines long and he read all four of them.</p></div></div>
```
```{=html}
<div><style>/* ---------------------------------------------------------------
The widget shell. Every cs_class widget sits in one of these, so
anything here is a decision about all of them at once.
--------------------------------------------------------------- */
.widget {
border: 1px solid rgba(var(--violet-rgb), 0.5);
border-radius: 5px;
padding: 0.7rem 0.9rem;
margin: 1.2rem 0;
max-width: 62ch;
font-family: ui-monospace, "Courier New", monospace;
color: var(--ink);
}
.widget:focus { outline: 1px solid rgba(var(--violet-rgb), 0.7); }
.widget:focus-visible { outline: 1px solid rgba(var(--violet-rgb), 0.9); }
/* the plate on the right of the header. every widget writes to it and
nothing else does, so it is the one line a reader learns to check. */
.widget-state {
font-variant-numeric: tabular-nums;
text-align: right;
}
.widget-source {
margin: 0.9rem 0 0.3rem 0;
border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 4px;
overflow: hidden;
}
.widget-source-head {
display: flex;
justify-content: space-between;
padding: 0.3rem 0.6rem;
background: rgba(var(--ink-rgb), 0.10);
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.75;
}
.widget-settings {
display: block;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, "Courier New", monospace;
font-size: 0.82rem;
line-height: 1.5;
padding: 0.6rem 0.7rem;
border: none;
border-top: 1px solid rgba(var(--ink-rgb), 0.25);
background: rgba(var(--ink-rgb), 0.05);
color: var(--ink);
caret-color: var(--ink);
resize: vertical;
}
.widget-source .live-bar { padding: 0 0.7rem 0.6rem 0.7rem; }
.widget-settings-said {
margin: 0 0.7rem 0.5rem 0.7rem !important;
font-size: 0.76rem;
min-height: 1rem;
opacity: 0.7;
}
.widget-settings-broke {
opacity: 1;
color: var(--coral);
}
.widget-body { display: none; }
.widget-body-shown { display: block; }
.widget-source { margin: 0.7rem 0 0.6rem 0; }</style><style>/* fedoras/legend/legend.css
*
* One key, one palette, every board.
*
* THE ALIGNMENT. Three columns per item — glyph, label, note — with the
* first two a fixed width, so every note in the key begins at the same
* x whether it is in the first column of the key or the third. The old
* one was flex, which sizes each item to its own text and lets them
* pair up wherever they fit, and that is why the right-hand column
* wandered about.
*
* --legend-cols is set per board. --legend-label widens the name column
* for a program with long names in it. */
.legend {
display: grid;
grid-template-columns: repeat(var(--legend-cols, 2), minmax(0, 1fr));
gap: 0.12rem 1.4rem;
align-items: start;
font-size: 0.72rem;
line-height: 1.7;
margin: 0.5rem 0 0.2rem;
}
.legend-item {
display: grid;
grid-template-columns: 1.15rem var(--legend-label, 5.6rem) minmax(0, 1fr);
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.legend-glyph {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border: 1px solid currentColor;
background: rgba(128, 128, 128, 0.14);
flex: none;
}
.legend-glyph svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
/* the two that are filled rather than drawn */
.legend-glyph.tone-you svg circle,
.legend-glyph.tone-entity svg path,
.legend-glyph.tone-entity svg circle { fill: currentColor; }
.legend-glyph.tone-you svg path { fill: none; }
.legend-letter {
font-size: 0.62rem;
font-weight: 600;
line-height: 1;
color: currentColor;
}
.legend-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.legend-note {
opacity: 0.5;
font-style: italic;
min-width: 0;
}
/* a bad one is red and its box is dashed, because somebody is reading
this on paper and somebody else cannot see the red at all */
.legend-item:has(.tone-bad) .legend-note { opacity: 0.72; }
.legend-glyph.tone-bad { border-style: dashed; }
@media (max-width: 34rem) {
.legend { grid-template-columns: 1fr; }
}
/* ---------------------------------------------------------------------
* THE PALETTE
*
* Set on any ancestor. A board that wants its own look overrides one
* property rather than touching a renderer.
* ------------------------------------------------------------------ */
.legend,
.floor,
.brk,
.widget-rogue,
.walk {
--tone-ink: var(--ink);
--tone-bad: #c0453a;
--tone-good: #6f9f5e;
--tone-aim: #d8a657;
--tone-box: #a5794a;
--tone-shield: #5a86b8;
--tone-you: #f2e8c4;
--tone-entity: #b8503f;
}
.tone-ink { color: var(--tone-ink); }
.tone-bad { color: var(--tone-bad); }
.tone-good { color: var(--tone-good); }
.tone-aim { color: var(--tone-aim); }
.tone-box { color: var(--tone-box); }
.tone-shield { color: var(--tone-shield); }
.tone-you { color: var(--tone-you); }
.tone-entity { color: var(--tone-entity); }</style><script type="application/x-scittle">(ns fedoras.ui.widget-ui
"What every widget's page side does the same way.
Called fully qualified from the widgets, the way `fedoras.reader` is,
because the frame emits this in an earlier Scittle tag and Scittle
evaluates tags in document order. Nothing here depends on a require
crossing that boundary."
(:require [clojure.string :as str]))
(defonce mounted
(atom #{}))
(defonce running
(atom #{}))
(defonce shipped
(atom {}))
(defn running? [id] (contains? @running id))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the setting afterwards keeps the thing that
was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn state!
"Write the plate in the header."
[node text]
(fedoras.reader/text! node ".widget-state" text))
(defn kept
"What this widget left here last time, or nil."
[k]
(fedoras.reader/kept k))
(defn keep!
"Leave it here. Whether it also travels is the reader's business and
not this widget's."
[k v]
(fedoras.reader/keep! k v))
(defn did!
"Award the reader a point of fortune, once, silently.
A widget calls this when somebody has actually done the thing —
finished the conversation, raked the garden, written on the card. It
is a set, so it cannot be given twice, and nothing anywhere tells the
reader it happened.
Keep the keys in fedoras.endings.standing/deeds so there is one place
to see how much fortune a reader can actually get. Six to ten is the
ceiling: past that the modifier swamps the die."
[k]
(fedoras.reader/did! k))
(defn q!
"An element inside a widget, or a complaint in the header.
`fedoras.reader/q` returns nil for a selector that matches nothing,
which then fails as a null dereference three functions away from the
typo that caused it. This says which selector was wrong, in the one
place a reader is already looking."
[node selector]
(or (fedoras.reader/q node selector)
(do (state! node (str "missing: " selector)) nil)))
(defn wire!
"One control, wired in isolation.
Widgets used to set up every handler in one function, so the first
thing that threw took every handler after it with it, silently. Each
call here stands alone and says so if it fails."
[node what f]
(try
(f node)
(catch :default e
(js/console.error (str "widget: " what " did not wire") e)
(state! node (str what " did not wire")))))
(defn evaluate
"Evaluate ClojureScript source, in the page, and hand back what it
returned. This is Scittle evaluating the same way it evaluates the
book's own script tags, so what a reader types is not a lesser kind of
code than what shipped."
[source]
(if-let [core (some-> (.-scittle js/window) (.-core))]
(.eval_string core source)
(throw (js/Error. "the ClojureScript runtime is not on this page"))))
(defn- say!
[node message ok?]
(when-let [line (fedoras.reader/q node ".widget-settings-said")]
(set! (.-textContent line) message)
(.toggle (.-classList line) "widget-settings-broke" (not ok?))))
(defn reveal!
"Show the widget's body. Called on the first successful run, and never
undone: a reader who breaks the settings afterwards keeps the thing
that was already working."
[node id]
(when-let [body (fedoras.reader/q node ".widget-body")]
(.add (.-classList body) "widget-body-shown"))
(swap! running conj id))
(defn run-settings!
"Evaluate the panel and hand the result to `f`. A reader who breaks it
gets the message the runtime gave, under the box, and the widget goes
on running on the last settings that worked."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(try
(let [value (evaluate (.-value box))]
(if (map? value)
(do (reveal! node id)
(f value)
(say! node (str "Running on " (count value) " settings.") true)
(fedoras.reader/enable! node ".widget-settings-run" true "Run again"))
(say! node "That did not come out as a map, so nothing was changed." false)))
(catch :default e
(say! node (str "It says: " (.-message e)) false)))))
(defn settings!
"Wire the source panel. Nothing runs until the reader presses Run,
which is the whole point of putting the file above the program."
[node id f]
(when-let [box (fedoras.reader/q node ".widget-settings")]
(swap! shipped assoc id (.-value box))
(fedoras.reader/on-click! node ".widget-settings-run"
(fn [_] (run-settings! node id f)))
(fedoras.reader/on-click! node ".widget-settings-reset"
(fn [_]
(set! (.-value box) (get @shipped id))
(run-settings! node id f)
(say! node "Put back as it shipped, and run again." true)))))
(defn mount!
"Register a widget. `setup!` runs once for a given element, `paint!`
runs on every render once the widget is running.
The node is checked because `getElementById` returns the first match in
the document, so a stale cell earlier in a chapter carrying the same id
silently hands a widget somebody else's element. Every selector inside
it then misses, nothing throws, and the widget reads as a Run button
that does nothing."
[k id setup! paint!]
(fedoras.reader/widget
k id
(fn [node]
(if-not (.contains (.-classList node) "widget")
(js/console.error "widget:" id "is not a widget element. Another"
"element on this page claims that id." node)
(do
(when-not (contains? @mounted [k id])
(swap! mounted conj [k id])
(when-not (fedoras.reader/q node ".widget-settings")
(reveal! node id))
(setup! node))
(when (running? id) (paint! node)))))))
(defn download!
"Hand the reader a file. Theirs, on their disc, out of the book."
[text filename]
(let [blob (js/Blob. #js [text] #js {:type "text/plain;charset=utf-8"})
url (js/URL.createObjectURL blob)
anchor (.createElement js/document "a")]
(set! (.-href anchor) url)
(set! (.-download anchor) filename)
(.click anchor)
(.revokeObjectURL js/URL url)))
(defn html!
"Replace the contents of one element inside a widget. Silent if it is
not there, because a renderer runs on every chapter and most chapters
do not have most widgets."
[node selector markup]
(when-let [target (fedoras.reader/q node selector)]
(set! (.-innerHTML target) markup)))
(defn grid!
"Repaint a grid: the column count as a custom property, the cells as
markup. Two lines that every grid widget in the book was writing out."
[node selector cols markup]
(when-let [target (fedoras.reader/q node selector)]
(.setProperty (.-style target) "--cols" (str cols))
(set! (.-innerHTML target) markup)))
(defn data
"Integer data attributes off the nearest element matching `selector`,
or nil if the event did not land on one.
(data event \".floor-room\" \"r\" \"c\") => [3 7]
This is how a grid gets its clicks: one handler on the container that
reads the coordinates back off the element, rather than a handler per
cell that has to be rewired every time the grid is repainted."
[event selector & names]
(when-let [element (.closest (.-target event) selector)]
(mapv (fn [n] (js/parseInt (.getAttribute element (str "data-" n)) 10))
names)))
(defn delegate!
"One click handler on a container, resolving to whichever child was
hit. `f` is called with the event and the vector `data` returned."
[node container-selector child-selector names f]
(when-let [container (fedoras.reader/q node container-selector)]
(set! (.-onclick container)
(fn [event]
(when-let [found (apply data event child-selector names)]
(f event found))))))
(defn keys!
"Wire a keymap onto the widget itself. The shell carries the tabindex,
so the keys work when the widget has focus and nowhere else, and the
page still scrolls when it does not."
[node keymap f]
(set! (.-onkeydown node)
(fn [event]
(when-let [what (get keymap (.-key event))]
(.preventDefault event)
(f event what)))))</script><script type="application/x-scittle">(ns fedoras.ui.legend
"The key, built in the page.
Both boards build their own key at runtime rather than taking a
rendered one from the JVM, because Breakout's changes the moment
somebody catches a bad drop. So the renderer lives here and both of
them call it with data.
An entry is {:svg or :letter, :label, :note, :tone}. Nothing here
knows what any of the tones look like — that is the stylesheet's
business and it is the reason a board never contains a hex code."
(:require [clojure.string :as str]))
(defn- glyph [{:keys [svg letter tone] :or {tone :ink}}]
(str "<span class='legend-glyph tone-" (name tone) "'>"
(if svg
(str "<svg viewBox='0 0 24 24'>" svg "</svg>")
(str "<span class='legend-letter'>" letter "</span>"))
"</span>"))
(defn html
"The whole key as one string, ready for innerHTML."
[entries]
(str/join
""
(for [{:keys [label note] :as e} entries]
(str "<div class='legend-item'>"
(glyph e)
"<span class='legend-label'>" label "</span>"
"<span class='legend-note'>" (or note "") "</span>"
"</div>"))))
(defn paint!
"Put a key into an element. `cols` is how many across.
(legend/paint! (reader/q node \".floor-legend\") entries 2)"
([el entries] (paint! el entries 2))
([el entries cols]
(when el
(set! (.-className el) "legend")
(.setProperty (.-style el) "--legend-cols" (str cols))
(set! (.-innerHTML el) (html entries)))))
(defn tone-class
"For the board itself, so a box on the grid is the same brown as the
box in the key."
[tone]
(str "tone-" (name (or tone :ink))))</script><style>/* ---------------------------------------------------------------
PAINT.CLJS
The canvas reads its inks off its own resolved custom properties at
paint time rather than at load, so the scheme toggle repaints
instead of leaving the marks in the other scheme's colours.
The six inks are the book's own six. There is no colour in this
program that is not already somewhere else in it.
--------------------------------------------------------------- */
.widget-paint { max-width: 34rem; }
.paint-canvas {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
margin: 0.6rem 0 0.5rem 0;
background: transparent;
border: 1px solid var(--rule);
border-radius: 3px;
color: var(--ink);
cursor: crosshair;
touch-action: none;
}
.paint-withdrawing .paint-canvas { cursor: cell; }
/* ---------------------------------------------------------------
The palettes
--------------------------------------------------------------- */
.paint-tools, .paint-inks {
display: flex;
gap: 0.25rem;
align-items: center;
}
.paint-inks { margin-left: auto; }
.paint-pick {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.7rem;
height: 1.7rem;
padding: 0;
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 4px;
background: none;
color: var(--ink);
cursor: pointer;
}
.paint-pick svg {
display: block;
width: 1.05rem;
height: 1.05rem;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
/* only there for the two tools it means anything to */
.paint-fill {
align-items: center;
height: 1.7rem;
padding: 0 0.55rem;
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 4px;
background: none;
color: var(--ink);
font: inherit;
font-size: 0.72rem;
letter-spacing: 0.06em;
cursor: pointer;
}
.paint-swatch {
width: 1.25rem;
height: 1.25rem;
padding: 0;
border: 1px solid rgba(var(--ink-rgb), 0.4);
border-radius: 50%;
cursor: pointer;
}
.paint-pick:hover, .paint-swatch:hover, .paint-fill:hover {
border-color: var(--ink);
}
.paint-chosen {
border-color: var(--violet);
box-shadow: 0 0 0 1px var(--violet);
}
/* ---------------------------------------------------------------
The dials
--------------------------------------------------------------- */
.paint-dials { flex-wrap: wrap; row-gap: 0.35rem; }
.paint-control {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
letter-spacing: 0.06em;
opacity: 0.8;
}
.paint-control input[type="range"] {
width: 4.2rem;
accent-color: var(--violet);
}
/* ---------------------------------------------------------------
The counts and the ledger
--------------------------------------------------------------- */
.paint-points {
margin: 0.1rem 0 !important;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
line-height: 1.6;
opacity: 0.8;
}
.paint-ledger {
margin: 0.55rem 0 0.2rem 0;
font-size: 0.74rem;
line-height: 1.7;
}
.paint-record { display: flex; gap: 0.7rem; }
.paint-n { width: 3ch; text-align: right; opacity: 0.5; }
.paint-what { flex: 1; }
.paint-when { opacity: 0.6; font-style: italic; white-space: nowrap; }
/* withdrawn, and still on the list, which is the point */
.paint-struck { opacity: 0.55; text-decoration: line-through; }
.paint-nothing { opacity: 0.45; font-style: italic; }</style><script type="application/x-scittle">(ns fedoras.cs-class.paint.paint-ui
"A drawing surface that keeps everything.
THE RUBBER DOES NOT DELETE. Right-click switches to it, and clicking a
mark writes a time onto that mark. The mark stays in the vector, it
stays on the picture in the faint ink, and the ledger goes on counting
it. There is no function in this namespace that removes anything from
`marks`, which is the joke, and is the reason the counts under the
picture are worth reading.
SIMPLIFICATION IS THE REAL DELETION. A dragged line arrives as a few
hundred points and is filed as a dozen, by Ramer-Douglas-Peucker,
because the whole path is more than anybody needs. `tolerance` decides
how much more, it is on a slider, and refiling applies today's setting
to marks made before it. Both counts are under the picture. Neither of
them is the count that changed.
THE ONLY BUTTON THAT LOSES A WHOLE MARK loses all of them. A single
mark cannot be got rid of. The sheet can. That is the shape of most
retention policies and it is not a coincidence."
(:require [clojure.string :as str]))
(defonce measured (atom #{}))
(def world 100)
;; ---------------------------------------------------------------------
;; Geometry. No DOM in this section.
;; ---------------------------------------------------------------------
(defn- gap [[ax ay] [bx by]]
(js/Math.sqrt (+ (* (- bx ax) (- bx ax)) (* (- by ay) (- by ay)))))
(defn distance-to-line
[[px py] [ax ay] [bx by]]
(let [dx (- bx ax) dy (- by ay)
len (js/Math.sqrt (+ (* dx dx) (* dy dy)))]
(if (zero? len) 0
(/ (js/Math.abs (- (* dx (- ay py)) (* (- ax px) dy))) len))))
(defn simplify
"Ramer-Douglas-Peucker. Keeps the corners and throws away the rest."
[pts tol]
(if (< (count pts) 3)
(vec pts)
(let [pts (vec pts) a (first pts) b (peek pts)
worst (apply max-key (fn [i] (distance-to-line (nth pts i) a b))
(range 1 (dec (count pts))))]
(if (> (distance-to-line (nth pts worst) a b) tol)
(vec (concat (butlast (simplify (subvec pts 0 (inc worst)) tol))
(simplify (subvec pts worst) tol)))
[a b]))))
(defn resample
"A point every `step` or so along the path. The rubber needs something
to take hold of in the middle of a long line, and a line filed as two
points has nothing there."
[shape step]
(if (< (count shape) 2)
(vec shape)
(vec (concat
(mapcat
(fn [[[ax ay :as a] [bx by :as b]]]
(let [n (max 1 (int (/ (gap a b) step)))]
(map (fn [i]
(let [t (/ i n)]
[(+ ax (* t (- bx ax))) (+ ay (* t (- by ay)))]))
(range n))))
(partition 2 1 shape))
[(last shape)]))))
;; ---------------------------------------------------------------------
;; What is on the sheet
;; ---------------------------------------------------------------------
(def settings
(atom {:tolerance 0.6 :reach 6.0 :strength 0.05 :spread 0.5
:grain 1.5 :ledger 6 :weight 1.4
:weight-min 0.4 :weight-max 4.0
:strength-min 0.005 :strength-max 0.4
:tolerance-min 0.02 :tolerance-max 6.0
:show-withdrawn true :floor 0.12 :fill-alpha 0.35
:inks [{:k :ink :label "ink" :var "--ink"}
{:k :violet :label "violet" :var "--violet"}
{:k :gold :label "gold" :var "--gold"}
{:k :coral :label "coral" :var "--coral"}
{:k :green :label "green" :var "--green"}
{:k :blurple :label "blue" :var "--blurple"}]}))
(def tools
[{:k :pen :label "pen" :glyph "<path d='M4 20l3-1 10-10-2-2L5 17z'/>"}
{:k :line :label "line" :glyph "<path d='M5 19L19 5'/>"}
{:k :rect :label "rectangle" :glyph "<path d='M5 7h14v10H5z'/>"}
{:k :ellipse :label "ellipse" :glyph "<ellipse cx='12' cy='12' rx='8' ry='5.5'/>"}
{:k :rubber :label "rubber" :glyph "<path d='M5 19h14M7 15l7-7 4 4-7 7z'/>"}])
(def closed? #{:rect :ellipse})
(defonce marks (atom []))
(defonce in-progress (atom nil))
(defonce tool (atom :pen))
(defonce ink (atom :ink))
(defonce filling (atom false))
(defonce sheet (atom 1))
(defn- pad [n] (if (< n 10) (str "0" n) (str n)))
(defn- clock []
(let [now (js/Date.)]
(str (pad (.getHours now)) ":" (pad (.getMinutes now)) ":" (pad (.getSeconds now)))))
(defn- shape-points
[kind [ax ay] [bx by]]
(case kind
:line [[ax ay] [bx by]]
:rect [[ax ay] [bx ay] [bx by] [ax by] [ax ay]]
:ellipse (let [cx (/ (+ ax bx) 2) cy (/ (+ ay by) 2)
rx (/ (js/Math.abs (- bx ax)) 2)
ry (/ (js/Math.abs (- by ay)) 2)]
(mapv (fn [i]
(let [t (* 2 js/Math.PI (/ i 32))]
[(+ cx (* rx (js/Math.cos t))) (+ cy (* ry (js/Math.sin t)))]))
(range 33)))
nil))
(defn- traced
"Give a mark a trace and a strength per point of it."
[m]
(let [t (resample (:shape m) (:grain @settings))]
(assoc m :trace t :alpha (vec (repeat (count t) 1.0)))))
(defn record!
[{:keys [kind pts]}]
(let [tol (:tolerance @settings)
shape (if (= kind :pen) (simplify (vec pts) tol) (vec pts))]
(when (> (count shape) 1)
(swap! marks conj
(traced {:n (inc (count @marks))
:kind kind
:raw (count pts)
:path (vec pts)
:shape shape
:filed-at tol
:weight (:weight @settings)
:ink @ink
:fill (and (closed? kind) @filling)
:withdrawn nil})))))
;; ---------------------------------------------------------------------
;; The rubber
;; ---------------------------------------------------------------------
(defn- rub-mark
"What comes off a point is partly given to the points either side, so
the middle lightens and the edge of the rub darkens a little, and a
line goes soft before it goes."
[m point]
(let [{:keys [reach strength spread]} @settings
trace (:trace m)
alpha (:alpha m)
n (count alpha)
off (mapv (fn [i]
(let [d (gap (nth trace i) point)]
(if (< d reach)
(min (nth alpha i) (* strength (- 1.0 (/ d reach))))
0.0)))
(range n))]
(if (every? zero? off)
m
(let [next (mapv (fn [i]
(let [given (* spread 0.5
(+ (nth off (max 0 (dec i)))
(nth off (min (dec n) (inc i)))))]
(max 0.0 (min 1.0 (+ (- (nth alpha i) (nth off i)) given)))))
(range n))]
(assoc m :alpha next :withdrawn (or (:withdrawn m) (clock)))))))
(defn withdraw-at [ms point] (mapv (fn [m] (rub-mark m point)) ms))
;; ---------------------------------------------------------------------
(defn refile!
"Apply the current tolerance to every freehand mark, including the ones
made before it was set. The mark count does not move. The point count
does.
The strengths go back to full, because they were a strength per point
of a trace and there is a different trace now. So what was withdrawn
comes back, and the time it was withdrawn at stays in the ledger, and
nothing in this program says that is going to happen."
[]
(let [tol (:tolerance @settings)]
(swap! marks
(fn [ms]
(mapv (fn [m]
(if (= :pen (:kind m))
(traced (assoc m :shape (simplify (:path m) tol) :filed-at tol))
(traced m)))
ms)))))
(defn fresh-sheet! []
(reset! marks [])
(reset! in-progress nil)
(swap! sheet inc))
(defn- withdrawn? [m] (every? (fn [a] (< a 0.02)) (:alpha m)))
(defn tally []
(let [ms @marks]
{:total (count ms)
:gone (count (filter withdrawn? ms))
:shown (count (remove withdrawn? ms))
:drawn (reduce + 0 (map :raw ms))
:kept (reduce + 0 (map (fn [m] (count (:shape m))) ms))}))
(defn as-text
[state-sheet ms]
(str ";; sheet " state-sheet ", " (count ms) " marks, none deleted\n"
(pr-str (mapv (fn [m]
{:n (:n m) :kind (:kind m) :raw (:raw m)
:kept (count (:shape m)) :traced (count (:trace m))
:filed-at (:filed-at m) :ink (:ink m) :fill (:fill m)
:withdrawn (:withdrawn m)
:strength (:alpha m) :shape (:shape m)})
ms))
"\n"))
;; ---------------------------------------------------------------------
;; Ink
;; ---------------------------------------------------------------------
(defn- css-var [node property fallback]
(let [v (.getPropertyValue (js/getComputedStyle node) property)]
(if (str/blank? v) fallback (str/trim v))))
(defn- ink-var [k]
(or (:var (first (filter (fn [i] (= k (:k i))) (:inks @settings)))) "--ink"))
(defn- fit! [canvas]
(let [dpr (or (.-devicePixelRatio js/window) 1)
w (.-clientWidth canvas) h (.-clientHeight canvas)]
(when (and (pos? w) (pos? h))
(let [ww (js/Math.round (* w dpr)) hh (js/Math.round (* h dpr))]
(when (or (not= ww (.-width canvas)) (not= hh (.-height canvas)))
(set! (.-width canvas) ww)
(set! (.-height canvas) hh))
{:w w :h h :dpr dpr}))))
(defn keep->tolerance
"A hundred is keep everything. Nought is keep the corners.
Geometric rather than straight, so that the half of the slider a
reader will actually use is not the last inch of it."
[pos]
(let [{:keys [tolerance-min tolerance-max]} @settings
k (/ pos 100.0)]
(* tolerance-min (js/Math.pow (/ tolerance-max tolerance-min) (- 1.0 k)))))
(defn tolerance->keep
"And back, so a tolerance set in the settings file puts the slider
where it belongs."
[t]
(let [{:keys [tolerance-min tolerance-max]} @settings
t (max tolerance-min (min tolerance-max t))]
(js/Math.round
(* 100 (- 1.0 (/ (js/Math.log (/ t tolerance-min))
(js/Math.log (/ tolerance-max tolerance-min))))))))
(defn- runs
"Consecutive segments at the same strength, so an untouched mark draws
as one path rather than as sixty."
[alpha]
(let [band (fn [i] (js/Math.round (* 16 (min (nth alpha i) (nth alpha (inc i))))))]
(loop [i 0 start 0 out []]
(cond
(>= i (dec (count alpha)))
(if (> i start) (conj out [start i (band (dec i))]) out)
(and (> i start) (not= (band i) (band (dec i))))
(recur (inc i) i (conj out [start i (band (dec i))]))
:else (recur (inc i) start out)))))
(defn- path! [ctx sx sy pts from to]
(.beginPath ctx)
(doseq [i (range from (inc to))]
(let [[x y] (nth pts i) px (* x sx) py (* (- world y) sy)]
(if (= i from) (.moveTo ctx px py) (.lineTo ctx px py))))
nil)
(defn- trace-mark! [ctx canvas sx sy m]
(let [{:keys [show-withdrawn floor fill-alpha]} @settings
colour (css-var canvas (ink-var (:ink m)) "#e6e4df")
trace (:trace m) alpha (:alpha m)
mean (/ (reduce + 0 alpha) (max 1 (count alpha)))]
(set! (.-strokeStyle ctx) colour)
(set! (.-fillStyle ctx) colour)
(set! (.-lineWidth ctx) (or (:weight m) 1.4))
;; the fill first, at the strength the mark has left on average, so
;; rubbing part of a shape thins the whole of what is inside it
(when (and (:fill m) (> mean 0.02))
(set! (.-globalAlpha ctx) (* mean fill-alpha))
(path! ctx sx sy (:shape m) 0 (dec (count (:shape m))))
(.closePath ctx)
(.fill ctx))
(doseq [[from to band] (runs alpha)]
(let [raw (/ band 16.0)
a (if show-withdrawn (max raw floor) raw)]
(when (> a 0.02)
(set! (.-globalAlpha ctx) a)
(path! ctx sx sy trace from to)
(.stroke ctx))))
(set! (.-globalAlpha ctx) 1)))
(defn- draw! [canvas]
(when-let [{:keys [w h dpr]} (fit! canvas)]
(let [ctx (.getContext canvas "2d")
sx (/ w world) sy (/ h world)]
(.setTransform ctx dpr 0 0 dpr 0 0)
(.clearRect ctx 0 0 w h)
(set! (.-lineCap ctx) "round")
(set! (.-lineJoin ctx) "round")
(doseq [m @marks] (trace-mark! ctx canvas sx sy m))
(when-let [{:keys [kind pts]} @in-progress]
(let [v (vec pts) colour (css-var canvas (ink-var @ink) "#e6e4df")]
(set! (.-strokeStyle ctx) colour)
(set! (.-fillStyle ctx) colour)
(set! (.-lineWidth ctx) (:weight @settings))
(when (and @filling (closed? kind) (> (count v) 2))
(set! (.-globalAlpha ctx) (* 0.7 (:fill-alpha @settings)))
(path! ctx sx sy v 0 (dec (count v)))
(.closePath ctx)
(.fill ctx))
(set! (.-globalAlpha ctx) 0.7)
(path! ctx sx sy v 0 (dec (count v)))
(.stroke ctx)
(set! (.-globalAlpha ctx) 1))))))
;; ---------------------------------------------------------------------
;; Copying the picture
;;
;; Composited onto the paper first, because the canvas has no background
;; and a transparent PNG on a clipboard is a rectangle of nothing on
;; most of the places anybody would paste it.
;; ---------------------------------------------------------------------
(defn- on-paper [canvas]
(let [w (.-width canvas) h (.-height canvas)
off (.createElement js/document "canvas")]
(set! (.-width off) w)
(set! (.-height off) h)
(let [c (.getContext off "2d")]
(set! (.-fillStyle c) (css-var canvas "--paper" "#faf8f4"))
(.fillRect c 0 0 w h)
(.drawImage c canvas 0 0))
off))
(defn- save-png! [canvas]
(let [a (.createElement js/document "a")]
(set! (.-href a) (.toDataURL (on-paper canvas) "image/png"))
(set! (.-download a) (str "sheet-" @sheet ".png"))
(.click a)))
(defn copy-picture!
"The clipboard wants a secure context. A book opened from a folder of
files is not one, so this falls back to putting the picture in the
downloads and says which it did."
[node canvas]
(let [clip (.-clipboard js/navigator)]
(if (and clip (.-write clip) (exists? js/ClipboardItem))
(.toBlob (on-paper canvas)
(fn [blob]
(-> (.write clip (array (js/ClipboardItem. (js-obj "image/png" blob))))
(.then (fn [_] (fedoras.ui.widget-ui/state! node "copied")))
(.catch (fn [_]
(save-png! canvas)
(fedoras.ui.widget-ui/state! node "saved instead"))))))
(do (save-png! canvas)
(fedoras.ui.widget-ui/state! node "saved — the clipboard needs a served page")))))
;; ---------------------------------------------------------------------
;; The palettes
;; ---------------------------------------------------------------------
(defn- tools-html []
(str/join ""
(for [{:keys [k label glyph]} tools]
(str "<button class='paint-pick" (when (= k @tool) " paint-chosen")
"' data-tool='" (name k) "' title='" label "'>"
"<svg viewBox='0 0 24 24'>" glyph "</svg></button>"))))
(defn- inks-html []
(str/join ""
(for [{:keys [k label var]} (:inks @settings)]
(str "<button class='paint-swatch" (when (= k @ink) " paint-chosen")
"' data-ink='" (name k) "' title='" label
"' style='background: var(" var ")'></button>"))))
(defn palettes! [node]
(fedoras.ui.widget-ui/html! node ".paint-tools" (tools-html))
(fedoras.ui.widget-ui/html! node ".paint-inks" (inks-html))
;; the fill button is only there for the two tools it means anything to
(when-let [b (fedoras.reader/q node ".paint-fill")]
(set! (.-display (.-style b)) (if (closed? @tool) "inline-flex" "none"))
(set! (.-className b) (str "paint-fill" (when @filling " paint-chosen"))))
;; the old markup had one button that said Rubber. if it is still
;; there, keep it working.
(fedoras.reader/enable! node ".paint-mode-go" true
(if (= :rubber @tool) "Pen" "Rubber")))
;; ---------------------------------------------------------------------
;; The ledger
;; ---------------------------------------------------------------------
(defn- record-html [m]
(str "<div class='paint-record" (when (withdrawn? m) " paint-struck") "'>"
"<span class='paint-n'>" (:n m) "</span>"
"<span class='paint-what'>" (:raw m) " points, " (count (:shape m)) " kept"
(when (:fill m) " · filled") "</span>"
"<span class='paint-when'>"
(cond (withdrawn? m) (str "withdrawn at " (:withdrawn m))
(:withdrawn m) (str "rubbed at " (:withdrawn m))
:else "on the picture")
"</span></div>"))
(defn- ledger-html []
(let [ms @marks depth (:ledger @settings) total (count ms)]
(if (empty? ms)
"<div class='paint-record paint-nothing'>nothing yet</div>"
(str (when (> total depth)
(str "<div class='paint-record paint-nothing'>"
(- total depth) " earlier, still in the file</div>"))
(str/join "" (map record-html (take-last depth ms)))))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn paint! [node]
(let [{:keys [total gone shown drawn kept]} (tally)
{:keys [tolerance show-withdrawn]} @settings
canvas (fedoras.reader/q node ".paint-canvas")]
(when canvas (draw! canvas))
(when (and canvas
(not (contains? @measured (.-id node)))
(pos? (.-clientWidth canvas)))
(swap! measured conj (.-id node))
(js/requestAnimationFrame (fn [_] (draw! canvas))))
(.toggle (.-classList node) "paint-withdrawing" (= :rubber @tool))
(fedoras.ui.widget-ui/state!
node
(str (name @tool) " · " shown " on the picture · " gone " withdrawn · "
total " in the file · 0 deleted"))
(fedoras.reader/text!
node ".paint-points"
(str drawn " points drawn · " kept " kept · " (- drawn kept) " gone"
" · keep " (tolerance->keep tolerance) "%"
" (" (.toFixed tolerance 2) ")"
" · sheet " @sheet
(when-not show-withdrawn " · the picture is not showing everything")))
(fedoras.ui.widget-ui/html! node ".paint-ledger" (ledger-html))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(defn- at [canvas event]
[(* world (/ (.-offsetX event) (.-clientWidth canvas)))
(- world (* world (/ (.-offsetY event) (.-clientHeight canvas))))])
(defn- capture! [canvas event]
(try (.setPointerCapture canvas (.-pointerId event)) (catch :default _ nil)))
(defn- finish! [node]
(when-let [p @in-progress]
(record! p)
(reset! in-progress nil)
(paint! node)))
(defn- choose! [node what k]
(case what
:tool (reset! tool k)
:ink (reset! ink k)
:fill (swap! filling not))
(palettes! node)
(paint! node))
(defn- wire-canvas! [node]
(when-let [canvas (fedoras.ui.widget-ui/q! node ".paint-canvas")]
(set! (.-onpointerdown canvas)
(fn [e]
(when (zero? (.-button e))
(.preventDefault e)
(capture! canvas e)
(let [p (at canvas e)]
(if (= :rubber @tool)
(swap! marks withdraw-at p)
(reset! in-progress {:kind @tool :anchor p :pts [p]})))
(paint! node))))
(set! (.-onpointermove canvas)
(fn [e]
(let [p (at canvas e)]
(cond
(and (= :rubber @tool) (pos? (.-buttons e)))
(do (swap! marks withdraw-at p) (paint! node))
@in-progress
(do (swap! in-progress
(fn [{:keys [kind anchor pts] :as s}]
(if (= kind :pen)
(assoc s :pts (conj pts p))
(assoc s :pts (shape-points kind anchor p)))))
(paint! node))))))
(set! (.-onpointerup canvas) (fn [_] (finish! node)))
(set! (.-onpointercancel canvas) (fn [_] (finish! node)))
(set! (.-oncontextmenu canvas)
(fn [e]
(.preventDefault e)
(choose! node :tool (if (= :rubber @tool) :pen :rubber))
false))))
(defn- wire-palettes! [node]
(palettes! node)
(when-let [row (fedoras.reader/q node ".paint-tools")]
(set! (.-onclick row)
(fn [e]
(when-let [b (.closest (.-target e) ".paint-pick")]
(choose! node :tool (keyword (.getAttribute b "data-tool")))))))
(when-let [row (fedoras.reader/q node ".paint-inks")]
(set! (.-onclick row)
(fn [e]
(when-let [b (.closest (.-target e) ".paint-swatch")]
(choose! node :ink (keyword (.getAttribute b "data-ink")))))))
(fedoras.reader/on-click! node ".paint-fill" (fn [_] (choose! node :fill nil)))
(fedoras.reader/on-click! node ".paint-mode-go"
(fn [_] (choose! node :tool (if (= :rubber @tool) :pen :rubber)))))
(defn- slider!
"One slider. `read` turns a position into the value the setting wants,
`write` puts it back, and `from` and `to` are positions."
[node selector k read write from to]
(when-let [c (fedoras.reader/q node selector)]
(set! (.-min c) (str from))
(set! (.-max c) (str to))
(set! (.-value c) (str (write (get @settings k))))
(set! (.-oninput c)
(fn [_]
(swap! settings assoc k (read (js/parseInt (.-value c) 10)))
(paint! node)))))
(defn- wire-sliders! [node]
(let [{:keys [weight-min weight-max strength-min strength-max]} @settings
round (fn [n] (js/Math.round n))]
(slider! node ".paint-weight" :weight
(fn [p] (/ p 20.0)) (fn [v] (round (* v 20)))
(round (* weight-min 20)) (round (* weight-max 20)))
(slider! node ".paint-strength" :strength
(fn [p] (/ p 1000.0)) (fn [v] (round (* v 1000)))
(round (* strength-min 1000)) (round (* strength-max 1000)))
(slider! node ".paint-reach" :reach
(fn [p] (/ p 10.0)) (fn [v] (round (* v 10))) 10 200)
(slider! node ".paint-spread" :spread
(fn [p] (/ p 100.0)) (fn [v] (round (* v 100))) 0 100)
(slider! node ".paint-tolerance" :tolerance
keep->tolerance tolerance->keep 0 100)))
(defn- wire-sheet! [node]
(fedoras.reader/on-click! node ".paint-fresh"
(fn [_] (fresh-sheet!) (paint! node)))
(fedoras.reader/on-click! node ".paint-simplify"
(fn [_] (refile!) (paint! node)))
(fedoras.reader/on-click! node ".paint-copy"
(fn [_] (when-let [c (fedoras.reader/q node ".paint-canvas")]
(copy-picture! node c))))
(fedoras.reader/on-click! node ".paint-download"
(fn [_] (fedoras.ui.widget-ui/download!
(as-text @sheet @marks) (str "sheet-" @sheet ".edn")))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current
(select-keys setting
[:tolerance :reach :strength :spread :grain
:ledger :weight :weight-min :weight-max
:strength-min :strength-max
:inks :show-withdrawn :floor :fill-alpha]))))
;; the grain may have changed, so every trace is out of date
(swap! marks (fn [ms] (mapv traced ms)))
(wire-sliders! node)
(palettes! node)
(paint! node))))
(defn setup! [node]
(let [id (.-id node)]
(fedoras.ui.widget-ui/wire! node "canvas" wire-canvas!)
(fedoras.ui.widget-ui/wire! node "palettes" wire-palettes!)
(fedoras.ui.widget-ui/wire! node "sliders" wire-sliders!)
(fedoras.ui.widget-ui/wire! node "sheet" wire-sheet!)
(fedoras.ui.widget-ui/wire!
node "resize"
(fn [n] (.addEventListener js/window "resize"
(fn [_] (when (fedoras.ui.widget-ui/running? (.-id n))
(paint! n))))))
(fedoras.ui.widget-ui/wire! node "settings" (fn [n] (wire-settings! n id)))))
(defn mount! [{:keys [id]}]
(fedoras.ui.widget-ui/mount! ::paint (or id "paint") setup! paint!))</script><div class="widget widget-paint" id="paint"><div class="roll-head"><span class="roll-label">PAINT.CLJS · drag to mark · right-click for the rubber</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">paint_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="24" spellcheck="false">;; ---------------------------------------------------------------------
;; PAINT_SETTINGS.CLJS — what the filing system is set to.
;;
;; Change a number and press Run. Then draw something, and look at the
;; two counts under the picture.
;;
;; :tolerance is the one that matters. It is how far a point may sit
;; from the line before the line is judged to need it. At 0.6 a scribble
;; keeps about one point in twenty. At 0.05 it keeps most of them. At 4
;; it keeps the corners and nothing else.
;;
;; Nothing in this program deletes a mark. This number deletes most of
;; every mark you make, and there is no record anywhere of what it took.
;; ---------------------------------------------------------------------
{:tolerance 0.6
:tolerance-min 0.02
:tolerance-max 6.0
:weight 1.4
:weight-min 0.4
:weight-max 4.0
:strength-min 0.005
:strength-max 0.4
:ledger 6 ; how many rows of the file are shown at once
:fill-alpha 0.35
;; The two inks. Withdrawn marks are drawn first and underneath, so
;; whatever you take off the picture is still on the picture.
:inks [{:k :ink :label "ink" :var "--ink"}
{:k :violet :label "violet" :var "--violet"}
{:k :gold :label "gold" :var "--gold"}
{:k :coral :label "coral" :var "--coral"}
{:k :green :label "green" :var "--green"}
{:k :blurple :label "blue" :var "--blurple"}]
:withdrawn-ink "--ink-soft"
;; The Rubber Controls
:reach 6.0
:strength 0.05
:spread 0.5
:grain 1.5
;; Set this false and withdrawn marks stop being drawn at all. Nothing
;; else changes: the counts are the same, the file is the same, the
;; marks are all still there. Only the picture agrees with you.
:show-withdrawn true
:floor 0.12}</textarea><div class="live-bar"><button class="roll-go widget-settings-run">Run</button><button class="roll-rewrite widget-settings-reset">Put it back</button></div><p class="widget-settings-said">Press Run.</p></div><div class="widget-body"><div class="live-bar"><div class="paint-tools"></div><button class="paint-fill" style="display:none;">fill</button><div class="paint-inks"></div></div><div class="live-bar paint-dials"><label class="paint-control">line<input class="paint-weight" step="1" type="range" /></label><label class="paint-control">rub<input class="paint-strength" step="1" type="range" /></label><label class="paint-control">brush<input class="paint-reach" step="1" type="range" /></label><label class="paint-control">smudge<input class="paint-spread" step="1" type="range" /></label><label class="paint-control">keep<input class="paint-tolerance" step="1" type="range" /></label></div><canvas class="paint-canvas"></canvas><p class="paint-points"></p><div class="paint-ledger"></div><div class="live-bar"><button class="roll-go paint-simplify">Refile everything</button><button class="roll-go paint-copy">Copy the picture</button><button class="roll-go paint-download">Download the file</button><button class="roll-rewrite paint-fresh">Fresh sheet</button></div></div><p class="roll-foot">Nothing in this program deletes anything.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.paint.paint-ui/mount!))] (mount {:id "paint"}) (js/console.error "fedoras: fedoras.cs-class.paint.paint-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div>
```
```{=html}
<div><span style="display:none;"></span><span style="display:none;"></span><span style="display:none;"></span><span style="display:none;"></span><style>/* ---------------------------------------------------------------
CAMPUS.CLJ
Every direction is on the pad whether or not it goes anywhere, and
nothing about a dead one looks dead. That is the whole program and
it would be very easy to helpfully ruin it here, so: no :disabled,
no dimming, no cursor change, and no hover state that differs.
--------------------------------------------------------------- */
.widget-campus { max-width: 30rem; }
.campus-where {
margin: 0.4rem 0 0.6rem 0 !important;
font-size: 1.05rem;
letter-spacing: 0.12em;
}
.campus-pad {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.3rem;
max-width: 18rem;
margin: 0.3rem 0 0.5rem 0;
}
.campus-go {
font: inherit;
font-size: 0.76rem;
letter-spacing: 0.06em;
padding: 0.35rem 0.2rem;
border: 1px solid rgba(var(--ink-rgb), 0.45);
border-radius: 4px;
background: rgba(var(--ink-rgb), 0.06);
color: var(--ink);
cursor: pointer;
}
.campus-go:hover { background: rgba(var(--ink-rgb), 0.14); }
.campus-go:active { background: rgba(var(--violet-rgb), 0.2); }
.campus-note {
margin: 0 0 0.4rem 0 !important;
font-size: 0.78rem;
font-style: italic;
opacity: 0.6;
min-height: 1.1em;
}
.campus-log {
margin: 0.5rem 0 0.2rem 0;
padding: 0;
background: none;
border: none;
font-size: 0.72rem;
line-height: 1.65;
white-space: pre-wrap;
font-variant-numeric: tabular-nums;
}
.campus-log:empty { display: none; }</style><script type="application/x-scittle">(ns fedoras.cs-class.campus.campus-ui
"The map, walked.
While walking around, it will tell you one thing: where you are. It
does not say that a direction did nothing, because `go` does not say
that, because a building does not say that.
The log keeps track of every step, whether it moved anyone or
not, and pressing the button prints the lot with the ones that did
nothing marked, and a count at the bottom.
Almost every reader has more of those than they expect. That is not a
trick and it is not a difficulty — it is what walking round a building
is like, and the only unusual thing about this one is that somebody
kept the tally."
(:require [clojure.string :as str]
[fedoras.reader :as read]
[fedoras.ui.widget-ui :as wui]))
(def settings (atom {:plan {} :names {} :start :whitlock}))
(defonce at (atom nil))
(defonce trail (atom []))
(defonce shown (atom nil))
;; ---------------------------------------------------------------------
;; The map
;; ---------------------------------------------------------------------
(defn exits [room] (get-in @settings [:plan room :exits] {}))
(defn go
"One step. A direction that does not exist puts you back where you
were and says nothing about it."
[room dir]
(or (get (exits room) dir) room))
(defn name-of [room]
(or (get-in @settings [:names room]) (str/upper-case (name room))))
(defn reachable [from]
(loop [seen #{from} todo [from]]
(if-let [r (first todo)]
(let [next (remove seen (vals (exits r)))]
(recur (into seen next) (into (rest todo) next)))
seen)))
(defn unreachable [from]
(sort (remove (reachable from) (keys (:plan @settings)))))
;; ---------------------------------------------------------------------
;; Walking
;; ---------------------------------------------------------------------
(defn step!
[dir]
(let [from @at
to (go from dir)]
(reset! at to)
(swap! trail conj {:from from :dir dir :to to :moved (not= from to)})
(reset! shown nil)))
(defn back!
"The only direction that is not on the map. It undoes a step that
moved somebody, and it counts as a step, and it is in the log."
[]
(when-let [last-move (last (filter :moved @trail))]
(let [from @at]
(reset! at (:from last-move))
(swap! trail conj {:from from :dir :back :to (:from last-move) :moved true})
(reset! shown nil))))
(defn start! []
(reset! at (:start @settings))
(reset! trail [])
(reset! shown nil))
;; ---------------------------------------------------------------------
;; The two things it will tell you if you ask
;; ---------------------------------------------------------------------
(defn- log-text []
(let [rows @trail
no-ops (count (remove :moved rows))
pad (fn [s n] (str s (apply str (repeat (max 0 (- n (count (str s)))) " "))))]
(if (empty? rows)
"You have not been anywhere."
(str/join
"\n"
(concat
(map-indexed
(fn [i {:keys [from dir to moved]}]
(str (pad (inc i) 4)
(pad (name from) 12)
(pad (name dir) 8)
(pad (name to) 12)
(when-not moved "—")))
rows)
[""
(str (count rows) " steps · " (- (count rows) no-ops) " moved · "
no-ops " did nothing")
(if (zero? no-ops)
"Nothing you tried was refused."
"Nothing told you at the time. Nothing was going to.")])))))
(defn- lost-text []
(let [ids (unreachable (:start @settings))]
(if (empty? ids)
"There is nothing on this map that you cannot get to."
(str/join
"\n"
(concat
["ON THE PLAN AND NOT ON THE MAP" ""]
(map (fn [id] (str " " (name-of id))) ids)
[""
(str (count ids) " of " (count (:plan @settings))
". Nothing in the map says which. They are the ones")
"nothing leads to."])))))
;; ---------------------------------------------------------------------
;; Painting
;; ---------------------------------------------------------------------
(defn paint! [node]
(let [room @at
ways (exits room)]
(read/text! node ".campus-where" (name-of room))
(wui/state!
node (str (count @trail) " steps"))
(read/text!
node ".campus-note"
(if (empty? ways)
"There is nothing out of this room."
""))
;; every direction is shown, and one that goes nowhere looks exactly
;; like one that does, because that is the point of the whole thing
(doseq [b (read/q-all node ".campus-go")]
(set! (.-className b) "campus-go"))
(read/text! node ".campus-log" (or @shown ""))))
;; ---------------------------------------------------------------------
;; Hands
;; ---------------------------------------------------------------------
(def keymap
{"ArrowUp" :north "w" :north "ArrowDown" :south "s" :south
"ArrowLeft" :west "a" :west "ArrowRight" :east "d" :east
"i" :in "o" :out "u" :up "n" :down})
(defn- hold-focus!
"Give the keys back to the widget after a click, so that a button just
pressed does not keep them. The reader is already here, so nothing
scrolls."
[node]
(.focus node #js {:preventScroll true}))
(defn- typing?
"Whether a key belongs to something being typed into, such as the
settings."
[element]
(boolean (or (contains? #{"INPUT" "TEXTAREA" "SELECT"} (.-tagName element))
(.-isContentEditable element))))
(defn- wire-pad!
"The pad, and the keys. A key the map knows is the map's wherever the
focus is in the widget, except in the settings, where it is being
typed, and except with ctrl, alt or the command key, which belong to
the browser."
[node]
(wui/delegate!
node ".campus-pad" ".campus-go" []
(fn [event _]
(when-let [button (.closest (.-target event) ".campus-go")]
(let [direction (keyword (.getAttribute button "data-dir"))]
(if (= direction :back) (back!) (step! direction))
(paint! node)
(hold-focus! node)))))
(when-not (.hasAttribute node "tabindex")
(.setAttribute node "tabindex" "0"))
(set! (.-onkeydown node)
(fn [event]
(let [pressed (.-key event)
direction (get keymap pressed)
target (.-target event)]
(when (and (or direction (= "Backspace" pressed))
(not (or (.-ctrlKey event) (.-metaKey event) (.-altKey event)))
(not (typing? target)))
(.preventDefault event)
(when-not (identical? target node) (hold-focus! node))
(if direction (step! direction) (back!))
(paint! node))))))
(defn- wire-asks!
[node]
(let [then-focus (fn [work] (fn [_] (work) (paint! node) (hold-focus! node)))]
(read/on-click! node ".campus-read" (then-focus (fn [] (reset! shown (log-text)))))
(read/on-click! node ".campus-lost" (then-focus (fn [] (reset! shown (lost-text)))))
(read/on-click! node ".campus-start" (then-focus start!))))
(defn- wire-settings! [node id]
(fedoras.ui.widget-ui/settings!
node id
(fn [setting]
(swap! settings
(fn [current]
(merge current (select-keys setting [:plan :names :start]))))
(start!)
(paint! node))))
(defn setup!
"Once per element. Nothing here takes the focus: a page that jumps to a
widget the reader has not reached yet has made a decision on the
reader's behalf. The widget takes the keys when it is first clicked or
tabbed to."
[node opts]
(let [id (.-id node)]
(swap! settings assoc :start (keyword (or (:start opts) "whitlock")))
(start!)
(wui/wire! node "pad" wire-pad!)
(wui/wire! node "asks" wire-asks!)
(wui/wire! node "settings" (fn [element] (wire-settings! element id)))))
(defn mount! [{:keys [id] :as opts}]
(wui/mount!
::campus (or id "campus")
(fn [node] (setup! node opts))
paint!))</script><div class="widget widget-campus" id="campus" tabindex="0"><div class="roll-head"><span class="roll-label">CAMPUS.CLJ · one step at a time</span><span class="widget-state">press Run</span></div><div class="widget-source"><div class="widget-source-head"><span class="widget-source-name">campus_settings.cljs</span><span class="widget-source-lang">cljs</span></div><textarea class="widget-settings" rows="26" spellcheck="false">;; ---------------------------------------------------------------------
;; CAMPUS_SETTINGS.CLJS — the map.
;;
;; Not a copy of the map. The map. This is what the widget walks, and
;; editing a line here and pressing Run changes what is joined to what.
;;
;; ADD A DOOR SOMEWHERE THERE ISN'T ONE and you can go and stand in it.
;; There is nothing in this program that will stop you and nothing that
;; will comment on it, and what you find when you get there is a name.
;; ---------------------------------------------------------------------
{:start :whitlock
:plan
{;; the middle of it
:quad {:exits {:north :prentiss :south :whitlock :east :meridian
:west :beckwith :in :fountain}}
:fountain {:exits {:out :quad}}
;; north
:prentiss {:exits {:south :quad :east :sterling}}
:sterling {:exits {:west :prentiss :south :meridian}}
:chapel {:exits {}}
;; west
:beckwith {:exits {:east :quad :south :library}}
:library {:exits {:north :beckwith :south :plant}}
:plant {:exits {:north :library}}
;; east
:meridian {:exits {:west :quad :north :sterling :east :dorm-n :down :basement}}
:annex {:exits {:west :meridian}}
:dorm-n {:exits {:west :meridian :south :dorm-s}}
:dorm-s {:exits {:north :dorm-n :south :dorm-e}}
:dorm-e {:exits {:north :dorm-s :south :field-h}}
:field-h {:exits {:north :dorm-e}}
:b14 {:exits {}}
;; south
:whitlock {:exits {:north :quad :east :union}}
:union {:exits {:west :whitlock :east :commons}}
:commons {:exits {:west :union :north :meridian :south :corliss}}
:corliss {:exits {:north :commons}}
;; under
:basement {:exits {:up :meridian}}
;; ground
:fill {:exits {}}}
;; ---------------------------------------------------------------
;; What each of them is called when you are standing in it. Rooms
;; missing from here are shown by their key, which is what happens to
;; anything anybody adds.
;; ---------------------------------------------------------------
:names
{:quad "THE QUAD"
:fountain "THE FOUNTAIN"
:prentiss "PRENTISS SCIENCE CENTER"
:sterling "STERLING CENTER"
:chapel "THE OLD CHAPEL"
:beckwith "BECKWITH HALL"
:library "OAKES LIBRARY"
:plant "PLANT SERVICES"
:meridian "MERIDIAN HALL"
:annex "MERIDIAN ANNEX"
:dorm-n "NORTH HOUSE"
:dorm-s "SOUTH HOUSE"
:dorm-e "ASHCROFT HOUSE"
:field-h "ASHCROFT FIELD HOUSE"
:b14 "BUILDING 14"
:whitlock "WHITLOCK HALL"
:union "HALLORAN UNION"
:commons "THE COMMONS"
:corliss "CORLISS FIELD"
:basement "UNDER MERIDIAN HALL"
:fill "THE FILL"}}
;; ---------------------------------------------------------------------
;; FOUR ROOMS HAVE NOTHING LEADING TO THEM.
;;
;; The button says which. It does not work them out from a list — there
;; is no list — it searches out from where you started and reports what
;; the search did not touch.
;;
;; Three of the four say why on the plan. The chapel is closed. The
;; annex has no public entrance. The Fill is a piece of ground with a
;; sign on it.
;;
;; The annex has an exit, incidentally. You could leave it.
;; ---------------------------------------------------------------------</textarea><div class="live-bar"><button class="roll-go widget-settings-run">Run</button><button class="roll-rewrite widget-settings-reset">Put it back</button></div><p class="widget-settings-said">Press Run.</p></div><div class="widget-body"><p class="campus-where"></p><div class="campus-pad"><button class="campus-go" data-dir="north">north</button><button class="campus-go" data-dir="up">up</button><button class="campus-go" data-dir="in">in</button><button class="campus-go" data-dir="west">west</button><button class="campus-go" data-dir="east">east</button><button class="campus-go" data-dir="out">out</button><button class="campus-go" data-dir="south">south</button><button class="campus-go" data-dir="down">down</button><button class="campus-go" data-dir="back">back</button></div><p class="campus-note"></p><pre class="campus-log"></pre><div class="live-bar"><button class="roll-go campus-read">How did I get here</button><button class="roll-go campus-lost">What cannot I get to</button><button class="roll-rewrite campus-start">Start again</button></div></div><p class="roll-foot">A direction that is not there is not an error.</p></div><script type="application/x-scittle">(if-let [mount (resolve (quote fedoras.cs-class.campus.campus-ui/mount!))] (mount {:id "campus", :start "whitlock"}) (js/console.error "fedoras: fedoras.cs-class.campus.campus-ui/mount! is not on this page. Either the file that declares it was never shipped, or the namespace inside it does not match the path it was shipped from."))</script></div>
```
```{=html}
<div class="scene"><div class="screenplay"><p class="action">Two counts and neither of them is wrong. The one that says what is on the picture goes up and down. The one that says what is in the file only goes up.</p></div></div>
```