diff --git a/QUICKSTART.md b/QUICKSTART.md index 7a314f1a..c4fe0a9d 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -305,6 +305,8 @@ The key set: | `style.mathstyle` | `display` \| `text` \| `script` \| `scriptscript` | | `fx.enter` / `fx.hover` / `fx.click` | `boom` \| `pulse` \| `fade` \| `glow` \| `lightning` \| `storm` \| `handscribe` \| `hologram` \| `neonsign` \| `crystallize` \| `blueprint` \| `wobble` \| `gravwell` \| `matrixrain` \| `supernova` \| `inkdrop` \| `diffusion` \| `refraction` \| `teleport` \| `shatter` \| `glitch` \| `sparkler` \| `quantum` \| `typeset` \| `constellation` \| `thread` \| `precedence` \| `cancel` \| `unfold` \| `substitute` \| `none` — see `examples/effects.html` live (`unfold` and `substitute` are opt-in/flag-gated and not shown there — see their own previews and the callout below) | | `fx.duration` | a `ms` value, e.g. `250ms` | +| `fx.bolts` | integer ASCII decimal `1..8`, for `lightning` and `storm`; omitted default `2` bolts per source | +| `fx.intensity` | integer ASCII decimal `0..300` percent, for `lightning` and `storm`; omitted default `100`; `0` switches those effects off | | `fx.substitute-to` | the literal integer `substitute` flips to, e.g. `3` or `-12` (at most 6 digits). The substituted form is grouped where adjacency would change the meaning: `2x` with `3` renders `2 \cdot 3`, never `23`; a negative after any operand keeps its product, so `ax` with `-3` renders `a \cdot -3`, never `a-3`; and a negative under a power or subscript is parenthesised, so `2x^2` with `-3` renders `2(-3)^2`, never `2-3^2`. `\textcolor` is transparent to both guards | | `fx.substitute-var` | the single letter to replace, e.g. `x` — optional; omit it and the body's one distinct letter is used | | `intent` / `concept` | a lowercase identifier (`^[a-z][a-z0-9_]*$`), e.g. `function` | @@ -317,6 +319,29 @@ is insignificant. This is the quickstart view — the full option grammar and ra live in the **`lattex-render-styling-options`** design plan. See [examples/showcase.html](examples/showcase.html) for the macro end-to-end. +Tune either bolt effect with the same two global options: + +```latex +\lx[fx.hover=lightning, fx.bolts=4, fx.intensity=150]{x^2} +\lx[fx.click=storm, fx.intensity=0]{\frac{a}{b}} +``` + +Numeric values must be whole ASCII decimals within those ranges. Signs, +fractions, exponents, units, internal whitespace and trailing junk fail at parse +time with the option, supplied value and allowed range. Whitespace around option +separators remains legal; quoted numeric values retain their internal whitespace. +Omitting a knob emits no new attribute and preserves the default output. +Explicit knobs stamp `data-lx-fx-bolts` and `data-lx-fx-intensity` on the wrapper; +they leave the inner SVG unchanged. A host sanitizer must allow these exact names +with bounded decimal values; this does not establish adoption by Stafficy `/docs`. + +Intensity adjusts brightness, glow and stroke width under fixed caps, without +adding bolt, segment, frame or timer work. Zero leaves the equation visible and +does not initialize lightning/storm overlays, timers or trigger listeners; +unrelated effects on the same equation keep their behavior. Reduced motion takes +precedence over every nonzero knob. `fx.strikes`, `fx.arc` and per-effect numeric +namespaces remain unknown options. + > **`unfold` and `substitute` are doubly gated.** They are the numeric-substitution > family — the effects that need LatteX to *compute* (pre-render a bounded `\sum` into > its explicit terms, or an expression with its variable replaced by a value). They stay diff --git a/SLOWSTART.md b/SLOWSTART.md index b85e635c..96cf55a9 100644 --- a/SLOWSTART.md +++ b/SLOWSTART.md @@ -266,13 +266,17 @@ real production `/docs` integration ran, minus its framework: care who wrote them. A Hugo shortcode / 11ty filter can stamp the wrapper itself: `class="lx-math"` plus any of `data-lx-fx-enter|hover|click` (an effect name from the catalogue), `data-lx-fx-duration` (`400ms`), and - `data-lx-fx-glow-color` (`#hex` or `currentColor`). If your authors are + `data-lx-fx-glow-color` (`#hex` or `currentColor`). Lightning and storm also + accept `data-lx-fx-bolts` (ASCII decimal `1..8`) and `data-lx-fx-intensity` + (ASCII decimal `0..300` percent). If your authors are untrusted, validate those values against the catalogue's closed vocabulary — when LatteX writes them it does that validation for you at parse time. 4. **If your pipeline sanitizes HTML** (rehype-sanitize and friends), allow-list - the wrapper: `class` on `span`/`div` plus the five `data-lx-fx-*` attributes — + the wrapper: `class` on `span`/`div` plus the seven exact `data-lx-fx-*` + attributes: `enter`, `hover`, `click`, `duration`, `glow-color`, `bolts` and + `intensity` (each prefixed with `data-lx-fx-`) — or your sanitizer strips the effects as silently as step 0 ignored them. - `unfold` needs a few more allow-list entries beyond the base five: the + `unfold` needs a few more allow-list entries beyond these trigger options: the `unfold` value itself on `fx.click`/`fx.enter`/`fx.hover`, the `data-lx-fx-expand` marker attribute, the nested `lx-fx-expanded` class (a `span` one level inside the wrapper, holding the pre-rendered payload @@ -285,7 +289,7 @@ real production `/docs` integration ran, minus its framework: attributes/class above gets full `unfold` now. `substitute` has the SAME shape and the same dependency, so its allow-list is - worth stating rather than inferring from unfold's. Beyond the base five it + worth stating rather than inferring from unfold's. Beyond these trigger options it needs: the `substitute` value on `fx.click`/`fx.enter`/`fx.hover`, the `data-lx-fx-substitute` marker (the literal target), the `data-lx-var` sidecar (the variable's `` addresses — what the runtime dims), the @@ -303,6 +307,25 @@ real production `/docs` integration ran, minus its framework: effect and no error anywhere. Same `/docs` caveat as `unfold`: the Stafficy host does not allow these today. +For lightning/storm, omitted numeric attributes mean `2` bolts per source and +`100` percent intensity. The JVM parser rejects malformed or out-of-range author +values and emits attributes only when explicitly supplied. Handcrafted DOM is +bounded again on every play: whole ASCII digit strings clamp to `1..8` / `0..300`, +while missing, empty or malformed strings independently fall back to `2` / `100`. +There is no partial numeric parsing. Intensity `0` disables just these two effects +before their overlays, timers or trigger listeners are initialized; the equation +and other effects remain available. If a page changes an initially disabled +effect to a positive intensity, use `LatteXFx.play(element)`; that change does not +install listeners that initialization skipped. + +Nonzero intensity changes capped appearance, without increasing work at a fixed +bolt count. Reduced motion retains its gentle behavior regardless of nonzero +knobs, and scroll tears down the fixed overlays. Keep renderer and runtime from +the same version, and explicitly adopt both numeric names and bounded values in +your sanitizer. This LatteX slice does not update Stafficy's sanitizer or establish +that `/docs` preserves the new numeric attributes. `fx.strikes`, `fx.arc` and +per-effect numeric namespaces remain deferred. + Skip all four and Sam's original promise holds: static math, zero runtime. Do them and the same pages get the full catalogue — with JavaScript off, readers still get perfect static math. diff --git a/src/main/java/com/lattex/api/LatteX.java b/src/main/java/com/lattex/api/LatteX.java index 50df5a48..976e3fe9 100644 --- a/src/main/java/com/lattex/api/LatteX.java +++ b/src/main/java/com/lattex/api/LatteX.java @@ -810,14 +810,15 @@ private static String openTag(EffectSpec fx, Semantics sem, String glyphmap, Str * Stafficy's {@code /docs} math seam is the reference consumer). * *

Returns {@code ""} when the source carries no top-level {@code \lx[fx.*]} - * annotations, else a leading-space-prefixed run of only the five - * {@code data-lx-fx-*} attributes (enter/hover/click/duration/glow-color, in that - * order) — never {@code data-lx-intent}/{@code concept}/{@code data.*}/ + * annotations, else a leading-space-prefixed run of only the seven + * {@code data-lx-fx-*} attributes (enter/hover/click/duration/glow-color/bolts/intensity, + * in that order) — never {@code data-lx-intent}/{@code concept}/{@code data.*}/ * {@code aria-*}, which ride {@link #renderStyledHtml}'s own container instead. * Every value is parse-time validated (effects are the closed {@code Effect} * vocabulary, duration matches {@code \d{1,5}ms}, glow-color is - * {@code currentColor} or a canonical hex literal), so no raw author string - * reaches an attribute. + * {@code currentColor} or a canonical hex literal, bolts/intensity are bounded ASCII + * decimals), so no raw author string reaches an attribute. Omitted numeric knobs + * produce no attributes; runtime defaults remain 2 bolts and 100 percent intensity. * * @param latex the LaTeX math source (optionally the {@code \lx[…]{…}} macro) * @return {@code ""} or a leading-space-prefixed {@code data-lx-fx-*} attribute run @@ -868,7 +869,7 @@ public static String fxContainerAttrs(String latex) { * The render-coupled seam record: the self-contained SVG document plus the container * attributes derived from the SAME parse+layout, as a key→value map. The consumer (the * Stafficy {@code /docs} markdown converter) stamps each entry onto ITS OWN wrapper - * element; the key set is pinned by the container-output contract (the five + * element; the key set is pinned by the container-output contract (the seven * {@code data-lx-fx-*} plus {@code data-lx-glyphmap}), so nothing re-encodes and the * reflective bridge stays name-agnostic. Values are parse-validated (effects: closed * vocabulary; duration: {@code \d{1,5}ms}; glow: {@code currentColor|#rrggbb}; glyphmap: @@ -886,7 +887,7 @@ public record RenderedMath(String svg, Map containerAttrs) { public RenderedMath { java.util.Objects.requireNonNull(svg, "svg"); // Order-PRESERVING defensive copy: the historical stamp order (enter/hover/ - // click, duration, glow, glyphmap) is load-bearing — the deprecated string + // click, duration, glow, explicit numeric knobs, glyphmap) is load-bearing — the deprecated string // seam is byte-identical to this map joined in iteration order. Map.copyOf // would be immutable but randomize iteration, breaking that equivalence, so // copy through a LinkedHashMap and wrap unmodifiable. @@ -1003,7 +1004,8 @@ private static RenderedMath renderMathPair(String latex, RenderOptions opts) { * THE single container-attribute producer: {@link #fxAttrs} (the string form * {@link #openTag} and the deprecated seam join) derives from this map, so the record * form and the string form cannot drift. Insertion order is the historical stamp order - * (enter/hover/click, duration, glow, glyphmap) so the joined string stays byte-identical. + * (enter/hover/click, duration, glow, explicit numeric knobs, glyphmap) so omitted knobs + * leave the joined string byte-identical to the predecessor. */ private static Map containerAttrMap(EffectSpec fx, String glyphmap, String groupmap) { Map attrs = new java.util.LinkedHashMap<>(); @@ -1013,6 +1015,12 @@ private static Map containerAttrMap(EffectSpec fx, String glyphm } fx.durationValue().ifPresent(v -> attrs.put("data-lx-fx-duration", v)); fx.glowColorValue().ifPresent(c -> attrs.put("data-lx-fx-glow-color", c.svgValue())); + if (fx.bolts() != null) { + attrs.put("data-lx-fx-bolts", Integer.toString(fx.bolts())); + } + if (fx.intensity() != null) { + attrs.put("data-lx-fx-intensity", Integer.toString(fx.intensity())); + } if (glyphmap != null && !glyphmap.isEmpty()) { attrs.put("data-lx-glyphmap", glyphmap); } @@ -1025,14 +1033,14 @@ private static Map containerAttrMap(EffectSpec fx, String glyphm /** * The shared fx-attribute stamping source consumed by BOTH {@link #openTag} and * {@link #fxContainerAttrs} — one producer, so the wrapper form and the seam form - * cannot drift. Emits only the container contract's five {@code data-lx-fx-*} + * cannot drift. Emits only the container contract's seven {@code data-lx-fx-*} * attributes, each value validated at parse time. */ private static String fxAttrs(EffectSpec fx) { // Derived from containerAttrMap — the ONE producer (record seam + string form share // it, so they cannot drift). Values are parse-validated; the glow colour's svgValue() // is currentColor or a canonical #rrggbb literal — a safe attribute VALUE. - // Empty sidecars: fxAttrs stamps only the fx-* / duration / glow attrs; openTag + // Empty sidecars: fxAttrs stamps only the fx-* attrs, including numeric knobs; openTag // stamps the glyphmap/groupmap sidecars separately (and the deprecated seam never // carried them). StringBuilder sb = new StringBuilder(); diff --git a/src/main/java/com/lattex/parse/EffectSpec.java b/src/main/java/com/lattex/parse/EffectSpec.java index 5795f887..63acc650 100644 --- a/src/main/java/com/lattex/parse/EffectSpec.java +++ b/src/main/java/com/lattex/parse/EffectSpec.java @@ -23,9 +23,18 @@ * without evaluating anything symbolic. * @param substituteVar the {@code fx.substitute-var} variable to replace: exactly one * letter, or {@code null} to auto-detect the single distinct letter in the body + * @param bolts explicit {@code fx.bolts} in 1..8, or {@code null} for runtime default 2 + * @param intensity explicit {@code fx.intensity} in 0..300 percent, or {@code null} + * for runtime default 100; zero disables lightning/storm */ public record EffectSpec(Map effects, String duration, Color glowColor, - String substituteTo, String substituteVar) { + String substituteTo, String substituteVar, Integer bolts, Integer intensity) { + + /** Preserves the original public constructor and its omitted-knob state. */ + public EffectSpec(Map effects, String duration, Color glowColor, + String substituteTo, String substituteVar) { + this(effects, duration, glowColor, substituteTo, substituteVar, null, null); + } /** {@code fx.duration} grammar: 1–5 digits followed by the {@code ms} unit. */ private static final Pattern DURATION_PATTERN = Pattern.compile("^\\d{1,5}ms$"); @@ -40,6 +49,8 @@ public record EffectSpec(Map effects, String duration, Color gl public EffectSpec { effects = Map.copyOf(effects); + validateIntegerOption("fx.bolts", bolts, 1, 8); + validateIntegerOption("fx.intensity", intensity, 0, 300); if (duration != null && !DURATION_PATTERN.matcher(duration).matches()) { throw new MathSyntaxException( "invalid fx.duration: \"" + duration + "\" (expected like 250ms)"); @@ -58,7 +69,41 @@ public record EffectSpec(Map effects, String duration, Color gl } } - /** The empty spec: no effects, no duration, no glow colour, no substitution. */ + /** Strict ASCII decimal, bounded during accumulation so overflow never escapes. */ + static int parseIntegerOption(String key, String value, int min, int max) { + if (value.isEmpty()) { + throw invalidIntegerOption(key, value, min, max); + } + int parsed = 0; + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + throw invalidIntegerOption(key, value, min, max); + } + int digit = c - '0'; + if (parsed > max / 10 || (parsed == max / 10 && digit > max % 10)) { + throw invalidIntegerOption(key, value, min, max); + } + parsed = parsed * 10 + digit; + } + if (parsed < min) { + throw invalidIntegerOption(key, value, min, max); + } + return parsed; + } + + private static void validateIntegerOption(String key, Integer value, int min, int max) { + if (value != null && (value < min || value > max)) { + throw invalidIntegerOption(key, value.toString(), min, max); + } + } + + private static MathSyntaxException invalidIntegerOption(String key, String value, int min, int max) { + return new MathSyntaxException("invalid " + key + ": \"" + value + + "\" (expected integral ASCII decimal " + min + ".." + max + ")"); + } + + /** The empty spec: all effect options, including numeric knobs, omitted. */ public static EffectSpec none() { return new EffectSpec(Map.of(), null, null, null, null); } @@ -91,9 +136,9 @@ public Optional substituteVarCodePoint() { return Optional.ofNullable(substituteVar).map(v -> v.codePointAt(0)); } - /** Whether any effect, duration, glow colour, or substitution option was specified. */ + /** Whether any effect option, including an explicit numeric knob, was specified. */ public boolean isEmpty() { return effects.isEmpty() && duration == null && glowColor == null - && substituteTo == null && substituteVar == null; + && substituteTo == null && substituteVar == null && bolts == null && intensity == null; } } diff --git a/src/main/java/com/lattex/parse/LxOptionsParser.java b/src/main/java/com/lattex/parse/LxOptionsParser.java index bf22a9dd..1cc2ab34 100644 --- a/src/main/java/com/lattex/parse/LxOptionsParser.java +++ b/src/main/java/com/lattex/parse/LxOptionsParser.java @@ -148,6 +148,8 @@ private static LxOptions parseLxOptions(String raw, String body) { Color glowColor = null; String substituteTo = null; String substituteVar = null; + Integer bolts = null; + Integer intensity = null; String intent = null; String concept = null; String a11yLabel = null; @@ -180,7 +182,31 @@ private static LxOptions parseLxOptions(String raw, String body) { i = skipWs(raw, i + 1); // past '=' // value: quoted or bare String value; - if (i < n && raw.charAt(i) == '"') { + if (key.equals("fx.bolts") || key.equals("fx.intensity")) { + // Keep malformed numeric spans intact until the keyed validator runs. + // In particular, `2 3` and `"2"junk` must not escape as a generic + // separator error. Strip only insignificant OUTSIDE whitespace/quotes. + int vs = i; + boolean inQuote = false; + while (i < n) { + char c = raw.charAt(i); + if (c == '"') { + inQuote = !inQuote; + } else if (c == ',' && !inQuote) { + break; + } + i++; + } + int end = i; + while (end > vs && isWhitespace(raw.charAt(end - 1))) { + end--; + } + value = raw.substring(vs, end); + if (value.length() >= 2 && value.charAt(0) == '"' + && value.charAt(value.length() - 1) == '"') { + value = value.substring(1, value.length() - 1); + } + } else if (i < n && raw.charAt(i) == '"') { int vs = ++i; while (i < n && raw.charAt(i) != '"') { i++; @@ -235,6 +261,8 @@ private static LxOptions parseLxOptions(String raw, String body) { case "fx.hover" -> effects.put(Trigger.HOVER, Effect.parse(value)); case "fx.click" -> effects.put(Trigger.CLICK, Effect.parse(value)); case "fx.duration" -> duration = value; // validated in EffectSpec + case "fx.bolts" -> bolts = EffectSpec.parseIntegerOption(key, value, 1, 8); + case "fx.intensity" -> intensity = EffectSpec.parseIntegerOption(key, value, 0, 300); // The glow/lightning halo colour. Validated through the same // Color boundary as style.color (accepts #rgb/#rrggbb/currentColor, // fails loud otherwise); it becomes a data-lx-fx-glow-color VALUE on @@ -338,7 +366,7 @@ private static LxOptions parseLxOptions(String raw, String body) { return new LxOptions( new RenderOptions(scale, color, mathStyle), - new EffectSpec(effects, duration, glowColor, substituteTo, substituteVar), + new EffectSpec(effects, duration, glowColor, substituteTo, substituteVar, bolts, intensity), new Semantics(intent, concept, a11yLabel, data)); } diff --git a/src/main/resources/com/lattex/fx/lattex-fx.js b/src/main/resources/com/lattex/fx/lattex-fx.js index d82ba829..379cbd25 100644 --- a/src/main/resources/com/lattex/fx/lattex-fx.js +++ b/src/main/resources/com/lattex/fx/lattex-fx.js @@ -139,10 +139,34 @@ ctx.stroke(); } + // Handcrafted DOM is not trusted: missing/empty/malformed values keep the + // historical defaults. Only WHOLE ASCII decimal strings are accepted (no + // partial parse, signs, whitespace, exponents or Unicode digits). Saturating + // accumulation bounds even huge digit strings without numeric overflow. + function boundedFxDecimal(el, attr, fallback, min, max) { + var raw = el.getAttribute(attr); + if (typeof raw !== 'string' || !raw.length || /[^0-9]/.test(raw)) { return fallback; } + var n = 0; + for (var i = 0; i < raw.length; i++) { + n = Math.min(max, n * 10 + raw.charCodeAt(i) - 48); + } + return Math.max(min, n); + } + function boltOptions(el) { + return { + bolts: boundedFxDecimal(el, 'data-lx-fx-bolts', 2, 1, 8), + intensity: boundedFxDecimal(el, 'data-lx-fx-intensity', 100, 0, 300) + }; + } + function boltEffect(name) { return name === 'lightning' || name === 'storm'; } + // The confluence: two rivers of lightning arc IN from the left- and right-mid // viewport edges and converge on the element's centre, flash, then fade. A // lazily-created, pointer-events-none body overlay — never inside the . function lightning(el) { + var options = boltOptions(el); // re-read on EVERY direct/event/public play + if (options.intensity === 0) { return; } // before ANY effect work + var strength = options.intensity / 100; var r = el.getBoundingClientRect(); var cx = r.left + r.width / 2, cy = r.top + r.height / 2; var vw = window.innerWidth, vh = window.innerHeight; @@ -159,13 +183,19 @@ var ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); + // The bolts converge on where the equation WAS at trigger time (fixed + // overlay) — if the page scrolls the anchor is gone, so end the show + // immediately rather than strike a phantom (Charles, 2026-07-06). + var die = scrollKillable(function () { canvas.remove(); }); + // Reduced motion: one faint static arc from each side, no flicker, no flash. + // Nonzero knobs cannot amplify this branch. if (reduced) { ctx.globalAlpha = 0.45; ctx.strokeStyle = color; ctx.lineWidth = 2; sources.forEach(function (s) { stroke(ctx, jagged(s[0], s[1], cx, cy, 8, 6)); }); - setTimeout(function () { canvas.remove(); }, 400); + setTimeout(die, 400); return; } @@ -174,12 +204,14 @@ ctx.clearRect(0, 0, vw, vh); ctx.strokeStyle = color; ctx.shadowColor = color; - ctx.shadowBlur = 12; + // Appearance only: fixed caps of 48px glow, 12px stroke and alpha 1. + // Intensity never changes geometry, random draws or scheduling. + ctx.shadowBlur = Math.min(48, 12 * strength); sources.forEach(function (s) { - for (var k = 0; k < 2; k++) { // 2 forked bolts per side + for (var k = 0; k < options.bolts; k++) { // bounded forked bolts per side var pts = jagged(s[0], s[1], cx, cy, 12, 18 + k * 10); - ctx.lineWidth = 2 + k; - ctx.globalAlpha = 0.9; + ctx.lineWidth = Math.min(12, (2 + k) * strength); + ctx.globalAlpha = Math.min(1, 0.9 * strength); stroke(ctx, pts, Math.floor(pts.length * progress)); } }); @@ -188,17 +220,12 @@ var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad); g.addColorStop(0, color); g.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.globalAlpha = 1; + ctx.globalAlpha = Math.min(1, strength); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, rad, 0, Math.PI * 2); ctx.fill(); } } - // The bolts converge on where the equation WAS at trigger time (fixed - // overlay) — if the page scrolls the anchor is gone, so end the show - // immediately rather than strike a phantom (Charles, 2026-07-06). - var die = scrollKillable(function () { canvas.remove(); }); - var t0 = performance.now(); function frame(now) { if (die.dead()) { return; } @@ -221,6 +248,10 @@ // bolt canvas are three SEPARATE pointer-events-none body overlays; nothing // is ever written into the element's (the containment contract holds). function storm(el) { + var options = boltOptions(el); // independent DOM bounds on every play + if (options.intensity === 0) { return; } // no overlays, canvas or listeners + // Reduced motion owns its unchanged gentle appearance at EVERY nonzero value. + var strength = reduced ? 1 : options.intensity / 100; var r = el.getBoundingClientRect(); var cx = r.left + r.width / 2, cy = r.top + r.height / 2; var vw = window.innerWidth, vh = window.innerHeight; @@ -266,13 +297,15 @@ var boltGlow = (authored && authored.toLowerCase() !== 'currentcolor') ? authored : '#8fc2ff'; function heatFilter(level) { - var outer = 6 + level * 22; // coloured halo: 6 → 28px - var warm = 4 + level * 16; // warm/amber mid layer - var core = level * 12; // white-hot core (0 at rest) + // Default halos stay 6..28 / 4..20 / 0..12px. Fixed maximums are + // 84 / 60 / 36px and alpha .95, even at the maximum authored strength. + var outer = Math.min(84, (6 + level * 22) * strength); + var warm = Math.min(60, (4 + level * 16) * strength); + var core = Math.min(36, level * 12 * strength); return 'drop-shadow(0 0 ' + outer + 'px ' + color + ')' + ' drop-shadow(0 0 ' + warm + 'px ' + heatTint + ')' + ' drop-shadow(0 0 ' + core + 'px rgba(255,255,255,' - + (0.35 + level * 0.6).toFixed(3) + '))'; + + Math.min(0.95, (0.35 + level * 0.6) * strength).toFixed(3) + '))'; } // Smooth the ramp between strike bumps (restored on cleanup). var filter0 = el.style.filter, trans0 = el.style.transition; @@ -306,16 +339,16 @@ if (reduced) { backdrop.style.setProperty('--lx-dark', '0.5'); el.style.filter = heatFilter(0.3); // gentle steady glow, no pulsing - requestAnimationFrame(function () { backdrop.style.opacity = '1'; }); + requestAnimationFrame(function () { if (!dead) { backdrop.style.opacity = String(Math.min(1, strength)); } }); sctx.globalAlpha = 0.55; sctx.strokeStyle = boltColor; sctx.shadowColor = boltGlow; sctx.shadowBlur = 10; sctx.lineWidth = 2; stroke(sctx, jagged(0, vh / 2, cx, cy, 8, 6)); - setTimeout(function () { backdrop.style.opacity = '0'; }, 520); + setTimeout(function () { if (!dead) { backdrop.style.opacity = '0'; } }, 520); setTimeout(cleanup, 950); return; } - requestAnimationFrame(function () { backdrop.style.opacity = '1'; }); + requestAnimationFrame(function () { if (!dead) { backdrop.style.opacity = String(Math.min(1, strength)); } }); // One strike: quickly draw converging jagged bolts to the centre, then at // convergence FLASH the backdrop bright (~70ms) — drop its darkness, pulse @@ -327,17 +360,20 @@ if (dead) { return; } // scrolled away mid-strike: overlays already gone var p = Math.min(1, (now - t0) / DRAW); sctx.clearRect(0, 0, vw, vh); - sctx.strokeStyle = boltColor; sctx.shadowColor = boltGlow; sctx.shadowBlur = 16; + sctx.strokeStyle = boltColor; sctx.shadowColor = boltGlow; + // Fixed appearance caps; no intensity-dependent work or random branches. + sctx.shadowBlur = Math.min(48, 16 * strength); sources.forEach(function (s) { - for (var k = 0; k < 2; k++) { // 2 forked bolts per source + for (var k = 0; k < options.bolts; k++) { // bounded forked bolts per source var pts = jagged(s[0], s[1], cx, cy, 12, 18 + k * 10); - sctx.lineWidth = 2 + k; sctx.globalAlpha = 0.9; + sctx.lineWidth = Math.min(12, (2 + k) * strength); + sctx.globalAlpha = Math.min(1, 0.9 * strength); stroke(sctx, pts, Math.floor(pts.length * p)); } }); if (p < 1) { requestAnimationFrame(frame); return; } backdrop.style.setProperty('--lx-dark', '0.55'); // night lights up - flash.style.opacity = '1'; + flash.style.opacity = String(Math.min(1, strength)); el.style.filter = heatFilter(level); // charge pulses the math hotter setTimeout(function () { if (dead) { return; } // never re-heat the equation after teardown @@ -2658,13 +2694,17 @@ var click = el.getAttribute('data-lx-fx-click'); var dur = el.getAttribute('data-lx-fx-duration') || '400ms'; var glowColor = el.getAttribute('data-lx-fx-glow-color'); + // Zero disables ONLY lightning/storm triggers. Mixed keyframe/semantic + // triggers keep their listeners and any enter pre-hide behavior unchanged. + var boltsOff = (boltEffect(enter) || boltEffect(hover) || boltEffect(click)) + && boundedFxDecimal(el, 'data-lx-fx-intensity', 100, 0, 300) === 0; // Author-set glow colour drives both the glow keyframe (via the CSS var) // and the lightning strike colour. if (glowColor) { el.style.setProperty('--lx-glow-color', glowColor); } // enter: play once on load. - if (enter) { + if (enter && !(boltsOff && boltEffect(enter))) { play(el, enter, dur); // Arming effects animate nothing at arm time; enter means "play on load", // so fire the cascade once now (reduced-motion is honored inside it). @@ -2681,11 +2721,11 @@ } } // hover: play on each mouseenter. - if (hover) { + if (hover && !(boltsOff && boltEffect(hover))) { el.addEventListener('mouseenter', function () { play(el, hover, dur); }); } // click: play on each click (and show it's interactive). - if (click) { + if (click && !(boltsOff && boltEffect(click))) { el.style.cursor = 'pointer'; el.addEventListener('click', function () { play(el, click, dur); }); } @@ -2737,6 +2777,8 @@ // nor stores anything back from the hook. if (typeof window !== 'undefined' && typeof window.__lxTestHook === 'function') { window.__lxTestHook({ + lightning: lightning, + storm: storm, placement: placement, setPathDelta: setPathDelta, clearPathDelta: clearPathDelta, diff --git a/src/test/java/com/lattex/api/ContainerDriftTest.java b/src/test/java/com/lattex/api/ContainerDriftTest.java index 090d9f3a..ea037687 100644 --- a/src/test/java/com/lattex/api/ContainerDriftTest.java +++ b/src/test/java/com/lattex/api/ContainerDriftTest.java @@ -31,6 +31,7 @@ class ContainerDriftTest { "aria-label", "data-lx-fx-enter", "data-lx-fx-hover", "data-lx-fx-click", "data-lx-fx-duration", "data-lx-fx-glow-color", + "data-lx-fx-bolts", "data-lx-fx-intensity", "data-lx-glyphmap", "data-lx-groupmap", // The graph-* metadata (LxOptionsParser stamps `graph.*` options as hyphenated // `data-lx-graph-` for the host-side plotter). These are HYPHENATED, so they @@ -232,12 +233,60 @@ void substituteMarkerAndVarmapRideTheAllowList() { assertNoInjection(tag, "substitute marker"); } + @Test + void numericFxNamesAreExactMembersAndTheirValuesAreBoundedAsciiDecimals() { + for (String effect : List.of("lightning", "storm")) { + for (int[] values : new int[][]{{1, 0}, {2, 100}, {8, 300}}) { + String latex = "\\lx[fx.hover=" + effect + ", fx.bolts=" + values[0] + + ", fx.intensity=" + values[1] + "]{x}"; + String tag = openTagOf(LatteX.renderStyledHtml(latex)); + List names = attrNames(tag); + for (String name : List.of("data-lx-fx-bolts", "data-lx-fx-intensity")) { + assertTrue(names.contains(name), "explicit knob must actually stamp " + name); + assertTrue(ALLOWED_EXACT.contains(name), "exact entry is load-bearing: " + name); + assertTrue(!DATA_ATTR.matcher(name).matches(), "identifier lane stays closed: " + name); + } + assertValue(tag, "data-lx-fx-bolts", "[1-8]"); + assertValue(tag, "data-lx-fx-intensity", "0|[1-9][0-9]?|[12][0-9]{2}|300"); + assertTrue(tag.contains("data-lx-fx-bolts=\"" + values[0] + "\""), tag); + assertTrue(tag.contains("data-lx-fx-intensity=\"" + values[1] + "\""), tag); + for (String name : names) { + assertTrue(isAllowed(name), "numeric knob drift: " + name); + } + assertNoInjection(tag, latex); + } + } + for (String name : List.of("data-lx-fx-strikes", "data-lx-fx-arc", + "data-lx-fx-lightning-bolts", "data-lx-fx-intensity-extra")) { + assertTrue(!isAllowed(name), "no broad hyphenated-name admission: " + name); + } + } + + @Test + void numericKnobInjectionIsRejectedBeforeAnyContainerCanBeStamped() { + for (String effect : List.of("lightning", "storm")) { + for (String key : List.of("fx.bolts", "fx.intensity")) { + for (String value : List.of("\"2 onload=evil\"", "\"" + rendered.svg() + "", + LatteX.renderStyledHtml(latex), "wrapper and map use the same stamp"); + assertEquals(baseline.svg(), rendered.svg(), "numeric knobs leave inner SVG unchanged"); + } + } + } + + @Test + void eachKnobStampsOnlyWhenExplicitIncludingDefaultsAndZero() { + for (String effect : new String[]{"lightning", "storm"}) { + String prefix = "\\lx[fx.hover=" + effect; + for (String suffix : new String[]{",fx.bolts=2", ",fx.intensity=100", ",fx.intensity=0"}) { + String latex = prefix + suffix + "]{x}"; + Map attrs = LatteX.tryRenderMath(latex).orElseThrow().containerAttrs(); + boolean bolts = suffix.startsWith(",fx.bolts"); + assertEquals(bolts, attrs.containsKey("data-lx-fx-bolts")); + assertEquals(!bolts, attrs.containsKey("data-lx-fx-intensity")); + assertEquals(2, attrs.size(), "only the effect and explicit knob"); + String stamp = LatteX.fxContainerAttrs(latex); + assertTrue(stamp.contains(bolts ? "data-lx-fx-bolts=\"2\"" + : "data-lx-fx-intensity=\"" + (suffix.endsWith("=0") ? "0" : "100") + "\"")); + } + Map omitted = LatteX.tryRenderMath(prefix + "]{x}").orElseThrow().containerAttrs(); + assertFalse(omitted.containsKey("data-lx-fx-bolts")); + assertFalse(omitted.containsKey("data-lx-fx-intensity")); + assertEquals(" data-lx-fx-hover=\"" + effect + "\"", LatteX.fxContainerAttrs(prefix + "]{x}")); + } + assertEquals(" data-lx-fx-bolts=\"2\" data-lx-fx-intensity=\"0\"", + LatteX.fxContainerAttrs("\\lx[fx.bolts=\"002\",fx.intensity=\"000\"]{x}"), + "explicit decimal values are canonical ASCII, even without a trigger"); + } + + @Test + void omittedKnobsMatchExactPredecessorWrapperAndSvgBytes() throws IOException { + String fixture; + try (InputStream stream = getClass().getResourceAsStream("/com/lattex/fx/absent-knobs-golden.html")) { + assertNotNull(stream, "immutable predecessor fixture must be present"); + fixture = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + Pattern row = Pattern.compile("", Pattern.DOTALL); + Matcher rows = row.matcher(fixture); + Set cases = new HashSet<>(); + int count = 0; + while (rows.find()) { + count++; + String id = rows.group(1); + assertTrue(cases.add(id), "duplicate predecessor case " + id); + String input = new String(Base64.getDecoder().decode(rows.group(2)), StandardCharsets.UTF_8); + byte[] expectedSvg = Base64.getDecoder().decode(rows.group(3)); + byte[] expectedWrapper = rows.group(4).getBytes(StandardCharsets.UTF_8); + assertArrayEquals(expectedWrapper, LatteX.renderStyledHtml(input).getBytes(StandardCharsets.UTF_8), + "exact predecessor wrapper bytes for case " + id); + LatteX.RenderedMath rendered = LatteX.tryRenderMath(input).orElseThrow(); + assertArrayEquals(expectedSvg, rendered.svg().getBytes(StandardCharsets.UTF_8), + "exact predecessor inner SVG bytes for case " + id); + assertFalse(rendered.containerAttrs().containsKey("data-lx-fx-bolts")); + assertFalse(rendered.containerAttrs().containsKey("data-lx-fx-intensity")); + } + assertEquals(6, count, "all six retained predecessor rows must be exercised"); + assertEquals(Set.of("0", "1", "2", "3", "4", "5"), cases); + } } diff --git a/src/test/java/com/lattex/fx/FxRuntimeJsHarnessTest.java b/src/test/java/com/lattex/fx/FxRuntimeJsHarnessTest.java index c8cb2805..bcd93da9 100644 --- a/src/test/java/com/lattex/fx/FxRuntimeJsHarnessTest.java +++ b/src/test/java/com/lattex/fx/FxRuntimeJsHarnessTest.java @@ -74,6 +74,157 @@ private Value makeEl(String script) { return context.eval(Source.create("js", script)); } + // ---- Slice 1: bounded lightning/storm work and appearance ------------------ + + private record BoltRun(String work, String geometry, String appearance, + int strokes, int segments, int gradients, + double alpha, double width, double blur, String filters) {} + + /** Fresh clocks/queues and the SAME seed for each run. Work survives teardown. */ + private BoltRun boltRun(String effect, String bolts, String intensity, + String route, boolean reducedMotion) throws IOException { + try (Context run = Context.newBuilder("js").build()) { + if (reducedMotion) { + run.eval("js", "globalThis.__lxStubReducedMotion = true;"); + } + run.eval(source("/com/lattex/fx/harness-dom-stub.js", "harness-dom-stub.js")); + run.eval("js", "globalThis.__lxTestHook = function (fx) { globalThis.__fx = fx; };"); + run.eval(source("/com/lattex/fx/lattex-fx.js", "lattex-fx.js")); + Value el = run.eval("js", "globalThis.__bolt = __lxMakeEl({}); __lxSeedRandom(173); __bolt"); + el.getMember("setAttribute").execute("data-lx-fx-hover", effect); + if (bolts != null) { el.getMember("setAttribute").execute("data-lx-fx-bolts", bolts); } + if (intensity != null) { el.getMember("setAttribute").execute("data-lx-fx-intensity", intensity); } + switch (route) { + case "direct" -> run.getBindings("js").getMember("__fx").getMember(effect).execute(el); + case "play" -> run.getBindings("js").getMember("__fx").getMember("play") + .execute(el, effect, "400ms"); + case "event" -> { + run.eval("js", "document.querySelectorAll = function (sel) {" + + " return sel === '.lx-math' ? [__bolt] : []; }; __fx.init();"); + el.getMember("__fire").execute("mouseenter"); + } + case "public" -> assertTrue(run.eval("js", "LatteXFx.play(__bolt)").asBoolean()); + default -> throw new AssertionError("unknown route " + route); + } + // Interleave strike timers with monotonically advancing RAF timestamps. + run.eval("js", "for (var turn = 0; turn < 12; turn++) {" + + " __lxRunTimeouts(1); __lxFlushRaf(100); }"); + assertEquals(0, run.eval("js", "__lxBodyChildren()").asInt(), "run really completed"); + assertEquals(0, run.eval("js", "__lxActiveTimeouts()").asInt(), "no hidden pending work"); + assertEquals(0, run.eval("js", "__lxScrollListeners()").asInt()); + Value work = run.eval("js", "__lxWorkSnapshot()"); + Value caps = run.eval("js", "({" + + "alpha: Math.max(0, ...__lxCanvasStrokes.map(s => s.alpha))," + + "width: Math.max(0, ...__lxCanvasStrokes.map(s => s.width))," + + "blur: Math.max(0, ...__lxCanvasStrokes.map(s => s.blur)) })"); + return new BoltRun(run.eval("js", "JSON.stringify(__lxWorkSnapshot())").asString(), + run.eval("js", "JSON.stringify(__lxCanvasStrokes.map(s => s.points))").asString(), + run.eval("js", "JSON.stringify(__lxCanvasStrokes)").asString(), + work.getMember("strokes").asInt(), work.getMember("segments").asInt(), + work.getMember("gradients").asInt(), caps.getMember("alpha").asDouble(), + caps.getMember("width").asDouble(), caps.getMember("blur").asDouble(), + run.eval("js", "JSON.stringify(__lxFilterWrites)").asString()); + } + } + + @Test + void boltDefaultsPreserveExplicitTwoAndOneHundredAppearanceAndWork() throws IOException { + for (String effect : new String[]{"lightning", "storm"}) { + assertEquals(boltRun(effect, null, null, "play", false), + boltRun(effect, "2", "100", "play", false), effect); + } + } + + @Test + void handcraftedBoltBoundsChangeActualGeometryForBothEffectsOnEveryRoute() throws IOException { + for (String effect : new String[]{"lightning", "storm"}) { + for (String route : new String[]{"direct", "play", "event", "public"}) { + BoltRun min = boltRun(effect, "1", "100", route, false); + BoltRun max = boltRun(effect, "8", "100", route, false); + assertTrue(min.strokes() > 0 && min.segments() > 0, effect + route); + assertEquals(min.strokes() * 8, max.strokes(), "actual bounded bolt count"); + assertEquals(min.segments() * 8, max.segments(), "actual bounded segment work"); + assertEquals(min, boltRun(effect, "0", "100", route, false), "DOM min clamps"); + assertEquals(max, boltRun(effect, "9", "100", route, false), "DOM max clamps"); + assertEquals(max, boltRun(effect, "9".repeat(400), "100", route, false), + "huge whole decimals saturate without overflow"); + } + } + } + + @Test + void malformedDomKnobsUseDefaultsWithoutPartialParsing() throws IOException { + String[] malformed = {"", "+2", "-1", "2.5", "2e1", "2px", " 2", "2 ", + "2\n", "٢", "0x2", "Infinity", "NaN", "2;3"}; + for (String effect : new String[]{"lightning", "storm"}) { + BoltRun defaults = boltRun(effect, null, null, "play", false); + for (String raw : malformed) { + assertEquals(defaults, boltRun(effect, raw, raw, "play", false), effect + raw); + } + assertEquals(boltRun(effect, "8", "300", "play", false), + boltRun(effect, "00008", "000300", "play", false), "whole leading zeros are decimal"); + } + } + + @Test + void intensityChangesOnlyBoundedAppearanceAtIdenticalSeedClockAndBolts() throws IOException { + for (String effect : new String[]{"lightning", "storm"}) { + BoltRun normal = boltRun(effect, "8", "100", "play", false); + BoltRun bright = boltRun(effect, "8", "300", "play", false); + assertEquals(normal.work(), bright.work(), "ALL cumulative work must match: " + effect); + assertEquals(normal.geometry(), bright.geometry(), "intensity cannot alter geometry"); + assertFalse(normal.appearance().equals(bright.appearance()), "appearance really changes"); + assertEquals(0.9, normal.alpha(), 0.0001, "default alpha preserved"); + assertEquals(9, normal.width(), 0.0001, "default 2+k width preserved at eight bolts"); + assertEquals(1, bright.alpha(), 0.0001); + assertEquals(12, bright.width(), 0.0001, "fixed stroke cap"); + assertTrue(bright.blur() <= 48 && bright.blur() > normal.blur(), "fixed glow cap"); + assertEquals(bright, boltRun(effect, "8", "301", "play", false)); + assertEquals(bright, boltRun(effect, "8", "9".repeat(400), "play", false)); + BoltRun faint = boltRun(effect, "8", "1", "play", false); + assertEquals(normal.work(), faint.work(), "positive low intensity also preserves work"); + assertTrue(faint.alpha() < normal.alpha() && faint.width() < normal.width()); + if (effect.equals("lightning")) { + assertTrue(normal.gradients() > 0, "convergence really exercises radial gradients"); + } else { + assertTrue(normal.filters().contains("28px") && normal.filters().contains("20px"), + "default climax halos preserved"); + assertTrue(bright.filters().contains("84px") && bright.filters().contains("60px") + && bright.filters().contains("36px"), "storm climax reaches fixed halo caps"); + Matcher halo = Pattern.compile("drop-shadow\\(0 0 ([0-9.]+)px").matcher(bright.filters()); + while (halo.find()) { + assertTrue(Double.parseDouble(halo.group(1)) <= 84, "all storm halos are bounded"); + } + } + } + } + + @Test + void reducedMotionWinsOverAllNonzeroBoltAndIntensityValues() throws IOException { + for (String effect : new String[]{"lightning", "storm"}) { + BoltRun gentle = boltRun(effect, "1", "1", "play", true); + assertEquals(gentle, boltRun(effect, "8", "300", "play", true)); + assertEquals(effect.equals("lightning") ? 2 : 1, gentle.strokes(), + "the original faint static reduced branch remains in control"); + assertEquals(2, gentle.width(), 0.0001); + assertEquals(effect.equals("lightning") ? 0.45 : 0.55, gentle.alpha(), 0.0001); + } + } + + @Test + void harnessCanvasGradientRetainsMeaningfulStopsAndCountsWorkCumulatively() { + Value result = makeEl("(function () { var c = document.createElement('canvas');" + + " var ctx = c.getContext('2d'); var g = ctx.createRadialGradient(1,2,0,1,2,8);" + + " g.addColorStop(0, '#fff'); g.addColorStop(1, 'transparent');" + + " document.body.appendChild(c); c.remove(); return g; })()"); + assertEquals(2, result.getMember("stops").getArraySize()); + assertEquals("#fff", result.getMember("stops").getArrayElement(0).getArrayElement(1).asString()); + assertEquals(2, makeEl("__lxWorkSnapshot().colorStops").asInt()); + assertEquals(1, makeEl("__lxWorkSnapshot().canvases").asInt()); + assertEquals(1, makeEl("__lxWorkSnapshot().bodyAppends").asInt()); + assertEquals(0, makeEl("__lxBodyChildren()").asInt(), "cumulative work survives removal"); + } + // ---- pin 0: the public page API (plan 51051447) ----------------------------- @Test diff --git a/src/test/java/com/lattex/fx/FxRuntimeLifecycleTest.java b/src/test/java/com/lattex/fx/FxRuntimeLifecycleTest.java index 0f5f9cda..ddef17b4 100644 --- a/src/test/java/com/lattex/fx/FxRuntimeLifecycleTest.java +++ b/src/test/java/com/lattex/fx/FxRuntimeLifecycleTest.java @@ -41,11 +41,17 @@ class FxRuntimeLifecycleTest { private Value fx; private void boot(boolean reducedMotion) throws IOException { + boot(reducedMotion, ""); + } + + private void boot(boolean reducedMotion, String beforeRuntime) throws IOException { + if (context != null) { context.close(); } context = Context.newBuilder("js").build(); if (reducedMotion) { context.eval(Source.create("js", "globalThis.__lxStubReducedMotion = true;")); } context.eval(source("/com/lattex/fx/harness-dom-stub.js", "harness-dom-stub.js")); + context.eval(Source.create("js", beforeRuntime)); context.eval(Source.create("js", "globalThis.__lxTestHook = function (internals) { globalThis.__lxInternals = internals; };")); context.eval(source("/com/lattex/fx/lattex-fx.js", "lattex-fx.js")); @@ -76,6 +82,143 @@ private int intOf(String expr) { return js(expr).asInt(); } + // ---- Slice 1: no-work initialization and stale-callback fences ------------- + + private String work() { + return js("JSON.stringify(__lxWorkSnapshot())").asString(); + } + + private void finishBoltRun() { + js("for (var turn = 0; turn < 12; turn++) { __lxRunTimeouts(1); __lxFlushRaf(100); }"); + } + + @Test + void zeroIntensityDoesNoTargetWorkAtActualInitializationOrAnyPlayRoute() throws IOException { + for (boolean reducedMotion : new boolean[]{false, true}) { + // The ordinary DOMContentLoaded bootstrap listener belongs to the APP, + // not to a target effect. Compare target deltas against this baseline. + boot(reducedMotion, "document.readyState = 'loading';"); + js("__lxFireDocument('DOMContentLoaded')"); + String baseline = work(); + assertEquals(1, intOf("__lxWorkSnapshot().listenerAdds"), "common bootstrap counted"); + for (String effect : new String[]{"lightning", "storm"}) { + boot(reducedMotion, "document.readyState = 'loading';" + + " globalThis.__zero = __lxMakeEl({'data-lx-fx-enter':'" + effect + "'," + + " 'data-lx-fx-hover':'" + effect + "', 'data-lx-fx-click':'" + effect + "'," + + " 'data-lx-fx-bolts':'8', 'data-lx-fx-intensity':'0'}, 2);" + + " document.querySelectorAll = function (sel) {" + + " return sel === '.lx-math' ? [__zero] : []; };"); + assertEquals(baseline, work(), "no eager target work before DOM ready"); + js("__lxFireDocument('DOMContentLoaded')"); + assertEquals(baseline, work(), "zero target initialization adds NOTHING: " + effect); + js("__zero.__fire('mouseenter'); __zero.__fire('click'); LatteXFx.play(__zero)"); + fx.getMember(effect).execute(js("__zero")); + fx.getMember("play").execute(js("__zero"), effect, "400ms"); + finishBoltRun(); + assertEquals(baseline, work(), "zero direct/event/public paths add NOTHING"); + assertEquals("", valueOrEmpty(js("__zero.style.opacity")), "equation stays visible"); + assertEquals("", valueOrEmpty(js("__zero.style.cursor")), "no inert click affordance"); + assertEquals(0, intOf("__lxBodyChildren()")); + assertEquals(0, intOf("__lxScrollListeners()")); + } + } + } + + @Test + void everyPlayRereadsBoundsAndZeroEvenForPreviouslyArmedEvents() throws IOException { + for (String effect : new String[]{"lightning", "storm"}) { + for (String route : new String[]{"direct", "play", "hover", "click", "public"}) { + boot(false); + Value el = js("globalThis.__changing = __lxMakeEl({'data-lx-fx-hover':'" + effect + + "', 'data-lx-fx-click':'" + effect + "', 'data-lx-fx-bolts':'1'," + + " 'data-lx-fx-intensity':'100'});" + + " document.querySelectorAll = function (sel) {" + + " return sel === '.lx-math' ? [__changing] : []; }; __lxInternals.init(); __changing"); + String trigger = switch (route) { + case "direct" -> "__lxInternals." + effect + "(__changing)"; + case "play" -> "__lxInternals.play(__changing, '" + effect + "', '400ms')"; + case "hover" -> "__changing.__fire('mouseenter')"; + case "click" -> "__changing.__fire('click')"; + case "public" -> "LatteXFx.play(__changing)"; + default -> throw new AssertionError(route); + }; + js(trigger); + finishBoltRun(); + int first = intOf("__lxWorkSnapshot().strokes"); + assertTrue(first > 0, "positive control " + effect + route); + el.getMember("setAttribute").execute("data-lx-fx-bolts", "999999999999999999999999999"); + el.getMember("setAttribute").execute("data-lx-fx-intensity", "300"); + js(trigger); + finishBoltRun(); + assertEquals(first * 8, intOf("__lxWorkSnapshot().strokes") - first, + "updated DOM clamps on replay, including armed event closures: " + effect + route); + el.getMember("setAttribute").execute("data-lx-fx-intensity", "000"); + String beforeZero = work(); + js(trigger); + finishBoltRun(); + assertEquals(beforeZero, work(), "updated zero must do no work: " + effect + route); + } + } + } + + @Test + void zeroOnlyDisablesBoltTriggersAndPreservesMixedPrehiddenKeyframes() throws IOException { + boot(false); + js("globalThis.__mixed = __lxMakeEl({'data-lx-fx-enter':'fade'," + + " 'data-lx-fx-hover':'lightning', 'data-lx-fx-click':'pulse', 'data-lx-fx-intensity':'0'});" + + " __mixed.style.opacity = '0';" + + " document.querySelectorAll = function (sel) { return sel === '.lx-math' ? [__mixed] : []; };" + + " __lxInternals.init();"); + assertEquals("0", js("__mixed.style.opacity").asString(), "preserve unrelated enter pre-hide"); + assertTrue(js("__mixed.style.animation").asString().startsWith("lx-fade ")); + assertEquals(2, intOf("__lxWorkSnapshot().listenerAdds"), "fade pin and pulse click only"); + String beforeHover = work(); + js("__mixed.__fire('mouseenter')"); + assertEquals(beforeHover, work(), "zero lightning has no hover listener"); + js("__mixed.__fire('animationend'); __mixed.__fire('click')"); + assertEquals("1", js("__mixed.style.opacity").asString(), "normal fade reveal retained"); + assertTrue(js("__mixed.style.animation").asString().startsWith("lx-pulse ")); + assertEquals("pointer", js("__mixed.style.cursor").asString()); + } + + @Test + void boltScrollTeardownParksAlreadyQueuedCallbacksInBothMotionModes() throws IOException { + for (boolean reducedMotion : new boolean[]{false, true}) { + for (String effect : new String[]{"lightning", "storm"}) { + for (boolean midStrike : new boolean[]{false, true}) { + boot(reducedMotion); + Value el = js("globalThis.__scrollBolt = __lxMakeEl({'data-lx-fx-bolts':'8'," + + " 'data-lx-fx-intensity':'300'});" + + " __scrollBolt.style.filter = 'pristine'; __scrollBolt.style.transition = 'original'; __scrollBolt"); + fx.getMember("play").execute(el, effect, "400ms"); + assertTrue(intOf("__lxBodyChildren()") > 0, "positive control: overlays exist"); + if (midStrike && !reducedMotion) { + js("__lxRunTimeouts(1); __lxFlushRaf(14)"); + assertTrue(intOf("__lxWorkSnapshot().strokes") > 0, "actual strike drawn"); + } + js("__lxFireScroll(0, 10)"); + assertEquals(0, intOf("__lxBodyChildren()"), "scroll removes all fixed overlays"); + assertEquals(0, intOf("__lxScrollListeners()")); + assertEquals("pristine", js("__scrollBolt.style.filter").asString()); + assertEquals("original", js("__scrollBolt.style.transition").asString()); + // Executing an already queued callback counts as rafRun. It must + // perform NO drawing, DOM/style writes, or new scheduling. + String parked = js("(function () { var w = __lxWorkSnapshot();" + + " delete w.rafRun; return JSON.stringify(w); })()").asString(); + finishBoltRun(); + assertEquals(parked, js("(function () { var w = __lxWorkSnapshot();" + + " delete w.rafRun; return JSON.stringify(w); })()").asString(), + "queued callbacks cannot resurrect work: " + effect + reducedMotion + midStrike); + assertEquals(0, intOf("__lxActiveTimeouts()")); + assertEquals(0, intOf("__lxBodyChildren()")); + assertEquals("pristine", js("__scrollBolt.style.filter").asString()); + assertFalse(js("LatteXFx.destroy(__scrollBolt)").asBoolean(), + "Slice 1 does not expand the public destroy contract"); + } + } + } + } + // ---- hologram: full teardown (review HIGH) --------------------------------- @Test diff --git a/src/test/java/com/lattex/parse/LxMacroTest.java b/src/test/java/com/lattex/parse/LxMacroTest.java index dd8cef06..d949f628 100644 --- a/src/test/java/com/lattex/parse/LxMacroTest.java +++ b/src/test/java/com/lattex/parse/LxMacroTest.java @@ -10,6 +10,7 @@ import com.lattex.api.MathStyle; import com.lattex.parse.MathNode.Fraction; import com.lattex.parse.MathNode.StyledMath; +import java.util.Map; import org.junit.jupiter.api.Test; /** @@ -115,6 +116,110 @@ void parsesStormEffect() { lx("\\lx[fx.click=storm]{ x }").fx().effect(Trigger.CLICK).orElseThrow()); } + @Test + void numericKnobsPreserveAbsentStateAndParseBothEffectsAtTheirBounds() { + for (String effect : new String[]{"lightning", "storm"}) { + EffectSpec omitted = lx("\\lx[fx.hover=" + effect + "]{x}").fx(); + assertNull(omitted.bolts()); + assertNull(omitted.intensity()); + for (int[] values : new int[][]{{1, 0}, {2, 100}, {8, 300}}) { + EffectSpec fx = lx("\\lx[ fx.hover = " + effect + ", fx.bolts = " + values[0] + + " ,\tfx.intensity = " + values[1] + " \n]{x}").fx(); + assertEquals(Integer.valueOf(values[0]), fx.bolts()); + assertEquals(Integer.valueOf(values[1]), fx.intensity()); + assertEquals(Effect.parse(effect), fx.effect(Trigger.HOVER).orElseThrow()); + } + } + EffectSpec quoted = lx("\\lx[fx.bolts=\"002\", fx.intensity=\"000\"]{x}").fx(); + assertEquals(Integer.valueOf(2), quoted.bolts()); + assertEquals(Integer.valueOf(0), quoted.intensity()); + EffectSpec boltsOnly = lx("\\lx[fx.bolts=2]{x}").fx(); + assertNull(boltsOnly.intensity()); + assertTrue(!boltsOnly.isEmpty(), "explicit default is still authored state"); + EffectSpec zeroOnly = lx("\\lx[fx.intensity=0]{x}").fx(); + assertNull(zeroOnly.bolts()); + assertTrue(!zeroOnly.isEmpty(), "explicit zero must not disappear"); + assertTrue(EffectSpec.none().isEmpty()); + } + + @Test + void numericKnobsRejectMalformedValuesWithKeyValueAndRangeEvenAtTokenizerFailures() { + String[][] malformed = { + {"", ""}, {"\"\"", ""}, {"-1", "-1"}, {"+1", "+1"}, + {"1.0", "1.0"}, {"1e0", "1e0"}, {"1E0", "1E0"}, {"1ms", "1ms"}, + {"2%", "2%"}, {"0x2", "0x2"}, {"NaN", "NaN"}, {"Infinity", "Infinity"}, + {"1x", "1x"}, {"١", "١"}, {"1", "1"}, + {"1 2", "1 2"}, {"1\t2", "1\t2"}, {"\" 2\"", " 2"}, + {"\"2 \"", "2 "}, {"\"1 2\"", "1 2"}, {"\"2\"junk", "\"2\"junk"}, + {"\"2,3\"", "2,3"}, {"2\u00a0", "2\u00a0"}, + {"2147483648", "2147483648"}, + {"999999999999999999999999999999999999", "999999999999999999999999999999999999"} + }; + for (String effect : new String[]{"lightning", "storm"}) { + for (String key : new String[]{"fx.bolts", "fx.intensity"}) { + for (String[] bad : malformed) { + assertNumericRejected(effect, key, bad[0], bad[1]); + } + } + for (String bad : new String[]{"0", "00", "9"}) { + assertNumericRejected(effect, "fx.bolts", bad, bad); + } + assertNumericRejected(effect, "fx.intensity", "301", "301"); + } + } + + private static void assertNumericRejected(String effect, String key, String sourceValue, + String suppliedValue) { + MathSyntaxException error = assertThrows(MathSyntaxException.class, + () -> MathParser.parse("\\lx[fx.hover=" + effect + "," + key + "=" + sourceValue + "]{x}")); + assertTrue(error.getMessage().contains(key), error.getMessage()); + assertTrue(error.getMessage().contains("\"" + suppliedValue + "\""), error.getMessage()); + assertTrue(error.getMessage().contains(key.equals("fx.bolts") ? "1..8" : "0..300"), + error.getMessage()); + } + + @Test + void onlyTheTwoFlatNumericKnobsAreKnown() { + for (String key : new String[]{"fx.strikes", "fx.arc", "fx.lightning.bolts", + "fx.storm.intensity", "fx.bolts.extra", "fx.intensity.extra"}) { + MathSyntaxException error = assertThrows(MathSyntaxException.class, + () -> MathParser.parse("\\lx[" + key + "=1]{x}")); + assertTrue(error.getMessage().contains("unknown"), error.getMessage()); + assertTrue(error.getMessage().contains(key), error.getMessage()); + } + } + + @Test + void directEffectConstructionSharesBoundsAndRetainsFiveArgumentConstructor() { + EffectSpec legacy = new EffectSpec(Map.of(Trigger.HOVER, Effect.LIGHTNING), + "250ms", Color.CURRENT, null, null); + assertNull(legacy.bolts()); + assertNull(legacy.intensity()); + assertEquals("250ms", legacy.duration()); + assertEquals(Effect.LIGHTNING, legacy.effects().get(Trigger.HOVER)); + assertTrue(new EffectSpec(Map.of(), null, null, null, null).isEmpty()); + for (int[] values : new int[][]{{1, 0}, {2, 100}, {8, 300}}) { + EffectSpec fx = new EffectSpec(Map.of(), null, null, null, null, values[0], values[1]); + assertEquals(Integer.valueOf(values[0]), fx.bolts()); + assertEquals(Integer.valueOf(values[1]), fx.intensity()); + assertTrue(!fx.isEmpty()); + } + for (int bad : new int[]{Integer.MIN_VALUE, -1, 0, 9, Integer.MAX_VALUE}) { + MathSyntaxException error = assertThrows(MathSyntaxException.class, + () -> new EffectSpec(Map.of(), null, null, null, null, bad, null)); + assertTrue(error.getMessage().contains("fx.bolts"), error.getMessage()); + assertTrue(error.getMessage().contains("\"" + bad + "\""), error.getMessage()); + assertTrue(error.getMessage().contains("1..8"), error.getMessage()); + } + for (int bad : new int[]{Integer.MIN_VALUE, -1, 301, Integer.MAX_VALUE}) { + MathSyntaxException error = assertThrows(MathSyntaxException.class, + () -> new EffectSpec(Map.of(), null, null, null, null, null, bad)); + assertTrue(error.getMessage().contains("fx.intensity"), error.getMessage()); + assertTrue(error.getMessage().contains("\"" + bad + "\""), error.getMessage()); + assertTrue(error.getMessage().contains("0..300"), error.getMessage()); + } + } + @Test void parsesGlowColorThroughTheColorBoundary() { // A valid colour flows through Color.parse and is stored on the EffectSpec. diff --git a/src/test/resources/com/lattex/fx/absent-knobs-golden.html b/src/test/resources/com/lattex/fx/absent-knobs-golden.html new file mode 100644 index 00000000..e193d9c5 --- /dev/null +++ b/src/test/resources/com/lattex/fx/absent-knobs-golden.html @@ -0,0 +1,54 @@ + + + + + + + diff --git a/src/test/resources/com/lattex/fx/harness-dom-stub.js b/src/test/resources/com/lattex/fx/harness-dom-stub.js index d8fda3bd..c7dae9ee 100644 --- a/src/test/resources/com/lattex/fx/harness-dom-stub.js +++ b/src/test/resources/com/lattex/fx/harness-dom-stub.js @@ -19,11 +19,34 @@ * __lxScrollListeners() — count of registered window 'scroll' listeners * __lxFireScroll(dx, dy) — move the viewport and fire scroll listeners * __lxBodyChildren() — live count of body-appended overlay elements + * __lxWorkSnapshot() — cumulative DOM/clock/listener/canvas work + * __lxSeedRandom(seed) — deterministic random stream, counted cumulatively * __lxMakeEl(attrs, paths) — fake element (optionally with inner svg paths) */ (function (g) { 'use strict'; + // CUMULATIVE work, never decremented by teardown or reset between snapshots. + // Fake fixture construction is excluded; document-created product nodes count. + var work = { + elements: 0, canvases: 0, bodyAppends: 0, animations: 0, styleWrites: 0, + rafScheduled: 0, rafRun: 0, timeouts: 0, intervals: 0, listenerAdds: 0, + canvasCalls: 0, polylines: 0, segments: 0, strokes: 0, gradients: 0, + colorStops: 0, arcs: 0, fills: 0, clears: 0, randomCalls: 0 + }; + g.__lxWorkSnapshot = function () { return Object.assign({}, work); }; + g.__lxCanvasStrokes = []; + g.__lxFilterWrites = []; + g.__lxGradients = []; + g.__lxCreatedElements = []; + g.__lxSeedRandom = function (seed) { + Math.random = function () { + work.randomCalls++; + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed / 4294967296; + }; + }; + function classListStub() { var set = {}; return { @@ -35,14 +58,15 @@ // ---- requestAnimationFrame ------------------------------------------------- var rafQueue = []; - g.requestAnimationFrame = function (cb) { rafQueue.push(cb); return rafQueue.length; }; + g.requestAnimationFrame = function (cb) { work.rafScheduled++; rafQueue.push(cb); return rafQueue.length; }; g.cancelAnimationFrame = function () {}; g.__lxFlushRaf = function (maxFrames) { var frames = 0; while (rafQueue.length && frames < maxFrames) { var batch = rafQueue; rafQueue = []; - for (var i = 0; i < batch.length; i++) { batch[i](frames * 16); } + perfClock += 16; + for (var i = 0; i < batch.length; i++) { work.rafRun++; batch[i](perfClock); } frames++; } return frames; @@ -52,9 +76,9 @@ var nextTimerId = 1; var pendingTimeouts = {}; // id -> callback var activeIntervals = {}; // id -> callback (never auto-run; tests only count) - g.setTimeout = function (cb) { var id = nextTimerId++; pendingTimeouts[id] = cb; return id; }; + g.setTimeout = function (cb) { work.timeouts++; var id = nextTimerId++; pendingTimeouts[id] = cb; return id; }; g.clearTimeout = function (id) { delete pendingTimeouts[id]; }; - g.setInterval = function (cb) { var id = nextTimerId++; activeIntervals[id] = cb; return id; }; + g.setInterval = function (cb) { work.intervals++; var id = nextTimerId++; activeIntervals[id] = cb; return id; }; g.clearInterval = function (id) { delete activeIntervals[id]; }; g.__lxActiveIntervals = function () { return Object.keys(activeIntervals).length; }; // Drive interval callbacks deterministically (a real interval re-fires until @@ -89,6 +113,7 @@ g.pageXOffset = 0; g.pageYOffset = 0; g.addEventListener = function (type, fn) { + work.listenerAdds++; if (type === 'scroll') { scrollListeners.push(fn); } }; g.removeEventListener = function (type, fn) { @@ -107,8 +132,9 @@ g.window = g; g.devicePixelRatio = 1; + g.innerWidth = 1024; g.innerHeight = 768; var perfClock = 0; - g.performance = { now: function () { return (perfClock += 16); } }; + g.performance = { now: function () { return perfClock; } }; var reducedBoot = !!g.__lxStubReducedMotion; g.matchMedia = function () { return { matches: reducedBoot, addEventListener: function () {} }; @@ -117,12 +143,40 @@ return { color: 'rgb(0, 0, 0)', getPropertyValue: function () { return ''; } }; }; - // ---- canvas 2D context: every method is a harmless noop returning 0 ---------- + // ---- canvas 2D context: real gradient shape + cumulative geometry work ------- function canvasContextStub() { + var points = []; var ctx = new Proxy({}, { get: function (target, prop) { if (prop in target) { return target[prop]; } - return function () { return 0; }; + return function () { + work.canvasCalls++; + var args = Array.prototype.slice.call(arguments); + if (prop === 'beginPath') { work.polylines++; points = []; } + if (prop === 'moveTo') { points.push(args); } + if (prop === 'lineTo') { work.segments++; points.push(args); } + if (prop === 'stroke') { + work.strokes++; + g.__lxCanvasStrokes.push({ points: points.slice(), alpha: target.globalAlpha, + width: target.lineWidth, blur: target.shadowBlur || 0 }); + } + if (prop === 'clearRect') { work.clears++; } + if (prop === 'arc') { work.arcs++; } + if (prop === 'fill') { work.fills++; } + if (prop === 'createRadialGradient') { + work.gradients++; + var gradient = { args: args, stops: [], addColorStop: function (offset, color) { + if (!Number.isFinite(offset) || offset < 0 || offset > 1 || typeof color !== 'string') { + throw new Error('invalid canvas color stop'); + } + work.colorStops++; + gradient.stops.push([offset, color]); + } }; + g.__lxGradients.push(gradient); + return gradient; + } + return 0; + }; }, set: function (target, prop, value) { target[prop] = value; return true; } }); @@ -132,12 +186,18 @@ // ---- style bag: plain properties + CSS-var store + cssText ------------------ function styleStub() { var vars = {}; - return { + return new Proxy({ getPropertyValue: function (k) { return vars[k] || ''; }, - setProperty: function (k, v) { vars[k] = v; }, + setProperty: function (k, v) { work.styleWrites++; vars[k] = v; }, removeProperty: function (k) { delete vars[k]; }, cssText: '' - }; + }, { set: function (target, prop, value) { + work.styleWrites++; + if (prop === 'filter' && typeof value === 'string') { g.__lxFilterWrites.push(value); } + if (prop === 'animation' && value && value !== 'none') { work.animations++; } + target[prop] = value; + return true; + } }); } // ---- created elements + body child tracking ---------------------------------- @@ -145,6 +205,8 @@ g.__lxBodyChildren = function () { return bodyChildren.length; }; function makeCreatedEl(tag) { + work.elements++; + if (String(tag).toLowerCase() === 'canvas') { work.canvases++; } var el = { tagName: String(tag || 'div').toUpperCase(), style: styleStub(), @@ -153,10 +215,10 @@ getAttribute: function (n) { return this.__attrs.hasOwnProperty(n) ? this.__attrs[n] : null; }, setAttribute: function (n, v) { this.__attrs[n] = String(v); }, appendChild: function () {}, - addEventListener: function () {}, + addEventListener: function () { work.listenerAdds++; }, removeEventListener: function () {}, getBoundingClientRect: function () { return { left: 0, top: 0, width: 100, height: 50 }; }, - getContext: function () { return canvasContextStub(); }, + getContext: function () { return this.__ctx || (this.__ctx = canvasContextStub()); }, remove: function () { var i = bodyChildren.indexOf(el); if (i >= 0) { bodyChildren.splice(i, 1); } @@ -164,14 +226,20 @@ querySelector: function () { return null; }, querySelectorAll: function () { return []; } }; + g.__lxCreatedElements.push(el); return el; } + var documentListeners = {}; + g.__lxFireDocument = function (type) { + var fns = (documentListeners[type] || []).slice(); + for (var i = 0; i < fns.length; i++) { fns[i]({}); } + }; g.document = { readyState: 'complete', // init() runs immediately over an empty .lx-math list documentElement: { classList: classListStub(), style: styleStub() }, body: { - appendChild: function (el) { bodyChildren.push(el); }, + appendChild: function (el) { work.bodyAppends++; bodyChildren.push(el); }, removeChild: function (el) { var i = bodyChildren.indexOf(el); if (i >= 0) { bodyChildren.splice(i, 1); } @@ -181,8 +249,15 @@ querySelector: function () { return null; }, createElement: function (tag) { return makeCreatedEl(tag); }, createElementNS: function (ns, tag) { return makeCreatedEl(tag); }, - addEventListener: function () {}, - removeEventListener: function () {} + addEventListener: function (type, fn) { + work.listenerAdds++; + (documentListeners[type] = documentListeners[type] || []).push(fn); + }, + removeEventListener: function (type, fn) { + var fns = documentListeners[type] || []; + var i = fns.indexOf(fn); + if (i >= 0) { fns.splice(i, 1); } + } }; // ---- fake-element factory for the pins --------------------------------------- @@ -203,7 +278,7 @@ getBoundingClientRect: function () { return { left: 10, top: 10, right: 110, bottom: 60, width: 100, height: 50 }; }, appendChild: function () {}, remove: function () {}, - addEventListener: function (type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, + addEventListener: function (type, fn) { work.listenerAdds++; (listeners[type] = listeners[type] || []).push(fn); }, removeEventListener: function () {}, __fire: function (type, evt) { var fns = (listeners[type] || []).slice(); @@ -219,7 +294,7 @@ for (var i = 0; i < (pathCount || 0); i++) { paths.push(makePath({})); } var svg = pathCount ? { querySelectorAll: function (sel) { return sel === 'path' ? paths.slice() : []; }, - addEventListener: function (type, fn) { (listeners['svg:' + type] = listeners['svg:' + type] || []).push(fn); }, + addEventListener: function (type, fn) { work.listenerAdds++; (listeners['svg:' + type] = listeners['svg:' + type] || []).push(fn); }, getBBox: function () { return { x: 0, y: 0, width: 200, height: 80 }; }, getBoundingClientRect: function () { return { left: 0, top: 0, width: 200, height: 80 }; } } : null; @@ -233,7 +308,7 @@ getBBox: function () { return { x: 0, y: 0, width: 100, height: 50 }; }, getBoundingClientRect: function () { return { left: 10, top: 10, width: 120, height: 40 }; }, appendChild: function () {}, - addEventListener: function (type, fn) { (listeners[type] = listeners[type] || []).push(fn); }, + addEventListener: function (type, fn) { work.listenerAdds++; (listeners[type] = listeners[type] || []).push(fn); }, removeEventListener: function () {}, __fire: function (type, evt) { var fns = (listeners[type] || []).slice();