From 71a79e30ae0cca42fc96c2e5c75b2d11b1c2c072 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Wed, 11 Nov 2026 23:47:53 +0100 Subject: [PATCH 1/3] fix(rq-cm21): A stepped dot kept apart from its neighbour never slides into the words keepMarksApart checked only a slid dot's middle against the cards and spoken lines, so a dot could land half under a line. A spot a mark slides to now keeps the whole dot off the words. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKeye47AT4ZyGRvKH933 --- web/src/lantern/areaNames.test.ts | 23 +++++++++++++++++++++++ web/src/lantern/areaNames.ts | 17 +++++++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/web/src/lantern/areaNames.test.ts b/web/src/lantern/areaNames.test.ts index db6c8fbcd..c78f165f4 100644 --- a/web/src/lantern/areaNames.test.ts +++ b/web/src/lantern/areaNames.test.ts @@ -899,6 +899,29 @@ describe('keepMarksApart', () => { expect(b.x >= 110 && b.x <= 310 && b.y >= 100 && b.y <= 180).toBe(false) }) + it('never slides a stepped mark so its dot lies half under the words beside it', () => { + // Books, kept apart from Shelves, slides right along its row: its middle + // lands a hair left of the spoken line, its right half under it. + const words = { x: 180, y: 100, w: 150, h: 40 } + const out = keepMarksApart( + [ + mark('shelves', 150, 110, { x: 150, y: 60 }), + mark('books', 155, 110, { x: 156, y: 60 }), + ], + [], + visible, + [words], + ) + const books = out[1] + const nearestX = Math.max(words.x, Math.min(books.x, words.x + words.w)) + const nearestY = Math.max(words.y, Math.min(books.y, words.y + words.h)) + // The 12 px dot reaches 6 px from its middle. + expect( + Math.hypot(books.x - nearestX, books.y - nearestY), + ).toBeGreaterThanOrEqual(6) + expect(Math.hypot(books.x - 150, books.y - 110)).toBeGreaterThanOrEqual(24) + }) + it('keeps marks moved in off the foot apart too', () => { const [a, b] = keepMarksApart( [ diff --git a/web/src/lantern/areaNames.ts b/web/src/lantern/areaNames.ts index 6075c57d5..74bc8e7f9 100644 --- a/web/src/lantern/areaNames.ts +++ b/web/src/lantern/areaNames.ts @@ -361,7 +361,9 @@ const MARK_APART = 24 * whole line is full onto a row one `MARK_APART` further from where it came * from (below the line, for a mark that stands on its thing). A spot must be on * the `visible` part, under none of the `panels` and not under the `words` - * (which would cover it); a mark with no such spot keeps where it is. A mark + * (which would cover it); a spot the mark slides to keeps its whole dot + * (`MARK_RADIUS` from its middle) off the words, so no part of it is covered + * and a press anywhere on it reaches it. A mark with no such spot keeps where it is. A mark * stepped out vertically gives way level with the nearest mark it gives way * to when it can, first to the side its thing lies on (left when its thing * lies left of that mark's thing, else right). A mark standing on the edge of @@ -390,6 +392,15 @@ export function keepMarksApart( const result = [...marks] const underWords = (x: number, y: number): boolean => words.some((r) => inWords(x, y, r)) + /** Whether a dot with its middle at (`x`, `y`) would lie even partly under the words. */ + const touchesWords = (x: number, y: number): boolean => + words.some( + (r) => + x > r.x - MARK_RADIUS && + x < r.x + r.w + MARK_RADIUS && + y > r.y - MARK_RADIUS && + y < r.y + r.h + MARK_RADIUS, + ) // A mark standing on its thing, clear of every other such mark, holds its // own spot: no mark that slides lands on it, so it never has to move. const holding = marks.filter( @@ -415,7 +426,9 @@ export function keepMarksApart( (self.onWords ? Math.abs(y - self.y) < 0.5 && words.some((r) => x >= r.x && x <= r.x + r.w) - : !underWords(x, y)) && + : Math.hypot(x - self.x, y - self.y) < 0.5 + ? !underWords(x, y) + : !touchesWords(x, y)) && placedAt.every(([px, py]) => Math.hypot(px - x, py - y) >= MARK_APART - 0.5) for (const { entry, index } of order) { // A mark standing on its thing under the words (none, once `clearOfWords` has run) is covered with them: it From 240a884d51b63c15be8aa65c7444f960ebec3a01 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Thu, 12 Nov 2026 00:04:45 +0100 Subject: [PATCH 2/3] fix(rq-cm21): The words end a dot's slide; it gives way a row further out Navigator's answer to review finding 1: rather than jump across the spoken line, a slid mark whose slide reaches the words moves on to the next row, beside its neighbour. The test now asserts the full 8px. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKeye47AT4ZyGRvKH933 --- web/src/lantern/areaNames.test.ts | 22 +++++++++++++++++++--- web/src/lantern/areaNames.ts | 12 ++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/lantern/areaNames.test.ts b/web/src/lantern/areaNames.test.ts index c78f165f4..5bf67391e 100644 --- a/web/src/lantern/areaNames.test.ts +++ b/web/src/lantern/areaNames.test.ts @@ -901,7 +901,7 @@ describe('keepMarksApart', () => { it('never slides a stepped mark so its dot lies half under the words beside it', () => { // Books, kept apart from Shelves, slides right along its row: its middle - // lands a hair left of the spoken line, its right half under it. + // would land a hair left of the spoken line, its right half under it. const words = { x: 180, y: 100, w: 150, h: 40 } const out = keepMarksApart( [ @@ -915,13 +915,29 @@ describe('keepMarksApart', () => { const books = out[1] const nearestX = Math.max(words.x, Math.min(books.x, words.x + words.w)) const nearestY = Math.max(words.y, Math.min(books.y, words.y + words.h)) - // The 12 px dot reaches 6 px from its middle. + // The whole dot, its glow included, keeps off the words. expect( Math.hypot(books.x - nearestX, books.y - nearestY), - ).toBeGreaterThanOrEqual(6) + ).toBeGreaterThanOrEqual(8) expect(Math.hypot(books.x - 150, books.y - 110)).toBeGreaterThanOrEqual(24) }) + it('gives way one row further out, beside its neighbour, when the words end its slide', () => { + const words = { x: 180, y: 100, w: 150, h: 40 } + const [shelves, books] = keepMarksApart( + [ + mark('shelves', 150, 110, { x: 150, y: 60 }), + mark('books', 155, 110, { x: 156, y: 60 }), + ], + [], + visible, + [words], + ) + // Not across the line, and not to the other side of Shelves. + expect(books).toMatchObject({ x: 155, y: 134 }) + expect(books.x).toBeGreaterThanOrEqual(shelves.x) + }) + it('keeps marks moved in off the foot apart too', () => { const [a, b] = keepMarksApart( [ diff --git a/web/src/lantern/areaNames.ts b/web/src/lantern/areaNames.ts index 74bc8e7f9..211c55289 100644 --- a/web/src/lantern/areaNames.ts +++ b/web/src/lantern/areaNames.ts @@ -362,10 +362,11 @@ const MARK_APART = 24 * from (below the line, for a mark that stands on its thing). A spot must be on * the `visible` part, under none of the `panels` and not under the `words` * (which would cover it); a spot the mark slides to keeps its whole dot - * (`MARK_RADIUS` from its middle) off the words, so no part of it is covered - * and a press anywhere on it reaches it. A mark with no such spot keeps where it is. A mark - * stepped out vertically gives way level with the nearest mark it gives way - * to when it can, first to the side its thing lies on (left when its thing + * (`MARK_RADIUS` from its middle) off the words, so a press anywhere on it + * reaches it. The words end a slide: a mark whose slide reaches them goes + * on to the next row rather than across them or to the other side. A mark + * with no such spot keeps where it is. A mark stepped out vertically gives + * way level with the nearest mark it gives way to when it can, first to the side its thing lies on (left when its thing * lies left of that mark's thing, else right). A mark standing on the edge of * the words (`onWords`) slides only along that edge, in the words' own band. */ @@ -470,6 +471,9 @@ export function keepMarksApart( // At each step the level spot first, then the mark's own line. for (const lift of row === 0 && level !== 0 ? [level, 0] : [0]) { const cy = by + lift + along[1] * k * MARK_APART + // The words end a slide: the mark gives way a row further out + // instead of across them or to the other side of its neighbour. + if (k !== 0 && !entry.onWords && touchesWords(cx, cy)) continue search if (free(cx, cy, entry)) { spot = [cx, cy] break search From 6cd98478395f8fd2433be26e852f04611f1c0298 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Thu, 12 Nov 2026 00:10:15 +0100 Subject: [PATCH 3/3] fix(rq-cm21): With no row further out, a dot the words stop gives way on the other side Delta review: the words ending a slide left two dots stacked when no row lay further out. The other side is now tried in a second pass, never across the words; a level spot touching the words no longer ends the row. The row test now matches the cottage (stepped up, row above). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKeye47AT4ZyGRvKH933 --- web/src/lantern/areaNames.test.ts | 29 +++++++++++++--- web/src/lantern/areaNames.ts | 57 +++++++++++++++++++------------ 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/web/src/lantern/areaNames.test.ts b/web/src/lantern/areaNames.test.ts index 5bf67391e..02d37e666 100644 --- a/web/src/lantern/areaNames.test.ts +++ b/web/src/lantern/areaNames.test.ts @@ -922,22 +922,43 @@ describe('keepMarksApart', () => { expect(Math.hypot(books.x - 150, books.y - 110)).toBeGreaterThanOrEqual(24) }) - it('gives way one row further out, beside its neighbour, when the words end its slide', () => { + it('gives way a row further up, beside its neighbour, when the words end its slide', () => { + // As in the cottage: both stepped up off their things below, the spoken + // line in their band to the right. const words = { x: 180, y: 100, w: 150, h: 40 } const [shelves, books] = keepMarksApart( [ - mark('shelves', 150, 110, { x: 150, y: 60 }), - mark('books', 155, 110, { x: 156, y: 60 }), + mark('shelves', 150, 110, { x: 150, y: 160 }), + mark('books', 155, 110, { x: 156, y: 160 }), ], [], visible, [words], ) // Not across the line, and not to the other side of Shelves. - expect(books).toMatchObject({ x: 155, y: 134 }) + expect(books).toMatchObject({ x: 155, y: 86 }) expect(books.x).toBeGreaterThanOrEqual(shelves.x) }) + it('gives way to the other side of its neighbour when the words end its slide and no row lies further out', () => { + const words = { x: 180, y: 560, w: 150, h: 140 } + const [shelves, books] = keepMarksApart( + [ + mark('shelves', 150, 690, { x: 150, y: 600 }), + mark('books', 155, 690, { x: 156, y: 600 }), + ], + [], + visible, + [words], + ) + expect( + Math.hypot(books.x - shelves.x, books.y - shelves.y), + ).toBeGreaterThanOrEqual(24) + // Never across the words. + expect(books.x).toBeLessThan(words.x) + expect(books.y).toBe(690) + }) + it('keeps marks moved in off the foot apart too', () => { const [a, b] = keepMarksApart( [ diff --git a/web/src/lantern/areaNames.ts b/web/src/lantern/areaNames.ts index 211c55289..491ec4060 100644 --- a/web/src/lantern/areaNames.ts +++ b/web/src/lantern/areaNames.ts @@ -363,11 +363,13 @@ const MARK_APART = 24 * the `visible` part, under none of the `panels` and not under the `words` * (which would cover it); a spot the mark slides to keeps its whole dot * (`MARK_RADIUS` from its middle) off the words, so a press anywhere on it - * reaches it. The words end a slide: a mark whose slide reaches them goes - * on to the next row rather than across them or to the other side. A mark - * with no such spot keeps where it is. A mark stepped out vertically gives - * way level with the nearest mark it gives way to when it can, first to the side its thing lies on (left when its thing - * lies left of that mark's thing, else right). A mark standing on the edge of + * reaches it. The words end a slide, which never goes across them: a mark + * whose slide reaches them goes on to the next row further out rather than + * to the other side, and tries the other side only when no row has a spot. + * A mark with no such spot keeps where it is. A mark stepped out vertically + * gives way level with the nearest mark it gives way to when it can, first + * to the side its thing lies on (left when its thing lies left of that + * mark's thing, else right). A mark standing on the edge of * the words (`onWords`) slides only along that edge, in the words' own band. */ export function keepMarksApart( @@ -461,22 +463,35 @@ export function keepMarksApart( const first = stepped && blocker && from.x < blocker[2] ? -1 : 1 const level = stepped && blocker ? blocker[1] - entry.y : 0 let spot: [number, number] | null = null - search: for (let row = 0; row <= reach; row++) { - const bx = entry.x + away[0] * row * MARK_APART - const by = entry.y + away[1] * row * MARK_APART - for (let n = 0; n <= reach * 2; n++) { - // 0, then onward along the line, then the other side of where it landed. - const k = (n <= reach ? n : -(n - reach)) * first - const cx = bx + along[0] * k * MARK_APART - // At each step the level spot first, then the mark's own line. - for (const lift of row === 0 && level !== 0 ? [level, 0] : [0]) { - const cy = by + lift + along[1] * k * MARK_APART - // The words end a slide: the mark gives way a row further out - // instead of across them or to the other side of its neighbour. - if (k !== 0 && !entry.onWords && touchesWords(cx, cy)) continue search - if (free(cx, cy, entry)) { - spot = [cx, cy] - break search + // The words end a slide: first the mark gives way a row further out + // rather than to the other side of its neighbour; only when no row has a + // spot does it try the other side as well. It never slides across them. + find: for (const otherSide of [false, true]) { + search: for (let row = 0; row <= reach; row++) { + const bx = entry.x + away[0] * row * MARK_APART + const by = entry.y + away[1] * row * MARK_APART + slide: for (let n = 0; n <= reach * 2; n++) { + // 0, then onward along the line, then the other side of where it landed. + const back = n > reach + const k = (back ? -(n - reach) : n) * first + const cx = bx + along[0] * k * MARK_APART + // At each step the level spot first, then the mark's own line. + for (const lift of row === 0 && level !== 0 ? [level, 0] : [0]) { + const cy = by + lift + along[1] * k * MARK_APART + if ( + k !== 0 && + lift === 0 && + !entry.onWords && + touchesWords(cx, cy) + ) { + if (back || !otherSide) continue search + n = reach + continue slide + } + if (free(cx, cy, entry)) { + spot = [cx, cy] + break find + } } } }