3 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 0852427586 Fix the pause: it would not hide, it was off centre, and it was too small
Three faults in 1.0.4, and the first was the serious one.

  1. It never went away. Core hides the overlay with `#pause { display: none }`
     and shows it with `#pause.paused { display: flex }`. 1.0.4 styled the BARE
     #pause with display:flex, which wins on specificity, so unpausing removed
     the class and changed nothing — GAME PAUSED sat over the table for the rest
     of the session. Every rule here is now scoped to `.paused` and `display` is
     not set anywhere.

  2. It was off centre because I repositioned it. Core already centres it: a
     full-width band at top calc(50vh - 100px), column flex, centred both ways,
     pointer-events none. That layout was correct and 1.0.4 fought it with
     left/bottom/transform. It is left alone now; only the height changes, and
     the top moves up by exactly half the extra so the centring holds.

  3. It was small: a 322x116 panel in the corner. It is now core's full-width
     band at 300px with a 170px dial — against core's own 100px — and the
     caption at 24px to match core's.

Guarded: check-behaviour now parses the stylesheet and fails any rule that sets
display on an unscoped #pause. Verified it bites. It strips CSS comments first,
because the first version of the guard reported the comment explaining the
guard — which is exactly what the template guard did last time, so that is twice
now and the lesson is written down properly.

Verified by driving the real toggle: running → display none, box 0x0, no class;
paused → display flex, 341x302, centre X exactly on the viewport centre;
unpaused again → display none, box 0x0.

Version 1.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:31:35 +01:00
slaguru666andClaude Opus 5 ad771ff2b5 A uniselector for the pause
Foundry's pause is a spinning d20 and a caption. This replaces both from CSS
alone, without touching the markup: figure#pause becomes a small navy panel with
a yellow rule along the top, and the <img> becomes the arm of a uniselector.

A uniselector is the stepping switch inside a telephone exchange — an arm that
clicks round one contact at a time, hunting for a free line. It is the right
object for this game. The department's whole business is a machine that says
where things belong, and OPEN DAY is set inside one. Eleven contacts, because
eleven minutes is the interval this setting keeps returning to.

It STEPS rather than sweeps: steps(11) over 7.7s, so a contact every 700ms. A
continuous spin reads as a loading throbber; a discrete click reads as a machine
working through a list, which is what the department is doing while the table is
out of the room. The arm carries a faint trailing sector, because without it a
700ms step is easy to miss and the stepping is the whole idea.

No new text. Foundry's caption is localised and this only restyles it — a string
hardcoded in a stylesheet would be one the lang file could never reach.

Two things the browser had to be asked rather than assumed:

  - Core pins #pause with BOTH top and bottom, so an auto height stretched it to
    180px for a 16px caption. It is pinned here instead and sized to content.
  - The arm is rotated, so getBoundingClientRect returns the diagonal of its own
    square (75px for a 52px element). The layout takes its size from the flex
    basis and never from the measured box.

prefers-reduced-motion stops the movement and keeps the object — a stepping dial
in the corner of the eye is exactly what that preference is asking about.

Version 1.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:25:12 +01:00
slaguru666andClaude Opus 5 53481bdfc1 Ship the LAST ADMISSION plates as showable images
A third journal in the adventure — LAST ADMISSION — Show the players — holding
the seven scene plates and the floor plan as eight image pages at player
visibility. Right-click, Show Players, done.

title.show is false and there is no caption, on purpose: the table gets the
picture and nothing else. No heading telling them which room this is, no label
naming the thing on the stairs. The page NAMES exist only for the GM's sidebar
so the right one can be found in a hurry under pressure, and the players never
see them.

Ordered by when each comes up, not by asset number, for the same reason.

The builder throws if a plate is missing from disk, because a missing image is
a blank frame on a projector in front of six people.

Verified in Foundry: eight pages import at OBSERVER, the image loads, and the
rendered page contains no headings, no figcaption and no text at all.

Version 1.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:12:35 +01:00
24 changed files with 202 additions and 22 deletions
+1 -1
View File
@@ -183,6 +183,6 @@ The build runs all three guards before it writes anything:
check-rules: OK — 87 rules, 21 files scanned, 3 aliases + 41 constant sets checked, 275 formulas verified
check-kits: OK — 25 roles, 10 trades, 218 catalogue items, every kit key resolves, every posting can use what it carries, every loadout distinct
check-lang: OK — en.json, 945 keys, no leaf/branch collisions
check-behaviour: OK — 45 behavioural tests
check-behaviour: OK — 46 behavioural tests
```
<!-- guards:end -->
+7
View File
@@ -426,6 +426,13 @@ If there is time — and there usually is, because this ends early — she says
## Artwork
**Showing them at the table.** The adventure ships a third journal,
**LAST ADMISSION — Show the players**, holding these as eight image pages at player
visibility. Right-click a page and *Show Players*: the table gets the picture and
nothing else — no heading, no caption, no label naming what they are looking at. The
page names are for the GM's sidebar and the players never see them.
**Style line:** flat editorial vector, deep navy and pale yellow with warm off-white,
1970s British public-information poster, `--style raw --ar 3:2 --v 6`.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.231003 174603000 Delete type=3 #1
2026/08/12-19:31:16.133590 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.298648 173df7000 Delete type=3 #1
2026/08/12-19:31:16.207708 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.232848 174603000 Delete type=3 #1
2026/08/12-19:31:16.135472 171f87000 Delete type=3 #1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.282484 173df7000 Delete type=3 #1
2026/08/12-19:31:16.192039 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.266480 174603000 Delete type=3 #1
2026/08/12-19:31:16.173317 172f9f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.293373 173df7000 Delete type=3 #1
2026/08/12-19:31:16.202720 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.252536 174603000 Delete type=3 #1
2026/08/12-19:31:16.157576 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.290196 172ddf000 Delete type=3 #1
2026/08/12-19:31:16.199567 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.286378 174603000 Delete type=3 #1
2026/08/12-19:31:16.195999 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.297168 1735eb000 Delete type=3 #1
2026/08/12-19:31:16.206279 172f9f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.224954 172ddf000 Delete type=3 #1
2026/08/12-19:31:16.127123 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.291920 1735eb000 Delete type=3 #1
2026/08/12-19:31:16.201269 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.276268 174603000 Delete type=3 #1
2026/08/12-19:31:16.184597 172793000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.270666 173df7000 Delete type=3 #1
2026/08/12-19:31:16.178030 172f9f000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.227877 174603000 Delete type=3 #1
2026/08/12-19:31:16.129805 171f87000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.234705 1735eb000 Delete type=3 #1
2026/08/12-19:31:16.137478 1737ab000 Delete type=3 #1
+1 -1
View File
@@ -1 +1 @@
2026/08/12-19:01:54.229633 174603000 Delete type=3 #1
2026/08/12-19:31:16.131540 171f87000 Delete type=3 #1
+104
View File
@@ -1684,3 +1684,107 @@
.rb-dying { border-left: 3px solid var(--rb-danger); padding-left: 8px; }
.rb-stable { border-left: 3px solid var(--rb-yellow); padding-left: 8px; }
.rb-dead { border-left: 3px solid var(--rb-white); padding-left: 8px; }
/* ============================================================
THE PAUSE — a uniselector, stepping
============================================================
Foundry's pause is <figure id="pause"> holding an <img> and a <figcaption>, and core
hides it with `#pause { display: none }` / shows it with `#pause.paused { display:
flex }`. The first version of this styled the bare #pause with display:flex and so
overrode the hide — the panel never went away when the game unpaused. EVERY rule here
that could affect visibility or layout is therefore scoped to `.paused`, and `display`
is not set anywhere.
Core also already centres it: a full-width band, 180px tall, at top calc(50vh - 100px),
column flex, centred both ways, pointer-events none. That layout is correct and is
left alone — only its size and its looks change here. The first version repositioned
it with left/bottom and pushed it off centre for the trouble.
A uniselector is the stepping switch inside a telephone exchange: an arm that clicks
round one contact at a time, hunting for a free line. The department's whole business
is a machine that says where things belong, and OPEN DAY is set inside one. Eleven
contacts, because eleven minutes is the interval this setting keeps returning to.
It STEPS rather than sweeps — a contact every 700ms. A continuous spin reads as a
loading throbber; a discrete click reads as a machine working through a list.
============================================================ */
/* Taller than core's 180px, and moved up by exactly half the extra so it stays centred. */
#pause.paused {
height: 300px;
top: calc(50vh - 150px);
gap: 28px;
background: linear-gradient(to right,
transparent 0%,
color-mix(in srgb, var(--rb-void) 82%, transparent) 38%,
color-mix(in srgb, var(--rb-void) 82%, transparent) 62%,
transparent 100%);
}
/* the dial: eleven contacts, standing still, behind the arm */
#pause.paused::before {
content: "";
position: absolute;
top: 24px; left: 50%; margin-left: -85px;
width: 170px; height: 170px;
border-radius: 50%;
background:
repeating-conic-gradient(from -90deg,
var(--rb-line) 0deg 1.6deg, transparent 1.6deg 32.727deg);
-webkit-mask: radial-gradient(circle, transparent 0 52px, #000 54px);
mask: radial-gradient(circle, transparent 0 52px, #000 54px);
opacity: .9;
}
/* the arm. The <img> keeps its box and loses its pixels: object-position walks the
raster out of frame, which leaves a paintable element without adding a wrapper. */
#pause.paused img {
width: 170px; height: 170px;
object-fit: none; object-position: -9999px -9999px;
border: none; box-shadow: none; filter: none;
/* the arm, and behind it the contact it has just left — without the trailing sector
a 700ms step is easy to miss, and the stepping is the whole idea */
background: conic-gradient(from -91deg,
var(--rb-yellow) 0deg 3.5deg,
var(--rb-yellow-dim) 3.5deg 28deg,
transparent 28deg 360deg);
-webkit-mask: radial-gradient(circle, #000 0 78px, transparent 80px);
mask: radial-gradient(circle, #000 0 78px, transparent 80px);
animation: rb-uniselector 7.7s steps(11, end) infinite;
}
/* core adds fa-spin to that img; ours must be the only rotation running */
#pause.paused img.fa-spin { animation: rb-uniselector 7.7s steps(11, end) infinite; }
/* the hub, and the amber that says the record is being written */
#pause.paused::after {
content: "";
position: absolute;
top: 101px; left: 50%; margin-left: -8px;
width: 16px; height: 16px;
border-radius: 50%;
background: var(--rb-yellow);
animation: rb-pause-pip 2.6s ease-in-out infinite;
}
#pause.paused figcaption {
font-family: var(--rb-display);
font-size: 24px;
letter-spacing: .3em;
text-transform: uppercase;
color: var(--rb-yellow);
line-height: 1.2;
text-shadow: 0 2px 18px var(--rb-void);
}
@keyframes rb-uniselector { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes rb-pause-pip { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
/* A stepping dial in the corner of the eye is exactly what this preference is about.
Keep the object, stop the movement — including core's own pulse on the whole figure. */
@media (prefers-reduced-motion: reduce) {
#pause.paused,
#pause.paused img,
#pause.paused img.fa-spin,
#pause.paused::after { animation: none; }
#pause.paused img { transform: rotate(-32.7deg); }
}
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ringbrp",
"title": "The Custodians",
"description": "A Basic Roleplaying game of agents who cross the crossing to contain what should not be there — and who come back a little less from here each time.",
"version": "1.0.2",
"version": "1.0.5",
"compatibility": {
"minimum": 13,
"verified": "14.364"
+50 -3
View File
@@ -461,6 +461,38 @@ function journalDoc(name, key, pages) {
return { doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [], ...BASE() }, pages: pageDocs, id };
}
/**
* A journal of nothing but pictures, for showing to players.
*
* title.show is FALSE and there is no caption on purpose: when the GM hits Show Players
* the table gets the image and nothing else — no heading telling them which room this
* is, no label naming the thing they are looking at. The page NAME is for the GM's list
* in the sidebar, so they can find the right one in a hurry; the players never see it.
*/
function imageJournal(name, key, plates) {
const id = idFor(`journal:${key}`);
const pageDocs = plates.map((p, i) => {
// A missing plate is a blank frame on a projector in front of six people.
if (!existsSync(path.join(ROOT, p.src))) throw new Error(`${name}: no plate at ${p.src}`);
return {
_id: idFor(`journal:${key}:${i}`),
name: p.name, type: "image",
title: { show: false, level: 1 },
src: `systems/ringbrp/${p.src}`,
image: { caption: "" },
text: { format: 1, content: "", markdown: "" },
video: { controls: true, volume: 0.5 },
system: {}, sort: (i + 1) * 100, ownership: { default: 2 }, flags: {},
category: null
};
});
return {
doc: { _id: id, name, pages: pageDocs.map(p => p._id), categories: [],
...BASE(), ownership: { default: 2 }, flags: { ringbrp: { brand: true } } },
pages: pageDocs, id
};
}
/* ---------------- writer ---------------- */
/**
@@ -842,6 +874,19 @@ function cullandScene() {
castDocs.push(actorDoc);
}
// The plates, one per page, for showing at the table. Named for what the GM will be
// looking for under pressure, ordered by when it comes up.
const showJournal = imageJournal("LAST ADMISSION — Show the players", "la_show", [
{ name: "1 · The house, from the drive", src: "art/scenes/la_01_hall.webp" },
{ name: "2 · Floor plan — Culland Hall", src: "art/maps/culland-hall.webp" },
{ name: "3 · Backstage — the green room", src: "art/scenes/la_06_green.webp" },
{ name: "4 · The east corridor", src: "art/scenes/la_03_corridor.webp" },
{ name: "5 · The nursery", src: "art/scenes/la_04_nursery.webp" },
{ name: "6 · The glasshouse — Old Marrow", src: "art/scenes/la_05_glass.webp" },
{ name: "7 · The stairs — the woman standing", src: "art/scenes/la_07_stairs.webp" },
{ name: "8 · The car park, afterwards", src: "art/scenes/la_08_carpark.webp" }
]);
const gmJournal = journalDoc("LAST ADMISSION — Run the prologue", "la_gm", L.LA_PAGES);
const handouts = journalDoc("LAST ADMISSION — Player handouts", "la_handouts", L.LA_HANDOUTS);
handouts.doc.ownership = { default: 2 };
@@ -870,8 +915,9 @@ function cullandScene() {
actors: castDocs,
items: [],
journal: [
{ ...gmJournal.doc, pages: gmJournal.pages },
{ ...handouts.doc, pages: handouts.pages }
{ ...gmJournal.doc, pages: gmJournal.pages },
{ ...handouts.doc, pages: handouts.pages },
{ ...showJournal.doc, pages: showJournal.pages }
],
scenes: [cullandScene()], combats: [], tables: [], macros: [], cards: [], playlists: [], folders: [],
sort: 0, ...BASE()
@@ -879,7 +925,8 @@ function cullandScene() {
await writePack("lastadmission", [[`!adventures!${advId}`, adventure]]);
console.log(` lastadm LAST ADMISSION — ${castDocs.length} cast, `
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts`);
+ `${L.LA_PAGES.length} GM pages, ${L.LA_HANDOUTS.length} handouts, `
+ `${showJournal.pages.length} plates to show`);
}
// OPEN DAY — the prologue. A much smaller adventure than BLACK PLATFORM: no case file,
+22
View File
@@ -426,6 +426,28 @@ test("every declared document type has a label and a sheet section", async () =>
}
});
// Core hides the pause overlay with `#pause { display: none }` and shows it with
// `#pause.paused { display: flex }`. A system rule on the BARE #pause that sets display
// wins on specificity and the overlay never goes away — the game unpauses, play carries
// on, and GAME PAUSED sits over the table for the rest of the session. Shipped exactly
// that in 1.0.4. Anything targeting #pause must be scoped to .paused.
test("no stylesheet rule sets display on a bare #pause", async () => {
const { readFile } = await import("node:fs/promises");
const raw = await readFile(new URL("../styles/ringbrp.css", import.meta.url), "utf8");
// Comments first, or this guard reports the comment that explains the guard — which
// is precisely what the template guard did the last time round.
const css = raw.replace(/\/\*[\s\S]*?\*\//g, "");
// every rule whose selector mentions #pause, with its declaration block
for (const m of css.matchAll(/([^{}]*#pause[^{}]*)\{([^{}]*)\}/g)) {
const selector = m[1].trim().replace(/\s+/g, " ");
const body = m[2];
const scoped = selector.split(",").every(sel => !sel.includes("#pause") || /#pause[^,]*\.paused/.test(sel));
if (scoped) continue;
assert.ok(!/(^|;)\s*display\s*:/.test(body),
`"${selector}" sets display on an unscoped #pause — the overlay will not hide on unpause`);
}
});
/* ---------------------------------------------------------------- */
await runAll();