@quillmark/svelte 0.26.2 → 0.27.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/THEMING.md CHANGED
@@ -127,7 +127,7 @@ import '@quillmark/svelte/preset';
127
127
  </body>
128
128
  ```
129
129
 
130
- This is the stylesheet our own apps import: the playground and studio draw with these rules and nothing else, so what you get is what we ship, not a reduction of it.
130
+ This is the stylesheet our own apps import: the playground, studio and quillkit's app client draw with these rules and nothing else, so what you get is what we ship, not a reduction of it.
131
131
 
132
132
  It carries a `--qmh-*` scale for the page, derived from the same nine dials and calibrated against the surfaces' own steps, so a plate of yours and a card of ours agree about which way "raised" goes. Turning a dial moves both.
133
133
 
@@ -177,9 +177,11 @@ An app that mounts an editor beside a preview over one session needs a screen to
177
177
 
178
178
  The narrow shape is one mount at a time, not two of them stacked: half a narrow viewport is under the width either surface reads at. The track you are not showing is `display: none`, so it leaves the tab order, hit-testing and the accessibility tree together: you need no `inert` on it, and no breakpoint in your JS, since the only state you hold is which of the two you last asked for. Keep both mounted: the hidden pane keeps its pixels, its caret and its scroll, and comes back to them — it rasters nothing at a box it has none of, and repaints only where it returns to a box that draws its pages at another scale. A preview scroll asked for while it was hidden is held and run on the way back, so it opens at the caret rather than several edits behind it. A click that lands in the hidden pane is the one thing the shape adds to your wiring, so send the caret and reveal the track together, or the hit goes somewhere the reader cannot see.
179
179
 
180
+ Every rule behind the five is wrapped in `:where()`, so it computes at specificity zero: a rule of yours on the same element wins at any specificity and in any sheet order, which is what lets a stylesheet written by someone other than the app's author (an `app.css` over quillkit's app client) restyle a band without knowing which shell rules set it.
181
+
180
182
  The shape is shared; the look of a band is not. Your wordmark, your nav and the depth of your head are the most app-specific things on the screen, and a preset that drew them would hand every tool built on this the same face, so they read `--qmh-*` like everything else and you write the rules. `.qm-split`'s gap is a default rather than a fact about splitting: close it to `0` and carry a hairline on one pane if you want the two mounts to meet instead of stand apart.
181
183
 
182
- It is **unlayered** and at ordinary specificity, so your own rules beat it by ordinary precedence. It is also opt-in and side-effect free until imported: nothing that styles your document arrives with a component.
184
+ It is **unlayered**, and nothing in it outranks a rule of yours aimed at the same element: the page, type, status and control recipes compute at ordinary specificity, so your own rules beat them by ordinary precedence, and the shell and the app chrome compute at specificity zero, as the surfaces do. It is also opt-in and side-effect free until imported: nothing that styles your document arrives with a component.
183
185
 
184
186
  One rule in it earns its place regardless of whether you take the rest, and you want it if you draw a focus ring of your own — cut the mounted surfaces out of the blanket:
185
187
 
@@ -191,6 +193,38 @@ One rule in it earns its place regardless of whether you take the rest, and you
191
193
 
192
194
  A blanket `:focus-visible` of yours outranks ours, including on the controls that already draw one, so a ring written for the controls that draw none repaints the ones that do. The cut-out leaves yours everywhere else, and `:not(:where(…))` contributes no specificity, so your rule ranks exactly as it would without it.
193
195
 
196
+ ### The app chrome
197
+
198
+ A tool's bands are yours, since a tool's head is the most app-specific thing on its screen. An app whose whole page is one form to fill in has less to say there: a name, the action the page leads to, and a line signing it. Four classes draw that much, quillkit's app client wears them, and a host of its own takes them the same way:
199
+
200
+ | Class | What it is |
201
+ | ----------------- | ------------------------------------------------------------------------------------------------- |
202
+ | `.qm-app-header` | The band naming the app: a plate, a gutter and a hairline under it. Put it beside `.qm-bar`. |
203
+ | `.qm-app-title` | What the app is called, at the title rung: the one line on the screen read as a name. |
204
+ | `.qm-app-footer` | The line signing the app, in meta type under a hairline; its links take the line's own ink. |
205
+ | `.qm-app-primary` | The one action the page leads to: the control wearing `--qm-accent`, standing at the touch floor. |
206
+
207
+ They compute at specificity zero, as the shell does, so `.qm-app-header { background: #00308f; color: #fff; }` is the whole of rebranding the head.
208
+
209
+ ## What a stylesheet may aim at
210
+
211
+ A stylesheet you write against a mounted surface, or one an author writes over an app someone else built, may aim at these and nothing else:
212
+
213
+ - the nine dials (§"The dials");
214
+ - the surface roots, `.qm-editor` and `.qm-preview`, and `.qm-pane`;
215
+ - `data-qm-card="<kind>"`, on the box of every card the editor draws, its value the card's kind and `main` for the main card;
216
+ - the preset's classes, on your own DOM, if you import it (§"Match ours").
217
+
218
+ `data-qm-card` is the one hook named by the schema rather than by the package: a rule aimed at a kind lands on that kind's cards wherever the editor puts them, which is the one thing a quill-specific restyle needs and a dial cannot say.
219
+
220
+ ```css
221
+ [data-qm-card='indorsement'] {
222
+ border-left: 3px solid var(--qm-accent);
223
+ }
224
+ ```
225
+
226
+ Nothing names a field or a property. Every other `data-qm-*` attribute the surfaces carry, `data-qm-prop` among them, is something the surface queries itself, and moves when it does.
227
+
194
228
  ## What is deliberately not public
195
229
 
196
230
  The two root classes and `.qm-pane` are the whole class contract on the surfaces. Every other `qm-*` class inside them is internal and renames without notice: a class contract over the interior would freeze the DOM shape the surfaces are free to re-cut, and the dials already reach the values a restyle wants. The preset's classes are a contract of their own: they land on _your_ DOM, so they freeze nothing of ours.
@@ -0,0 +1,69 @@
1
+ /* An app's own chrome: the band that names it, the line that signs it, and the one
2
+ action it exists for. The shell gives a band its row; these give the app's bands their
3
+ look, for a host whose page is one form rather than a workroom. quillkit's app client
4
+ wears them, and an author's `app.css` restyles them (THEMING §"The app chrome").
5
+
6
+ Specificity zero throughout, as the shell's: a rule of the host's own on the same
7
+ element wins at any specificity and in any sheet order. `check:style` holds the rank.
8
+ This file mints nothing. */
9
+
10
+ /* The head: the plate the title stands on, a hairline under it, and the ink everything on
11
+ it takes, so a host recolouring the band recolours its name with it. */
12
+ :where(.qm-app-header) {
13
+ padding: var(--qmh-space-2) var(--qmh-space-4);
14
+ color: var(--qmh-ink);
15
+ background: var(--qmh-surface);
16
+ border-block-end: var(--qmh-border-width) solid var(--qmh-border);
17
+ }
18
+
19
+ /* What the app is called. Display type rather than chrome type: it is the one line on
20
+ the screen a reader reads as a name. */
21
+ :where(.qm-app-title) {
22
+ margin: 0;
23
+ font-size: var(--qmh-text-title);
24
+ font-weight: var(--qmh-weight-strong);
25
+ line-height: var(--qmh-leading-tight);
26
+ color: inherit;
27
+ overflow-wrap: anywhere;
28
+ }
29
+
30
+ /* The foot: a line of meta type under a hairline, its links in the line's own ink. */
31
+ :where(.qm-app-footer) {
32
+ padding: var(--qmh-space-2) var(--qmh-space-4);
33
+ border-block-start: var(--qmh-border-width) solid var(--qmh-border);
34
+ font-size: var(--qmh-text-meta);
35
+ line-height: var(--qmh-leading-tight);
36
+ color: var(--qmh-ink-meta);
37
+ }
38
+
39
+ :where(.qm-app-footer a) {
40
+ color: inherit;
41
+ }
42
+
43
+ /* The primary action: the one control on the screen that wears the accent, at the touch
44
+ floor, since it is the press the whole page leads to. */
45
+ :where(.qm-app-primary) {
46
+ font: inherit;
47
+ font-size: var(--qmh-text-label);
48
+ font-weight: var(--qmh-weight-strong);
49
+ line-height: var(--qmh-leading-tight);
50
+ min-height: var(--qmh-tap);
51
+ padding: var(--qmh-space-2) var(--qmh-space-4);
52
+ color: var(--qmh-on-accent);
53
+ background: var(--qmh-accent);
54
+ border: var(--qmh-border-width) solid var(--qmh-accent);
55
+ border-radius: var(--qmh-radius-inner);
56
+ cursor: pointer;
57
+ transition: background-color var(--qmh-duration) var(--qmh-ease-reverse);
58
+ }
59
+
60
+ :where(.qm-app-primary:hover) {
61
+ background: var(--qmh-accent-hover);
62
+ }
63
+
64
+ :where(.qm-app-primary:disabled) {
65
+ color: var(--qmh-ink-meta);
66
+ background: var(--qmh-surface-hover);
67
+ border-color: var(--qmh-border);
68
+ cursor: default;
69
+ }
@@ -3,8 +3,11 @@
3
3
  the surface barrels pull `core/theme.css` and never this, because this
4
4
  styles the document and nothing may put that in a consumer's page uninvited.
5
5
 
6
- Two files because one of them is exempt: `scale.css` is where the literals are
7
- minted, `recipes.css` is held to reading them. */
6
+ `scale.css` is where the literals are minted and is exempt; the rest are held to
7
+ reading them. `recipes.css` ranks at ordinary specificity, `shell.css` and `app.css`
8
+ at zero. */
8
9
 
9
10
  @import './scale.css';
10
11
  @import './recipes.css';
12
+ @import './shell.css';
13
+ @import './app.css';
@@ -1,5 +1,6 @@
1
- /* The endorsed look: the rules a host draws around a mounted surface and the shell it
2
- mounts one in, as classes it puts on its own elements. The rungs beside this in
1
+ /* The endorsed look: the rules a host draws around a mounted surface, as classes it puts
2
+ on its own elements; the shell it mounts one in is `shell.css` beside this, and an
3
+ app's own chrome is `app.css`. The rungs beside this in
3
4
  `scale.css` are the invisible half (a scale alone still leaves a consumer writing
4
5
  every declaration before anything on the page looks like anything), so the recipes
5
6
  are what the preset is for. This file mints nothing; `check:style` runs its axes here.
@@ -40,156 +41,6 @@
40
41
  max-width: var(--qmh-measure);
41
42
  }
42
43
 
43
- /* ── The workspace ─────────────────────────────────────────────────────────── */
44
-
45
- /* The rules above dress the page a surface sits on; these four are the shell it is
46
- mounted in, a tool where `.qm-page` is a document: the screen an app that mounts two
47
- surfaces over one session is, drawn once (ARCHITECTURE §Styling).
48
-
49
- Pinned to the viewport rather than sized to it: a page-height box still leaves the
50
- root scroller counting the overflow its panes clip, and a wheel over the chrome drags
51
- the whole app off the top. Three bands (a head, the body, an optional foot), and the
52
- body takes what is left and may shrink below its content, so the panes in it own
53
- their overflow and hold still while their contents move.
54
-
55
- The one column is stated for the same reason the rows are: an implicit track is `auto`,
56
- which is sized by what a band holds, and a `.qm-split` under one contributes its items'
57
- max-content through its `fr` tracks. The shell would take the width of the widest thing
58
- mounted in it instead of the viewport's, and every band would be that wide with the
59
- second pane past the edge. A host holding its content to a maximum never sees it; one
60
- spending the whole screen sees it on the first surface it mounts. */
61
- .qm-workspace {
62
- position: fixed;
63
- inset: 0;
64
- display: grid;
65
- grid-template-columns: minmax(0, 1fr);
66
- grid-template-rows: auto minmax(0, 1fr) auto;
67
- overflow: hidden;
68
- }
69
-
70
- /* A gesture past the end of a pane chains out to the viewport and bounces the empty
71
- document, which is the last way a pinned shell moves. Conditioned on the workspace, so
72
- a host's other routes keep a page's own feel. */
73
- html:has(.qm-workspace) {
74
- overscroll-behavior: none;
75
- }
76
-
77
- /* A band's line: the row a head or a foot puts its parts on, wrapping, centred. That is
78
- the whole of it. What a band is made of is the host's: the rule under it, the gutter
79
- beside it, and the depth of it, because where a band ends and how tall it stands are
80
- page-shell decisions: a site holding its content to a maximum runs the rule past it at
81
- its own rhythm, and a one-screen tool has no maximum to hold to. */
82
- .qm-bar {
83
- display: flex;
84
- flex-wrap: wrap;
85
- align-items: center;
86
- gap: var(--qmh-space-2) var(--qmh-space-4);
87
- }
88
-
89
- /* Two even tracks for two surfaces, and no resizer, since a divider that can be dragged
90
- is a control competing with what it divides. Each track may shrink below its content,
91
- which is what lets the frame in it clip rather than grow.
92
-
93
- The gap is the endorsed default and not a fact about splitting: two surfaces standing
94
- apart on a page read as two things, which is what a host showing its instruments wants.
95
- A host that spends the whole screen on the mounts closes it to nothing and carries a
96
- hairline on one pane instead: one property, and the tracks are untouched. */
97
- .qm-split {
98
- display: grid;
99
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
100
- grid-template-rows: minmax(0, 1fr);
101
- gap: var(--qmh-space-4);
102
- min-height: 0;
103
- }
104
-
105
- /* The band that switches them, and nothing at the widths where both tracks are showing
106
- and there is nothing to switch. Where it stands is the host's, like any other band; what
107
- goes in it is a pair of `.qm-control`s, which already carry the on-state a pressed
108
- control takes. Its visibility is the preset's because the threshold is: a host hiding
109
- its own tab row writes this file's number a second time, which is the one thing the
110
- single source is for. */
111
- .qm-switch {
112
- display: none;
113
- }
114
-
115
- /* One mount at a time under the threshold, rather than two of them abreast. Half a narrow
116
- viewport is under the width either surface reads at, so the split stops being one and
117
- the tracks become views of the same room: the host says which with `data-qm-show`, and
118
- the attribute names the track rather than what is in it, so nothing here knows an editor
119
- from a preview. No attribute is the first track.
120
-
121
- The other track is `display: none` and not a clip, which is the whole of the hiding: it
122
- leaves the tab order, hit-testing and the accessibility tree in one property, so the
123
- pane needs no `inert` and the host needs no breakpoint in JS. Both stay mounted (a
124
- preview binds its handle once, an editor holds a caret, a history and a scroll), so
125
- the switch is presentational, never conditional. A pane hidden this way keeps the
126
- pixels it had: the paint loop's band sweep no-ops on an empty visible set, and its
127
- resize observer holds the last real width rather than rastering at the 0×0 box a hidden
128
- pane reports, so what comes back at the width it left needs no paint at all. The
129
- preview's caret follow spans the same window — a trip asked for against a pane with no
130
- box is held and run when it has one (`preview/bridge.ts`), the pane opening at the
131
- caret rather than several edits behind it.
132
-
133
- Height is the second clause because a landscape phone passes the first one: it clears
134
- the width with room for two tracks and has no body left to put them in. A desktop window
135
- that short wants one mount for the same reason. */
136
- @media (width < 50rem), (height < 30rem) {
137
- .qm-switch {
138
- display: flex;
139
- gap: var(--qmh-space);
140
- }
141
-
142
- /* Even halves of the band, at the height a thumb needs. The floor is the recipe's and
143
- not the host's, because the band exists only at the sizes where the pointer is a
144
- finger: a host is free to stand the band deeper, and has nothing to gain from
145
- standing it shallower than the thing it is tapping. */
146
- .qm-switch > * {
147
- flex: 1 1 0;
148
- min-height: var(--qmh-tap);
149
- }
150
-
151
- /* Centred, which is what a control filling half a band reads as; `.qm-control` itself
152
- reads left, for the menus it also dresses. */
153
- .qm-switch > .qm-control {
154
- text-align: center;
155
- }
156
-
157
- .qm-split {
158
- grid-template-columns: minmax(0, 1fr);
159
- }
160
-
161
- .qm-split:not([data-qm-show='2']) > :nth-child(2),
162
- .qm-split[data-qm-show='2'] > :nth-child(1) {
163
- display: none;
164
- }
165
- }
166
-
167
- /* The mounting site: an edge, a corner, and the clip that holds a surface to them, which
168
- is all a surface painting its own column needs. No tone and no gutter: those are the
169
- surface's own (THEMING §"Drop it in"). `border-box`, so a stated height is the height
170
- it draws.
171
-
172
- Positioned, because a clip reaches only what it is the containing block for: a
173
- surface's out-of-flow parts (a parked description, an overlay) resolve against the
174
- nearest positioned ancestor, and with none between them and the document they land at
175
- their static position unclipped, leaving the page to scroll past its own foot by
176
- however far the frame's contents run over.
177
-
178
- The two minimums are what let a frame in a track clip rather than push: a grid or flex
179
- item floors at its content, so a mount that paints wider than its column widens the
180
- column instead. They ride the frame rather than the split's children, which keeps every
181
- rule in this file landing where the host put a class. Outside a track they are the
182
- initial value and do nothing. */
183
- .qm-frame {
184
- box-sizing: border-box;
185
- position: relative;
186
- min-width: 0;
187
- min-height: 0;
188
- border: var(--qmh-border-width) solid var(--qmh-border);
189
- border-radius: var(--qmh-radius);
190
- overflow: hidden;
191
- }
192
-
193
44
  /* ── Type ──────────────────────────────────────────────────────────────────── */
194
45
 
195
46
  /* A run of chrome type: a section's label, a panel's caption, the name over a readout.
@@ -306,6 +157,13 @@ pre.qm-readout {
306
157
  background: var(--qmh-surface-hover);
307
158
  }
308
159
 
160
+ /* Centred in the shell's switch band, which is what a control filling half a band reads
161
+ as; `.qm-control` itself reads left, for the menus it also dresses. Here rather than
162
+ beside the band in `shell.css`, which ranks at zero and so could not outrank this. */
163
+ .qm-switch > .qm-control {
164
+ text-align: center;
165
+ }
166
+
309
167
  /* One choice means nothing to pick: the control stays, because what it reads is a fact
310
168
  worth having, and it recedes to say it is not a choice. */
311
169
  .qm-control:disabled {
@@ -18,7 +18,7 @@
18
18
  could not be both.
19
19
 
20
20
  Literals live here and nowhere else in the preset: `check:style` runs its axes over
21
- `recipes.css` beside it, with this file exempt as the place the defaults are minted.
21
+ the sheets beside it, with this file exempt as the place the defaults are minted.
22
22
 
23
23
  The poles fall back to the system colours, so a host that turns no dial still lands
24
24
  where its own unstyled text does — `Canvas` being the plane the UA would have
@@ -48,6 +48,14 @@
48
48
  the underline under a link. */
49
49
  --qmh-border-strong: color-mix(in oklab, var(--qmh-surface), var(--qmh-ink) 40%);
50
50
 
51
+ /* The one hue a host's page spends on purpose: the primary action wears it. The
52
+ figures are the surfaces' own default for the same dial, so a page that turns no
53
+ dial and a card's focus ring agree; the ink on it is the plate's, the pole the
54
+ accent's default contrasts against in both schemes. */
55
+ --qmh-accent: var(--qm-accent, light-dark(#71717a, #a1a1aa));
56
+ --qmh-on-accent: var(--qmh-surface);
57
+ --qmh-accent-hover: color-mix(in oklab, var(--qmh-accent), var(--qmh-ink) 15%);
58
+
51
59
  /* The hue a host spends on a failure. The figures are the surfaces' own, so a
52
60
  page's status line and a card's diagnostic carry one colour meaning error.
53
61
  Each pole is its own figure: a hue calibrated against white reads as a stain
@@ -92,6 +100,7 @@
92
100
  surfaces' ramp, which is chrome inside a card, and a page's body text is a
93
101
  different job at a different size. A host that wants them locked sets both. */
94
102
  --qmh-text: 1rem;
103
+ --qmh-text-title: 1.125rem;
95
104
  --qmh-text-label: 0.8125rem;
96
105
  --qmh-text-meta: 0.6875rem;
97
106
  --qmh-weight-strong: 600;
@@ -0,0 +1,147 @@
1
+ /* The shell: the screen an app mounting two surfaces over one session is, drawn once
2
+ (THEMING §"The shell, if you are building a tool"). `recipes.css` dresses the page a
3
+ surface sits on; this is what it is mounted in, a tool where `.qm-page` is a document.
4
+
5
+ Every selector here computes at specificity zero, as the surfaces' own do: a host's
6
+ rule on the same element wins whatever order the sheets land in, and an app whose
7
+ stylesheet someone else writes (quillkit's app client, under an author's `app.css`)
8
+ needs no knowledge of which of these the client happened to set. `check:style` holds
9
+ the rank. This file mints nothing. */
10
+
11
+ /* Pinned to the viewport rather than sized to it: a page-height box still leaves the
12
+ root scroller counting the overflow its panes clip, and a wheel over the chrome drags
13
+ the whole app off the top. Three bands (a head, the body, an optional foot), and the
14
+ body takes what is left and may shrink below its content, so the panes in it own
15
+ their overflow and hold still while their contents move.
16
+
17
+ The one column is stated for the same reason the rows are: an implicit track is `auto`,
18
+ which is sized by what a band holds, and a `.qm-split` under one contributes its items'
19
+ max-content through its `fr` tracks. The shell would take the width of the widest thing
20
+ mounted in it instead of the viewport's, and every band would be that wide with the
21
+ second pane past the edge. A host holding its content to a maximum never sees it; one
22
+ spending the whole screen sees it on the first surface it mounts. */
23
+ :where(.qm-workspace) {
24
+ position: fixed;
25
+ inset: 0;
26
+ display: grid;
27
+ grid-template-columns: minmax(0, 1fr);
28
+ grid-template-rows: auto minmax(0, 1fr) auto;
29
+ overflow: hidden;
30
+ }
31
+
32
+ /* A gesture past the end of a pane chains out to the viewport and bounces the empty
33
+ document, which is the last way a pinned shell moves. Conditioned on the workspace, so
34
+ a host's other routes keep a page's own feel. */
35
+ :where(html:has(.qm-workspace)) {
36
+ overscroll-behavior: none;
37
+ }
38
+
39
+ /* A band's line: the row a head or a foot puts its parts on, wrapping, centred. That is
40
+ the whole of it. What a band is made of is the host's: the rule under it, the gutter
41
+ beside it, and the depth of it, because where a band ends and how tall it stands are
42
+ page-shell decisions: a site holding its content to a maximum runs the rule past it at
43
+ its own rhythm, and a one-screen tool has no maximum to hold to. */
44
+ :where(.qm-bar) {
45
+ display: flex;
46
+ flex-wrap: wrap;
47
+ align-items: center;
48
+ gap: var(--qmh-space-2) var(--qmh-space-4);
49
+ }
50
+
51
+ /* Two even tracks for two surfaces, and no resizer, since a divider that can be dragged
52
+ is a control competing with what it divides. Each track may shrink below its content,
53
+ which is what lets the frame in it clip rather than grow.
54
+
55
+ The gap is the endorsed default and not a fact about splitting: two surfaces standing
56
+ apart on a page read as two things, which is what a host showing its instruments wants.
57
+ A host that spends the whole screen on the mounts closes it to nothing and carries a
58
+ hairline on one pane instead: one property, and the tracks are untouched. */
59
+ :where(.qm-split) {
60
+ display: grid;
61
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
62
+ grid-template-rows: minmax(0, 1fr);
63
+ gap: var(--qmh-space-4);
64
+ min-height: 0;
65
+ }
66
+
67
+ /* The band that switches them, and nothing at the widths where both tracks are showing
68
+ and there is nothing to switch. Where it stands is the host's, like any other band; what
69
+ goes in it is a pair of `.qm-control`s, which already carry the on-state a pressed
70
+ control takes. Its visibility is the preset's because the threshold is: a host hiding
71
+ its own tab row writes this file's number a second time, which is the one thing the
72
+ single source is for. */
73
+ :where(.qm-switch) {
74
+ display: none;
75
+ }
76
+
77
+ /* One mount at a time under the threshold, rather than two of them abreast. Half a narrow
78
+ viewport is under the width either surface reads at, so the split stops being one and
79
+ the tracks become views of the same room: the host says which with `data-qm-show`, and
80
+ the attribute names the track rather than what is in it, so nothing here knows an editor
81
+ from a preview. No attribute is the first track.
82
+
83
+ The other track is `display: none` and not a clip, which is the whole of the hiding: it
84
+ leaves the tab order, hit-testing and the accessibility tree in one property, so the
85
+ pane needs no `inert` and the host needs no breakpoint in JS. Both stay mounted (a
86
+ preview binds its handle once, an editor holds a caret, a history and a scroll), so
87
+ the switch is presentational, never conditional. A pane hidden this way keeps the
88
+ pixels it had: the paint loop's band sweep no-ops on an empty visible set, and its
89
+ resize observer holds the last real width rather than rastering at the 0×0 box a hidden
90
+ pane reports, so what comes back at the width it left needs no paint at all. The
91
+ preview's caret follow spans the same window — a trip asked for against a pane with no
92
+ box is held and run when it has one (`preview/bridge.ts`), the pane opening at the
93
+ caret rather than several edits behind it.
94
+
95
+ Height is the second clause because a landscape phone passes the first one: it clears
96
+ the width with room for two tracks and has no body left to put them in. A desktop window
97
+ that short wants one mount for the same reason. */
98
+ @media (width < 50rem), (height < 30rem) {
99
+ :where(.qm-switch) {
100
+ display: flex;
101
+ gap: var(--qmh-space);
102
+ }
103
+
104
+ /* Even halves of the band, at the height a thumb needs. The floor is the recipe's and
105
+ not the host's, because the band exists only at the sizes where the pointer is a
106
+ finger: a host is free to stand the band deeper, and has nothing to gain from
107
+ standing it shallower than the thing it is tapping. */
108
+ :where(.qm-switch > *) {
109
+ flex: 1 1 0;
110
+ min-height: var(--qmh-tap);
111
+ }
112
+
113
+ :where(.qm-split) {
114
+ grid-template-columns: minmax(0, 1fr);
115
+ }
116
+
117
+ :where(.qm-split:not([data-qm-show='2']) > :nth-child(2)),
118
+ :where(.qm-split[data-qm-show='2'] > :nth-child(1)) {
119
+ display: none;
120
+ }
121
+ }
122
+
123
+ /* The mounting site: an edge, a corner, and the clip that holds a surface to them, which
124
+ is all a surface painting its own column needs. No tone and no gutter: those are the
125
+ surface's own (THEMING §"Drop it in"). `border-box`, so a stated height is the height
126
+ it draws.
127
+
128
+ Positioned, because a clip reaches only what it is the containing block for: a
129
+ surface's out-of-flow parts (a parked description, an overlay) resolve against the
130
+ nearest positioned ancestor, and with none between them and the document they land at
131
+ their static position unclipped, leaving the page to scroll past its own foot by
132
+ however far the frame's contents run over.
133
+
134
+ The two minimums are what let a frame in a track clip rather than push: a grid or flex
135
+ item floors at its content, so a mount that paints wider than its column widens the
136
+ column instead. They ride the frame rather than the split's children, which keeps every
137
+ rule in this file landing where the host put a class. Outside a track they are the
138
+ initial value and do nothing. */
139
+ :where(.qm-frame) {
140
+ box-sizing: border-box;
141
+ position: relative;
142
+ min-width: 0;
143
+ min-height: 0;
144
+ border: var(--qmh-border-width) solid var(--qmh-border);
145
+ border-radius: var(--qmh-radius);
146
+ overflow: hidden;
147
+ }
@@ -178,6 +178,14 @@
178
178
  pointer-events: none;
179
179
  transition: opacity var(--_qm-reveal);
180
180
  }
181
+ /* A screen with no hover has no pointer to bring it back, and the pill is the way back
182
+ to the fit there, so it stays. */
183
+ @media (hover: none) {
184
+ :global(:where(.qm-preview-zoom[data-idle])) {
185
+ opacity: 1;
186
+ pointer-events: auto;
187
+ }
188
+ }
181
189
  :global(:where(.qm-preview-zoom:focus-visible)) {
182
190
  opacity: 1;
183
191
  pointer-events: auto;
@@ -1,5 +1,5 @@
1
1
  import { boxesForField, rectToPercent, clickToPdfPt, pxToPt, applyPercentRect } from './geometry.js';
2
- /** A pane hidden by the shell's narrow switch (`preset/recipes.css`) measures 0×0, and so
2
+ /** A pane hidden by the shell's narrow switch (`preset/shell.css`) measures 0×0, and so
3
3
  * does every box on it: the clearance arithmetic reads that as a caret clear of both
4
4
  * edges, and a centring computes its trip out of zeros. Neither is an answer about where
5
5
  * the caret is. */
@@ -42,15 +42,17 @@ export function createViewport(container, slots, setScale, resetLabel) {
42
42
  // was hidden at stands, so a return to the same box re-rasters nothing.
43
43
  return width > 0 && height > 0 ? { width, height } : undefined;
44
44
  }
45
- // What the pages are drawn at, the zoom held against this pane's floor, and the floor.
45
+ // What the pages are drawn at, the zoom held against this pane's floor, the floor, and
46
+ // the default fit.
46
47
  function bounds() {
47
48
  const box = pane();
48
49
  if (!box || !slots.length)
49
50
  return undefined;
50
51
  const sizes = slots.map((s) => s.size);
51
52
  const floor = floorZoom(sizes, box);
52
- const current = zoom === undefined ? fitZoom(sizes, box) : clampZoom(zoom, floor);
53
- return { current, floor };
53
+ const fit = fitZoom(sizes, box);
54
+ const current = zoom === undefined ? fit : clampZoom(zoom, floor);
55
+ return { current, floor, fit };
54
56
  }
55
57
  function apply(settle) {
56
58
  const b = bounds();
@@ -113,11 +115,11 @@ export function createViewport(container, slots, setScale, resetLabel) {
113
115
  // A re-fit holds the line at the pane's top edge, so a dragged divider leaves the
114
116
  // reader where they were. The desk turns the browser's own scroll anchoring off
115
117
  // (`Preview.svelte`), which would correct the same move a second time.
116
- function refit() {
118
+ function refit(settle) {
117
119
  if (!container.scrollTop && !container.scrollLeft)
118
- return apply();
120
+ return apply(settle);
119
121
  const r = container.getBoundingClientRect();
120
- holding(r.left + r.width / 2, r.top, () => apply());
122
+ holding(r.left + r.width / 2, r.top, () => apply(settle));
121
123
  }
122
124
  function centre() {
123
125
  const r = container.getBoundingClientRect();
@@ -125,27 +127,29 @@ export function createViewport(container, slots, setScale, resetLabel) {
125
127
  }
126
128
  // The pointer while it is over the pane, else the pane's centre.
127
129
  const anchor = () => pointer ?? centre();
130
+ // A zoom landing on the fit is the default again, and follows the pane from there.
131
+ const settled = (next, b) => next === b.fit ? undefined : next;
128
132
  function step(next, at = anchor()) {
129
133
  const b = bounds();
130
134
  if (!b)
131
135
  return;
132
136
  run = undefined;
133
- zoomTo(next(b), at.x, at.y);
137
+ zoomTo(settled(next(b), b), at.x, at.y);
134
138
  }
135
139
  // About the pane's centre, the pointer being on the pill.
136
140
  const reset = () => step(() => undefined, centre());
137
141
  // The raw zoom a gesture starting now carries from: the run's own while it is still
138
142
  // the one on screen.
139
143
  function from(b) {
140
- return run && run.shown === b.current ? run : { raw: b.current, shown: b.current };
144
+ return run && run.shown === b.current ? run : { raw: b.current, shown: b.current, slack: 0 };
141
145
  }
142
146
  function glideTo(raw, x, y) {
143
147
  const b = bounds();
144
148
  if (!b)
145
149
  return;
146
150
  const start = from(b);
147
- run = glide(start, raw(start), b.floor);
148
- zoomTo(run.shown, x, y, SETTLE_MS);
151
+ run = glide(start, raw(start), b.floor, b.fit);
152
+ zoomTo(settled(run.shown, b), x, y, SETTLE_MS);
149
153
  }
150
154
  // Ctrl-wheel is a mouse's zoom, and the trackpad pinch Chromium and Firefox send as
151
155
  // `deltaY = -100 ln(scale)`, in deltas smaller than a notch's: those track the fingers.
@@ -169,7 +173,7 @@ export function createViewport(container, slots, setScale, resetLabel) {
169
173
  if ((!dir && e.key !== '0') || !bounds())
170
174
  return;
171
175
  e.preventDefault();
172
- step((b) => (dir ? stepZoom(b.current, dir, b.floor) : undefined));
176
+ step((b) => (dir ? stepZoom(b.current, dir, b.floor, b.fit) : undefined));
173
177
  }
174
178
  let pinch;
175
179
  // The fingers on this pane, not one resting on the editor beside it.
@@ -270,13 +274,14 @@ export function createViewport(container, slots, setScale, resetLabel) {
270
274
  ];
271
275
  for (const [target, type, fn, opts] of listeners)
272
276
  target.addEventListener(type, fn, opts);
273
- // The pane's box moving: a dragged divider, a resized window, a hidden track shown.
274
- // Guarded for jsdom, which ships no ResizeObserver.
275
- const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => refit());
277
+ // The pane's box moving: a dragged divider, a resized window, a hidden track shown. A
278
+ // drag is a gesture, so its pages re-raster once it rests. Guarded for jsdom, which
279
+ // ships no ResizeObserver.
280
+ const resizeObserver = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(() => refit(SETTLE_MS));
276
281
  resizeObserver?.observe(container);
277
282
  apply();
278
283
  return {
279
- update: refit,
284
+ update: () => refit(),
280
285
  setZoom(next) {
281
286
  const valid = next !== undefined && Number.isFinite(next) && next > 0 ? next : undefined;
282
287
  if (bounds())
@@ -20,22 +20,26 @@ export declare function fitZoom(pages: readonly PageSize[], pane: Pane): number;
20
20
  * default, which on a pane past the cap is already smaller than that. */
21
21
  export declare function floorZoom(pages: readonly PageSize[], pane: Pane): number;
22
22
  export declare function clampZoom(zoom: number, floor: number): number;
23
- /** The next level in `dir` from `zoom`, skipping levels under `floor`. From between two
24
- * levels the first press lands on the nearer one in that direction; past the last level
25
- * above the floor, the floor. Half a percent of slack, so a zoom shown as a level is at
26
- * it. */
27
- export declare function stepZoom(zoom: number, dir: 1 | -1, floor: number): number;
23
+ /** The next level in `dir` from `zoom`, `fit` among them, skipping levels under `floor`.
24
+ * From between two levels the first press lands on the nearer one in that direction;
25
+ * past the last level above the floor, the floor. Half a percent of slack, so a zoom
26
+ * shown as a level is at it. */
27
+ export declare function stepZoom(zoom: number, dir: 1 | -1, floor: number, fit: number): number;
28
28
  /**
29
- * A continuous gesture's run: where it would be unsnapped (`raw`), and what it shows.
30
- * The run carries `raw` across events, so the detent at 100% holds a slow gesture
31
- * rather than stopping it: a trackpad's small deltas accumulate past the detent, where
32
- * snapping the shown value alone would pin every later event back to it.
29
+ * A continuous gesture's run: the zoom its events have asked for (`raw`), what it shows,
30
+ * and, while it shows a hold, how far past the hold the events have carried (`slack`, a
31
+ * log of the ratio). The run moves `shown` by each event's ratio rather than snapping
32
+ * `raw`, so a trackpad's small deltas carry it out of a hold as smoothly as they carried
33
+ * it in.
33
34
  */
34
35
  export interface Glide {
35
36
  raw: number;
36
37
  shown: number;
38
+ slack: number;
37
39
  }
38
- /** One event of a continuous gesture, carrying the run to `raw`: held at 100% when it
39
- * crosses or lands within {@link DETENT} of it, so one wheel notch across actual size
40
- * stops there. */
41
- export declare function glide(run: Glide, raw: number, floor: number): Glide;
40
+ /** One event of a continuous gesture, carrying the run to `raw`. It stops at `fit` or
41
+ * 100% when it crosses one, at the first it meets, so one wheel notch across either
42
+ * stops there; it leaves a hold once carried {@link DETENT} past, moving on from the
43
+ * hold rather than jumping to where the events would have put it. 100% gives way to a
44
+ * `fit` within the detent of it: two holds that close would pin the run between them. */
45
+ export declare function glide(run: Glide, raw: number, floor: number, fit: number): Glide;
@@ -12,8 +12,10 @@ const FLOOR_FILL = 0.9;
12
12
  /** What `Mod +` and `Mod −` step through: a subset of the levels a browser's own zoom
13
13
  * keys land on, so a step lands somewhere a reader has been before. */
14
14
  export const STEPS = [0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
15
- /** How far past 100% a continuous gesture carries before it leaves it. */
15
+ /** How far, as a log of the zoom's ratio to a hold, a continuous gesture carries past it
16
+ * before it leaves it: a ratio, so a hold is as wide at a phone's fit as at 100%. */
16
17
  const DETENT = 0.04;
18
+ const near = (zoom, hold) => Math.abs(Math.log(zoom / hold)) <= DETENT;
17
19
  /** The default: the widest page fits the pane's width, capped at {@link FIT_CAP}. A
18
20
  * portrait-only document lands at the pane's width, as a narrow pane always drew it. */
19
21
  export function fitZoom(pages, pane) {
@@ -30,23 +32,43 @@ export function floorZoom(pages, pane) {
30
32
  export function clampZoom(zoom, floor) {
31
33
  return Math.min(MAX_ZOOM, Math.max(floor, zoom));
32
34
  }
33
- /** The next level in `dir` from `zoom`, skipping levels under `floor`. From between two
34
- * levels the first press lands on the nearer one in that direction; past the last level
35
- * above the floor, the floor. Half a percent of slack, so a zoom shown as a level is at
36
- * it. */
37
- export function stepZoom(zoom, dir, floor) {
35
+ /** The levels a step lands on: `STEPS` with `fit` among them, which takes the
36
+ * place of any level within {@link DETENT} of it, since fit is the one that follows the
37
+ * pane. */
38
+ function stops(fit) {
39
+ return [...STEPS.filter((s) => !near(s, fit)), fit].sort((a, b) => a - b);
40
+ }
41
+ /** The next level in `dir` from `zoom`, `fit` among them, skipping levels under `floor`.
42
+ * From between two levels the first press lands on the nearer one in that direction;
43
+ * past the last level above the floor, the floor. Half a percent of slack, so a zoom
44
+ * shown as a level is at it. */
45
+ export function stepZoom(zoom, dir, floor, fit) {
46
+ const levels = stops(fit);
38
47
  const next = dir > 0
39
- ? STEPS.find((s) => s > zoom + 0.005)
40
- : [...STEPS].reverse().find((s) => s < zoom - 0.005 && s >= floor);
48
+ ? levels.find((s) => s > zoom + 0.005)
49
+ : levels.reverse().find((s) => s < zoom - 0.005 && s >= floor);
41
50
  return clampZoom(next ?? (dir > 0 ? MAX_ZOOM : floor), floor);
42
51
  }
43
- /** One event of a continuous gesture, carrying the run to `raw`: held at 100% when it
44
- * crosses or lands within {@link DETENT} of it, so one wheel notch across actual size
45
- * stops there. */
46
- export function glide(run, raw, floor) {
47
- let next = clampZoom(raw, floor);
48
- if ((run.shown - 1) * (next - 1) < 0)
49
- next = 1;
50
- const shown = Math.abs(next - 1) <= DETENT ? 1 : next;
51
- return { raw: next, shown: clampZoom(shown, floor) };
52
+ /** One event of a continuous gesture, carrying the run to `raw`. It stops at `fit` or
53
+ * 100% when it crosses one, at the first it meets, so one wheel notch across either
54
+ * stops there; it leaves a hold once carried {@link DETENT} past, moving on from the
55
+ * hold rather than jumping to where the events would have put it. 100% gives way to a
56
+ * `fit` within the detent of it: two holds that close would pin the run between them. */
57
+ export function glide(run, raw, floor, fit) {
58
+ const holds = near(fit, 1) ? [fit] : [fit, 1];
59
+ const step = Math.log(raw / run.raw);
60
+ let next;
61
+ if (holds.includes(run.shown)) {
62
+ const slack = run.slack + step;
63
+ if (Math.abs(slack) <= DETENT)
64
+ return { raw, shown: run.shown, slack };
65
+ next = run.shown * Math.exp(slack - Math.sign(slack) * DETENT);
66
+ }
67
+ else
68
+ next = run.shown * Math.exp(step);
69
+ next = clampZoom(next, floor);
70
+ const crossed = holds
71
+ .filter((t) => (run.shown - t) * (next - t) < 0)
72
+ .sort((a, b) => Math.abs(a - run.shown) - Math.abs(b - run.shown));
73
+ return { raw, shown: crossed[0] ?? next, slack: 0 };
52
74
  }
@@ -52,7 +52,7 @@
52
52
 
53
53
  <!-- Not `.qm-switch`: that name is the preset's pane band, whose narrow-viewport rule
54
54
  stands every child of it at `--qmh-tap`, so a track wearing it grows a thumb three times
55
- its own height over the labels around it (`preset/recipes.css`, held by `check:style`). -->
55
+ its own height over the labels around it (`preset/shell.css`, held by `check:style`). -->
56
56
  <span
57
57
  class="qm-toggle-wrap"
58
58
  data-unset={unset ? '' : undefined}
@@ -211,12 +211,14 @@
211
211
  </script>
212
212
 
213
213
  <!-- `data-qm-card` names the island for a floating surface that has to stay inside one
214
- ({@link FieldHint}). A surface portals to `[data-qm-root]`, so the card is not its
215
- ancestor and nothing else marks the box its guidance belongs in. -->
214
+ ({@link FieldHint}): a surface portals to `[data-qm-root]`, so the card is not its
215
+ ancestor and nothing else marks the box its guidance belongs in. Its value is the card's
216
+ kind, `main` for the main card, which is the one schema-named hook a host's stylesheet
217
+ is promised (THEMING §"What a stylesheet may aim at"). -->
216
218
  <section
217
219
  bind:this={el}
218
220
  class={['qm-card', { 'qm-main': card.isMain, 'qm-unschemable': card.unschemable }]}
219
- data-qm-card
221
+ data-qm-card={card.kind}
220
222
  >
221
223
  {#if card.unschemable}
222
224
  {@render recoveryShell()}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quillmark/svelte",
3
- "version": "0.26.2",
3
+ "version": "0.27.0",
4
4
  "description": "Editor, live-preview and source surfaces over a Quillmark WASM session.",
5
5
  "repository": {
6
6
  "type": "git",