feat: Kinetic Text Tags {bt} {sc} {fi} {rotat} move letters; closing tags close their own tag

Letters of these custom tags animate one after another (CSS). A closing tag now closes the style that
it opened (with anything left open inside) instead of popping whatever was on top, and an unknown tag
that has a closing tag later shows its text plainly and stays balanced.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 04:29:20 +03:00
1 parent 8251fca9bf
commit dcddd42aca
3 files changed
+60 -6

No files matched your search

+11
View File
@@ -369,3 +369,14 @@
/* checkbox row of the settings screen */
.pg-check { display: flex; align-items: center; gap: 10px; margin: 4px 0 14px; cursor: pointer; font-size: 14px; }
.pg-check input { width: 18px; height: 18px; accent-color: var(--pg-accent, #f5a040); }
/* Kinetic Text Tags: {bt}, {sc}, {fi}, {rotat} move each letter on its own */
.pg-fx { display: inline-block; white-space: pre; }
.pg-fx-bt { animation: pg-fx-bounce 0.9s ease-in-out infinite; }
.pg-fx-sc { animation: pg-fx-scare 0.12s linear infinite; }
.pg-fx-fi { animation: pg-fx-in 0.6s ease-out both; }
.pg-fx-rotat { animation: pg-fx-spin 2s linear infinite; }
@keyframes pg-fx-bounce { 50% { transform: translateY(calc(var(--fx, 6) * -1px)); } }
@keyframes pg-fx-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } }
@keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } }
@keyframes pg-fx-spin { to { transform: rotate(360deg); } }
+36 -6
View File
@@ -9,6 +9,11 @@ export interface Style {
s?: boolean;
color?: string;
size?: string;
/** Per-letter motion from a custom tag: bt (bounce), sc (shake), fi (fade in), rotat (spin). */
fx?: string;
fxArg?: string;
/** The tag that opened this style, so its closing tag finds it again. */
tag?: string;
}
export type Segment =
@@ -74,7 +79,11 @@ export function parseText(text: string): ParsedText {
const [name, value] = splitTag(tag);
const top = stack[stack.length - 1];
if (name.startsWith("/")) {
if (stack.length > 1) stack.pop();
const opened = name.slice(1);
let at = stack.length - 1;
if (opened) while (at > 0 && stack[at].tag !== opened) at--;
if (opened && at > 0) stack.length = at; // closes what it opened, and anything left open inside
else if (!opened && stack.length > 1) stack.pop(); // `{/}` closes the innermost style
continue;
}
switch (name) {
@@ -82,13 +91,16 @@ export function parseText(text: string): ParsedText {
case "i":
case "u":
case "s":
stack.push({ ...top, [name]: true });
stack.push({ ...top, [name]: true, tag: name });
break;
case "color":
stack.push({ ...top, color: value });
stack.push({ ...top, color: value, tag: name });
break;
case "size":
stack.push({ ...top, size: value });
stack.push({ ...top, size: value, tag: name });
break;
case "bt": case "sc": case "fi": case "rotat": // Kinetic Text Tags
stack.push({ ...top, fx: name, fxArg: value, tag: name });
break;
case "w":
segments.push({ kind: "wait", seconds: value ? Number(value) : null });
@@ -111,10 +123,12 @@ export function parseText(text: string): ParsedText {
break;
case "a": case "font": case "cps": case "alpha": case "k": case "plain":
case "outlinecolor": case "rt": case "rb": case "art": case "shader":
stack.push({ ...top }); // styling we ignore but must still balance
stack.push({ ...top, tag: name }); // styling we ignore but must still balance
break;
default:
// {image=...}, {#comment}, {done}, {clear} and unknown self-closing tags
// a tag with its closing tag later ({chaos}...{/chaos}) shows its text plainly; anything else
// ({image=...}, {#comment}, {done}, {clear}) is self-closing
if (name && text.includes(`{/${name}}`, end)) stack.push({ ...top, tag: name });
break;
}
continue;
@@ -142,6 +156,22 @@ export function renderText(el: HTMLElement, parsed: ParsedText, visible: number,
const shown = chars.length <= left ? seg.text : chars.slice(0, left).join("");
left -= chars.length;
const st = seg.style;
if (st.fx) { // every letter moves on its own, one after another
const amount = Number.parseFloat(st.fxArg ?? "");
[...shown].forEach((letter, index) => {
const piece = document.createElement("span");
piece.textContent = letter;
piece.className = `pg-fx pg-fx-${st.fx}`;
piece.style.animationDelay = `${(index * 0.07).toFixed(2)}s`;
if (Number.isFinite(amount)) piece.style.setProperty("--fx", String(amount));
if (st.b) piece.style.fontWeight = "bold";
if (st.i) piece.style.fontStyle = "italic";
if (st.color) piece.style.color = st.color;
if (st.size) piece.style.fontSize = sizeValue(st.size, baseSize);
el.append(piece);
});
continue;
}
if (!st.b && !st.i && !st.u && !st.s && !st.color && !st.size) {
el.append(shown);
continue;