@jokerized/decksmith 0.2.0 → 0.3.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tilting a plane, and paying for it in type size.
3
+ *
4
+ * Gap 2 wants depth as exposition — a diagram tilting to reveal a layer. The
5
+ * expensive half is not the transform. It is that perspective shrinks the type on
6
+ * the far half of the plane, and invariant 5 is about what the AUDIENCE sees, so
7
+ * a tilt that leaves a 40px label drawing 31px has broken the floor whatever the
8
+ * source says. `src/verify/apparent.ts` is the gate that catches it; this module
9
+ * is how an archetype avoids tripping it, by declaring bigger type up front.
10
+ *
11
+ * WHAT IS AND IS NOT SUPPORTED HERE. One transform on one plane — the whole
12
+ * scene's content tilts as a unit. NOT per-element depth: measured in Chrome,
13
+ * `translateZ` on an SVG `<g>` under a `preserve-3d` ancestor is ignored
14
+ * completely (z=0, z=+200 and z=-200 paint the identical rect, while an HTML
15
+ * sibling in the same test moves and scales correctly). An exploding stack needs
16
+ * sibling HTML planes and is a later slice; a tilting plate is this one.
17
+ *
18
+ * THE MODEL. For a plane rotated by `t` about its horizontal centre line under
19
+ * CSS `perspective: d`, a point `dy` below the plane's centre is drawn at
20
+ *
21
+ * scale(dy) = cos(t) * (d / (d - dy * sin(t)))^2
22
+ *
23
+ * — `cos(t)` for the foreshortening and the square for the perspective divide
24
+ * applying to both the glyph's height and its distance from the eye. Fitted
25
+ * against Chrome at `rotateX(30deg)`, `perspective: 1400px`:
26
+ *
27
+ * dy = -340 (near the top) model 0.6886 Chrome 0.6820 +0.97%
28
+ * dy = +360 (near the bottom) model 1.1404 Chrome 1.1260 +1.28%
29
+ *
30
+ * The model runs about 1% OPTIMISTIC, so `worstScale` rounds its answer down by
31
+ * 2% before anyone divides by it. Erring large costs a couple of type px; erring
32
+ * small ships a slide under the floor, which is the whole failure this exists to
33
+ * prevent.
34
+ */
35
+ /** How a scene's plane is tilted. Degrees, and CSS `perspective` in px. */
36
+ export interface Pose {
37
+ /** Rotation about the horizontal centre line. Positive tips the top away. */
38
+ rotateX: number;
39
+ /** CSS `perspective`, in reference px. Smaller is a stronger effect. */
40
+ perspective: number;
41
+ }
42
+ /**
43
+ * The default a depth archetype gets when it asks for one.
44
+ *
45
+ * 12 degrees, not the 30 the spike reached for. The table in `worstScale`'s note
46
+ * is why: at 30 degrees a 40px audience floor needs 67px declared, which is more
47
+ * than a headline can spend and still fit its own line. At 12 it needs 48.7px,
48
+ * which archetypes can and do pay. Depth here is a lean, not a dive.
49
+ */
50
+ export declare const DEFAULT_POSE: Pose;
51
+ /** Apparent scale at `dy` reference px below the plane's centre. */
52
+ export declare function scaleAt(pose: Pose, dy: number): number;
53
+ /**
54
+ * The smallest scale anywhere on a `height`-tall plane, already discounted.
55
+ *
56
+ * The smallest scale is at the TOP edge, furthest from the eye. The BOTTOM edge
57
+ * is the one that can swing through the eye entirely, and when it does the plane
58
+ * is not being tilted any more — it is being turned inside out — so the pose is
59
+ * refused rather than priced. Checking only the top would have returned a
60
+ * perfectly reasonable-looking floor for a pose that cannot be drawn.
61
+ *
62
+ * What it buys, at `perspective: 1400` on a 1080 canvas, AFTER the 2% discount:
63
+ *
64
+ * rotateX(6) 0.901 -> a 40px floor needs 44.4px declared
65
+ * rotateX(12) 0.821 -> 48.7px
66
+ * rotateX(18) 0.744 -> 53.8px
67
+ * rotateX(24) 0.669 -> 59.8px
68
+ * rotateX(30) 0.597 -> 67.0px
69
+ *
70
+ * That table is the tilt budget. Depth in this project is bounded by the type
71
+ * floor rather than by taste, and the bound is tighter than it looks.
72
+ */
73
+ export declare function worstScale(pose: Pose, height: number): number;
74
+ /**
75
+ * The type floor an archetype must solve against to LAND on `floor` once tilted.
76
+ *
77
+ * Archetypes already solve against a floor — `stack` passes `MIN_FONT` into its
78
+ * own fit — so depth costs them one substitution rather than a second solver.
79
+ */
80
+ export declare function tiltedFloor(pose: Pose, height: number, floor: number): number;
81
+ /**
82
+ * The CSS a tilted scene needs, scoped to its own id (invariant 3).
83
+ *
84
+ * `perspective` sits on the scene and the rotation on ONE part of it, named by
85
+ * `part`, because tilting a whole scene tilts its chrome too. Built that way
86
+ * first and it was obviously wrong the moment a frame was opened: the headline
87
+ * came out sheared, leaning like a mistake rather than a design, and the stack's
88
+ * probe marker — a straight rule with a dot under it — bent into a hook. The
89
+ * diagram is what gains by leaning; the words are what the audience reads.
90
+ *
91
+ * Nothing here animates: a pose is a static property of the beat, and a tilt that
92
+ * moved would put every label at a different apparent size on every frame while
93
+ * `apparent` only measures at the declared stops.
94
+ */
95
+ export declare function depthCss(sid: string, pose: Pose, part: string): string;
@@ -285,6 +285,13 @@ export interface Scene {
285
285
  * values.
286
286
  */
287
287
  measure?: string[];
288
+ /**
289
+ * Vendored runtimes this scene's tweens need registered before its script
290
+ * runs — `"dsMorph"` for the equation morph. The shell loads a runtime only
291
+ * when some scene names it, so a deck that names none is byte-for-byte what
292
+ * it was.
293
+ */
294
+ plugins?: readonly string[];
288
295
  /**
289
296
  * Hold points in seconds from the scene's start — where a presenter should
290
297
  * pause. The shell converts these to absolute island fragment times.
@@ -0,0 +1,178 @@
1
+ /**
2
+ * The keyed equation morph — TransformMatchingTex, for the DOM.
3
+ *
4
+ * Two KaTeX renderings in, one seek-evaluable plan out. `build()` runs once in
5
+ * the browser, inside the ready gate (Seam B), and bakes every number; the
6
+ * plan is then driven by a GSAP PLUGIN — `render(ratio)` is part of being
7
+ * seeked, so it fires under `suppressEvents` where an `onUpdate` would not
8
+ * (invariant 11). Nothing here runs on a callback.
9
+ *
10
+ * Four phases, kept separate on purpose:
11
+ * 1. LIFT — pull every ink-bearing leaf out of KaTeX's nested boxes into a
12
+ * flat absolutely-positioned overlay. Removes clipping, vlist
13
+ * stacking and transform-on-inline entirely.
14
+ * 2. GROUP — collect leaves into the units that move as one body. An author
15
+ * key makes a group; an unkeyed leaf is its own group.
16
+ * 3. MATCH — decide which group of A becomes which group of B.
17
+ * 4. PLAN — a similarity transform per group, as per-leaf segments.
18
+ *
19
+ * `evaluate()` turns a plan and a progress into styles. It is a pure function
20
+ * of its arguments — two seeks to one time write the same strings — which is
21
+ * what makes the render byte-identical across browser processes, measured on
22
+ * the spike this is ported from (`experiments/013-vocabulary/morph/`).
23
+ *
24
+ * Bundled to an IIFE by `scripts/build.mjs`, vendored beside GSAP by the CLI,
25
+ * and loaded only by a deck that has a morph in it. The pure halves — `group`,
26
+ * `match`, `plan`, `evaluate` — are exported for the tests, which is why the
27
+ * window registration at the bottom is guarded.
28
+ */
29
+ /** The class prefix an author key is carried on: `\htmlClass{ds-k-<key>}{...}`. */
30
+ export declare const KEY_PREFIX = "ds-k-";
31
+ export interface Leaf {
32
+ el: HTMLElement;
33
+ key: string;
34
+ sig: string;
35
+ text: string;
36
+ /** Font size in layout px. */
37
+ fs: number;
38
+ /** Centre and box, in the host's own layout px. */
39
+ cx: number;
40
+ cy: number;
41
+ w: number;
42
+ h: number;
43
+ }
44
+ export interface Group {
45
+ ident: string;
46
+ key: string;
47
+ leaves: Leaf[];
48
+ cx: number;
49
+ cy: number;
50
+ w: number;
51
+ h: number;
52
+ fs: number;
53
+ text: string;
54
+ }
55
+ /**
56
+ * The unit that moves as one body.
57
+ *
58
+ * This is the whole quality argument of the file. Matching GLYPH BY GLYPH is
59
+ * what a naive reading of TransformMatchingTex suggests and it is visibly
60
+ * wrong: in `a^2+b^2=c^2 -> c^2-b^2=a^2` the three `2`s are interchangeable
61
+ * under any text-plus-class identity, so they pair left-to-right and stay put
62
+ * while their bases cross over. The audience sees the letters swap and the
63
+ * exponents refuse to follow, which asserts something false about the algebra.
64
+ *
65
+ * Manim does not avoid this by being cleverer — `MathTex("a^2", "+", "b^2",
66
+ * ...)` is the AUTHOR splitting the expression into the parts meant to
67
+ * survive. `\htmlClass{ds-k-<key>}{...}` is the same act, and it is the only
68
+ * mechanism here that produces a correct morph on an ambiguous pair.
69
+ *
70
+ * A group's identity is its key alone, not its contents, so `ds-k-exp` can
71
+ * carry `x^2` onto `x^3`: the body moves and its contents dissolve.
72
+ */
73
+ export declare function group(items: Leaf[]): Group[];
74
+ export interface Match {
75
+ pairs: [number, number][];
76
+ dropA: number[];
77
+ addB: number[];
78
+ }
79
+ /**
80
+ * Bucket by identity, then pair NEAREST FIRST within a bucket.
81
+ *
82
+ * Document order — the spike's rule — sends the wrong twin across the slide:
83
+ * `F = E(I), X = W(F)` has two `=` and its successor one, and pairing the
84
+ * first `=` with it flew a glyph 800px over the equation while the `=` sitting
85
+ * 40px from its destination faded out. Nearest-first is what a viewer expects
86
+ * of a symbol that did not move. Ties fall to document order, so it is still a
87
+ * pure function of the two layouts.
88
+ */
89
+ export declare function match(A: Group[], B: Group[]): Match;
90
+ export type Prop = "x" | "y" | "s" | "o";
91
+ export type Ease = "none" | "power1.in" | "power1.out" | "power2.in" | "power2.out" | "power2.inOut";
92
+ /**
93
+ * One property of one leaf, over one slice of the morph.
94
+ *
95
+ * Times are FRACTIONS of the morph, not seconds: the plan is evaluated against
96
+ * the plugin tween's ratio, so `pace()` scaling the tween's `duration` scales
97
+ * all of this with it, and the plan never has to know how long it takes.
98
+ */
99
+ export interface Step {
100
+ el: HTMLElement;
101
+ prop: Prop;
102
+ from: number;
103
+ to: number;
104
+ at: number;
105
+ dur: number;
106
+ ease: Ease;
107
+ }
108
+ /**
109
+ * One similarity transform per matched group, expressed per leaf.
110
+ *
111
+ * A group's leaves move RIGIDLY: every leaf gets the same scale and the
112
+ * translation that the group's own centre-to-centre move implies for its
113
+ * position within the body. `a^2` therefore arrives with its exponent still
114
+ * attached, which is the whole reason groups exist.
115
+ *
116
+ * The B side is placed by the INVERSE transform — B's leaves start where they
117
+ * would sit inside A's box — so when the contents differ the two renderings
118
+ * dissolve into each other while sharing one trajectory.
119
+ *
120
+ * `arc` bows the path. Two glyphs swapping ends of an equation travel the same
121
+ * straight line in opposite directions and pile up in the middle, which reads
122
+ * as a collision rather than an exchange. The bow is two segments on `y` — out
123
+ * over the first half, back over the second — against one on `x` that runs
124
+ * the whole way.
125
+ */
126
+ export declare function plan(A: Group[], B: Group[], m: Match, opt: {
127
+ arc?: boolean;
128
+ }): Step[];
129
+ export interface MorphPlan {
130
+ leaves: {
131
+ el: HTMLElement;
132
+ side: "a" | "b";
133
+ }[];
134
+ steps: Step[];
135
+ }
136
+ /**
137
+ * Write the morph at progress `v` in [0, 1] into every leaf's style.
138
+ *
139
+ * Every leaf is written every time, from its resting state forward — A drawn,
140
+ * B hidden, nothing moved — so a cold seek to any progress produces the same
141
+ * strings as a walk to it. That is the property the capture depends on.
142
+ */
143
+ export declare function evaluate(p: MorphPlan, v: number): void;
144
+ export interface Stats {
145
+ groupsA: number;
146
+ groupsB: number;
147
+ leavesA: number;
148
+ leavesB: number;
149
+ matched: number;
150
+ dropped: number;
151
+ added: number;
152
+ }
153
+ /**
154
+ * Build the morph inside `host`, which must already contain two rendered KaTeX
155
+ * roots marked `data-morph="a"` and `data-morph="b"`. Runs in `measure`, after
156
+ * fonts; the plan is kept for the plugin tween on the same host to find.
157
+ */
158
+ export declare function build(host: HTMLElement, opt?: {
159
+ arc?: boolean;
160
+ }): Stats;
161
+ interface PluginState {
162
+ plan: MorphPlan;
163
+ end: number;
164
+ }
165
+ /**
166
+ * `dsMorph` as a GSAP property: `tl.fromTo(host, { dsMorph: 0 }, { dsMorph: 1 })`.
167
+ *
168
+ * A plugin's `render` is called by the timeline as part of being seeked, so
169
+ * this runs under `suppressEvents` — the capture path — where an `onUpdate`
170
+ * does not. The tween's own ease must be "none": the plan carries its eases
171
+ * per segment, and a second ease on top would warp every one of them.
172
+ */
173
+ export declare const DSMorphPlugin: {
174
+ name: string;
175
+ init(this: PluginState, target: Element, value: unknown): void;
176
+ render(ratio: number, d: PluginState): void;
177
+ };
178
+ export {};
@@ -140,6 +140,30 @@ export declare const equationWalkParamsSchema: z.ZodObject<{
140
140
  }>;
141
141
  }, z.core.$strip>>;
142
142
  }, z.core.$strip>;
143
+ /**
144
+ * Two equations from the source, the first becoming the second.
145
+ *
146
+ * `terms` are the parts that TRAVEL: each must occur in both equations, and is
147
+ * carried from its place in the first to its place in the second as one rigid
148
+ * body. Everything else dissolves. Which terms are keyed IS the animation —
149
+ * the same two strings are a glyph riot unkeyed and an exchange keyed.
150
+ */
151
+ export declare const equationMorphParamsSchema: z.ZodObject<{
152
+ eyebrow: z.ZodOptional<z.ZodString>;
153
+ headline: z.ZodString;
154
+ fromId: z.ZodString;
155
+ toId: z.ZodString;
156
+ terms: z.ZodArray<z.ZodObject<{
157
+ tex: z.ZodString;
158
+ label: z.ZodString;
159
+ tone: z.ZodEnum<{
160
+ a: "a";
161
+ b: "b";
162
+ c: "c";
163
+ d: "d";
164
+ }>;
165
+ }, z.core.$strip>>;
166
+ }, z.core.$strip>;
143
167
  export declare const dataTableParamsSchema: z.ZodObject<{
144
168
  eyebrow: z.ZodOptional<z.ZodString>;
145
169
  headline: z.ZodString;
@@ -274,6 +298,7 @@ export declare const stackParamsSchema: z.ZodObject<{
274
298
  note: z.ZodOptional<z.ZodString>;
275
299
  }, z.core.$strip>>;
276
300
  note: z.ZodOptional<z.ZodString>;
301
+ tilt: z.ZodOptional<z.ZodNumber>;
277
302
  }, z.core.$strip>;
278
303
  export declare const splitCompareParamsSchema: z.ZodObject<{
279
304
  eyebrow: z.ZodOptional<z.ZodString>;
@@ -423,6 +448,44 @@ export declare const beatSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
423
448
  }, z.core.$strip>>>;
424
449
  weight: z.ZodDefault<z.ZodNumber>;
425
450
  seconds: z.ZodDefault<z.ZodNumber>;
451
+ }, z.core.$strip>, z.ZodObject<{
452
+ narration: z.ZodOptional<z.ZodString>;
453
+ archetype: z.ZodLiteral<"equation-morph">;
454
+ params: z.ZodObject<{
455
+ eyebrow: z.ZodOptional<z.ZodString>;
456
+ headline: z.ZodString;
457
+ fromId: z.ZodString;
458
+ toId: z.ZodString;
459
+ terms: z.ZodArray<z.ZodObject<{
460
+ tex: z.ZodString;
461
+ label: z.ZodString;
462
+ tone: z.ZodEnum<{
463
+ a: "a";
464
+ b: "b";
465
+ c: "c";
466
+ d: "d";
467
+ }>;
468
+ }, z.core.$strip>>;
469
+ }, z.core.$strip>;
470
+ id: z.ZodString;
471
+ intent: z.ZodString;
472
+ inside: z.ZodOptional<z.ZodObject<{
473
+ beat: z.ZodString;
474
+ element: z.ZodString;
475
+ label: z.ZodOptional<z.ZodString>;
476
+ }, z.core.$strip>>;
477
+ claim: z.ZodOptional<z.ZodString>;
478
+ evidence: z.ZodDefault<z.ZodArray<z.ZodObject<{
479
+ kind: z.ZodEnum<{
480
+ figure: "figure";
481
+ equation: "equation";
482
+ table: "table";
483
+ section: "section";
484
+ }>;
485
+ id: z.ZodString;
486
+ }, z.core.$strip>>>;
487
+ weight: z.ZodDefault<z.ZodNumber>;
488
+ seconds: z.ZodDefault<z.ZodNumber>;
426
489
  }, z.core.$strip>, z.ZodObject<{
427
490
  narration: z.ZodOptional<z.ZodString>;
428
491
  archetype: z.ZodLiteral<"data-table">;
@@ -703,6 +766,7 @@ export declare const beatSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
703
766
  note: z.ZodOptional<z.ZodString>;
704
767
  }, z.core.$strip>>;
705
768
  note: z.ZodOptional<z.ZodString>;
769
+ tilt: z.ZodOptional<z.ZodNumber>;
706
770
  }, z.core.$strip>;
707
771
  id: z.ZodString;
708
772
  intent: z.ZodString;
@@ -905,6 +969,44 @@ export declare const storyboardSchema: z.ZodObject<{
905
969
  }, z.core.$strip>>>;
906
970
  weight: z.ZodDefault<z.ZodNumber>;
907
971
  seconds: z.ZodDefault<z.ZodNumber>;
972
+ }, z.core.$strip>, z.ZodObject<{
973
+ narration: z.ZodOptional<z.ZodString>;
974
+ archetype: z.ZodLiteral<"equation-morph">;
975
+ params: z.ZodObject<{
976
+ eyebrow: z.ZodOptional<z.ZodString>;
977
+ headline: z.ZodString;
978
+ fromId: z.ZodString;
979
+ toId: z.ZodString;
980
+ terms: z.ZodArray<z.ZodObject<{
981
+ tex: z.ZodString;
982
+ label: z.ZodString;
983
+ tone: z.ZodEnum<{
984
+ a: "a";
985
+ b: "b";
986
+ c: "c";
987
+ d: "d";
988
+ }>;
989
+ }, z.core.$strip>>;
990
+ }, z.core.$strip>;
991
+ id: z.ZodString;
992
+ intent: z.ZodString;
993
+ inside: z.ZodOptional<z.ZodObject<{
994
+ beat: z.ZodString;
995
+ element: z.ZodString;
996
+ label: z.ZodOptional<z.ZodString>;
997
+ }, z.core.$strip>>;
998
+ claim: z.ZodOptional<z.ZodString>;
999
+ evidence: z.ZodDefault<z.ZodArray<z.ZodObject<{
1000
+ kind: z.ZodEnum<{
1001
+ figure: "figure";
1002
+ equation: "equation";
1003
+ table: "table";
1004
+ section: "section";
1005
+ }>;
1006
+ id: z.ZodString;
1007
+ }, z.core.$strip>>>;
1008
+ weight: z.ZodDefault<z.ZodNumber>;
1009
+ seconds: z.ZodDefault<z.ZodNumber>;
908
1010
  }, z.core.$strip>, z.ZodObject<{
909
1011
  narration: z.ZodOptional<z.ZodString>;
910
1012
  archetype: z.ZodLiteral<"data-table">;
@@ -1185,6 +1287,7 @@ export declare const storyboardSchema: z.ZodObject<{
1185
1287
  note: z.ZodOptional<z.ZodString>;
1186
1288
  }, z.core.$strip>>;
1187
1289
  note: z.ZodOptional<z.ZodString>;
1290
+ tilt: z.ZodOptional<z.ZodNumber>;
1188
1291
  }, z.core.$strip>;
1189
1292
  id: z.ZodString;
1190
1293
  intent: z.ZodString;
@@ -1546,6 +1649,44 @@ export declare const packSchema: z.ZodObject<{
1546
1649
  }, z.core.$strip>>>;
1547
1650
  weight: z.ZodDefault<z.ZodNumber>;
1548
1651
  seconds: z.ZodDefault<z.ZodNumber>;
1652
+ }, z.core.$strip>, z.ZodObject<{
1653
+ narration: z.ZodOptional<z.ZodString>;
1654
+ archetype: z.ZodLiteral<"equation-morph">;
1655
+ params: z.ZodObject<{
1656
+ eyebrow: z.ZodOptional<z.ZodString>;
1657
+ headline: z.ZodString;
1658
+ fromId: z.ZodString;
1659
+ toId: z.ZodString;
1660
+ terms: z.ZodArray<z.ZodObject<{
1661
+ tex: z.ZodString;
1662
+ label: z.ZodString;
1663
+ tone: z.ZodEnum<{
1664
+ a: "a";
1665
+ b: "b";
1666
+ c: "c";
1667
+ d: "d";
1668
+ }>;
1669
+ }, z.core.$strip>>;
1670
+ }, z.core.$strip>;
1671
+ id: z.ZodString;
1672
+ intent: z.ZodString;
1673
+ inside: z.ZodOptional<z.ZodObject<{
1674
+ beat: z.ZodString;
1675
+ element: z.ZodString;
1676
+ label: z.ZodOptional<z.ZodString>;
1677
+ }, z.core.$strip>>;
1678
+ claim: z.ZodOptional<z.ZodString>;
1679
+ evidence: z.ZodDefault<z.ZodArray<z.ZodObject<{
1680
+ kind: z.ZodEnum<{
1681
+ figure: "figure";
1682
+ equation: "equation";
1683
+ table: "table";
1684
+ section: "section";
1685
+ }>;
1686
+ id: z.ZodString;
1687
+ }, z.core.$strip>>>;
1688
+ weight: z.ZodDefault<z.ZodNumber>;
1689
+ seconds: z.ZodDefault<z.ZodNumber>;
1549
1690
  }, z.core.$strip>, z.ZodObject<{
1550
1691
  narration: z.ZodOptional<z.ZodString>;
1551
1692
  archetype: z.ZodLiteral<"data-table">;
@@ -1826,6 +1967,7 @@ export declare const packSchema: z.ZodObject<{
1826
1967
  note: z.ZodOptional<z.ZodString>;
1827
1968
  }, z.core.$strip>>;
1828
1969
  note: z.ZodOptional<z.ZodString>;
1970
+ tilt: z.ZodOptional<z.ZodNumber>;
1829
1971
  }, z.core.$strip>;
1830
1972
  id: z.ZodString;
1831
1973
  intent: z.ZodString;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The type floor, measured on the rendered frame instead of read off the source.
3
+ *
4
+ * `typefloor.ts` scans declared sizes and says so plainly in its own header:
5
+ * "text shrunk by a `scale` below 1 at a hold reads as its unscaled size". That
6
+ * hole is not hypothetical and it is not only about `scale`. Measured in Chrome
7
+ * at `rotateX(30deg)` under `perspective: 1400px`, a run declaring 46px renders
8
+ * 31.4 apparent px near the top of the plane — invariant 5 broken by 8.6px, with
9
+ * `lint`, `check`, `drift` and the declared-size scan all green, because not one
10
+ * of them looks at how big the glyph actually came out.
11
+ *
12
+ * That matters now because Gap 2 wants depth, and every way of tilting a plane
13
+ * shrinks the type on the far half of it. A depth archetype built against a floor
14
+ * that cannot see projection would ship unreadable slides that pass. So the gate
15
+ * comes first and the archetype second.
16
+ *
17
+ * HOW APPARENT SIZE IS MEASURED. For an SVG text node, `getBBox()` is the
18
+ * untransformed box in user units and `getClientRects()` is the painted box in
19
+ * device px, so their ratio is the TOTAL scale between the two — the element's
20
+ * own transform, every ancestor transform, the 3D projection's per-point
21
+ * perspective divide, and the stage zoom, all of it, without having to know which
22
+ * of them applied. Measured against the three cases that matter:
23
+ *
24
+ * flat, declared 46 ratio 1.000 -> 46.0 apparent px
25
+ * tilted 30deg, near plane top 0.682 -> 31.4
26
+ * tilted 30deg, near plane bottom 1.126 -> 51.8 (magnified, not a fault)
27
+ * `scale(0.6)` at a hold 0.600 -> 27.6
28
+ *
29
+ * `getScreenCTM()` was tried first and rejected: it returns 0.673 for BOTH the
30
+ * top and bottom runs above, because it carries the affine part and drops the
31
+ * perspective divide that makes those two differ by 23 apparent px.
32
+ *
33
+ * WHY IT DIVIDES BY THE STAGE. A deck legitimately scales its whole stage —
34
+ * `zoomOf`/`REF_PULL` — and in portrait 40 reference px is SUPPOSED to land at 30
35
+ * canvas px, which `typefloor.ts` calls "the whole argument of REF_PULL and not a
36
+ * violation". Dividing the run's own ratio by the scene's gives apparent size
37
+ * back in REFERENCE px, so this gate agrees with the declared one on every deck
38
+ * that does nothing clever, and only diverges where something shrank one element
39
+ * relative to its own scene.
40
+ */
41
+ import type { Finding } from "../types.js";
42
+ /** One text run, as the frame actually drew it. */
43
+ export interface ApparentRun {
44
+ /** First 40 characters, for the message. */
45
+ text: string;
46
+ /** `font-size` as declared, in px. */
47
+ declared: number;
48
+ /** Painted height over untransformed height, including the stage's own zoom. */
49
+ ratio: number;
50
+ /** Its own computed opacity, multiplied down the ancestors that carry one. */
51
+ opacity: number;
52
+ }
53
+ /** What `measureApparent` hands back for one instant. */
54
+ export interface ApparentStop {
55
+ sid: string;
56
+ t: number;
57
+ runs: ApparentRun[];
58
+ /** The scene's own painted-over-declared scale, the baseline runs divide by. */
59
+ stage: number;
60
+ /**
61
+ * True at a declared stop, where the frame has arrived and everything drawn is
62
+ * being read. False at a sampled midpoint, where a run may be part-way through
63
+ * its own entrance — see `SETTLED_OPACITY`.
64
+ */
65
+ settled: boolean;
66
+ }
67
+ /**
68
+ * Serialised into the page: every visible SVG text run's apparent scale.
69
+ *
70
+ * Deliberately the same walk as `collectSvgTextRuns` in overprint.ts — same
71
+ * visibility rules, same "only inside an `<svg>`" restriction — because two gates
72
+ * that disagree about which text exists are two gates that cannot be reconciled
73
+ * when they disagree about a deck.
74
+ */
75
+ export declare function collectApparent(sid: string): {
76
+ runs: ApparentRun[];
77
+ stage: number;
78
+ };
79
+ /**
80
+ * Apparent size in reference px: what the run declared, times how much the frame
81
+ * shrank or grew it relative to its own scene.
82
+ */
83
+ export declare function apparentPx(run: ApparentRun, stage: number): number;
84
+ /**
85
+ * How opaque a run must be, at a sampled midpoint, to count as text being read.
86
+ *
87
+ * At a declared stop the frame has arrived and everything drawn counts. Between
88
+ * stops it has not: `annotated-figure` enters its labels from `scale: 0.97`, so
89
+ * a run caught half way through its own entrance is BOTH under the floor and
90
+ * fading in, and failing a build for it would be crying wolf about a frame no
91
+ * one reads. A run at 0.95 opacity is not entering any more; it has arrived.
92
+ */
93
+ export declare const SETTLED_OPACITY = 0.95;
94
+ /**
95
+ * The times to sample BETWEEN the declared stops.
96
+ *
97
+ * `fidelity` measures at stops because that is where a frame has settled, and
98
+ * the apparent floor inherited that — which left exactly the hole it was built
99
+ * to close, one interval over: text scaled DOWN between two stops is invisible
100
+ * to a gate that only looks at the stops. A camera pulling back, or an exit that
101
+ * shrinks a label, would ship.
102
+ *
103
+ * Midway between consecutive stops of the SAME scene. Not across a scene
104
+ * boundary, where the midpoint lands in a cross-fade between two compositions
105
+ * and belongs to neither. Costs a seek and a DOM read each — no screenshot,
106
+ * which is what makes doubling the sample count affordable.
107
+ */
108
+ export declare function midpoints(stops: readonly {
109
+ sid: string;
110
+ t: number;
111
+ }[]): {
112
+ sid: string;
113
+ t: number;
114
+ }[];
115
+ /**
116
+ * Runs the frame drew below the floor, whatever shrank them.
117
+ *
118
+ * An ERROR, not a warning, and for the same reason `typefloor` is: text the
119
+ * audience cannot read is not a matter of degree. The tolerance is a tenth of a
120
+ * pixel, which is rasteriser noise rather than a grace margin — a deck that wants
121
+ * 40px declares 40px and measures 40.0, as the flat control does exactly.
122
+ */
123
+ export declare function gradeApparent(stops: readonly ApparentStop[], floor?: number): Finding[];
@@ -28,7 +28,9 @@
28
28
  *
29
29
  * WHAT IT DOES NOT SEE, stated because a gate that overclaims is the thing this
30
30
  * file was written to stop. It reads declared sizes, so text shrunk by a
31
- * `scale` below 1 at a hold reads as its unscaled size; and it says nothing about
31
+ * `scale` below 1 at a hold reads as its unscaled size `apparent.ts` is the
32
+ * gate that closes exactly that, by measuring the painted glyph instead, and it
33
+ * catches a 3D projection the same way; and it says nothing about
32
34
  * whether the text FITS — `container_overflow` and `text_occluded` in
33
35
  * `hyperframes check` are the gates for that, and `check.ts` already grades them
34
36
  * up to errors.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jokerized/decksmith",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Turn a source document into an animated explanation deck.",
5
5
  "type": "module",
6
6
  "license": "MIT",