diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index c72d0e19..8cc86843 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -15,6 +15,20 @@ font-display: swap; } +/* Registered so they always compute to a plain value: story-mode.js reads --story-panel-min back + with parseFloat (an unregistered property holding calc() would come back as its raw text, and + caption fitting would silently stop). --story-k scales the comic text with the row height. */ +@property --story-panel-min { + syntax: ''; + inherits: true; + initial-value: 270px; +} +@property --story-k { + syntax: ''; + inherits: true; + initial-value: 1; +} + /* Session Options → Story (story-mode.js): shown while Story Mode is on. Sits in the sheet's header tray beside the close button, on every device. */ #sessionOptionsModal .set-head-actions .story-options-btn { @@ -69,6 +83,8 @@ --story-body: 'Manrope', system-ui, sans-serif; --story-mono: 'JetBrains Mono', ui-monospace, Menlo, monospace; --story-drawer-w: min(560px, 44vw); + /* The Director's cut wire beside the sheet grows with the window. */ + --story-wire-w: clamp(320px, 25vw, 460px); position: fixed; inset: 0; @@ -277,14 +293,16 @@ body.story-open { /* Main: the sheet and the wire */ .story-main { overflow: auto; - padding: 16px max(16px, env(safe-area-inset-right, 0px)) 40px max(16px, env(safe-area-inset-left, 0px)); + /* The scrollbar's room is kept whether the page scrolls or not, so the sheet's width (and the row + height story-mode.js derives from it) never depends on whether the rows overflow. */ + scrollbar-gutter: stable; + padding: 16px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); } .story-overlay.has-drawer .story-main { padding-right: calc(var(--story-drawer-w) + 16px); } .story-note { - max-width: 1240px; - margin: 0 auto 14px; + margin: 0 0 14px; padding: 8px 12px; font-size: 14px; line-height: 1.45; @@ -299,35 +317,47 @@ body.story-open { grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; - max-width: 1240px; - margin: 0 auto; } .story-overlay.is-director .story-stage { - grid-template-columns: minmax(0, 1fr) 320px; + grid-template-columns: minmax(0, 1fr) var(--story-wire-w); +} +/* The wire runs the full height beside the note too (it starts at the top of the main area): the + note ends where the sheet does, at the stage's 16px gap before the wire. */ +.story-overlay.is-director .story-note { + margin-right: calc(var(--story-wire-w) + 16px); } @media (max-width: 1100px) { .story-overlay.is-director .story-stage { grid-template-columns: minmax(0, 1fr); } + .story-overlay.is-director .story-note { + margin-right: 0; + } } .story-sheet { container: story-sheet / inline-size; + /* Down to the visible bottom even when a short page's rows stop short of it (a row is capped). */ + min-height: calc(var(--story-view-h, 0px) - var(--story-note-h, 0px)); background: var(--story-sheet); } .story-page { + /* The shortest row: story-mode.js sets each row from the visible height (plain px values, in + grid-template-rows) and this to the shortest, for any row past those and as a panel's default. */ + --story-row-h: 270px; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); - /* A row grows only for the final panel, whose words and chips are in flow (never clipped). */ - grid-auto-rows: minmax(270px, auto); + /* A row grows past it only for words that need more room (the final panel's are in flow). */ + grid-auto-rows: minmax(var(--story-row-h), auto); gap: 14px; padding: 14px; } /* Panels */ .story-panel { - /* The panel's usual height, read back as its scene's stage: a long caption or ending makes the - panel taller (inline min-height), and the scene then stands at the bottom, never scaled up. */ - --story-panel-min: 270px; + /* The panel's usual height, read back as its scene's stage: its row's, which story-mode.js writes + on the panel (plain px). A long caption or ending makes the panel taller (inline min-height), + and the scene then stands at the bottom, never scaled up. */ + --story-panel-min: var(--story-row-h); position: relative; margin: 0; min-height: var(--story-panel-min); @@ -387,7 +417,7 @@ body.story-open { background: var(--story-cap); color: var(--story-cap-ink); border: 2.5px solid var(--story-cap-ink); - font: 17px/1.13 var(--story-letter); + font: calc(17px * var(--story-k, 1)) / 1.13 var(--story-letter); text-wrap: pretty; } .story-cap-dir { @@ -395,7 +425,7 @@ body.story-open { } .story-overlay.is-director .story-cap { max-width: min(74%, 46ch); - font: 500 11px/1.45 var(--story-mono); + font: 500 calc(11px * var(--story-k, 1)) / 1.45 var(--story-mono); } .story-overlay.is-director .story-cap-story { display: none; @@ -430,7 +460,7 @@ body.story-open { background: var(--story-balloon); color: var(--story-balloon-ink); border: 2.5px solid var(--story-balloon-ink); - font: 17px/1.12 var(--story-letter); + font: calc(17px * var(--story-k, 1)) / 1.12 var(--story-letter); text-wrap: balance; overflow-wrap: anywhere; } @@ -489,7 +519,8 @@ body.story-open .toast-container { } .story-balloon--you { left: 16px; - top: 74px; + /* Below the caption, which grows with the comic text (--story-k). */ + top: calc(74px * var(--story-k, 1)); max-width: 54%; } .story-balloon--you::before { @@ -511,7 +542,7 @@ body.story-open .toast-container { /* A turn somebody else started (a background task, another session): a notice, not your speech. */ .story-balloon--note { left: 16px; - top: 74px; + top: calc(74px * var(--story-k, 1)); max-width: 54%; border-radius: 4px; border-style: dashed; @@ -603,7 +634,7 @@ body.story-open .toast-container { .story-splash-title { /* Room for the tilt: rotated about its bottom-left corner, its right end rises. */ margin: 0.4em 0 0; - font: 400 clamp(28px, 4.4cqi, 44px) / 0.92 var(--story-display); + font: 400 calc(clamp(28px, 4.4cqi, 44px) * var(--story-k, 1)) / 0.92 var(--story-display); color: var(--story-yellow); -webkit-text-stroke: 2.5px var(--story-line); paint-order: stroke fill; @@ -622,7 +653,7 @@ body.story-open .toast-container { .story-chips li > * { display: block; padding: 4px 8px; - font: 500 12px/1.2 var(--story-mono) !important; + font: 500 calc(12px * var(--story-k, 1)) / 1.2 var(--story-mono) !important; background: var(--story-balloon); color: var(--story-balloon-ink) !important; border: 2px solid var(--story-line); @@ -716,13 +747,17 @@ body.story-open .toast-container { } } -/* The wire */ +/* The wire: beside the sheet, a column as tall as the main area's visible height (story-mode.js + measures it), sticky, its list scrolling inside. It starts at the top of the main area even when + a note sits above the sheet: pulled up by the note's height, which the note's own margin clears. */ .story-wire { display: flex; flex-direction: column; min-height: 0; - max-height: calc(100vh - 120px); + height: var(--story-view-h, calc(100vh - 120px)); + margin-top: calc(-1 * var(--story-note-h, 0px)); position: sticky; + /* 0: a sticky box already stops inside its scroll container's padding. */ top: 0; background: var(--story-sheet); border: 2.5px solid var(--story-ink); @@ -733,7 +768,9 @@ body.story-open .toast-container { @media (max-width: 1100px) { .story-wire { position: static; + height: auto; max-height: 460px; + margin-top: 0; } } .story-wire-head { diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index cd1238be..003cccb1 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -49,6 +49,11 @@ const STORY_STAGE_MAX = 680; const STORY_FPS_INTRO = 30; /** Panel backgrounds kept for reuse (kind × size × palette). */ const STORY_BG_CACHE = 32; +/** + * And at most this many bytes of them: rows grow with the window, and 32 backgrounds of a 1440p + * screen at 2x came to 342 MB (a panel still showing one keeps it; only reuse is lost). + */ +const STORY_BG_CACHE_BYTES = 160e6; /** Horizontal bands a new background is built in, one per frame. */ const STORY_BG_BANDS = 4; const storyBgCache = new Map(); @@ -1577,6 +1582,108 @@ const STORY_LEVEL_DEFAULT = 'director'; /** Where the reader's pick is kept, per device. */ const STORY_LEVEL_KEY = 'codeman:story-level'; +/** Shortest row a page draws, in CSS px: the height the scenes and the comic text were drawn for. */ +const STORY_ROW_MIN = 270; +/** + * Tallest row, in CSS px: twice the drawn size. A row past it holds mostly background (a turn that + * just started is one row of two panels), and the comic text, which stops growing at + * STORY_TEXT_MAX, would look lost in art drawn more than twice its size. + */ +const STORY_ROW_MAX = 540; +/** + * The narrowest stage, in scene units, that keeps all of a scene's art on the panel: the mission's + * exclamation mark beside Claude's head (at 0.78 of the width) runs off the right edge below about + * 278. A row is never so tall that its narrowest panel's stage gets narrower, so a narrow panel + * stays about as wide as it is tall, never a portrait. + */ +const STORY_STAGE_MIN = 280; +/** + * The final panel's narrowest stage: under 420 units its scene centres Claude, behind the words on + * its left. Its row may still be taller (it takes what the narrow rows cannot); the scene then + * stands at the bottom, the words above it, as it does when the words make the panel taller. + */ +const STORY_FINAL_STAGE_MIN = 440; +/** The comic text grows with the rows up to this factor (`--story-k`); a caption in a narrow panel would wrap past its band beyond it. */ +const STORY_TEXT_MAX = 1.45; + +/** Each item's row, and the items in each row, for `spans` on a page `cols` wide. */ +function storyLayoutRows(spans, cols) { + const rowOf = []; + const rows = []; + let used = 0; + spans.forEach((span, i) => { + if (!used) rows.push([]); + rows[rows.length - 1].push(i); + rowOf.push(rows.length - 1); + used += span; + if (used >= cols) used = 0; + }); + return { rowOf, rows }; +} + +/** + * Row heights, in whole CSS px, that make a page that fits fill the visible height, and each item's + * stage (the height its scene is drawn at, `--story-panel-min`). The height is shared evenly; a row + * never goes under STORY_ROW_MIN (a longer page keeps its rows and scrolls) nor over its cap + * (STORY_ROW_MAX, and the height at which a panel's stage would fall under STORY_STAGE_MIN wide), + * and what a capped row cannot take goes to the rows that can (the final panel's full-width row, + * mostly). Rounded down, so the rows never overflow by a fraction and raise a scrollbar. + * `box` is the page's content box: `width` across, `height` the visible height the rows may take, + * `gap` between rows and columns; `stageMin` each item's narrowest stage in scene units (default + * STORY_STAGE_MIN; 0 for an item without art, the pencils); and `lastRow` the height the last row + * takes once its words made it taller (the final panel's are in flow): the other rows give that up, + * so the page still ends at the bottom. The text scale `k` (the shortest row over STORY_ROW_MIN) is + * settled before that, so the words that set `lastRow` never change with it. One column (a phone) + * gives null: its panels keep their natural heights. + * @returns {{ heights: number[], stage: number[], rowOf: number[], k: number, fill: number, caps: number[] } | null} + */ +function storyRowHeights(spans, cols, { width, height, gap = 0, stageMin = [], lastRow = 0 }) { + if (!(cols >= 2) || !spans?.length || !(width > 0) || !(height > 0)) return null; + const { rowOf, rows } = storyLayoutRows(spans, cols); + const col = (width - gap * (cols - 1)) / cols; + // The tallest row an item allows: its stage, its width wide in scene units, no narrower than `min`. + const tallest = (i, min) => { + const n = Math.min(cols, spans[i]); + return min > 0 ? ((n * col + (n - 1) * gap) * StoryArt.VH) / min : Infinity; + }; + const own = (i) => stageMin[i] ?? STORY_STAGE_MIN; + const caps = rows.map((items) => + Math.max(STORY_ROW_MIN, Math.min(STORY_ROW_MAX, ...items.map((i) => tallest(i, Math.min(own(i), STORY_STAGE_MIN))))) + ); + /** `total` shared by the rows in `which`, evenly, up to each row's cap; the rest to the others. */ + const share = (which, total) => { + const out = new Map(); + let open = which; + let left = total; + while (open.length) { + const even = left / open.length; + const full = open.filter((r) => caps[r] < even); + if (!full.length) { + for (const r of open) out.set(r, even); + break; + } + for (const r of full) { + out.set(r, caps[r]); + left -= caps[r]; + } + open = open.filter((r) => caps[r] >= even); + } + return which.map((r) => Math.max(STORY_ROW_MIN, Math.floor(out.get(r)))); + }; + const n = rows.length; + const avail = height - gap * (n - 1); + const all = rows.map((_, r) => r); + let heights = share(all, avail); + const k = Math.round(Math.min(STORY_TEXT_MAX, Math.min(...heights) / STORY_ROW_MIN) * 1000) / 1000; + const others = heights.slice(0, -1).reduce((a, h) => a + h, 0); + if (n > 1 && lastRow > heights[n - 1] && others + lastRow > avail) + heights = [...share(all.slice(0, -1), avail - lastRow), heights[n - 1]].map((h, r) => Math.min(h, heights[r])); + const stage = spans.map((_, i) => + Math.max(STORY_ROW_MIN, Math.min(heights[rowOf[i]], Math.floor(tallest(i, own(i))))) + ); + return { heights, stage, rowOf, k, fill: avail / n, caps }; +} + Object.assign(CodemanApp.prototype, { /** Per-device setting, default OFF. */ isStoryButtonEnabled() { @@ -2057,7 +2164,36 @@ Object.assign(CodemanApp.prototype, { _storyStartRuntime() { const st = this._story; const bar = st.root.querySelector('.story-bar'); + const sheet = st.pageEl.parentElement; + st.sheetW = undefined; + st.pageH = undefined; + cancelAnimationFrame(st.layoutRaf || 0); + st.layoutRaf = 0; st.ro = new ResizeObserver((entries) => { + // The main area's box (the window, the drawer's padding, a bar above or below it) or the + // sheet's width (the Director's cut wire takes some) sets the row heights: once per delivery, + // before the panels re-size to them. The page's height changes with its words too (another + // reading level, a caption that grew, the fonts arriving), which the final panel's row + // measures: that pass waits for the next frame, so a pass that moves the page's own height + // is never reported back in the same one. Same boxes, same values, nothing written: no loop. + let relayout = false; + let later = false; + for (const entry of entries) { + if (entry.target === st.mainEl) relayout = true; + else if (entry.target === sheet && Math.abs(entry.contentRect.width - (st.sheetW ?? -1)) > 0.5) { + st.sheetW = entry.contentRect.width; + relayout = true; + } else if (entry.target === st.pageEl && Math.abs(entry.contentRect.height - (st.pageH ?? -1)) > 0.5) { + st.pageH = entry.contentRect.height; + later = true; + } + } + if (relayout) this._storyLayoutPage(); + if (later && !st.layoutRaf) + st.layoutRaf = requestAnimationFrame(() => { + st.layoutRaf = 0; + this._storyLayoutPage(); + }); for (const entry of entries) { // The bar's height places the phone drawer below it, and changes when the phone turns. if (entry.target === bar) { @@ -2069,6 +2205,9 @@ Object.assign(CodemanApp.prototype, { } }); if (bar) st.ro.observe(bar); + st.ro.observe(st.mainEl); + if (sheet) st.ro.observe(sheet); + st.ro.observe(st.pageEl); st.io = new IntersectionObserver((entries) => { for (const entry of entries) { const rt = st.panels.find((p) => p.el === entry.target); @@ -2383,6 +2522,14 @@ Object.assign(CodemanApp.prototype, { else st.pageEl.prepend(rt.el); } }); + // Spans first, then the row height they make: a new panel is sized at its final width and height. + next.forEach((rt, i) => { + rt.el.style.setProperty('--story-span6', String(spans6[i])); + rt.el.style.setProperty('--story-span2', String(spans2[i])); + }); + st.spans6 = spans6; + st.spans2 = spans2; + this._storyLayoutPage(); want.forEach((panel, i) => { const rt = st.panels[i]; rt.panel = panel; @@ -2391,8 +2538,6 @@ Object.assign(CodemanApp.prototype, { rt.isNew = false; this._storySizePanel(rt); } - rt.el.style.setProperty('--story-span6', String(spans6[i])); - rt.el.style.setProperty('--story-span2', String(spans2[i])); this._storyFillPanelText(rt, page, i); this._storyQueueDraw(rt); }); @@ -2416,6 +2561,8 @@ Object.assign(CodemanApp.prototype, { pencils.style.setProperty('--story-span2', String(spans2[want.length])); st.pageEl.append(pencils); } else pencils?.remove(); + // Again now that the words are in: the final panel's may take more of the height than its row. + if (st.panels[st.panels.length - 1]?.splash) this._storyLayoutPage(); if (focusAt >= 0 && !document.contains(focused)) { const rt = st.panels[Math.min(focusAt, st.panels.length - 1)]; if (rt && !rt.w) this._storySizePanel(rt); @@ -2706,14 +2853,18 @@ Object.assign(CodemanApp.prototype, { const w = rt.el.clientWidth; const h = rt.el.clientHeight; const dpr = Math.min(window.devicePixelRatio || 1, 2); - if (w === rt.w && h === rt.h && dpr === rt.dpr) return; - rt.w = w; - rt.h = h; // A panel its words made taller (a long caption, the final panel's ending) keeps its scene at the // usual size, standing at the bottom: the stage is the panel's usual height inside its border. + // That height is its row's (a plain px value _storyLayoutPage writes on the panel), so the art + // grows with the rows; read before the early return, since a row can change height while this + // panel's box, held by a taller neighbour, does not. const minH = parseFloat(getComputedStyle(rt.el).getPropertyValue('--story-panel-min')) || 0; const border = rt.el.offsetHeight - rt.el.clientHeight; - rt.stageH = minH > border ? Math.min(h, minH - border) : h; + const stageH = minH > border ? Math.min(h, minH - border) : h; + if (w === rt.w && h === rt.h && dpr === rt.dpr && stageH === rt.stageH) return; + rt.w = w; + rt.h = h; + rt.stageH = stageH; rt.dpr = dpr; rt.canvas.width = Math.max(1, Math.round(w * dpr)); rt.canvas.height = Math.max(1, Math.round(h * dpr)); @@ -2721,6 +2872,73 @@ Object.assign(CodemanApp.prototype, { this._storyQueueDraw(rt); }, + /** + * Fits the page to the main area: the rows take the visible height (storyRowHeights), the comic + * text grows with them (`--story-k`), and the sheet and the Director's cut wire span that height. + * It reads the main area's box and the page's width, never the sheet's height, so nothing it + * writes feeds back into it, and it writes a property only when its value changes. Called when the + * page is rendered and by the resize observer (window, drawer, the wire's toggle). + */ + _storyLayoutPage() { + const st = this._story; + if (!st?.open) return; + const px = (v) => parseFloat(v) || 0; + const main = st.mainEl; + const mcs = getComputedStyle(main); + const view = Math.max(0, main.clientHeight - px(mcs.paddingTop) - px(mcs.paddingBottom)); + const noteH = st.note.hidden ? 0 : st.note.offsetHeight + px(getComputedStyle(st.note).marginBottom); + const pcs = getComputedStyle(st.pageEl); + // The column count the container queries chose: the page's resolved tracks, else their thresholds. + const tracks = pcs.gridTemplateColumns || ''; + const sheetW = st.pageEl.parentElement?.getBoundingClientRect().width || 0; + const cols = + (tracks && !tracks.includes('(') && tracks.trim().split(/\s+/).length) || + (sheetW > 1000 ? 6 : sheetW > 600 ? 2 : 1); + const spans = cols === 6 ? st.spans6 : cols === 2 ? st.spans2 : null; + const box = { + width: st.pageEl.clientWidth - px(pcs.paddingLeft) - px(pcs.paddingRight), + height: view - noteH - px(pcs.paddingTop) - px(pcs.paddingBottom), + gap: px(pcs.rowGap), + }; + const set = (el, name, value) => { + if (value === null) el.style.removeProperty(name); + else if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value); + }; + set(st.stage, '--story-view-h', `${view}px`); + set(st.stage, '--story-note-h', `${noteH}px`); + // Plain px values, never calc() or clamp(): every panel reads its row back with parseFloat + // (--story-panel-min), as its scene's stage. No fit (one column): the stylesheet's own heights. + const apply = (f) => { + set(st.pageEl, '--story-row-h', f ? `${Math.min(...f.heights)}px` : null); + set(st.pageEl, '--story-k', f ? String(f.k) : null); + set(st.pageEl, 'grid-template-rows', f ? f.heights.map((h) => `minmax(${h}px, auto)`).join(' ') : null); + st.panels.forEach((rt, i) => set(rt.el, '--story-panel-min', f ? `${f.stage[i]}px` : null)); + }; + // The pencils (after the panels) draw no art; the final panel's scene needs a wide stage. + box.stageMin = st.panels.map((rt) => (rt.splash ? STORY_FINAL_STAGE_MIN : STORY_STAGE_MIN)).concat([0]); + let fit = storyRowHeights(spans, cols, box); + apply(fit); + // The final panel's words are in flow and may make its row taller at this text size (it sits + // alone in the last row): measured now, the other rows give that height up. The words depend on + // the text size and the width only, never on the row heights, so this settles in one step. + const last = st.panels[st.panels.length - 1]; + if (fit && last?.splash && last.panel && fit.rowOf[st.panels.length - 1] === fit.heights.length - 1) { + const lastRow = last.el.offsetHeight; + if (lastRow > fit.heights[fit.heights.length - 1]) { + fit = storyRowHeights(spans, cols, { ...box, lastRow }); + apply(fit); + } + } + const sig = fit ? `${fit.heights.join(',')}|${fit.stage.join(',')}|${fit.k}` : ''; + const changed = sig !== st.layoutSig; + st.layoutSig = sig; + st.fit = fit; + // A panel held at its size by a taller neighbour gets no resize report: its stage still follows + // its row. Sizing only queues the draws (the frame loop rations them); a panel not sized yet is + // sized when it is filled. + if (changed) for (const rt of st.panels) if (rt.w) this._storySizePanel(rt); + }, + /** * A caption taller than the band above the art makes its panel taller by the difference, and the * scene stands below it (`rt.stageH`): a long caption never covers a test board or a code page. @@ -2769,7 +2987,9 @@ Object.assign(CodemanApp.prototype, { canvas.height = rt.canvas.height; job = { canvas, next: 0, total: STORY_BG_BANDS }; storyBgCache.set(key, job); - if (storyBgCache.size > STORY_BG_CACHE) storyBgCache.delete(storyBgCache.keys().next().value); + const bytes = () => [...storyBgCache.values()].reduce((n, j) => n + j.canvas.width * j.canvas.height * 4, 0); + while (storyBgCache.size > 1 && (storyBgCache.size > STORY_BG_CACHE || bytes() > STORY_BG_CACHE_BYTES)) + storyBgCache.delete(storyBgCache.keys().next().value); } rt.bg = job; rt.bgKey = key; diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index e059ab91..50309030 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -1521,6 +1521,100 @@ describe('story-mode.js layout', () => { } expect(spans([1, 1, 1], 2)).toEqual([1, 1, 2]); }); + + it('rows fill the visible height: shared evenly, never under 270, capped for narrow panels and one row', () => { + boot(null); + type Box = { width: number; height: number; gap: number; stageMin?: number[]; lastRow?: number }; + type Fit = { heights: number[]; stage: number[]; k: number } | null; + const rows = (spans: number[], cols: number, box: Box) => win.storyRowHeights(spans, cols, box) as Fit; + // A 1505x870 window: the page's rows may take 753 px, 1437 px across. + const box = { width: 1437, height: 753, gap: 14 }; + // The mission and one step over the final panel: two rows of 369 px fill it, text 1.367x. + const two = rows([3, 3, 6], 6, box)!; + expect(two.heights).toEqual([369, 369]); + expect(two.stage).toEqual([369, 369, 369]); + expect(two.k).toBe(1.367); + // More rows than fit at 270: they stay 270 and the page scrolls; the text keeps its drawn size. + const long = rows([3, 3, 2, 2, 2, 6, 6], 6, box)!; + expect(long.heights).toEqual([270, 270, 270, 270]); + expect(long.k).toBe(1); + // One row (a turn that just started): twice the drawn size at most, not the whole window. + expect(rows([3, 3], 6, box)!.heights).toEqual([540]); + // Two columns of 365 px: the narrow row stops where its stage would fall under 280 units, the + // full-width row takes the rest, and the final panel's scene keeps a stage wide enough to stand + // beside its words (440 units), at the bottom of its taller row. + const narrow = rows([1, 1, 2], 2, { width: 744, height: 870, gap: 14, stageMin: [280, 280, 440] })!; + expect(narrow.heights).toEqual([Math.floor((365 * 260) / 280), 517]); + expect(narrow.heights[0] + narrow.heights[1] + 14).toBeGreaterThan(868); + expect(narrow.stage).toEqual([338, 338, Math.floor((744 * 260) / 440)]); + // The final panel's words make its row 410 px tall: the other row gives that up, the text stays. + const fixed = rows([3, 3, 6], 6, { ...box, lastRow: 410 })!; + expect(fixed.heights).toEqual([329, 369]); + expect(fixed.k).toBe(two.k); + // One column (a phone) keeps its natural heights; no visible height, nothing to fit. + expect(rows([1, 1, 1], 1, box)).toBeNull(); + expect(rows([3, 3, 6], 6, { ...box, height: 0 })).toBeNull(); + }); + + it('every panel reads its row back as a plain px value, and its scene is drawn at that height', async () => { + boot(storyOf([prompt('first job'), ...read('r1', '/work/demo-shop/a.ts'), said('Done.'), end(2000)])); + app.openStoryMode(SID); + await settle(); + const st = app._story; + // JSDOM lays nothing out: the boxes a 1505x870 window gives the overlay, the paddings the + // stylesheet sets. + const fix = (el: HTMLElement, prop: string, value: number) => + Object.defineProperty(el, prop, { configurable: true, get: () => value }); + st.mainEl.style.padding = '16px'; + st.pageEl.style.padding = '14px'; + st.pageEl.style.rowGap = '14px'; + fix(st.mainEl, 'clientHeight', 813); + fix(st.pageEl, 'clientWidth', 1465); + let sheetW = 1465; + st.pageEl.parentElement.getBoundingClientRect = () => ({ width: sheetW }) as DOMRect; + app._storyLayoutPage(); + expect(st.pageEl.style.getPropertyValue('grid-template-rows')).toBe('minmax(369px, auto) minmax(369px, auto)'); + expect(st.pageEl.style.getPropertyValue('--story-k')).toBe('1.367'); + expect(st.stage.style.getPropertyValue('--story-view-h')).toBe('781px'); + // What _storySizePanel and _storyFitCaption read with parseFloat: a finite px value, never the + // raw text of a calc() or clamp(). + for (const p of st.panels) { + const raw = win.getComputedStyle(p.el).getPropertyValue('--story-panel-min'); + expect(raw).toMatch(/^\d+px$/); + expect(parseFloat(raw)).toBeGreaterThan(0); + } + const rt = st.panels[0]; + fix(rt.el, 'clientWidth', 712); + fix(rt.el, 'clientHeight', 363); + fix(rt.el, 'offsetHeight', 369); + app._storySizePanel(rt); + expect(rt.stageH).toBe(363); // 369 inside the 3 px border: the art grows with the row + // The final panel's words make its row 410 px tall: the first row gives the height up, and the + // panel already sized redraws its scene at its new row. + fix(st.panels[st.panels.length - 1].el, 'offsetHeight', 410); + app._storyLayoutPage(); + expect(st.pageEl.style.getPropertyValue('grid-template-rows')).toBe('minmax(329px, auto) minmax(369px, auto)'); + expect(win.getComputedStyle(rt.el).getPropertyValue('--story-panel-min')).toBe('329px'); + expect(rt.stageH).toBe(323); + // One column (a phone): nothing inline, the stylesheet's own heights apply. + sheetW = 500; + app._storyLayoutPage(); + expect(st.pageEl.style.getPropertyValue('grid-template-rows')).toBe(''); + expect(st.pageEl.style.getPropertyValue('--story-k')).toBe(''); + expect(rt.el.style.getPropertyValue('--story-panel-min')).toBe(''); + }); + + it('the stylesheet keeps the row height one plain length, registered, with one default', () => { + const css = readFileSync(new URL('../src/web/public/story-mode.css', import.meta.url), 'utf-8'); + // Registered: whatever a later rule puts in it, it computes to a length parseFloat reads. + expect(css).toMatch(/@property --story-panel-min \{[^}]*syntax: '';/); + // One default on the page, used by the rows and by every panel (no second 270 anywhere). + expect(css).toMatch( + /\.story-page \{[^}]*--story-row-h: 270px;[^}]*grid-auto-rows: minmax\(var\(--story-row-h\), auto\);/ + ); + expect(css).toMatch(/\.story-panel \{[^}]*--story-panel-min: var\(--story-row-h\);/); + expect(css).not.toMatch(/minmax\(270px/); + }); }); describe('story-mode.js reading levels', () => {