mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
feat(story-mode): the page fills the window, its comics grow with it
The comic sheet was capped at 1240px with empty margins beside it, the Director's cut wire was a 320px box, and a short page ended half way down the window. Now the stage takes the full width (16px gutters kept), the wire is clamp(320px, 25vw, 460px) wide and runs the main area's whole visible height, sticky, its list scrolling inside, and the rows grow to fill the visible height. Row heights come from a pure function next to storyLayoutSpans (storyRowHeights): the visible height is shared evenly between the page's rows for the active column count (6 over a 1000px sheet, 2 over 600px; one column keeps its natural heights), never under 270px (a longer page keeps 270px rows and scrolls as before) and never over a cap: 540px (twice the drawn size: past it a one-row page is mostly background and the text, which stops growing at 1.45x, looks lost), and the height at which a row's narrowest panel would get a scene stage under 280 units wide (the mission's exclamation mark ran off the panel below that, which is where a span-2 panel turns into a portrait). What a capped row cannot take goes to the rows that can, mostly the final panel's full-width row; its scene keeps a stage wide enough (440 units) to stand beside its words. When the final panel's in-flow words make its row taller, the other rows give that height up, so the page still ends at the bottom. A capped page leaves blank paper, never a band of background: the sheet reaches the visible bottom. The values are plain px written from JS (each panel's --story-panel-min, the page's grid-template-rows and --story-row-h, which also feeds grid-auto-rows, so there is one 270 default), and --story-panel-min is registered as a <length>: _storySizePanel and _storyFitCaption read it with parseFloat, which a raw calc() would have turned into NaN. The comic text scales with --story-k (the shortest row over 270, at most 1.45), set on the page; the you/notice balloons sit below the caption by the same factor. It is recomputed by the existing ResizeObserver, which now also watches the main area (window, drawer padding, a bar added later) and the sheet's width (the Director's cut toggle), once per delivery, and on every render. It reads boxes that its writes cannot change, and the main area keeps its scrollbar gutter, so it never oscillates; panels still resize through _storySizePanel, whose draws the frame loop rations. Main's bottom padding is 16px. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ed6c89ff98
commit
53f18526db
3 files changed
+354
-26
No files matched your search
@@ -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: '<length>';
|
||||
inherits: true;
|
||||
initial-value: 270px;
|
||||
}
|
||||
@property --story-k {
|
||||
syntax: '<number>';
|
||||
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;
|
||||
@@ -268,14 +284,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;
|
||||
@@ -290,35 +308,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);
|
||||
@@ -378,7 +408,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 {
|
||||
@@ -386,7 +416,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;
|
||||
@@ -404,7 +434,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;
|
||||
}
|
||||
@@ -463,7 +493,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 {
|
||||
@@ -485,7 +516,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;
|
||||
@@ -577,7 +608,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;
|
||||
@@ -596,7 +627,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);
|
||||
@@ -690,13 +721,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);
|
||||
@@ -707,7 +742,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 {
|
||||
|
||||
@@ -1567,6 +1567,108 @@ function storyLayoutSpans(prefs, cols) {
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
@@ -2006,7 +2108,21 @@ 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.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 height: once per delivery, before the
|
||||
// panels re-size to it. The sheet's height follows the rows, so a height alone changes nothing.
|
||||
let relayout = 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;
|
||||
}
|
||||
}
|
||||
if (relayout) 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) {
|
||||
@@ -2018,6 +2134,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
});
|
||||
if (bar) st.ro.observe(bar);
|
||||
st.ro.observe(st.mainEl);
|
||||
if (sheet) st.ro.observe(sheet);
|
||||
st.io = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const rt = st.panels.find((p) => p.el === entry.target);
|
||||
@@ -2331,6 +2449,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;
|
||||
@@ -2339,8 +2465,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);
|
||||
});
|
||||
@@ -2364,6 +2488,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);
|
||||
@@ -2641,14 +2767,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));
|
||||
@@ -2656,6 +2786,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.
|
||||
|
||||
@@ -1299,4 +1299,98 @@ 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: '<length>';/);
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user