kasaframe 0.1.4 → 0.1.5

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 (294) hide show
  1. package/NOTICE +298 -0
  2. package/README.md +16 -3
  3. package/docs/AGENTS.md +1 -1
  4. package/package.json +1 -1
  5. package/packages/cli/src/commands/doctor.ts +15 -2
  6. package/packages/cli/src/commands/fonts.ts +118 -0
  7. package/packages/cli/src/commands/update.ts +66 -0
  8. package/packages/cli/src/fonts.ts +405 -0
  9. package/packages/cli/src/kf.ts +5 -0
  10. package/packages/cli/src/update.ts +250 -0
  11. package/packages/plan/src/asks.ts +1 -1
  12. package/packages/plan/src/themes.ts +8 -2
  13. package/packages/themes/kit/THEME-MAKE.md +2 -2
  14. package/packages/themes/kit/fonts/display.json +3517 -0
  15. package/packages/themes/kit/fonts/sans.json +5155 -0
  16. package/packages/themes/kit/fonts/serif.json +5229 -0
  17. package/packages/themes/kit/fonts.json +243 -79
  18. package/packages/themes/package.json +3 -1
  19. package/packages/themes/src/fontfile.ts +183 -0
  20. package/packages/themes/src/fonts.ts +296 -0
  21. package/packages/themes/src/index.ts +10 -2
  22. package/packages/themes/src/look.ts +34 -8
  23. package/packages/themes/src/types.ts +5 -0
  24. package/packages/themes/src/vary.ts +35 -34
  25. package/presets/kawaii-pastel.json +2 -2
  26. package/skills/kasaframe/SKILL.md +1 -0
  27. package/themes/blueprint/sample/style.json +5 -5
  28. package/themes/blueprint/style.json +5 -5
  29. package/themes/blueprint/theme.json +3 -1
  30. package/themes/blueprint/variants/deep-rose-suit.json +1 -1
  31. package/themes/blueprint/variants/navy-coral-2.json +1 -1
  32. package/themes/blueprint/variants/navy-coral-suit.json +1 -1
  33. package/themes/blueprint/variants/navy-orange-2.json +1 -1
  34. package/themes/blueprint/variants/navy-orange-suit.json +1 -1
  35. package/themes/cartography/sample/style.json +2 -2
  36. package/themes/cartography/style.json +2 -2
  37. package/themes/cartography/theme.json +3 -1
  38. package/themes/cartography/variants/deep-plum.json +1 -1
  39. package/themes/cartography/variants/frost-teal.json +1 -1
  40. package/themes/cartography/variants/navy-green.json +1 -1
  41. package/themes/circuit-board/sample/style.json +2 -2
  42. package/themes/circuit-board/style.json +2 -2
  43. package/themes/circuit-board/theme.json +3 -1
  44. package/themes/circuit-board/variants/dusk-gold-pretendard.json +1 -1
  45. package/themes/circuit-board/variants/navy-gold-grain.json +1 -1
  46. package/themes/circuit-board/variants/wine-gold-pretendard.json +1 -1
  47. package/themes/clay-toy/sample/style.json +2 -2
  48. package/themes/clay-toy/style.json +2 -2
  49. package/themes/clay-toy/theme.json +3 -1
  50. package/themes/clay-toy/variants/blush-coral-grain.json +1 -1
  51. package/themes/clay-toy/variants/chalk-teal.json +1 -1
  52. package/themes/clay-toy/variants/frost-sky.json +1 -1
  53. package/themes/clay-toy/variants/frost-teal.json +1 -1
  54. package/themes/clay-toy/variants/lilac-plum-round.json +1 -1
  55. package/themes/clay-toy/variants/lilac-plum.json +1 -1
  56. package/themes/clay-toy/variants/sage-indigo.json +1 -1
  57. package/themes/conveyor-factory/sample/style.json +3 -3
  58. package/themes/conveyor-factory/style.json +3 -3
  59. package/themes/conveyor-factory/theme.json +3 -1
  60. package/themes/conveyor-factory/variants/cocoa-gold.json +1 -1
  61. package/themes/conveyor-factory/variants/navy-gold.json +1 -1
  62. package/themes/conveyor-factory/variants/slate-amber-2.json +1 -1
  63. package/themes/conveyor-factory/variants/slate-amber-suit.json +1 -1
  64. package/themes/conveyor-factory/variants/slate-gold-bold.json +1 -1
  65. package/themes/data-journal/theme.json +3 -1
  66. package/themes/data-journal/variants/deep-coral.json +1 -1
  67. package/themes/data-journal/variants/navy-lime.json +1 -1
  68. package/themes/data-journal/variants/wine-indigo.json +1 -1
  69. package/themes/debug-hud/sample/style.json +1 -1
  70. package/themes/debug-hud/style.json +1 -1
  71. package/themes/debug-hud/theme.json +2 -1
  72. package/themes/debug-hud/variants/cocoa-teal.json +2 -2
  73. package/themes/debug-hud/variants/deep-teal-scan.json +2 -2
  74. package/themes/debug-hud/variants/dusk-plum.json +2 -2
  75. package/themes/debug-hud/variants/forest-lime.json +2 -2
  76. package/themes/debug-hud/variants/slate-green.json +2 -2
  77. package/themes/debug-hud/variants/wine-gold.json +2 -2
  78. package/themes/depth-flight/sample/style.json +1 -1
  79. package/themes/depth-flight/style.json +1 -1
  80. package/themes/depth-flight/theme.json +3 -1
  81. package/themes/depth-flight/variants/cocoa-cyan.json +1 -1
  82. package/themes/depth-flight/variants/cocoa-violet.json +1 -1
  83. package/themes/depth-flight/variants/deep-sky.json +1 -1
  84. package/themes/depth-flight/variants/forest-blue.json +1 -1
  85. package/themes/depth-flight/variants/navy-sky-grain.json +1 -1
  86. package/themes/depth-flight/variants/navy-sky.json +1 -1
  87. package/themes/depth-flight/variants/night-teal.json +1 -1
  88. package/themes/depth-flight/variants/slate-indigo.json +1 -1
  89. package/themes/depth-flight/variants/slate-violet.json +1 -1
  90. package/themes/film-reel/sample/style.json +2 -2
  91. package/themes/film-reel/style.json +2 -2
  92. package/themes/film-reel/theme.json +3 -1
  93. package/themes/film-reel/variants/chalk-pink.json +1 -1
  94. package/themes/film-reel/variants/lilac-gold.json +1 -1
  95. package/themes/film-reel/variants/lilac-violet.json +1 -1
  96. package/themes/glass-light/sample/style.json +1 -1
  97. package/themes/glass-light/style.json +1 -1
  98. package/themes/glass-light/theme.json +3 -1
  99. package/themes/glass-light/variants/blush-pink.json +1 -1
  100. package/themes/glass-light/variants/blush-plum.json +1 -1
  101. package/themes/glass-light/variants/cream-violet.json +1 -1
  102. package/themes/glass-light/variants/frost-sky-suit.json +1 -1
  103. package/themes/glass-light/variants/frost-sky.json +1 -1
  104. package/themes/glass-light/variants/sage-green.json +1 -1
  105. package/themes/ink-wash/sample/style.json +3 -3
  106. package/themes/ink-wash/style.json +3 -3
  107. package/themes/ink-wash/theme.json +3 -1
  108. package/themes/iso-diorama/sample/style.json +1 -1
  109. package/themes/iso-diorama/style.json +1 -1
  110. package/themes/iso-diorama/theme.json +3 -1
  111. package/themes/iso-diorama/variants/chalk-plum.json +1 -1
  112. package/themes/iso-diorama/variants/cocoa-indigo.json +1 -1
  113. package/themes/iso-diorama/variants/dusk-blue.json +1 -1
  114. package/themes/iso-diorama/variants/dusk-coral.json +1 -1
  115. package/themes/iso-diorama/variants/dusk-jade.json +1 -1
  116. package/themes/iso-diorama/variants/forest-cyan.json +1 -1
  117. package/themes/iso-diorama/variants/frost-green.json +1 -1
  118. package/themes/iso-diorama/variants/frost-teal.json +1 -1
  119. package/themes/iso-diorama/variants/navy-amber.json +1 -1
  120. package/themes/isotype/sample/style.json +2 -2
  121. package/themes/isotype/style.json +2 -2
  122. package/themes/isotype/theme.json +2 -1
  123. package/themes/isotype/variants/blush-rose.json +1 -1
  124. package/themes/isotype/variants/chalk-coral.json +1 -1
  125. package/themes/isotype/variants/chalk-orange.json +1 -1
  126. package/themes/isotype/variants/frost-amber.json +1 -1
  127. package/themes/isotype/variants/frost-coral.json +1 -1
  128. package/themes/isotype/variants/frost-orange.json +1 -1
  129. package/themes/isotype/variants/sand-amber.json +1 -1
  130. package/themes/kawaii-sticker/sample/style.json +2 -2
  131. package/themes/kawaii-sticker/style.json +2 -2
  132. package/themes/kawaii-sticker/theme.json +3 -1
  133. package/themes/kawaii-sticker/variants/cream-gold.json +1 -1
  134. package/themes/kawaii-sticker/variants/dusk-green.json +1 -1
  135. package/themes/kawaii-sticker/variants/dusk-indigo.json +1 -1
  136. package/themes/kawaii-sticker/variants/dusk-sky.json +1 -1
  137. package/themes/kawaii-sticker/variants/dusk-teal.json +1 -1
  138. package/themes/kawaii-sticker/variants/frost-blue.json +1 -1
  139. package/themes/kawaii-sticker/variants/frost-plum.json +1 -1
  140. package/themes/kawaii-sticker/variants/lilac-violet.json +1 -1
  141. package/themes/kawaii-sticker/variants/navy-gold.json +1 -1
  142. package/themes/kawaii-sticker/variants/navy-plum.json +1 -1
  143. package/themes/lab-notebook/sample/style.json +3 -3
  144. package/themes/lab-notebook/style.json +3 -3
  145. package/themes/lab-notebook/theme.json +2 -1
  146. package/themes/lab-notebook/variants/blush-orange.json +1 -1
  147. package/themes/lab-notebook/variants/chalk-coral.json +1 -1
  148. package/themes/lab-notebook/variants/chalk-orange.json +1 -1
  149. package/themes/lab-notebook/variants/chalk-rose.json +1 -1
  150. package/themes/lab-notebook/variants/lilac-rose.json +1 -1
  151. package/themes/lab-notebook/variants/sage-rose.json +1 -1
  152. package/themes/lab-notebook/variants/sand-orange.json +1 -1
  153. package/themes/lab-notebook/variants/snow-orange.json +1 -1
  154. package/themes/lab-notebook/variants/snow-rose.json +1 -1
  155. package/themes/manga-panel/sample/style.json +3 -3
  156. package/themes/manga-panel/style.json +3 -3
  157. package/themes/manga-panel/theme.json +3 -1
  158. package/themes/manga-panel/variants/chalk-cyan-dots.json +1 -1
  159. package/themes/manga-panel/variants/chalk-rose.json +1 -1
  160. package/themes/manga-panel/variants/snow-cyan.json +1 -1
  161. package/themes/manga-panel/variants/snow-magenta-dots.json +1 -1
  162. package/themes/marble-run/sample/style.json +2 -2
  163. package/themes/marble-run/style.json +2 -2
  164. package/themes/marble-run/theme.json +3 -1
  165. package/themes/marble-run/variants/slate-orange.json +1 -1
  166. package/themes/neon-sign/sample/style.json +2 -2
  167. package/themes/neon-sign/style.json +2 -2
  168. package/themes/neon-sign/theme.json +2 -1
  169. package/themes/neon-sign/variants/dusk-plum.json +1 -1
  170. package/themes/neon-sign/variants/forest-magenta.json +1 -1
  171. package/themes/neon-sign/variants/night-pink.json +1 -1
  172. package/themes/neon-sign/variants/night-violet.json +1 -1
  173. package/themes/neon-sign/variants/slate-violet.json +1 -1
  174. package/themes/newspaper/sample/style.json +2 -2
  175. package/themes/newspaper/style.json +2 -2
  176. package/themes/newspaper/theme.json +2 -1
  177. package/themes/newspaper/variants/blush-amber.json +1 -1
  178. package/themes/newspaper/variants/chalk-magenta.json +1 -1
  179. package/themes/newspaper/variants/frost-sky.json +1 -1
  180. package/themes/newspaper/variants/lilac-green.json +1 -1
  181. package/themes/newspaper/variants/sage-lime.json +1 -1
  182. package/themes/og-measure/THEME.md +32 -6
  183. package/themes/og-measure/effects.ts +219 -115
  184. package/themes/og-measure/sample/assets/brand/og-logo.svg +11 -11
  185. package/themes/og-measure/sample/lib/data.ts +6 -0
  186. package/themes/og-measure/sample/scenes/end.ts +14 -10
  187. package/themes/og-measure/sample/scenes/gather.ts +12 -1
  188. package/themes/og-measure/sample/scenes/models.ts +13 -2
  189. package/themes/og-measure/sample/scenes/one.ts +8 -6
  190. package/themes/og-measure/sample/scenes/price.ts +20 -3
  191. package/themes/og-measure/sample/style.json +30 -35
  192. package/themes/og-measure/slots.ts +25 -30
  193. package/themes/og-measure/still.png +0 -0
  194. package/themes/og-measure/style.json +30 -35
  195. package/themes/og-measure/theme.json +64 -49
  196. package/themes/og-measure/variants/snow-blue.json +90 -0
  197. package/themes/orbit-system/sample/style.json +3 -3
  198. package/themes/orbit-system/style.json +3 -3
  199. package/themes/orbit-system/theme.json +3 -1
  200. package/themes/orbit-system/variants/cocoa-amber.json +1 -1
  201. package/themes/orbit-system/variants/cocoa-cyan.json +1 -1
  202. package/themes/orbit-system/variants/dusk-magenta.json +1 -1
  203. package/themes/orbit-system/variants/dusk-violet.json +1 -1
  204. package/themes/orbit-system/variants/forest-coral.json +1 -1
  205. package/themes/orbit-system/variants/wine-amber.json +1 -1
  206. package/themes/orbit-system/variants/wine-magenta.json +1 -1
  207. package/themes/origami-fold/sample/style.json +1 -1
  208. package/themes/origami-fold/style.json +1 -1
  209. package/themes/origami-fold/theme.json +3 -1
  210. package/themes/origami-fold/variants/deep-amber-sunflower.json +1 -1
  211. package/themes/origami-fold/variants/deep-orange.json +1 -1
  212. package/themes/origami-fold/variants/forest-coral.json +1 -1
  213. package/themes/origami-fold/variants/forest-pink.json +1 -1
  214. package/themes/origami-fold/variants/frost-amber-paper.json +1 -1
  215. package/themes/origami-fold/variants/sage-coral-sunflower.json +1 -1
  216. package/themes/origami-fold/variants/sage-coral.json +1 -1
  217. package/themes/origami-fold/variants/sage-pink-round.json +1 -1
  218. package/themes/origami-fold/variants/sage-pink.json +1 -1
  219. package/themes/paper-cutout/sample/style.json +2 -2
  220. package/themes/paper-cutout/style.json +2 -2
  221. package/themes/paper-cutout/theme.json +3 -1
  222. package/themes/paper-cutout/variants/blush-violet.json +1 -1
  223. package/themes/paper-cutout/variants/frost-lime.json +1 -1
  224. package/themes/paper-cutout/variants/lilac-gold.json +1 -1
  225. package/themes/paper-cutout/variants/sage-cyan.json +1 -1
  226. package/themes/paper-cutout/variants/sage-green.json +1 -1
  227. package/themes/paper-cutout/variants/sage-pink.json +1 -1
  228. package/themes/paper-cutout/variants/sand-blue.json +1 -1
  229. package/themes/pixel-quest/theme.json +2 -1
  230. package/themes/receipt/sample/style.json +2 -2
  231. package/themes/receipt/style.json +2 -2
  232. package/themes/riso-zine/sample/style.json +2 -2
  233. package/themes/riso-zine/style.json +2 -2
  234. package/themes/riso-zine/theme.json +3 -1
  235. package/themes/riso-zine/variants/chalk-lime.json +1 -1
  236. package/themes/riso-zine/variants/frost-green.json +1 -1
  237. package/themes/riso-zine/variants/lilac-plum.json +1 -1
  238. package/themes/riso-zine/variants/lilac-violet.json +1 -1
  239. package/themes/server-hall/sample/style.json +2 -2
  240. package/themes/server-hall/style.json +2 -2
  241. package/themes/server-hall/theme.json +3 -1
  242. package/themes/server-hall/variants/night-indigo.json +1 -1
  243. package/themes/server-hall/variants/wine-plum.json +1 -1
  244. package/themes/subway-map/sample/style.json +2 -2
  245. package/themes/subway-map/style.json +2 -2
  246. package/themes/subway-map/theme.json +3 -1
  247. package/themes/subway-map/variants/deep-green.json +1 -1
  248. package/themes/subway-map/variants/deep-teal.json +1 -1
  249. package/themes/subway-map/variants/frost-jade.json +1 -1
  250. package/themes/subway-map/variants/frost-teal.json +1 -1
  251. package/themes/subway-map/variants/slate-green.json +1 -1
  252. package/themes/subway-map/variants/snow-jade.json +1 -1
  253. package/themes/swiss-poster/sample/style.json +2 -2
  254. package/themes/swiss-poster/style.json +2 -2
  255. package/themes/swiss-poster/theme.json +3 -1
  256. package/themes/swiss-poster/variants/chalk-plum.json +1 -1
  257. package/themes/swiss-poster/variants/cocoa-cyan.json +1 -1
  258. package/themes/swiss-poster/variants/dusk-magenta.json +1 -1
  259. package/themes/swiss-poster/variants/frost-indigo.json +1 -1
  260. package/themes/swiss-poster/variants/slate-orange.json +1 -1
  261. package/themes/water-works/sample/style.json +1 -1
  262. package/themes/water-works/style.json +1 -1
  263. package/themes/water-works/theme.json +3 -1
  264. package/themes/water-works/variants/chalk-cyan.json +1 -1
  265. package/themes/water-works/variants/frost-indigo.json +1 -1
  266. package/themes/water-works/variants/sand-amber.json +1 -1
  267. package/themes/water-works/variants/sand-plum.json +1 -1
  268. package/themes/whiteboard/sample/style.json +2 -2
  269. package/themes/whiteboard/style.json +2 -2
  270. package/themes/whiteboard/theme.json +3 -1
  271. package/themes/whiteboard/variants/chalk-coral.json +1 -1
  272. package/themes/whiteboard/variants/cream-lime.json +1 -1
  273. package/themes/whiteboard/variants/frost-teal.json +1 -1
  274. package/themes/whiteboard/variants/lilac-indigo.json +1 -1
  275. package/themes/whiteboard/variants/sage-plum.json +1 -1
  276. package/themes/whiteboard/variants/snow-cyan.json +1 -1
  277. package/themes/whiteboard/variants/snow-green.json +1 -1
  278. package/themes/whiteboard/variants/snow-magenta.json +1 -1
  279. package/themes/og-measure/sample/assets/brand/og-logo-full.svg +0 -12
  280. package/themes/og-measure/variants/chalk-orange.json +0 -102
  281. package/themes/og-measure/variants/cocoa-amber.json +0 -138
  282. package/themes/og-measure/variants/cocoa-cyan.json +0 -138
  283. package/themes/og-measure/variants/cocoa-gold.json +0 -129
  284. package/themes/og-measure/variants/cream-lime.json +0 -137
  285. package/themes/og-measure/variants/deep-jade.json +0 -100
  286. package/themes/og-measure/variants/dusk-violet.json +0 -133
  287. package/themes/og-measure/variants/forest-cyan.json +0 -123
  288. package/themes/og-measure/variants/frost-amber.json +0 -129
  289. package/themes/og-measure/variants/frost-cyan.json +0 -133
  290. package/themes/og-measure/variants/lilac-plum.json +0 -127
  291. package/themes/og-measure/variants/lilac-violet.json +0 -139
  292. package/themes/og-measure/variants/sage-green.json +0 -137
  293. package/themes/og-measure/variants/wine-jade.json +0 -127
  294. package/themes/og-measure/variants/wine-plum.json +0 -127
@@ -1,20 +1,29 @@
1
- import { applyStyle, clamp, emit, ease, progress, safeTime, type Style } from "@kasaframe/core";
1
+ import { applyStyle, bezier, clamp, emit, ease, progress, safeTime, type Style } from "@kasaframe/core";
2
2
  import { typeText } from "@kasaframe/motion";
3
3
 
4
4
  // 비유: 계측기 화면. 실제 호출이 한 줄 나가고, 원자료 로그가 흐르고, 거기서 숫자가 뽑혀 점·막대·큰 숫자로 쌓인다.
5
5
  // 화면의 숫자는 늘 재 온 값(measure.json 같은 원자료)에서 온다 — 효과는 숫자를 지어내지 않고, 들어온 시각에 보여 줄 뿐이다.
6
- // 화면은 0.1초도 쉬지 않는다 — 카메라는 늘 조금씩 다가가며 흐르고, 박마다 글자·줄·점·막대 중 하나가 크게 움직인다.
6
+ // 겉모습은 OpenGateway 스타일가이드(design.md·globals.css)를 따른다: 상자에 가두지 않고, 칩은 속을 칠하지 않으며,
7
+ // 모서리는 역할(알약·면 12px·각짐)이고, 움직임은 150/220/300ms 감속·스프링과 6px 나타남뿐이다(커졌다 줄기·흔들기 없음).
7
8
 
8
9
  const MONO = "var(--kf-font-mono)";
9
- const BAR = "rgba(100, 116, 139, 0.28)";
10
- const snappy = ease.snappy;
10
+ const BAR = "var(--kf-bar)";
11
+ // globals.css --motion-fast/base/slow·--motion-rise·--ease-out·--ease-spring
12
+ const FAST = 0.15;
13
+ const BASE = 0.22;
14
+ const SLOW = 0.3;
15
+ const RISE = 6;
16
+ const out = ease.snappy;
17
+ const springy = bezier(0.34, 1.3, 0.64, 1);
11
18
 
12
- // OG 움직임 결: 220ms 감속 + 진입 거리
13
- function rise(t: number, at: number, dur = 0.22, px = 14): Style {
14
- const p = snappy(progress(t, at, dur));
19
+ function rise(t: number, at: number, dur = BASE, px = RISE): Style {
20
+ const p = out(progress(t, at, dur));
15
21
  return { opacity: p, y: px * (1 - p) };
16
22
  }
17
23
 
24
+ // 마스터 로고 원문은 루트 <svg> 에 width·height(1140×650)가 박혀 있다 — 루트에서만 걷고 상자를 채우게(안쪽 rect 의 width 는 그대로)
25
+ const fitSvg = (svg: string) => svg.replace(/<svg\b[^>]*>/, (tag) => tag.replace(/\s(width|height)="[^"]*"/g, "").replace("<svg", '<svg width="100%" height="100%"'));
26
+
18
27
  function box(doc: Document, css: string, text = ""): HTMLElement {
19
28
  const d = doc.createElement("div");
20
29
  d.style.cssText = css;
@@ -22,27 +31,33 @@ function box(doc: Document, css: string, text = ""): HTMLElement {
22
31
  return d;
23
32
  }
24
33
 
34
+ // 배지 — 흐린 테두리와 글자색만(badge.tsx). 화면 글이 UI 의 서너 배라 선도 그만큼 굵게
35
+ type Tone = "accent" | "plain" | "soft" | "success";
36
+ const edge = (size: number) => Math.max(2, Math.round(size * 0.05));
37
+ function badgeCss(tone: Tone, size: number): string {
38
+ const line = tone === "accent" ? "color-mix(in srgb, var(--kf-accent) 45%, transparent)" : tone === "success" ? "color-mix(in srgb, var(--kf-success) 45%, transparent)" : "var(--kf-line)";
39
+ const ink = tone === "accent" ? "var(--kf-accent-text)" : tone === "success" ? "var(--kf-success)" : tone === "soft" ? "var(--kf-dim)" : "var(--kf-ink)";
40
+ return `border:${edge(size)}px solid ${line};border-radius:var(--kf-radius-pill);background:transparent;color:${ink}`;
41
+ }
42
+
25
43
  /**
26
- * 측정 바탕 — 흰 모눈이 천천히 흐르고 옅은 파란 주사선이 반 주기 간격으로 둘 지나간다. 주사선은 카드 위로도 지나간다 —
27
- * 카메라가 서 있는 사이 화면을 꽉 채운 카드 밑에 숨으면 박 사이가 멈춘 프레임이 된다. 구도는 안 바꾼다
44
+ * 측정 바탕 — 바탕색 위에 구분선 색의 옅은 모눈이 천천히 흐른다. 그라데이션·빛 번짐 없이 모눈 하나뿐이고 구도는 안 바꾼다.
45
+ * 박 사이를 메우는 몫은 바탕이 아니라 내용(카운터·로그·막대·읽는 칸)이 진다
28
46
  */
29
- export function graphPaper(el: HTMLElement, o: { cell?: number; period?: number; phase?: number } = {}): (t: number) => void {
47
+ export function graphPaper(el: HTMLElement, o: { cell?: number; phase?: number } = {}): (t: number) => void {
30
48
  const doc = el.ownerDocument;
31
49
  const cell = o.cell ?? 56;
50
+ const grid = "color-mix(in srgb, var(--kf-line) 45%, transparent)";
32
51
  const bg = box(
33
52
  doc,
34
53
  `position:absolute;inset:0;background:var(--kf-bg);overflow:hidden;` +
35
- `background-image:linear-gradient(rgba(203,213,225,.55) 1px,transparent 1px),linear-gradient(90deg,rgba(203,213,225,.55) 1px,transparent 1px);` +
54
+ `background-image:linear-gradient(${grid} 1px,transparent 1px),linear-gradient(90deg,${grid} 1px,transparent 1px);` +
36
55
  `background-size:${cell}px ${cell}px`,
37
56
  );
38
- const scans = [0, 0.5].map((k) => ({ k, el: box(doc, "position:absolute;top:0;bottom:0;width:120px;z-index:5;pointer-events:none;background:linear-gradient(90deg,transparent,rgba(0,111,214,.07),transparent)") }));
39
- for (const s of scans) bg.append(s.el);
40
57
  el.append(bg);
41
- const period = o.period ?? 4;
42
58
  return (t: number) => {
43
59
  const u = safeTime(t) + (o.phase ?? 0);
44
60
  bg.style.backgroundPosition = `${(u * 18) % cell}px ${(u * 9) % cell}px`;
45
- for (const s of scans) s.el.style.transform = `translateX(${((((u / period + s.k) % 1) + 1) % 1) * ((el.clientWidth || 1920) + 240) - 120}px)`;
46
61
  };
47
62
  }
48
63
 
@@ -52,47 +67,40 @@ export interface CamShot {
52
67
  x?: number;
53
68
  y?: number;
54
69
  zoom?: number;
55
- /** 샷이 바뀌는 순간 배율을 이만큼 더했다 걷는다(툭) */
70
+ /** 샷이 서는 순간 이만큼 가까이에서 시작해 300ms 감속으로 물러나 앉는다 */
56
71
  kick?: number;
57
72
  }
58
73
 
59
74
  /**
60
- * 계측 카메라 — 샷이 바뀌면 220ms 감속으로 옮기며 툭(kick) 치고, 샷 사이에는 선다. 샷은 드러냄·결과처럼 이유가 있을 때만 —
61
- * 쉬지 않는 몫은 카운터·로그 한 줄·막대가 진다(늘 흐르는 카메라는 kf check camera.wander). drift·pan 을 주면 샷 사이에도 흐르지만 이벤트가 안 남는다.
75
+ * 계측 카메라 — 샷이 바뀌면 300ms(자리 옮김) 스프링으로 옮기고 샷 사이에는 선다. 스프링이 살짝 넘었다 돌아오는 것이 「툭」이다.
76
+ * 샷은 드러냄·결과처럼 이유가 있을 때만 — 쉬지 않는 몫은 카운터·로그 한 줄·막대가 진다(늘 흐르는 카메라는 kf check camera.wander).
62
77
  * 긴 로그 줄이 반쯤 잘리지 않게 크게 들어가지 않는다(배율 1–1.1). 무대 상자(transform-origin 0 0)에 붙인다.
63
78
  */
64
- export function trackCam(t: number, o: { shots: CamShot[]; drift?: number; pan?: number; w?: number; h?: number; scene?: string }): Style {
79
+ export function trackCam(t: number, o: { shots: CamShot[]; w?: number; h?: number; scene?: string }): Style {
65
80
  t = safeTime(t);
66
81
  const W = o.w ?? 1920;
67
82
  const H = o.h ?? 1080;
68
- const drift = o.drift ?? 0;
69
- const pan = o.pan ?? 0;
70
83
  const shots = o.shots.length ? o.shots : [{ at: 0 }];
71
- const frame = (i: number, u: number) => {
72
- const s = shots[i];
73
- const e = Math.max(0, u - s.at);
74
- const dir = i % 2 ? -1 : 1;
75
- return { x: (s.x ?? W / 2) + dir * pan * e, y: (s.y ?? H / 2) + pan * 0.4 * e, z: (s.zoom ?? 1) * (1 + drift * e) };
76
- };
84
+ const frame = (i: number) => ({ x: shots[i].x ?? W / 2, y: shots[i].y ?? H / 2, z: shots[i].zoom ?? 1 });
77
85
  let i = 0;
78
86
  for (let k = 0; k < shots.length; k++) if (shots[k].at <= t) i = k;
79
- let f = frame(i, t);
87
+ let f = frame(i);
80
88
  const since = t - shots[i].at;
81
- if (i > 0 && since < 0.22) {
82
- const p = snappy(since / 0.22);
83
- const a = frame(i - 1, shots[i].at);
89
+ if (i > 0 && since < SLOW) {
90
+ const p = springy(since / SLOW);
91
+ const a = frame(i - 1);
84
92
  f = { x: a.x + (f.x - a.x) * p, y: a.y + (f.y - a.y) * p, z: a.z + (f.z - a.z) * p };
85
- emit({ kind: "camera", source: "trackCam", scene: o.scene, value: Math.round((1 - p) * 1000) });
93
+ emit({ kind: "camera", source: "trackCam", scene: o.scene, value: Math.round((1 - clamp(since / SLOW)) * 1000) });
86
94
  }
87
- const kick = since >= 0 ? (shots[i].kick ?? 0) * Math.exp(-since / 0.12) : 0;
88
- if (since >= 0.22 && kick > 0.0005) emit({ kind: "camera", source: "trackCam", scene: o.scene, value: Math.round(kick * 1000) });
95
+ const kick = since >= 0 && since < SLOW ? (shots[i].kick ?? 0) * (1 - out(since / SLOW)) : 0;
96
+ if (kick > 0.0005) emit({ kind: "camera", source: "trackCam", scene: o.scene, value: Math.round(kick * 1000) });
89
97
  const z = f.z * (1 + kick);
90
98
  return { x: W / 2 - z * f.x, y: H / 2 - z * f.y, scale: z };
91
99
  }
92
100
 
93
101
  /**
94
- * 실제 호출 한 줄 — 주소가 박마다 한 조각씩 붙고, 모델·보낼 글이 찍히고, sendAt 부터 경과 시간이 실제 속도로 굴러
95
- * 잰 값(ms)에 멈추며 상태·첫 토큰·받은 글이 붙는다. 카운터는 지어낸 값이 아니라 원자료의 ms 까지만 센다.
102
+ * 실제 호출 한 줄 — 응답 패널(틀이 곧 뜻인 면, 12px). 주소가 박마다 한 조각씩 붙고, 모델·보낼 글이 찍히고, sendAt 부터 경과 시간이
103
+ * 실제 속도로 굴러 잰 값(ms)에 멈추며 상태·첫 토큰·받은 글이 붙는다. 카운터는 지어낸 값이 아니라 원자료의 ms 까지만 센다.
96
104
  */
97
105
  export function callLine(
98
106
  el: HTMLElement,
@@ -117,13 +125,13 @@ export function callLine(
117
125
  method?: string;
118
126
  size?: number;
119
127
  },
120
- ): (t: number) => void {
128
+ ): ((t: number) => void) & { parts: HTMLElement[]; timer: HTMLElement } {
121
129
  const doc = el.ownerDocument;
122
130
  const size = o.size ?? 56;
123
- const card = box(doc, `position:absolute;left:${o.x}px;top:${o.y}px;width:${o.w}px;box-sizing:border-box;padding:${size * 0.7}px ${size * 0.85}px;border-radius:12px;background:var(--kf-surface);font-family:${MONO};font-size:${size}px;line-height:1.45;color:var(--kf-ink);font-variant-numeric:tabular-nums`);
131
+ const card = box(doc, `position:absolute;left:${o.x}px;top:${o.y}px;width:${o.w}px;box-sizing:border-box;padding:${size * 0.7}px ${size * 0.85}px;border-radius:var(--kf-radius-md);background:var(--kf-surface);font-family:${MONO};font-size:${size}px;line-height:1.45;color:var(--kf-ink);font-variant-numeric:tabular-nums`);
124
132
  const row = (css = "") => box(doc, `display:flex;align-items:center;gap:${size * 0.35}px;white-space:nowrap;${css}`);
125
133
  const r1 = row();
126
- const chip = box(doc, `padding:0 ${size * 0.3}px;border-radius:999px;background:var(--kf-accent);color:var(--kf-on-accent);font-size:${size * 0.8}px;font-weight:700`, o.method ?? "POST");
134
+ const chip = box(doc, `padding:0 ${size * 0.3}px;font-size:${size * 0.8}px;font-weight:600;line-height:1.3;${badgeCss("accent", size * 0.8)}`, o.method ?? "POST");
127
135
  const urlBox = box(doc, "display:flex");
128
136
  const parts = o.url.map((u) => {
129
137
  const s = doc.createElement("span");
@@ -136,9 +144,9 @@ export function callLine(
136
144
  const r2 = row("color:var(--kf-dim)");
137
145
  const r3 = row("color:var(--kf-dim)");
138
146
  const r4 = row(`margin-top:${size * 0.4}px`);
139
- const timer = box(doc, `font-family:var(--kf-font-display);font-weight:800;font-size:${size * 1.9}px;letter-spacing:-0.03em;line-height:1;transform-origin:0 50%`);
140
- const ok = box(doc, `padding:0 ${size * 0.3}px;border-radius:999px;background:var(--kf-success);color:var(--kf-ink);font-size:${size * 0.8}px;font-weight:700`, String(o.status ?? ""));
141
- const what = box(doc, "color:var(--kf-accent-text);font-weight:700", "첫 토큰");
147
+ const timer = box(doc, `font-family:var(--kf-font-display);font-weight:800;font-size:${size * 1.9}px;letter-spacing:-0.055em;line-height:1`);
148
+ const ok = box(doc, `padding:0 ${size * 0.3}px;font-size:${size * 0.8}px;font-weight:600;line-height:1.3;${badgeCss("success", size * 0.8)}`, String(o.status ?? ""));
149
+ const what = box(doc, "color:var(--kf-accent-text);font-weight:600", "첫 토큰");
142
150
  const ans = box(doc, "color:var(--kf-ink)");
143
151
  r4.append(timer, ok, what, ans);
144
152
  const r5 = row(`color:var(--kf-dim);font-size:${size * 0.7}px`);
@@ -146,12 +154,12 @@ export function callLine(
146
154
  el.append(card);
147
155
  const ms = o.ms ?? 0;
148
156
  const doneAt = o.sendAt === undefined ? Infinity : o.sendAt + ms / 1000;
149
- return (t: number) => {
150
- applyStyle(card, rise(t, o.at, 0.22, 40));
157
+ const paint = (t: number) => {
158
+ applyStyle(card, rise(t, o.at));
151
159
  parts.forEach((s, i) => {
152
160
  const at = o.urlAt[i] ?? o.at;
153
161
  s.style.visibility = t >= at ? "visible" : "hidden";
154
- applyStyle(s, t >= at ? { ...rise(t, at, 0.18, 0), scale: 1 + 0.25 * (1 - snappy(progress(t, at, 0.18))) } : {});
162
+ applyStyle(s, t >= at ? rise(t, at) : {});
155
163
  });
156
164
  r2.textContent = o.model ? typeText(`model: ${o.model}`, t, { at: o.modelAt ?? o.at, cps: 90, jitter: 0, ime: false }).text : "";
157
165
  r2.style.display = o.model ? "" : "none";
@@ -161,21 +169,24 @@ export function callLine(
161
169
  r4.style.display = o.sendAt === undefined ? "none" : "";
162
170
  r4.style.visibility = o.sendAt !== undefined && t >= o.sendAt ? "visible" : "hidden";
163
171
  timer.textContent = o.sendAt === undefined ? "" : `${Math.min(ms, Math.max(0, Math.floor((t - o.sendAt) * 1000)))}ms`;
164
- timer.style.color = done ? "var(--kf-accent-text)" : "var(--kf-ink)";
172
+ // 색만 150ms 로 바뀐다 — 크기는 그대로(누름·도착에 커졌다 줄기 없음)
173
+ const c = done ? Math.round(out(progress(t, doneAt, FAST)) * 100) : 0;
174
+ timer.style.color = `color-mix(in srgb, var(--kf-accent-text) ${c}%, var(--kf-ink))`;
165
175
  for (const e of [ok, what, ans]) {
166
176
  e.style.display = done ? "" : "none";
167
177
  applyStyle(e, rise(t, doneAt));
168
178
  }
169
- applyStyle(timer, done ? { scale: 1 + 0.12 * Math.exp(-(t - doneAt) / 0.1) } : {});
170
179
  ans.textContent = done ? typeText(o.answer ?? "", t, { at: doneAt + 0.08, cps: 22, jitter: 0, ime: false }).text : "";
171
180
  r5.textContent = o.usage && o.usageAt !== undefined && t >= o.usageAt ? o.usage : "";
172
181
  r5.style.display = o.usage ? "" : "none";
173
182
  applyStyle(r5, o.usageAt !== undefined ? rise(t, o.usageAt) : {});
174
183
  };
184
+ return Object.assign(paint, { parts, timer });
175
185
  }
176
186
 
177
187
  /**
178
- * 원자료 로그 — 줄이 들어온 시각에 맨 아래 붙고 한 줄씩 위로 밀려 오른다. pick 정규식이 잡은 숫자는 그 시각부터 파란 칸.
188
+ * 원자료 로그 — 코드 블록(틀이 곧 뜻인 면, 12px). 줄이 들어온 시각에 맨 아래 붙고 한 줄씩 위로 밀려 오른다.
189
+ * pick 정규식이 잡은 숫자는 그 시각부터 고름 칠(파랑 /10 알약)과 파란 글자.
179
190
  * 줄 자리는 size·lineHeight·pad 로 정해지니 pickFly 출발점을 장면이 같은 값으로 셀 수 있다(새 줄 = 맨 아래 칸).
180
191
  */
181
192
  export function rawLog(
@@ -187,7 +198,7 @@ export function rawLog(
187
198
  const lh = o.lineHeight ?? Math.round(size * 1.5);
188
199
  const rows = o.rows ?? 9;
189
200
  const pad = o.pad ?? 28;
190
- const panel = box(doc, `position:absolute;left:${o.x}px;top:${o.y}px;width:${o.w}px;box-sizing:border-box;padding:${pad}px;border-radius:12px;background:var(--kf-surface);font-family:${MONO};font-size:${size}px;line-height:${lh}px;color:var(--kf-ink-soft);font-variant-numeric:tabular-nums`);
201
+ const panel = box(doc, `position:absolute;left:${o.x}px;top:${o.y}px;width:${o.w}px;box-sizing:border-box;padding:${pad}px;border-radius:var(--kf-radius-md);background:var(--kf-surface);font-family:${MONO};font-size:${size}px;line-height:${lh}px;color:var(--kf-ink-soft);font-variant-numeric:tabular-nums`);
191
202
  // 밀려 오르는 줄이 안쪽 여백에 반쯤 걸쳐 보이지 않게 줄 칸만큼만 보이는 창을 따로 둔다
192
203
  const view = box(doc, `position:relative;height:${rows * lh}px;overflow:hidden`);
193
204
  const strip = box(doc, "position:relative");
@@ -203,28 +214,28 @@ export function rawLog(
203
214
  const start = m.index + m[0].indexOf(g);
204
215
  num = doc.createElement("span");
205
216
  num.textContent = g;
206
- num.style.cssText = "border-radius:6px;padding:0 4px;margin:0 -4px";
217
+ num.style.cssText = "border-radius:var(--kf-radius-pill);padding:0 0.3em;margin:0 -0.3em";
207
218
  row.append(doc.createTextNode(line.slice(0, start)), num, doc.createTextNode(line.slice(start + g.length)));
208
219
  } else row.textContent = line;
209
220
  strip.append(row);
210
221
  return { row, num };
211
222
  });
212
223
  return (t: number) => {
213
- applyStyle(panel, rise(t, o.at ?? 0, 0.22, 40));
224
+ applyStyle(panel, rise(t, o.at ?? 0));
214
225
  let shown = 0;
215
226
  let slide = 1;
216
227
  o.times.forEach((at, i) => {
217
228
  if (t >= at) {
218
229
  shown = i + 1;
219
- slide = snappy(progress(t, at, 0.2));
230
+ slide = out(progress(t, at, BASE));
220
231
  }
221
- applyStyle(items[i].row, t >= at ? rise(t, at, 0.2, 0) : { opacity: 0 });
232
+ applyStyle(items[i].row, t >= at ? rise(t, at, BASE, 0) : { opacity: 0 });
222
233
  const n = items[i].num;
223
234
  if (n) {
224
235
  const on = t >= at;
225
- n.style.background = on ? "rgba(0,111,214,.16)" : "transparent";
236
+ n.style.background = on ? "var(--kf-tint)" : "transparent";
226
237
  n.style.color = on ? "var(--kf-accent-text)" : "";
227
- n.style.fontWeight = on ? "700" : "";
238
+ n.style.fontWeight = on ? "600" : "";
228
239
  }
229
240
  });
230
241
  const over = Math.max(0, shown - rows);
@@ -232,37 +243,130 @@ export function rawLog(
232
243
  };
233
244
  }
234
245
 
235
- /** 뽑힌 숫자 — 로그 줄의 숫자가 그 자리에서 떠올라 호를 그리며 점 그림 자리로 날아가 꽂힌다(박마다 큰 움직임) */
246
+ type Pt = { x: number; y: number };
247
+ /** 읽는 칸이 닿을 자리 — 무대 좌표 가운데(x·y)와 크기, 아니면 target 요소(효과가 돌려준 .el)를 그 자리에서 잰다 */
248
+ export interface ReadStop {
249
+ at: number;
250
+ x?: number;
251
+ y?: number;
252
+ w?: number;
253
+ h?: number;
254
+ target?: HTMLElement | null;
255
+ pad?: number;
256
+ }
257
+ type Box = Pt & { w: number; h: number };
258
+
259
+ // SlidingPillNav 처럼 offsetTop·offsetLeft 를 무대까지 더해 잰다 — 나타남의 6px 올라옴(transform)은 자리에 넣지 않는다
260
+ function measure(target: HTMLElement, stage: HTMLElement, pad: number): Box | null {
261
+ let x = 0;
262
+ let y = 0;
263
+ let n: HTMLElement | null = target;
264
+ for (; n && n !== stage; n = n.offsetParent as HTMLElement | null) {
265
+ x += n.offsetLeft;
266
+ y += n.offsetTop;
267
+ }
268
+ if (n !== stage || !target.offsetWidth) return null;
269
+ return { x: x + target.offsetWidth / 2, y: y + target.offsetHeight / 2, w: target.offsetWidth + pad * 2, h: target.offsetHeight + pad * 2 };
270
+ }
271
+
272
+ /**
273
+ * 뽑힌 숫자·읽는 칸 — 고름 칠(파랑 /20, 정본의 bg-primary/20) 알약에 스타일가이드의 미끄러지는 알약 테두리(SlidingPillNav, 화면 글 크기에 맞춰 파랑 /55)를 두른 한 장.
274
+ * from·to 를 주면 로그 줄의 숫자가 알약째 떠올라 호를 그리며 점 그림 자리로 300ms 감속으로 날아가 꽂힌다(박마다 큰 움직임).
275
+ * stops 를 주면 지금 재는 값 위로 옮겨 다니며 짚는다 — 메뉴의 알약처럼 값이 나타나 선 뒤(lag, 기본 220ms)에 그 값에 닿고, 그 사이엔 서지 않으며(가까우면 옆으로 휘어 길을 늘린다),
276
+ * 크기는 짚는 값에 맞춰 바뀐다. hold 면 마지막 값 뒤에도 서지 않고 처음 값부터 화면을 다시 짚어 훑는다(holdEvery 초마다 하나)
277
+ */
236
278
  export function pickFly(
237
279
  el: HTMLElement,
238
- o: { items: { at: number; text: string; from: { x: number; y: number }; to: { x: number; y: number } }[]; dur?: number; size?: number },
280
+ o: {
281
+ items: ({ at: number; text: string; from: Pt; to: Pt } | { text?: string; stops: ReadStop[]; hold?: boolean; holdEvery?: number; lag?: number })[];
282
+ dur?: number;
283
+ size?: number;
284
+ /** 읽는 칸의 최소 빠르기(px/초) */
285
+ minSpeed?: number;
286
+ },
239
287
  ): (t: number) => void {
240
288
  const doc = el.ownerDocument;
241
- const dur = o.dur ?? 0.3;
289
+ const dur = o.dur ?? SLOW;
242
290
  const size = o.size ?? 44;
243
- const spans = o.items.map((it) => {
244
- const s = box(doc, `position:absolute;left:0;top:0;font-family:${MONO};font-size:${size}px;font-weight:800;color:var(--kf-accent-text);background:rgba(0,111,214,.14);border-radius:8px;padding:0 8px;white-space:nowrap;opacity:0;z-index:7;font-variant-numeric:tabular-nums`, it.text);
291
+ const vmin = o.minSpeed ?? 320;
292
+ const pills = o.items.map((it) => {
293
+ const s = box(doc, `position:absolute;left:0;top:0;box-sizing:border-box;display:flex;align-items:center;justify-content:center;font-family:${MONO};font-size:${size}px;font-weight:600;color:var(--kf-accent-text);background:color-mix(in srgb, var(--kf-accent) 20%, transparent);border:${Math.max(3, Math.round(size * 0.08))}px solid color-mix(in srgb, var(--kf-accent) 55%, transparent);border-radius:var(--kf-radius-pill);padding:0 0.3em;white-space:nowrap;opacity:0;z-index:7;pointer-events:none;font-variant-numeric:tabular-nums`, it.text ?? "");
245
294
  el.append(s);
246
295
  return s;
247
296
  });
297
+ const glide = (a: Box, b: Box, u: number, gap: number): Box => {
298
+ const d = Math.hypot(b.x - a.x, b.y - a.y);
299
+ // 넓은 알약(줄 하나를 짚는 칸)은 옆으로 휘면 긴 위아래 테두리가 제자리라 곧게 가고, 같은 칸을 다시 짚을 때만 화면 가운데 쪽으로 넘었다 돌아온다.
300
+ // 작은 알약은 곧게 옆으로만 가면 위아래 테두리가 제자리라 늘 조금은 휜다
301
+ const wide = a.w + b.w > 1.5 * (a.h + b.h);
302
+ const want = wide ? (d < 20 ? vmin * gap : d) : Math.max(d * 1.05, vmin * gap);
303
+ const h = Math.sqrt(Math.max(0, want * want - d * d) * (3 / 16));
304
+ const toCenter = (el.offsetHeight || 1080) / 2 < (a.y + b.y) / 2 ? -1 : 1;
305
+ const [nx, ny] = d > 1 && !wide ? [-(b.y - a.y) / d, (b.x - a.x) / d] : [0, toCenter];
306
+ const c = { x: (a.x + b.x) / 2 + nx * h * 2, y: (a.y + b.y) / 2 + ny * h * 2 };
307
+ // 감속 곡선에 곧은 몫을 섞어 닿기 직전에도 빠르기가 0 이 되지 않게 — 감속 곡선은 앞에 몰려 있어 곧은 몫을 더 크게
308
+ const e = out(u) * 0.4 + u * 0.6;
309
+ const v = 1 - e;
310
+ return { x: v * v * a.x + 2 * v * e * c.x + e * e * b.x, y: v * v * a.y + 2 * v * e * c.y + e * e * b.y, w: a.w + (b.w - a.w) * e, h: a.h + (b.h - a.h) * e };
311
+ };
312
+ const place = (s: HTMLElement, p: Box, opacity: number) => {
313
+ s.style.opacity = String(opacity);
314
+ s.style.width = `${p.w.toFixed(1)}px`;
315
+ s.style.height = `${p.h.toFixed(1)}px`;
316
+ s.style.transform = `translate(${(p.x - p.w / 2).toFixed(1)}px, ${(p.y - p.h / 2).toFixed(1)}px)`;
317
+ };
248
318
  return (t: number) => {
249
319
  o.items.forEach((it, i) => {
250
- const s = spans[i];
251
- const u = (t - it.at) / dur;
252
- if (u < 0 || u > 1) {
320
+ const s = pills[i];
321
+ if (!("stops" in it)) {
322
+ const u = (t - it.at) / dur;
323
+ if (u < 0 || u > 1) {
324
+ s.style.opacity = "0";
325
+ return;
326
+ }
327
+ const p = out(u);
328
+ s.style.width = s.style.height = "";
329
+ s.style.opacity = String(1 - clamp((u - 0.85) / 0.15));
330
+ s.style.transform = `translate(${it.from.x + (it.to.x - it.from.x) * p}px, ${it.from.y + (it.to.y - it.from.y) * p - Math.sin(Math.PI * p) * 180}px) translate(-50%, -50%)`;
331
+ return;
332
+ }
333
+ const lag = it.lag ?? BASE;
334
+ // 첫 값과는 함께 나타나고, 그다음부터 값이 선 뒤에 따라간다
335
+ const stops = [...it.stops].sort((a, b) => a.at - b.at).map((st, i) => ({ ...st, at: i ? st.at + lag : st.at }));
336
+ if (it.hold && stops.length > 1) {
337
+ // 다시 훑기 — 처음 값으로 돌아가 차례대로. 작은 칸끼리 옆으로만 오가면 화면이 거의 안 바뀌어 큰 칸(첫 값)으로 크게 돌아간다. 같은 t 면 같은 차례
338
+ const every = it.holdEvery ?? clamp(stops[stops.length - 1].at - stops[stops.length - 2].at, 0.3, 0.6);
339
+ const n = stops.length;
340
+ const end = stops[n - 1].at;
341
+ for (let j = 1; end + j * every <= t + every; j++) {
342
+ stops.push({ ...stops[(j - 1) % n], at: end + j * every });
343
+ }
344
+ }
345
+ if (!stops.length || t < stops[0].at) {
253
346
  s.style.opacity = "0";
254
347
  return;
255
348
  }
256
- const p = ease.inOutCubic(u);
257
- const x = it.from.x + (it.to.x - it.from.x) * p;
258
- const y = it.from.y + (it.to.y - it.from.y) * p - Math.sin(Math.PI * p) * 180;
259
- s.style.opacity = String(1 - clamp((u - 0.85) / 0.15));
260
- s.style.transform = `translate(${x}px, ${y}px) translate(-50%, -50%) scale(${1 + 0.5 * Math.sin(Math.PI * p)})`;
349
+ const boxOf = (st: ReadStop): Box => (st.target && measure(st.target, el, st.pad ?? size * 0.25)) || { x: st.x ?? 0, y: st.y ?? 0, w: st.w ?? size * 3, h: st.h ?? size * 1.6 };
350
+ const k = stops.findIndex((st) => st.at > t);
351
+ const shown = out(progress(t, stops[0].at, BASE));
352
+ if (k > 0) {
353
+ const [a, b] = [stops[k - 1], stops[k]];
354
+ place(s, glide(boxOf(a), boxOf(b), clamp((t - a.at) / (b.at - a.at)), b.at - a.at), shown);
355
+ return;
356
+ }
357
+ const last = boxOf(stops[stops.length - 1]);
358
+ const since = t - stops[stops.length - 1].at;
359
+ if (!it.hold) {
360
+ place(s, last, shown * (1 - out(progress(t, stops[stops.length - 1].at + BASE, FAST))));
361
+ return;
362
+ }
363
+ const r = 36 * clamp(since / 0.2);
364
+ place(s, { ...last, x: last.x + r * Math.sin(since * 5.5), y: last.y - r * (1 - Math.cos(since * 5.5)) * 0.5 }, shown);
261
365
  });
262
366
  };
263
367
  }
264
368
 
265
- /** 샘플 점 그림 — 모델마다 재 온 값이 들어온 시각에 점으로 꽂히고, 지금까지의 가운데값 눈금·숫자가 따라 미끄러진다 */
369
+ /** 샘플 점 그림 — 모델마다 재 온 값이 들어온 시각에 점으로 꽂히고, 지금까지의 가운데값 눈금·숫자가 300ms 로 따라 미끄러진다. 눈금·막대는 각짐 */
266
370
  export function samplePlot(
267
371
  el: HTMLElement,
268
372
  o: { x: number; y: number; w: number; rows: { label: string; samples: { t: number; v: number }[] }[]; min: number; max: number; unit?: string; decimals?: number; rowH?: number; at?: number; ticks?: number[]; best?: number; size?: number },
@@ -282,10 +386,10 @@ export function samplePlot(
282
386
  const rows = o.rows.map((r, i) => {
283
387
  const top = i * rowH;
284
388
  const label = box(doc, `position:absolute;left:0;top:${top}px;font-size:${size}px;color:var(--kf-ink);white-space:nowrap`, r.label);
285
- const track = box(doc, `position:absolute;left:0;right:0;top:${top + trackY}px;height:10px;border-radius:5px;background:${BAR}`);
389
+ const track = box(doc, `position:absolute;left:0;right:0;top:${top + trackY}px;height:10px;background:${BAR}`);
286
390
  const dots = r.samples.map(() => box(doc, `position:absolute;top:${top + trackY - 19}px;width:48px;height:48px;margin-left:-24px;border-radius:50%;background:var(--kf-accent);opacity:0`));
287
- const mark = box(doc, `position:absolute;top:${top + trackY - 40}px;width:8px;height:90px;margin-left:-4px;border-radius:4px;background:var(--kf-ink);opacity:0`);
288
- const val = box(doc, `position:absolute;top:${top + trackY + 30}px;right:0;font-size:${size * 1.35}px;font-weight:700;color:var(--kf-ink);opacity:0;transform-origin:100% 50%`);
391
+ const mark = box(doc, `position:absolute;top:${top + trackY - 40}px;width:8px;height:90px;margin-left:-4px;background:var(--kf-ink);opacity:0`);
392
+ const val = box(doc, `position:absolute;top:${top + trackY + 30}px;right:0;font-size:${size * 1.35}px;font-weight:600;color:var(--kf-ink);opacity:0`);
289
393
  wrap.append(label, track, ...dots, mark, val);
290
394
  return { r, dots, mark, val };
291
395
  });
@@ -295,32 +399,29 @@ export function samplePlot(
295
399
  return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
296
400
  };
297
401
  return (t: number) => {
298
- applyStyle(wrap, rise(t, o.at ?? 0, 0.22, 40));
402
+ applyStyle(wrap, rise(t, o.at ?? 0));
299
403
  rows.forEach(({ r, dots, mark, val }, i) => {
300
404
  const got = r.samples.filter((s) => t >= s.t);
301
405
  r.samples.forEach((s, k) => {
302
- const p = snappy(progress(t, s.t, 0.2));
303
406
  dots[k].style.left = `${X(s.v)}px`;
304
- dots[k].style.opacity = String(0.6 * p);
305
- dots[k].style.transform = `scale(${t >= s.t ? 1.8 - 0.8 * p : 0})`;
407
+ applyStyle(dots[k], t >= s.t ? { ...rise(t, s.t), opacity: 0.6 * out(progress(t, s.t, BASE)) } : { opacity: 0 });
306
408
  });
307
409
  if (!got.length) {
308
410
  mark.style.opacity = "0";
309
411
  val.style.opacity = "0";
310
412
  return;
311
413
  }
312
- // 가운데값은 새 점이 올 때마다 그 자리로 미끄러진다 — 앞 값과 새 값을 220ms 로 잇는다
414
+ // 가운데값은 새 점이 올 때마다 그 자리로 미끄러진다 — 앞 값과 새 값을 300ms(자리 옮김)로 잇는다
313
415
  const last = got[got.length - 1];
314
416
  const prev = got.length > 1 ? median(got.slice(0, -1).map((s) => s.v)) : last.v;
315
417
  const now = median(got.map((s) => s.v));
316
- const k = snappy(progress(t, last.t, 0.22));
418
+ const k = out(progress(t, last.t, SLOW));
317
419
  const m = prev + (now - prev) * k;
318
420
  mark.style.left = `${X(m)}px`;
319
421
  mark.style.opacity = "1";
320
422
  mark.style.background = o.best === i ? "var(--kf-accent)" : "var(--kf-ink)";
321
- val.style.opacity = "1";
423
+ applyStyle(val, rise(t, got[0].t));
322
424
  val.style.color = o.best === i ? "var(--kf-accent-text)" : "var(--kf-ink)";
323
- val.style.transform = `scale(${1 + 0.15 * (1 - k)})`;
324
425
  val.textContent = `${m.toFixed(o.decimals ?? 1)}${o.unit ?? ""}`;
325
426
  });
326
427
  };
@@ -333,24 +434,24 @@ export function meter(t: number, o: { to: number; at: number; dur?: number; deci
333
434
  const n = target.replace(/\D/g, "").length || 1;
334
435
  const dur = o.dur ?? 0.9;
335
436
  let k = 0;
336
- let out = "";
437
+ let s = "";
337
438
  for (const ch of target) {
338
439
  if (!/\d/.test(ch)) {
339
- out += ch;
440
+ s += ch;
340
441
  continue;
341
442
  }
342
443
  const settle = o.at + (dur * (k + 1)) / n;
343
- out += t >= settle ? ch : String(Math.floor((t * 26 + k * 3.7) % 10 + 10) % 10);
444
+ s += t >= settle ? ch : String(Math.floor((t * 26 + k * 3.7) % 10 + 10) % 10);
344
445
  k++;
345
446
  }
346
- return `${out}${o.unit ?? ""}`;
447
+ return `${s}${o.unit ?? ""}`;
347
448
  }
348
449
 
349
- /** 막대 — 굵은 막대가 박마다 하나씩 값만큼 자란다. best 하나만 OG 파랑, 나머지는 회색. 값은 막대 끝, delta 를 주면 그 박에 차이 칩 */
450
+ /** 막대 — 각진 막대가 박마다 하나씩 값만큼 300ms 로 자란다. best 만 브랜드 파랑(차트 칠), 나머지는 회색. 값은 막대 끝, delta 를 주면 그 박에 테두리 배지 */
350
451
  export function rankBars(
351
452
  el: HTMLElement,
352
453
  o: { x: number; y: number; w: number; items: { label: string; value: number; at: number; delta?: string; deltaAt?: number }[]; max: number; best?: number | number[]; prefix?: string; unit?: string; decimals?: number; barH?: number; gap?: number; size?: number },
353
- ): (t: number) => void {
454
+ ): ((t: number) => void) & { vals: HTMLElement[]; deltas: (HTMLElement | null)[] } {
354
455
  const doc = el.ownerDocument;
355
456
  const barH = o.barH ?? 76;
356
457
  const size = o.size ?? 38;
@@ -363,16 +464,16 @@ export function rankBars(
363
464
  const top = i * step;
364
465
  const label = box(doc, `position:absolute;left:0;top:${top}px;font-size:${size}px;color:var(--kf-ink-soft);white-space:nowrap`, it.label);
365
466
  const bar = box(doc, `position:absolute;left:0;top:${top + size * 1.4}px;height:${barH}px;background:${bests.includes(i) ? "var(--kf-accent)" : BAR};transform-origin:0 50%`);
366
- const val = box(doc, `position:absolute;top:${top + size * 1.4}px;line-height:${barH}px;font-size:${size * 1.2}px;font-weight:700;white-space:nowrap;color:${bests.includes(i) ? "var(--kf-accent-text)" : "var(--kf-ink)"}`);
467
+ const val = box(doc, `position:absolute;top:${top + size * 1.4}px;line-height:${barH}px;font-size:${size * 1.2}px;font-weight:600;white-space:nowrap;color:${bests.includes(i) ? "var(--kf-accent-text)" : "var(--kf-ink)"}`);
367
468
  const delta = it.delta
368
- ? box(doc, `position:absolute;top:${top + size * 1.4 + barH / 2}px;padding:4px 16px;border-radius:999px;background:var(--kf-raised);color:var(--kf-ink);font-size:${size}px;font-weight:700;white-space:nowrap;transform-origin:0 50%`, it.delta)
469
+ ? box(doc, `position:absolute;top:${top + size * 1.4 + barH / 2}px;padding:2px 16px;font-size:${size}px;font-weight:600;white-space:nowrap;${badgeCss("plain", size)}`, it.delta)
369
470
  : null;
370
471
  wrap.append(label, bar, val, ...(delta ? [delta] : []));
371
472
  return { it, label, bar, val, delta };
372
473
  });
373
- return (t: number) => {
474
+ const paint = (t: number) => {
374
475
  rows.forEach(({ it, label, bar, val, delta }) => {
375
- const p = snappy(progress(t, it.at, 0.3));
476
+ const p = out(progress(t, it.at, SLOW));
376
477
  const w = (it.value / o.max) * (o.w - 440);
377
478
  applyStyle(label, rise(t, it.at));
378
479
  bar.style.width = `${w}px`;
@@ -382,51 +483,54 @@ export function rankBars(
382
483
  val.textContent = `${o.prefix ?? ""}${it.value.toFixed(o.decimals ?? 1)}${o.unit ?? ""}`;
383
484
  if (delta) {
384
485
  const at = it.deltaAt ?? it.at + 0.35;
385
- const d = snappy(progress(t, at, 0.22));
386
486
  delta.style.left = `${w + 24 + size * 6}px`;
387
- delta.style.opacity = String(d);
388
- delta.style.transform = `translateY(-50%) scale(${t >= at ? 1.3 - 0.3 * d : 0})`;
487
+ applyStyle(delta, t >= at ? rise(t, at) : { opacity: 0 });
488
+ delta.style.translate = "0 -50%";
389
489
  }
390
490
  });
391
491
  };
492
+ return Object.assign(paint, { vals: rows.map((r) => r.val), deltas: rows.map((r) => r.delta) });
392
493
  }
393
494
 
394
- /** 끝 도장 — 「측정 날짜 · 호출 N번 · $비용 · 키 하나」 한 줄과 OG 로고가 쾅 찍힌다. 비용은 반올림해 0 으로 만들지 않고 첫 유효 자리까지 */
395
- export function stamp(el: HTMLElement, o: { x: number; y: number; at: number; date: string; calls: number; cost: number; logo?: string; size?: number }): (t: number) => void {
495
+ /**
496
+ * 끝 도장 — 구분선 한 줄 위에 OG 로고와 「측정 날짜 · 호출 N번 · $비용 · 키 하나」가 선다(상자 없이). 비용은 반올림해 0 으로 만들지 않고 첫 유효 자리까지.
497
+ * logo 는 마스터 로고 SVG 원문(currentColor) — 한 색으로 칠한다. 어두운 바탕 흰색·밝은 바탕 검정(logoInk)
498
+ */
499
+ export function stamp(el: HTMLElement, o: { x: number; y: number; at: number; date: string; calls: number; cost: number; logo?: string; logoInk?: string; size?: number }): (t: number) => void {
396
500
  const doc = el.ownerDocument;
397
501
  const size = o.size ?? 36;
398
- const card = box(doc, `position:absolute;left:${o.x}px;top:${o.y}px;display:flex;align-items:center;gap:${size}px;padding:${size * 0.6}px ${size}px;border-radius:12px;background:var(--kf-raised);font-family:${MONO};font-size:${size}px;color:var(--kf-ink);white-space:nowrap;font-variant-numeric:tabular-nums`);
502
+ const row = box(doc, `position:absolute;left:${o.x}px;top:${o.y}px;display:flex;align-items:center;gap:${size}px;padding-top:${size * 0.6}px;border-top:${edge(size)}px solid var(--kf-line);font-family:${MONO};font-size:${size}px;color:var(--kf-ink);white-space:nowrap;font-variant-numeric:tabular-nums`);
399
503
  if (o.logo) {
400
- const img = doc.createElement("img");
401
- img.src = o.logo;
402
- img.style.cssText = `height:${size * 1.25}px;width:auto;display:block`;
403
- card.append(img);
504
+ const mark = box(doc, `height:${size * 1.25}px;width:${(size * 1.25 * 1140) / 650}px;color:${o.logoInk ?? "var(--kf-ink)"};display:flex`);
505
+ mark.innerHTML = fitSvg(o.logo);
506
+ row.append(mark);
404
507
  }
405
508
  const digits = o.cost > 0 ? Math.max(2, -Math.floor(Math.log10(o.cost))) : 2;
406
- card.append(box(doc, "", `측정 ${o.date} · 호출 ${o.calls}번 · $${o.cost.toFixed(digits)} · 키 하나`));
407
- el.append(card);
509
+ row.append(box(doc, "", `측정 ${o.date} · 호출 ${o.calls}번 · $${o.cost.toFixed(digits)} · 키 하나`));
510
+ el.append(row);
408
511
  return (t: number) => {
409
- const p = snappy(progress(t, o.at, 0.22));
410
- card.style.opacity = String(p);
411
- card.style.transform = `translateX(-50%) translateY(${20 * (1 - p)}px) scale(${1.25 - 0.25 * p})`;
512
+ applyStyle(row, rise(t, o.at));
513
+ row.style.translate = "-50% 0";
412
514
  };
413
515
  }
414
516
 
415
- /** 칩 — 짧은 낱말·모델 ID·잰 값 하나(「실측」·「openai/ 41」). 220ms 로 크게 들어오고 until 에 오그라들며 빠진다 */
416
- export function flag(el: HTMLElement, o: { x: number; y: number; text: string; at: number; until?: number; tone?: "accent" | "soft" | "plain"; size?: number; mono?: boolean }): (t: number) => void {
517
+ /**
518
+ * 배지 — 짧은 낱말·모델 ID·잰 값 하나(「실측」·「openai/ 41」). 속을 칠하지 않고 흐린 테두리와 글자색만(badge.tsx 의 default·outline·secondary).
519
+ * 6px 올라오며 220ms 로 나타나고 until 에 150ms 로 빠진다
520
+ */
521
+ export function flag(el: HTMLElement, o: { x: number; y: number; text: string; at: number; until?: number; tone?: "accent" | "soft" | "plain"; size?: number; mono?: boolean }): ((t: number) => void) & { el: HTMLElement } {
417
522
  const doc = el.ownerDocument;
418
- const soft = o.tone === "soft";
523
+ const size = o.size ?? 38;
419
524
  const chip = box(
420
525
  doc,
421
- `position:absolute;left:${o.x}px;top:${o.y}px;padding:6px 20px;border-radius:999px;font-family:${o.mono ? MONO : "var(--kf-font-sans)"};font-size:${o.size ?? 38}px;font-weight:700;white-space:nowrap;transform-origin:0 50%;font-variant-numeric:tabular-nums;` +
422
- (o.tone === "plain" ? "background:var(--kf-surface);color:var(--kf-ink)" : soft ? "background:var(--kf-tint);color:var(--kf-accent-text)" : "background:var(--kf-accent);color:var(--kf-on-accent)"),
526
+ `position:absolute;left:${o.x}px;top:${o.y}px;padding:4px 0.5em;font-family:${o.mono ? MONO : "var(--kf-font-sans)"};font-size:${size}px;font-weight:600;white-space:nowrap;font-variant-numeric:tabular-nums;${badgeCss(o.tone ?? "accent", size)}`,
423
527
  o.text,
424
528
  );
425
529
  el.append(chip);
426
- return (t: number) => {
427
- const p = snappy(progress(t, o.at, 0.22));
428
- const gone = o.until === undefined ? 0 : snappy(progress(t, o.until, 0.12));
429
- chip.style.opacity = String(p * (1 - gone));
430
- chip.style.transform = `scale(${t >= o.at ? (1.4 - 0.4 * p) * (1 - 0.6 * gone) : 0.6})`;
530
+ const paint = (t: number) => {
531
+ const gone = o.until === undefined ? 0 : out(progress(t, o.until, FAST));
532
+ const s = rise(t, o.at);
533
+ applyStyle(chip, t >= o.at ? { ...s, opacity: (s.opacity ?? 1) * (1 - gone) } : { opacity: 0 });
431
534
  };
535
+ return Object.assign(paint, { el: chip });
432
536
  }
@@ -1,12 +1,12 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <svg id="_레이어_1" data-name="레이어_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1140 650">
3
- <!-- Generator: Adobe Illustrator 30.6.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 109) -->
4
- <g id="node-in">
5
- <path d="M367,650H125c-68.9,0-125-56.1-125-125V125C0,56.1,56.1,0,125,0h242c68.9,0,125,56.1,125,125v400c0,68.9-56.1,125-125,125ZM125,72c-29.2,0-53,23.8-53,53v400c0,29.2,23.8,53,53,53h242c29.2,0,53-23.8,53-53V125c0-29.2-23.8-53-53-53H125Z"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1140 650" width="1140" height="650" fill="none" role="img" aria-label="OpenGateway">
2
+ <title>OpenGateway</title>
3
+ <g id="logo" stroke="currentColor" fill="currentColor">
4
+ <rect id="node-in" x="36" y="36" width="420" height="578" rx="89" fill="none" stroke-width="72"/>
5
+ <rect id="bar-in" x="72" y="272" width="277" height="106" stroke="none"/>
6
+ <path id="node-out" d="M684 213 L684 125 A89 89 0 0 1 773 36 L1015 36 A89 89 0 0 1 1104 125 L1104 525 A89 89 0 0 1 1015 614 L773 614 A89 89 0 0 1 684 525 L684 436" fill="none" stroke-width="72" stroke-linecap="butt"/>
7
+ <g id="arrow">
8
+ <rect id="arrow-shaft" x="420" y="272" width="394" height="106" stroke="none"/>
9
+ <path id="arrow-head" d="M833 220 L970 325 L833 430 Z" stroke-width="38" stroke-linejoin="round"/>
10
+ </g>
6
11
  </g>
7
- <rect id="bar-in" x="72" y="272" width="277" height="106"/>
8
- <g id="node-out">
9
- <path d="M1015,650h-242c-68.9,0-125-56.1-125-125v-89h72v89c0,29.2,23.8,53,53,53h242c29.2,0,53-23.8,53-53V125c0-29.2-23.8-53-53-53h-242c-29.2,0-53,23.8-53,53v88h-72v-88c0-68.9,56.1-125,125-125h242c68.9,0,125,56.1,125,125v400c0,68.9-56.1,125-125,125Z"/>
10
- </g>
11
- <path d="M981.6,309.9l-137-105c-5.7-4.4-13.5-5.2-20-2-6.5,3.2-10.6,9.8-10.6,17v52h-394v106h394v52c0,7.2,4.1,13.8,10.6,17,2.7,1.3,5.5,2,8.4,2,4.1,0,8.2-1.3,11.6-3.9l137-105c4.7-3.6,7.4-9.2,7.4-15.1s-2.8-11.5-7.4-15.1Z"/>
12
- </svg>
12
+ </svg>