Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<n>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` |
Expand All @@ -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
Expand Down
31 changes: 27 additions & 4 deletions SLOWSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<path>` addresses — what the runtime dims), the
Expand All @@ -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.
Expand Down
28 changes: 18 additions & 10 deletions src/main/java/com/lattex/api/LatteX.java
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
* <p>Returns {@code ""} when the source carries no top-level {@code \lx[fx.*]}
* annotations, else a <em>leading-space-prefixed</em> 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 <em>leading-space-prefixed</em> 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
Expand Down Expand Up @@ -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:
Expand All @@ -886,7 +887,7 @@ public record RenderedMath(String svg, Map<String, String> 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.
Expand Down Expand Up @@ -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<String, String> containerAttrMap(EffectSpec fx, String glyphmap, String groupmap) {
Map<String, String> attrs = new java.util.LinkedHashMap<>();
Expand All @@ -1013,6 +1015,12 @@ private static Map<String, String> 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);
}
Expand All @@ -1025,14 +1033,14 @@ private static Map<String, String> 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();
Expand Down
53 changes: 49 additions & 4 deletions src/main/java/com/lattex/parse/EffectSpec.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<Trigger, Effect> 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<Trigger, Effect> 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$");
Expand All @@ -40,6 +49,8 @@ public record EffectSpec(Map<Trigger, Effect> 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)");
Expand All @@ -58,7 +69,41 @@ public record EffectSpec(Map<Trigger, Effect> 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);
}
Expand Down Expand Up @@ -91,9 +136,9 @@ public Optional<Integer> 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;
}
}
Loading
Loading