- commit
- 65f109cb6b522250840b2cf4df0b22af1509fb0b
- parent
- edcb9d800afc84117ca06be08e1a1fad0a5f9e74
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 43 | ++++++++++---------- |
| A | src/ | 66 | ++++++++++++++++++++++++++++++ |
| M | src/ | 29 | ++++++++++--- |
| M | tests/ | 24 | +++++------ |
| A | tests/ | 26 | ++++++++++++ |
| M | tests/ | 15 | +++++-- |
@@ -1,6 +1,7 @@
11 import { drawBoard } from '../render/board.js';22 import { createGame } from '../engine.js';33 import { cloneData } from '../data/clone.js';4+import { warningLines } from './warning-lines.js';45 56 const FONT = 'Recursive, ui-monospace, monospace';67 const COLORS = Object.freeze({@@ -59,6 +60,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
5960 let detailsOpen = false;6061 let buttons = [];6162 let hintButtons = [];63+ let noteButtons = [];6264 const primaryChoices = () => ['keep', 'later'];6365 const collisionChoices = () => ['replace', 'copy', 'later'];6466 const choices = () => mode === 'collision' ? collisionChoices() : primaryChoices();@@ -109,6 +111,8 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
109111 function hit(x, y) {110112 const button = buttons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);111113 if (button) { selected = button.index; return choose(button.action); }114+ const note = noteButtons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);115+ if (note) return note.action === 'copy' ? copyNotes() : { ok: true, action: 'details', open: toggleDetails() };112116 const hint = hintButtons.find(item => x >= item.x && x <= item.x + item.width && y >= item.y && y <= item.y + item.height);113117 if (!hint) return { ok: false, action: null };114118 return input(hint.action);@@ -118,16 +122,15 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
118122 const width = options.width || ctx.canvas?.width || 800;119123 const height = options.height || ctx.canvas?.height || 600;120124 const narrow = width < 760;125+ const coarsePointer = options.coarsePointer ?? globalThis.matchMedia?.('(pointer: coarse)').matches ?? false;126+ const touchLayout = narrow || coarsePointer;121127 const cardWidth = narrow ? Math.max(0, width - 24) : Math.min(760, width - 48);122128 const cardHeight = narrow ? Math.min(detailsOpen ? 760 : 620, height - 24) : Math.min(detailsOpen ? 680 : 560, height - 48);123129 const left = (width - cardWidth) / 2; const top = (height - cardHeight) / 2;124130 const title = packName(preview); const author = packAuthor(preview); const levels = levelList(preview); const selectedAction = choices()[selected];125- const warningLines = detailsOpen ? warnings.slice(0, 5).flatMap(warning => {126- const levelIndex = levels.findIndex(level => level.id === warning.levelId);127- const proof = levelIndex >= 0 ? `Proof ${levelIndex + 1}` : 'Proof';128- const titleText = warning.levelTitle || levels[levelIndex]?.title || warning.levelId || 'Untitled';129- return wrapText(ctx, `${proof} · ${titleText} · ${String(warning.word || '').toUpperCase()}: ${warning.message || warning.sentence || 'may not behave as a rule.'}`, cardWidth - 68, 9, 650);130- }) : [];131+ const noteLines = warningLines(preview, warnings);132+ const displayWarningLines = detailsOpen ? noteLines.slice(0, 5).flatMap(line => wrapText(ctx, line, cardWidth - 68, 9, 650)) : [];133+ noteButtons = [];131134 ctx.save(); ctx.fillStyle = 'rgba(0,0,0,.78)'; ctx.fillRect(0, 0, width, height);132135 ctx.fillStyle = COLORS.lip; rounded(ctx, left + 8, top + 8, cardWidth, cardHeight, 5); ctx.fill(); ctx.fillStyle = COLORS.board; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, left, top, cardWidth, cardHeight, 5); ctx.stroke();133136 text(ctx, mode === 'collision' ? 'ALREADY HERE' : 'A PACK FOR YOU', left + 32, top + 38, { size: 12, weight: 800, color: COLORS.accent });@@ -161,18 +164,15 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
161164 ctx.restore();162165 const noteLabel = fitLabel(ctx, noteSummary, cardWidth - 68 - noteWidth - 8, 10, 800);163166 text(ctx, noteLabel.value, noteX + noteWidth + 8, top + 132, { size: noteLabel.size, weight: 800, color: COLORS.accent });167+ noteButtons = [{ action: 'details', x: noteX - 6, y: noteY - 5, width: Math.min(cardWidth - 56, noteWidth + 8 + ctx.measureText(noteLabel.value).width + 8), height: 32 }];164168 if (detailsOpen) {165169 const detailTop = top + 157;166- const visible = warnings.slice(0, 5);167170 let lineIndex = 0;168- visible.forEach(warning => {169- const levelIndex = levels.findIndex(level => level.id === warning.levelId);170- const proof = levelIndex >= 0 ? `Proof ${levelIndex + 1}` : 'Proof';171- const titleText = warning.levelTitle || levels[levelIndex]?.title || warning.levelId || 'Untitled';172- const lines = wrapText(ctx, `${proof} · ${titleText} · ${String(warning.word || '').toUpperCase()}: ${warning.message || warning.sentence || 'may not behave as a rule.'}`, cardWidth - 68, 9, 650);173- lines.forEach(line => { text(ctx, line, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 650, color: COLORS.muted }); });174- });175- if (warnings.length > visible.length) text(ctx, `and ${warnings.length - visible.length} more`, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 700, color: COLORS.muted });171+ noteLines.slice(0, 5).forEach(line => wrapText(ctx, line, cardWidth - 68, 9, 650).forEach(wrapped => { text(ctx, wrapped, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 650, color: COLORS.muted }); }));172+ if (noteLines.length > 5) text(ctx, `and ${noteLines.length - 5} more`, left + 34, detailTop + lineIndex++ * 16, { size: 9, weight: 700, color: COLORS.muted });173+ const copyY = detailTop + lineIndex * 16 + 3;174+ text(ctx, 'COPY', left + 34, copyY, { size: 9, weight: 850, color: COLORS.accent });175+ noteButtons.push({ action: 'copy', x: left + 25, y: copyY - 12, width: 55, height: 26 });176176 }177177 }178178 }@@ -184,7 +184,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
184184 const buttonLeft = narrow ? left + 24 : left + cardWidth - buttonWidth - 32;185185 const first = levels[0];186186 const previewX = narrow ? left + 24 : left + 32;187- const previewY = top + (warnings.length ? (detailsOpen ? 175 + warningLines.length * 16 : 158) : 140);187+ const previewY = top + (warnings.length ? (detailsOpen ? 175 + displayWarningLines.length * 16 : 158) : 140);188188 const actionColumnLeft = narrow ? previewX : buttonLeft;189189 const previewWidth = narrow ? Math.max(0, cardWidth - 48) : Math.max(0, Math.min(400, actionColumnLeft - previewX - 32));190190 const previewHeight = narrow ? Math.max(0, Math.min(220, buttonTop - previewY - 20)) : Math.max(0, Math.min(cardHeight * 0.48, buttonTop - previewY - 20));@@ -208,20 +208,21 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
208208 buttons = [];209209 hintButtons = [];210210 labels.forEach(([action, label], index) => { const y = buttonTop + index * (buttonHeight + buttonGap); const active = selectedAction === action; buttons.push({ action, index, x: buttonLeft, y, width: buttonWidth, height: buttonHeight }); ctx.save(); ctx.fillStyle = COLORS.lip; rounded(ctx, buttonLeft + 4, y + 4, buttonWidth, buttonHeight, 4); ctx.fill(); ctx.fillStyle = active ? (action === 'replace' ? COLORS.danger : COLORS.accent) : COLORS.board; rounded(ctx, buttonLeft, y, buttonWidth, buttonHeight, 4); ctx.fill(); ctx.strokeStyle = active ? COLORS.fg : COLORS.edge; ctx.lineWidth = active ? 2 : 1; rounded(ctx, buttonLeft, y, buttonWidth, buttonHeight, 4); ctx.stroke(); text(ctx, label, buttonLeft + buttonWidth / 2, y + buttonHeight / 2, { size: 12, weight: 850, color: active ? COLORS.ink : COLORS.fg, align: 'center' }); ctx.restore(); });211- const hints = warnings.length ? [['↑ ↓', 'choose'], ['I', detailsOpen ? (narrow ? 'close' : 'close notes') : 'details'], ['C', narrow ? 'copy' : 'copy notes'], ['ENTER', 'select'], ['ESC', 'not now']] : [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];211+ const hints = warnings.length ? [['↑ ↓', 'choose'], ['I', detailsOpen ? 'close notes' : 'details'], ['C', 'copy notes'], ['ENTER', 'select'], ['ESC', 'not now']] : [['↑ ↓', 'choose'], ['ENTER', 'select'], ['ESC', 'not now']];212+ const visibleHints = touchLayout ? [] : hints;212213 const hintSize = narrow ? 8 : 10; const hintLabelSize = narrow ? 8 : 10; const hintGap = narrow ? 7 : 16;213- const hintMetrics = hints.map(([key, label]) => {214+ const hintMetrics = visibleHints.map(([key, label]) => {214215 ctx.font = `800 ${hintSize}px ${FONT}`;215216 const keyWidth = Math.max(29, ctx.measureText(key).width + 12);216217 const labelFit = fitLabel(ctx, label, narrow ? 44 : Infinity, hintLabelSize, 700);217218 const labelWidth = ctx.measureText(labelFit.value).width;218219 return { key, label: labelFit.value, labelSize: labelFit.size, keyWidth, labelWidth, width: keyWidth + 6 + labelWidth };219220 });220- const hintRows = narrow && warnings.length ? [hintMetrics.slice(0, 3), hintMetrics.slice(3)] : [hintMetrics];221+ const hintRows = hintMetrics.length ? [hintMetrics] : [];221222 const rowGap = 6;222223 const rowHeight = 20;223224 const rowWidths = hintRows.map(row => row.reduce((sum, item) => sum + item.width, 0) + hintGap * Math.max(0, row.length - 1));224- const hintTotal = Math.max(...rowWidths);225+ const hintTotal = rowWidths.length ? Math.max(...rowWidths) : 0;225226 const hintStart = left + Math.max(12, (cardWidth - hintTotal) / 2);226227 const hintBottom = top + cardHeight - 15;227228 hintRows.forEach((row, rowIndex) => {@@ -238,7 +239,7 @@ export function createGiftScene({ pack = null, collision = false, warnings: init
238239 ctx.save(); ctx.fillStyle = COLORS.board; rounded(ctx, hintX, hintY, keyWidth, rowHeight, 3); ctx.fill(); ctx.strokeStyle = COLORS.edge; ctx.lineWidth = 1; rounded(ctx, hintX, hintY, keyWidth, rowHeight, 3); ctx.stroke(); text(ctx, key, hintX + keyWidth / 2, hintY + rowHeight / 2, { size: hintSize - 1, weight: 850, color: COLORS.fg, align: 'center' }); text(ctx, label, hintX + keyWidth + 6, hintY + rowHeight / 2, { size: labelSize, weight: 700, color: COLORS.muted }); ctx.restore(); hintX += itemWidth + hintGap;239240 });240241 });241- return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };242+ return { x: left, y: top, width: cardWidth, height: cardHeight, narrow, touchLayout, preview: { x: previewX, y: previewY, width: previewWidth, height: previewHeight }, actions: buttons.map(button => ({ ...button })), hintActions: hintButtons.map(button => ({ ...button })), noteActions: noteButtons.map(button => ({ ...button })), hints: { x: hintStart, width: hintTotal }, hintY: hintBottom - rowHeight / 2, hintRows: hintRows.length, selected: selectedAction, now };242243 }243244 const api = { show, hide, setPack, choose, input, hit, draw, accessibilityText, get open() { return open; }, get mode() { return mode; }, get selected() { return choices()[selected]; }, get pack() { return preview ? cloneData(preview) : null; }, get collision() { return collides ? cloneData(collides) : null; }, get warnings() { return cloneData(warnings); }, get detailsOpen() { return detailsOpen; } };244245 return Object.freeze(api);@@ -0,0 +1,66 @@
1+function sentenceCase(value) {2+ const text = String(value || '').trim();3+ if (!text) return 'Unknown words';4+ const normalized = /^[A-Z0-9 _-]+$/.test(text) ? text.toLowerCase() : text;5+ return normalized.charAt(0).toUpperCase() + normalized.slice(1);6+}7+8+function warningProof(pack, warning) {9+ const levels = Array.isArray(pack?.levels) ? pack.levels : [];10+ const index = levels.findIndex(level => level?.id === warning?.levelId);11+ const proof = index >= 0 ? `Proof ${index + 1}` : 'Proof';12+ const title = sentenceCase(warning?.levelTitle || levels[index]?.title || warning?.levelId);13+ return `${proof}, ${title}`;14+}15+16+function warningBody(warning) {17+ const word = String(warning?.word || '').toUpperCase();18+ const sentence = String(warning?.sentence || '').trim();19+ const message = String(warning?.message || '').replaceAll('\u00b7', ',');20+ const propertyElsewhere = /declared as a property|property in another proof/i.test(message);21+ const thingElsewhere = /thing in another proof|used as a thing here/i.test(message);22+ if (sentence) {23+ const parts = sentence.split(/\s+IS\s+/i);24+ const subject = parts[0]?.trim();25+ const predicate = parts[1]?.trim();26+ if (subject && predicate && predicate.toUpperCase() === word) {27+ const article = /^[AEIOU]/i.test(word) ? 'an' : 'a';28+ const consequence = `${sentence} would turn every ${subject.toLowerCase()} into ${article} ${word}.`;29+ if (propertyElsewhere && thingElsewhere) return `${word} is a rule word in one proof and a thing in another, so ${consequence}`;30+ if (propertyElsewhere) return `${word} is a rule word in another proof but not here, so ${consequence}`;31+ if (thingElsewhere) return `${word} is a thing in another proof, so ${consequence}`;32+ return `${word} isn't a rule word here, so ${consequence}`;33+ }34+ }35+ if (propertyElsewhere && thingElsewhere) return `${word} is a rule word in one proof and a thing in another.`;36+ if (propertyElsewhere) return `${word} is a rule word in another proof but not here.`;37+ if (thingElsewhere) return `${word} is a thing in another proof but not here.`;38+ if (/not a known noun or declared property|not a declared property|not a known noun/i.test(message)) return `${word} isn't a rule word or a thing in this proof, so it can't do anything.`;39+ return message || `${word} may not behave as a rule.`;40+}41+42+/** Build the plain language lines shared by Gift and Workshop warning cards. */43+export function warningLines(pack, warnings = []) {44+ const lines = [];45+ const loose = new Map();46+ for (const warning of warnings) {47+ if (!warning.sentence) {48+ if (/thing in another proof|used as a thing here|declared as a property|property in another proof/i.test(String(warning.message || ''))) {49+ lines.push(`${warningProof(pack, warning)}: ${warningBody(warning)}`);50+ continue;51+ }52+ const key = `${warning.levelId || ''}\u0000${warning.levelTitle || ''}`;53+ const group = loose.get(key) || { proof: warningProof(pack, warning), words: [] };54+ group.words.push(String(warning.word || '').toUpperCase());55+ loose.set(key, group);56+ continue;57+ }58+ lines.push(`${warningProof(pack, warning)}: ${warningBody(warning)}`);59+ }60+ for (const group of loose.values()) {61+ const words = [...new Set(group.words)];62+ const joined = words.length > 1 ? `${words.slice(0, -1).join(', ')} and ${words.at(-1)}` : words[0];63+ lines.push(`${group.proof}: ${joined} ${words.length === 1 ? "isn't" : "aren't"} rule words or things in this proof, so ${words.length === 1 ? "it can't" : "they can't"} do anything.`);64+ }65+ return lines;66+}@@ -1,4 +1,5 @@
11 import { cloneData } from '../data/clone.js';2+import { warningLines } from './warning-lines.js';23 34 /**45 * Canvas Workshop scene.@@ -30,6 +31,17 @@ function fit(ctx, value, width, size, weight = 700) {
3031 while (next > size * 0.55) { ctx.font = `${weight} ${next}px ${FONT}`; if (ctx.measureText(String(value)).width <= width) break; next *= 0.92; }3132 return `${weight} ${Math.max(size * 0.55, next)}px ${FONT}`;3233 }34+function wrapText(ctx, value, width, size, weight = 700) {35+ ctx.font = `${weight} ${size}px ${FONT}`;36+ const lines = [];37+ let line = '';38+ for (const word of String(value).split(/\s+/)) {39+ const candidate = line ? `${line} ${word}` : word;40+ if (line && ctx.measureText(candidate).width > width) { lines.push(line); line = word; } else line = candidate;41+ }42+ if (line) lines.push(line);43+ return lines.length ? lines : [''];44+}3345 function drawKeyHint(ctx, key, verb, right, y, colors) {3446 ctx.font = `800 10px ${FONT}`;3547 const verbWidth = ctx.measureText(verb).width;@@ -101,12 +113,13 @@ export function createWorkshopScene(options = {}) {
101113 feedback = { message: 'This pack has problems', kind: 'error', errors: list };102114 return view();103115 }104- function showWarnings(warnings) {116+ function showWarnings(warnings, pack = null) {105117 const entries = Array.isArray(warnings) ? warnings : [warnings];106118 const list = entries.filter(Boolean).map(warning => typeof warning === 'object'107119 ? { ...warning, message: String(warning.message || warning.detail || 'This word may not work as a rule.'), detail: String(warning.detail || warning.message || '') }108120 : { message: String(warning), detail: String(warning) });109- note = { kind: 'warning', errors: list, visible: list.slice(0, 5).map(warning => warning.message), remaining: Math.max(0, list.length - 5) };121+ const visible = warningLines(pack, list);122+ note = { kind: 'warning', errors: list, visible: visible.slice(0, 5), remaining: Math.max(0, visible.length - 5) };110123 feedback = { message: 'Pack notes', kind: 'warning', warnings: list };111124 return view();112125 }@@ -142,7 +155,7 @@ export function createWorkshopScene(options = {}) {
142155 const id = row.pack.id;143156 try {144157 if (action === 'play') { options.onPlay?.(id); return { ...view(), event: { type: 'play', packId: id } }; }145- if (action === 'notes') return { ...showWarnings(row.pack.warnings), event: { type: 'notes', packId: id } };158+ if (action === 'notes') return { ...showWarnings(row.pack.warnings, row.pack), event: { type: 'notes', packId: id } };146159 if (action === 'share') {147160 const result = service?.packLink?.(id);148161 if (result && typeof result === 'object' && result.ok === false) return showErrors(result.errors || [{ message: 'Could not create pack link.' }]);@@ -290,7 +303,11 @@ export function createWorkshopScene(options = {}) {
290303 }291304 });292305 if (note) {293- const noteHeight = Math.min(height * 0.45, 76 + note.visible.length * 19); const noteWidth = compact ? width - 48 : width - 96;306+ const noteWidth = compact ? width - 48 : width - 96;307+ const noteTextWidth = Math.max(40, noteWidth - 40);308+ const noteLineHeight = compact ? 16 : 18;309+ const wrappedNotes = note.visible.flatMap(error => wrapText(ctx, error, noteTextWidth, 10, 600));310+ const noteHeight = Math.min(height * 0.72, 76 + wrappedNotes.length * noteLineHeight);294311 const noteX = compact ? 24 : 48; const noteY = compact ? Math.max(24, (height - noteHeight) / 2) : height - noteHeight - 24;295312 hitLayout.noteCard = { x: noteX, y: noteY, width: noteWidth, height: noteHeight };296313 hitLayout.noteCopy = { x: noteX + noteWidth * 0.52, y: noteY + noteHeight - 38, height: 34 };@@ -298,8 +315,8 @@ export function createWorkshopScene(options = {}) {
298315 if (compact) { ctx.fillStyle = 'rgba(0,0,0,.72)'; ctx.fillRect(0, 0, width, height); }299316 ctx.fillStyle = colors.raised; ctx.fillRect(noteX, noteY, noteWidth, noteHeight); ctx.strokeStyle = noteColor; ctx.strokeRect(noteX, noteY, noteWidth, noteHeight);300317 drawText(ctx, note.kind === 'warning' ? 'PACK NOTES' : 'This pack has problems', noteX + 20, noteY + 22, 11, noteColor, 'left', 850);301- note.visible.forEach((error, index) => {302- ctx.font = fit(ctx, error, Math.max(40, noteWidth - 40), 10, 600); ctx.fillStyle = colors.fg; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(error, noteX + 20, noteY + 44 + index * 18);318+ wrappedNotes.forEach((error, index) => {319+ ctx.font = `600 10px ${FONT}`; ctx.fillStyle = colors.fg; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(error, noteX + 20, noteY + 44 + index * noteLineHeight);303320 });304321 drawText(ctx, compact ? 'ESC CLOSE' : 'ESC CLOSE', noteX + 20, noteY + noteHeight - 18, 10, colors.muted, 'left', 800);305322 drawText(ctx, `${note.remaining ? `AND ${note.remaining} MORE | ` : ''}COPY DETAILS`, noteX + noteWidth - 20, noteY + noteHeight - 18, 10, noteColor, 'right', 800);@@ -144,10 +144,10 @@ test('Gift warning details render an amber NOTE and plain language proof line',
144144 assert.ok(labels.some(value => value.includes('NOTE')));145145 assert.ok(labels.some(value => value.includes('1 word may not work as rules')));146146 assert.ok(fills.includes('#FFB020'));147- assert.ok(labels.some(value => value.includes('SHUT has no behaviour')));147+ assert.ok(labels.some(value => value.includes("SHUT isn't a rule word here")));148148 });149149 150-test('Gift note keycaps are tappable on mobile and details stay above the preview', () => {150+test('Gift note line and COPY text are tappable on mobile without keycaps', () => {151151 let copied = '';152152 const warnings = Array.from({ length: 5 }, (_, index) => ({153153 word: `WORD${index}`, levelId: 'intro', levelTitle: 'Read the board',@@ -160,23 +160,19 @@ test('Gift note keycaps are tappable on mobile and details stay above the previe
160160 canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},161161 fillText() {}, measureText(value) { return { width: String(value).length * 5 }; },162162 }, 0, { width: 390, height: 844 });163- assert.equal(layout.hintActions.length, 2);164- const detailsKey = layout.hintActions.find(item => item.action === 'details');165- const copyKey = layout.hintActions.find(item => item.action === 'copy');166- assert.ok(scene.hit(detailsKey.x + detailsKey.width / 2, detailsKey.y + detailsKey.height / 2).open);163+ assert.equal(layout.hintActions.length, 0);164+ assert.equal(layout.noteActions.length, 1);165+ const noteLine = layout.noteActions[0];166+ assert.ok(scene.hit(noteLine.x + noteLine.width / 2, noteLine.y + noteLine.height / 2).open);167167 layout = scene.draw({168168 canvas: { width: 390, height: 844 }, save() {}, restore() {}, beginPath() {}, roundRect() {}, rect() {}, fill() {}, stroke() {}, arc() {}, fillRect() {}, moveTo() {}, lineTo() {}, closePath() {}, clip() {}, ellipse() {},169169 fillText() {}, measureText(value) { return { width: String(value).length * 5 }; },170170 }, 0, { width: 390, height: 844 });171171 assert.ok(layout.preview.y > layout.y);172172 assert.ok(layout.preview.y + layout.preview.height <= layout.actions[0].y);173- assert.equal(layout.hintRows, 2);174- assert.ok(layout.hints.x >= layout.x + 8);175- assert.ok(layout.hints.x + layout.hints.width <= layout.x + layout.width - 8);176- assert.ok(layout.hintActions.every(item => item.x >= layout.x && item.x + item.width <= layout.x + layout.width));177- assert.ok(layout.hintActions.every(item => item.width >= 44 && item.height >= 44));178- assert.ok(layout.actions.at(-1).y + layout.actions.at(-1).height + 8 <= Math.min(...layout.hintActions.map(item => item.y)));179- const expandedCopyKey = layout.hintActions.find(item => item.action === 'copy');180- assert.ok(scene.hit(expandedCopyKey.x + expandedCopyKey.width / 2, expandedCopyKey.y + expandedCopyKey.height / 2).ok);173+ assert.equal(layout.hintRows, 0);174+ assert.ok(layout.noteActions.some(item => item.action === 'copy'));175+ const copyButton = layout.noteActions.find(item => item.action === 'copy');176+ assert.ok(scene.hit(copyButton.x + copyButton.width / 2, copyButton.y + copyButton.height / 2).ok);181177 assert.match(copied, /WORD0/);182178 });@@ -0,0 +1,26 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import { warningLines } from '../src/scenes/warning-lines.js';4+5+test('warning lines group ordinary loose words but preserve cross-proof ambiguity context', () => {6+ const pack = { levels: [{ id: 'plain', title: 'Unknown words' }] };7+ const lines = warningLines(pack, [8+ { word: 'SHUT', levelId: 'plain', message: 'SHUT is not a known noun or declared property in this proof.' },9+ { word: 'OPEN', levelId: 'plain', message: 'OPEN is not a known noun or declared property in this proof.' },10+ { word: 'TICK', levelId: 'plain', message: 'TICK is used as a thing in another proof but is not a known noun here.' },11+ ]);12+ assert.deepEqual(lines, [13+ 'Proof 1, Unknown words: TICK is a thing in another proof but not here.',14+ "Proof 1, Unknown words: SHUT and OPEN aren't rule words or things in this proof, so they can't do anything.",15+ ]);16+});17+18+test('formed cross-proof property ambiguity keeps its consequence', () => {19+ const lines = warningLines({ levels: [{ id: 'door', title: 'Key and door' }] }, [{20+ word: 'SHUT', levelId: 'door', sentence: 'DOOR IS SHUT',21+ message: 'SHUT is declared as a property in another proof and used as a thing here; DOOR IS SHUT will transform DOOR into SHUT.',22+ }]);23+ assert.deepEqual(lines, [24+ 'Proof 1, Key and door: SHUT is a rule word in one proof and a thing in another, so DOOR IS SHUT would turn every door into a SHUT.',25+ ]);26+});@@ -99,9 +99,10 @@ test('warning notes add an amber Workshop chip and NOTES action', async () => {
9999 let copied = '';100100 const pack = {101101 ...makePack('clockwork'),102+ levels: [{ id: 'door', title: 'Key and door', objects: [] }],102103 warnings: [103- { word: 'SHUT', levelId: 'door', levelTitle: 'Key and door', message: 'Proof 1, Key and door: SHUT is not a rule word here.', detail: 'levels[0].objects[2].text' },104- { word: 'OPEN', levelId: 'door', levelTitle: 'Key and door', message: 'Proof 1, Key and door: OPEN is not a rule word here.', detail: 'levels[0].objects[3].text' },104+ { word: 'SHUT', levelId: 'door', levelTitle: 'Key and door', sentence: 'DOOR IS SHUT', message: 'SHUT is not a rule word here.', detail: 'levels[0].objects[2].text' },105+ { word: 'OPEN', levelId: 'door', levelTitle: 'Key and door', sentence: 'DOOR IS OPEN', message: 'OPEN is not a rule word here.', detail: 'levels[0].objects[3].text' },105106 ],106107 };107108 const scene = createWorkshopScene({ packs: [pack], copyToClipboard: async value => { copied = value; } });@@ -112,8 +113,9 @@ test('warning notes add an amber Workshop chip and NOTES action', async () => {
112113 const result = await scene.handleInput('enter');113114 assert.equal(result.event.type, 'notes');114115 assert.equal(scene.view().note.kind, 'warning');115- assert.match(scene.accessibilityText(), /Pack notes.*SHUT is not a rule word here/);116- assert.deepEqual(scene.view().note.visible, [pack.warnings[0].message, pack.warnings[1].message]);116+ assert.match(scene.accessibilityText(), /Pack notes.*Proof 1, Key and door: SHUT isn't a rule word here/);117+ assert.deepEqual(scene.view().note.visible, ['Proof 1, Key and door: SHUT isn\'t a rule word here, so DOOR IS SHUT would turn every door into a SHUT.', 'Proof 1, Key and door: OPEN isn\'t a rule word here, so DOOR IS OPEN would turn every door into an OPEN.']);118+ assert.equal(scene.view().note.visible.some(line => line.includes('\u00b7')), false);117119 scene.draw(canvasContext(), { width: 800, height: 600 });118120 const labels = scene.hitLayout.rows.flatMap(row => row.actions.map(action => action.action));119121 assert.ok(labels.includes('notes'));@@ -124,6 +126,11 @@ test('warning notes add an amber Workshop chip and NOTES action', async () => {
124126 assert.ok(scene.hitLayout.noteCard.x > 0 && scene.hitLayout.noteCard.y > 0);125127 assert.ok(scene.hitLayout.noteCard.x + scene.hitLayout.noteCard.width <= 390);126128 assert.ok(scene.hitLayout.noteCard.y + scene.hitLayout.noteCard.height <= 844);129+ const noteText = canvasContext();130+ scene.draw(noteText, { width: 390, height: 844 });131+ const renderedNoteLines = noteText.calls.filter(call => call[0] === 'fillText' && String(call[1]).includes('Proof 1'));132+ assert.ok(renderedNoteLines.length >= 2, 'long note text wraps inside the mobile card');133+ assert.ok(renderedNoteLines.every(call => String(call[1]).length < 42), 'wrapped note lines stay bounded');127134 const closed = scene.hit(2, 2);128135 assert.equal(closed.event.type, 'notes-close');129136 await scene.handleInput('enter');