@texuli/core 0.4.0 → 0.6.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.
Files changed (53) hide show
  1. package/README.md +30 -6
  2. package/dist/editor/document.js +8 -1
  3. package/dist/editor/extensions.d.ts +64 -0
  4. package/dist/editor/extensions.js +116 -0
  5. package/dist/editor/inline.d.ts +5 -3
  6. package/dist/editor/inline.js +24 -12
  7. package/dist/editor/load.d.ts +10 -0
  8. package/dist/editor/load.js +16 -0
  9. package/dist/editor/markdown.d.ts +10 -1
  10. package/dist/editor/markdown.js +340 -100
  11. package/dist/editor/model.d.ts +3 -1
  12. package/dist/editor/model.js +3 -1
  13. package/dist/editor/transforms.d.ts +1 -1
  14. package/dist/editor/tree.d.ts +11 -1
  15. package/dist/editor/tree.js +11 -2
  16. package/dist/editor/types.d.ts +10 -0
  17. package/dist/editor/types.js +1 -0
  18. package/dist/focus-scope/focus-scope.d.ts +7 -0
  19. package/dist/focus-scope/focus-scope.js +24 -0
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/inert/inert.d.ts +11 -0
  23. package/dist/inert/inert.js +13 -4
  24. package/dist/markup/index.d.ts +5 -0
  25. package/dist/markup/index.js +5 -0
  26. package/dist/markup/markup.d.ts +72 -0
  27. package/dist/markup/markup.js +233 -0
  28. package/dist/motion/animate.d.ts +109 -0
  29. package/dist/motion/animate.js +225 -0
  30. package/dist/motion/flip.d.ts +58 -0
  31. package/dist/motion/flip.js +167 -0
  32. package/dist/motion/index.d.ts +15 -0
  33. package/dist/motion/index.js +15 -0
  34. package/dist/motion/preferences.d.ts +68 -0
  35. package/dist/motion/preferences.js +111 -0
  36. package/dist/motion/presence.d.ts +61 -0
  37. package/dist/motion/presence.js +231 -0
  38. package/dist/motion/sequence.d.ts +70 -0
  39. package/dist/motion/sequence.js +159 -0
  40. package/dist/motion/shared.d.ts +45 -0
  41. package/dist/motion/shared.js +144 -0
  42. package/dist/motion/spring.d.ts +109 -0
  43. package/dist/motion/spring.js +166 -0
  44. package/dist/overlay/core.js +16 -3
  45. package/dist/url-policy/filter-image-srcset.d.ts +11 -0
  46. package/dist/url-policy/filter-image-srcset.js +60 -0
  47. package/dist/url-policy/index.d.ts +1 -0
  48. package/dist/url-policy/index.js +1 -0
  49. package/dist/zip/index.d.ts +5 -0
  50. package/dist/zip/index.js +5 -0
  51. package/dist/zip/zip.d.ts +35 -0
  52. package/dist/zip/zip.js +190 -0
  53. package/package.json +6 -2
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Shared-element transitions (ADR-013): a keyed snapshot of one element's box
3
+ * before a state change, played onto the element that represents the same
4
+ * thing after it (a thumbnail and its lightbox, a card and its detail view).
5
+ *
6
+ * The fallback everywhere is FLIP: the new element moves and scales from the
7
+ * captured box; when the old element is still on the page the two cross-fade.
8
+ * {@link startScopedViewTransition} uses an element-scoped View Transition
9
+ * when the browser has one and otherwise just applies the update.
10
+ */
11
+ import { createRun, resolveMotion, startAnimation, } from "./animate.js";
12
+ import { flipTransform } from "./flip.js";
13
+ import { resolveMotionLevel } from "./preferences.js";
14
+ function measure(element) {
15
+ if (!element?.isConnected || typeof element.getBoundingClientRect !== 'function')
16
+ return null;
17
+ const r = element.getBoundingClientRect();
18
+ const box = { left: r.left, top: r.top, width: r.width, height: r.height };
19
+ return Object.values(box).every(Number.isFinite) ? box : null;
20
+ }
21
+ /**
22
+ * Create a registry of keyed snapshots.
23
+ *
24
+ * @example
25
+ * const shared = createSharedElements({ duration: 250 });
26
+ * shared.capture('photo-3', thumbnail);
27
+ * openLightbox(); // the DOM changes
28
+ * shared.play('photo-3', lightboxImage);
29
+ */
30
+ export function createSharedElements(options = {}) {
31
+ const limit = Math.max(1, Math.min(4096, Math.floor(Number.isFinite(options.maxEntries) ? options.maxEntries : 256)));
32
+ const snapshots = new Map();
33
+ return {
34
+ capture(key, element) {
35
+ const box = element ? measure(element) : null;
36
+ const id = String(key);
37
+ snapshots.delete(id);
38
+ if (!box)
39
+ return false;
40
+ snapshots.set(id, { element, box });
41
+ while (snapshots.size > limit) {
42
+ const oldest = snapshots.keys().next().value;
43
+ if (oldest === undefined)
44
+ break;
45
+ snapshots.delete(oldest);
46
+ }
47
+ return true;
48
+ },
49
+ play(key, element) {
50
+ const id = String(key);
51
+ const snapshot = snapshots.get(id);
52
+ snapshots.delete(id);
53
+ const last = element ? measure(element) : null;
54
+ if (!snapshot || !last) {
55
+ return createRun([], options.onFinish, last ? 'no-properties' : 'detached');
56
+ }
57
+ const transform = flipTransform(snapshot.box, last, true);
58
+ const crossFade = snapshot.element !== element && snapshot.element.isConnected;
59
+ const toFrames = [
60
+ {
61
+ transform: transform ?? 'none',
62
+ transformOrigin: '0 0',
63
+ ...(crossFade ? { opacity: 0 } : {}),
64
+ },
65
+ { transform: 'none', transformOrigin: '0 0', ...(crossFade ? { opacity: 1 } : {}) },
66
+ ];
67
+ if (!transform && !crossFade)
68
+ return createRun([], options.onFinish, 'no-properties');
69
+ // Read phase done; write phase.
70
+ const plans = [
71
+ [element, resolveMotion(element, toFrames, options)],
72
+ ];
73
+ if (crossFade) {
74
+ // The old element travels from its box onto the new one while it fades out.
75
+ const travel = flipTransform(last, snapshot.box, true) ?? 'none';
76
+ plans.push([
77
+ snapshot.element,
78
+ resolveMotion(snapshot.element, [
79
+ { transform: 'none', transformOrigin: '0 0', opacity: 1 },
80
+ { transform: travel, transformOrigin: '0 0', opacity: 0 },
81
+ ], options),
82
+ ]);
83
+ }
84
+ const animations = [];
85
+ let reason = null;
86
+ for (const [target, plan] of plans) {
87
+ if (plan.instant) {
88
+ reason ??= plan.reason;
89
+ continue;
90
+ }
91
+ const animation = startAnimation(target, plan);
92
+ if (animation)
93
+ animations.push(animation);
94
+ else
95
+ reason ??= 'error';
96
+ }
97
+ return createRun(animations, options.onFinish, reason);
98
+ },
99
+ has: (key) => snapshots.has(String(key)),
100
+ release: (key) => {
101
+ snapshots.delete(String(key));
102
+ },
103
+ clear: () => snapshots.clear(),
104
+ get size() {
105
+ return snapshots.size;
106
+ },
107
+ };
108
+ }
109
+ /**
110
+ * Run `update` inside an element-scoped View Transition when the browser
111
+ * offers `element.startViewTransition`; otherwise (no support, the server,
112
+ * level `none`, reduced motion) apply it at once. Resolves `"finished"` after
113
+ * the transition, `"instant"` when the update was applied directly.
114
+ */
115
+ export async function startScopedViewTransition(scope, update, options = {}) {
116
+ const host = scope;
117
+ // Same gates as any animation (server, detached, level, reduced motion);
118
+ // a missing Web Animations API does not matter here.
119
+ const probe = resolveMotion(host ?? null, [{ opacity: 1 }], {
120
+ level: resolveMotionLevel({ host: options.level }),
121
+ reducedMotion: options.reducedMotion,
122
+ duration: 1,
123
+ });
124
+ const usable = !probe.instant || probe.reason === 'no-waapi';
125
+ if (!host || !usable || typeof host.startViewTransition !== 'function') {
126
+ await update();
127
+ return 'instant';
128
+ }
129
+ let transition;
130
+ try {
131
+ transition = host.startViewTransition.call(host, update);
132
+ }
133
+ catch {
134
+ await update();
135
+ return 'instant';
136
+ }
137
+ try {
138
+ await transition.finished;
139
+ return 'finished';
140
+ }
141
+ catch {
142
+ return 'cancelled';
143
+ }
144
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Spring: a pure damped harmonic oscillator solver (ADR-013).
3
+ *
4
+ * Framework-free and DOM-free. The solver moves a normalized progress from 0
5
+ * to 1 with the closed form for every damping case (under, critical and over
6
+ * damped), finds when the spring settles, samples it into keyframe offsets or
7
+ * a CSS `linear()` easing, and reports position and velocity at any time so an
8
+ * interrupted animation can be retargeted without a velocity jump.
9
+ *
10
+ * Hostile inputs are clamped: stiffness, damping, mass, velocity and sample
11
+ * counts have fixed ranges, NaN falls back to the defaults, and the settle
12
+ * search never evaluates more than `MAX_SPRING_MS + 1` points.
13
+ */
14
+ /** Physical spring parameters (unitless, like the CSS-in-JS spring convention). */
15
+ export type SpringParams = {
16
+ /** Spring constant `k`. Clamped to [1, 10000]. */
17
+ readonly stiffness: number;
18
+ /** Damping coefficient `c`. Clamped to [0, 1000]. */
19
+ readonly damping: number;
20
+ /** Mass `m`. Clamped to [0.01, 1000]. */
21
+ readonly mass: number;
22
+ };
23
+ /** Options shared by the solver helpers. */
24
+ export type SpringSolveOptions = {
25
+ /**
26
+ * Initial velocity in progress units per second (1 = the whole distance per
27
+ * second). Clamped to [-1000, 1000]. Default 0.
28
+ */
29
+ velocity?: number;
30
+ /** Settled when |1 − progress| is below this. Default 0.001. */
31
+ restDelta?: number;
32
+ /** Settled when |velocity| (progress per second) is below this. Default 0.01. */
33
+ restSpeed?: number;
34
+ };
35
+ /** Position and velocity of the spring at one time. */
36
+ export type SpringState = {
37
+ /** Normalized progress (0 at rest start, 1 at the target; may overshoot). */
38
+ readonly position: number;
39
+ /** Progress units per second. */
40
+ readonly velocity: number;
41
+ };
42
+ /** Settle result. */
43
+ export type SpringSettle = {
44
+ /** Milliseconds until the spring stays within rest thresholds, at most `MAX_SPRING_MS`. */
45
+ readonly durationMs: number;
46
+ /** False when the spring was still moving at `MAX_SPRING_MS` (the last frame snaps to 1). */
47
+ readonly settled: boolean;
48
+ };
49
+ /** Sampled spring curve. */
50
+ export type SpringSamples = SpringSettle & {
51
+ /** Progress values at evenly spaced times from 0 to `durationMs`; first 0, last 1. */
52
+ readonly points: readonly number[];
53
+ };
54
+ /** Longest spring (and any Texuli motion) duration, in milliseconds. */
55
+ export declare const MAX_SPRING_MS = 1000;
56
+ /** Default sample count for keyframes and `linear()` easings. */
57
+ export declare const DEFAULT_SPRING_SAMPLES = 40;
58
+ /** Largest sample count accepted (keeps `linear()` strings short). */
59
+ export declare const MAX_SPRING_SAMPLES = 120;
60
+ /** Spring used when parameters are missing or not finite. */
61
+ export declare const DEFAULT_SPRING: SpringParams;
62
+ /** Clamp spring parameters into the supported ranges; NaN takes the default. */
63
+ export declare function normalizeSpring(params: Partial<SpringParams> | null | undefined): SpringParams;
64
+ /** Damping ratio ζ = c / (2√(k·m)); below 1 overshoots, 1 is critical. */
65
+ export declare function dampingRatio(params: Partial<SpringParams>): number;
66
+ /** Position and velocity `ms` milliseconds after release (closed form, O(1)). */
67
+ export declare function springState(params: Partial<SpringParams>, ms: number, options?: SpringSolveOptions): SpringState;
68
+ /**
69
+ * When the spring comes to rest: the first millisecond after which every
70
+ * sampled millisecond stays inside the rest thresholds. Bounded: at most
71
+ * `MAX_SPRING_MS + 1` closed-form evaluations.
72
+ */
73
+ export declare function springSettle(params: Partial<SpringParams>, options?: SpringSolveOptions): SpringSettle;
74
+ /**
75
+ * Sample the spring at evenly spaced times across its settle duration. The
76
+ * first point is the start position (0) and the last is pinned to 1, so a
77
+ * spring cut at `MAX_SPRING_MS` lands exactly on its target.
78
+ */
79
+ export declare function springSamples(params: Partial<SpringParams>, options?: SpringSolveOptions & {
80
+ samples?: number;
81
+ }): SpringSamples;
82
+ /**
83
+ * A CSS `linear()` easing that traces the spring, with its duration. Points
84
+ * are evenly spaced, so no stop percentages are needed.
85
+ *
86
+ * @example
87
+ * const { easing, durationMs } = springEasing({ stiffness: 400, damping: 30, mass: 1 });
88
+ * element.animate(keyframes, { duration: durationMs, easing });
89
+ */
90
+ export declare function springEasing(params: Partial<SpringParams>, options?: SpringSolveOptions & {
91
+ samples?: number;
92
+ }): SpringSettle & {
93
+ readonly easing: string;
94
+ };
95
+ /**
96
+ * Keyframes for any property: `build(progress)` maps a spring position to a
97
+ * keyframe (for example a transform). Offsets are evenly spaced.
98
+ */
99
+ export declare function springKeyframes(params: Partial<SpringParams>, build: (progress: number) => Keyframe, options?: SpringSolveOptions & {
100
+ samples?: number;
101
+ }): SpringSettle & {
102
+ readonly keyframes: Keyframe[];
103
+ };
104
+ /**
105
+ * Velocity for a retargeted spring: a velocity measured in the old segment's
106
+ * progress units, converted to the new segment (`newSpan` / `oldSpan` are the
107
+ * distances in any shared unit). Zero or non-finite spans give 0.
108
+ */
109
+ export declare function retargetVelocity(velocity: number, oldSpan: number, newSpan: number): number;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Spring: a pure damped harmonic oscillator solver (ADR-013).
3
+ *
4
+ * Framework-free and DOM-free. The solver moves a normalized progress from 0
5
+ * to 1 with the closed form for every damping case (under, critical and over
6
+ * damped), finds when the spring settles, samples it into keyframe offsets or
7
+ * a CSS `linear()` easing, and reports position and velocity at any time so an
8
+ * interrupted animation can be retargeted without a velocity jump.
9
+ *
10
+ * Hostile inputs are clamped: stiffness, damping, mass, velocity and sample
11
+ * counts have fixed ranges, NaN falls back to the defaults, and the settle
12
+ * search never evaluates more than `MAX_SPRING_MS + 1` points.
13
+ */
14
+ /** Longest spring (and any Texuli motion) duration, in milliseconds. */
15
+ export const MAX_SPRING_MS = 1000;
16
+ /** Default sample count for keyframes and `linear()` easings. */
17
+ export const DEFAULT_SPRING_SAMPLES = 40;
18
+ /** Largest sample count accepted (keeps `linear()` strings short). */
19
+ export const MAX_SPRING_SAMPLES = 120;
20
+ /** Spring used when parameters are missing or not finite. */
21
+ export const DEFAULT_SPRING = Object.freeze({
22
+ stiffness: 170,
23
+ damping: 26,
24
+ mass: 1,
25
+ });
26
+ function clamp(value, min, max, fallback) {
27
+ if (typeof value !== 'number' || Number.isNaN(value))
28
+ return fallback;
29
+ return Math.min(max, Math.max(min, value));
30
+ }
31
+ /** Clamp spring parameters into the supported ranges; NaN takes the default. */
32
+ export function normalizeSpring(params) {
33
+ const p = params ?? {};
34
+ return {
35
+ stiffness: clamp(p.stiffness ?? DEFAULT_SPRING.stiffness, 1, 10000, DEFAULT_SPRING.stiffness),
36
+ damping: clamp(p.damping ?? DEFAULT_SPRING.damping, 0, 1000, DEFAULT_SPRING.damping),
37
+ mass: clamp(p.mass ?? DEFAULT_SPRING.mass, 0.01, 1000, DEFAULT_SPRING.mass),
38
+ };
39
+ }
40
+ /** Damping ratio ζ = c / (2√(k·m)); below 1 overshoots, 1 is critical. */
41
+ export function dampingRatio(params) {
42
+ const { stiffness, damping, mass } = normalizeSpring(params);
43
+ return damping / (2 * Math.sqrt(stiffness * mass));
44
+ }
45
+ function solver(params, velocity) {
46
+ const { stiffness, damping, mass } = normalizeSpring(params);
47
+ const v0 = clamp(velocity, -1000, 1000, 0);
48
+ const w0 = Math.sqrt(stiffness / mass);
49
+ const zeta = damping / (2 * Math.sqrt(stiffness * mass));
50
+ // Displacement y = position − 1, so y(0) = −1 and y'(0) = v0.
51
+ const a = -1;
52
+ if (Math.abs(zeta - 1) < 1e-6) {
53
+ const b = v0 + w0 * a;
54
+ return (t) => {
55
+ const e = Math.exp(-w0 * t);
56
+ return { position: 1 + (a + b * t) * e, velocity: e * (b - w0 * (a + b * t)) };
57
+ };
58
+ }
59
+ if (zeta < 1) {
60
+ const wd = w0 * Math.sqrt(1 - zeta * zeta);
61
+ const decay = zeta * w0;
62
+ const b = (v0 + decay * a) / wd;
63
+ return (t) => {
64
+ const e = Math.exp(-decay * t);
65
+ const cos = Math.cos(wd * t);
66
+ const sin = Math.sin(wd * t);
67
+ return {
68
+ position: 1 + e * (a * cos + b * sin),
69
+ velocity: e * ((-decay * a + wd * b) * cos + (-decay * b - wd * a) * sin),
70
+ };
71
+ };
72
+ }
73
+ const root = Math.sqrt(zeta * zeta - 1);
74
+ const r1 = -w0 * (zeta - root);
75
+ const r2 = -w0 * (zeta + root);
76
+ const c2 = (v0 - r1 * a) / (r2 - r1);
77
+ const c1 = a - c2;
78
+ return (t) => {
79
+ const e1 = Math.exp(r1 * t);
80
+ const e2 = Math.exp(r2 * t);
81
+ return { position: 1 + c1 * e1 + c2 * e2, velocity: r1 * c1 * e1 + r2 * c2 * e2 };
82
+ };
83
+ }
84
+ /** Position and velocity `ms` milliseconds after release (closed form, O(1)). */
85
+ export function springState(params, ms, options = {}) {
86
+ const t = clamp(ms, 0, Number.MAX_SAFE_INTEGER, 0) / 1000;
87
+ return solver(params, options.velocity ?? 0)(t);
88
+ }
89
+ /**
90
+ * When the spring comes to rest: the first millisecond after which every
91
+ * sampled millisecond stays inside the rest thresholds. Bounded: at most
92
+ * `MAX_SPRING_MS + 1` closed-form evaluations.
93
+ */
94
+ export function springSettle(params, options = {}) {
95
+ const solve = solver(params, options.velocity ?? 0);
96
+ const restDelta = clamp(options.restDelta ?? 0.001, 1e-6, 0.5, 0.001);
97
+ const restSpeed = clamp(options.restSpeed ?? 0.01, 1e-6, 100, 0.01);
98
+ let lastMoving = -1;
99
+ for (let ms = 0; ms <= MAX_SPRING_MS; ms++) {
100
+ const s = solve(ms / 1000);
101
+ if (Math.abs(1 - s.position) >= restDelta || Math.abs(s.velocity) >= restSpeed) {
102
+ lastMoving = ms;
103
+ }
104
+ }
105
+ if (lastMoving >= MAX_SPRING_MS)
106
+ return { durationMs: MAX_SPRING_MS, settled: false };
107
+ return { durationMs: lastMoving + 1, settled: true };
108
+ }
109
+ function sampleCount(samples) {
110
+ return Math.round(clamp(samples ?? DEFAULT_SPRING_SAMPLES, 2, MAX_SPRING_SAMPLES, DEFAULT_SPRING_SAMPLES));
111
+ }
112
+ /**
113
+ * Sample the spring at evenly spaced times across its settle duration. The
114
+ * first point is the start position (0) and the last is pinned to 1, so a
115
+ * spring cut at `MAX_SPRING_MS` lands exactly on its target.
116
+ */
117
+ export function springSamples(params, options = {}) {
118
+ const settle = springSettle(params, options);
119
+ const count = sampleCount(options.samples);
120
+ const solve = solver(params, options.velocity ?? 0);
121
+ const points = [];
122
+ for (let i = 0; i < count; i++) {
123
+ const t = (settle.durationMs * i) / (count - 1) / 1000;
124
+ points.push(i === count - 1 ? 1 : round(solve(t).position));
125
+ }
126
+ points[0] = 0;
127
+ return { ...settle, points };
128
+ }
129
+ function round(value) {
130
+ const r = Math.round(value * 10000) / 10000;
131
+ return Object.is(r, -0) ? 0 : r;
132
+ }
133
+ /**
134
+ * A CSS `linear()` easing that traces the spring, with its duration. Points
135
+ * are evenly spaced, so no stop percentages are needed.
136
+ *
137
+ * @example
138
+ * const { easing, durationMs } = springEasing({ stiffness: 400, damping: 30, mass: 1 });
139
+ * element.animate(keyframes, { duration: durationMs, easing });
140
+ */
141
+ export function springEasing(params, options = {}) {
142
+ const { points, durationMs, settled } = springSamples(params, options);
143
+ return { easing: `linear(${points.join(', ')})`, durationMs, settled };
144
+ }
145
+ /**
146
+ * Keyframes for any property: `build(progress)` maps a spring position to a
147
+ * keyframe (for example a transform). Offsets are evenly spaced.
148
+ */
149
+ export function springKeyframes(params, build, options = {}) {
150
+ const { points, durationMs, settled } = springSamples(params, options);
151
+ const last = points.length - 1;
152
+ const keyframes = points.map((p, i) => ({ ...build(p), offset: i / last }));
153
+ return { keyframes, durationMs, settled };
154
+ }
155
+ /**
156
+ * Velocity for a retargeted spring: a velocity measured in the old segment's
157
+ * progress units, converted to the new segment (`newSpan` / `oldSpan` are the
158
+ * distances in any shared unit). Zero or non-finite spans give 0.
159
+ */
160
+ export function retargetVelocity(velocity, oldSpan, newSpan) {
161
+ if (!Number.isFinite(velocity) || !Number.isFinite(oldSpan) || !Number.isFinite(newSpan))
162
+ return 0;
163
+ if (newSpan === 0)
164
+ return 0;
165
+ return clamp((velocity * oldSpan) / newSpan, -1000, 1000, 0);
166
+ }
@@ -161,10 +161,16 @@ export function createOverlayCore(options, features = {}) {
161
161
  /**
162
162
  * R16: an exempt region shown in the top layer (the toast region as a
163
163
  * `popover`) must keep painting above the overlay that just joined it.
164
- * The top layer orders by show time, so re-show it.
164
+ * The top layer orders by show time, so re-show it. Exemptions registered
165
+ * with `raise: false` (a dialog backdrop, a menu or listbox opened inside a
166
+ * modal) stay where they are: re-showing them would cover this overlay.
165
167
  */
166
168
  function raiseExemptPopovers() {
167
- for (const region of exemptElements()) {
169
+ const regions = (inertRegistry()?.exemptions() ?? [])
170
+ .filter((entry) => entry.raise !== false)
171
+ .map((entry) => entry.element)
172
+ .filter((region) => region !== element && !element.contains(region));
173
+ for (const region of regions) {
168
174
  const popover = region;
169
175
  try {
170
176
  if (!popover.matches(':popover-open'))
@@ -239,12 +245,19 @@ export function createOverlayCore(options, features = {}) {
239
245
  else if (inert && inert.size() > 0 && isLive(element)) {
240
246
  // A menu or popover opened from inside a modal: keep it live while open,
241
247
  // even when it sits outside the modal (portaled to the body).
242
- liveExemption = inert.exempt({ element, label: options.label ?? 'overlay' });
248
+ // `raise: false`: an overlay opened later from this one must stay above it.
249
+ liveExemption = inert.exempt({
250
+ element,
251
+ label: options.label ?? 'overlay',
252
+ raise: false,
253
+ });
243
254
  }
244
255
  if (wantsTrap && features.focusScope) {
245
256
  focusScope = features.focusScope(element, {
246
257
  ...(options.initialFocus ? { initialFocus: options.initialFocus } : {}),
247
258
  ...(options.cycleTabNavigation ? { cycleTabNavigation: true } : {}),
259
+ // Focus-exempt regions (the toast region) keep their own Tab order.
260
+ exemptRegions: exemptElements,
248
261
  onBeforeRestoreFocus: (event) => {
249
262
  if (options.restoreFocus === false)
250
263
  event.preventDefault();
@@ -0,0 +1,11 @@
1
+ import type { HrefTrust } from '../url/safe-href.js';
2
+ /**
3
+ * Filter an image `srcset` candidate by candidate, where `safeImageSrcset`
4
+ * refuses the whole value. Every candidate URL goes through the image URL
5
+ * policy (`safeImageSrc`) at `trust`; a refused or malformed candidate (or one
6
+ * whose URL holds a comma) is dropped and the rest are kept. Candidates keep
7
+ * the descriptor kind of the first kept one (`x` or `w`), since a srcset
8
+ * cannot mix them. Returns `undefined` when nothing is left, so a caller can
9
+ * drop `sizes` with it.
10
+ */
11
+ export declare function filterImageSrcset(value: unknown, trust?: HrefTrust): string | undefined;
@@ -0,0 +1,60 @@
1
+ import { safeImageSrcset } from "./safe-image-srcset.js";
2
+ /**
3
+ * Split a `srcset` into candidates the way the HTML parser reads it: a URL is
4
+ * a run of non-whitespace (so a `data:` URL may hold commas), trailing commas
5
+ * end it, and descriptors run to the next comma.
6
+ */
7
+ function candidates(value) {
8
+ const out = [];
9
+ let index = 0;
10
+ while (index < value.length) {
11
+ while (index < value.length && /[\s,]/.test(value[index] ?? ''))
12
+ index += 1;
13
+ if (index >= value.length)
14
+ break;
15
+ let end = index;
16
+ while (end < value.length && !/\s/.test(value[end] ?? ''))
17
+ end += 1;
18
+ let url = value.slice(index, end);
19
+ index = end;
20
+ if (url.endsWith(',')) {
21
+ url = url.replace(/,+$/, '');
22
+ out.push({ url, descriptors: '' });
23
+ continue;
24
+ }
25
+ let close = index;
26
+ while (close < value.length && value[close] !== ',')
27
+ close += 1;
28
+ out.push({ url, descriptors: value.slice(index, close).trim() });
29
+ index = close + 1;
30
+ }
31
+ return out;
32
+ }
33
+ /**
34
+ * Filter an image `srcset` candidate by candidate, where `safeImageSrcset`
35
+ * refuses the whole value. Every candidate URL goes through the image URL
36
+ * policy (`safeImageSrc`) at `trust`; a refused or malformed candidate (or one
37
+ * whose URL holds a comma) is dropped and the rest are kept. Candidates keep
38
+ * the descriptor kind of the first kept one (`x` or `w`), since a srcset
39
+ * cannot mix them. Returns `undefined` when nothing is left, so a caller can
40
+ * drop `sizes` with it.
41
+ */
42
+ export function filterImageSrcset(value, trust = 'untrusted') {
43
+ if (typeof value !== 'string' || value.trim() === '' || value.length > 16_384)
44
+ return undefined;
45
+ const kept = [];
46
+ let kind;
47
+ for (const { url, descriptors } of candidates(value)) {
48
+ if (url === '' || url.includes(','))
49
+ continue;
50
+ const candidate = safeImageSrcset(descriptors ? `${url} ${descriptors}` : url, trust);
51
+ if (candidate === undefined)
52
+ continue;
53
+ const candidateKind = descriptors.endsWith('w') ? 'width' : 'density';
54
+ kind ??= candidateKind;
55
+ if (candidateKind !== kind)
56
+ continue;
57
+ kept.push(candidate);
58
+ }
59
+ return kept.length > 0 ? safeImageSrcset(kept.join(', '), trust) : undefined;
60
+ }
@@ -1,4 +1,5 @@
1
1
  export { type HrefTrust, safeHref } from '../url/safe-href.js';
2
+ export { filterImageSrcset } from './filter-image-srcset.js';
2
3
  export { safeImageSrc } from './safe-image-src.js';
3
4
  export { safeImageSrcset } from './safe-image-srcset.js';
4
5
  export { type SafeMediaSrcOptions, safeMediaSrc } from './safe-media-src.js';
@@ -1,4 +1,5 @@
1
1
  export { safeHref } from "../url/safe-href.js";
2
+ export { filterImageSrcset } from "./filter-image-srcset.js";
2
3
  export { safeImageSrc } from "./safe-image-src.js";
3
4
  export { safeImageSrcset } from "./safe-image-srcset.js";
4
5
  export { safeMediaSrc } from "./safe-media-src.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @texuli/core/zip: a deterministic ZIP writer (stored, or deflated through
3
+ * the platform `CompressionStream`) and CRC-32 (ADR-016).
4
+ */
5
+ export { assertZipPath, type CompressionFormatName, canCompress, compressBytes, crc32, writeZip, writeZipAsync, type ZipEntry, type ZipOptions, } from './zip.js';
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @texuli/core/zip: a deterministic ZIP writer (stored, or deflated through
3
+ * the platform `CompressionStream`) and CRC-32 (ADR-016).
4
+ */
5
+ export { assertZipPath, canCompress, compressBytes, crc32, writeZip, writeZipAsync, } from "./zip.js";
@@ -0,0 +1,35 @@
1
+ /**
2
+ * A deterministic ZIP writer for export packages (ADR-016). Entries keep
3
+ * their order, use UTF-8 names and a fixed 1980-01-01 timestamp, so the
4
+ * same input always produces the same bytes. `writeZip` stores entries
5
+ * (synchronous, no compression); `writeZipAsync` deflates them through the
6
+ * platform `CompressionStream` when it exists and keeps an entry stored when
7
+ * deflate is missing or would not make it smaller. ZIP64 is not supported.
8
+ */
9
+ export interface ZipEntry {
10
+ path: string;
11
+ data: Uint8Array | string;
12
+ /** `writeZipAsync` only: `false` keeps this entry stored (an already compressed image). */
13
+ compress?: boolean;
14
+ }
15
+ export interface ZipOptions {
16
+ /** Deflate entries when the platform can (default true). */
17
+ compress?: boolean;
18
+ }
19
+ export type CompressionFormatName = 'deflate' | 'deflate-raw';
20
+ /** CRC-32 (IEEE 802.3), as ZIP and PNG use. */
21
+ export declare function crc32(data: Uint8Array): number;
22
+ /** Refuse empty, absolute, backslashed, dot-segment and control-character paths (no traversal on extract). */
23
+ export declare function assertZipPath(path: string): void;
24
+ /** A stored (uncompressed) ZIP, synchronously. */
25
+ export declare function writeZip(entries: readonly ZipEntry[]): Uint8Array;
26
+ /** True when the platform has `CompressionStream` with raw deflate. */
27
+ export declare function canCompress(format?: CompressionFormatName): boolean;
28
+ /**
29
+ * Compress bytes with the platform `CompressionStream` (`deflate` is the
30
+ * zlib wrapper PDF's FlateDecode reads; `deflate-raw` is what ZIP stores),
31
+ * or `null` when the platform has no such stream.
32
+ */
33
+ export declare function compressBytes(data: Uint8Array, format: CompressionFormatName): Promise<Uint8Array | null>;
34
+ /** A ZIP whose entries are deflated where the platform can and where it makes them smaller. */
35
+ export declare function writeZipAsync(entries: readonly ZipEntry[], options?: ZipOptions): Promise<Uint8Array>;