@quario/landing 0.1.0
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 +33 -0
- package/LICENSE +219 -0
- package/README.md +48 -0
- package/lib/failure.js +77 -0
- package/lib/index.d.ts +119 -0
- package/lib/index.js +19 -0
- package/lib/landing.js +116 -0
- package/lib/options.js +71 -0
- package/lib/sheet.js +406 -0
- package/package.json +61 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to @quario/landing are documented in this file.
|
|
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).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-09-07
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **First release.** `options()` is the option check two surface elements run
|
|
15
|
+
per update cycle — first-failure-wins, re-validate-everything, and a commit
|
|
16
|
+
gate so writing one property cannot clobber what another committed.
|
|
17
|
+
`Landing` is the render boundary across an element's lifetime: whether a
|
|
18
|
+
render has ever painted, whether work was abandoned on a disconnect, and the
|
|
19
|
+
counter that asks for a fresh one. `failures()` is which failure a panel is
|
|
20
|
+
showing and whether the reader put it away, with the announce policy taken
|
|
21
|
+
as an argument rather than settled.
|
|
22
|
+
|
|
23
|
+
- **`failures().reopen()`** — the dismissal lapses without announcing
|
|
24
|
+
anything. A dismissal is about a subject, so when that changes underneath
|
|
25
|
+
it, a standing failure is worth drawing again. Distinct from `clear()`,
|
|
26
|
+
which is a render landing and takes the failure with it.
|
|
27
|
+
|
|
28
|
+
- **`sheet()`** — the paged sheet both surfaces paint on: one canvas per page
|
|
29
|
+
of a display list, and the reach among them carrying pixels. Every page
|
|
30
|
+
keeps its CSS size, so the extent a reader scrolls through is whole whatever
|
|
31
|
+
is painted; what comes and goes is the backing store. `GUTTER` and `GAP`
|
|
32
|
+
come with it, because the reach walk is measured in them. This adds
|
|
33
|
+
`@quario/layout` as a runtime dependency, which the sheet paints through.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Quario License Agreement
|
|
2
|
+
|
|
3
|
+
> **In short:** Quario is commercial software with visible source.
|
|
4
|
+
> Evaluate it free for as long as you like. Evaluation output is
|
|
5
|
+
> watermarked. Anything else needs a per-developer license, after
|
|
6
|
+
> which deployment is unlimited and royalty-free. This summary is not
|
|
7
|
+
> the license; the full terms below govern.
|
|
8
|
+
|
|
9
|
+
Version 1.0, August 2026
|
|
10
|
+
|
|
11
|
+
This agreement is between you and Webstronauts, trading as Quario,
|
|
12
|
+
established in the Netherlands ("we", "us"), and governs the Software
|
|
13
|
+
defined below.
|
|
14
|
+
|
|
15
|
+
Quario is commercial software. Its packages ship with readable source
|
|
16
|
+
code, but it is not open source. No rights are granted beyond those in
|
|
17
|
+
this agreement.
|
|
18
|
+
|
|
19
|
+
## 1. Definitions
|
|
20
|
+
|
|
21
|
+
- The **Software** is any software we publish under the Quario name,
|
|
22
|
+
including the `quario` and `@quario/*` packages, except where a
|
|
23
|
+
product states its own terms. Each release states the edition of this
|
|
24
|
+
agreement that governs it.
|
|
25
|
+
- **You** means the individual exercising rights under this agreement
|
|
26
|
+
and, where that individual acts for a company or other organization,
|
|
27
|
+
that organization. In that case the organization is the licensee, and
|
|
28
|
+
the rights and limits in this agreement apply to it as a whole.
|
|
29
|
+
- An **Application** is software you create that incorporates the
|
|
30
|
+
Software and adds substantial functionality beyond it. A
|
|
31
|
+
licensee-operated service that embeds the Software and renders
|
|
32
|
+
reports for your End Users is an Application under this agreement,
|
|
33
|
+
even if it does little more than render.
|
|
34
|
+
- A **Developer** is any individual (employee or contractor working on
|
|
35
|
+
your behalf) who writes or maintains code that uses the Software's
|
|
36
|
+
APIs, or who creates or maintains report definitions for use with the
|
|
37
|
+
Software. That includes people who never install or run the Software
|
|
38
|
+
themselves, when their definitions are rendered by a service that
|
|
39
|
+
embeds it.
|
|
40
|
+
- A **Seat** is a paid license for one named Developer, purchased from
|
|
41
|
+
us or an authorized reseller.
|
|
42
|
+
- **End Users** are people who interact with the Software only through
|
|
43
|
+
an Application: running it, viewing the reports it produces, or
|
|
44
|
+
supplying parameters or other input to existing report definitions
|
|
45
|
+
the Application provides. Creating or maintaining report definitions
|
|
46
|
+
makes someone a Developer. End Users do not purchase a license; their
|
|
47
|
+
rights come from section 5.
|
|
48
|
+
|
|
49
|
+
## 2. Acceptance
|
|
50
|
+
|
|
51
|
+
This file is our standing offer. Anyone who exercises the evaluation
|
|
52
|
+
rights in section 3 accepts them, subject to their conditions. All
|
|
53
|
+
other rights arise when Seats are purchased. Where a purchase is made
|
|
54
|
+
for an organization, the person completing it confirms that they are
|
|
55
|
+
authorized to accept this agreement on the organization's behalf, and
|
|
56
|
+
acceptance covers the whole organization. If you do not accept this
|
|
57
|
+
agreement, do not use the Software.
|
|
58
|
+
|
|
59
|
+
## 3. Evaluation
|
|
60
|
+
|
|
61
|
+
You may download, install, and use the Software free of charge to
|
|
62
|
+
evaluate it: assessing whether it suits your purposes, and building
|
|
63
|
+
prototypes or proofs of concept. Evaluation covers the full feature
|
|
64
|
+
set, needs no key, and has no fixed time limit. The Software marks
|
|
65
|
+
evaluation output as described in section 6.
|
|
66
|
+
|
|
67
|
+
Evaluation is limited to assessment: you may not distribute any
|
|
68
|
+
Application containing the Software, use the Software in live business
|
|
69
|
+
operations, or expose it in services offered to third parties. The
|
|
70
|
+
moment your use goes beyond evaluating the Software, it requires Seats
|
|
71
|
+
under section 4.
|
|
72
|
+
|
|
73
|
+
## 4. Developer licenses
|
|
74
|
+
|
|
75
|
+
Any use of the Software beyond section 3 requires a Seat for every
|
|
76
|
+
Developer, whether or not an Application has shipped. With those Seats
|
|
77
|
+
in place:
|
|
78
|
+
|
|
79
|
+
- Each licensed Developer may install and use the Software on any number
|
|
80
|
+
of machines for developing Applications.
|
|
81
|
+
- Automated build, test, and CI systems do not consume Seats.
|
|
82
|
+
- Seats are per named individual. They may be reassigned when a
|
|
83
|
+
Developer stops working with the Software, but not shared or pooled
|
|
84
|
+
between concurrently active Developers. Contractors' work for you is
|
|
85
|
+
covered by your Seats; work they do for other clients is not.
|
|
86
|
+
|
|
87
|
+
## 5. Distribution and runtime
|
|
88
|
+
|
|
89
|
+
With Seats under section 4 in place, you may deploy and operate your
|
|
90
|
+
Applications yourself, and distribute the Software as an embedded part
|
|
91
|
+
of them, **royalty-free**: no additional fees or per-deployment
|
|
92
|
+
licenses are required for any number of servers, copies, or End Users.
|
|
93
|
+
You may embed your license key in an Application and ship it at no
|
|
94
|
+
charge, so section 6 does not mark that Application's output.
|
|
95
|
+
|
|
96
|
+
For that purpose, we grant you the right to pass through to each End
|
|
97
|
+
User, and to anyone who distributes or operates your Application on your
|
|
98
|
+
behalf, a limited, non-exclusive, non-transferable right to execute the
|
|
99
|
+
Software only as embedded in the Application. You must not permit them
|
|
100
|
+
to extract the Software from the Application or use it to develop
|
|
101
|
+
software of their own.
|
|
102
|
+
|
|
103
|
+
If you operate a service that embeds the Software and renders reports
|
|
104
|
+
for your End Users, that service is an Application under section 1. You
|
|
105
|
+
may run it royalty-free under this section, and you may put your license
|
|
106
|
+
key in it as in any Application. Calling applications that only request
|
|
107
|
+
renders from it need not incorporate the Software. People who use those
|
|
108
|
+
applications only as End Users under section 1 get the pass-through
|
|
109
|
+
rights above.
|
|
110
|
+
|
|
111
|
+
You may not:
|
|
112
|
+
|
|
113
|
+
- distribute the Software on its own, or as part of a product whose
|
|
114
|
+
primary purpose is to provide report-engine, report-design, or
|
|
115
|
+
document-generation capability to third-party developers;
|
|
116
|
+
- expose the Software's APIs to third parties as a development tool or
|
|
117
|
+
service that substitutes for those parties buying their own Seats. A
|
|
118
|
+
licensee-operated service that embeds the Software and renders for
|
|
119
|
+
your own End Users falls outside that ban;
|
|
120
|
+
- fork, mirror, repackage, rename, or wrap the Software, or republish
|
|
121
|
+
it on any package registry or code-hosting platform, other than as
|
|
122
|
+
embedded in an Application.
|
|
123
|
+
|
|
124
|
+
For as long as this agreement is in force, you may not create any tool,
|
|
125
|
+
library, product, or service that competes with the Software or any
|
|
126
|
+
other Quario product, whether the competition is direct or indirect;
|
|
127
|
+
this restriction does not survive termination. You may not remove or
|
|
128
|
+
alter copyright or license notices in the Software. If your Application
|
|
129
|
+
is itself a closed development product for third-party developers (OEM
|
|
130
|
+
redistribution), contact us for separate terms.
|
|
131
|
+
|
|
132
|
+
## 6. License keys and output marking
|
|
133
|
+
|
|
134
|
+
The Software accepts a license key that evidences your Seats under
|
|
135
|
+
section 4. Key checks run offline.
|
|
136
|
+
|
|
137
|
+
Without a valid key, or with a key whose validity period does not cover
|
|
138
|
+
the installed version's release date, the Software runs with its full
|
|
139
|
+
feature set but marks its output as unlicensed. Using a key you are not
|
|
140
|
+
entitled to is a material breach of this agreement.
|
|
141
|
+
|
|
142
|
+
You may not remove, disable, or circumvent the key check or the output
|
|
143
|
+
marking, or distribute anything that does.
|
|
144
|
+
|
|
145
|
+
## 7. Term, updates, and perpetual fallback
|
|
146
|
+
|
|
147
|
+
Seats are sold as an annual subscription. While the subscription is
|
|
148
|
+
active, licensed Developers receive all Software versions we release,
|
|
149
|
+
and support for the Software through our issue tracker and by email. We
|
|
150
|
+
provide support with commercially reasonable effort; we do not guarantee
|
|
151
|
+
specific response times.
|
|
152
|
+
|
|
153
|
+
If the subscription lapses, your license to the versions released during
|
|
154
|
+
your subscription is **perpetual, for the number of Seats held at the
|
|
155
|
+
lapse**: up to that many Developers may keep using those versions, and
|
|
156
|
+
your Applications may keep shipping them, forever, at no further cost.
|
|
157
|
+
Versions released after the lapse require renewal. We encode this rule
|
|
158
|
+
in purchased keys: a key remains valid for every version released
|
|
159
|
+
during its validity period, with no end date.
|
|
160
|
+
|
|
161
|
+
## 8. Source availability
|
|
162
|
+
|
|
163
|
+
The Software's packages ship with readable source code so that you can
|
|
164
|
+
review and debug what you run. Readable source does not grant extra
|
|
165
|
+
rights: this agreement governs all use of the source, and you may
|
|
166
|
+
publish the Software (modified or not) only as part of an Application
|
|
167
|
+
under section 5.
|
|
168
|
+
|
|
169
|
+
## 9. Ownership, trademarks, and feedback
|
|
170
|
+
|
|
171
|
+
We license the Software; we retain all intellectual property rights in
|
|
172
|
+
it. Report definitions, data, and output you create with the Software
|
|
173
|
+
are yours. "Quario" and the Quario logo are our trademarks; this
|
|
174
|
+
agreement grants no rights to use them beyond identifying the Software.
|
|
175
|
+
If you send us feedback or suggestions, we may use them without
|
|
176
|
+
obligation.
|
|
177
|
+
|
|
178
|
+
## 10. Warranty and liability
|
|
179
|
+
|
|
180
|
+
We provide the Software as is. To the fullest extent the law allows, we
|
|
181
|
+
disclaim all warranties and conditions, express or implied, including
|
|
182
|
+
merchantability, fitness for a particular purpose, and non-infringement.
|
|
183
|
+
|
|
184
|
+
To the fullest extent the law allows: we are not liable for indirect or
|
|
185
|
+
consequential damages, loss of profits, loss of data, or business
|
|
186
|
+
interruption; and our total aggregate liability under this agreement is
|
|
187
|
+
limited to the greater of EUR 100 and the fees you paid us in the
|
|
188
|
+
twelve months preceding the event giving rise to the claim. These
|
|
189
|
+
limitations do not apply where the damage results from the intent or
|
|
190
|
+
deliberate recklessness (opzet of bewuste roekeloosheid) of us or our
|
|
191
|
+
management, or where mandatory law does not permit them.
|
|
192
|
+
|
|
193
|
+
## 11. Termination and survival
|
|
194
|
+
|
|
195
|
+
If you materially breach this agreement, we may terminate it by notice.
|
|
196
|
+
If the breach can be remedied, we will first allow 30 days from that
|
|
197
|
+
notice to remedy it, and termination takes effect only if the breach
|
|
198
|
+
remains. On termination, the licenses granted here end.
|
|
199
|
+
Perpetual-fallback rights under section 7 survive termination only if
|
|
200
|
+
they arose from a lapse that predates the breach, and section 5
|
|
201
|
+
survives with them to the extent needed to keep exercising them.
|
|
202
|
+
Sections 8 through 12 survive any termination.
|
|
203
|
+
|
|
204
|
+
## 12. General
|
|
205
|
+
|
|
206
|
+
Dutch law governs this agreement. Disputes go to the competent court for
|
|
207
|
+
our registered office. You may not assign this agreement without our
|
|
208
|
+
consent, except to a successor of your whole business; we may assign it
|
|
209
|
+
to a successor of ours. If a provision is unenforceable, the remainder
|
|
210
|
+
stays in force. Failure to enforce a provision does not waive it. This
|
|
211
|
+
agreement, together with your order for Seats, is the entire agreement
|
|
212
|
+
about the Software, unless we agree otherwise in writing.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
**Purchasing and contact:** <contact@getquario.com> ·
|
|
217
|
+
<https://getquario.com>
|
|
218
|
+
|
|
219
|
+
Copyright © 2026 Webstronauts. All rights reserved.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# @quario/landing
|
|
2
|
+
|
|
3
|
+
**What [quario](https://www.npmjs.com/package/quario)'s two surface elements share below their
|
|
4
|
+
own surfaces.** `@quario/viewer` and `@quario/editor` both take host properties, both carry a
|
|
5
|
+
render boundary across their lifetime, and both draw a panel when something fails. The parts of
|
|
6
|
+
that which are easy to get subtly wrong — and invisible when they are — live here once.
|
|
7
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @quario/landing
|
|
15
|
+
```
|
|
16
|
+
|
|
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.
|
|
20
|
+
|
|
21
|
+
ESM-only, Node 22+, and browser-ready through a standards-based bundler. CSP-safe: no
|
|
22
|
+
string-to-code paths anywhere.
|
|
23
|
+
|
|
24
|
+
## What is here
|
|
25
|
+
|
|
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
|
|
43
|
+
downstream of what re-runs the render, which is the one thing the two surfaces do not share.
|
|
44
|
+
|
|
45
|
+
## What is not here
|
|
46
|
+
|
|
47
|
+
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.
|
package/lib/failure.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failure a surface is showing, and whether the reader has put it away.
|
|
3
|
+
*
|
|
4
|
+
* A panel is a **state**, not a log: it replaces rather than stacks, and the
|
|
5
|
+
* error event is what takes every failure. This owns which failure is current
|
|
6
|
+
* and whether it has been dismissed; the markup stays in each surface, where
|
|
7
|
+
* the class names and the button are its own.
|
|
8
|
+
*
|
|
9
|
+
* Whether a repeat announces again is a **policy the caller passes in**, not a
|
|
10
|
+
* rule settled here. It is downstream of what re-runs the render, which is the
|
|
11
|
+
* one thing the two surfaces will never share: an editor re-plans on every
|
|
12
|
+
* keystroke, so an unchanged broken document would fire an event per keypress;
|
|
13
|
+
* a viewer only re-runs when a host property or the epoch moves, so its rate
|
|
14
|
+
* is already bounded.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { wording } from "./options.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether an announcement says what the standing one already says. Wording is
|
|
21
|
+
* the identity for the same reason the option check compares by it: each
|
|
22
|
+
* failure mints a fresh error, so the objects never match.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ label: string, error: unknown } | undefined} failure
|
|
25
|
+
* @param {string} label
|
|
26
|
+
* @param {unknown} error
|
|
27
|
+
*/
|
|
28
|
+
export let repeats = (failure, label, error) =>
|
|
29
|
+
!!failure && failure.label === label && wording(failure.error) === wording(error);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The panel's model for one surface.
|
|
33
|
+
*
|
|
34
|
+
* `announce` answers whether anything changed — `false` when the policy
|
|
35
|
+
* refused a repeat — so a caller knows whether to raise its own event. It
|
|
36
|
+
* never fires one itself: the event is public API and belongs to the element.
|
|
37
|
+
*
|
|
38
|
+
* @param {(failure: { label: string, error: unknown } | undefined, label: string, error: unknown) => boolean} [refuse]
|
|
39
|
+
* Whether to say nothing. Defaults to announcing every failure.
|
|
40
|
+
*/
|
|
41
|
+
export let failures = (refuse = () => false) => {
|
|
42
|
+
/** @type {{ label: string, error: unknown } | undefined} */
|
|
43
|
+
let failure;
|
|
44
|
+
let dismissed = false;
|
|
45
|
+
return {
|
|
46
|
+
/** What the panel should draw, or `undefined` while there is nothing to
|
|
47
|
+
* look at — either no failure, or one the reader put away. */
|
|
48
|
+
get showing() {
|
|
49
|
+
return dismissed ? undefined : failure;
|
|
50
|
+
},
|
|
51
|
+
/** Take the panel down until the next failure worth drawing. */
|
|
52
|
+
dismiss() {
|
|
53
|
+
dismissed = true;
|
|
54
|
+
},
|
|
55
|
+
/** The dismissal lapses, without announcing anything. A dismissal is
|
|
56
|
+
* about a subject — a reader who put a failure away did so about the
|
|
57
|
+
* document they were looking at — so when that changes underneath it, the
|
|
58
|
+
* standing failure is worth drawing again. Distinct from `clear`, which
|
|
59
|
+
* is a render landing and takes the failure with it. */
|
|
60
|
+
reopen() {
|
|
61
|
+
dismissed = false;
|
|
62
|
+
},
|
|
63
|
+
/** A render reached the sheet: the panel says what is wrong with what the
|
|
64
|
+
* reader is looking at, and this is the moment that stops being true. */
|
|
65
|
+
clear() {
|
|
66
|
+
failure = undefined;
|
|
67
|
+
dismissed = false;
|
|
68
|
+
},
|
|
69
|
+
/** @type {(label: string, error: unknown) => boolean} */
|
|
70
|
+
announce(label, error) {
|
|
71
|
+
if (refuse(failure, label, error)) return false;
|
|
72
|
+
failure = { error, label };
|
|
73
|
+
dismissed = false;
|
|
74
|
+
return true;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
|
2
|
+
|
|
3
|
+
/** One failure, as a panel draws it: the surface's own label and the value
|
|
4
|
+
* that was thrown. */
|
|
5
|
+
export interface Failure {
|
|
6
|
+
label: string;
|
|
7
|
+
error: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Whether to say nothing about a failure the surface is already showing. */
|
|
11
|
+
export type Refuse = (standing: Failure | undefined, label: string, error: unknown) => boolean;
|
|
12
|
+
|
|
13
|
+
/** Whether a property changed this update cycle. `hasUpdated` false means the
|
|
14
|
+
* first cycle, where everything counts as changed. */
|
|
15
|
+
export type Gate = (...keys: string[]) => boolean;
|
|
16
|
+
|
|
17
|
+
/** One property's check: validate unconditionally, commit under the gate. */
|
|
18
|
+
export type Check = (gate: Gate) => void;
|
|
19
|
+
|
|
20
|
+
/** What a caught value says. Each check mints a fresh error, so wording rather
|
|
21
|
+
* than identity is what tells two failures apart. */
|
|
22
|
+
export function wording(error: unknown): string;
|
|
23
|
+
|
|
24
|
+
/** The option check across one update cycle. `recheck` keeps the first
|
|
25
|
+
* failure, runs every check whatever any of them threw, and answers whether
|
|
26
|
+
* the failure changed. */
|
|
27
|
+
export function options(): {
|
|
28
|
+
readonly invalid: unknown;
|
|
29
|
+
recheck(
|
|
30
|
+
hasUpdated: boolean,
|
|
31
|
+
changed: Map<string, unknown> | Set<string>,
|
|
32
|
+
checks: Check[],
|
|
33
|
+
): boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Whether an announcement says what the standing one already says. */
|
|
37
|
+
export function repeats(standing: Failure | undefined, label: string, error: unknown): boolean;
|
|
38
|
+
|
|
39
|
+
/** The panel's model. `announce` answers whether anything changed, so the
|
|
40
|
+
* caller knows whether to raise its own event; it never raises one. */
|
|
41
|
+
export function failures(refuse?: Refuse): {
|
|
42
|
+
readonly showing: Failure | undefined;
|
|
43
|
+
dismiss(): void;
|
|
44
|
+
/** The dismissal lapses without announcing: the subject it was about has
|
|
45
|
+
* changed, so a standing failure is worth drawing again. */
|
|
46
|
+
reopen(): void;
|
|
47
|
+
clear(): void;
|
|
48
|
+
announce(label: string, error: unknown): boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The render in flight, so a disconnect can stop it. */
|
|
52
|
+
export interface Aborts {
|
|
53
|
+
abort(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The render boundary one surface carries across its lifetime. A Lit
|
|
58
|
+
* `ReactiveController` structurally — nothing is imported from `lit` at run
|
|
59
|
+
* time.
|
|
60
|
+
*/
|
|
61
|
+
export class Landing implements ReactiveController {
|
|
62
|
+
constructor(host: ReactiveControllerHost, task: Aborts);
|
|
63
|
+
/** Whether a render has ever painted the sheet. */
|
|
64
|
+
landed: boolean;
|
|
65
|
+
/** Whether work was abandoned rather than finished. */
|
|
66
|
+
stale: boolean;
|
|
67
|
+
/** What the render task is keyed on beyond the host's own properties. */
|
|
68
|
+
epoch: number;
|
|
69
|
+
/** Whether a run is in flight. The host owns the task, so the host sets it. */
|
|
70
|
+
pending: boolean;
|
|
71
|
+
hostConnected(): void;
|
|
72
|
+
hostDisconnected(): void;
|
|
73
|
+
/** Ask for a fresh render where one is owed. */
|
|
74
|
+
wake(): void;
|
|
75
|
+
/** Whether this run was abandoned: aborted, and the element gone. */
|
|
76
|
+
dropped(signal: { aborted: boolean }, connected: boolean): boolean;
|
|
77
|
+
/** Give up on this run, leaving the boundary owing a fresh one. */
|
|
78
|
+
abandon(): null;
|
|
79
|
+
/** A render reached the sheet. */
|
|
80
|
+
land(): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The gutter around the sheet, and the gap between pages, in CSS pixels at
|
|
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
|
|
86
|
+
* sheet whose CSS disagreed would paint the wrong pages. */
|
|
87
|
+
export const GUTTER: number;
|
|
88
|
+
export const GAP: number;
|
|
89
|
+
|
|
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.
|
|
93
|
+
*
|
|
94
|
+
* `scale` is asked afresh every time — CSS pixels per point — so scale policy
|
|
95
|
+
* stays with the surface, and the elements are the surface's too, because
|
|
96
|
+
* their class names are its public CSS.
|
|
97
|
+
*/
|
|
98
|
+
export function sheet(
|
|
99
|
+
scroll: HTMLElement,
|
|
100
|
+
sheetEl: HTMLElement,
|
|
101
|
+
scale: () => number,
|
|
102
|
+
pageClass?: string,
|
|
103
|
+
): {
|
|
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. */
|
|
106
|
+
swap(list: unknown, fonts: unknown): Promise<void>;
|
|
107
|
+
/** Give every page its CSS size at the current scale. */
|
|
108
|
+
sizeAll(): void;
|
|
109
|
+
/** Repaint the reach, releasing what it has left behind. */
|
|
110
|
+
repaint(): Promise<void>;
|
|
111
|
+
/** One reach pass a frame, however many scrolls or resizes ask. */
|
|
112
|
+
follow(): void;
|
|
113
|
+
/** Where each page starts down the sheet, in CSS pixels. */
|
|
114
|
+
tops(): number[];
|
|
115
|
+
/** The page width the list carries, or `null` before one arrives. */
|
|
116
|
+
width(): number | null;
|
|
117
|
+
pageAt(i: number): HTMLCanvasElement;
|
|
118
|
+
count(): number;
|
|
119
|
+
};
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `@quario/viewer` and `@quario/editor` share below their own surfaces:
|
|
3
|
+
* the option check they both run per update cycle, the render boundary they
|
|
4
|
+
* both carry across a lifetime, and the failure state they both draw a panel
|
|
5
|
+
* from.
|
|
6
|
+
*
|
|
7
|
+
* Four modules, and no coupling between them — the option check answers
|
|
8
|
+
* whether to re-render through a return value, the failure state knows nothing
|
|
9
|
+
* about the boundary, and the boundary knows neither.
|
|
10
|
+
*
|
|
11
|
+
* What is deliberately **not** here: each surface's own checks, its panel
|
|
12
|
+
* markup, and its public API. The two elements differ in their vocabulary and
|
|
13
|
+
* in their policy, and only the machinery underneath is one thing
|
|
14
|
+
* (docs/adr/0059-the-surfaces-share-a-package-not-a-shape.md).
|
|
15
|
+
*/
|
|
16
|
+
export { options, wording } from "./options.js";
|
|
17
|
+
export { Landing } from "./landing.js";
|
|
18
|
+
export { failures, repeats } from "./failure.js";
|
|
19
|
+
export { GAP, GUTTER, sheet } from "./sheet.js";
|
package/lib/landing.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render boundary a surface carries across its own lifetime: whether a
|
|
3
|
+
* render has ever painted the sheet, whether work was abandoned mid-flight,
|
|
4
|
+
* and the counter that asks for a fresh one.
|
|
5
|
+
*
|
|
6
|
+
* A [Lit `ReactiveController`](https://lit.dev/docs/composition/controllers/),
|
|
7
|
+
* which is a structural interface — a host calls `addController`, and this
|
|
8
|
+
* answers `hostConnected` and `hostDisconnected`. Nothing is imported from
|
|
9
|
+
* `lit` at run time, only its type, so this package stays free of it.
|
|
10
|
+
*
|
|
11
|
+
* What it owns is the machinery ADR 0043 and ADR 0046 argue about: a
|
|
12
|
+
* disconnected element abandons work rather than hiding it, and a stale
|
|
13
|
+
* boundary asks for a new render when it comes back.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @import { ReactiveController, ReactiveControllerHost } from 'lit' */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} Aborts
|
|
20
|
+
* @property {() => void} abort Stop the render in flight.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The boundary for one surface element.
|
|
25
|
+
*
|
|
26
|
+
* @implements {ReactiveController}
|
|
27
|
+
*/
|
|
28
|
+
export class Landing {
|
|
29
|
+
/** @type {ReactiveControllerHost} */
|
|
30
|
+
#host;
|
|
31
|
+
/** @type {Aborts} */
|
|
32
|
+
#task;
|
|
33
|
+
/**
|
|
34
|
+
* Whether a render has ever painted the sheet: the line between an element
|
|
35
|
+
* that is mounting and one that is updating (CONTEXT.md, "Landed"). It is
|
|
36
|
+
* the element's lifetime, not the document's — a new document does not
|
|
37
|
+
* un-land what is already on screen.
|
|
38
|
+
*/
|
|
39
|
+
landed = false;
|
|
40
|
+
/**
|
|
41
|
+
* Whether work was abandoned rather than finished. A disconnected element
|
|
42
|
+
* still processes updates, so abandoning is what makes removal stop work
|
|
43
|
+
* instead of merely hiding it; the boundary stays stale until something
|
|
44
|
+
* asks for a fresh render.
|
|
45
|
+
*/
|
|
46
|
+
stale = false;
|
|
47
|
+
/** What the render task is keyed on beyond the host's own properties: bump
|
|
48
|
+
* it and the newest state renders again. */
|
|
49
|
+
epoch = 0;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {ReactiveControllerHost} host
|
|
53
|
+
* @param {Aborts} task The render in flight, so a disconnect can stop it.
|
|
54
|
+
*/
|
|
55
|
+
constructor(host, task) {
|
|
56
|
+
this.#host = host;
|
|
57
|
+
this.#task = task;
|
|
58
|
+
host.addController(this);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Coming back asks for the render that was abandoned, or the one still in
|
|
62
|
+
* flight when the element left.
|
|
63
|
+
*
|
|
64
|
+
* Called by Lit through the controller protocol, never by name here, which
|
|
65
|
+
* is why the dead-code scan cannot see the caller. */
|
|
66
|
+
// fallow-ignore-next-line unused-class-member
|
|
67
|
+
hostConnected() {
|
|
68
|
+
this.wake();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Removal is not destruction: the properties and the sheet persist, and
|
|
72
|
+
* only the work in flight stops. Each surface's own teardown — an observer,
|
|
73
|
+
* a key binding — stays in its `disconnectedCallback`. Called by Lit, as
|
|
74
|
+
* `hostConnected` is. */
|
|
75
|
+
// fallow-ignore-next-line unused-class-member
|
|
76
|
+
hostDisconnected() {
|
|
77
|
+
this.#task.abort();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Ask for a fresh render, but only where one is owed: a boundary that was
|
|
81
|
+
* abandoned, or a run that is still going and will be superseded. */
|
|
82
|
+
wake() {
|
|
83
|
+
if (this.stale || this.pending) {
|
|
84
|
+
this.epoch++;
|
|
85
|
+
this.#host.requestUpdate();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a run is in flight. Set by the host, which owns the task. */
|
|
90
|
+
pending = false;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether this run was abandoned: the engine takes no signal, so the guard
|
|
94
|
+
* around the body is what abandonment is. It spares only the work that
|
|
95
|
+
* arrives after a disconnect.
|
|
96
|
+
*
|
|
97
|
+
* @param {{ aborted: boolean }} signal
|
|
98
|
+
* @param {boolean} connected
|
|
99
|
+
*/
|
|
100
|
+
dropped(signal, connected) {
|
|
101
|
+
return signal.aborted && !connected;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Give up on this run, leaving the boundary owing a fresh one. Answers
|
|
105
|
+
* `null`, the "nothing to show" result a task settles with. */
|
|
106
|
+
abandon() {
|
|
107
|
+
this.stale = true;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A render reached the sheet. */
|
|
112
|
+
land() {
|
|
113
|
+
this.landed = true;
|
|
114
|
+
this.stale = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/lib/options.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The option check both surfaces run per update cycle.
|
|
3
|
+
*
|
|
4
|
+
* A host writes several properties at once and gets one render, so the checks
|
|
5
|
+
* have to agree on three things that are easy to get subtly wrong and
|
|
6
|
+
* invisible when they are: that one bad property does not block a good write
|
|
7
|
+
* to another, that a property still broken keeps the element failing rather
|
|
8
|
+
* than being cleared by an unrelated write, and that a write to one property
|
|
9
|
+
* does not clobber what another committed.
|
|
10
|
+
*
|
|
11
|
+
* The checks themselves stay in each surface — they are its own vocabulary —
|
|
12
|
+
* and only that agreement lives here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a caught value says. Each check mints a fresh error, so a still-broken
|
|
17
|
+
* property re-reported on every unrelated write would never compare equal by
|
|
18
|
+
* identity; its wording is its identity here, and a value with no message of
|
|
19
|
+
* its own still compares.
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} error
|
|
22
|
+
* @returns {string}
|
|
23
|
+
*/
|
|
24
|
+
export let wording = (error) => String(/** @type {any} */ (error)?.message || error);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An option check across one update cycle.
|
|
28
|
+
*
|
|
29
|
+
* `recheck` runs every check, whatever any of them threw, and keeps the
|
|
30
|
+
* **first** failure — one bad property must not stop a good write to another.
|
|
31
|
+
* Every check re-validates whatever changed, so a property that is still
|
|
32
|
+
* broken keeps failing rather than being cleared by a write elsewhere. It
|
|
33
|
+
* answers whether the failure changed, which is what a caller turns into a
|
|
34
|
+
* fresh render.
|
|
35
|
+
*
|
|
36
|
+
* Each check receives a `gate`: the caller validates unconditionally and
|
|
37
|
+
* commits only under `gate("page")`, so writing `filename` cannot clobber the
|
|
38
|
+
* mode a reader clicked to or dirty an inherited colour scheme.
|
|
39
|
+
*
|
|
40
|
+
* @returns {{
|
|
41
|
+
* readonly invalid: unknown,
|
|
42
|
+
* recheck: (
|
|
43
|
+
* hasUpdated: boolean,
|
|
44
|
+
* changed: Map<string, unknown> | Set<string>,
|
|
45
|
+
* checks: ((gate: (...keys: string[]) => boolean) => void)[],
|
|
46
|
+
* ) => boolean,
|
|
47
|
+
* }}
|
|
48
|
+
*/
|
|
49
|
+
export let options = () => {
|
|
50
|
+
/** @type {unknown} */
|
|
51
|
+
let invalid;
|
|
52
|
+
return {
|
|
53
|
+
get invalid() {
|
|
54
|
+
return invalid;
|
|
55
|
+
},
|
|
56
|
+
recheck(hasUpdated, changed, checks) {
|
|
57
|
+
let was = invalid;
|
|
58
|
+
invalid = undefined;
|
|
59
|
+
/** @type {(...keys: string[]) => boolean} */
|
|
60
|
+
let gate = (...keys) => !hasUpdated || keys.some((key) => changed.has(key));
|
|
61
|
+
for (let check of checks) {
|
|
62
|
+
try {
|
|
63
|
+
check(gate);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
invalid ??= error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return wording(was) !== wording(invalid);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
package/lib/sheet.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
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.
|
|
4
|
+
*
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* **A page the browser will not paint is blank, not a failure.** `start`
|
|
12
|
+
* swallows and nothing here rejects: the report laid out and the list reached
|
|
13
|
+
* the sheet, which is all a render promised.
|
|
14
|
+
*
|
|
15
|
+
* **A canvas is never re-pointed at a second paint.** `paint()` awaits before
|
|
16
|
+
* it draws, so a canvas whose paint is still in flight is *retired* rather
|
|
17
|
+
* than painted over: it comes off the sheet, its pixels go back, and a fresh
|
|
18
|
+
* one stands in its place, so the superseded paint draws into an element
|
|
19
|
+
* nobody is looking at (ADR 0046).
|
|
20
|
+
*
|
|
21
|
+
* What this does **not** own is scale policy. It takes a `scale()` thunk and
|
|
22
|
+
* asks it afresh every time: the viewer answers a zoom percentage, the editor
|
|
23
|
+
* answers a constant. Nor the elements — each surface builds and styles its
|
|
24
|
+
* own scroll container and sheet, and hands them in.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { paint } from "@quario/layout";
|
|
28
|
+
|
|
29
|
+
/** The gutter around the sheet, and the gap between pages, in CSS pixels at
|
|
30
|
+
* 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. */
|
|
33
|
+
export const GUTTER = 28;
|
|
34
|
+
export const GAP = 16;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A paged sheet on `scroll`, painting into `sheetEl`.
|
|
38
|
+
*
|
|
39
|
+
* @param {HTMLElement} scroll The scroll container the reader looks through.
|
|
40
|
+
* @param {HTMLElement} sheetEl What the page canvases are children of.
|
|
41
|
+
* @param {() => number} scale CSS pixels per point, asked afresh each time.
|
|
42
|
+
* @param {string} [pageClass] What to name a page element. Each surface's
|
|
43
|
+
* `q*-` names are its own documented CSS surface, so the class is the
|
|
44
|
+
* caller's to choose and this only supplies the viewer's as a default.
|
|
45
|
+
*/
|
|
46
|
+
export let sheet = (scroll, sheetEl, scale, pageClass = "qv-page") => {
|
|
47
|
+
/** @type {any} The list on the sheet. */
|
|
48
|
+
let list = null;
|
|
49
|
+
/** @type {any} */
|
|
50
|
+
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 = [];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What each backed page is carrying: the scale it was painted at, so a page
|
|
60
|
+
* re-entering the reach at that scale is not repainted and a scale change
|
|
61
|
+
* invalidates it, and the paint itself, so whoever asks second waits behind
|
|
62
|
+
* the paint the first caller started rather than being told it is done.
|
|
63
|
+
* Keyed by the canvas, so a swap's new pages start with nothing remembered
|
|
64
|
+
* and the pages it replaced take their entries with them — which is why the
|
|
65
|
+
* map is weak: nothing has to remember to forget them.
|
|
66
|
+
*
|
|
67
|
+
* @type {WeakMap<HTMLCanvasElement, { scale: number, painted: Promise<void> }>}
|
|
68
|
+
*/
|
|
69
|
+
let backed = new WeakMap();
|
|
70
|
+
|
|
71
|
+
/**
|
|
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>}
|
|
84
|
+
*/
|
|
85
|
+
let settled = new WeakSet();
|
|
86
|
+
|
|
87
|
+
/**
|
|
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.
|
|
91
|
+
*
|
|
92
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
93
|
+
*/
|
|
94
|
+
let pageAt = (i) => pages[i];
|
|
95
|
+
|
|
96
|
+
/**
|
|
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.
|
|
102
|
+
*
|
|
103
|
+
* @type {(canvas: HTMLElement, i: number, px?: number) => void}
|
|
104
|
+
*/
|
|
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";
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
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.
|
|
116
|
+
*
|
|
117
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
118
|
+
*/
|
|
119
|
+
let pageOf = (i) => {
|
|
120
|
+
let canvas = document.createElement("canvas");
|
|
121
|
+
canvas.className = pageClass;
|
|
122
|
+
// A canvas is 300 x 150 until told otherwise, and that store counts. A
|
|
123
|
+
// page starts with none and takes one once it is inside the reach.
|
|
124
|
+
canvas.width = 0;
|
|
125
|
+
canvas.height = 0;
|
|
126
|
+
canvas.setAttribute("role", "img");
|
|
127
|
+
canvas.setAttribute("aria-label", "Page " + (i + 1) + " of " + list.pages.length);
|
|
128
|
+
sizePage(canvas, i);
|
|
129
|
+
return canvas;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Take page `i`'s canvas off the sheet and stand a fresh one in its place,
|
|
134
|
+
* answering with the replacement. A paint still in flight holds the old
|
|
135
|
+
* canvas's context and draws into something nobody is looking at, which is
|
|
136
|
+
* how a superseded paint is stopped here — by construction, rather than by
|
|
137
|
+
* 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.
|
|
139
|
+
*
|
|
140
|
+
* Its pixels go back at once, by the same 0 × 0 idiom `release` uses: the
|
|
141
|
+
* store is what ADR 0043 rations, and leaving it to be collected whenever
|
|
142
|
+
* the superseded paint lets go of the context is the timing that ADR
|
|
143
|
+
* refuses. `paint()` does its whole `save`/draw/`restore` after its awaits,
|
|
144
|
+
* so emptying the canvas between them unbalances nothing — it just leaves
|
|
145
|
+
* every op clipped to nothing, which spares the raster work too.
|
|
146
|
+
*
|
|
147
|
+
* @type {(i: number) => HTMLCanvasElement}
|
|
148
|
+
*/
|
|
149
|
+
let retire = (i) => {
|
|
150
|
+
let old = pageAt(i);
|
|
151
|
+
let fresh = pageOf(i);
|
|
152
|
+
old.replaceWith(fresh);
|
|
153
|
+
pages[i] = fresh;
|
|
154
|
+
old.width = 0;
|
|
155
|
+
old.height = 0;
|
|
156
|
+
return fresh;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
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.
|
|
167
|
+
*
|
|
168
|
+
* @type {(i: number) => void}
|
|
169
|
+
*/
|
|
170
|
+
let release = (i) => {
|
|
171
|
+
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;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** Device pixels per point at the applied scale: what a page is
|
|
180
|
+
* painted at, and what its backing store is sized in. */
|
|
181
|
+
let deviceScale = () => scale() * (globalThis.devicePixelRatio || 1);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Start painting page `i`, and answer with that paint. **The one place a
|
|
185
|
+
* paint begins, and the one place the rule is enforced**: a canvas whose
|
|
186
|
+
* paint is still in flight is retired here before a second one is pointed
|
|
187
|
+
* at it, so no caller can reach a live canvas with a second paint by
|
|
188
|
+
* forgetting to ask first (ADR 0046). A paint that failed is forgotten, so
|
|
189
|
+
* the next pass over the reach tries again instead of counting the page as
|
|
190
|
+
* painted; one that settles joins `settled`.
|
|
191
|
+
*
|
|
192
|
+
* **The paint this answers with never rejects**, which is the whole of why
|
|
193
|
+
* `swap` does not either. A page that could not be drawn is not a render
|
|
194
|
+
* that failed: the report laid out and the list reached the sheet, so there
|
|
195
|
+
* is nothing for the error panel to say and nothing to report through the
|
|
196
|
+
* error event. Swallowed here, the one place a paint begins, so no caller
|
|
197
|
+
* has to remember to; one bad page then costs its own pixels rather than
|
|
198
|
+
* every page after it in the reach, which this loop awaits one at a time.
|
|
199
|
+
*
|
|
200
|
+
* See the header for how little can still reach it — the layout absorbs a
|
|
201
|
+
* bad image and refuses a bad face while measuring — and why it stays
|
|
202
|
+
* anyway. A paint that failed is forgotten so the next pass over the reach
|
|
203
|
+
* tries again, which is what makes a failure for a passing reason
|
|
204
|
+
* recoverable.
|
|
205
|
+
*
|
|
206
|
+
* @type {(i: number) => Promise<void>}
|
|
207
|
+
*/
|
|
208
|
+
let start = (i) => {
|
|
209
|
+
let canvas = pageAt(i);
|
|
210
|
+
if (backed.has(canvas) && !settled.has(canvas)) canvas = retire(i);
|
|
211
|
+
settled.delete(canvas);
|
|
212
|
+
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);
|
|
216
|
+
let ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext("2d"));
|
|
217
|
+
let painted = paint(ctx, each, { scale, fonts }).then(
|
|
218
|
+
() => {
|
|
219
|
+
settled.add(canvas);
|
|
220
|
+
},
|
|
221
|
+
() => {
|
|
222
|
+
backed.delete(canvas);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
backed.set(canvas, { scale, painted });
|
|
226
|
+
return painted;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Paint one page and answer with that paint — or with the paint already
|
|
231
|
+
* under way at this scale, which is what makes scrolling back over ground
|
|
232
|
+
* already covered free. Answering with the paint rather than with a
|
|
233
|
+
* resolved promise is what lets two callers share one page's paint and both
|
|
234
|
+
* settle behind its pixels.
|
|
235
|
+
*
|
|
236
|
+
* Takes the index, not the canvas: the page it paints may be retired out
|
|
237
|
+
* 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.
|
|
239
|
+
*
|
|
240
|
+
* @type {(i: number) => Promise<void>}
|
|
241
|
+
*/
|
|
242
|
+
let paintPage = (i) => {
|
|
243
|
+
let carrying = backed.get(pageAt(i));
|
|
244
|
+
if (carrying && carrying.scale === deviceScale()) return carrying.painted;
|
|
245
|
+
return start(i);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Bumped per repaint, so a repaint overtaken by the next stops walking.
|
|
250
|
+
* What that guards is the backing store, not the pixels: a superseded loop
|
|
251
|
+
* would paint at the *current* scale — `paintPage` reads it afresh — but
|
|
252
|
+
* onto pages the newer reach has since dropped, re-backing pages that
|
|
253
|
+
* should be blank (ADR 0043). Stale pixels are `retire`'s business, not
|
|
254
|
+
* this one, so neither guard stands in for the other.
|
|
255
|
+
*
|
|
256
|
+
* It guards repaint against repaint, and nothing else. A repaint chooses
|
|
257
|
+
* 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.
|
|
261
|
+
*/
|
|
262
|
+
let epoch = 0;
|
|
263
|
+
|
|
264
|
+
/**
|
|
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.
|
|
268
|
+
*/
|
|
269
|
+
let sizeAll = () => {
|
|
270
|
+
if (!list) return;
|
|
271
|
+
let px = scale();
|
|
272
|
+
for (let i = 0; i < list.pages.length; i++) sizePage(pageAt(i), i, px);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 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.
|
|
284
|
+
*
|
|
285
|
+
* @returns {number[]}
|
|
286
|
+
*/
|
|
287
|
+
let inReach = () => {
|
|
288
|
+
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;
|
|
293
|
+
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
|
+
}
|
|
299
|
+
return found;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
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
|
|
306
|
+
* pass both go through here, and neither holds a page the other released.
|
|
307
|
+
*
|
|
308
|
+
* Indices rather than elements, for the reason `paintPage` takes one: a
|
|
309
|
+
* page can be retired between this pass and the paint that follows it.
|
|
310
|
+
*
|
|
311
|
+
* @type {() => number[]}
|
|
312
|
+
*/
|
|
313
|
+
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
|
+
}
|
|
321
|
+
return kept;
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Paint the reach in order, yielding between pages and giving way to any
|
|
325
|
+
// repaint that started since. Sizing is the caller's, and comes first.
|
|
326
|
+
let repaint = async () => {
|
|
327
|
+
if (!list) return;
|
|
328
|
+
let mine = ++epoch;
|
|
329
|
+
for (let i of keep()) {
|
|
330
|
+
if (mine !== epoch) return;
|
|
331
|
+
await paintPage(i);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* One reach pass a frame, however many scrolls and resizes ask for one. It
|
|
337
|
+
* keeps no epoch of its own, so a swap painting behind it is never cut off
|
|
338
|
+
* part-painted, and a page it starts is one a swap arriving at the same page
|
|
339
|
+
* waits behind rather than skips. Nothing awaits these paints here: a scroll
|
|
340
|
+
* is not a render, and a page that will not paint is blank whoever asked for
|
|
341
|
+
* it — `start` settles either way, so there is nothing here to catch.
|
|
342
|
+
*/
|
|
343
|
+
let pending = false;
|
|
344
|
+
let follow = () => {
|
|
345
|
+
if (pending) return;
|
|
346
|
+
pending = true;
|
|
347
|
+
requestAnimationFrame(() => {
|
|
348
|
+
pending = false;
|
|
349
|
+
if (!list) return;
|
|
350
|
+
for (let i of keep()) void paintPage(i);
|
|
351
|
+
});
|
|
352
|
+
};
|
|
353
|
+
scroll.addEventListener("scroll", follow);
|
|
354
|
+
|
|
355
|
+
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. */
|
|
358
|
+
/** @type {(next: any, faces: any) => Promise<void>} */
|
|
359
|
+
async swap(next, faces) {
|
|
360
|
+
fonts = faces;
|
|
361
|
+
for (let el of pages) el.remove();
|
|
362
|
+
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);
|
|
369
|
+
await repaint();
|
|
370
|
+
},
|
|
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. */
|
|
374
|
+
sizeAll,
|
|
375
|
+
/** Repaint the reach, releasing what it has left behind. */
|
|
376
|
+
repaint,
|
|
377
|
+
/** One reach pass a frame, however many scrolls or resizes ask. */
|
|
378
|
+
follow,
|
|
379
|
+
/** Where each page starts down the sheet, in CSS pixels: what an overlay
|
|
380
|
+
* positioned against the pages has to agree with.
|
|
381
|
+
*
|
|
382
|
+
* Presumes the caller's sheet stacks its pages `GAP` apart — both
|
|
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
|
+
},
|
|
399
|
+
/** The page width the list carries, or `null` before one arrives — what a
|
|
400
|
+
* fit is measured against. */
|
|
401
|
+
width: () => (list ? list.width : null),
|
|
402
|
+
/** @type {(i: number) => HTMLCanvasElement} */
|
|
403
|
+
pageAt,
|
|
404
|
+
count: () => (list ? list.pages.length : 0),
|
|
405
|
+
};
|
|
406
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quario/landing",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"homepage": "https://getquario.com",
|
|
6
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/getquario/quario.git",
|
|
10
|
+
"directory": "packages/landing"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"lib"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"types": "lib/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./lib/index.d.ts",
|
|
21
|
+
"default": "./lib/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"check": "npm run size && npm test && npm run test:browser",
|
|
30
|
+
"size": "size-limit",
|
|
31
|
+
"test": "npm run test:unit && npm run test:types",
|
|
32
|
+
"test:types": "tsc && attw --pack . --profile esm-only",
|
|
33
|
+
"test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
|
|
34
|
+
"prepack": "node -e \"require('fs').copyFileSync('../../LICENSE','LICENSE')\"",
|
|
35
|
+
"postpack": "node -e \"require('fs').rmSync('LICENSE',{force:true})\"",
|
|
36
|
+
"test:browser": "node test/browser/setup.js"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@quario/layout": "^0.4.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@arethetypeswrong/cli": "^0.18.3",
|
|
43
|
+
"@size-limit/preset-small-lib": "^13.0.3",
|
|
44
|
+
"lit": "^3.3.3",
|
|
45
|
+
"playwright": "^1.62.1",
|
|
46
|
+
"size-limit": "^13.0.3",
|
|
47
|
+
"typescript": "^7.0.2"
|
|
48
|
+
},
|
|
49
|
+
"size-limit": [
|
|
50
|
+
{
|
|
51
|
+
"path": "lib/index.js",
|
|
52
|
+
"limit": "6 kB",
|
|
53
|
+
"ignore": [
|
|
54
|
+
"@quario/layout"
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
],
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=22.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|