@cube-drone/marquee-html-renderer 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -158,7 +158,7 @@ export function usedFontTokens(html) {
158
158
  }
159
159
  /** One rung of the font-element seven-step dial: presentational floor
160
160
  * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
161
- * The named rungs - miniscule, tiny, huge, enormous - are unnecessary
161
+ * The named rungs - teeny, tiny, huge, enormous - are unnecessary
162
162
  * given the dial, and yet. */
163
163
  function sizeRung(value, inner) {
164
164
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
@@ -404,7 +404,7 @@ function span(name, attrs, nodes, ctx) {
404
404
  }
405
405
  return inner; // not on the list: the words survive in their own clothes
406
406
  }
407
- case "miniscule":
407
+ case "teeny":
408
408
  return sizeRung("1", inner);
409
409
  case "tiny":
410
410
  return sizeRung("2", inner);
@@ -419,7 +419,12 @@ function span(name, attrs, nodes, ctx) {
419
419
  }
420
420
  return inner; // invalid value: the effect degrades, the words survive
421
421
  }
422
- case "sidenote": {
422
+ case "sidenote":
423
+ case "aside":
424
+ case "footnote": {
425
+ // Permanent synonyms (SPEC.md): nobody keeps these three words
426
+ // distinct in their head, and an aside that silently isn't an aside
427
+ // is worse than two extra names - the list-marker rule, one layer up.
423
428
  // A numbered mark in the flow; the note itself flushes just below the
424
429
  // triggering paragraph (see flushNotes). Numbering runs sequentially
425
430
  // through the whole document.
@@ -438,18 +443,41 @@ function span(name, attrs, nodes, ctx) {
438
443
  const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
439
444
  ? ` style="--mq-rate:${attrs["rate"]}"`
440
445
  : "";
446
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
447
+ // Split blink: ramp is theater-marquee chase lights, scatter is
448
+ // twinkle. The rate var rides the container; units inherit it.
449
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
450
+ }
441
451
  return `<span class="mq-blink"${rate}>${inner}</span>`;
442
452
  }
443
453
  case "rainbow":
444
454
  case "bounce":
445
455
  case "jitter":
446
456
  case "wave":
457
+ case "rubber":
447
458
  if (attrs["by"] === "letter" || attrs["by"] === "word") {
448
459
  return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
449
460
  }
450
461
  return `<span class="mq-${name}">${inner}</span>`;
451
- case "typewriter":
452
- return `<span class="mq-${name}">${inner}</span>`;
462
+ case "typewriter": {
463
+ // Inherently per-unit: the reveal IS a by=letter effect (by=word for
464
+ // word-at-a-time). speed= is units per second; the container carries
465
+ // the per-unit delay step, each unit its ordinal in --mq-o.
466
+ const by = attrs["by"] === "word" ? "word" : "letter";
467
+ const step = revealStep(attrs["speed"], 14);
468
+ return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
469
+ }
470
+ case "fadein": {
471
+ // The ghostly reveal. Bare [fadein] fades the whole run in once;
472
+ // by=letter / by=word drift units in on staggered starts (same
473
+ // one-shot family as typewriter: sequential ordinals, high cap);
474
+ // phase=scatter is apparition weather.
475
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
476
+ const step = revealStep(attrs["speed"], 16);
477
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
478
+ }
479
+ return `<span class="mq-fadein">${inner}</span>`;
480
+ }
453
481
  }
454
482
  return inner; // unknown span: pure shrug, children as plain content
455
483
  }
@@ -461,9 +489,13 @@ const SEGMENTERS = {
461
489
  letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
462
490
  word: new Intl.Segmenter("en", { granularity: "word" }),
463
491
  };
464
- /** DOM-weight discipline: past this many units, the run animates whole. */
492
+ /** DOM-weight discipline: past this many units, the run animates whole.
493
+ * Loopers pay a live animation per element forever, so they cap low;
494
+ * typewriter's units are 1ms one-shots (finished animations cost nothing)
495
+ * and its natural material is long text, so it caps high. */
465
496
  const MAX_SPLIT_UNITS = 400;
466
- function bySegments(effect, by, phaseAttr, nodes, ctx) {
497
+ const MAX_REVEAL_UNITS = 2000;
498
+ function bySegments(effect, by, phaseAttr, nodes, ctx, containerStyle = "") {
467
499
  // Each effect has a natural phase order (jitter scatters, the rest sweep);
468
500
  // the knob overrides it either way. Invalid values degrade to the default.
469
501
  const phase = phaseAttr === "scatter" || phaseAttr === "ramp"
@@ -473,10 +505,11 @@ function bySegments(effect, by, phaseAttr, nodes, ctx) {
473
505
  : "ramp";
474
506
  const state = { effect, by, phase, i: 0, total: 0 };
475
507
  state.total = countUnits(nodes, by);
476
- if (state.total === 0 || state.total > MAX_SPLIT_UNITS) {
508
+ const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
509
+ if (state.total === 0 || state.total > cap) {
477
510
  return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
478
511
  }
479
- return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
512
+ return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
480
513
  }
481
514
  /** A segment gets wrapped if it's animatable: for words, word-like segments
482
515
  * (spaces and bare punctuation ride along); for letters, anything that
@@ -488,6 +521,19 @@ function isUnit(seg, by) {
488
521
  * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
489
522
  * integer hash - randomness-shaped, never random. */
490
523
  function unitOffset(state) {
524
+ // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
525
+ // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
526
+ // looping effects replay. phase=scatter reveals in a
527
+ // scrambled-but-deterministic order: a stride at the run's golden-ratio
528
+ // point, nudged coprime with the total, walks a well-spread permutation
529
+ // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
530
+ // -1, so forty-unit runs typed in backwards.)
531
+ if (isReveal(state.effect)) {
532
+ const o = state.phase === "scatter"
533
+ ? (state.i * scatterStride(state.total)) % state.total
534
+ : state.i;
535
+ return String(o);
536
+ }
491
537
  let o;
492
538
  if (state.phase === "scatter") {
493
539
  o = ((state.i * 7919) % 101) / 101;
@@ -510,6 +556,30 @@ function unitOffset(state) {
510
556
  }
511
557
  return String(Math.round(o * 1000) / 1000);
512
558
  }
559
+ /** speed= (units per second, a COUNT) into a per-unit delay step in
560
+ * seconds; invalid or absent falls to the effect's default rate. */
561
+ function revealStep(speedAttr, dflt) {
562
+ const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
563
+ ? Number(speedAttr)
564
+ : dflt;
565
+ return Math.round(1000 / speed) / 1000;
566
+ }
567
+ /** The one-shot reveals: sequential ordinals, the high unit cap. */
568
+ function isReveal(effect) {
569
+ return effect === "typewriter" || effect === "fadein";
570
+ }
571
+ function gcd(a, b) {
572
+ return b === 0 ? a : gcd(b, a % b);
573
+ }
574
+ /** The smallest stride >= ~61.8% of total that's coprime with it (falls
575
+ * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
576
+ function scatterStride(total) {
577
+ let stride = Math.max(1, Math.round(total * 0.618));
578
+ while (stride < total && gcd(stride, total) !== 1) {
579
+ stride += 1;
580
+ }
581
+ return gcd(stride, total) === 1 ? stride : 1;
582
+ }
513
583
  function countUnits(nodes, by) {
514
584
  let n = 0;
515
585
  for (const node of nodes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cube-drone/marquee-html-renderer",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Reference static HTML renderer for the Marquee markup language",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {
@@ -35,12 +35,12 @@
35
35
  "prepack": "npm run build"
36
36
  },
37
37
  "dependencies": {
38
- "@cube-drone/marquee-parser": "^0.1.1"
38
+ "@cube-drone/marquee-parser": "^0.2.0"
39
39
  },
40
40
  "devDependencies": {
41
- "@cube-drone/marquee-css": "^0.1.1",
42
- "@cube-drone/marquee-fonts": "^0.1.1",
43
- "@cube-drone/marquee-turbolink": "^0.1.1",
41
+ "@cube-drone/marquee-css": "^0.2.0",
42
+ "@cube-drone/marquee-fonts": "^0.2.0",
43
+ "@cube-drone/marquee-turbolink": "^0.2.0",
44
44
  "@types/node": "^24.0.0",
45
45
  "typescript": "^5.9.0"
46
46
  }
package/src/render.ts CHANGED
@@ -180,7 +180,7 @@ export function usedFontTokens(html: string): string[] {
180
180
 
181
181
  /** One rung of the font-element seven-step dial: presentational floor
182
182
  * (works with no stylesheet, under any CSP), stylesheet class as ceiling.
183
- * The named rungs - miniscule, tiny, huge, enormous - are unnecessary
183
+ * The named rungs - teeny, tiny, huge, enormous - are unnecessary
184
184
  * given the dial, and yet. */
185
185
  function sizeRung(value: string, inner: string): string {
186
186
  return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
@@ -440,7 +440,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
440
440
  }
441
441
  return inner; // not on the list: the words survive in their own clothes
442
442
  }
443
- case "miniscule":
443
+ case "teeny":
444
444
  return sizeRung("1", inner);
445
445
  case "tiny":
446
446
  return sizeRung("2", inner);
@@ -455,7 +455,12 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
455
455
  }
456
456
  return inner; // invalid value: the effect degrades, the words survive
457
457
  }
458
- case "sidenote": {
458
+ case "sidenote":
459
+ case "aside":
460
+ case "footnote": {
461
+ // Permanent synonyms (SPEC.md): nobody keeps these three words
462
+ // distinct in their head, and an aside that silently isn't an aside
463
+ // is worse than two extra names - the list-marker rule, one layer up.
459
464
  // A numbered mark in the flow; the note itself flushes just below the
460
465
  // triggering paragraph (see flushNotes). Numbering runs sequentially
461
466
  // through the whole document.
@@ -474,18 +479,41 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
474
479
  const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
475
480
  ? ` style="--mq-rate:${attrs["rate"]}"`
476
481
  : "";
482
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
483
+ // Split blink: ramp is theater-marquee chase lights, scatter is
484
+ // twinkle. The rate var rides the container; units inherit it.
485
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
486
+ }
477
487
  return `<span class="mq-blink"${rate}>${inner}</span>`;
478
488
  }
479
489
  case "rainbow":
480
490
  case "bounce":
481
491
  case "jitter":
482
492
  case "wave":
493
+ case "rubber":
483
494
  if (attrs["by"] === "letter" || attrs["by"] === "word") {
484
495
  return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
485
496
  }
486
497
  return `<span class="mq-${name}">${inner}</span>`;
487
- case "typewriter":
488
- return `<span class="mq-${name}">${inner}</span>`;
498
+ case "typewriter": {
499
+ // Inherently per-unit: the reveal IS a by=letter effect (by=word for
500
+ // word-at-a-time). speed= is units per second; the container carries
501
+ // the per-unit delay step, each unit its ordinal in --mq-o.
502
+ const by = attrs["by"] === "word" ? "word" : "letter";
503
+ const step = revealStep(attrs["speed"], 14);
504
+ return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
505
+ }
506
+ case "fadein": {
507
+ // The ghostly reveal. Bare [fadein] fades the whole run in once;
508
+ // by=letter / by=word drift units in on staggered starts (same
509
+ // one-shot family as typewriter: sequential ordinals, high cap);
510
+ // phase=scatter is apparition weather.
511
+ if (attrs["by"] === "letter" || attrs["by"] === "word") {
512
+ const step = revealStep(attrs["speed"], 16);
513
+ return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
514
+ }
515
+ return `<span class="mq-fadein">${inner}</span>`;
516
+ }
489
517
  }
490
518
  return inner; // unknown span: pure shrug, children as plain content
491
519
  }
@@ -502,8 +530,12 @@ const SEGMENTERS = {
502
530
 
503
531
  type SplitBy = keyof typeof SEGMENTERS;
504
532
 
505
- /** DOM-weight discipline: past this many units, the run animates whole. */
533
+ /** DOM-weight discipline: past this many units, the run animates whole.
534
+ * Loopers pay a live animation per element forever, so they cap low;
535
+ * typewriter's units are 1ms one-shots (finished animations cost nothing)
536
+ * and its natural material is long text, so it caps high. */
506
537
  const MAX_SPLIT_UNITS = 400;
538
+ const MAX_REVEAL_UNITS = 2000;
507
539
 
508
540
  type Phase = "ramp" | "scatter";
509
541
 
@@ -521,6 +553,7 @@ function bySegments(
521
553
  phaseAttr: string | undefined,
522
554
  nodes: Node[],
523
555
  ctx: Ctx,
556
+ containerStyle = "",
524
557
  ): string {
525
558
  // Each effect has a natural phase order (jitter scatters, the rest sweep);
526
559
  // the knob overrides it either way. Invalid values degrade to the default.
@@ -532,10 +565,11 @@ function bySegments(
532
565
  : "ramp";
533
566
  const state: SplitState = { effect, by, phase, i: 0, total: 0 };
534
567
  state.total = countUnits(nodes, by);
535
- if (state.total === 0 || state.total > MAX_SPLIT_UNITS) {
568
+ const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
569
+ if (state.total === 0 || state.total > cap) {
536
570
  return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
537
571
  }
538
- return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
572
+ return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
539
573
  }
540
574
 
541
575
  /** A segment gets wrapped if it's animatable: for words, word-like segments
@@ -549,6 +583,20 @@ function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
549
583
  * twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
550
584
  * integer hash - randomness-shaped, never random. */
551
585
  function unitOffset(state: SplitState): string {
586
+ // Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
587
+ // ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
588
+ // looping effects replay. phase=scatter reveals in a
589
+ // scrambled-but-deterministic order: a stride at the run's golden-ratio
590
+ // point, nudged coprime with the total, walks a well-spread permutation
591
+ // at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
592
+ // -1, so forty-unit runs typed in backwards.)
593
+ if (isReveal(state.effect)) {
594
+ const o =
595
+ state.phase === "scatter"
596
+ ? (state.i * scatterStride(state.total)) % state.total
597
+ : state.i;
598
+ return String(o);
599
+ }
552
600
  let o: number;
553
601
  if (state.phase === "scatter") {
554
602
  o = ((state.i * 7919) % 101) / 101;
@@ -571,6 +619,35 @@ function unitOffset(state: SplitState): string {
571
619
  return String(Math.round(o * 1000) / 1000);
572
620
  }
573
621
 
622
+ /** speed= (units per second, a COUNT) into a per-unit delay step in
623
+ * seconds; invalid or absent falls to the effect's default rate. */
624
+ function revealStep(speedAttr: string | undefined, dflt: number): number {
625
+ const speed =
626
+ speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
627
+ ? Number(speedAttr)
628
+ : dflt;
629
+ return Math.round(1000 / speed) / 1000;
630
+ }
631
+
632
+ /** The one-shot reveals: sequential ordinals, the high unit cap. */
633
+ function isReveal(effect: string): boolean {
634
+ return effect === "typewriter" || effect === "fadein";
635
+ }
636
+
637
+ function gcd(a: number, b: number): number {
638
+ return b === 0 ? a : gcd(b, a % b);
639
+ }
640
+
641
+ /** The smallest stride >= ~61.8% of total that's coprime with it (falls
642
+ * back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
643
+ function scatterStride(total: number): number {
644
+ let stride = Math.max(1, Math.round(total * 0.618));
645
+ while (stride < total && gcd(stride, total) !== 1) {
646
+ stride += 1;
647
+ }
648
+ return gcd(stride, total) === 1 ? stride : 1;
649
+ }
650
+
574
651
  function countUnits(nodes: Node[], by: SplitBy): number {
575
652
  let n = 0;
576
653
  for (const node of nodes) {