kasaframe 0.1.1 → 0.1.3

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.
Files changed (200) hide show
  1. package/NOTICE +10 -8
  2. package/README.md +16 -1
  3. package/app/server/engine.ts +133 -15
  4. package/app/server/main.ts +7 -3
  5. package/app/web/main.ts +218 -36
  6. package/app/web/style.css +37 -4
  7. package/bin/hooks.mjs +14 -1
  8. package/bin/kf.mjs +20 -2
  9. package/docs/AGENTS.md +64 -16
  10. package/music/README.md +31 -7
  11. package/package.json +2 -2
  12. package/packages/adapters/src/project.ts +1 -1
  13. package/packages/check/src/check.ts +10 -3
  14. package/packages/check/src/rules/assets.ts +4 -1
  15. package/packages/check/src/rules/frames.ts +2 -1
  16. package/packages/check/src/rules/layout.ts +30 -6
  17. package/packages/cli/package.json +2 -1
  18. package/packages/cli/src/blur.ts +15 -3
  19. package/packages/cli/src/commands/bench.ts +123 -0
  20. package/packages/cli/src/commands/blender.ts +72 -49
  21. package/packages/cli/src/commands/direct.ts +210 -0
  22. package/packages/cli/src/commands/doctor.ts +9 -1
  23. package/packages/cli/src/commands/look.ts +9 -2
  24. package/packages/cli/src/commands/lyrics.ts +47 -1
  25. package/packages/cli/src/commands/make.ts +729 -150
  26. package/packages/cli/src/commands/music.ts +78 -0
  27. package/packages/cli/src/commands/narrate.ts +238 -0
  28. package/packages/cli/src/commands/new.ts +14 -5
  29. package/packages/cli/src/commands/render.ts +94 -60
  30. package/packages/cli/src/commands/series.ts +110 -0
  31. package/packages/cli/src/commands/setup.ts +42 -3
  32. package/packages/cli/src/commands/theme.ts +89 -2
  33. package/packages/cli/src/deps.ts +6 -6
  34. package/packages/cli/src/kf.ts +14 -3
  35. package/packages/cli/src/mix.ts +131 -0
  36. package/packages/cli/src/pool.ts +34 -0
  37. package/packages/cli/src/segments.ts +402 -0
  38. package/packages/core/src/index.ts +1 -1
  39. package/packages/core/src/project.ts +11 -0
  40. package/packages/director/package.json +23 -0
  41. package/packages/director/src/assemble.ts +361 -0
  42. package/packages/director/src/brief.ts +122 -0
  43. package/packages/director/src/catalog.ts +49 -0
  44. package/packages/director/src/content.ts +343 -0
  45. package/packages/director/src/fallback.ts +58 -0
  46. package/packages/director/src/index.ts +13 -0
  47. package/packages/director/src/pattern.ts +90 -0
  48. package/packages/director/src/patterns/fill/cells.ts +115 -0
  49. package/packages/director/src/patterns/formula/stack.ts +93 -0
  50. package/packages/director/src/patterns/util.ts +58 -0
  51. package/packages/director/src/scene.ts +62 -0
  52. package/packages/director/src/select.ts +163 -0
  53. package/packages/director/src/transitions.ts +115 -0
  54. package/packages/gen/PROVIDERS.md +39 -4
  55. package/packages/gen/src/ask.ts +132 -0
  56. package/packages/gen/src/audio.ts +91 -0
  57. package/packages/gen/src/blender.ts +54 -0
  58. package/packages/gen/src/capabilities.ts +151 -0
  59. package/packages/gen/src/generate.ts +4 -2
  60. package/packages/gen/src/image/codex.ts +117 -0
  61. package/packages/gen/src/index.ts +10 -2
  62. package/packages/gen/src/keys.ts +26 -0
  63. package/packages/gen/src/music/elevenlabs.ts +41 -0
  64. package/packages/gen/src/music/index.ts +120 -0
  65. package/packages/gen/src/music/library.ts +73 -0
  66. package/packages/gen/src/music/lyria.ts +52 -0
  67. package/packages/gen/src/music/types.ts +28 -0
  68. package/packages/gen/src/registry.ts +6 -3
  69. package/packages/gen/src/tts/dashscope.ts +64 -0
  70. package/packages/gen/src/tts/elevenlabs.ts +84 -0
  71. package/packages/gen/src/tts/gemini.ts +73 -0
  72. package/packages/gen/src/tts/index.ts +241 -0
  73. package/packages/gen/src/tts/local.ts +42 -0
  74. package/packages/gen/src/tts/types.ts +44 -0
  75. package/packages/gen/src/types.ts +4 -0
  76. package/packages/gen/src/video/h3.ts +5 -0
  77. package/packages/gen/src/video/http.ts +1 -1
  78. package/packages/jizura/src/index.ts +2 -2
  79. package/packages/jizura/src/install.ts +198 -155
  80. package/packages/jizura/src/layer.ts +75 -32
  81. package/packages/jizura/src/pin.ts +4 -1
  82. package/packages/jizura/src/vendor.ts +140 -0
  83. package/packages/jizura/src/vite.ts +34 -7
  84. package/packages/jizura/vendor/JIZURA_LICENSE.txt +21 -0
  85. package/packages/jizura/vendor/NOTICE +43 -0
  86. package/packages/jizura/vendor/engine.json +47 -0
  87. package/packages/jizura/vendor/fonts/BlackHanSans-Regular.woff2 +0 -0
  88. package/packages/jizura/vendor/fonts/DotGothic16-Regular.woff2 +0 -0
  89. package/packages/jizura/vendor/fonts/GowunBatang-Bold.woff2 +0 -0
  90. package/packages/jizura/vendor/fonts/IBMPlexMono-Medium.ttf +0 -0
  91. package/packages/jizura/vendor/fonts/IBMPlexMono-SemiBold.ttf +0 -0
  92. package/packages/jizura/vendor/fonts/NotoSansKR-VF.woff2 +0 -0
  93. package/packages/jizura/vendor/fonts/NotoSerifKR-VF.woff2 +0 -0
  94. package/packages/jizura/vendor/fonts/OFL-blackhansans.txt +93 -0
  95. package/packages/jizura/vendor/fonts/OFL-dotgothic16.txt +93 -0
  96. package/packages/jizura/vendor/fonts/OFL-gowunbatang.txt +94 -0
  97. package/packages/jizura/vendor/fonts/OFL-ibmplexmono.txt +93 -0
  98. package/packages/jizura/vendor/fonts/OFL-notosanskr.txt +93 -0
  99. package/packages/jizura/vendor/fonts/OFL-notoserifkr.txt +94 -0
  100. package/packages/jizura/vendor/fonts/fonts.json +52 -0
  101. package/packages/jizura/vendor/jizura_engine.js +35024 -0
  102. package/packages/motion/README.md +11 -0
  103. package/packages/motion/package.json +1 -0
  104. package/packages/motion/src/captions.ts +32 -24
  105. package/packages/motion/src/moves/brushWipe.ts +14 -1
  106. package/packages/motion/src/moves/dress.ts +191 -0
  107. package/packages/motion/src/moves/echoStack.ts +3 -3
  108. package/packages/motion/src/moves/fit.ts +36 -0
  109. package/packages/motion/src/moves/ghostStack.ts +14 -5
  110. package/packages/motion/src/moves/imageSwarm.ts +8 -4
  111. package/packages/motion/src/moves/index.ts +23 -4
  112. package/packages/motion/src/moves/karaokeLine.ts +17 -3
  113. package/packages/motion/src/moves/menu.ts +138 -0
  114. package/packages/motion/src/moves/neonSign.ts +2 -1
  115. package/packages/motion/src/moves/slamWords.ts +14 -7
  116. package/packages/motion/src/moves/sparkTrail.ts +16 -0
  117. package/packages/motion/src/moves/splitFlap.ts +4 -2
  118. package/packages/motion/src/moves/stepWords.ts +19 -8
  119. package/packages/motion/src/moves/tickerRows.ts +13 -9
  120. package/packages/motion/src/moves/tokenPicker.ts +4 -3
  121. package/packages/motion/src/moves/wordWall.ts +23 -21
  122. package/packages/motion/src/moves/worldRoll.ts +2 -1
  123. package/packages/motion/src/transition3d/index.ts +10 -3
  124. package/packages/motion/src/transition3d/rig.ts +107 -9
  125. package/packages/motion/src/transition3d/shatter.ts +5 -2
  126. package/packages/motion/src/transition3d/space.ts +173 -0
  127. package/packages/motion/src/transition3d/whip.ts +19 -6
  128. package/packages/motion/src/transition3d/world.ts +54 -0
  129. package/packages/motion/src/transitions2d/catalog.ts +1 -1
  130. package/packages/patterns/package.json +15 -0
  131. package/packages/patterns/src/bars/rank.ts +119 -0
  132. package/packages/patterns/src/before-after/shrink.ts +127 -0
  133. package/packages/patterns/src/before-after/strike.ts +125 -0
  134. package/packages/patterns/src/code/terminal.ts +133 -0
  135. package/packages/patterns/src/compare/bars.ts +142 -0
  136. package/packages/patterns/src/compare/duel.ts +103 -0
  137. package/packages/patterns/src/end/lockup.ts +85 -0
  138. package/packages/patterns/src/end/slam.ts +91 -0
  139. package/packages/patterns/src/flow/graph.ts +250 -0
  140. package/packages/patterns/src/hook-number/counter.ts +101 -0
  141. package/packages/patterns/src/hook-number/roll.ts +179 -0
  142. package/packages/patterns/src/hook-number/slam.ts +91 -0
  143. package/packages/patterns/src/index.ts +38 -0
  144. package/packages/patterns/src/kit.ts +449 -0
  145. package/packages/patterns/src/limit/gauge.ts +117 -0
  146. package/packages/patterns/src/list/cards.ts +131 -0
  147. package/packages/patterns/src/list/cycle.ts +22 -0
  148. package/packages/patterns/src/media/panels.ts +100 -0
  149. package/packages/patterns/src/objects.ts +234 -0
  150. package/packages/patterns/src/quote/karaoke.ts +126 -0
  151. package/packages/patterns/src/quote/slam.ts +22 -0
  152. package/packages/patterns/src/screen/focus.ts +85 -0
  153. package/packages/patterns/src/steps/path.ts +137 -0
  154. package/packages/patterns/src/steps/stack.ts +111 -0
  155. package/packages/patterns/src/term-reveal/chips.ts +92 -0
  156. package/packages/patterns/src/term-reveal/expand.ts +127 -0
  157. package/packages/patterns/src/timeline/axis.ts +144 -0
  158. package/packages/patterns/src/title/mask.ts +85 -0
  159. package/packages/patterns/src/title/slam.ts +23 -0
  160. package/packages/patterns/src/toggle-sim/switch.ts +92 -0
  161. package/packages/patterns/src/trend/columns.ts +108 -0
  162. package/packages/patterns/src/trend/draw.ts +140 -0
  163. package/packages/patterns/src/types.ts +67 -0
  164. package/packages/patterns/src/words/moves.ts +42 -0
  165. package/packages/patterns/src/words/slam.ts +4 -0
  166. package/packages/plan/src/asks.ts +256 -0
  167. package/packages/plan/src/brief.ts +136 -7
  168. package/packages/plan/src/captions.ts +5 -2
  169. package/packages/plan/src/index.ts +6 -2
  170. package/packages/plan/src/llm.ts +64 -2
  171. package/packages/plan/src/look.ts +3 -1
  172. package/packages/plan/src/plan.ts +67 -13
  173. package/packages/plan/src/series.ts +76 -0
  174. package/packages/plan/src/types.ts +53 -2
  175. package/packages/themes/package.json +5 -2
  176. package/packages/themes/src/index.ts +9 -0
  177. package/packages/themes/src/slotkit.ts +79 -0
  178. package/packages/themes/src/slots.ts +46 -0
  179. package/packages/themes/src/stage3d.ts +20 -0
  180. package/series/og-shorts.json +14 -0
  181. package/skills/kasaframe/SKILL.md +4 -3
  182. package/templates/_shared/project/lib/stage.ts +70 -50
  183. package/templates/_shared/scenes/default.ts +10 -7
  184. package/templates/_shared/scenes/drop.ts +11 -8
  185. package/templates/_shared/scenes/end.ts +9 -6
  186. package/templates/_shared/scenes/hook.ts +8 -6
  187. package/templates/_shared/scenes/step.ts +13 -4
  188. package/templates/ad/template.json +20 -1
  189. package/templates/demo/template.json +11 -0
  190. package/templates/explainer/template.json +54 -3
  191. package/templates/launch/template.json +34 -1
  192. package/templates/mv/scenes/default.ts +11 -8
  193. package/templates/mv/template.json +4 -2
  194. package/templates/promo/template.json +42 -1
  195. package/templates/shorts/scenes/hook.ts +8 -5
  196. package/templates/shorts/template.json +72 -3
  197. package/templates/square/template.json +21 -1
  198. package/themes/debug-hud/slots.ts +99 -0
  199. package/themes/og-measure/slots.ts +88 -0
  200. package/themes/riso-zine/slots.ts +81 -0
@@ -0,0 +1,449 @@
1
+ import { clamp, ease, progress, spring, type SceneCtx, type Style } from "@kasaframe/core";
2
+ import { mark } from "@kasaframe/motion/moves";
3
+ import { typeDuration, typeText } from "@kasaframe/motion";
4
+ import { partial } from "@kasaframe/themes/slotkit";
5
+ import type { Kit, ThemeSlots } from "./types.ts";
6
+
7
+ // 패턴들이 같이 쓰는 몫. 모듈 맨 위에서 DOM 을 만지지 않는다 — 감독이 Node 에서 meta·fits 를 읽는다.
8
+
9
+ export type Paint = (t: number) => void;
10
+
11
+ /** 패턴이 그릴 상자(= 무대 카메라 층 = 안전 영역). u 는 짧은 변 1080 을 1 로 본 배율 */
12
+ export function area(ctx: SceneCtx, kit: Kit): { w: number; h: number; vertical: boolean; u: number } {
13
+ const w = kit.stage?.safeW ?? ctx.meta.width - ctx.safe.left - ctx.safe.right;
14
+ const h = kit.stage?.safeH ?? ctx.meta.height - ctx.safe.top - ctx.safe.bottom;
15
+ return { w, h, vertical: ctx.meta.height > ctx.meta.width, u: Math.min(w, h) / 1080 };
16
+ }
17
+
18
+ /** 패턴이 화면을 움직이는 동안 검사용 기록 — 같은 패턴을 이어지는 장면에 되풀이하면 motion.repeat 가 잡는다 */
19
+ export const moving = (id: string, ctx: SceneCtx): void => mark(id, ctx.id, true);
20
+
21
+ /** 배수 기호(57×) — 숫자에 붙어 한 덩어리로 읽힌다. 세로 화면이라도 아래 줄로 내리면 혼자 떠서 지우기 아이콘처럼 읽혀 늘 숫자 옆에 둔다 */
22
+ export const timesSign = (unit: string): boolean => /^[×✕✖xX]$/.test(unit.trim());
23
+
24
+ /** 강조색 글 — 테마의 accentText(바탕 위에서 읽히는 강조색). 면은 accent, 글은 이것 */
25
+ export const ACCENT_TEXT = "var(--kf-accent-text, var(--kf-accent))";
26
+
27
+ /** 스타일·틀이 카메라를 끈 영상(cameraMove: off)이면 패턴은 카메라 대신 화면 안 강조로 */
28
+ export const cameraOk = (ctx: SceneCtx): boolean => ctx.style.motion?.cameraMove !== "off";
29
+
30
+ /** 형광펜 띠 — 글 뒤에 accentSoft 면이 왼쪽부터 차오른다(강조). p 는 0..1 */
31
+ export function marker(parent: HTMLElement, css = ""): (p: number) => void {
32
+ const band = node(parent, `position:absolute;left:-3%;right:-3%;top:8%;bottom:4%;background:var(--kf-accent-soft);transform-origin:0 50%;z-index:-1;${css}`);
33
+ return (p) => {
34
+ band.style.visibility = p > 0 ? "visible" : "hidden";
35
+ band.style.transform = `scaleX(${clamp(p).toFixed(4)})`;
36
+ };
37
+ }
38
+
39
+ // 상대휘도(WCAG) — #rgb·#rrggbb 만, 못 읽으면 NaN
40
+ function lum(hex: string | undefined): number {
41
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((hex ?? "").trim());
42
+ if (!m) return NaN;
43
+ const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
44
+ const ch = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
45
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
46
+ }
47
+
48
+ /**
49
+ * 강조색 글이 본문 글과 대비가 3:1 이 안 되는 테마(HUD 형광 하나·OG 짙은 파랑·리소 파랑) — 글 색만 바꾸면 화면이 안 바뀐 것으로 읽혀
50
+ * (kf check frame.freeze) 반전 칸으로 짚는다
51
+ */
52
+ export function inkLike(ctx: SceneCtx): boolean {
53
+ const c = ctx.style.colors as Record<string, string | undefined>;
54
+ const a = lum(c.accentText ?? c.accent);
55
+ const b = lum(c.ink);
56
+ const hi = Math.max(a, b);
57
+ const lo = Math.min(a, b);
58
+ return !Number.isFinite(a) || !Number.isFinite(b) || (hi + 0.05) / (lo + 0.05) < 3;
59
+ }
60
+
61
+ /**
62
+ * 큰 이름 — 글자마다 가림막 아래에서 올라와 서고(riseAt 부터 each 간격), fill 진행도(0..1)만큼 강조가 왼쪽부터 글자를 짚는다.
63
+ * 강조색 글이 본문 글과 같은 테마(HUD)면 반전 칸으로 — 같은 색으로 채우면 아무것도 안 바뀐다
64
+ */
65
+ export function riseName(el: HTMLElement, text: string, ctx: SceneCtx): (t: number, o: { riseAt: number; each: number; fill: number }) => void {
66
+ const hiCss = inkLike(ctx) ? "background:var(--kf-accent);color:var(--kf-on-accent)" : `color:${ACCENT_TEXT}`;
67
+ odoStyle(el);
68
+ const glyphs = Array.from(text).map((ch) => {
69
+ const clip = node(el, "display:inline-block;overflow:hidden;padding:0.06em 0 0.12em;margin:-0.06em 0 -0.12em");
70
+ // 올라오는 동안은 ::before 로 그린다 — 가림막에 반쯤 걸린 글자를 검사가 「대비 낮은 글」로 읽지 않게. 다 서면 진짜 글
71
+ const g = node(clip, "position:relative;display:inline-block;white-space:pre");
72
+ g.dataset.ch = ch;
73
+ // 강조 겹은 글자 안에 — 이름 전체를 한 진행도로 채울 때 글자 자리가 어긋나지 않게
74
+ const hi = node(g, `position:absolute;inset:0;visibility:hidden;white-space:pre;${hiCss}`, ch);
75
+ return { g, hi };
76
+ });
77
+ return (t, o) => {
78
+ const fp = clamp(o.fill) * glyphs.length;
79
+ glyphs.forEach(({ g, hi }, i) => {
80
+ const p = ease.outExpo(progress(t, o.riseAt + i * o.each, 0.3));
81
+ g.style.transform = `translateY(${((1 - p) * 110).toFixed(2)}%)`;
82
+ const up = p >= 0.995;
83
+ if (up && g.firstChild?.nodeType !== 3) {
84
+ delete g.dataset.kfOdo;
85
+ g.insertBefore(g.ownerDocument.createTextNode(g.dataset.ch ?? ""), g.firstChild);
86
+ } else if (!up && g.firstChild?.nodeType === 3) {
87
+ g.firstChild.remove();
88
+ }
89
+ if (!up) g.dataset.kfOdo = g.dataset.ch ?? "";
90
+ const k = clamp(fp - i);
91
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
92
+ hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
93
+ });
94
+ };
95
+ }
96
+
97
+ /** 가림막에 걸린 글자를 ::before 로 그리는 규칙 — 페이지에 한 번 */
98
+ function odoStyle(el: HTMLElement): void {
99
+ if (el.ownerDocument.getElementById("kf-odo-style")) return;
100
+ const st = el.ownerDocument.createElement("style");
101
+ st.id = "kf-odo-style";
102
+ st.textContent = "[data-kf-odo]::before{content:attr(data-kf-odo)}";
103
+ el.ownerDocument.head.append(st);
104
+ }
105
+
106
+ /**
107
+ * 주행계 숫자 — 끝자리 띠가 값을 따라 연속으로 굴러, 0→57 처럼 작은 범위도 프레임마다 화면이 바뀐다. 윗자리는 아랫자리가 9 를 넘을 때만 같이 구른다.
108
+ * 보이는 숫자는 늘 이웃한 두 숫자 사이(엉뚱한 값 없음). 맨 앞 0 자리는 비운다. 쉼표·음수가 있으면 null(글 굴림으로)
109
+ */
110
+ export function odometer(el: HTMLElement, o: { template: string; decimals: number; linePx: number }): ((v: number) => void) | null {
111
+ if (/[^0-9.]/.test(o.template)) return null;
112
+ el.textContent = "";
113
+ el.style.display = "inline-flex";
114
+ // 띠의 숫자는 ::before 로 — 창 가장자리에 반쯤 걸린 숫자를 검사가 「대비 낮은 글」로 읽지 않게. 앉은 뒤엔 패턴이 진짜 글로 바꾼다
115
+ odoStyle(el);
116
+ const lowest = -o.decimals;
117
+ let place = (o.template.split(".")[0] ?? "").length - 1;
118
+ const cols: { strip: HTMLDivElement; glyphs: HTMLDivElement[]; j: number }[] = [];
119
+ for (const ch of o.template) {
120
+ if (ch === ".") {
121
+ node(el, "display:inline-block", ".");
122
+ continue;
123
+ }
124
+ const k = place;
125
+ const box = node(el, `display:inline-block;position:relative;overflow:hidden;height:${o.linePx}px;width:0.62em;text-align:center`);
126
+ const strip = node(box, "position:absolute;left:0;right:0;top:0");
127
+ const glyphs = "01234567890".split("").map((d) => {
128
+ const g = node(strip, `height:${o.linePx}px;line-height:${o.linePx}px`);
129
+ g.dataset.kfOdo = d;
130
+ return g;
131
+ });
132
+ cols.push({ strip, glyphs, j: k - lowest });
133
+ place--;
134
+ }
135
+ return (v) => {
136
+ const n = Math.max(0, v) / 10 ** lowest;
137
+ const whole = Math.floor(n + 1e-9);
138
+ const frac = n - whole;
139
+ for (const c of cols) {
140
+ const base = Math.floor(whole / 10 ** c.j);
141
+ const lower = whole % 10 ** c.j;
142
+ const roll = c.j === 0 || lower === 10 ** c.j - 1 ? frac : 0;
143
+ // 맨 앞 0 은 비운다 — 다음 자리로 올라가는 동안은 빈칸에서 1 로 구른다
144
+ const pos = (base % 10) + roll;
145
+ // 창에 걸친 두 숫자만 보인다 — 띠 밖 숫자는 검사가 읽지 않게 숨긴다
146
+ c.glyphs.forEach((g, i) => (g.style.visibility = i === Math.floor(pos) || i === Math.floor(pos) + 1 ? "visible" : "hidden"));
147
+ if (base === 0 && c.j > -lowest) c.glyphs[0].style.visibility = "hidden";
148
+ c.strip.style.transform = `translateY(${(-pos * o.linePx).toFixed(2)}px)`;
149
+ }
150
+ };
151
+ }
152
+
153
+ /**
154
+ * 반전 짚음 — el(글 상자, position:relative — 쓰기 슬롯이 글을 갈아 끼우는 요소면 그 부모에 건다) 위에 강조색 면 + onAccent 글 겹을 깔고 p 만큼 왼쪽부터 드러낸다.
155
+ * 옅은 형광펜(accentSoft)은 밝은 바탕에서 화면이 바뀐 것으로 안 읽혀 이쪽이 쉬지 않는 몫을 진다
156
+ */
157
+ export function inverseOver(el: HTMLElement, text: string): (p: number) => void {
158
+ const hi = node(el, "position:absolute;inset:0;background:var(--kf-accent);color:var(--kf-on-accent);white-space:inherit;text-align:inherit;visibility:hidden;pointer-events:none", text);
159
+ return (p) => {
160
+ const k = clamp(p);
161
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
162
+ hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
163
+ };
164
+ }
165
+
166
+ /** 이 장면 안에 든 첫 드롭의 번호(곡 전체 기준 — 테마 drop 슬롯·dropBurst 가 받는 번호). 없으면 null */
167
+ export function sceneDrop(ctx: SceneCtx): number | null {
168
+ const i = ctx.beats.drops.findIndex((d) => d >= 0 && d < ctx.dur);
169
+ return i < 0 ? null : i;
170
+ }
171
+
172
+ /**
173
+ * 쾅 — at 에 크게(from 배) 흐린 채 들어와 내려앉고 출렁인다. 앉는 순간이 주인공이 서는 박.
174
+ * 등장 전엔 opacity 0 이라 그 사이를 다른 움직임이 채워야 한다
175
+ */
176
+ export function slam(t: number, at: number, from = 1.9): Style {
177
+ const lead = 0.16;
178
+ if (t < at - lead) return { opacity: 0 };
179
+ if (t < at) {
180
+ const p = ease.inCubic((t - (at - lead)) / lead);
181
+ return { opacity: clamp(p * 2.5), scale: from - (from - 1) * p, blur: 14 * (1 - p) };
182
+ }
183
+ const s = t - at;
184
+ const wob = Math.exp(-s / 0.14) * Math.cos(s * 30);
185
+ return { scaleX: 1 + 0.08 * wob, scaleY: 1 - 0.08 * wob };
186
+ }
187
+
188
+ /**
189
+ * 글 폭을 실제로 재서 맞춘다 — build 때는 장면이 숨어 글 폭을 못 재 글자 수로 어림하는데, 라틴 글꼴은 어림보다 좁아 화면이 빈다.
190
+ * 처음 보이는 프레임에 한 번 재서 el 의 글 크기를 maxW 폭(maxPx 상한)에 맞춘다. 글꼴이 다 받아진 뒤라 어느 탭에서 재도 같다
191
+ */
192
+ export function fitOnce(el: HTMLElement, maxW: number, maxPx: number, text?: string): () => void {
193
+ let done = false;
194
+ return () => {
195
+ // 글이 주어지면 움직이지 않는 숨은 견본을 문서 본문(늘 보이는 자리)에 두고 잰다 — 장면이 아직 숨은 첫 프레임엔 폭이 0 이라 맞춤을
196
+ // 건너뛰고 다음 프레임부터 맞춰져, 그 장면을 처음 그린 시각만 다른 크기가 됐다. 올라오는 중인 글자(riseName)를 재도 프레임 상태를 따랐다
197
+ if (done || (text === undefined && !el.offsetWidth)) return;
198
+ done = true;
199
+ const px = parseFloat(el.style.fontSize) || 16;
200
+ let width = el.offsetWidth;
201
+ if (text !== undefined) {
202
+ const cs = getComputedStyle(el);
203
+ const probe = node(el.ownerDocument.body, `position:absolute;left:-100000px;top:0;visibility:hidden;white-space:pre;font-family:${cs.fontFamily};font-weight:${cs.fontWeight};letter-spacing:${cs.letterSpacing};font-size:${px}px`, text);
204
+ width = probe.offsetWidth;
205
+ probe.remove();
206
+ }
207
+ if (width > 0) el.style.fontSize = `${Math.round(Math.min(maxPx, (px * maxW) / width))}px`;
208
+ };
209
+ }
210
+
211
+ /**
212
+ * 짚개의 정지점 — from 뒤의 박을 간격 gap 넘게 골라 쓰고(빈 곳은 고르게 메운다), 마지막 정지점은 장면 끝(to)이다.
213
+ * 끝 프레임에서 칸이 대상에 거의 앉아 있어야 표지·견본 스틸에 반쯤 걸친 띠가 안 찍힌다. 칸은 그 순간까지 움직이니(직선 7) 서지는 않는다.
214
+ * 장면이 길어도 짚개 빠르기는 박이 정한다 — 장면 길이에 비례해 느려지는 차오름은 긴 장면에서 멈춘 화면이 된다
215
+ */
216
+ export function beatStops(hits: readonly number[], from: number, to: number, gap = 0.4): number[] {
217
+ const out = [from];
218
+ for (const b of hits) if (b > out[out.length - 1] + gap - 1e-6 && b < to) out.push(b);
219
+ for (let i = 0; i + 1 < out.length; i++) if (out[i + 1] - out[i] > gap * 1.6) out.splice(i + 1, 0, (out[i] + out[i + 1]) / 2);
220
+ while (to - out[out.length - 1] > gap * 1.6) out.push(out[out.length - 1] + gap * 1.2);
221
+ // 끝 바로 앞 정지점은 뺀다 — 남은 토막이 짧으면 칸이 한두 프레임 만에 건너뛴다
222
+ if (out.length > 1 && to - out[out.length - 1] < gap * 0.6) out.pop();
223
+ out.push(to);
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * 짚개 — 강조색 칸이 대상 사이를 정지점마다 옮겨 다니며 「지금 볼 항」을 짚는다. 정지점 사이를 내내 움직인다(직선 7 + smoothstep 3 —
229
+ * 닿아서 서면 그 사이가 멈춘 화면). 칸은 대상 글 뒤에 깔리고(host 에 isolation), 칸이 앉은 대상 글은 onAccent 로 바뀐다.
230
+ * 대상 자리는 그 프레임에 잰다 — 대상이 굴러가거나 내려앉아도 따라간다
231
+ */
232
+ // 글자를 두 색으로 — over 사각형(글 상자 안 좌표) 안은 띠 위 글색, 밖은 그 글의 원래 색. 글자 모양으로 오려 칠하니 반쯤 겹쳐도 겹친 글자 부분만 바뀐다.
233
+ // 글색(color)은 건드리지 않아 원래 색을 늘 다시 읽을 수 있다. 칠하기 전 인라인 값을 그대로 되돌린다 — 테마 슬롯이 글에 칠한 배경을 지우면
234
+ // 한 번 짚고 지나간 뒤로 그림이 바뀌어 같은 시각이 다르게 그려졌다(frame.nondeterministic)
235
+ const TONE_PROPS = ["-webkit-text-fill-color", "background-image", "background-color", "background-repeat", "background-size", "background-position", "-webkit-background-clip", "background-clip"];
236
+ const toneSaved = new WeakMap<HTMLElement, [string, string, string][]>();
237
+
238
+ function paintTone(el: HTMLElement, over: { x: number; y: number; w: number; h: number } | null): void {
239
+ const s = el.style;
240
+ if (!over) {
241
+ const saved = toneSaved.get(el);
242
+ if (!saved) return;
243
+ for (const [k, v, pri] of saved) v ? s.setProperty(k, v, pri) : s.removeProperty(k);
244
+ toneSaved.delete(el);
245
+ delete el.dataset.kfTone;
246
+ return;
247
+ }
248
+ if (!toneSaved.has(el)) toneSaved.set(el, TONE_PROPS.map((k) => [k, s.getPropertyValue(k), s.getPropertyPriority(k)]));
249
+ el.dataset.kfTone = "";
250
+ s.setProperty("-webkit-text-fill-color", "transparent");
251
+ s.backgroundColor = "currentColor";
252
+ s.backgroundImage = "linear-gradient(var(--kf-on-accent), var(--kf-on-accent))";
253
+ s.backgroundRepeat = "no-repeat";
254
+ s.backgroundSize = `${over.w.toFixed(1)}px ${over.h.toFixed(1)}px`;
255
+ s.backgroundPosition = `${over.x.toFixed(1)}px ${over.y.toFixed(1)}px`;
256
+ s.setProperty("-webkit-background-clip", "text");
257
+ s.setProperty("background-clip", "text");
258
+ }
259
+
260
+ // 대상 안에 바탕이 칠해진 요소가 있나 — 투명·알파 0 이면 없는 것
261
+ function hasPlate(root: HTMLElement): boolean {
262
+ const els = [root, ...Array.from(root.querySelectorAll<HTMLElement>("*"))];
263
+ return els.some((el) => {
264
+ // 짚개가 글에 칠한 배경(표식 data-kf-tone)은 판이 아니다 — 앞 프레임에 칠했는지와 상관없이 같은 답이 나오게
265
+ if (el.dataset.kfTone !== undefined) return false;
266
+ const cs = getComputedStyle(el);
267
+ if (cs.backgroundImage !== "none") return true;
268
+ const m = /rgba?\(([^)]+)\)/.exec(cs.backgroundColor);
269
+ const a = m ? Number(m[1].split(",")[3] ?? 1) : 0;
270
+ return a > 0.05;
271
+ });
272
+ }
273
+
274
+ // 글을 담는 잎 자리 — 글 노드를 품었거나, riseName 처럼 ::before 로 글자를 그리거나, 자식 요소가 없는 것(글 찍기 슬롯이 곧 글을 넣을 칸).
275
+ // 처음부터 띠 위 글색(on-accent)으로 칠한 글(게이지 값)은 뺀다. 보이는지·지금 글이 있는지로 고르지 않는다 — 짚개가 같은 프레임의
276
+ // 글 찍기보다 먼저 돌아 그 둘은 앞 프레임 상태라, 앞에 어느 시각을 그렸느냐에 따라 칠할 글이 달라졌다(frame.nondeterministic).
277
+ // 부모에 오리기를 걸면 변환 걸린 자식 글자를 못 잡아 글이 사라져서 잎마다 칠한다
278
+ function leavesOf(root: HTMLElement): HTMLElement[] {
279
+ const out: HTMLElement[] = [];
280
+ const walk = (el: HTMLElement) => {
281
+ const own = el.dataset.kfOdo !== undefined || el.dataset.ch !== undefined || !el.children.length || Array.from(el.childNodes).some((n) => n.nodeType === 3);
282
+ if (own && !/on-accent/.test(el.style.color)) out.push(el);
283
+ for (const c of Array.from(el.children)) walk(c as HTMLElement);
284
+ };
285
+ walk(root);
286
+ return out;
287
+ }
288
+
289
+ export function glider(host: HTMLElement, targets: HTMLElement[], o: { pad?: number; hub?: boolean } = {}): (t: number, stops: readonly number[], until?: number) => void {
290
+ host.style.isolation = "isolate";
291
+ const block = node(host, "position:absolute;left:0;top:0;width:0;height:0;background:var(--kf-accent);border-radius:var(--kf-radius-sm);z-index:-1;visibility:hidden;pointer-events:none");
292
+ let toned = new Set<HTMLElement>();
293
+ const pad = o.pad ?? 0.12;
294
+ // 프레임마다 잰다 — 처음 그린 프레임에서 한 번만 재면 탭마다 첫 프레임이 달라 같은 시각이 다르게 그려졌다(frame.nondeterministic)
295
+ const opaqueOf = hasPlate;
296
+ // 대상 글 상자(raw)와 칸이 덮을 자리(여백 붙임)
297
+ const boxOf = (el: HTMLElement) => {
298
+ const hr = host.getBoundingClientRect();
299
+ const k = hr.width / (host.offsetWidth || 1) || 1;
300
+ const r = el.getBoundingClientRect();
301
+ return { x: (r.left - hr.left) / k, y: (r.top - hr.top) / k, w: r.width / k, h: r.height / k };
302
+ };
303
+ const rect = (el: HTMLElement) => {
304
+ const b = boxOf(el);
305
+ const fs = parseFloat(getComputedStyle(el).fontSize) || 20;
306
+ return { x: b.x - fs * pad, y: b.y - fs * pad * 0.5, w: b.w + fs * pad * 2, h: b.h + fs * pad };
307
+ };
308
+ return (t, stops, until = Infinity) => {
309
+ const n = targets.length;
310
+ if (!n || t < stops[0] || t >= until) {
311
+ block.style.visibility = "hidden";
312
+ for (const el of toned) paintTone(el, null);
313
+ toned = new Set();
314
+ return;
315
+ }
316
+ let j = 0;
317
+ while (j + 1 < stops.length && stops[j + 1] <= t) j++;
318
+ const a = stops[j];
319
+ const b = stops[j + 1] ?? a + 1;
320
+ const p = clamp((t - a) / Math.max(1e-3, b - a));
321
+ const e = 0.7 * p + 0.3 * p * p * (3 - 2 * p);
322
+ // hub 면 주인공(첫 대상)과 나머지를 번갈아 — 작은 글 사이를 옮겨 다니면 칸이 조금밖에 안 움직여 멈춘 화면으로 읽힌다.
323
+ // 마지막 정지점(장면 끝)이 주인공이 되게 차례를 맞춘다 — 끝 프레임(표지·견본)에 곁가지 글이 아니라 주인공이 짚혀 있다
324
+ const base = o.hub ? (stops.length - 1) % 2 : 0;
325
+ const order = (k: number) => (o.hub && n > 1 ? ((k + base) % 2 === 0 ? 0 : 1 + (((k + base - 1) / 2) % (n - 1))) : k % n);
326
+ const from = rect(targets[order(j)]);
327
+ const to = rect(targets[order(j + 1)]);
328
+ const mix = (u: number, v: number) => u + (v - u) * e;
329
+ block.style.visibility = "visible";
330
+ block.style.transform = `translate(${mix(from.x, to.x).toFixed(1)}px, ${mix(from.y, to.y).toFixed(1)}px)`;
331
+ block.style.width = `${mix(from.w, to.w).toFixed(1)}px`;
332
+ block.style.height = `${mix(from.h, to.h).toFixed(1)}px`;
333
+ // 띠와 겹친 글자 부분만 흰 글로 — 글자에 「겹친 사각형만 on-accent, 나머지는 원래 글색」인 배경을 깔고 글자 모양으로 오린다.
334
+ // 통째로 덮였을 때만 글색을 바꾸면, 쉬지 않고 옮겨 가는 띠는 거의 늘 반쯤 걸쳐 있어 띠 위 글이 검은 채로 남았다
335
+ const bx = mix(from.x, to.x);
336
+ const by = mix(from.y, to.y);
337
+ const bw = mix(from.w, to.w);
338
+ const bh = mix(from.h, to.h);
339
+ const now = new Set<HTMLElement>();
340
+ // 불투명한 판을 품은 대상(테마 panel 이 깔린 숫자판·계기 판)은 띠가 그 판 뒤로 가려 안 보인다 — 거기 글을 흰 글로 칠하면 흰 판 위 흰 글이 된다
341
+ for (const el of targets.filter((x) => !opaqueOf(x)).flatMap(leavesOf)) {
342
+ const r = boxOf(el);
343
+ const x0 = Math.max(bx, r.x);
344
+ const y0 = Math.max(by, r.y);
345
+ const x1 = Math.min(bx + bw, r.x + r.w);
346
+ const y1 = Math.min(by + bh, r.y + r.h);
347
+ if (x1 - x0 < 1 || y1 - y0 < 1) continue;
348
+ // 글 상자에 변환(배율)이 걸려 있으면 배경 좌표는 변환 전 크기로
349
+ const sx = (el.offsetWidth || r.w) / (r.w || 1);
350
+ const sy = (el.offsetHeight || r.h) / (r.h || 1);
351
+ paintTone(el, { x: (x0 - r.x) * sx, y: (y0 - r.y) * sy, w: (x1 - x0) * sx, h: (y1 - y0) * sy });
352
+ now.add(el);
353
+ }
354
+ for (const el of toned) if (!now.has(el)) paintTone(el, null);
355
+ toned = now;
356
+ };
357
+ }
358
+
359
+ /** n 개가 차례로 붙는 시각 — 0.32초 간격을 박에 붙이되(0.08초 안의 박만), 박이 멀면 간격대로. 첫 붙음과 둘째 사이가 비면 멈춘 화면이다 */
360
+ export function cadence(hits: readonly number[], n: number, from = 0, gap = 0.32): number[] {
361
+ return Array.from({ length: n }, (_, i) => {
362
+ const want = from + i * gap;
363
+ const near = hits.reduce((b, h) => (Math.abs(h - want) < Math.abs(b - want) ? h : b), Infinity);
364
+ return Math.abs(near - want) < 0.08 ? near : want;
365
+ });
366
+ }
367
+
368
+ export function node(parent: HTMLElement, css: string, text?: string): HTMLDivElement {
369
+ const d = parent.ownerDocument.createElement("div");
370
+ d.style.cssText = css;
371
+ if (text !== undefined) d.textContent = text;
372
+ parent.append(d);
373
+ return d;
374
+ }
375
+
376
+ /** t 이후 첫 박 — 없으면 fallback */
377
+ export const hitAfter = (hits: readonly number[], t: number, fallback = t): number => hits.find((h) => h >= t - 1e-6) ?? fallback;
378
+
379
+ /** 박을 빠른 곡에서도 간격 gap 넘게 솎는다(잰 시각 그대로) */
380
+ export function spaced(hits: readonly number[], gap: number): number[] {
381
+ const out: number[] = [];
382
+ for (const h of hits) if (!out.length || h - out[out.length - 1] >= gap - 1e-6) out.push(h);
383
+ return out;
384
+ }
385
+
386
+ export const decimalsOf = (n: number): number => {
387
+ const s = String(n);
388
+ return s.includes("e") ? 0 : (s.split(".")[1] ?? "").length;
389
+ };
390
+
391
+ export const fmt = (v: number, dec: number): string => v.toLocaleString("en-US", { minimumFractionDigits: dec, maximumFractionDigits: dec });
392
+
393
+ /**
394
+ * 사실 숫자 사이만 지나는 굴림 — from 에서 to 로 단조(넘침 없음), 지나는 값은 늘 둘 사이, 소수 자리는 to 기준으로 고정.
395
+ * 다 굴린 뒤엔 to 를 그 자리 수로 찍은 글 그대로. p 는 0..1 진행도(자·게이지가 같은 값을 쓴다).
396
+ * 감속은 끝까지 빠르기가 0 이 되지 않게 곧은 몫을 섞는다 — 끝자리가 오래 서 있으면 앉기 전에 멈춘 화면이 된다
397
+ */
398
+ export function factRoll(t: number, o: { from: number; to: number; at: number; dur: number; decimals?: number }): { text: string; value: number; p: number } {
399
+ const dec = o.decimals ?? decimalsOf(o.to);
400
+ const x = progress(t, o.at, o.dur);
401
+ const p = 0.75 * ease.outCubic(x) + 0.25 * x;
402
+ const lo = Math.min(o.from, o.to);
403
+ const hi = Math.max(o.from, o.to);
404
+ const value = p >= 1 ? o.to : clamp(o.from + (o.to - o.from) * p, lo, hi);
405
+ return { text: fmt(value, dec), value, p };
406
+ }
407
+
408
+ // 테마가 비운 슬롯은 여기 기본으로 — 테마 토큰(CSS 변수)만 쓴다
409
+ const DEFAULT_SLOTS: Required<Pick<ThemeSlots, "panel" | "land" | "write" | "trace">> = {
410
+ panel(el, { at, tone }) {
411
+ const plate = el.ownerDocument.createElement("div");
412
+ plate.style.cssText =
413
+ `position:absolute;inset:0;border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-soft);` +
414
+ `background:${tone === "accent" ? "var(--kf-accent)" : "var(--kf-raised)"};border:2px solid var(--kf-line)`;
415
+ el.prepend(plate);
416
+ return (t) => {
417
+ plate.style.opacity = String(clamp((t - at) / 0.08));
418
+ };
419
+ },
420
+ land(_el, { at }) {
421
+ return (t): Style => (t < at ? {} : { scale: spring(t - at, { from: 1.16, to: 1, stiffness: 320, damping: 17 }) });
422
+ },
423
+ trace(el, { points, at, dur, tone }) {
424
+ const svg = el.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "svg");
425
+ svg.setAttribute("style", "position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none");
426
+ const line = el.ownerDocument.createElementNS("http://www.w3.org/2000/svg", "polyline");
427
+ line.setAttribute("fill", "none");
428
+ line.setAttribute("stroke", tone === "accent" ? "var(--kf-accent)" : "var(--kf-dim)");
429
+ line.setAttribute("stroke-width", "4");
430
+ line.setAttribute("stroke-linejoin", "round");
431
+ svg.append(line);
432
+ el.append(svg);
433
+ return (t) => {
434
+ const p = clamp((t - at) / Math.max(0.05, dur));
435
+ line.setAttribute("points", p > 0 ? partial(points, p).map((v) => `${v[0].toFixed(1)},${v[1].toFixed(1)}`).join(" ") : "");
436
+ };
437
+ },
438
+ write(el, { text, at, dur, seed }) {
439
+ const cps = typeDuration(text, { cps: 1, jitter: 0 }) / Math.max(0.05, dur);
440
+ return (t) => {
441
+ el.textContent = t >= at + dur ? text : t < at ? "" : typeText(text, t, { at, cps, jitter: 0, seed }).text;
442
+ };
443
+ },
444
+ };
445
+
446
+ /** 테마 슬롯 + 비운 자리는 기본 */
447
+ export function slotsOf(kit: Kit): ThemeSlots & typeof DEFAULT_SLOTS {
448
+ return { ...DEFAULT_SLOTS, ...kit.slots } as ThemeSlots & typeof DEFAULT_SLOTS;
449
+ }
@@ -0,0 +1,117 @@
1
+ import { applyStyle, clamp, spring } from "@kasaframe/core";
2
+ import { fitSize, textEm } from "@kasaframe/motion/moves";
3
+ import { area, beatStops, decimalsOf, factRoll, fmt, glider, moving, node, slotsOf } from "../kit.ts";
4
+ import type { Pattern } from "../types.ts";
5
+
6
+ export interface LimitContent {
7
+ label: string;
8
+ value?: number;
9
+ unit?: string;
10
+ cost?: string;
11
+ }
12
+
13
+ /**
14
+ * 한계 — 굵은 계기가 차올라 끝의 위험 칸(한계)에 부딪힌다. 값이 있으면 계기와 같은 진행도로 굴러 그 값에 닿는다.
15
+ * 부딪힌 뒤 치르는 것 한 낱말이 딱지로 붙고, 짚개가 한계 문장·계기·치르는 것을 번갈아 박마다 짚는다.
16
+ */
17
+ const pattern: Pattern<LimitContent> = {
18
+ id: "limit.gauge",
19
+ type: "limit",
20
+ does: "계기가 차올라 끝의 위험 칸에 부딪히고, 치르는 것이 붙은 뒤 짚개가 한계·계기·대가를 번갈아 짚는다",
21
+ energy: ["low", "mid"],
22
+ family: "grow",
23
+ camera: ["none"],
24
+ bg: "stage",
25
+ aspects: ["16:9", "9:16", "1:1"],
26
+ dur: { min: 2.4, max: 7 },
27
+ fits(c) {
28
+ if (typeof c?.label !== "string" || !c.label.trim()) return "label 이 비었다";
29
+ if ([...c.label].length > 24) return "label 이 24자를 넘는다";
30
+ if (c.value !== undefined && !(Number.isFinite(c.value) && c.value >= 0)) return "value 가 0 이상 수가 아니다";
31
+ if (c.cost && [...c.cost].length > 10) return "cost 가 10자를 넘는다";
32
+ return true;
33
+ },
34
+ build(root, ctx, c, kit) {
35
+ const slots = slotsOf(kit);
36
+ const { w, h, vertical, u } = area(ctx, kit);
37
+ // 세로 화면은 계기를 세워 아래에서 위로 차오르게 한다 — 위아래를 다 써서 화면 위쪽에만 몰리지 않는다
38
+ const gw = vertical ? Math.round(w * 0.74) : w * 0.9;
39
+ const gh = Math.round(h * (vertical ? 0.5 : 0.14));
40
+ // 계기 두께(차오르는 방향과 직각) — 값 글자 크기의 기준
41
+ const thick = vertical ? gw : gh;
42
+ const wall = 0.82;
43
+ const pieces = c.label.split(/\s+/);
44
+ const lines = vertical ? Math.min(3, pieces.length) : Math.min(2, pieces.length);
45
+ const labelPx = fitSize(vertical ? pieces.reduce((a, p) => (textEm(p) > textEm(a) ? p : a), "") : c.label, gw * (vertical ? 0.95 : 0.9 * lines), h, { hShare: (vertical ? 0.3 : 0.26) / lines, max: Math.round((vertical ? 200 : 150) * u), min: Math.round(44 * u) });
46
+ const dec = c.value !== undefined ? decimalsOf(c.value) : 0;
47
+ const valText = c.value !== undefined ? `${factRoll(Infinity, { from: 0, to: c.value, at: 0, dur: 1, decimals: dec }).text}${c.unit ? ` ${c.unit}` : ""}` : "";
48
+ const valPx = Math.round(Math.min(thick * (vertical ? 0.3 : 0.62), fitSize(valText || "0", vertical ? gw * 0.86 : gw * 0.3, h)));
49
+ const costPx = Math.round(Math.max(52 * u, labelPx * 0.42));
50
+
51
+ const host = node(root, "position:absolute;inset:0;display:flex;align-items:center;justify-content:center");
52
+ const labelW = vertical ? w * 0.92 : gw;
53
+ // 세로면 문장과 계기 사이에 대가 칩이 설 줄 하나를 비워 둔다 — 칩이 문장 아래 끝에 겹쳤다
54
+ const col = node(host, `display:flex;flex-direction:column;align-items:center;gap:${Math.round(vertical ? Math.max(h * 0.05, c.cost ? costPx * 2.4 : 0) : gh * 0.45)}px;width:${labelW}px`);
55
+ const labelBox = node(col, `position:relative;display:inline-block;max-width:${labelW}px;text-align:center`);
56
+ const label = node(labelBox, `font-family:var(--kf-font-display);font-size:${labelPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};color:var(--kf-ink);text-align:center;line-height:1.12;word-break:keep-all;max-width:${labelW}px`);
57
+ // 세로면 계기 옆에 치르는 것을 벽 높이에 — 가로면 계기 아래 오른쪽
58
+ const row = vertical ? node(col, "position:relative;display:flex;justify-content:center;width:100%") : col;
59
+ const line = Math.max(2, Math.round(3 * u));
60
+ const gauge = node(row, `position:relative;width:${gw}px;height:${gh}px;background:var(--kf-raised);border:${line}px solid var(--kf-line);box-sizing:border-box`);
61
+ const fill = node(gauge, vertical ? "position:absolute;left:0;right:0;bottom:0;height:100%;background:var(--kf-accent);transform-origin:50% 100%;transform:scaleY(0)" : "position:absolute;left:0;top:0;bottom:0;width:100%;background:var(--kf-accent);transform-origin:0 50%;transform:scaleX(0)");
62
+ const danger = node(gauge, vertical ? `position:absolute;left:0;right:0;top:0;height:${(1 - wall) * 100}%;background:var(--kf-danger, var(--kf-accent));opacity:.28` : `position:absolute;left:${wall * 100}%;right:0;top:0;bottom:0;background:var(--kf-danger, var(--kf-accent));opacity:.28`);
63
+ const bar = Math.max(4, Math.round(6 * u));
64
+ const wallEl = node(
65
+ gauge,
66
+ vertical
67
+ ? `position:absolute;top:${(1 - wall) * 100}%;left:-${Math.round(gw * 0.12)}px;right:-${Math.round(gw * 0.12)}px;height:${bar}px;margin-top:-${bar / 2}px;background:var(--kf-danger, var(--kf-ink))`
68
+ : `position:absolute;left:${wall * 100}%;top:-${Math.round(gh * 0.3)}px;bottom:-${Math.round(gh * 0.3)}px;width:${bar}px;margin-left:-${bar / 2}px;background:var(--kf-danger, var(--kf-ink))`,
69
+ );
70
+ const val = valText
71
+ ? node(gauge, vertical ? `position:absolute;left:0;right:0;display:flex;justify-content:center;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:var(--kf-on-accent);white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1.2` : `position:absolute;top:0;bottom:0;display:flex;align-items:center;padding:0 ${Math.round(gh * 0.25)}px;font-family:var(--kf-font-mono);font-size:${valPx}px;font-weight:700;color:var(--kf-on-accent);white-space:nowrap;font-variant-numeric:tabular-nums`)
72
+ : null;
73
+ const costCss = `font-family:var(--kf-font-sans);font-size:${costPx}px;font-weight:800;color:var(--kf-ink);border:${Math.max(3, Math.round(4 * u))}px solid var(--kf-danger, var(--kf-ink));padding:0.08em 0.45em;line-height:1.2;white-space:nowrap`;
74
+ // 세로면 치르는 것은 계기 위 바깥, 위험 칸 바로 위 오른쪽에 — 계기 안에 두면 짚개 띠가 불투명한 계기 판 뒤로만 오가 화면이 안 바뀐다
75
+ const cost = c.cost ? node(vertical ? row : col, vertical ? `position:absolute;right:${Math.round((w * 0.92 - gw) / 2)}px;top:-${Math.round(costPx * 1.75)}px;${costCss}` : `align-self:flex-end;${costCss}`) : null;
76
+ kit.stage?.hero(gauge);
77
+
78
+ const hits = kit.hits.length ? kit.hits : [0];
79
+ const labelDur = clamp([...c.label].length * 0.03, 0.3, 0.7);
80
+ const fillAt = labelDur * 0.5;
81
+ // 계기는 1–2.2초 안에 벽에 닿는다 — 장면이 길다고 느리게 차오르면 그 사이가 멈춘 화면이다
82
+ const hitWant = clamp(ctx.dur * 0.45, fillAt + 0.9, fillAt + 2.2);
83
+ const hitAt = hits.reduce((best, b) => (Math.abs(b - hitWant) < Math.abs(best - hitWant) && b > fillAt + 0.6 ? b : best), hitWant);
84
+ const costAt = hitAt + 0.1;
85
+ const markFrom = hitAt + 0.2;
86
+
87
+ const writeLabel = slots.write(label, { ctx, text: c.label, at: 0, dur: labelDur, seed: `${kit.seed}:l` });
88
+ const writeCost = cost ? slots.write(cost, { ctx, text: c.cost!, at: costAt, dur: 0.25, seed: `${kit.seed}:c` }) : null;
89
+ const landed = slots.land(wallEl, { ctx, at: hitAt, seed: `${kit.seed}:wall` });
90
+ // 벽에 닿은 뒤엔 짚개가 한계 문장 ↔ 계기 ↔ 치르는 것을 번갈아 박마다 짚는다
91
+ // 세로면 계기 판은 짚지 않는다 — 띠가 판 뒤로 가려 그 사이가 멈춘 화면이 된다. 한계 문장 ↔ 대가를 오간다
92
+ const point = glider(host, vertical && cost ? [labelBox, cost] : [labelBox, gauge, ...(cost ? [cost] : [])], { hub: true });
93
+ const stops = beatStops(kit.hits, markFrom, ctx.dur);
94
+
95
+ return (t: number) => {
96
+ moving("limit.gauge", ctx);
97
+ writeLabel(t);
98
+ point(t, stops);
99
+ // 고른 빠르기로 차올라 벽에 부딪힌다 — 감속하면 부딪힘이 없고 끝자락이 멈춘 화면이 된다
100
+ const p = clamp((t - fillAt) / (hitAt - fillAt));
101
+ fill.style.transform = vertical ? `scaleY(${(p * wall).toFixed(4)})` : `scaleX(${(p * wall).toFixed(4)})`;
102
+ if (val && c.value !== undefined) {
103
+ val.textContent = `${p >= 1 ? fmt(c.value, dec) : fmt(c.value * p, dec)}${c.unit ? ` ${c.unit}` : ""}`;
104
+ // 세로면 값이 차오르는 면 꼭대기 바로 아래를 따라 오른다
105
+ if (vertical) val.style.top = `${Math.round(Math.min(gh * (1 - p * wall) + valPx * 0.25, gh - valPx * 1.35))}px`;
106
+ else val.style.left = "0";
107
+ val.style.visibility = p > 0.18 ? "visible" : "hidden";
108
+ }
109
+ danger.style.opacity = t >= hitAt ? String(0.28 + 0.5 * spring(t - hitAt, { from: 1, to: 0, stiffness: 200, damping: 12 })) : "0.28";
110
+ applyStyle(wallEl, landed(t));
111
+ if (cost) cost.style.visibility = t >= costAt ? "visible" : "hidden";
112
+ writeCost?.(t);
113
+ };
114
+ },
115
+ };
116
+
117
+ export default pattern;