kasaframe 0.1.5 → 0.1.7

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 (104) hide show
  1. package/NOTICE +13 -0
  2. package/README.md +2 -2
  3. package/docs/AGENTS.md +1 -0
  4. package/package.json +1 -1
  5. package/packages/adapters/src/project.ts +2 -0
  6. package/packages/check/package.json +1 -0
  7. package/packages/check/src/check.ts +8 -2
  8. package/packages/check/src/rules/frames.ts +4 -5
  9. package/packages/cli/src/commands/direct.ts +24 -4
  10. package/packages/cli/src/commands/make.ts +15 -5
  11. package/packages/cli/src/commands/render.ts +34 -7
  12. package/packages/cli/src/mix.ts +66 -4
  13. package/packages/cli/src/segments.ts +1 -1
  14. package/packages/cli/src/sfx.ts +429 -0
  15. package/packages/core/src/frame.ts +7 -4
  16. package/packages/core/src/index.ts +3 -1
  17. package/packages/core/src/project.ts +19 -0
  18. package/packages/core/src/sfx.ts +70 -0
  19. package/packages/director/src/assemble.ts +15 -6
  20. package/packages/director/src/fallback.ts +14 -2
  21. package/packages/director/src/index.ts +1 -1
  22. package/packages/director/src/pattern.ts +2 -0
  23. package/packages/director/src/patterns/fill/cells.ts +5 -2
  24. package/packages/director/src/scene.ts +17 -5
  25. package/packages/director/src/transitions.ts +21 -8
  26. package/packages/motion/src/enter.ts +7 -1
  27. package/packages/motion/src/impact.ts +5 -2
  28. package/packages/motion/src/moves/dress.ts +15 -2
  29. package/packages/motion/src/moves/menu.ts +11 -5
  30. package/packages/motion/src/transition.ts +3 -1
  31. package/packages/motion/src/transition3d/index.ts +3 -1
  32. package/packages/motion/src/transition3d/live.ts +5 -3
  33. package/packages/patterns/src/bars/rank.ts +6 -7
  34. package/packages/patterns/src/before-after/shrink.ts +3 -4
  35. package/packages/patterns/src/before-after/strike.ts +4 -5
  36. package/packages/patterns/src/code/terminal.ts +25 -16
  37. package/packages/patterns/src/end/lockup.ts +13 -10
  38. package/packages/patterns/src/end/slam.ts +14 -8
  39. package/packages/patterns/src/hook-number/counter.ts +15 -9
  40. package/packages/patterns/src/hook-number/slam.ts +6 -8
  41. package/packages/patterns/src/kit.ts +364 -59
  42. package/packages/patterns/src/limit/gauge.ts +19 -13
  43. package/packages/patterns/src/quote/karaoke.ts +17 -12
  44. package/packages/patterns/src/sfx.ts +80 -0
  45. package/packages/patterns/src/steps/path.ts +15 -7
  46. package/packages/patterns/src/term-reveal/chips.ts +24 -10
  47. package/packages/patterns/src/term-reveal/expand.ts +17 -13
  48. package/packages/patterns/src/timeline/axis.ts +79 -25
  49. package/packages/patterns/src/title/mask.ts +15 -10
  50. package/packages/patterns/src/title/slam.ts +1 -1
  51. package/packages/patterns/src/types.ts +1 -0
  52. package/packages/plan/src/brief.ts +5 -2
  53. package/packages/themes/src/index.ts +22 -0
  54. package/packages/themes/src/slots.ts +32 -2
  55. package/packages/themes/src/types.ts +28 -0
  56. package/sfx/README.md +82 -0
  57. package/sfx/click/appear.wav +0 -0
  58. package/sfx/click/burst.wav +0 -0
  59. package/sfx/click/drop.wav +0 -0
  60. package/sfx/click/end.wav +0 -0
  61. package/sfx/click/hop.wav +0 -0
  62. package/sfx/click/land.wav +0 -0
  63. package/sfx/click/select.wav +0 -0
  64. package/sfx/click/stick.wav +0 -0
  65. package/sfx/click/tick.wav +0 -0
  66. package/sfx/click/type.wav +0 -0
  67. package/sfx/click/whoosh.wav +0 -0
  68. package/sfx/pop/appear.wav +0 -0
  69. package/sfx/pop/burst.wav +0 -0
  70. package/sfx/pop/drop.wav +0 -0
  71. package/sfx/pop/end.wav +0 -0
  72. package/sfx/pop/hop.wav +0 -0
  73. package/sfx/pop/land.wav +0 -0
  74. package/sfx/pop/select.wav +0 -0
  75. package/sfx/pop/stick.wav +0 -0
  76. package/sfx/pop/tick.wav +0 -0
  77. package/sfx/pop/type.wav +0 -0
  78. package/sfx/pop/whoosh.wav +0 -0
  79. package/sfx/tags.json +545 -0
  80. package/sfx/ui/appear.wav +0 -0
  81. package/sfx/ui/burst.wav +0 -0
  82. package/sfx/ui/drop.wav +0 -0
  83. package/sfx/ui/end.wav +0 -0
  84. package/sfx/ui/hop.wav +0 -0
  85. package/sfx/ui/land.wav +0 -0
  86. package/sfx/ui/select.wav +0 -0
  87. package/sfx/ui/stick.wav +0 -0
  88. package/sfx/ui/tick.wav +0 -0
  89. package/sfx/ui/type.wav +0 -0
  90. package/sfx/ui/whoosh.wav +0 -0
  91. package/themes/kawaii-sticker/effects.ts +15 -3
  92. package/themes/kawaii-sticker/theme.json +1 -0
  93. package/themes/og-measure/THEME.md +13 -2
  94. package/themes/og-measure/effects.ts +76 -59
  95. package/themes/og-measure/sample/scenes/end.ts +2 -2
  96. package/themes/og-measure/sample/scenes/gather.ts +2 -10
  97. package/themes/og-measure/sample/scenes/models.ts +2 -10
  98. package/themes/og-measure/sample/scenes/one.ts +2 -1
  99. package/themes/og-measure/sample/scenes/price.ts +4 -16
  100. package/themes/og-measure/slots.ts +29 -0
  101. package/themes/og-measure/still.png +0 -0
  102. package/themes/og-measure/style.json +3 -0
  103. package/themes/og-measure/theme.json +15 -1
  104. package/themes/og-measure/variants/snow-blue.json +3 -0
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, spring } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, beatStops, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, glider, inverseOver, markOf, moving, node, slotsOf } from "../kit.ts";
4
4
  import type { Pattern, SpokenWord } from "../types.ts";
5
5
 
6
6
  export interface QuoteContent {
@@ -80,14 +80,23 @@ const pattern: Pattern<QuoteContent> = {
80
80
  const mark = node(col, `font-family:var(--kf-font-display);font-size:${Math.round(px * 1.4)}px;line-height:.6;height:${Math.round(px * 0.6)}px;font-weight:900;color:${ACCENT_TEXT}`, "“");
81
81
  const body = node(col, `font-family:var(--kf-font-display);font-size:${px}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.3;color:var(--kf-ink);word-break:keep-all`);
82
82
  kit.stage?.hero(body);
83
+ const shape = markOf(kit, ctx);
83
84
  const spans = words.map((wd, i) => {
84
85
  const sp = body.ownerDocument.createElement("span");
85
86
  sp.style.cssText = "position:relative;white-space:nowrap";
86
87
  sp.textContent = wd;
87
- const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
88
88
  body.append(sp);
89
89
  if (i < words.length - 1) body.append(body.ownerDocument.createTextNode(" "));
90
- return { sp, hi };
90
+ // 읽는 낱말 반전 칸 — 테마가 짚는 칸 모양을 정했으면 그 칸(반전 짚음)
91
+ if (shape) return { sp, paint: inverseOver(sp, wd, shape) };
92
+ const hi = node(sp, "position:absolute;left:-0.08em;right:-0.08em;top:0.02em;bottom:0.02em;padding:0 0.08em;background:var(--kf-accent);color:var(--kf-on-accent);white-space:nowrap;visibility:hidden", wd);
93
+ return {
94
+ sp,
95
+ paint: (k: number) => {
96
+ hi.style.visibility = k > 0 ? "visible" : "hidden";
97
+ hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
98
+ },
99
+ };
91
100
  });
92
101
  const whoText = [c.who, c.role].filter(Boolean).join(" · ");
93
102
  const who = whoText ? node(col, `align-self:flex-end;font-family:var(--kf-font-sans);font-size:${whoPx}px;font-weight:600;color:var(--kf-ink-soft, var(--kf-ink));white-space:nowrap;line-height:1.3;height:1.3em`) : null;
@@ -101,24 +110,20 @@ const pattern: Pattern<QuoteContent> = {
101
110
  const times = kit.say.length ? timing(words, kit.say, readFrom, ctx.dur - 0.05) : even(words, readFrom, readTo);
102
111
  const readEnd = times[times.length - 1][1];
103
112
  const whoAt = Math.min(Math.max(readFrom, ctx.dur - whoDur - 0.6), readEnd);
104
- // 다 읽은 뒤엔 짚개가 문장 ↔ 누가 말했는지를 박마다 번갈아 짚는다(누가 없으면 낱말을 차례로)
105
- const point = glider(host, who ? [body, who] : spans.map((x) => x.sp), { hub: !!who, pad: 0.06 });
106
- const stops = beatStops(kit.hits, readEnd + 0.05, ctx.dur);
113
+ // 다 읽으면 짚개가 문장부터 짚는다(머무는 테마는 목소리가 누가를 말할 때만 그리로). 누가가 없으면 훑는 테마만 낱말을 차례로 — 머무는 테마엔 주인공이 없다
114
+ const point = glider(host, who ? [{ el: body, text: c.text }, { el: who, text: whoText }] : spans.map((x) => ({ el: x.sp })), { kit, ctx, from: readEnd + 0.05, hub: !!who, pad: 0.06 });
107
115
  const writeWho = who ? slots.write(who, { ctx, text: whoText, at: whoAt, dur: whoDur, seed: `${kit.seed}:who` }) : null;
108
116
 
109
117
  return (t: number) => {
110
118
  moving("quote.karaoke", ctx);
111
119
  applyStyle(mark, { scale: spring(t, { from: 0.4, to: 1, stiffness: 260, damping: 14 }) });
120
+ // 짚는 동안은 낱말 반전 칸을 걷는다 — 두 강조가 겹치면 무엇을 짚는지 흐려진다
112
121
  spans.forEach((s, i) => {
113
122
  const [a, b] = times[i];
114
- const k = clamp((t - a) / Math.max(0.05, b - a));
115
- s.hi.style.visibility = k > 0 ? "visible" : "hidden";
116
- s.hi.style.clipPath = `inset(0 ${((1 - k) * 100).toFixed(2)}% 0 0)`;
123
+ s.paint(t >= readEnd + 0.05 ? 0 : clamp((t - a) / Math.max(0.05, b - a)));
117
124
  });
118
125
  writeWho?.(t);
119
- // 짚는 동안은 낱말 반전 칸을 걷는다 — 두 강조가 겹치면 무엇을 짚는지 흐려진다
120
- if (t >= readEnd + 0.05) spans.forEach((x) => (x.hi.style.visibility = "hidden"));
121
- point(t, stops);
126
+ point(t);
122
127
  };
123
128
  },
124
129
  };
@@ -0,0 +1,80 @@
1
+ import { cue } from "@kasaframe/core";
2
+ import type { ThemeSlots } from "./types.ts";
3
+
4
+ // 패턴이 부르는 테마 슬롯에 소리 자리를 단다 — 테마 슬롯도 패턴 기본 슬롯도 같은 자리에서 소리가 나고, 장면·테마 코드는 그대로다
5
+
6
+ /** 글 찍기 소리 간격(초) — 글자마다 내면 1초에 열 번이 넘어 밀도 상한에 다 잘린다 */
7
+ const TYPE_EVERY = 0.09;
8
+ const TYPE_MAX = 10;
9
+
10
+ export function withSfx<S extends ThemeSlots>(s: S): S {
11
+ const out: ThemeSlots = { ...s };
12
+ if (s.panel)
13
+ out.panel = (el, o) => {
14
+ const paint = s.panel!(el, o);
15
+ return (t) => {
16
+ cue("appear", t - o.at, { source: `panel:${o.seed}@${o.at.toFixed(3)}`, scene: o.ctx.id, weight: 0.8, minor: true });
17
+ paint(t);
18
+ };
19
+ };
20
+ if (s.land)
21
+ out.land = (el, o) => {
22
+ const style = s.land!(el, o);
23
+ return (t) => {
24
+ cue("land", t - o.at, { source: `land:${o.seed}@${o.at.toFixed(3)}`, scene: o.ctx.id });
25
+ return style(t);
26
+ };
27
+ };
28
+ if (s.write)
29
+ out.write = (el, o) => {
30
+ const paint = s.write!(el, o);
31
+ const n = Math.min(TYPE_MAX, Math.max(1, Math.floor(o.dur / TYPE_EVERY)));
32
+ return (t) => {
33
+ const k = Math.floor((t - o.at) / TYPE_EVERY);
34
+ for (const j of [k - 1, k]) if (j >= 0 && j < n) cue("type", t - o.at - j * TYPE_EVERY, { source: `write:${o.seed}:${j}@${o.at.toFixed(3)}`, scene: o.ctx.id, minor: true });
35
+ paint(t);
36
+ };
37
+ };
38
+ if (s.drop)
39
+ out.drop = (el, o) => {
40
+ const burst = s.drop!(el, o);
41
+ const at = o.ctx.beats.drop(o.drop);
42
+ return (t) => {
43
+ cue("drop", t - at, { source: `slot-drop:${o.drop}`, scene: o.ctx.id });
44
+ return burst(t);
45
+ };
46
+ };
47
+ return out as S;
48
+ }
49
+
50
+ /** 쾅 앉는 순간 — kit slam 이 부른다 */
51
+ export const slamCue = (t: number, at: number): void => cue("land", t - at, { source: `slam@${at.toFixed(3)}` });
52
+
53
+ /** 짚개가 요점(핵심 값)으로 옮겨 가는 순간 딸깍 — 요점만 짚으니 잔소리가 아니다 */
54
+ export const keyCue = (t: number, at: number): void => cue("select", t - at, { source: `glider@${at.toFixed(3)}`, weight: 0.8 });
55
+
56
+ /** 박마다 훑는 짚개가 주인공(핵심 값)에 닿는 순간 딸깍 — 곁가지에 닿는 것은 잔소리. 첫 정지점은 짚개가 나타나는 자리라 빼고 */
57
+ export function stopCues(t: number, stops: readonly number[], key: (k: number) => boolean = () => true): void {
58
+ for (let j = 1; j < stops.length - 1; j++)
59
+ if (t >= stops[j] && t < stops[j] + 0.2) cue("select", t - stops[j], { source: `glider@${stops[j].toFixed(3)}`, weight: 0.8, minor: !key(j) });
60
+ }
61
+
62
+ /**
63
+ * 숫자 굴림이 째깍거리는 시각 — 진행도 p(x)(감속)가 k/n 을 지나는 x 에. 빨리 구르는 앞은 촘촘, 앉기 전은 띄엄띄엄(슬롯 머신처럼).
64
+ * p 는 단조라 이분법으로 거꾸로 푼다
65
+ */
66
+ export function rollCues(t: number, o: { at: number; dur: number; p: (x: number) => number; n?: number }): void {
67
+ if (t < o.at || t > o.at + o.dur + 0.2) return;
68
+ const n = o.n ?? Math.max(3, Math.min(10, Math.round(o.dur / 0.12)));
69
+ for (let k = 1; k <= n; k++) {
70
+ let lo = 0;
71
+ let hi = 1;
72
+ for (let i = 0; i < 24; i++) {
73
+ const mid = (lo + hi) / 2;
74
+ if (o.p(mid) < k / n) lo = mid;
75
+ else hi = mid;
76
+ }
77
+ const at = o.at + hi * o.dur;
78
+ cue(k === n ? "land" : "tick", t - at, { source: `roll:${k}@${o.at.toFixed(3)}`, weight: k === n ? 0.8 : 0.7, minor: k !== n });
79
+ }
80
+ }
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, combine, spring } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { area, moving, node, slotsOf, spaced } from "../kit.ts";
3
+ import { area, FILL, FILL_LINE, fillEdge, followSpoken, moving, node, ON_FILL, slotsOf, spaced } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  export interface StepsContent {
@@ -76,16 +76,19 @@ const pattern: Pattern<StepsContent> = {
76
76
  const [x, y] = cardPos(i);
77
77
  const card = node(host, `position:absolute;left:${x}px;top:${y}px;width:${cardW}px;height:${cardH}px;transform-origin:${vertical ? "0 50%" : "50% 0"}`);
78
78
  const panel = slots.panel(card, { ctx, at: enters[i], seed: `${kit.seed}:c${i}` });
79
- const fill = node(card, "position:absolute;inset:0;background:var(--kf-accent);border-radius:var(--kf-radius-md)");
79
+ const edge = Math.max(2, Math.round(3 * u));
80
+ const fill = node(card, `position:absolute;inset:0;background:${FILL};${fillEdge(edge)};border-radius:var(--kf-radius-md)`);
81
+ // 차오르는 수위 — 칠 테두리 색 줄(토큰이 없는 테마엔 안 보인다). 옅은 칠은 차오름만으로는 화면이 거의 안 바뀐다
82
+ const level = node(card, `position:absolute;left:0;right:0;top:0;height:${edge}px;background:var(--kf-fill-line, transparent);visibility:hidden`);
80
83
  const face = (lit: boolean) => {
81
- const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(detailPx * 0.4)}px;padding:0 ${Math.round(labelPx * 0.4)}px;box-sizing:border-box;color:${lit ? "var(--kf-on-accent)" : "var(--kf-ink)"}`);
84
+ const f = node(card, `position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:${vertical ? "flex-start" : "center"};gap:${Math.round(detailPx * 0.4)}px;padding:0 ${Math.round(labelPx * 0.4)}px;box-sizing:border-box;color:${lit ? ON_FILL : "var(--kf-ink)"}`);
82
85
  const label = node(f, `font-family:var(--kf-font-display);font-size:${labelPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};line-height:1.08;word-break:keep-all;text-align:${vertical ? "left" : "center"};white-space:${vertical ? "nowrap" : "normal"}`);
83
86
  const detail = showDetail && s.detail ? node(f, `font-family:var(--kf-font-sans);font-size:${detailPx}px;line-height:1.3;opacity:.85;text-align:${vertical ? "left" : "center"};word-break:keep-all`) : null;
84
87
  return { f, label, detail };
85
88
  };
86
89
  const base = face(false);
87
90
  const lit = face(true);
88
- return { card, panel, fill, base, lit, s };
91
+ return { card, panel, fill, level, base, lit, s };
89
92
  });
90
93
 
91
94
  const walkFrom = enters[n - 1] + 0.3;
@@ -100,6 +103,8 @@ const pattern: Pattern<StepsContent> = {
100
103
  ]),
101
104
  );
102
105
  const lands = dots.map((d, i) => slots.land(d, { ctx, at: enters[i], seed: `${kit.seed}:n${i}` }));
106
+ // 머무는 짚개 테마는 지금 단계 차례마다 카메라가 그 카드 쪽으로 다가간다
107
+ followSpoken(kit, ctx, cards.map((cd, i) => ({ at: turns[i], el: cd.card })), { reach: 0.15, zoom: 1.03 });
103
108
 
104
109
  return (t: number) => {
105
110
  moving("steps.path", ctx);
@@ -113,14 +118,17 @@ const pattern: Pattern<StepsContent> = {
113
118
  cards[i].panel(t);
114
119
  applyStyle(cards[i].card, { scale: spring(t - enters[i], { from: 0.82, to: 1, stiffness: 300, damping: 16 }) });
115
120
  const now = t >= turns[i];
116
- dots[i].style.background = now ? "var(--kf-accent)" : "var(--kf-bg)";
117
- dots[i].style.color = now ? "var(--kf-on-accent)" : "var(--kf-ink)";
118
- dots[i].style.borderColor = now ? "var(--kf-accent)" : "var(--kf-ink)";
121
+ // 칠이 옅은 테마도 길이 마디 뒤로 비치지 않게 바탕 위에 칠한다
122
+ dots[i].style.background = now ? `linear-gradient(${FILL}, ${FILL}), var(--kf-bg)` : "var(--kf-bg)";
123
+ dots[i].style.color = now ? ON_FILL : "var(--kf-ink)";
124
+ dots[i].style.borderColor = now ? FILL_LINE : "var(--kf-ink)";
119
125
  // 그 단계 차례 내내 카드가 아래부터 차오른다 — 차례 끝자락엔 길이 다음 마디로 같이 이어진다
120
126
  const k = clamp((t - turns[i]) / Math.max(0.05, ends[i] - turns[i]));
121
127
  const clip = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
122
128
  cards[i].fill.style.clipPath = clip;
123
129
  cards[i].lit.f.style.clipPath = clip;
130
+ cards[i].level.style.visibility = k > 0 && k < 1 ? "visible" : "hidden";
131
+ cards[i].level.style.top = `${((1 - k) * 100).toFixed(2)}%`;
124
132
  });
125
133
  rails.forEach((r, i) => {
126
134
  // 회색 길은 뒤 마디가 서기 직전에 그어지고, 강조색 길은 앞 단계 차례 끝자락에 다음 마디까지 이어진다
@@ -1,12 +1,13 @@
1
1
  import { applyStyle, clamp, spring } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, inverseOver, moving, node, slotsOf } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, dwells, followSpoken, glider, inverseOver, markOf, moving, node, sayTimes, slotsOf } from "../kit.ts";
4
4
  import type { TermContent } from "./expand.ts";
5
5
  import type { Pattern } from "../types.ts";
6
6
 
7
7
  /**
8
8
  * 용어 해부 — 약자가 큰 판 위에 서고, 풀이 낱말이 박마다 하나씩 판 아래 칩으로 톡 붙는다(첫 글자는 강조색 — 약자 글자와 같은 글자).
9
9
  * 다 붙으면 한국어 이름이 판 아래에 찍히고, 판이 아래부터 강조색으로 차올라 장면 끝까지 그 용어를 짚는다.
10
+ * 테마가 짚는 칸 모양을 정했으면 판을 칠하지 않는다 — 짚개가 약자부터 짚는다(머무는 테마는 목소리가 풀이·한국어 이름을 말할 때만 그리로).
10
11
  */
11
12
  const pattern: Pattern<TermContent> = {
12
13
  id: "term-reveal.chips",
@@ -42,11 +43,15 @@ const pattern: Pattern<TermContent> = {
42
43
  const col = node(host, `display:flex;flex-direction:column;align-items:center;gap:${Math.round(h * 0.035)}px`);
43
44
  const board = node(col, `position:relative;width:${boardW}px;height:${boardH}px`);
44
45
  const panel = slots.panel(board, { ctx, at: 0, seed: `${kit.seed}:board` });
45
- const fill = node(board, "position:absolute;inset:0;background:var(--kf-accent);border-radius:var(--kf-radius-md)");
46
+ const mark = markOf(kit, ctx);
47
+ const fill = mark ? null : node(board, "position:absolute;inset:0;background:var(--kf-accent);border-radius:var(--kf-radius-md)");
46
48
  const face = (lit: boolean) =>
47
49
  node(board, `position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-family:var(--kf-font-display);font-size:${termPx}px;font-weight:${ctx.style.type?.weightDisplay ?? 800};letter-spacing:0.04em;color:${lit ? "var(--kf-on-accent)" : "var(--kf-ink)"}`, c.term);
48
- face(false);
49
- const lit = face(true);
50
+ const plain = face(false);
51
+ const lit = mark ? null : face(true);
52
+ // 짚개가 잴 약자 글 상자 — 판 전체가 아니라 글자에 칸이 앉게
53
+ const termText = mark ? node(plain, "position:relative", c.term) : null;
54
+ if (termText) plain.firstChild!.remove();
50
55
  kit.stage?.hero(board);
51
56
  const row = words.length ? node(col, `display:flex;flex-direction:${vertical ? "column" : "row"};align-items:center;gap:${Math.round(chipPx * 0.35)}px;min-height:${Math.round(chipPx * 1.5)}px`) : null;
52
57
  const chips = words.map((wd) => {
@@ -57,17 +62,25 @@ const pattern: Pattern<TermContent> = {
57
62
  });
58
63
  const koBox = c.ko ? node(col, `position:relative;font-family:var(--kf-font-sans);font-size:${koPx}px;font-weight:700;white-space:nowrap;line-height:1.3;height:1.3em`) : null;
59
64
  const ko = koBox ? node(koBox, "color:var(--kf-ink)") : null;
60
- const koMark = koBox ? inverseOver(koBox, c.ko!) : null;
65
+ const koMark = koBox && !mark ? inverseOver(koBox, c.ko!) : null;
66
+ // 짚개가 이름 자리를 찍히기 전부터 짚는다 — 빈 상자는 폭이 0 이라 칸이 점으로 줄어든다
67
+ if (koBox && mark) koBox.style.cssText += `;min-width:${Math.round(textEm(c.ko!) * koPx)}px;text-align:center`;
61
68
 
62
- const hits = kit.hits.length ? kit.hits : [0];
63
69
  const boardAt = kit.first ? -0.3 : 0;
64
- const chipAt = words.map((_, i) => hits.find((b) => b >= 0.12 + i * 0.3) ?? 0.2 + i * 0.3);
65
- const koAt = (chipAt[chipAt.length - 1] ?? 0.2) + 0.3;
70
+ // 머무는 짚개 테마는 풀이 칩·한국어 이름을 목소리가 그것을 말할 때 붙인다(못 들으면 판이 열린 뒤부터 장면 끝 무렵까지 박에 붙여 고르게) —
71
+ // 처음에 몰아 띄우면 남은 시간이 빈다. 붙을 때마다 카메라가 그쪽으로 다가간다. 훑는 테마는 0.3초 간격 박에 붙인다(판 차오름이 끝까지 간다)
72
+ const hits = kit.hits.length ? kit.hits : [0];
73
+ const times = dwells(kit, ctx) ? sayTimes(kit, [...words, c.ko], 0.12, Math.max(0.6, ctx.dur - 0.9)) : null;
74
+ const chipAt = words.map((_, i) => times?.[i] ?? hits.find((b) => b >= 0.12 + i * 0.3) ?? 0.2 + i * 0.3);
75
+ const koAt = c.ko && times ? times[words.length] : (chipAt[chipAt.length - 1] ?? 0.2) + 0.3;
76
+ followSpoken(kit, ctx, [...chips.map((el, i) => ({ at: chipAt[i], el })), ...(koBox ? [{ at: koAt, el: koBox }] : [])]);
66
77
  const koDur = c.ko ? clamp([...c.ko].length * 0.05, 0.25, 0.6) : 0;
67
78
  // 판이 열리는 것만으로는 밝은 바탕에서 화면이 바뀐 것으로 안 읽힌다 — 차오름을 곧바로 시작한다
68
79
  const fillFrom = 0;
69
80
  const writeKo = ko ? slots.write(ko, { ctx, text: c.ko!, at: koAt, dur: koDur, seed: `${kit.seed}:ko` }) : null;
70
81
  const lands = chips.map((ch, i) => slots.land(ch, { ctx, at: chipAt[i], seed: `${kit.seed}:c${i}` }));
82
+ // 판이 열리면 짚개가 약자부터 짚고, 머무는 테마는 목소리가 풀이·한국어 이름을 말할 때만 그리로 옮긴다 — 칠한 판이 차오르던 몫
83
+ const point = termText ? glider(host, [{ el: termText, text: c.term }, ...(row ? [{ el: row, text: words.join(" ") }] : []), ...(koBox ? [{ el: koBox, text: c.ko }] : [])], { kit, ctx, from: boardAt + 0.2 }) : null;
71
84
 
72
85
  return (t: number) => {
73
86
  moving("term-reveal.chips", ctx);
@@ -81,8 +94,9 @@ const pattern: Pattern<TermContent> = {
81
94
  // 판은 칩이 붙기 시작할 때부터 장면 끝까지 차오른다 — 용어를 짚는 몫
82
95
  const k = clamp((t - fillFrom) / Math.max(0.3, ctx.dur - fillFrom - 0.05));
83
96
  const clip = `inset(${((1 - k) * 100).toFixed(2)}% 0 0 0)`;
84
- fill.style.clipPath = clip;
85
- lit.style.clipPath = clip;
97
+ if (fill) fill.style.clipPath = clip;
98
+ if (lit) lit.style.clipPath = clip;
99
+ point?.(t);
86
100
  writeKo?.(t);
87
101
  koMark?.((t - koAt - koDur) / Math.max(0.2, ctx.dur - koAt - koDur - 0.05));
88
102
  };
@@ -1,6 +1,6 @@
1
1
  import { applyStyle, clamp, ease, progress } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, beatStops, fitOnce, glider, moving, node, slotsOf } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, dwells, fitOnce, followSpoken, glider, moving, node, sayTimes, slotsOf } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  export interface TermContent {
@@ -21,12 +21,12 @@ function spell(term: string, full: string | undefined): string[] | null {
21
21
 
22
22
  /**
23
23
  * 용어 공개 — 약자가 크게 서고, 약자 글자마다 풀이 낱말이 그 글자에서 아래로 펴져 나온다(글자 → 낱말이 같은 첫 글자로 이어진다).
24
- * 풀이가 약자와 안 맞으면 풀이 한 줄이 펴진다. 그다음 한국어 이름이 찍히고, 짚개가 약자와 풀이 낱말을 번갈아 박마다 짚는다.
24
+ * 풀이가 약자와 안 맞으면 풀이 한 줄이 펴진다. 그다음 한국어 이름이 찍히고, 짚개가 약자부터 짚는다(머무는 테마는 목소리가 풀이 낱말·이름을 말할 때만 그리로).
25
25
  */
26
26
  const pattern: Pattern<TermContent> = {
27
27
  id: "term-reveal.expand",
28
28
  type: "term-reveal",
29
- does: "약자가 서고 글자마다 풀이 낱말이 펴진다, 한국어 이름이 찍히고 짚개가 약자와 풀이를 번갈아 짚는다",
29
+ does: "약자가 서고 글자마다 풀이 낱말이 펴진다, 한국어 이름이 찍히고 짚개가 약자부터 짚는다",
30
30
  energy: ["mid"],
31
31
  family: "mask",
32
32
  camera: ["none"],
@@ -83,17 +83,21 @@ const pattern: Pattern<TermContent> = {
83
83
  // 펴기는 약자가 서자마자 낱말마다 이어서 — 사이에 빈 틈이 있으면 화면이 선 것으로 읽힌다
84
84
  const unfoldAt = termAt + 0.06 * Math.max(0, chars.length - 1) + 0.2;
85
85
  const each = parts.length > 1 ? 0.3 : 0;
86
- const unfoldEnd = unfoldAt + each * Math.max(0, parts.length - 1) + 0.35;
87
- const koAt = parts.length ? unfoldEnd : termAt + 0.2;
88
86
  const koDur = c.ko ? clamp([...c.ko].length * 0.05, 0.25, 0.6) : 0;
89
- const meanAt = koAt + koDur;
87
+ // 머무는 짚개 테마는 풀이 낱말·한국어 이름·뜻을 목소리가 그것을 말할 때 펴고 찍는다(못 들으면 약자가 선 뒤부터 장면 끝 무렵까지 박에 붙여 고르게).
88
+ // 펴지고 찍힐 때마다 카메라가 그쪽으로 다가간다. 훑는 테마는 약자가 서자마자 낱말마다 0.3초 간격으로 이어 편다
89
+ const said = dwells(kit, ctx) ? sayTimes(kit, [...parts.map((_, i) => (words ?? [fullText])[i]), c.ko, c.meaning], unfoldAt, Math.max(unfoldAt + 0.3, ctx.dur - 0.9)) : null;
90
+ const partAt = parts.map((_, i) => said?.[i] ?? unfoldAt + i * each);
91
+ const unfoldEnd = unfoldAt + each * Math.max(0, parts.length - 1) + 0.35;
92
+ const koAt = said && (parts.length || kit.say.length || kit.lines.length) ? said[parts.length] : parts.length ? unfoldEnd : termAt + 0.2;
93
+ const meanAt = Math.max(koAt + koDur, said?.[parts.length + 1] ?? 0);
94
+ followSpoken(kit, ctx, [...parts.map((p, i) => ({ at: partAt[i], el: p.box })), ...(ko ? [{ at: koAt, el: ko }] : []), ...(meaning ? [{ at: meanAt, el: meaning }] : [])]);
90
95
  const meanDur = meaning ? clamp([...c.meaning!].length * 0.035, 0.4, 1.2) : 0;
91
96
  // 짚음은 첫 낱말이 다 펴지자마자 — 펴기와 겹쳐 장면 끝까지 이어진다
92
97
  // 풀이 낱말이 없으면(약자가 아닌 용어) 펼 것이 없다 — 용어가 서자마자 짚기 시작한다
93
- const markFrom = parts.length ? unfoldAt + Math.max(each, 0.3) : termAt + 0.06 * Math.max(0, chars.length - 1) + 0.12;
94
- // 짚개 — 약자와 풀이 낱말·한국어 이름을 번갈아 짚는다(약자 → 낱말 → 약자 → 다음 낱말 …). 빠르기는 박이 정한다
95
- const point = glider(host, [termEl, ...parts.map((p) => p.box), ...(ko ? [ko] : [])], { hub: true });
96
- const stops = beatStops(kit.hits, markFrom, ctx.dur);
98
+ const markFrom = parts.length ? partAt[0] + Math.max(each, 0.3) : termAt + 0.06 * Math.max(0, chars.length - 1) + 0.12;
99
+ // 짚개 — 약자부터 짚고, 머무는 테마는 목소리가 풀이 낱말·한국어 이름(풀이·이름이 없으면 뜻)을 말할 때만 그리로 옮긴다
100
+ const point = glider(host, [{ el: termEl, text: c.term }, ...parts.map((p, i) => ({ el: p.box, text: (words ?? [fullText])[i] })), ...(ko ? [{ el: ko, text: c.ko }] : []), ...(meaning && !parts.length && !ko ? [{ el: meaning, text: c.meaning }] : [])], { kit, ctx, from: markFrom });
97
101
 
98
102
  const writeKo = ko ? slots.write(ko, { ctx, text: c.ko!, at: koAt, dur: koDur, seed: `${kit.seed}:ko` }) : null;
99
103
  const writeMeaning = meaning ? slots.write(meaning, { ctx, text: c.meaning!, at: meanAt, dur: meanDur, seed: `${kit.seed}:m` }) : null;
@@ -106,18 +110,18 @@ const pattern: Pattern<TermContent> = {
106
110
  const p = ease.outExpo(progress(t, termAt + i * 0.06, 0.3));
107
111
  applyStyle(ch, { y: (1 - p) * termPx * 0.4, opacity: p });
108
112
  // 약자 글자는 제 낱말이 펴질 때 강조색으로 — 글자와 낱말이 이어진다
109
- const k = words ? clamp((t - (unfoldAt + i * each)) / 0.2) : 0;
113
+ const k = words ? clamp((t - ((partAt[i] ?? unfoldAt))) / 0.2) : 0;
110
114
  ch.style.color = k > 0.5 ? ACCENT_TEXT : "var(--kf-ink)";
111
115
  });
112
116
  applyStyle(termEl, landed(t));
113
117
  parts.forEach((pt, i) => {
114
118
  // 고른 빠르기로 편다 — 감속 끝자락이 길면 그 사이가 멈춘 화면이 된다
115
- const p = progress(t, unfoldAt + i * each, 0.3);
119
+ const p = progress(t, (partAt[i] ?? unfoldAt), 0.3);
116
120
  pt.box.style.width = `${(p * pt.em * fullPx * 1.02).toFixed(1)}px`;
117
121
  pt.box.style.visibility = p > 0 ? "visible" : "hidden";
118
122
  });
119
123
  // 풀이를 낱말 차례로 반전해 짚는다 — 펴기가 끝난 뒤 장면 끝까지
120
- point(t, stops);
124
+ point(t);
121
125
  writeKo?.(t);
122
126
  writeMeaning?.(t);
123
127
  };
@@ -1,6 +1,6 @@
1
- import { applyStyle, clamp, spring } from "@kasaframe/core";
1
+ import { applyStyle, clamp } from "@kasaframe/core";
2
2
  import { fitSize, textEm } from "@kasaframe/motion/moves";
3
- import { ACCENT_TEXT, area, decimalsOf, fmt, moving, node, slotsOf, spaced } from "../kit.ts";
3
+ import { ACCENT_TEXT, area, decimalsOf, FILL, fillEdge, fmt, followSpoken, moving, node, ON_FILL, slotsOf, spaced } from "../kit.ts";
4
4
  import type { Pattern } from "../types.ts";
5
5
 
6
6
  export interface TimelineContent {
@@ -64,6 +64,8 @@ const pattern: Pattern<TimelineContent> = {
64
64
  ? `left:${axisX - trackT / 2}px;top:${a0}px;width:${trackT}px;height:${len}px`
65
65
  : `left:${a0}px;top:${axisY - trackT / 2}px;width:${len}px;height:${trackT}px`;
66
66
  const track = node(host, `position:absolute;${trackCss};background:var(--kf-raised);border:${Math.max(2, Math.round(2 * u))}px solid var(--kf-line);box-sizing:border-box;transform-origin:${vertical ? "50% 0" : "0 50%"}`);
67
+ const edge = Math.max(2, Math.round(3 * u));
68
+ const winPx = Math.round(chipPx * 0.95);
67
69
  const win = c.window ? (() => {
68
70
  const p0 = pos(c.window.from);
69
71
  const p1 = pos(c.window.to);
@@ -74,29 +76,72 @@ const pattern: Pattern<TimelineContent> = {
74
76
  // 세로 화면은 그 구간을 화면 폭 가득 옅게 칠한다 — 「이 시간대」가 축 옆 점선만으로는 작게 읽혔다
75
77
  if (vertical) host.prepend(node(host, `position:absolute;left:0;right:0;top:${a0 + len * p0}px;height:${len * (p1 - p0)}px;background:color-mix(in srgb, var(--kf-accent) 14%, transparent)`));
76
78
  const [lx, ly] = at((p0 + p1) / 2);
79
+ // 구간 이름은 눕히지 않는다 — 세로 화면은 오른쪽 딱지 줄에 두고 자리는 아래 줄 맞추기가 정한다
77
80
  const label = node(host, vertical
78
- ? `position:absolute;left:${Math.max(chipPx * 1.2, lx - trackT * 0.5 - readPx * 3.6)}px;top:${ly}px;transform:translate(-100%,-50%) rotate(-90deg);transform-origin:100% 50%;font-family:var(--kf-font-display);font-size:${Math.round(chipPx * 1.1)}px;font-weight:800;color:${ACCENT_TEXT};white-space:nowrap`
81
+ ? `position:absolute;left:0;top:0;font-family:var(--kf-font-display);font-size:${winPx}px;font-weight:800;line-height:1.2;color:${ACCENT_TEXT};white-space:nowrap`
79
82
  : `position:absolute;left:${lx}px;top:${ly - trackT * 1.35 - chipPx * 1.75}px;transform:translate(-50%,-100%);font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:${ACCENT_TEXT};white-space:nowrap`);
80
- return { p0, p1, outline, label };
83
+ return { p0, p1, outline, label, y: ly };
81
84
  })() : null;
82
- const done = node(track, `position:absolute;${vertical ? "left:0;right:0;top:0;height:100%" : "top:0;bottom:0;left:0;width:100%"};background:var(--kf-accent);transform-origin:${vertical ? "50% 0" : "0 50%"};transform:scale(0)`);
85
+ const done = node(track, `position:absolute;${vertical ? "left:0;right:0;top:0;height:100%" : "top:0;bottom:0;left:0;width:100%"};background:${FILL};${fillEdge(edge)};transform-origin:${vertical ? "50% 0" : "0 50%"};transform:scale(0)`);
86
+ // 축 끝 값 — 세로 화면은 왼쪽(지금 읽음값과 같은 줄, 오른쪽은 딱지 줄)
83
87
  const ends = [span.from, span.to].map((v, i) => {
84
88
  const [x, y] = at(i);
85
89
  return node(host, vertical
86
- ? `position:absolute;left:${x + trackT * 0.7}px;top:${y}px;transform:translateY(${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(Math.max(52 * u, readPx * 0.8))}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`
90
+ ? `position:absolute;left:${x - trackT / 2 - 10}px;top:${y}px;transform:translate(-100%, ${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(Math.max(44 * u, readPx * 0.8))}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`
87
91
  : `position:absolute;left:${x}px;top:${y + trackT * 0.7}px;transform:translateX(${i ? "-100%" : "0"});font-family:var(--kf-font-mono);font-size:${Math.round(readPx * 0.8)}px;color:var(--kf-dim, var(--kf-ink));white-space:nowrap`, `${fmt(v, dec)}${unit ? ` ${unit}` : ""}`);
88
92
  });
89
- const pins = marks.map((m, i) => {
90
- const [x, y] = at(pos(m.at));
91
- const r = Math.round(trackT * (m.hot ? 0.62 : 0.48));
92
- const pin = node(host, `position:absolute;left:${x - r}px;top:${y - r}px;width:${r * 2}px;height:${r * 2}px;border-radius:50%;box-sizing:border-box;border:${Math.max(3, Math.round(r * 0.22))}px solid var(--kf-ink);background:var(--kf-bg)`);
93
- const side = vertical ? 1 : i % 2 ? 1 : -1;
94
- const chip = node(host, vertical
95
- ? `position:absolute;left:${x + trackT * 1.0}px;top:${y}px;transform:translateY(-50%);padding:0.12em 0.4em;font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:var(--kf-ink);background:var(--kf-raised);white-space:nowrap;line-height:1.2`
96
- : `position:absolute;left:${x}px;top:${y + side * trackT * 1.25}px;transform:translate(-50%,${side < 0 ? "-100%" : "0"});padding:0.12em 0.4em;font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:var(--kf-ink);background:var(--kf-raised);white-space:nowrap;line-height:1.2`);
97
- return { m, pin, chip, p: pos(m.at) };
93
+
94
+ // 표시 — 축을 가로지르는 눈금 하나와 딱지를 잇는 선. 큰 동그라미는 뜻 없이 이웃 표시·딱지를 덮었다
95
+ const chipCss = `padding:0.12em 0.4em;font-family:var(--kf-font-sans);font-size:${chipPx}px;font-weight:700;color:var(--kf-ink);background:var(--kf-raised);white-space:nowrap;line-height:1.2`;
96
+ // 자리는 바깥 칸이 잡고 내려앉음(land)은 안쪽 딱지에 — land 가 transform 을 통째로 다시 쓴다
97
+ const pins = marks.map((m) => {
98
+ const anchor = node(host, "position:absolute;left:0;top:0");
99
+ return { m, p: pos(m.at), anchor, chip: node(anchor, chipCss) };
100
+ });
101
+ // 세로 화면 딱지 줄 — 바라는 자리(표시 높이·구간 가운데)에서 서로 안 겹치게 밀고, 아래로 넘치면 거꾸로 올린다
102
+ const chipX = vertical ? axisX + trackT / 2 + trackT * 0.55 : 0;
103
+ const rowGap = chipPx * 0.22;
104
+ const slots2 = [...pins.map((p) => ({ want: at(p.p)[1], h: chipPx * 1.44, el: p.anchor })), ...(vertical && win ? [{ want: win.y, h: winPx * 1.2, el: win.label }] : [])].sort((x, y) => x.want - y.want);
105
+ const ys = slots2.map((x) => x.want);
106
+ if (vertical) {
107
+ for (let i = 0; i < slots2.length; i++) ys[i] = Math.max(slots2[i].want, i ? ys[i - 1] + (slots2[i - 1].h + slots2[i].h) / 2 + rowGap : a0 + slots2[i].h / 2);
108
+ const bottom = a0 + len + chipPx;
109
+ for (let i = slots2.length - 1; i >= 0; i--) ys[i] = Math.min(ys[i], i < slots2.length - 1 ? ys[i + 1] - (slots2[i].h + slots2[i + 1].h) / 2 - rowGap : bottom - slots2[i].h / 2);
110
+ }
111
+ const yOf = new Map(slots2.map((x, i) => [x.el, ys[i]]));
112
+ const SVG = "http://www.w3.org/2000/svg";
113
+ const svg = host.ownerDocument.createElementNS(SVG, "svg");
114
+ svg.setAttribute("style", "position:absolute;left:0;top:0;width:100%;height:100%;overflow:visible;pointer-events:none");
115
+ host.append(svg);
116
+ const ticks = pins.map((p, i) => {
117
+ const [x, y] = at(p.p);
118
+ const line = host.ownerDocument.createElementNS(SVG, "polyline");
119
+ line.setAttribute("fill", "none");
120
+ line.setAttribute("stroke-width", String(Math.max(3, Math.round(4 * u))));
121
+ line.setAttribute("stroke-linejoin", "round");
122
+ if (vertical) {
123
+ const cy = yOf.get(p.anchor)!;
124
+ const elbow = chipX - trackT * 0.25;
125
+ line.setAttribute("points", `${x - trackT / 2},${y} ${elbow},${y} ${elbow},${cy} ${chipX},${cy}`);
126
+ p.anchor.style.transform = `translate(${chipX}px, ${cy.toFixed(1)}px) translateY(-50%)`;
127
+ } else {
128
+ // 가로 화면은 딱지가 위아래 번갈아 — 눈금이 축을 가로질러 딱지까지 곧게
129
+ const side = i % 2 ? 1 : -1;
130
+ const cy = y + side * trackT * 1.25;
131
+ line.setAttribute("points", `${x},${y - side * trackT / 2} ${x},${cy}`);
132
+ p.anchor.style.transform = `translate(${x}px, ${cy}px) translate(-50%, ${side < 0 ? "-100%" : "0"})`;
133
+ }
134
+ svg.append(line);
135
+ return line;
98
136
  });
99
- const head = node(host, `position:absolute;left:0;top:0;padding:0.1em 0.35em;font-family:var(--kf-font-mono);font-size:${readPx}px;font-weight:700;color:var(--kf-on-accent);background:var(--kf-accent);white-space:nowrap;font-variant-numeric:tabular-nums;visibility:hidden`);
137
+ if (vertical && win) win.label.style.transform = `translate(${chipX}px, ${yOf.get(win.label)!.toFixed(1)}px) translateY(-50%)`;
138
+ // 지금 줄 — 읽음값에서 축을 가로질러 딱지 줄 앞까지. 옅게 칠하는 테마는 지난 길이 차오르는 것만으로는 화면이 거의 안 바뀐다
139
+ const nowT = Math.max(2, Math.round(3 * u));
140
+ const nowLine = node(host, vertical
141
+ ? `position:absolute;left:${w * 0.03}px;top:0;width:${chipX - trackT * 0.25 - w * 0.03}px;height:${nowT}px;margin-top:-${nowT / 2}px;background:var(--kf-accent);visibility:hidden`
142
+ : `position:absolute;left:0;top:${axisY - trackT * 0.65}px;width:${nowT}px;height:${trackT * 1.3}px;margin-left:-${nowT / 2}px;background:var(--kf-accent);visibility:hidden`);
143
+ // 읽음값 칩은 지금 줄 위에 앉는다 — 옅은 칠도 줄이 비치지 않게 바탕 위에 겹쳐 칠한다
144
+ const head = node(host, `position:absolute;left:0;top:0;padding:0.1em 0.35em;font-family:var(--kf-font-mono);font-size:${readPx}px;font-weight:700;color:${ON_FILL};background:linear-gradient(${FILL}, ${FILL}), var(--kf-bg);${fillEdge(edge)};white-space:nowrap;font-variant-numeric:tabular-nums;visibility:hidden`);
100
145
 
101
146
  const hits = spaced(kit.hits.length ? kit.hits : [0], 0.3);
102
147
  const drawAt = 0;
@@ -107,35 +152,44 @@ const pattern: Pattern<TimelineContent> = {
107
152
 
108
153
  const writes = pins.map((p, i) => slots.write(p.chip, { ctx, text: p.m.label, at: pinAt[i], dur: 0.25, seed: `${kit.seed}:m${i}` }));
109
154
  const writeWin = win ? slots.write(win.label, { ctx, text: c.window!.label, at: walkFrom - 0.3, dur: 0.3, seed: `${kit.seed}:w` }) : null;
110
- const lands = pins.map((p, i) => slots.land(p.pin, { ctx, at: pinAt[i], seed: `${kit.seed}:p${i}` }));
155
+ const lands = pins.map((p, i) => slots.land(p.chip, { ctx, at: pinAt[i], seed: `${kit.seed}:p${i}` }));
156
+ // 머무는 짚개 테마는 지금이 표시에 닿을 때마다 카메라가 그 딱지 쪽으로 다가간다
157
+ followSpoken(kit, ctx, pins.map((p) => ({ at: walkFrom + p.p * (walkEnd - walkFrom), el: p.anchor })), { reach: 0.15, zoom: 1.03 });
111
158
 
112
159
  return (t: number) => {
113
160
  moving("timeline.axis", ctx);
114
161
  const g = clamp((t - drawAt) / 0.3);
115
162
  track.style.transform = vertical ? `scaleY(${g.toFixed(4)})` : `scaleX(${g.toFixed(4)})`;
116
- for (const e of ends) e.style.visibility = g >= 1 ? "visible" : "hidden";
117
163
  // 지금 — 고른 빠르기로 축 처음에서 끝까지
118
164
  const now = clamp((t - walkFrom) / (walkEnd - walkFrom));
119
165
  done.style.transform = vertical ? `scaleY(${now.toFixed(4)})` : `scaleX(${now.toFixed(4)})`;
120
166
  const [hx, hy] = at(now);
121
167
  head.style.visibility = t >= walkFrom ? "visible" : "hidden";
168
+ nowLine.style.visibility = head.style.visibility;
169
+ nowLine.style.transform = vertical ? `translateY(${hy.toFixed(1)}px)` : `translateX(${hx.toFixed(1)}px)`;
122
170
  head.textContent = `${fmt(span.from + (span.to - span.from) * now, dec)}${unit ? ` ${unit}` : ""}`;
123
171
  head.style.transform = vertical ? `translate(${(hx - trackT * 0.5 - 8).toFixed(1)}px, ${hy.toFixed(1)}px) translate(-100%, -50%)` : `translate(${hx.toFixed(1)}px, ${(hy - trackT * 0.5 - 8).toFixed(1)}px) translate(-50%, -100%)`;
172
+ // 세로 화면은 끝 값과 지금 읽음값이 같은 줄 — 지금이 그 끝 가까이 오면 끝 값을 비킨다(같은 값을 두 번 쓴다)
173
+ ends.forEach((e, i) => {
174
+ const near = vertical && Math.abs(hy - at(i)[1]) < readPx * 1.6 && t >= walkFrom;
175
+ e.style.visibility = g >= 1 && !near ? "visible" : "hidden";
176
+ });
124
177
  if (win) win.label.style.visibility = t >= walkFrom - 0.3 ? "visible" : "hidden";
125
178
  writeWin?.(t);
126
179
  pins.forEach((p, i) => {
127
180
  const on = t >= pinAt[i];
128
- p.pin.style.visibility = on ? "visible" : "hidden";
129
181
  p.chip.style.visibility = on ? "visible" : "hidden";
182
+ ticks[i].style.visibility = on ? "visible" : "hidden";
130
183
  if (!on) return;
131
184
  writes[i](t);
132
- applyStyle(p.pin, { scale: spring(t - pinAt[i], { from: 0.3, to: 1, stiffness: 320, damping: 14 }) * ((lands[i](t).scale as number | undefined) ?? 1) });
133
- // 지금이 지나간 표시는 켜진다 — 강조 표시(hot)는 반전 칩으로
185
+ // 지금이 지나간 표시는 눈금이 켜진다 — 강조 표시(hot)는 딱지도 칠한 칸으로
134
186
  const passed = now >= p.p - 1e-6 && t >= walkFrom;
135
- p.pin.style.background = passed ? "var(--kf-accent)" : "var(--kf-bg)";
136
- p.pin.style.borderColor = passed ? "var(--kf-accent)" : "var(--kf-ink)";
137
- p.chip.style.background = passed && p.m.hot ? "var(--kf-accent)" : "var(--kf-raised)";
138
- p.chip.style.color = passed && p.m.hot ? "var(--kf-on-accent)" : "var(--kf-ink)";
187
+ ticks[i].setAttribute("stroke", passed ? "var(--kf-accent)" : "var(--kf-dim, var(--kf-ink))");
188
+ const hot = passed && p.m.hot;
189
+ p.chip.style.background = hot ? FILL : "var(--kf-raised)";
190
+ p.chip.style.color = hot ? ON_FILL : "var(--kf-ink)";
191
+ p.chip.style.boxShadow = hot ? `inset 0 0 0 ${edge}px var(--kf-fill-line, transparent)` : "";
192
+ applyStyle(p.chip, lands[i](t));
139
193
  });
140
194
  };
141
195
  },
@@ -1,7 +1,7 @@
1
1
  import { applyStyle, clamp } from "@kasaframe/core";
2
2
  import { sweep } from "@kasaframe/motion";
3
3
  import { fitSize } from "@kasaframe/motion/moves";
4
- import { ACCENT_TEXT, area, beatStops, cameraOk, glider, node, riseName, slotsOf, moving } from "../kit.ts";
4
+ import { ACCENT_TEXT, area, beatStops, cameraOk, dwells, followSpoken, glider, moving, node, reread, riseName, sayTimes, slotsOf } from "../kit.ts";
5
5
  import type { Pattern } from "../types.ts";
6
6
 
7
7
  export interface TitleContent {
@@ -12,12 +12,12 @@ export interface TitleContent {
12
12
 
13
13
  /**
14
14
  * 이름이 글자마다 아래에서 가림막을 뚫고 올라와 서고(드러냄), 카메라가 그 이름으로 한 번 다가간다 — 다가감의 이유가 드러냄이다.
15
- * 다가가는 동안 밑줄·부제가 서고, 짚개가 이름과 작은 글을 박마다 번갈아 짚는다.
15
+ * 다가가는 동안 밑줄·부제가 서고, 작은 글이 있으면 짚개가 이름부터 짚는다(머무는 테마는 목소리가 작은 글을 말할 때만 그리로).
16
16
  */
17
17
  const pattern: Pattern<TitleContent> = {
18
18
  id: "title.mask",
19
19
  type: "title",
20
- does: "이름이 글자마다 가림막을 뚫고 서고, 다가가는 동안 짚개가 이름과 작은 글을 번갈아 짚는다",
20
+ does: "이름이 글자마다 가림막을 뚫고 서고, 다가가는 동안 짚개가 이름부터 짚는다",
21
21
  energy: ["mid"],
22
22
  family: "mask",
23
23
  camera: ["pushIn"],
@@ -58,20 +58,25 @@ const pattern: Pattern<TitleContent> = {
58
58
  const nameEnd = nameAt + each * (count - 1) + 0.3;
59
59
  const settle = hits.find((b) => b >= nameEnd - 0.05) ?? nameEnd;
60
60
  const sweepAt = settle;
61
- const subAt = sweepAt + 0.3;
61
+ // 머무는 짚개 테마는 부제를 자막·목소리가 말하는 순간에 세우고, 카메라가 이름에서 부제 쪽으로 서지 않고 다가간다 — 한 번에 길게 다가가면
62
+ // 앞뒤 느린 끝이 멈춘 화면이라 이름 하나뿐인 용어 장면이 2초 넘게 섰다. 이름 낱말을 말하는 순간에 나눠 세우면 짧은 장면은 낱말 사이가 도리어 멈춘다
63
+ const dwell = dwells(kit, ctx);
64
+ const subAt = dwell && sub ? sayTimes(kit, [c.sub], sweepAt + 0.3, Math.max(sweepAt + 0.3, ctx.dur - 1))[0] : sweepAt + 0.3;
62
65
  const subDur = Math.max(0.3, ctx.dur - subAt - 0.08);
63
- if (cameraOk(ctx)) kit.stage?.camera.pushIn(settle, { to: name, scale: 1.07, dur: Math.max(0.6, ctx.dur - settle) });
64
- // 이름이 선 뒤엔 짚개가 이름 ↔ 위 작은 글·부제를 박마다 번갈아 짚는다(카메라를 끈 영상에서도 쉬지 않는 몫, 장면이 길어도 박이 빠르기를 정한다)
65
- const point = glider(host, [name, ...(kicker ? [kicker] : []), ...(sub ? [sub] : [])], { hub: true });
66
- const stops = beatStops(kit.hits, Math.min(settle, nameAt + 0.35), ctx.dur);
66
+ if (dwell) followSpoken(kit, ctx, [{ at: settle, el: name }, ...(sub ? [{ at: subAt, el: sub }] : [])], { reach: 0.5, zoom: 1.1 });
67
+ else if (cameraOk(ctx)) kit.stage?.camera.pushIn(settle, { to: name, scale: 1.07, dur: Math.max(0.6, ctx.dur - settle) });
68
+ // 이름이 서면 짚개가 이름과 위 작은 글·부제를 짚는다(짚기 방식은 테마 짚음). 작은 글이 없고 훑는 테마면 이름 글자를 박마다 다시 짚는다
69
+ const markFrom = Math.min(settle, nameAt + 0.35);
70
+ const point = glider(host, [{ el: name, text: c.title }, ...(kicker ? [{ el: kicker, text: c.kicker }] : []), ...(sub ? [{ el: sub, text: c.sub }] : [])], { kit, ctx, from: markFrom });
71
+ const again = !kicker && !sub && !dwell ? beatStops(kit.hits, markFrom, ctx.dur) : null;
67
72
  const writeKicker = kicker ? slots.write(kicker, { ctx, text: c.kicker!, at: 0, dur: Math.min(0.35, nameEnd * 0.6), seed: `${kit.seed}:kicker` }) : null;
68
73
  const writeSub = sub ? slots.write(sub, { ctx, text: c.sub!, at: subAt, dur: subDur, seed: `${kit.seed}:sub` }) : null;
69
74
  const landed = slots.land(name, { ctx, at: nameEnd, seed: `${kit.seed}:land` });
70
75
 
71
76
  return (t: number) => {
72
77
  moving("title.mask", ctx);
73
- paintName(t, { riseAt: nameAt, each, fill: 0 });
74
- point(t, stops);
78
+ paintName(t, { riseAt: nameAt, each, fill: 0, up: again ? reread(t, again) : undefined });
79
+ point(t);
75
80
  applyStyle(name, landed(t));
76
81
 
77
82
  applyStyle(bar, sweep(t, { at: sweepAt, dur: 0.3 }));
@@ -7,7 +7,7 @@ const pattern: Pattern<TitleContent> = {
7
7
  ...endSlam,
8
8
  id: "title.slam",
9
9
  type: "title",
10
- does: "옅은 자리표 이름이 먼저 서고 드롭에 꽉 찬 이름이 쾅 내려앉는다, 부제·작은 글이 붙고 짚개가 번갈아 짚는다",
10
+ does: "옅은 자리표 이름이 먼저 서고 드롭에 꽉 찬 이름이 쾅 내려앉는다, 부제·작은 글이 붙고 짚개가 이름부터 짚는다",
11
11
  purposes: { prefer: ["promo", "launch", "ad", "mv"] },
12
12
  fits(c, info) {
13
13
  if (typeof c?.title !== "string" || !c.title.trim() || [...c.title].length > 16) return "title 이 비었거나 16자를 넘는다";
@@ -40,6 +40,7 @@ export interface Kit {
40
40
  seed: string;
41
41
  hits: number[];
42
42
  say: SpokenWord[];
43
+ lines: SpokenWord[];
43
44
  slots: ThemeSlots;
44
45
  stage: StageLike;
45
46
  policy: TextPolicy;