@quario/landing 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,15 +1,73 @@
1
- # Changelog
1
+ # @quario/landing
2
2
 
3
- All notable changes to @quario/landing are documented in this file.
3
+ ## 0.2.1
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ### Patch Changes
7
6
 
8
- ## [Unreleased]
7
+ - Updated dependencies
8
+ - @quario/layout@0.6.0
9
9
 
10
- ## [0.1.0] - 2026-09-07
10
+ ## 0.2.0
11
11
 
12
- ### Added
12
+ ### Minor Changes
13
+
14
+ - **The sheet carries the extent; only the reach carries elements.** `sheet()`
15
+ gave every page of a display list a sized canvas, so a scale change
16
+ invalidated one box per page and the browser laid all of them out again — a
17
+ cost linear in the length of the document, around 10 microseconds a page, so
18
+ a five-thousand-page report paid roughly 50 ms on every zoom step. The sheet
19
+ element is now given its own height and width inline, from the paper the
20
+ list carries, and paints the page silhouettes as a repeating gradient at the
21
+ page pitch; the pages in the reach are positioned absolutely at the offset
22
+ `topOf()` reports, and a page leaving the reach is removed rather than
23
+ emptied. Sizing is now O(reach).
24
+
25
+ The extent a reader scrolls through is unchanged, and is still whole and
26
+ synchronous — more so, since it no longer waits on the browser laying out one
27
+ box per page to discover it.
28
+
29
+ - **`pageAt(i)` answers `null` for a page outside the reach**, which has no
30
+ element and is not given one by being asked for. `count()` still answers the
31
+ document's length rather than the sheet's child count.
32
+ - **`sheet()` places its own pages.** It writes `position: relative` on the
33
+ sheet element and `position: absolute` on every page canvas, alongside the
34
+ offsets and sizes it was already writing. A caller's own stylesheet no longer
35
+ stacks the pages, and must not: everything that places a page is decided in
36
+ one place now, and what a surface's CSS keeps is the look of one.
37
+ - **`sheet().at(clientX, clientY)`** — which page a client point is over, and
38
+ where it lands in that page's own points, or `null` for a point on no page:
39
+ the gap between two pages, above the first, below the last, or left or right
40
+ of the sheet. The inverse of `topOf()`, and answered by the same pitch the
41
+ sheet places its pages at — one division, and one read of the sheet's own
42
+ box rather than one per page. It answers for any page of the
43
+ list, with an element or without one — the reach decides what is painted,
44
+ not what is there — so a surface asking it during a fast scroll gets the
45
+ page the reader is looking at rather than nothing until the reach catches
46
+ up.
47
+
48
+ ### Patch Changes
49
+
50
+ - **Finding the reach no longer walks the document.** Every page of a report is
51
+ drawn on the same paper, so the pages down a sheet sit one pitch apart and the
52
+ two ends of the reach are a division rather than a pass over every page. A
53
+ scroll frame spent 24 microseconds choosing the reach at five thousand pages
54
+ and now spends one, whatever the document's length. `topOf(i)` answers where
55
+ page `i` sits, computed from that pitch.
56
+ - **Every published README says where the documentation is.** Each package now
57
+ carries a Documentation section pointing at the reference, at the report schema
58
+ that normatively specifies what a report may declare, and at the package's own
59
+ API. The paragraphs that used to end on an unstated contract — the event
60
+ stream's field semantics, the style vocabulary, page columns, the Content
61
+ Security Policy a fragment with images needs, the formula mangling, and each
62
+ target's own contract — link the page that states it. Every link is an absolute
63
+ URL, so it resolves from the npm package page as readily as from an installed
64
+ copy.
65
+ - Updated dependencies
66
+ - @quario/layout@0.5.0
67
+
68
+ ## 0.1.0
69
+
70
+ ### Minor Changes
13
71
 
14
72
  - **First release.** `options()` is the option check two surface elements run
15
73
  per update cycle — first-failure-wins, re-validate-everything, and a commit
@@ -19,12 +77,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
77
  counter that asks for a fresh one. `failures()` is which failure a panel is
20
78
  showing and whether the reader put it away, with the announce policy taken
21
79
  as an argument rather than settled.
22
-
23
80
  - **`failures().reopen()`** — the dismissal lapses without announcing
24
81
  anything. A dismissal is about a subject, so when that changes underneath
25
82
  it, a standing failure is worth drawing again. Distinct from `clear()`,
26
83
  which is a render landing and takes the failure with it.
27
-
28
84
  - **`sheet()`** — the paged sheet both surfaces paint on: one canvas per page
29
85
  of a display list, and the reach among them carrying pixels. Every page
30
86
  keeps its CSS size, so the extent a reader scrolls through is whole whatever
package/README.md CHANGED
@@ -6,7 +6,7 @@ render boundary across their lifetime, and both draw a panel when something fail
6
6
  that which are easy to get subtly wrong — and invisible when they are — live here once.
7
7
 
8
8
  You almost certainly do not install this package. The two surfaces depend on it and run it for
9
- you; it holds no report vocabulary and renders nothing.
9
+ you. It holds no report vocabulary and renders nothing.
10
10
 
11
11
  ## Install
12
12
 
@@ -15,8 +15,8 @@ npm install @quario/landing
15
15
  ```
16
16
 
17
17
  One runtime dependency, `@quario/layout`, which the paged sheet paints through. It implements
18
- Lit's `ReactiveController` protocol, which is structural, so nothing is imported from `lit` at run
19
- time and this adds none of it to your tree.
18
+ Lit's `ReactiveController` protocol, which is structural, so this package imports nothing from
19
+ `lit` at run time and adds none of it to your tree.
20
20
 
21
21
  ESM-only, Node 22+, and browser-ready through a standards-based bundler. CSP-safe: no
22
22
  string-to-code paths anywhere.
@@ -24,25 +24,39 @@ string-to-code paths anywhere.
24
24
  ## What is here
25
25
 
26
26
  **`options()`** — the option check across one update cycle. A host writes several properties at
27
- once and gets one render, so the checks have to agree that one bad property does not block a good
28
- write to another, that a property still broken keeps the element failing rather than being cleared
29
- by an unrelated write, and that writing one property does not clobber what another committed. The
30
- checks themselves are each surface's own; only that agreement is here.
31
-
32
- **`Landing`** — the render boundary: whether a render has ever painted the sheet, whether work was
33
- abandoned mid-flight, and the counter that asks for a fresh one. A disconnected element abandons
34
- work rather than hiding it, and comes back owing a render.
35
-
36
- **`sheet()`** the paged sheet both surfaces paint on: one canvas per page of a display list,
37
- and the reach among them carrying pixels. Every page keeps its CSS size, so the extent a reader
38
- scrolls through is whole whatever is painted; what comes and goes is the backing store. It takes a
39
- `scale()` thunk and the two elements, so scale policy and styling stay with each surface.
40
-
41
- **`failures()`** which failure a panel is showing and whether the reader put it away. Whether a
42
- repeat announces again is a policy the caller passes in, never a rule settled here: it is
27
+ once and gets one render, so the checks have to agree that:
28
+
29
+ - one bad property does not block a good write to another,
30
+ - a property still broken keeps the element failing, rather than an unrelated write clearing it,
31
+ - writing one property does not clobber what another committed.
32
+
33
+ The checks themselves are each surface's own. Only that agreement is here.
34
+
35
+ **`Landing`** — the render boundary: whether a render has ever painted the sheet, whether the
36
+ element abandoned work mid-flight, and the counter that asks for a fresh one. A disconnected
37
+ element abandons work rather than hiding it, and comes back owing a render.
38
+
39
+ **`sheet()`** the paged sheet both surfaces paint on: the extent of a display list, and the
40
+ reach within it carrying elements and pixels. The sheet element takes its own height and width
41
+ from the list's paper and paints the page silhouettes itself, so the extent a reader scrolls
42
+ through is whole whatever is standing. Only the pages in the reach have a canvas, positioned
43
+ absolutely at the offset `topOf()` reports. A list has one paper, so the pages are a pitch apart:
44
+ a position is a multiple of it and the reach's two ends are a division by it. `sheet()` takes a
45
+ `scale()` thunk and the two elements, so scale policy stays with each surface, and so does the
46
+ look of a page — the paper, the shadow, the margins. Everything that places a page is `sheet()`'s
47
+ and needs no stylesheet from the caller.
48
+
49
+ **`failures()`** — which failure a panel carries and whether the reader dismissed it. Whether
50
+ a repeat announces again is a policy the caller passes in, never a rule settled here. It is
43
51
  downstream of what re-runs the render, which is the one thing the two surfaces do not share.
44
52
 
45
53
  ## What is not here
46
54
 
47
55
  Each surface's own property checks, its panel markup, and its public API. The two elements differ
48
- in their vocabulary and in their policy; only the machinery underneath is one thing.
56
+ in their vocabulary and in their policy. Only the machinery underneath is one thing.
57
+
58
+ ## Documentation
59
+
60
+ [The quario documentation](https://getquario.com/docs/) is the reference.
61
+ The [report schema](https://getquario.com/docs/reference/report-schema/) is the normative
62
+ specification of what a report may declare.
package/lib/index.d.ts CHANGED
@@ -82,14 +82,19 @@ export class Landing implements ReactiveController {
82
82
 
83
83
  /** The gutter around the sheet, and the gap between pages, in CSS pixels at
84
84
  * any scale: both are chrome, so neither scales with the page. A surface reads
85
- * them rather than restating them — the reach walk is measured in them, and a
85
+ * them rather than restating them — the reach is measured in them, and a
86
86
  * sheet whose CSS disagreed would paint the wrong pages. */
87
87
  export const GUTTER: number;
88
88
  export const GAP: number;
89
89
 
90
90
  /**
91
- * A paged sheet on `scroll`, painting into `sheetEl`: one canvas per page of a
92
- * display list, and the reach among them carrying pixels.
91
+ * A paged sheet on `scroll`, painting into `sheetEl`: the extent of a display
92
+ * list, and the reach within it carrying elements and pixels.
93
+ *
94
+ * `sheetEl` is given the extent inline — `position: relative`, its height, its
95
+ * width and the page silhouettes — and each canvas of the reach its own
96
+ * `position: absolute` and offset within it, so a caller owes it no stylesheet
97
+ * (ADR 0065). What a surface's CSS keeps is the look of a page.
93
98
  *
94
99
  * `scale` is asked afresh every time — CSS pixels per point — so scale policy
95
100
  * stays with the surface, and the elements are the surface's too, because
@@ -101,19 +106,31 @@ export function sheet(
101
106
  scale: () => number,
102
107
  pageClass?: string,
103
108
  ): {
104
- /** One canvas per page, sized, with the reach painted. Never rejects: a
105
- * page the browser will not draw is blank, not a failure. */
109
+ /** The extent whole at once, with the reach's pages painted. Never rejects:
110
+ * a page the browser will not draw is blank, not a failure. */
106
111
  swap(list: unknown, fonts: unknown): Promise<void>;
107
- /** Give every page its CSS size at the current scale. */
112
+ /** Give the sheet its extent and the reach its sizes at the current scale. */
108
113
  sizeAll(): void;
109
114
  /** Repaint the reach, releasing what it has left behind. */
110
115
  repaint(): Promise<void>;
111
116
  /** One reach pass a frame, however many scrolls or resizes ask. */
112
117
  follow(): void;
113
- /** Where each page starts down the sheet, in CSS pixels. */
114
- tops(): number[];
118
+ /** Where page `i` starts down the sheet, in CSS pixels — the very offset the
119
+ * sheet places that page at, so an overlay can agree with it. `0` before a
120
+ * list arrives. */
121
+ topOf(i: number): number;
115
122
  /** The page width the list carries, or `null` before one arrives. */
116
123
  width(): number | null;
117
- pageAt(i: number): HTMLCanvasElement;
124
+ /** The canvas standing for page `i`, or `null` for a page outside the reach
125
+ * — which has no element, and is not minted one by being asked for. */
126
+ pageAt(i: number): HTMLCanvasElement | null;
127
+ /** Which page a client point is over, and where it lands in that page's own
128
+ * points — the inverse of `topOf()`. Answers for any page of the list, with
129
+ * an element or without one, since the list carries a page's geometry
130
+ * whether or not the reach has minted a canvas for it. `null` for a point on
131
+ * no page: the gap between two, above the first, below the last, or left or
132
+ * right of the sheet. */
133
+ at(clientX: number, clientY: number): { index: number; x: number; y: number } | null;
134
+ /** The document's length, which is not the sheet's child count. */
118
135
  count(): number;
119
136
  };
package/lib/landing.js CHANGED
@@ -63,7 +63,7 @@ export class Landing {
63
63
  *
64
64
  * Called by Lit through the controller protocol, never by name here, which
65
65
  * is why the dead-code scan cannot see the caller. */
66
- // fallow-ignore-next-line unused-class-member
66
+ // fallow-ignore-next-line unused-class-member -- Lit calls it through the controller protocol, so no caller is in the graph
67
67
  hostConnected() {
68
68
  this.wake();
69
69
  }
@@ -72,7 +72,7 @@ export class Landing {
72
72
  * only the work in flight stops. Each surface's own teardown — an observer,
73
73
  * a key binding — stays in its `disconnectedCallback`. Called by Lit, as
74
74
  * `hostConnected` is. */
75
- // fallow-ignore-next-line unused-class-member
75
+ // fallow-ignore-next-line unused-class-member -- Lit calls it through the controller protocol, so no caller is in the graph
76
76
  hostDisconnected() {
77
77
  this.#task.abort();
78
78
  }
package/lib/sheet.js CHANGED
@@ -1,12 +1,24 @@
1
1
  /**
2
- * The paged sheet both surfaces paint on: one canvas per page of a display
3
- * list, and the [reach](../../../CONTEXT.md#reach) among them carrying pixels.
2
+ * The paged sheet both surfaces paint on: the extent of a display list, and
3
+ * the [reach](../../../CONTEXT.md#reach) within it carrying elements and
4
+ * pixels.
4
5
  *
5
- * Every page keeps its CSS size, so the extent a reader scrolls through is
6
- * whole and synchronous whatever is painted; what comes and goes is the
7
- * backing store, released by sizing a canvas to 0 x 0. ADR 0043 says what that
8
- * is worth: painting the whole sheet asked a thousand-page report for
9
- * gigabytes, and past what the browser would grant the pages came up blank.
6
+ * The sheet carries its own extent its height and width are written here
7
+ * from the list's [paper](../../../CONTEXT.md#paper), and a page canvas is
8
+ * positioned absolutely at the offset `topOf()` reports. Only the pages in the
9
+ * reach have an element at all; a page leaving it is removed, which takes its
10
+ * backing store with it. So the extent a reader scrolls through is whole and
11
+ * synchronous whatever is painted, without the browser having to lay out one
12
+ * box per page to discover it (ADR 0065). ADR 0043 says what the reach is
13
+ * worth: painting the whole sheet asked a thousand-page report for gigabytes,
14
+ * and past what the browser would grant the pages came up blank.
15
+ *
16
+ * **The page silhouettes are a paint, not elements.** A page outside the reach
17
+ * has nothing to carry white, so the sheet's own background is a repeating
18
+ * gradient at the page pitch — exact rather than approximate, because a list
19
+ * has one paper and the pitch is the same one the pages are placed at. One
20
+ * style write per scale change, and it rasters even with the main thread
21
+ * wedged.
10
22
  *
11
23
  * **A page the browser will not paint is blank, not a failure.** `start`
12
24
  * swallows and nothing here rejects: the report laid out and the list reached
@@ -16,7 +28,8 @@
16
28
  * it draws, so a canvas whose paint is still in flight is *retired* rather
17
29
  * than painted over: it comes off the sheet, its pixels go back, and a fresh
18
30
  * one stands in its place, so the superseded paint draws into an element
19
- * nobody is looking at (ADR 0046).
31
+ * nobody is looking at (ADR 0046). A page that leaves the reach mid-paint is
32
+ * dropped for the same reason, with nothing standing in.
20
33
  *
21
34
  * What this does **not** own is scale policy. It takes a `scale()` thunk and
22
35
  * asks it afresh every time: the viewer answers a zoom percentage, the editor
@@ -28,16 +41,27 @@ import { paint } from "@quario/layout";
28
41
 
29
42
  /** The gutter around the sheet, and the gap between pages, in CSS pixels at
30
43
  * any scale: both are chrome, so neither scales with the page. Here rather
31
- * than in each surface because the reach walk reads them, and a sheet whose
32
- * geometry disagreed with its own reach would paint the wrong pages. */
44
+ * than in each surface because the reach is measured in them, and a sheet
45
+ * whose geometry disagreed with its own reach would paint the wrong pages. */
33
46
  export const GUTTER = 28;
34
47
  export const GAP = 16;
35
48
 
49
+ /** The paper a page is drawn on, and what the sheet paints where a page has
50
+ * no element. Both surfaces' page rules carry the same white; this is the
51
+ * copy that shows through a canvas with no backing store, and the one a page
52
+ * outside the reach is made of. */
53
+ const PAPER = "#fff";
54
+
36
55
  /**
37
56
  * A paged sheet on `scroll`, painting into `sheetEl`.
38
57
  *
39
58
  * @param {HTMLElement} scroll The scroll container the reader looks through.
40
- * @param {HTMLElement} sheetEl What the page canvases are children of.
59
+ * @param {HTMLElement} sheetEl What the page canvases are children of. Made a
60
+ * containing block here, as its pages are made absolute here: a page's
61
+ * `top` is meaningless without those two, so they are one decision with it
62
+ * rather than an obligation on a caller who would have to be told, and
63
+ * pinned, and could still forget. What a surface's stylesheet keeps is the
64
+ * look — the paper, the shadow, the margins.
41
65
  * @param {() => number} scale CSS pixels per point, asked afresh each time.
42
66
  * @param {string} [pageClass] What to name a page element. Each surface's
43
67
  * `q*-` names are its own documented CSS surface, so the class is the
@@ -48,12 +72,16 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
48
72
  let list = null;
49
73
  /** @type {any} */
50
74
  let fonts;
51
- /** The page canvases, in order. Its own list rather than `sheetEl.children`:
52
- * a sheet may hold more than pages the editor hangs its box overlay in
53
- * the same element, positioned in the sheet's own pixels and indexing the
54
- * children would then read an overlay as a page and a swap would wipe it. */
55
- /** @type {HTMLCanvasElement[]} */
56
- let pages = [];
75
+ /**
76
+ * The page canvases the reach has, by their index in the list. Sparse, and
77
+ * its own map rather than `sheetEl.children`: a sheet may hold more than
78
+ * pages the editor hangs its box overlay in the same element and
79
+ * indexing the children would then read an overlay as a page, and could not
80
+ * say which page a canvas stood for anyway.
81
+ *
82
+ * @type {Map<number, HTMLCanvasElement>}
83
+ */
84
+ let pages = new Map();
57
85
 
58
86
  /**
59
87
  * What each backed page is carrying: the scale it was painted at, so a page
@@ -64,55 +92,143 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
64
92
  * and the pages it replaced take their entries with them — which is why the
65
93
  * map is weak: nothing has to remember to forget them.
66
94
  *
67
- * @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
95
+ * `done` is the third: whether that paint has finished. A promise's settled
96
+ * state is not synchronously observable and it is the one question the
97
+ * retirement rule turns on — a canvas whose entry is `done` has nothing left
98
+ * that could draw into it and can be re-sized in place; one still painting
99
+ * must be retired instead. It rides the entry rather than a set beside it,
100
+ * so it is per *paint* for free: `start` mints a fresh entry, and a page
101
+ * settled at one scale cannot count as settled the moment it begins painting
102
+ * at the next.
103
+ *
104
+ * @typedef {{ scale: number, painted: Promise<void>, done: boolean }} Backing
105
+ * @type {WeakMap<HTMLCanvasElement, Backing>}
68
106
  */
69
107
  let backed = new WeakMap();
70
108
 
71
109
  /**
72
- * The canvases whose paint has finished. A promise's settled state is not
73
- * synchronously observable and this is the one question the rule below
74
- * turns on: a canvas in here has nothing left that could draw into it, so
75
- * it can be emptied or re-sized in place; one that is backed but absent
76
- * here is still being painted, and must be retired instead. Weak on the
77
- * same key as `backed`, so a retired canvas takes its membership with it.
78
- *
79
- * Membership is per paint, not per canvas — `start` takes a canvas out
80
- * before painting it again, or a page settled at one scale would count as
81
- * settled the moment it began painting at the next.
82
- *
83
- * @type {WeakSet<HTMLCanvasElement>}
110
+ * The list stacked at one scale, in CSS pixels: the
111
+ * [paper](../../../CONTEXT.md#paper) every page is drawn on, the pitch from
112
+ * one page's top to the next's, and how tall the sheet has to be to hold them
113
+ * all. There is no width beside those: the sheet is one paper wide, which is
114
+ * the claim `extend` writes rather than a second number to keep in step.
115
+ *
116
+ * The **pitch** is the paper's height plus the gap — the step down the sheet,
117
+ * and the period of the silhouette gradient.
118
+ *
119
+ * @typedef {{ px: number, paper: { width: number, height: number },
120
+ * pitch: number, height: number }} Stacked
84
121
  */
85
- let settled = new WeakSet();
86
122
 
87
123
  /**
88
- * The canvas standing for page `i` right now. Read through here and never
89
- * held: a retired page is a different element, so a canvas taken before an
90
- * await may be off the sheet by the time it is used.
124
+ * The stacking at the applied scale, computed once and held until the scale
125
+ * moves or a list arrives. **The one reading of the list's paper there is**
126
+ * the reach divides by it, the pages are positioned from it, `topOf()`
127
+ * answers it and the extent is written from it, so none of those can drift
128
+ * from the others.
91
129
  *
92
- * @type {(i: number) => HTMLCanvasElement}
130
+ * @type {Stacked | null}
131
+ */
132
+ let stacking = null;
133
+
134
+ /** The stacking the sheet element's style already says, so an `extend` that
135
+ * would write what is there writes nothing.
136
+ * @type {Stacked | null} */
137
+ let extended = null;
138
+
139
+ /** The stacking itself, at `px` CSS pixels per point: every page a gap below
140
+ * the one before it, from the sheet's own top. Read off the list's own paper,
141
+ * which is one width and one height for every page of it (ADR 0069), so a
142
+ * page's offset is a multiple of the pitch rather than a sum walked up from
143
+ * the top — and the extent, the silhouettes and the placement all come off
144
+ * the same two numbers instead of agreeing by hand.
145
+ *
146
+ * @type {(px: number) => Stacked}
147
+ */
148
+ let stack = (px) => {
149
+ let paper = { width: list.width * px, height: list.height * px };
150
+ let pitch = paper.height + GAP;
151
+ // The trailing gap belongs to no page: the extent ends at the last page's
152
+ // own bottom, which is where the silhouette gradient's last band ends too.
153
+ return { px, paper, pitch, height: Math.max(0, list.pages.length * pitch - GAP) };
154
+ };
155
+
156
+ /** @type {() => Stacked} */
157
+ let measure = () => {
158
+ let px = scale();
159
+ return stacking?.px === px ? stacking : (stacking = stack(px));
160
+ };
161
+
162
+ /**
163
+ * The page silhouettes, as one background over the sheet's own height. This
164
+ * paints what a page element used to, for the pages that no longer have one
165
+ * (ADR 0065).
166
+ *
167
+ * A band of paper and a gap of nothing, repeated at the pitch — which is the
168
+ * stacking's own pitch, not a second answer to it. This used to presume the
169
+ * pitch it painted, reading the first page's height as every page's because
170
+ * a gradient can only have one; the list states its paper now, so the
171
+ * gradient asks for what the pages are placed by (ADR 0069). No guard for a
172
+ * pageless list either: the band flow opens the body's first page whether or
173
+ * not anything renders on it, and a gradient over a sheet no pages tall
174
+ * paints nothing regardless.
175
+ *
176
+ * @type {(at: Stacked) => string}
177
+ */
178
+ let silhouettes = (at) =>
179
+ `repeating-linear-gradient(${PAPER} 0 ${at.paper.height}px, ` +
180
+ `transparent ${at.paper.height}px ${at.pitch}px)`;
181
+
182
+ /**
183
+ * Give the sheet the extent of the whole list, and the silhouettes of the
184
+ * pages it is not showing. Three style writes, whatever the page count: this
185
+ * is what makes the extent whole without an element per page, and callers
186
+ * run it before writing scroll offsets back, because the extent those are
187
+ * clamped against is this one.
93
188
  */
94
- let pageAt = (i) => pages[i];
189
+ let extend = () => {
190
+ let at = measure();
191
+ // Reference, not value: `measure` hands back the very same object until
192
+ // the scale moves, so an unchanged stacking costs one compare rather than
193
+ // three style writes — and a background write invalidates the paint of an
194
+ // extent that can be hundreds of thousands of pixels tall. The editor's
195
+ // scale never moves, so every swap after the first takes this branch.
196
+ if (at === extended) return;
197
+ extended = at;
198
+ sheetEl.style.position = "relative";
199
+ // One paper wide, and as tall as the pages stacked: the sheet has no width
200
+ // of its own, so this is where that is said rather than a field carrying it.
201
+ sheetEl.style.width = at.paper.width + "px";
202
+ sheetEl.style.height = at.height + "px";
203
+ sheetEl.style.background = silhouettes(at);
204
+ };
95
205
 
96
206
  /**
97
- * Give a page's canvas its size on screen. The one answer to how big page
98
- * `i` is at the applied scale: `pageOf` builds a canvas with it and
99
- * `sizeAll` writes it over the sheet after a scale change, so a page
100
- * retired between the two cannot arrive sizeless and move the extent the
101
- * reader is scrolling through.
207
+ * Give a page's canvas its size and its place on the sheet. The one answer
208
+ * to where page `i` sits at the applied scale: `pageOf` builds a canvas with
209
+ * it and `sizeAll` writes it over the reach after a scale change, so a page
210
+ * retired between the two cannot arrive sizeless or off its own offset.
102
211
  *
103
- * @type {(canvas: HTMLElement, i: number, px?: number) => void}
212
+ * @type {(canvas: HTMLElement, i: number) => void}
104
213
  */
105
- let sizePage = (canvas, i, px = scale()) => {
106
- let each = list.pages[i];
107
- canvas.style.width = each.width * px + "px";
108
- canvas.style.height = each.height * px + "px";
214
+ let place = (canvas, i) => {
215
+ let at = measure();
216
+ canvas.style.position = "absolute";
217
+ // `left` written rather than left to the static position, which would be
218
+ // the sheet's content edge and so would move with a padding neither
219
+ // surface sets today. `at()` below measures from the padding edge, so the
220
+ // two agree by construction instead of by that absence.
221
+ canvas.style.left = "0px";
222
+ canvas.style.top = i * at.pitch + "px";
223
+ canvas.style.width = at.paper.width + "px";
224
+ canvas.style.height = at.paper.height + "px";
109
225
  };
110
226
 
111
227
  /**
112
- * One page of the list as a canvas: sized on screen, carrying no pixels
113
- * yet, and named for a screen reader. Every canvas on the sheet is built
114
- * here a swap's and a retirement's alike — so a replacement is the same
115
- * element in every respect but identity.
228
+ * One page of the list as a canvas: sized, placed, carrying no pixels yet,
229
+ * and named for a screen reader. Every canvas on the sheet is built here —
230
+ * a page entering the reach and a retirement alike — so a replacement is the
231
+ * same element in every respect but identity.
116
232
  *
117
233
  * @type {(i: number) => HTMLCanvasElement}
118
234
  */
@@ -125,17 +241,94 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
125
241
  canvas.height = 0;
126
242
  canvas.setAttribute("role", "img");
127
243
  canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
128
- sizePage(canvas, i);
244
+ place(canvas, i);
129
245
  return canvas;
130
246
  };
131
247
 
248
+ /**
249
+ * The canvas standing for page `i` right now, or `null` for a page outside
250
+ * the reach — which has no element, and is not minted one by being asked
251
+ * for: that would make a read a mutation, and would have `keep()`
252
+ * materialise every page it releases. Read through here and never held: a
253
+ * retired or released page is gone, so a canvas taken before an await may be
254
+ * off the sheet by the time it is used.
255
+ *
256
+ * @type {(i: number) => HTMLCanvasElement | null}
257
+ */
258
+ let pageAt = (i) => pages.get(i) ?? null;
259
+
260
+ /**
261
+ * Which page a client point is over, and where it lands in that page's own
262
+ * points — or `null` for a point on no page at all.
263
+ *
264
+ * **The inverse of `topOf()`, and here because `topOf()` is here.** The
265
+ * stacking is one arithmetic read two ways: `topOf()` answers where a page
266
+ * starts, this answers which page a distance is inside. A caller computing
267
+ * it from `topOf()` would be a second place that knows how pages stack,
268
+ * which is the drift ADR 0065 bought out by making the offset a fact rather
269
+ * than a presumption.
270
+ *
271
+ * **One division, and one layout read.** The pages step by a fixed pitch, so
272
+ * the page a distance falls in is that distance over the pitch — the same
273
+ * arithmetic `inReach()` bounds the reach with, in the other direction, and
274
+ * flat in the length of the document. The sheet's own box is read afresh
275
+ * each call, because a point is a client coordinate and nothing else can
276
+ * turn it into a sheet one. The editor's hit test walked every page to find
277
+ * the handful with an element and read a rect off the one it landed in; this
278
+ * replaces that walk, not that read.
279
+ *
280
+ * **It answers for a page that has no element.** The reach decides what is
281
+ * painted, not what is there: a point over a page the reach has not minted
282
+ * still lands on that page, because the stacking is the list's geometry and
283
+ * not the elements'. So a fast scroll or a resize cannot make a point over a
284
+ * page the reader is looking at answer "nowhere" for the frame before the
285
+ * reach catches up.
286
+ *
287
+ * Null in four places, all of them genuinely no page: the gap between two
288
+ * pages, above the first, below the last, and left or right of the sheet.
289
+ *
290
+ * @type {(clientX: number, clientY: number) => { index: number, x: number, y: number } | null}
291
+ */
292
+ let at = (clientX, clientY) => {
293
+ if (!list?.pages.length) return null;
294
+ let stacked = measure();
295
+ let box = sheetEl.getBoundingClientRect();
296
+ // The padding box, not the border box the rect describes: a page is placed
297
+ // `left: 0` inside this element and resolves against its padding edge, as
298
+ // does an overlay a surface hangs there with `inset: 0`. `clientLeft` and
299
+ // `clientTop` are that difference — the border — and are read off the very
300
+ // element whose rect was just read, so they cost no second flush.
301
+ let x = clientX - box.left - sheetEl.clientLeft;
302
+ let y = clientY - box.top - sheetEl.clientTop;
303
+ // The page whose pitch band `y` falls in. Negative above the first page,
304
+ // past the end below the last: each runs off its own end of the list.
305
+ let i = Math.floor(y / stacked.pitch);
306
+ return i < 0 || i >= list.pages.length ? null : onPage(i, x, y, stacked);
307
+ };
308
+
309
+ /** Page `i`'s own answer for a sheet-space point already known to be in its
310
+ * pitch band: the point in that page's points, or `null` past its paper —
311
+ * the gap under it, or the sheet's end — or beside it. The paper's own edges
312
+ * count as inside, which is what the rect test this replaced said with `<=`.
313
+ *
314
+ * Answers the point rather than a yes, so one page's origin is read in one
315
+ * place instead of being tested here and subtracted in the caller.
316
+ *
317
+ * @type {(i: number, x: number, y: number, stacked: Stacked) => { index: number, x: number, y: number } | null} */
318
+ let onPage = (i, x, y, stacked) => {
319
+ let top = i * stacked.pitch;
320
+ if (y > top + stacked.paper.height) return null;
321
+ if (x < 0 || x > stacked.paper.width) return null;
322
+ return { index: i, x: x / stacked.px, y: (y - top) / stacked.px };
323
+ };
324
+
132
325
  /**
133
326
  * Take page `i`'s canvas off the sheet and stand a fresh one in its place,
134
327
  * answering with the replacement. A paint still in flight holds the old
135
328
  * canvas's context and draws into something nobody is looking at, which is
136
329
  * how a superseded paint is stopped here — by construction, rather than by
137
330
  * a check the painter would have to make above its own draw (ADR 0046).
138
- * The retired canvas takes its entries in `backed` and `settled` with it.
331
+ * The retired canvas takes its entry in `backed` with it, being weak.
139
332
  *
140
333
  * Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
141
334
  * store is what ADR 0043 rations, and leaving it to be collected whenever
@@ -147,33 +340,42 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
147
340
  * @type {(i: number) => HTMLCanvasElement}
148
341
  */
149
342
  let retire = (i) => {
150
- let old = pageAt(i);
343
+ let old = /** @type {HTMLCanvasElement} */ (pageAt(i));
151
344
  let fresh = pageOf(i);
152
345
  old.replaceWith(fresh);
153
- pages[i] = fresh;
346
+ pages.set(i, fresh);
154
347
  old.width = 0;
155
348
  old.height = 0;
156
349
  return fresh;
157
350
  };
158
351
 
159
352
  /**
160
- * Give page `i`'s pixels back. A page whose paint has settled is emptied in
161
- * place: sizing the canvas to 0 × 0 is the one idiom that frees the store
162
- * synchronously in every engine the viewer runs in, and the CSS size is
163
- * untouched, so the page keeps its place in the extent and shows the
164
- * sheet's white. A page still painting is retired instead emptying it
165
- * would leave that paint pointed at a canvas the next pass over the reach
166
- * re-sizes and re-paints.
353
+ * Let page `i` go. It leaves the sheet entirely the element and its
354
+ * backing store together, since a detached canvas takes its store with it
355
+ * and the extent does not move, because the extent is the sheet's own.
356
+ *
357
+ * Removal is what makes this safe whether the page's paint has settled or
358
+ * not: a paint still in flight is left drawing into a detached element, the
359
+ * same outcome retirement arranges, so there is nothing here to stand in
360
+ * its place for. Sized to 0 × 0 rather than left to be collected when that
361
+ * paint lets go of the context, which is the timing ADR 0043 refuses.
167
362
  *
168
363
  * @type {(i: number) => void}
169
364
  */
170
365
  let release = (i) => {
171
366
  let canvas = pageAt(i);
172
- if (!backed.has(canvas)) return;
173
- if (!settled.has(canvas)) return void retire(i);
174
- backed.delete(canvas);
175
- canvas.width = 0;
176
- canvas.height = 0;
367
+ if (!canvas) return;
368
+ pages.delete(i);
369
+ canvas.remove();
370
+ // Only where there is a store to give back: assigning a canvas's width
371
+ // resets its bitmap whatever the value, so an untouched page would pay for
372
+ // a reset it has nothing to reset. `backed` is weak and the canvas is now
373
+ // unreachable, so its entry needs no forgetting — the same reason `retire`
374
+ // does not forget one either.
375
+ if (canvas.width) {
376
+ canvas.width = 0;
377
+ canvas.height = 0;
378
+ }
177
379
  };
178
380
 
179
381
  /** Device pixels per point at the applied scale: what a page is
@@ -187,7 +389,7 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
187
389
  * at it, so no caller can reach a live canvas with a second paint by
188
390
  * forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
189
391
  * the next pass over the reach tries again instead of counting the page as
190
- * painted; one that settles joins `settled`.
392
+ * painted; one that settles marks its own entry `done`.
191
393
  *
192
394
  * **The paint this answers with never rejects**, which is the whole of why
193
395
  * `swap` does not either. A page that could not be drawn is not a render
@@ -206,23 +408,33 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
206
408
  * @type {(i: number) => Promise<void>}
207
409
  */
208
410
  let start = (i) => {
209
- let canvas = pageAt(i);
210
- if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
211
- settled.delete(canvas);
411
+ let canvas = /** @type {HTMLCanvasElement} */ (pageAt(i));
412
+ let carrying = backed.get(canvas);
413
+ if (carrying && !carrying.done) canvas = retire(i);
212
414
  let scale = deviceScale();
213
- let each = list.pages[i];
214
- canvas.width = Math.round(each.width * scale);
215
- canvas.height = Math.round(each.height * scale);
415
+ // The store is the paper in device pixels, rounded to a whole one. Every
416
+ // page of the list is the same size, so this is the list's paper and not
417
+ // the page's — a page carries no geometry to ask (ADR 0069).
418
+ canvas.width = Math.round(list.width * scale);
419
+ canvas.height = Math.round(list.height * scale);
216
420
  let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
217
- let painted = paint(ctx, each, { scale, fonts }).then(
421
+ // The handlers mark the entry they belong to rather than the canvas: a
422
+ // second `start` on the same canvas replaces the entry, so the superseded
423
+ // paint marks one nobody reads instead of declaring the new paint settled.
424
+ // Assigned after the promise and read only from a handler, which cannot
425
+ // run before this turn ends.
426
+ /** @type {Backing} */
427
+ let entry;
428
+ let painted = paint(ctx, list.pages[i], { scale, fonts }).then(
218
429
  () => {
219
- settled.add(canvas);
430
+ entry.done = true;
220
431
  },
221
432
  () => {
222
433
  backed.delete(canvas);
223
434
  },
224
435
  );
225
- backed.set(canvas, { scale, painted });
436
+ entry = { scale, painted, done: false };
437
+ backed.set(canvas, entry);
226
438
  return painted;
227
439
  };
228
440
 
@@ -235,12 +447,16 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
235
447
  *
236
448
  * Takes the index, not the canvas: the page it paints may be retired out
237
449
  * from under a caller, so a caller that handed one in would be left holding
238
- * an element that is no longer on the sheet.
450
+ * an element that is no longer on the sheet. A page released between the
451
+ * pass that chose it and the paint that follows has nothing to paint onto,
452
+ * and is skipped.
239
453
  *
240
454
  * @type {(i: number) => Promise<void>}
241
455
  */
242
456
  let paintPage = (i) => {
243
- let carrying = backed.get(pageAt(i));
457
+ let canvas = pageAt(i);
458
+ if (!canvas) return Promise.resolve();
459
+ let carrying = backed.get(canvas);
244
460
  if (carrying && carrying.scale === deviceScale()) return carrying.painted;
245
461
  return start(i);
246
462
  };
@@ -255,54 +471,65 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
255
471
  *
256
472
  * It guards repaint against repaint, and nothing else. A repaint chooses
257
473
  * its pages once and holds that list across its awaits, so a scroll pass
258
- * releasing a page mid-repaint is one this loop will paint anyway; the
259
- * store that leaves behind is bounded by the reach and goes back on the
260
- * next pass over it.
474
+ * releasing a page mid-repaint is one this loop skips when it gets there;
475
+ * the store that leaves behind is bounded by the reach.
261
476
  */
262
477
  let epoch = 0;
263
478
 
264
479
  /**
265
- * Give every page its CSS size. Callers run this before writing the scroll
266
- * offsets back: the extent those offsets are clamped against is this one,
267
- * and the reach below is read from the offsets once they are in.
480
+ * Give the sheet its extent and the reach its sizes, at the current scale.
481
+ * Callers run this before writing the scroll offsets back: the extent those
482
+ * offsets are clamped against is this one, and the reach below is read from
483
+ * the offsets once they are in. O(reach), not O(pages) — which is what ADR
484
+ * 0065 is for.
268
485
  */
269
486
  let sizeAll = () => {
270
487
  if (!list) return;
271
- let px = scale();
272
- for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
488
+ extend();
489
+ for (let [i, canvas] of pages) place(canvas, i);
273
490
  };
274
491
 
275
492
  /**
276
493
  * The indices of the pages in the reach — the ones the viewport shows, and
277
- * everything within one viewport height above or below. Walked over the
278
- * list's own geometry (the gutter, each page's height at the applied
279
- * scale, the gap), never a layout read, so it costs nothing to ask
280
- * mid-scroll the whole list is walked because a multiply and an add per
281
- * page is beneath measuring, and stopping early would be a second rule
282
- * about where the reach ends. `clientHeight` is read afresh each time, so a
283
- * pane that changed size changes the reach with it.
494
+ * everything within one viewport height above or below. Read off the pitch
495
+ * `measure` holds, never a layout read, so it costs nothing to ask
496
+ * mid-scroll. `clientHeight` is read afresh each time, so a pane that
497
+ * changed size changes the reach with it.
498
+ *
499
+ * **Two divisions rather than a search, because the pages are a pitch
500
+ * apart.** Page `i` starts at `i * pitch` and ends a paper below that, so
501
+ * both bounds of the band invert straight into an index — no walk over the
502
+ * pages, and no search among them either. This was a bisection first (ADR
503
+ * 0065's amendment); what made it arithmetic is the list stating its paper
504
+ * once, so the stacking is a pitch rather than an array of offsets to look
505
+ * through (ADR 0069).
284
506
  *
285
507
  * @returns {number[]}
286
508
  */
287
509
  let inReach = () => {
288
510
  let view = scroll.clientHeight;
289
- let top = scroll.scrollTop - view;
290
- let bottom = scroll.scrollTop + 2 * view;
291
- let px = scale();
292
- let y = GUTTER;
511
+ let at = measure();
512
+ // Sheet-space: the sheet starts a gutter down what the reader scrolls, so
513
+ // the band is measured from the first page's own top rather than the scroll
514
+ // container's. Converted once, here.
515
+ let eye = scroll.scrollTop - GUTTER;
516
+ // The near bound is the first page whose foot the band has reached — its
517
+ // foot being a paper below its own top — and the far one the first page
518
+ // past the band's head. Each can only run off its own end of the list: a
519
+ // band above the sheet gives a negative near bound, one below it a far
520
+ // bound past the last page.
521
+ let first = Math.max(0, Math.ceil((eye - view - at.paper.height) / at.pitch));
522
+ let after = Math.min(list.pages.length, Math.floor((eye + 2 * view) / at.pitch) + 1);
293
523
  let found = [];
294
- for (let [i, each] of list.pages.entries()) {
295
- let height = each.height * px;
296
- if (y + height >= top && y <= bottom) found.push(i);
297
- y += height + GAP;
298
- }
524
+ for (let i = first; i < after; i++) found.push(i);
299
525
  return found;
300
526
  };
301
527
 
302
528
  /**
303
- * Release every page the reach has left behind, and answer with the indices
304
- * of the ones to keep, in the order they are painted in. The single
305
- * definition of which pages carry pixels: the repaint below and the scroll
529
+ * Bring the sheet's elements to the reach minting the pages that entered
530
+ * it, letting go of the ones that left and answer with the indices in the
531
+ * order they are painted in. The single definition of which pages carry
532
+ * pixels, and now of which carry elements: the repaint below and the scroll
306
533
  * pass both go through here, and neither holds a page the other released.
307
534
  *
308
535
  * Indices rather than elements, for the reason `paintPage` takes one: a
@@ -311,16 +538,37 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
311
538
  * @type {() => number[]}
312
539
  */
313
540
  let keep = () => {
314
- let wanted = new Set(inReach());
315
- let kept = [];
316
- // `retire` replaces one for one, so the count is fixed across the walk.
317
- for (let i = 0, total = pages.length; i < total; i++) {
318
- if (wanted.has(i)) kept.push(i);
319
- else release(i);
320
- }
541
+ let kept = inReach();
542
+ let wanted = new Set(kept);
543
+ // A `Map` iterator tolerates the entry it is visiting being deleted, which
544
+ // is the only one `release` takes, so this walks the live map.
545
+ for (let i of pages.keys()) if (!wanted.has(i)) release(i);
546
+ mint(kept);
321
547
  return kept;
322
548
  };
323
549
 
550
+ /**
551
+ * Give every page of the reach that has none an element. Ascending, each
552
+ * fresh page inserted after the one before it, so the sheet's page elements
553
+ * stay in the list's own order and stay one run of children — an overlay the
554
+ * caller hung in the same element keeps its place after them.
555
+ *
556
+ * @type {(kept: number[]) => void}
557
+ */
558
+ let mint = (kept) => {
559
+ let previous = null;
560
+ for (let i of kept) {
561
+ let canvas = pageAt(i);
562
+ if (!canvas) {
563
+ canvas = pageOf(i);
564
+ pages.set(i, canvas);
565
+ if (previous) previous.after(canvas);
566
+ else sheetEl.prepend(canvas);
567
+ }
568
+ previous = canvas;
569
+ }
570
+ };
571
+
324
572
  // Paint the reach in order, yielding between pages and giving way to any
325
573
  // repaint that started since. Sizing is the caller's, and comes first.
326
574
  let repaint = async () => {
@@ -353,54 +601,49 @@ export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
353
601
  scroll.addEventListener("scroll", follow);
354
602
 
355
603
  return {
356
- /** Put a list on the sheet: one canvas per page, sized, with the reach
357
- * among them painted. Settles behind the reach — never rejects. */
604
+ /** Put a list on the sheet: the extent whole at once, and the reach among
605
+ * its pages given elements and painted. Settles behind the reach — never
606
+ * rejects. */
358
607
  /** @type {(next: any, faces: any) => Promise<void>} */
359
608
  async swap(next, faces) {
360
609
  fonts = faces;
361
- for (let el of pages) el.remove();
610
+ for (let el of pages.values()) el.remove();
611
+ pages = new Map();
362
612
  list = next;
363
- pages = next.pages.map((/** @type {any} */ _, /** @type {number} */ i) => pageOf(i));
364
- // Prepended through a fragment, never replacing the sheet's children:
365
- // whatever else it holds is the caller's and stays where it was.
366
- let batch = document.createDocumentFragment();
367
- for (let el of pages) batch.append(el);
368
- sheetEl.prepend(batch);
613
+ stacking = null;
614
+ extended = null;
615
+ // Before the first page is built, so the extent is whole from the moment
616
+ // the list arrives rather than from the moment the reach has painted.
617
+ extend();
369
618
  await repaint();
370
619
  },
371
- /** Give every page its CSS size at the current scale. Callers run this
372
- * before writing scroll offsets back: the extent those are clamped
373
- * against is this one. */
620
+ /** Give the sheet its extent and the reach its sizes at the current scale.
621
+ * Callers run this before writing scroll offsets back: the extent those
622
+ * are clamped against is this one. */
374
623
  sizeAll,
375
624
  /** Repaint the reach, releasing what it has left behind. */
376
625
  repaint,
377
626
  /** One reach pass a frame, however many scrolls or resizes ask. */
378
627
  follow,
379
- /** Where each page starts down the sheet, in CSS pixels: what an overlay
380
- * positioned against the pages has to agree with.
628
+ /** Where page `i` starts down the sheet, in CSS pixels: what an overlay
629
+ * positioned against that page has to agree with. `0` before a list
630
+ * arrives, there being no sheet to sit on.
381
631
  *
382
- * Presumes the caller's sheet stacks its pages `GAP` apartboth
383
- * surfaces do it with `display: flex; gap`. The CSS is the surface's, so
384
- * this is the one number the two have to agree on by hand; a sheet that
385
- * stacked them differently would place every overlay after the first
386
- * against the wrong page. */
387
- tops() {
388
- let out = [];
389
- // Sheet-space, from the sheet's own top — not the scroll-space walk the
390
- // reach uses, which starts a gutter down. An overlay hangs inside the
391
- // sheet, so this is the origin it is positioned against.
392
- let y = 0;
393
- for (let page of list ? list.pages : []) {
394
- out.push(y);
395
- y += page.height * scale() + GAP;
396
- }
397
- return out;
398
- },
632
+ * The very offset the sheet places its own page atso the agreement an
633
+ * overlay needs is a fact rather than the presumption it used to be (ADR
634
+ * 0065). One page rather than an array of every page's, because the
635
+ * stacking is a pitch and there is no array: a caller asks for the page it
636
+ * is placing something against (ADR 0069). */
637
+ topOf: (/** @type {number} */ i) => (list ? i * measure().pitch : 0),
399
638
  /** The page width the list carries, or `null` before one arrives — what a
400
639
  * fit is measured against. */
401
640
  width: () => (list ? list.width : null),
402
- /** @type {(i: number) => HTMLCanvasElement} */
641
+ /** @type {(i: number) => HTMLCanvasElement | null} */
403
642
  pageAt,
643
+ /** Which page a client point is over, and where in that page's own points
644
+ * — the inverse of `topOf()`, answering for any page of the list whether or
645
+ * not the reach has given it an element. */
646
+ at,
404
647
  count: () => (list ? list.pages.length : 0),
405
648
  };
406
649
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quario/landing",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "What quario's two surface elements share — the option check, the render boundary and the failure state — in the makings, not yet released",
5
5
  "homepage": "https://getquario.com",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -36,13 +36,13 @@
36
36
  "test:browser": "node test/browser/setup.js"
37
37
  },
38
38
  "dependencies": {
39
- "@quario/layout": "^0.4.0"
39
+ "@quario/layout": "^0.6.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@arethetypeswrong/cli": "^0.18.3",
43
43
  "@size-limit/preset-small-lib": "^13.0.3",
44
44
  "lit": "^3.3.3",
45
- "playwright": "^1.62.1",
45
+ "playwright": "^1.63.0",
46
46
  "size-limit": "^13.0.3",
47
47
  "typescript": "^7.0.2"
48
48
  },