From e0d46f8449502ecf0da0bbb71a78b1414f0c0f4d Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers <74975850+ExtraToast@users.noreply.github.com> Date: Sat, 5 Sep 2026 23:48:17 +0200 Subject: [PATCH 1/3] fix(island): every milestone opens as the reader goes past it Scrolling through the history skipped milestones: one would go by without ever standing up or telling its story. The band watched for crossings of the middle 16% of the screen. An observer checks once a frame, so a wheel flick that carries a stop across that band between two frames reports no crossing at all, and the milestone is never told about. The gaps between the stops were dead ground for the same reason. The stop being read is now measured rather than watched: whichever milestone's own middle is nearest the middle of the screen is the one being read, worked out from a scroll listener at most once a frame. Distance is always answerable, so there is no speed and no gap at which nothing is being read. --- .../association/island/HistoryBand.vue | 87 +++++++++---- .../domains/association/HistoryBand.test.ts | 120 ++++++++++-------- 2 files changed, 132 insertions(+), 75 deletions(-) diff --git a/services/frontend/src/domains/association/island/HistoryBand.vue b/services/frontend/src/domains/association/island/HistoryBand.vue index 3544a62f4..7df035244 100644 --- a/services/frontend/src/domains/association/island/HistoryBand.vue +++ b/services/frontend/src/domains/association/island/HistoryBand.vue @@ -25,32 +25,65 @@ const motion = useMotionAllowed() /** Which milestone is nearest the middle of the screen, or none while the page is elsewhere. */ const nearest = ref(-1) const items = ref([]) +const list = ref(null) const holdItem = (element: unknown, index: number): void => { if (element instanceof HTMLElement) items.value[index] = element } -let watching: IntersectionObserver | null = null +/** + * Whichever milestone's own middle is closest to the middle of the screen is the one being read. + * + * Measured from where the stops actually are, not from crossings reported as they happen: a + * reader flicking the wheel can carry a stop past the middle of the screen between two frames, + * and a milestone nobody was told about is a milestone that never opens. Distance is always + * answerable, so scrolling past always arrives somewhere. + */ +const measure = (): void => { + const band = list.value?.getBoundingClientRect() + const middle = window.innerHeight / 2 + // Nothing is being read while the whole history is above or below the reader. + if (!band || band.bottom < middle || band.top > middle) { + nearest.value = -1 + return + } + + let closest = -1 + let away = Number.POSITIVE_INFINITY + items.value.forEach((item, index) => { + if (!item) return + const box = item.getBoundingClientRect() + const distance = Math.abs((box.top + box.bottom) / 2 - middle) + if (distance < away) { + away = distance + closest = index + } + }) + nearest.value = closest +} + +/** At most one measurement a frame, however many scroll events the browser sends. */ +let pending: number | null = null +const remeasure = (): void => { + if (typeof requestAnimationFrame !== "function") { + measure() + return + } + if (pending !== null) return + pending = requestAnimationFrame(() => { + pending = null + measure() + }) +} onMounted(() => { // A reader who asked for less motion gets every milestone open and none of the growing. if (motion.reduced.value) return - if (typeof IntersectionObserver !== "function") return - - watching = new IntersectionObserver( - entries => { - for (const entry of entries) { - const index = items.value.indexOf(entry.target as HTMLElement) - if (index === -1) continue - if (entry.isIntersecting) nearest.value = index - else if (nearest.value === index) nearest.value = -1 - } - }, - // The same band across the middle the slices open on: only what a reader has actually - // brought to the centre counts as the one they are reading. - {rootMargin: "-42% 0px -42% 0px", threshold: 0}, - ) - for (const item of items.value) if (item) watching.observe(item) + if (typeof window === "undefined") return + + window.addEventListener("scroll", remeasure, {passive: true}) + window.addEventListener("resize", remeasure, {passive: true}) + measure() }) /** @@ -105,8 +138,12 @@ const tellingSoFar = (index: number): string => { } onBeforeUnmount(() => { - watching?.disconnect() - watching = null + if (typeof window !== "undefined") { + window.removeEventListener("scroll", remeasure) + window.removeEventListener("resize", remeasure) + } + if (pending !== null) cancelAnimationFrame(pending) + pending = null stopTyping() }) @@ -119,7 +156,10 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value :class="{'history--still': motion.reduced.value}" :data-testid="testid" > -
    +
    1. motion.reduced.value || nearest.value /* * A little more height than the words need, and no more. * - * The band that decides which milestone is being read is the middle 16% of the screen. A stop - * no taller than its text is crossed in one flick of the wheel and its telling opens and shuts - * before anybody could read it; a stop as tall as the screen turns the history into a chore. - * This is the smallest height that makes scrolling arrive at them one at a time. + * A stop no taller than its text is crossed in one flick of the wheel and its telling opens and + * shuts before anybody could read it; a stop as tall as the screen turns the history into a + * chore. This is the smallest height that makes scrolling arrive at them one at a time. */ .history__stop { position: relative; diff --git a/services/frontend/tests/unit/domains/association/HistoryBand.test.ts b/services/frontend/tests/unit/domains/association/HistoryBand.test.ts index fe2a921ac..4e2b8f4bd 100644 --- a/services/frontend/tests/unit/domains/association/HistoryBand.test.ts +++ b/services/frontend/tests/unit/domains/association/HistoryBand.test.ts @@ -1,5 +1,5 @@ import {beforeEach, describe, expect, it, vi} from "vitest" -import {mount} from "@vue/test-utils" +import {mount, type VueWrapper} from "@vue/test-utils" import HistoryBand from "@/domains/association/island/HistoryBand.vue" import type {Milestone} from "@/domains/association/historyAxis" @@ -9,39 +9,49 @@ const MILESTONES: Milestone[] = [ {year: "Now", title: "Third", summary: "Where it stands, in a line.", telling: "Where it stands today."}, ] -const observed: Element[] = [] -let fire: ((entries: {target: Element; isIntersecting: boolean}[]) => void) | null = null +const SCREEN = 900 +const STOP = 200 -const installObserver = () => { - observed.length = 0 - fire = null - vi.stubGlobal("IntersectionObserver", class { - constructor(callback: (entries: unknown[]) => void) { - fire = callback as never - } +const mountBand = (): VueWrapper => mount(HistoryBand, {props: {milestones: MILESTONES, testid: "history"}}) - observe(element: Element) { - observed.push(element) - } +const place = (element: Element, top: number, height: number): void => { + element.getBoundingClientRect = () => ({ + top, bottom: top + height, height, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}), + }) as DOMRect +} - disconnect() { - observed.length = 0 - } +/** + * Put the list on the screen with the given stop's middle at the middle of the screen. + * + * jsdom lays nothing out, so the stops are placed by hand: three of them in a column, moved as + * one so that scrolling is the whole column sliding up past a fixed middle. + */ +const scrollTo = async (wrapper: VueWrapper, top: number): Promise => { + place(wrapper.get(".history__line").element, top, STOP * MILESTONES.length) + wrapper.findAll('[data-testid="history-stop"]').forEach((stop, index) => { + place(stop.element, top + index * STOP, STOP) }) + window.dispatchEvent(new Event("scroll")) + await new Promise(resolve => requestAnimationFrame(() => resolve(null))) + await wrapper.vm.$nextTick() } -const mountBand = () => mount(HistoryBand, {props: {milestones: MILESTONES, testid: "history"}}) +/** Where the column has to start for the given stop's middle to sit at the middle of the screen. */ +const middleOf = (index: number): number => SCREEN / 2 - index * STOP - STOP / 2 + +const readStop = (wrapper: VueWrapper): number => + wrapper.findAll('[data-testid="history-stop"]').findIndex(stop => stop.classes().includes("history__stop--read")) describe("HistoryBand", () => { beforeEach(() => { vi.unstubAllGlobals() + window.innerHeight = SCREEN vi.stubGlobal("matchMedia", (query: string) => ({ matches: false, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), })) - installObserver() }) it("draws every milestone, in the order they happened", () => { @@ -63,50 +73,64 @@ describe("HistoryBand", () => { expect(wrapper.findAll(".history__stop--read")).toHaveLength(0) }) - it("watches every milestone for the middle of the screen", () => { - mountBand() + /** The one at the middle of the screen is the one being read, and only that one. */ + it("reads whichever milestone reaches the middle", async () => { + const wrapper = mountBand() + + await scrollTo(wrapper, middleOf(1)) - expect(observed).toHaveLength(3) + expect(readStop(wrapper)).toBe(1) + expect(wrapper.findAll(".history__stop--read")).toHaveLength(1) }) - /** The one at the middle of the screen is the one being read, and only that one. */ - it("reads whichever milestone reaches the middle", async () => { + /** + * The reason this is measured rather than watched. + * + * A wheel flick moves the page further in one frame than a milestone is tall, so the middle + * of the screen can be past a stop before anything is asked about it. The stop nearest the + * middle still answers, so every milestone opens as it goes by. + */ + it("reads a milestone the page flew past in a single frame", async () => { const wrapper = mountBand() - const stops = wrapper.findAll('[data-testid="history-stop"]') - fire?.([{target: stops[1].element, isIntersecting: true}]) - await wrapper.vm.$nextTick() + await scrollTo(wrapper, middleOf(0)) + expect(readStop(wrapper)).toBe(0) + + // A single frame carrying the reader most of the way through the second milestone. + await scrollTo(wrapper, middleOf(1) - STOP * 0.4) + expect(readStop(wrapper)).toBe(1) + }) - expect(stops[1].classes()).toContain("history__stop--read") - expect(stops[0].classes()).not.toContain("history__stop--read") - expect(stops[2].classes()).not.toContain("history__stop--read") + /** No dead ground between two milestones: one of them is always the one being read. */ + it("reads a milestone while the middle sits between two of them", async () => { + const wrapper = mountBand() + + await scrollTo(wrapper, middleOf(1) - STOP / 2) + + expect(wrapper.findAll(".history__stop--read")).toHaveLength(1) }) /** The telling carries on from the summary, and only for the milestone being read. */ it("writes the telling out once a milestone reaches the middle", async () => { const wrapper = mountBand() - const stops = wrapper.findAll('[data-testid="history-stop"]') expect(wrapper.text()).not.toContain(MILESTONES[1].telling) - fire?.([{target: stops[1].element, isIntersecting: true}]) - await wrapper.vm.$nextTick() + await scrollTo(wrapper, middleOf(1)) await new Promise(resolve => setTimeout(resolve, 400)) await wrapper.vm.$nextTick() + const stops = wrapper.findAll('[data-testid="history-stop"]') expect(stops[1].text()).toContain(MILESTONES[1].telling) expect(stops[0].text()).not.toContain(MILESTONES[0].telling) }) - it("stops reading a milestone that leaves the middle", async () => { + it("stops reading once the whole history is behind the reader", async () => { const wrapper = mountBand() - const stops = wrapper.findAll('[data-testid="history-stop"]') + await scrollTo(wrapper, middleOf(1)) - fire?.([{target: stops[1].element, isIntersecting: true}]) - await wrapper.vm.$nextTick() - fire?.([{target: stops[1].element, isIntersecting: false}]) - await wrapper.vm.$nextTick() + await scrollTo(wrapper, -STOP * MILESTONES.length - 10) - expect(stops[1].classes()).not.toContain("history__stop--read") + expect(wrapper.findAll(".history__stop--read")).toHaveLength(0) }) /** @@ -114,14 +138,13 @@ describe("HistoryBand", () => { * * Nothing grows and nothing opens, so nothing is hidden behind an effect they turned off. */ - it("opens every milestone for a reader who asked for less motion", () => { + it("opens every milestone for a reader who asked for less motion", async () => { vi.stubGlobal("matchMedia", (query: string) => ({ matches: true, media: query, addEventListener: vi.fn(), removeEventListener: vi.fn(), })) - installObserver() const wrapper = mountBand() @@ -129,15 +152,10 @@ describe("HistoryBand", () => { for (const stop of wrapper.findAll('[data-testid="history-stop"]')) { expect(stop.classes()).toContain("history__stop--read") } - // Nothing to watch for: there is no growing to trigger. - expect(observed).toHaveLength(0) - }) - - it("draws the whole history where the browser cannot watch the page", () => { - vi.stubGlobal("IntersectionObserver", undefined) - - const wrapper = mountBand() - - expect(wrapper.findAll('[data-testid="history-stop"]')).toHaveLength(3) + // Nothing to measure for: there is no growing to trigger. + await scrollTo(wrapper, -STOP * MILESTONES.length - 10) + for (const stop of wrapper.findAll('[data-testid="history-stop"]')) { + expect(stop.classes()).toContain("history__stop--read") + } }) }) From b2507d93d45e0a5d6a554cc7c25f9dcafddd2225 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers <74975850+ExtraToast@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:15:20 +0200 Subject: [PATCH 2/3] feat(island): the history writes itself out the way a console does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tellings are set in Silkscreen, a bitmap face, and written a character every other frame with a block cursor at the head of the writing — the pace and the look of a line of dialogue in a handheld RPG. Each character drops the last pixel or two into place in two steps as the cursor leaves it behind. The whole telling holds its room from the start, in no colour, and the cursor travels across ground the words already occupy. Two reasons. The milestones left of the line are right-aligned, and text added to while it is right-aligned grows away from its own end, so the line crawled leftwards and read as though it were being written backwards. And a telling that claimed its room only once it was being read pushed everything below it down the page, under a reader who was scrolling towards it. The milestones are named the way a quest is, which is the register the rest of the band now reads in. Silkscreen is a 226-codepoint face, so what the history is written with is held to what the file actually carries: the cmap reader the name-coverage test used moves to tests/unit/styles/glyphCoverage.ts and both tests share it. --- .../src/assets/fonts/Silkscreen-Bold.ttf | Bin 0 -> 30632 bytes .../src/assets/fonts/Silkscreen-OFL.txt | 93 +++++++++++ .../src/assets/fonts/Silkscreen-Regular.ttf | Bin 0 -> 32220 bytes .../src/domains/association/historyAxis.ts | 14 +- .../association/island/HistoryBand.vue | 149 ++++++++++++++++-- services/frontend/src/styles/fonts.scss | 19 ++- services/frontend/src/styles/island.css | 2 + .../domains/association/HistoryBand.test.ts | 54 ++++++- .../unit/styles/bitmapGlyphCoverage.test.ts | 29 ++++ .../tests/unit/styles/glyphCoverage.ts | 97 ++++++++++++ .../unit/styles/nameGlyphCoverage.test.ts | 94 +---------- 11 files changed, 428 insertions(+), 123 deletions(-) create mode 100644 services/frontend/src/assets/fonts/Silkscreen-Bold.ttf create mode 100644 services/frontend/src/assets/fonts/Silkscreen-OFL.txt create mode 100644 services/frontend/src/assets/fonts/Silkscreen-Regular.ttf create mode 100644 services/frontend/tests/unit/styles/bitmapGlyphCoverage.test.ts create mode 100644 services/frontend/tests/unit/styles/glyphCoverage.ts diff --git a/services/frontend/src/assets/fonts/Silkscreen-Bold.ttf b/services/frontend/src/assets/fonts/Silkscreen-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..6771252683388362e64aaea6e0d5d991e417c9fb GIT binary patch literal 30632 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 -import {onBeforeUnmount, onMounted, ref, watch} from "vue" +import {computed, onBeforeUnmount, onMounted, ref, watch} from "vue" import type {Milestone} from "@/domains/association/historyAxis" import {useMotionAllowed} from "@/components/island/useMotionAllowed" @@ -93,12 +93,16 @@ onMounted(() => { * it, so opening one is the sentence continuing rather than a second block arriving. Written * out a character at a time for the same reason: the eye follows the writing instead of * hunting for what just changed. + * + * A character a frame, in a bitmap face, with a block cursor waiting at the end of it: the way + * a console wrote a line of dialogue, and the one piece of the page that is playing rather than + * presenting. */ const typed = ref(0) let typing: number | null = null -/** Roughly the pace of somebody writing, which is what stops it reading as a wipe. */ -const CHARACTERS_A_SECOND = 120 +/** A character every other frame on a 60Hz screen: the pace a handheld RPG writes a line at. */ +const CHARACTERS_A_SECOND = 30 const stopTyping = (): void => { if (typing !== null) cancelAnimationFrame(typing) @@ -130,11 +134,43 @@ watch(nearest, index => { typing = requestAnimationFrame(write) }) -/** What is on the page for this milestone: all of it once read, nothing before. */ -const tellingSoFar = (index: number): string => { +/** The cursor sits at the end of the writing, and goes when there is nothing left to write. */ +const writing = computed(() => { + if (motion.reduced.value || nearest.value < 0) return false + return typed.value < (props.milestones[nearest.value]?.telling.length ?? 0) +}) + +/** + * The telling in three pieces: what is written, the character just written, and what is not yet. + * + * The part not yet written is on the page the whole time, in no colour. It has to be: the + * milestones left of the line are set right-aligned, and text added to while it is right-aligned + * grows away from its own end — the line crawls leftwards and reads as though it were being + * written backwards. With the whole telling holding its place from the start, the cursor travels + * across ground the words already occupy and leaves them behind it, which is the thing being + * imitated. + */ +interface Written { + written: string + landing: string + waiting: string +} + +const tellingSoFar = (index: number): Written => { const telling = props.milestones[index]?.telling ?? "" - if (motion.reduced.value) return telling - return index === nearest.value ? telling.slice(0, typed.value) : "" + if (motion.reduced.value) return {written: ` ${telling}`, landing: "", waiting: ""} + // Every milestone holds the room its telling will need, read or not, so arriving at one never + // pushes the ones below it down the page — and never moves the page out from under the reader. + if (index !== nearest.value) return {written: " ", landing: "", waiting: telling} + + const at = typed.value + // A space is never given a box of its own: an inline-block space is not a place a line breaks. + const lands = at > 0 && telling[at - 1] !== " " + return { + written: ` ${telling.slice(0, lands ? at - 1 : at)}`, + landing: lands ? (telling[at - 1] ?? "") : "", + waiting: telling.slice(at), + } } onBeforeUnmount(() => { @@ -181,8 +217,16 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value -

      - {{ milestone.summary }}{{ " " + tellingSoFar(index) }} +

      + {{ milestone.summary }}{{ tellingSoFar(index).written }}{{ tellingSoFar(index).landing }}

    2. @@ -223,14 +267,14 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value /* * A little more height than the words need, and no more. * - * A stop no taller than its text is crossed in one flick of the wheel and its telling opens and - * shuts before anybody could read it; a stop as tall as the screen turns the history into a - * chore. This is the smallest height that makes scrolling arrive at them one at a time. + * The telling holds its own room whether or not it has been written yet, so the height comes + * mostly from the words. The floor under it is for the short ones: a stop crossed in one flick + * of the wheel opens and shuts before anybody could read it. */ .history__stop { position: relative; width: calc(50% - 2.75rem); - min-height: 17vh; + min-height: 12vh; display: flex; align-items: center; } @@ -305,11 +349,18 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value color: var(--color-chalk); } -/* Always legible, and never as loud as the telling it stands in for. */ +/* + * Always legible, and never as loud as the telling it stands in for. + * + * Set in the bitmap face at 16px, not in rem: Silkscreen is drawn on an 8px grid, and a size + * off that grid puts its stems between pixels. The line height is loose because a bitmap face + * set tight is a wall. + */ .history__summary { margin-top: 0.5rem; - font-size: 0.9rem; - line-height: 1.5; + font-family: var(--font-bitmap); + font-size: 16px; + line-height: 1.65; color: var(--color-ash); } @@ -318,6 +369,62 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value color: inherit; } +/* What has been written, and the only part of the telling anybody can read. */ +.history__written { + color: inherit; +} + +/* Holding its place and saying nothing: the reason a right-aligned telling does not crawl. */ +.history__waiting { + color: transparent; +} + +/* + * Each character drops the last pixel or two into place as the cursor leaves it behind. + * + * In two steps rather than smoothly. A bitmap face and a smooth ease come off different + * machines; stepping it keeps the whole band on one of them. + */ +.history__landing { + display: inline-block; + animation: history-landing 140ms steps(2, end); +} + +@keyframes history-landing { + from { + transform: translateY(-0.18em); + opacity: 0.35; + } + + to { + transform: none; + opacity: 1; + } +} + +/* Drawn rather than typed: no bitmap face is guaranteed to carry a block character. */ +.history__cursor { + display: inline-block; + width: 0.5em; + height: 0.9em; + margin-left: 0.15em; + vertical-align: -0.1em; + background: currentColor; + animation: history-cursor 640ms steps(1, end) infinite; +} + +@keyframes history-cursor { + 0%, + 50% { + opacity: 1; + } + + 50.01%, + 100% { + opacity: 0; + } +} + /* * Every milestone open, nothing growing, for a reader who asked for less motion — and for one * whose browser cannot watch the page at all. Nothing is held tall either: with everything @@ -343,6 +450,14 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value .history__year { transition: none; } + + .history__cursor { + display: none; + } + + .history__landing { + animation: none; + } } /* @@ -365,7 +480,7 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value margin: 0; padding-left: 2.25rem; text-align: left; - min-height: 20vh; + min-height: 14vh; } .history__stop:nth-child(odd) .history__dot, diff --git a/services/frontend/src/styles/fonts.scss b/services/frontend/src/styles/fonts.scss index c7fc74c0e..dc40661b7 100644 --- a/services/frontend/src/styles/fonts.scss +++ b/services/frontend/src/styles/fonts.scss @@ -34,4 +34,21 @@ font-family: 'Blueshell'; src: local("Blueshell"), url('@/assets/fonts/Blueshell.woff2') format('woff2'), url('@/assets/fonts/Blueshell.ttf') format('truetype'); font-display: swap; -} \ No newline at end of file +} + +// The bitmap face, for the history's tellings, which are written out a character at a time the +// way an old console writes dialogue. Silkscreen is drawn on an 8px grid, so it is only ever set +// at whole multiples of it. Licence beside it: Silkscreen-OFL.txt. +@font-face { + font-family: 'Silkscreen'; + src: url('@/assets/fonts/Silkscreen-Regular.ttf') format('truetype'); + font-weight: 400; + font-display: swap; +} + +@font-face { + font-family: 'Silkscreen'; + src: url('@/assets/fonts/Silkscreen-Bold.ttf') format('truetype'); + font-weight: 700; + font-display: swap; +} diff --git a/services/frontend/src/styles/island.css b/services/frontend/src/styles/island.css index b5d39eaf2..a002d1dda 100644 --- a/services/frontend/src/styles/island.css +++ b/services/frontend/src/styles/island.css @@ -103,6 +103,8 @@ --font-display: "Shellhouse One", system-ui, sans-serif; --font-body: "Barlow Semi Condensed", system-ui, sans-serif; + /* Set at whole multiples of 8px, which is the grid the face is drawn on. */ + --font-bitmap: "Silkscreen", ui-monospace, monospace; /* A person's or a board's name, and never the display face. It was a correctness constraint: the display face had no İ, ı, ş or Ş, so `İlayda Hotamiş` lost two letters to diff --git a/services/frontend/tests/unit/domains/association/HistoryBand.test.ts b/services/frontend/tests/unit/domains/association/HistoryBand.test.ts index 4e2b8f4bd..ddf2c1ff5 100644 --- a/services/frontend/tests/unit/domains/association/HistoryBand.test.ts +++ b/services/frontend/tests/unit/domains/association/HistoryBand.test.ts @@ -39,6 +39,26 @@ const scrollTo = async (wrapper: VueWrapper, top: number): Promise => { /** Where the column has to start for the given stop's middle to sit at the middle of the screen. */ const middleOf = (index: number): number => SCREEN / 2 - index * STOP - STOP / 2 +/** Waits out the writing: the longest telling here at the pace it is written at, and then some. */ +const written = async (wrapper: VueWrapper): Promise => { + await new Promise(resolve => setTimeout(resolve, 1200)) + await wrapper.vm.$nextTick() +} + +/** + * What a reader can actually read of a milestone's telling. + * + * Not `text()`: the part not yet written is on the page the whole time, in no colour, holding + * the room the words will need. Reading the element's text would count that as written. + */ +type Part = {exists: () => boolean; text: () => string} + +const shown = (stop: {find: (selector: string) => Part}): string => { + const text = (selector: string): string => (stop.find(selector).exists() ? stop.find(selector).text() : "") + // The character just written has a box of its own, so that it can land in it. + return `${text(".history__written")}${text(".history__landing")}` +} + const readStop = (wrapper: VueWrapper): number => wrapper.findAll('[data-testid="history-stop"]').findIndex(stop => stop.classes().includes("history__stop--read")) @@ -113,15 +133,39 @@ describe("HistoryBand", () => { /** The telling carries on from the summary, and only for the milestone being read. */ it("writes the telling out once a milestone reaches the middle", async () => { const wrapper = mountBand() - expect(wrapper.text()).not.toContain(MILESTONES[1].telling) + expect(shown(wrapper.findAll('[data-testid="history-stop"]')[1])).not.toContain(MILESTONES[1].telling) await scrollTo(wrapper, middleOf(1)) - await new Promise(resolve => setTimeout(resolve, 400)) - await wrapper.vm.$nextTick() + await written(wrapper) const stops = wrapper.findAll('[data-testid="history-stop"]') - expect(stops[1].text()).toContain(MILESTONES[1].telling) - expect(stops[0].text()).not.toContain(MILESTONES[0].telling) + expect(shown(stops[1])).toContain(MILESTONES[1].telling) + expect(shown(stops[0])).not.toContain(MILESTONES[0].telling) + }) + + /** + * Every milestone holds the room its telling needs from the start. + * + * Otherwise arriving at one grows it, everything below it moves down the page, and the page + * shifts out from under the reader who was scrolling towards it. + */ + it("keeps the room for every telling whether or not it has been written", () => { + const wrapper = mountBand() + + const waiting = wrapper.findAll(".history__waiting").map(part => part.text()) + expect(waiting).toEqual(MILESTONES.map(milestone => milestone.telling)) + }) + + /** The console's blinking block, waiting at the end of the writing and gone once it stops. */ + it("keeps a cursor at the end of the writing until the telling is finished", async () => { + const wrapper = mountBand() + + await scrollTo(wrapper, middleOf(1)) + expect(wrapper.findAll(".history__cursor")).toHaveLength(1) + + await written(wrapper) + + expect(wrapper.findAll(".history__cursor")).toHaveLength(0) }) it("stops reading once the whole history is behind the reader", async () => { diff --git a/services/frontend/tests/unit/styles/bitmapGlyphCoverage.test.ts b/services/frontend/tests/unit/styles/bitmapGlyphCoverage.test.ts new file mode 100644 index 000000000..2af1cd180 --- /dev/null +++ b/services/frontend/tests/unit/styles/bitmapGlyphCoverage.test.ts @@ -0,0 +1,29 @@ +/* + * That the bitmap face carries every letter the history is written in. + * + * Silkscreen is a small face: 226 codepoints, where Barlow has thousands. The history's copy is + * fixed text in the repository, so what it needs can be checked exactly rather than guessed at + * — and a milestone written with an em dash or an accent the face lacks would go to the page as + * tofu, mid-sentence, while it is being written out a character at a time. + */ +import {describe, expect, it} from "vitest" +import {coverageOf} from "./glyphCoverage" +import {MILESTONES} from "@/domains/association/historyAxis" + +const BITMAP_FACES = ["Silkscreen-Regular.ttf", "Silkscreen-Bold.ttf"] + +/** Every character the band actually sets in the bitmap face: the summaries and the tellings. */ +const written = (): string[] => { + const copy = MILESTONES.map(milestone => `${milestone.summary} ${milestone.telling}`).join("") + return [...new Set(copy)].filter(character => character !== " ") +} + +describe("the face the history is written in", () => { + it("has every letter the milestones are written with", () => { + for (const face of BITMAP_FACES) { + const covered = coverageOf(face) + const missing = written().filter(character => !covered(character.codePointAt(0) ?? 0)) + expect(missing, `${face} would draw tofu in the history`).toEqual([]) + } + }) +}) diff --git a/services/frontend/tests/unit/styles/glyphCoverage.ts b/services/frontend/tests/unit/styles/glyphCoverage.ts new file mode 100644 index 000000000..408fe27dd --- /dev/null +++ b/services/frontend/tests/unit/styles/glyphCoverage.ts @@ -0,0 +1,97 @@ +/* + * Whether a font file actually carries a given letter, read from the file rather than assumed. + * + * No font library: the format is a table directory of 16-byte records, and a cmap subtable in + * format 4 (BMP) or 12 (full range) is enough to answer "is this codepoint in this file". + * Shared by the tests that hold a face to the letters the site sets in it. + */ +import {readFileSync} from "node:fs" +import {fileURLToPath} from "node:url" + +// Held in a variable, not written inline: Vite rewrites a literal `new URL("…", import.meta.url)` +// into an asset reference, and the font then resolves against the served root instead of the disk. +const FONT_DIR = "../../../src/assets/fonts/" + +export function read(file: string): DataView { + const bytes = readFileSync(fileURLToPath(new URL(FONT_DIR + file, import.meta.url))) + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) +} + +function tableOffset(font: DataView, tag: string): number { + for (let i = 0; i < font.getUint16(4); i++) { + const record = 12 + i * 16 + const name = Array.from({length: 4}, (_, byte) => String.fromCharCode(font.getUint8(record + byte))).join("") + if (name === tag) return font.getUint32(record + 8) + } + throw new Error(`no ${tag} table`) +} + +/** Segmented mapping, 16-bit. Codepoints above the BMP are out of its reach by definition. */ +function glyphFromFormat4(font: DataView, sub: number, codepoint: number): number { + if (codepoint > 0xffff) return 0 + const segments = font.getUint16(sub + 6) / 2 + const ends = sub + 14 + const starts = ends + segments * 2 + 2 + const deltas = starts + segments * 2 + const rangeOffsets = deltas + segments * 2 + + for (let i = 0; i < segments; i++) { + if (font.getUint16(ends + i * 2) < codepoint) continue + const start = font.getUint16(starts + i * 2) + if (start > codepoint) return 0 + + const delta = font.getInt16(deltas + i * 2) + const rangeOffset = font.getUint16(rangeOffsets + i * 2) + if (rangeOffset === 0) return (codepoint + delta) & 0xffff + + // The offset is measured from its own slot in the array, not from the subtable. + const glyph = font.getUint16(rangeOffsets + i * 2 + rangeOffset + (codepoint - start) * 2) + return glyph === 0 ? 0 : (glyph + delta) & 0xffff + } + return 0 +} + +/** Segmented coverage, 32-bit, groups ascending. */ +function glyphFromFormat12(font: DataView, sub: number, codepoint: number): number { + const groups = font.getUint32(sub + 12) + for (let i = 0; i < groups; i++) { + const group = sub + 16 + i * 12 + const start = font.getUint32(group) + if (codepoint < start) return 0 + if (codepoint > font.getUint32(group + 4)) continue + return font.getUint32(group + 8) + (codepoint - start) + } + return 0 +} + +/** Every format 4 and 12 subtable in the file, so a missing letter is missing from all of them. */ +function subtables(font: DataView, cmap: number): number[] { + const found: number[] = [] + for (let i = 0; i < font.getUint16(cmap + 2); i++) { + const sub = cmap + font.getUint32(cmap + 4 + i * 8 + 4) + const format = font.getUint16(sub) + if (format === 4 || format === 12) found.push(sub) + } + return found +} + +export function coverageOf(file: string): (codepoint: number) => boolean { + const font = read(file) + const cmap = tableOffset(font, "cmap") + const found = subtables(font, cmap) + // Otherwise "no glyphs" would read as a missing letter rather than as a parser that + // understood none of the file, and the whole test would pass by saying nothing. + if (found.length === 0) throw new Error(`${file} has no format 4 or 12 cmap subtable`) + + return (codepoint) => found.some((sub) => { + const glyph = font.getUint16(sub) === 4 + ? glyphFromFormat4(font, sub, codepoint) + : glyphFromFormat12(font, sub, codepoint) + return glyph !== 0 + }) +} + +export function missingFrom(file: string, letters: Record): string[] { + const covered = coverageOf(file) + return Object.entries(letters).filter(([, codepoint]) => !covered(codepoint)).map(([letter]) => letter) +} diff --git a/services/frontend/tests/unit/styles/nameGlyphCoverage.test.ts b/services/frontend/tests/unit/styles/nameGlyphCoverage.test.ts index af9983dda..93932d1b2 100644 --- a/services/frontend/tests/unit/styles/nameGlyphCoverage.test.ts +++ b/services/frontend/tests/unit/styles/nameGlyphCoverage.test.ts @@ -10,13 +10,9 @@ * * Both halves matter. If either face is ever replaced by something narrower, this fails and * says which file and which letters, rather than a name breaking on the page. - * - * No font library: the format is a table directory of 16-byte records, and a cmap subtable in - * format 4 (BMP) or 12 (full range) is enough to answer "is this codepoint in this file". */ -import {readFileSync} from "node:fs" -import {fileURLToPath} from "node:url" import {describe, expect, it} from "vitest" +import {missingFrom} from "./glyphCoverage" const DISPLAY_FACE = "ShellhouseOne-Regular.ttf" const BARLOW_FACES = [ @@ -36,94 +32,6 @@ const TURKISH = { } const EUROPEAN = {"ë": 0x00eb, "é": 0x00e9} -// Held in a variable, not written inline: Vite rewrites a literal `new URL("…", import.meta.url)` -// into an asset reference, and the font then resolves against the served root instead of the disk. -const FONT_DIR = "../../../src/assets/fonts/" - -function read(file: string): DataView { - const bytes = readFileSync(fileURLToPath(new URL(FONT_DIR + file, import.meta.url))) - return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) -} - -function tableOffset(font: DataView, tag: string): number { - for (let i = 0; i < font.getUint16(4); i++) { - const record = 12 + i * 16 - const name = Array.from({length: 4}, (_, byte) => String.fromCharCode(font.getUint8(record + byte))).join("") - if (name === tag) return font.getUint32(record + 8) - } - throw new Error(`no ${tag} table`) -} - -/** Segmented mapping, 16-bit. Codepoints above the BMP are out of its reach by definition. */ -function glyphFromFormat4(font: DataView, sub: number, codepoint: number): number { - if (codepoint > 0xffff) return 0 - const segments = font.getUint16(sub + 6) / 2 - const ends = sub + 14 - const starts = ends + segments * 2 + 2 - const deltas = starts + segments * 2 - const rangeOffsets = deltas + segments * 2 - - for (let i = 0; i < segments; i++) { - if (font.getUint16(ends + i * 2) < codepoint) continue - const start = font.getUint16(starts + i * 2) - if (start > codepoint) return 0 - - const delta = font.getInt16(deltas + i * 2) - const rangeOffset = font.getUint16(rangeOffsets + i * 2) - if (rangeOffset === 0) return (codepoint + delta) & 0xffff - - // The offset is measured from its own slot in the array, not from the subtable. - const glyph = font.getUint16(rangeOffsets + i * 2 + rangeOffset + (codepoint - start) * 2) - return glyph === 0 ? 0 : (glyph + delta) & 0xffff - } - return 0 -} - -/** Segmented coverage, 32-bit, groups ascending. */ -function glyphFromFormat12(font: DataView, sub: number, codepoint: number): number { - const groups = font.getUint32(sub + 12) - for (let i = 0; i < groups; i++) { - const group = sub + 16 + i * 12 - const start = font.getUint32(group) - if (codepoint < start) return 0 - if (codepoint > font.getUint32(group + 4)) continue - return font.getUint32(group + 8) + (codepoint - start) - } - return 0 -} - -/** Every format 4 and 12 subtable in the file, so a missing letter is missing from all of them. */ -function subtables(font: DataView, cmap: number): number[] { - const found: number[] = [] - for (let i = 0; i < font.getUint16(cmap + 2); i++) { - const sub = cmap + font.getUint32(cmap + 4 + i * 8 + 4) - const format = font.getUint16(sub) - if (format === 4 || format === 12) found.push(sub) - } - return found -} - -function coverageOf(file: string): (codepoint: number) => boolean { - const font = read(file) - const cmap = tableOffset(font, "cmap") - const found = subtables(font, cmap) - // Otherwise "no glyphs" would read as a missing letter rather than as a parser that - // understood none of the file, and the whole test would pass by saying nothing. - if (found.length === 0) throw new Error(`${file} has no format 4 or 12 cmap subtable`) - - return (codepoint) => found.some((sub) => { - const glyph = font.getUint16(sub) === 4 - ? glyphFromFormat4(font, sub, codepoint) - : glyphFromFormat12(font, sub, codepoint) - return glyph !== 0 - }) -} - -function missingFrom(file: string, letters: Record): string[] { - const covered = coverageOf(file) - return Object.entries(letters).filter(([, codepoint]) => !covered(codepoint)).map(([letter]) => letter) -} - describe("the fonts a name can be set in", () => { it("has every Turkish letter in the display face, which is why it is the Turkish cut", () => { expect( From e8b0ae57d89cff9c23705405122f510b350780c6 Mon Sep 17 00:00:00 2001 From: Joris Wouter Jonkers <74975850+ExtraToast@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:27:46 +0200 Subject: [PATCH 3/3] fix(island): the line the history writes on never re-wraps Two things on the line had boxes of their own, and a box is a place a line may break. The cursor was a zero-width inline-block and the character under it an inline-block of its own, so the paragraph re-wrapped as the cursor travelled along it: the words jumped up a line and dropped back, over and over. The cursor is painted out of the flow from an empty inline box, and the character being written is brought up on opacity alone. Nothing on the line has a box any more, so the wrap is the wrap of the finished paragraph from the first character to the last. The whole line is written now, summary and telling together, and a milestone nobody has reached shows none of it. A summary drawn beforehand had to be taken off the page for the writing to start, and text that vanishes to come back is worse than text that was never there. The room is still held from the start, so nothing below moves. --- .../association/island/HistoryBand.vue | 101 ++++++++++-------- .../domains/association/HistoryBand.test.ts | 19 ++-- 2 files changed, 71 insertions(+), 49 deletions(-) diff --git a/services/frontend/src/domains/association/island/HistoryBand.vue b/services/frontend/src/domains/association/island/HistoryBand.vue index c8ab53c18..2efbbc71e 100644 --- a/services/frontend/src/domains/association/island/HistoryBand.vue +++ b/services/frontend/src/domains/association/island/HistoryBand.vue @@ -101,8 +101,8 @@ onMounted(() => { const typed = ref(0) let typing: number | null = null -/** A character every other frame on a 60Hz screen: the pace a handheld RPG writes a line at. */ -const CHARACTERS_A_SECOND = 30 +/** Around a character and a half a frame on a 60Hz screen: the pace a handheld RPG writes at. */ +const CHARACTERS_A_SECOND = 40 const stopTyping = (): void => { if (typing !== null) cancelAnimationFrame(typing) @@ -116,9 +116,9 @@ watch(nearest, index => { return } - const telling = props.milestones[index]?.telling ?? "" + const line = lineOf(index) if (motion.reduced.value || typeof requestAnimationFrame !== "function") { - typed.value = telling.length + typed.value = line.length return } @@ -127,8 +127,8 @@ watch(nearest, index => { // that clock, and one that is not sends the elapsed time negative. const write = (): void => { const written = Math.round(((performance.now() - started) / 1000) * CHARACTERS_A_SECOND) - typed.value = Math.min(written, telling.length) - typing = written < telling.length ? requestAnimationFrame(write) : null + typed.value = Math.min(written, line.length) + typing = written < line.length ? requestAnimationFrame(write) : null } typed.value = 0 typing = requestAnimationFrame(write) @@ -137,16 +137,20 @@ watch(nearest, index => { /** The cursor sits at the end of the writing, and goes when there is nothing left to write. */ const writing = computed(() => { if (motion.reduced.value || nearest.value < 0) return false - return typed.value < (props.milestones[nearest.value]?.telling.length ?? 0) + return typed.value < lineOf(nearest.value).length }) /** - * The telling in three pieces: what is written, the character just written, and what is not yet. + * The milestone's line in three pieces: what is written, the character being written, the rest. + * + * The whole line is written out, summary and telling together, and a milestone that is not + * being read shows none of it. Arriving at one is a dialogue box opening: it starts empty and + * fills, and nothing had to be taken off the page first. * * The part not yet written is on the page the whole time, in no colour. It has to be: the * milestones left of the line are set right-aligned, and text added to while it is right-aligned * grows away from its own end — the line crawls leftwards and reads as though it were being - * written backwards. With the whole telling holding its place from the start, the cursor travels + * written backwards. With the whole line holding its place from the start, the cursor travels * across ground the words already occupy and leaves them behind it, which is the thing being * imitated. */ @@ -156,20 +160,28 @@ interface Written { waiting: string } -const tellingSoFar = (index: number): Written => { - const telling = props.milestones[index]?.telling ?? "" - if (motion.reduced.value) return {written: ` ${telling}`, landing: "", waiting: ""} - // Every milestone holds the room its telling will need, read or not, so arriving at one never +const lineOf = (index: number): string => { + const milestone = props.milestones[index] + return milestone ? `${milestone.summary} ${milestone.telling}` : "" +} + +const lineSoFar = (index: number): Written => { + const line = lineOf(index) + if (motion.reduced.value) return {written: line, landing: "", waiting: ""} + // Every milestone holds the room its line will need, read or not, so arriving at one never // pushes the ones below it down the page — and never moves the page out from under the reader. - if (index !== nearest.value) return {written: " ", landing: "", waiting: telling} + // Nothing of it is on show until it is reached: a summary drawn beforehand would have to be + // taken off the page for the writing to start, and text that vanishes to come back is worse + // than text that was never there. + if (index !== nearest.value) return {written: "", landing: "", waiting: line} const at = typed.value - // A space is never given a box of its own: an inline-block space is not a place a line breaks. - const lands = at > 0 && telling[at - 1] !== " " + // A space never gets a mark of its own: there is nothing to see landing. + const lands = at > 0 && line[at - 1] !== " " return { - written: ` ${telling.slice(0, lands ? at - 1 : at)}`, - landing: lands ? (telling[at - 1] ?? "") : "", - waiting: telling.slice(at), + written: line.slice(0, lands ? at - 1 : at), + landing: lands ? (line[at - 1] ?? "") : "", + waiting: line.slice(at), } } @@ -215,18 +227,18 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value

      {{ milestone.title }}

      - +

      - {{ milestone.summary }}{{ tellingSoFar(index).written }}{{ lineSoFar(index).written }}{{ tellingSoFar(index).landing }}{{ lineSoFar(index).landing }} + />{{ lineSoFar(index).waiting }}

      @@ -364,11 +376,6 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value color: var(--color-ash); } -/* Part of the sentence it continues: same size, same colour, no space of its own. */ -.history__telling { - color: inherit; -} - /* What has been written, and the only part of the telling anybody can read. */ .history__written { color: inherit; @@ -380,35 +387,45 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value } /* - * Each character drops the last pixel or two into place as the cursor leaves it behind. + * The character under the cursor comes up in two steps rather than appearing outright. * - * In two steps rather than smoothly. A bitmap face and a smooth ease come off different - * machines; stepping it keeps the whole band on one of them. + * Opacity and nothing else. A box of its own — anything that would let it be moved — is a break + * opportunity in the middle of a word: the line re-wraps around the character being written, + * and the paragraph jumps a line and back as the cursor travels along it. */ .history__landing { - display: inline-block; animation: history-landing 140ms steps(2, end); } @keyframes history-landing { from { - transform: translateY(-0.18em); - opacity: 0.35; + opacity: 0.25; } to { - transform: none; opacity: 1; } } -/* Drawn rather than typed: no bitmap face is guaranteed to carry a block character. */ +/* + * Drawn rather than typed: no bitmap face is guaranteed to carry a block character. + * + * It is painted out of the flow, over the character it is standing on, from an inline box that + * is empty and stays inline. Anything with a box of its own — an inline-block, even a zero-width + * one — is a place the line may break: the words after it drop to the next line, and the + * paragraph jumps a line and back as the cursor travels along it. + */ .history__cursor { - display: inline-block; + position: relative; +} + +.history__cursor::before { + content: ""; + position: absolute; + top: 0.05em; + left: 0.08em; width: 0.5em; height: 0.9em; - margin-left: 0.15em; - vertical-align: -0.1em; background: currentColor; animation: history-cursor 640ms steps(1, end) infinite; } @@ -451,7 +468,7 @@ const isRead = (index: number): boolean => motion.reduced.value || nearest.value transition: none; } - .history__cursor { + .history__cursor::before { display: none; } diff --git a/services/frontend/tests/unit/domains/association/HistoryBand.test.ts b/services/frontend/tests/unit/domains/association/HistoryBand.test.ts index ddf2c1ff5..dc3777422 100644 --- a/services/frontend/tests/unit/domains/association/HistoryBand.test.ts +++ b/services/frontend/tests/unit/domains/association/HistoryBand.test.ts @@ -39,9 +39,9 @@ const scrollTo = async (wrapper: VueWrapper, top: number): Promise => { /** Where the column has to start for the given stop's middle to sit at the middle of the screen. */ const middleOf = (index: number): number => SCREEN / 2 - index * STOP - STOP / 2 -/** Waits out the writing: the longest telling here at the pace it is written at, and then some. */ +/** Waits out the writing: the longest line here at the pace it is written at, and then some. */ const written = async (wrapper: VueWrapper): Promise => { - await new Promise(resolve => setTimeout(resolve, 1200)) + await new Promise(resolve => setTimeout(resolve, 2600)) await wrapper.vm.$nextTick() } @@ -83,12 +83,17 @@ describe("HistoryBand", () => { expect(stops[2].text()).toContain("Third") }) - /** A reader passing through gets the whole history, a line each, without stopping. */ - it("draws every summary whether or not its milestone is being read", () => { + /** + * A milestone shows none of its line until it is reached. + * + * Drawing the summary beforehand would mean taking it off the page again for the writing to + * start, and text that vanishes to come back is worse than text that was never there. + */ + it("writes nothing for a milestone nobody has reached", () => { const wrapper = mountBand() - for (const milestone of MILESTONES) { - expect(wrapper.text()).toContain(milestone.summary) + for (const stop of wrapper.findAll('[data-testid="history-stop"]')) { + expect(shown(stop)).toBe("") } expect(wrapper.findAll(".history__stop--read")).toHaveLength(0) }) @@ -153,7 +158,7 @@ describe("HistoryBand", () => { const wrapper = mountBand() const waiting = wrapper.findAll(".history__waiting").map(part => part.text()) - expect(waiting).toEqual(MILESTONES.map(milestone => milestone.telling)) + expect(waiting).toEqual(MILESTONES.map(milestone => `${milestone.summary} ${milestone.telling}`)) }) /** The console's blinking block, waiting at the end of the writing and gone once it stops. */