thursday-agent 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/build-manifest.json +3 -3
  3. package/.next/prerender-manifest.json +3 -3
  4. package/.next/server/app/_global-error.html +1 -1
  5. package/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  9. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  10. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_not-found.html +1 -1
  12. package/.next/server/app/_not-found.rsc +2 -2
  13. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  14. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  15. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  16. package/.next/server/app/api/artifact/route.js +3 -3
  17. package/.next/server/app/api/artifact/route.js.nft.json +1 -1
  18. package/.next/server/app/api/bot/memory/files/route.js.nft.json +1 -1
  19. package/.next/server/app/api/bot/memory/route.js.nft.json +1 -1
  20. package/.next/server/app/api/bot/route.js.nft.json +1 -1
  21. package/.next/server/app/api/bot/thread/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/bot/thread/route.js.nft.json +1 -1
  23. package/.next/server/app/api/file/[...path]/route.js.nft.json +1 -1
  24. package/.next/server/app/api/memory/edit/route.js +1 -1
  25. package/.next/server/app/api/memory/edit/route.js.nft.json +1 -1
  26. package/.next/server/app/api/skills/[source]/[name]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  28. package/.next/server/app/api/thursday/call/route.js.nft.json +1 -1
  29. package/.next/server/app/api/thursday/skills/route.js.nft.json +1 -1
  30. package/.next/server/app/api/thursday/tool-call/route.js +1 -1
  31. package/.next/server/app/api/thursday/tool-call/route.js.nft.json +1 -1
  32. package/.next/server/app/api/workspace/missing/route.js +2 -2
  33. package/.next/server/app/api/workspace/missing/route.js.nft.json +1 -1
  34. package/.next/server/app/api/workspace/route.js.nft.json +1 -1
  35. package/.next/server/app/artifact/[...path]/page/server-reference-manifest.json +1 -1
  36. package/.next/server/app/artifact/[...path]/page.js.nft.json +1 -1
  37. package/.next/server/app/artifact/[...path]/page_client-reference-manifest.js +1 -1
  38. package/.next/server/app/page/server-reference-manifest.json +42 -42
  39. package/.next/server/app/page.js +5 -4
  40. package/.next/server/app/page.js.nft.json +1 -1
  41. package/.next/server/app/page_client-reference-manifest.js +1 -1
  42. package/.next/server/chunks/[root-of-the-server]__0-06t3f._.js +8 -0
  43. package/.next/server/chunks/{[root-of-the-server]__049f521._.js → [root-of-the-server]__06l05n_._.js} +1 -1
  44. package/.next/server/chunks/[root-of-the-server]__0_ue8oe._.js +1 -1
  45. package/.next/server/chunks/{[root-of-the-server]__0ye3da2._.js → [root-of-the-server]__0btqrct._.js} +1 -1
  46. package/.next/server/chunks/[root-of-the-server]__0c9fgca._.js +1 -1
  47. package/.next/server/chunks/[root-of-the-server]__0zp35ul._.js +1 -1
  48. package/.next/server/chunks/[root-of-the-server]__1kv8p3e._.js +1 -1
  49. package/.next/server/chunks/_02szwk8._.js +1 -1
  50. package/.next/server/chunks/_06c9kie._.js +24 -24
  51. package/.next/server/chunks/_0i55en5._.js +6 -6
  52. package/.next/server/chunks/_0p56fn0._.js +1 -1
  53. package/.next/server/chunks/_11ujpya._.js +1 -1
  54. package/.next/server/chunks/_1dbixxz._.js +1 -1
  55. package/.next/server/chunks/_1dc_d1q._.js +16 -16
  56. package/.next/server/chunks/_1gnjfli._.js +3 -0
  57. package/.next/server/chunks/_1klbcft._.js +1 -1
  58. package/.next/server/chunks/features_0-cnt0l._.js +1 -1
  59. package/.next/server/chunks/features_0hdaci0._.js +1 -1
  60. package/.next/server/chunks/ssr/[root-of-the-server]__0-e011f._.js +3 -0
  61. package/.next/server/chunks/ssr/[root-of-the-server]__0pd8yr7._.js +3 -0
  62. package/.next/server/chunks/ssr/[root-of-the-server]__1nygfik._.js +1 -1
  63. package/.next/server/chunks/ssr/[root-of-the-server]__1sdphb0._.js +1 -1
  64. package/.next/server/chunks/ssr/{[root-of-the-server]__1fll8dc._.js → [root-of-the-server]__1vdmfbe._.js} +2 -2
  65. package/.next/server/chunks/ssr/[root-of-the-server]__1waa3rc._.js +34 -49
  66. package/.next/server/chunks/ssr/{_1anqots._.js → _0-2qsxo._.js} +2 -2
  67. package/.next/server/chunks/ssr/_02oc_h5._.js +9 -9
  68. package/.next/server/chunks/ssr/_05yghx6._.js +1 -1
  69. package/.next/server/chunks/ssr/_07nbwap._.js +3 -0
  70. package/.next/server/chunks/ssr/_08c4z-9._.js +1 -1
  71. package/.next/server/chunks/ssr/_0_qfdlw._.js +1 -1
  72. package/.next/server/chunks/ssr/_0b6_jv-._.js +3 -0
  73. package/.next/server/chunks/ssr/_0hxt2ul._.js +1 -1
  74. package/.next/server/chunks/ssr/_0i_6q1w._.js +1 -1
  75. package/.next/server/chunks/ssr/{_18xqmny._.js → _0no7i1o._.js} +2 -2
  76. package/.next/server/chunks/ssr/_0tzg--b._.js +3 -0
  77. package/.next/server/chunks/ssr/{_1ztk7p6._.js → _114ylv7._.js} +3 -3
  78. package/.next/server/chunks/ssr/{_1k0spz7._.js → _12c-iwh._.js} +2 -2
  79. package/.next/server/chunks/ssr/_12w42gi._.js +1 -1
  80. package/.next/server/chunks/ssr/{_0eaog6_._.js → _167hchk._.js} +2 -2
  81. package/.next/server/chunks/ssr/{_1x9pskj._.js → _1_x5spz._.js} +3 -3
  82. package/.next/server/chunks/ssr/{_0dr-ddg._.js → _1a61fc9._.js} +2 -2
  83. package/.next/server/chunks/ssr/_1jmz33w._.js +1 -1
  84. package/.next/server/chunks/ssr/{_1y6b5mr._.js → _1w_t7h7._.js} +2 -2
  85. package/.next/server/chunks/ssr/{_0dh2e1j._.js → _1wyfaq_._.js} +7 -7
  86. package/.next/server/chunks/ssr/{[root-of-the-server]__0bxsfyn._.js → _200gt-m._.js} +4 -3
  87. package/.next/server/chunks/ssr/features_config_components_config-setting_tsx_0xfwqdg._.js +1 -1
  88. package/.next/server/chunks/ssr/features_connectors_components_mcp-setting_tsx_0jmbgvp._.js +1 -1
  89. package/.next/server/chunks/ssr/{_0c9e5w6._.js → features_workspace_00jz2hu._.js} +3 -4
  90. package/.next/server/chunks/ssr/{node_modules_0746rzi._.js → node_modules_02ai7br._.js} +1 -1
  91. package/.next/server/chunks/ssr/{node_modules_0vsxbzp._.js → node_modules_02deexj._.js} +1 -1
  92. package/.next/server/chunks/ssr/{node_modules_1g1eihe._.js → node_modules_07za262._.js} +1 -1
  93. package/.next/server/chunks/ssr/{node_modules_0332gw4._.js → node_modules_0_s9get._.js} +1 -1
  94. package/.next/server/chunks/ssr/{node_modules_1gpednh._.js → node_modules_0n3c7h0._.js} +1 -1
  95. package/.next/server/chunks/ssr/{node_modules_0t-9nup._.js → node_modules_0od6jwr._.js} +1 -1
  96. package/.next/server/chunks/ssr/{node_modules_0mxvg8h._.js → node_modules_0rdt7pk._.js} +1 -1
  97. package/.next/server/chunks/ssr/{node_modules_0vv0-fg._.js → node_modules_0w1z7kz._.js} +1 -1
  98. package/.next/server/chunks/ssr/{node_modules_1d53yiq._.js → node_modules_0wdupfz._.js} +1 -1
  99. package/.next/server/chunks/ssr/{node_modules_1u6w1pb._.js → node_modules_1720w0a._.js} +1 -1
  100. package/.next/server/chunks/ssr/{node_modules_1gu1pph._.js → node_modules_18lsg7d._.js} +1 -1
  101. package/.next/server/chunks/ssr/{node_modules_1he1w8_._.js → node_modules_1b94fxf._.js} +1 -1
  102. package/.next/server/chunks/ssr/{node_modules_0qbfyjh._.js → node_modules_1ecdo1s._.js} +1 -1
  103. package/.next/server/chunks/ssr/{node_modules_0qapgjr._.js → node_modules_1fa9ag7._.js} +1 -1
  104. package/.next/server/chunks/ssr/{node_modules_0d5uzof._.js → node_modules_1fv-_si._.js} +1 -1
  105. package/.next/server/chunks/ssr/{node_modules_1lnjapm._.js → node_modules_1hf461g._.js} +1 -1
  106. package/.next/server/chunks/ssr/{node_modules_1-urho9._.js → node_modules_1m71ta-._.js} +1 -1
  107. package/.next/server/chunks/ssr/{node_modules_1lynj9m._.js → node_modules_1n93n-g._.js} +1 -1
  108. package/.next/server/chunks/ssr/{node_modules_0fr-ml8._.js → node_modules_1n9lqu9._.js} +1 -1
  109. package/.next/server/chunks/ssr/{node_modules_04vxchl._.js → node_modules_1npnsvd._.js} +1 -1
  110. package/.next/server/chunks/ssr/{node_modules_0exkwcm._.js → node_modules_1rn2r0n._.js} +1 -1
  111. package/.next/server/chunks/ssr/{node_modules_022sipm._.js → node_modules_1vq-mfk._.js} +1 -1
  112. package/.next/server/chunks/ssr/{node_modules_00_51_v._.js → node_modules_1vzyw1t._.js} +1 -1
  113. package/.next/server/chunks/ssr/{node_modules_0wsvp00._.js → node_modules_1w359w4._.js} +1 -1
  114. package/.next/server/chunks/ssr/{node_modules_1xn6saw._.js → node_modules_1yajbl6._.js} +1 -1
  115. package/.next/server/chunks/ssr/{node_modules_16pi_dd._.js → node_modules_20eqhy8._.js} +1 -1
  116. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_067_exa._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_019sbis._.js} +1 -1
  117. package/.next/server/chunks/ssr/{node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0nrrdaq._.js → node_modules_@mermaid-js_parser_dist_chunks_mermaid-parser_core_0s6piq3._.js} +1 -1
  118. package/.next/server/chunks/ssr/{node_modules_@vercel_oidc_dist_21a5haa._.js → node_modules_@vercel_oidc_dist_05itbb1._.js} +1 -1
  119. package/.next/server/middleware-build-manifest.js +3 -3
  120. package/.next/server/pages/404.html +1 -1
  121. package/.next/server/pages/500.html +1 -1
  122. package/.next/server/server-reference-manifest.js +1 -1
  123. package/.next/server/server-reference-manifest.json +43 -43
  124. package/.next/static/chunks/{0dr16v67vj4n7.js → 07vqbk_9t4em4.js} +2 -2
  125. package/.next/static/chunks/{2nvepds1fu2ux.js → 08krja1zx7nqg.js} +1 -1
  126. package/.next/static/chunks/{42wcg3wfg_m_p.js → 0qcuwdk132jjt.js} +1 -1
  127. package/.next/static/chunks/{2_pnccjp6j1ca.js → 0z69gvysmave7.js} +1 -1
  128. package/.next/static/chunks/12fvz1w5ows75.js +1 -0
  129. package/.next/static/chunks/14t03ned286iu.js +1 -0
  130. package/.next/static/chunks/{2jlz8zp4jax_m.js → 156xxbkrz5lzu.js} +1 -1
  131. package/.next/static/chunks/1fz14reod4lft.js +1 -0
  132. package/.next/static/chunks/{0joan-69kbspd.js → 1g23dbabl-vkr.js} +1 -1
  133. package/.next/static/chunks/{18mkhstgiqcbt.js → 1qy_rc3oxfxok.js} +1 -1
  134. package/.next/static/chunks/1zea7qdl-7365.js +1 -0
  135. package/.next/static/chunks/1zvqumkg7kmo0.css +1 -0
  136. package/.next/static/chunks/26hpqt3y6bogl.js +12 -0
  137. package/.next/static/chunks/{3zk6q3-3f9swq.js → 2_j5mqm6d-z18.js} +1 -1
  138. package/.next/static/chunks/{1vj_5bnbxx84e.js → 2dt6tu7a22svs.js} +1 -1
  139. package/.next/static/chunks/2h5ls3d86kya6.js +1 -0
  140. package/.next/static/chunks/2wc6y1ht6dfuy.js +1 -0
  141. package/.next/static/chunks/{1i-u2wzub_to1.js → 2xr0zpll7thyi.js} +6 -6
  142. package/.next/static/chunks/{26ql4y-43xwhp.js → 31jztc-yq30m7.js} +1 -1
  143. package/.next/static/chunks/391af0i8eljzk.js +1 -0
  144. package/.next/static/chunks/{1kp5nfp06nj9h.js → 3p2s2cjokx1aa.js} +2 -2
  145. package/.next/static/chunks/{1wlo979mx-y3w.js → 41s3fml5oivsa.js} +1 -1
  146. package/README.md +60 -19
  147. package/docs/how-it-works.md +2 -0
  148. package/docs/live-calls.md +38 -33
  149. package/features/ai/load-tools.ts +4 -2
  150. package/features/ai/prompts/bot.prompt.ts +5 -5
  151. package/features/ai/prompts/live.prompt.ts +36 -132
  152. package/features/ai/prompts/prompt-helper.ts +5 -4
  153. package/features/ai/prompts/thursday.prompt.ts +75 -54
  154. package/features/ai/tools/call.tool.ts +32 -4
  155. package/features/ai/tools/tool-name.ts +1 -0
  156. package/features/bot/bot.runner.ts +5 -5
  157. package/features/thursday/ascii.const.ts +74 -0
  158. package/features/thursday/components/ascii-orb.tsx +173 -30
  159. package/features/thursday/components/face.tsx +6 -1
  160. package/features/thursday/components/side-captions.tsx +273 -0
  161. package/features/thursday/components/tab-state.tsx +11 -3
  162. package/features/thursday/components/thursday-setting.tsx +6 -5
  163. package/features/thursday/components/thursday.tsx +227 -251
  164. package/features/thursday/face.store.ts +5 -0
  165. package/features/thursday/thursday.action.ts +4 -6
  166. package/features/thursday/thursday.schema.ts +10 -5
  167. package/features/thursday/thursday.store.ts +3 -0
  168. package/features/thursday/use-thursday.ts +461 -392
  169. package/features/workspace/file-kind.ts +9 -0
  170. package/hooks/use-hotkey.ts +3 -2
  171. package/lib/live/live.schema.ts +0 -21
  172. package/lib/live/live.server.ts +1 -3
  173. package/package.json +1 -1
  174. package/scripts/artifact-paths.test.mts +13 -0
  175. package/scripts/live-session.test.mts +56 -66
  176. package/.next/server/chunks/[root-of-the-server]__0g-ueei._.js +0 -8
  177. package/.next/server/chunks/node_modules_consola_dist_chunks_prompt_mjs_1kipdeo._.js +0 -3
  178. package/.next/server/chunks/ssr/[root-of-the-server]__172cq4x._.js +0 -3
  179. package/.next/server/chunks/ssr/_03_928f._.js +0 -3
  180. package/.next/server/chunks/ssr/_0g_rlog._.js +0 -3
  181. package/.next/server/chunks/ssr/_123ttsf._.js +0 -3
  182. package/.next/static/chunks/10k2xvap03z20.css +0 -1
  183. package/.next/static/chunks/1t9qms5pwwa3n.js +0 -1
  184. package/.next/static/chunks/2a51-ywz9b2a3.js +0 -1
  185. package/.next/static/chunks/2fvdgxih7x7hr.js +0 -1
  186. package/.next/static/chunks/3addj3i4-izma.js +0 -1
  187. package/.next/static/chunks/3gfh7nxwusbt0.js +0 -1
  188. package/.next/static/chunks/3gwa44cvz4qoi.js +0 -1
  189. package/.next/static/chunks/3zkg5iup3v4a2.js +0 -12
  190. package/.next/static/chunks/405m0bqtoihev.js +0 -1
  191. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_buildManifest.js +0 -0
  192. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_clientMiddlewareManifest.js +0 -0
  193. /package/.next/static/{0qzGoKn2wTERVZtblX1tp → yyRVBc0xxYQsh3GpbQX9b}/_ssgManifest.js +0 -0
@@ -11,15 +11,17 @@ import {
11
11
  EMOJI_POOL,
12
12
  EMOJI_RATIO,
13
13
  hash,
14
+ LETTERS,
14
15
  RAMP,
15
16
  smoothstep,
16
17
  } from "../ascii.const";
17
18
  import type { AsciiCharset } from "../face.const";
19
+ import type { FaceWord } from "../thursday.schema";
18
20
 
19
21
  export type AsciiOrbMode =
20
22
  | "idle"
21
23
  | "connecting"
22
- /** The user's turn: the resting body, retyping with the microphone */
24
+ /** The user's turn: the resting body, thinned out, retyping with the microphone */
23
25
  | "listening"
24
26
  /** Hanging up: the body draws in and goes out, leaving the field empty */
25
27
  | "ending"
@@ -47,6 +49,11 @@ export type AsciiOrbProps = {
47
49
  * glyphs retype; without them listening is the resting body alone.
48
50
  */
49
51
  getMicSpectrum?: () => ArrayLike<number>;
52
+ /**
53
+ * A word to show (emote): the body steps aside, the letters light in one by
54
+ * one, hold, and go out, and the mode takes the face back. A new `at` shows it again.
55
+ */
56
+ word?: FaceWord | null;
50
57
  /** Side length (px). Everything scales with it; cell count scales with area. */
51
58
  size?: number;
52
59
  /**
@@ -62,14 +69,23 @@ const DEFAULT_COLOR: [number, number, number] = [235, 235, 235];
62
69
  /** Color easing per frame (0.05 is about 0.5s) */
63
70
  const COLOR_EASE = 0.05;
64
71
 
65
- /** ERROR: 4 cells per letter, 1 gap (24 x 5) */
66
- const ERROR_BITMAP = [
67
- "████ ███ ███ ██ ███ ",
68
- "█ █ █ █ █ █ █ █ █",
69
- "███ ███ ███ █ █ ███ ",
70
- "█ █ █ █ █ █ █ █ █ ",
71
- "████ █ █ █ █ ██ █ █",
72
- ];
72
+ /** A word as five rows of letter indices, one empty column between letters; -1 is no ink. */
73
+ function spell(text: string) {
74
+ const rows: number[][] = [[], [], [], [], []];
75
+ let letter = 0;
76
+ for (const char of Array.from(text.toUpperCase())) {
77
+ const glyph = LETTERS[char];
78
+ if (!glyph) continue;
79
+ if (rows[0].length) for (const row of rows) row.push(-1);
80
+ glyph.forEach((line, r) => {
81
+ for (const px of line) rows[r].push(px === "#" ? letter : -1);
82
+ });
83
+ if (char !== " ") letter++;
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ const ERROR_ROWS = spell("ERROR");
73
89
 
74
90
  /** Reference size the tuning constants assume; coordinates are normalized to it. */
75
91
  const DESIGN = 680;
@@ -145,6 +161,8 @@ const RISE = {
145
161
  comet: 0.45,
146
162
  speech: 0.5,
147
163
  err: 0.5,
164
+ word: 0.35,
165
+ listen: 0.3,
148
166
  };
149
167
  const FALL = {
150
168
  scale: 0.7,
@@ -153,6 +171,8 @@ const FALL = {
153
171
  comet: 0.9,
154
172
  speech: 0.75,
155
173
  err: 0.6,
174
+ word: 0.5,
175
+ listen: 0.6,
156
176
  };
157
177
  /** Hanging up pulls the body in faster than anything else grows back. */
158
178
  const END_FALL = 0.4;
@@ -209,6 +229,14 @@ const LISTEN_PACE_VOICE = 8;
209
229
  const LISTEN_LIFT = 0.24;
210
230
  /** Seconds the retype rate takes to follow the voice (toward) */
211
231
  const PACE_TIME = 0.14;
232
+ /**
233
+ * Listening also thins the body out, so the voice has gaps to show in: every
234
+ * cell's gate rises by THIN, and POP_RATE times a second each cell re-rolls up
235
+ * to POP of it, as far as the voice is loud — glyphs pop in and out with the words.
236
+ */
237
+ const LISTEN_THIN = 0.16;
238
+ const LISTEN_POP = 0.6;
239
+ const LISTEN_POP_RATE = 9;
212
240
 
213
241
  /** Working: a comet orbiting just outside the resting body */
214
242
  const WORK_R = 210;
@@ -228,6 +256,15 @@ const ERR_STEP_OUT = 0.5;
228
256
  const ERR_FADE_OUT = 0.7;
229
257
  const ERR_CYCLE = 9.2;
230
258
 
259
+ /** A word (emote), once: letters light in one by one, hold, and go out the same way (s) */
260
+ const WORD_STEP_IN = 0.11;
261
+ const WORD_FADE_IN = 0.3;
262
+ const WORD_HOLD = 3.4;
263
+ const WORD_STEP_OUT = 0.08;
264
+ const WORD_FADE_OUT = 0.35;
265
+ /** The widest a word is drawn, as a share of the box: about her body */
266
+ const WORD_WIDTH = 0.62;
267
+
231
268
  type Cell = {
232
269
  /** Draw position on the canvas */
233
270
  x: number;
@@ -249,26 +286,23 @@ type Cell = {
249
286
  speck: number;
250
287
  /** Index of the ERROR letter this cell belongs to, or -1 */
251
288
  letter: number;
289
+ /** Index of the shown word's letter this cell belongs to, or -1; laid again for each word */
290
+ word: number;
252
291
  };
253
292
 
254
- /** Index of the ERROR letter at (dx,dy), or -1 */
255
- function errorLetterAt(
293
+ /** Index of the letter at (dx,dy) of a word centred on the box, each of its cells `s` grid cells wide; -1 when none */
294
+ function letterAt(
295
+ rows: number[][],
256
296
  dx: number,
257
297
  dy: number,
258
298
  cw: number,
259
299
  ch: number,
260
300
  s: number,
261
301
  ) {
262
- const rows = ERROR_BITMAP.length;
263
- const cols = ERROR_BITMAP[0].length;
264
- const bx = Math.floor(dx / (cw * s) + cols / 2);
265
- const by = Math.floor(dy / (ch * s) + rows / 2);
266
-
267
- if (by < 0 || by >= rows) return -1;
268
- const line = ERROR_BITMAP[by];
269
- if (bx < 0 || bx >= line.length) return -1;
270
- if (line[bx] !== "█") return -1;
271
- return Math.floor(bx / 5);
302
+ const bx = Math.floor(dx / (cw * s) + rows[0].length / 2);
303
+ const by = Math.floor(dy / (ch * s) + rows.length / 2);
304
+ if (by < 0 || by >= rows.length) return -1;
305
+ return rows[by][bx] ?? -1;
272
306
  }
273
307
 
274
308
  /**
@@ -308,6 +342,10 @@ type Field = {
308
342
  speech: number;
309
343
  /** The ERROR letters */
310
344
  err: number;
345
+ /** A word's letters (emote) */
346
+ word: number;
347
+ /** Listening's thinning out */
348
+ listen: number;
311
349
  };
312
350
 
313
351
  /** Where a mode wants the field. `e` is seconds since the mode was asked for. */
@@ -319,6 +357,8 @@ function targetFor(m: AsciiOrbMode, e: number, v: Voice): Field {
319
357
  comet: 0,
320
358
  speech: 0,
321
359
  err: 0,
360
+ word: 0,
361
+ listen: 0,
322
362
  };
323
363
  switch (m) {
324
364
  case "idle":
@@ -335,11 +375,12 @@ function targetFor(m: AsciiOrbMode, e: number, v: Voice): Field {
335
375
  };
336
376
  }
337
377
 
338
- // the body holds still; the user's voice shows as ink and as the retype rate
378
+ // the body holds still; the user's voice shows as ink, in its gaps and as the retype rate
339
379
  case "listening":
340
380
  return {
341
381
  ...rest,
342
382
  lift: LISTEN_LIFT * (0.7 * v.micPhrase + 0.3 * v.micLevel),
383
+ listen: 1,
343
384
  };
344
385
 
345
386
  case "speaking":
@@ -459,12 +500,52 @@ function errorValue(cell: Cell, t: number, age: number) {
459
500
  return Math.max(0, appear - vanish) * flick;
460
501
  }
461
502
 
503
+ /** Marks which cells ink each letter of `text`, fitted to about her body's width; returns the letter count. */
504
+ function layWord(cells: Cell[], cw: number, ch: number, text: string) {
505
+ const rows = spell(text);
506
+ const s = Math.max(
507
+ 1,
508
+ Math.min(3, Math.floor((DESIGN * WORD_WIDTH) / (rows[0].length * cw))),
509
+ );
510
+ let letters = 0;
511
+ for (const cell of cells) {
512
+ cell.word = letterAt(rows, cell.dx, cell.dy, cw, ch, s);
513
+ letters = Math.max(letters, cell.word + 1);
514
+ }
515
+ return letters;
516
+ }
517
+
518
+ /** A word's letters `age` seconds into its showing: each lights in turn, holds, and goes out in turn. */
519
+ function wordValue(cell: Cell, t: number, age: number) {
520
+ if (cell.word < 0) return 0;
521
+ const jitter = cell.seed * 0.08;
522
+ const appear = smoothstep(
523
+ 0,
524
+ 1,
525
+ (age - cell.word * WORD_STEP_IN - jitter) / WORD_FADE_IN,
526
+ );
527
+ const vanish = smoothstep(
528
+ 0,
529
+ 1,
530
+ (age - WORD_HOLD - cell.word * WORD_STEP_OUT - jitter) / WORD_FADE_OUT,
531
+ );
532
+ const flick = 0.86 + Math.sin(t * 3.5 + cell.seed * 6) * 0.14;
533
+ return Math.max(0, appear - vanish) * flick;
534
+ }
535
+
462
536
  /**
463
537
  * One cell's brightness: the body, plus whichever behaviours are up. Her voice
464
538
  * hollows the body rather than replacing it, so there is nothing to dissolve
465
539
  * between when she starts or stops.
466
540
  */
467
- function fieldValue(cell: Cell, t: number, errAge: number, f: Field, v: Voice) {
541
+ function fieldValue(
542
+ cell: Cell,
543
+ t: number,
544
+ errAge: number,
545
+ wordAge: number,
546
+ f: Field,
547
+ v: Voice,
548
+ ) {
468
549
  let value =
469
550
  f.scale > 0.02
470
551
  ? idleValue(cell, t, f.lift, f.scale) * (1 - 0.6 * f.speech)
@@ -473,6 +554,7 @@ function fieldValue(cell: Cell, t: number, errAge: number, f: Field, v: Voice) {
473
554
  if (f.comet > 0.01) value += cometValue(cell, t) * f.comet;
474
555
  if (f.speech > 0.01) value += speechValue(cell, t, v, f.speech) * f.speech;
475
556
  if (f.err > 0.01) value += errorValue(cell, t, errAge) * f.err;
557
+ if (f.word > 0.01) value += wordValue(cell, t, wordAge) * f.word;
476
558
  return value;
477
559
  }
478
560
 
@@ -486,6 +568,7 @@ export function AsciiOrb({
486
568
  color = DEFAULT_COLOR,
487
569
  getSpectrum,
488
570
  getMicSpectrum,
571
+ word = null,
489
572
  }: AsciiOrbProps) {
490
573
  const hostRef = useRef<HTMLCanvasElement>(null);
491
574
  const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
@@ -539,8 +622,19 @@ export function AsciiOrb({
539
622
  comet: 0,
540
623
  speech: 0,
541
624
  err: 0,
625
+ word: 0,
626
+ listen: 0,
542
627
  });
543
628
 
629
+ /** The word being shown, when it started, and how many letters it has; its cells carry the rest */
630
+ const wordRef = useRef<{
631
+ text: string;
632
+ start: number;
633
+ letters: number;
634
+ } | null>(null);
635
+ /** Grid pitch in reference units, for laying a word onto cells that already exist */
636
+ const pitchRef = useRef({ cw: 1, ch: 1 });
637
+
544
638
  // grid is rebuilt only when size or density changes
545
639
  useEffect(() => {
546
640
  const host = hostRef.current;
@@ -556,9 +650,10 @@ export function AsciiOrb({
556
650
  // scale ERROR so it stays inside the box at any glyph size
557
651
  const errScale = Math.max(
558
652
  1,
559
- Math.floor((DESIGN * 0.92) / (ERROR_BITMAP[0].length * cwN)),
653
+ Math.floor((DESIGN * 0.92) / (ERROR_ROWS[0].length * cwN)),
560
654
  );
561
655
  boxRef.current = size;
656
+ pitchRef.current = { cw: cwN, ch: chN };
562
657
 
563
658
  const cells: Cell[] = [];
564
659
  const cols = Math.ceil(size / cw);
@@ -573,7 +668,7 @@ export function AsciiOrb({
573
668
  const dy = (y - size / 2) * norm;
574
669
  const dist = Math.hypot(dx, dy);
575
670
 
576
- const letter = errorLetterAt(dx, dy, cwN, chN, errScale);
671
+ const letter = letterAt(ERROR_ROWS, dx, dy, cwN, chN, errScale);
577
672
  if (letter < 0 && dist > FIELD_R) continue;
578
673
 
579
674
  cells.push({
@@ -590,11 +685,14 @@ export function AsciiOrb({
590
685
  emoji: hash(c * 2.7 + 31, r * 5.9 + 17) < EMOJI_RATIO,
591
686
  speck: hash(c * 4.1 + 23, r * 6.3 + 41),
592
687
  letter,
688
+ word: -1,
593
689
  });
594
690
  }
595
691
  }
596
692
 
597
693
  cellsRef.current = cells;
694
+ // a word showing while the grid changes is laid onto the new cells
695
+ if (wordRef.current) layWord(cells, cwN, chN, wordRef.current.text);
598
696
 
599
697
  // bucketing by level keeps fillStyle changes to one per level
600
698
  bucketsRef.current = {
@@ -650,6 +748,17 @@ export function AsciiOrb({
650
748
  modeRef.current = { mode, start: performance.now() * 0.001 };
651
749
  }, [mode]);
652
750
 
751
+ // a new word starts from its first letter, in place of one still showing
752
+ useEffect(() => {
753
+ if (!word) return;
754
+ const { cw, ch } = pitchRef.current;
755
+ wordRef.current = {
756
+ text: word.text,
757
+ start: performance.now() * 0.001,
758
+ letters: layWord(cellsRef.current, cw, ch, word.text),
759
+ };
760
+ }, [word]);
761
+
653
762
  // animation loop
654
763
  useEffect(() => {
655
764
  let raf = 0;
@@ -748,7 +857,25 @@ export function AsciiOrb({
748
857
 
749
858
  // the field eases toward what this mode wants, each channel on its own clock
750
859
  const cur = modeRef.current;
751
- const want = targetFor(cur.mode, t - cur.start, voice);
860
+ // a word takes the face from the mode until its last letter is out; an error keeps it
861
+ const shown = wordRef.current;
862
+ const wordAge = shown ? t - shown.start : 0;
863
+ const wording =
864
+ shown !== null &&
865
+ cur.mode !== "error" &&
866
+ wordAge < WORD_HOLD + shown.letters * WORD_STEP_OUT + WORD_FADE_OUT;
867
+ const want: Field = wording
868
+ ? {
869
+ scale: 0,
870
+ lift: 0,
871
+ gather: 0,
872
+ comet: 0,
873
+ speech: 0,
874
+ err: 0,
875
+ word: 1,
876
+ listen: 0,
877
+ }
878
+ : targetFor(cur.mode, t - cur.start, voice);
752
879
  const f = fieldRef.current;
753
880
  f.scale = toward(
754
881
  f.scale,
@@ -768,15 +895,20 @@ export function AsciiOrb({
768
895
  errAge += dt;
769
896
  }
770
897
  f.err = toward(f.err, want.err, RISE.err, FALL.err, dt);
771
- const keepGlyphSolid = f.err > 0.5;
898
+ f.word = toward(f.word, want.word, RISE.word, FALL.word, dt);
899
+ f.listen = toward(f.listen, want.listen, RISE.listen, FALL.listen, dt);
900
+ const solidError = f.err > 0.5;
901
+ const solidWord = f.word > 0.5;
772
902
  const rate = cs === "emojiOnly" ? EMOJI_CHAR_RATE : CHAR_RATE;
773
903
 
774
904
  const all = cellsRef.current;
775
905
  for (let ci = 0; ci < all.length; ci++) {
776
906
  const cell = all[ci];
777
- let v = fieldValue(cell, t, errAge, f, voice);
907
+ let v = fieldValue(cell, t, errAge, wordAge, f, voice);
778
908
 
779
- if (!(keepGlyphSolid && cell.letter >= 0)) {
909
+ if (
910
+ !((solidError && cell.letter >= 0) || (solidWord && cell.word >= 0))
911
+ ) {
780
912
  // per-cell brightness response breaks concentric rings; multiplicative, so empty (0) stays empty
781
913
  v *= 0.66 + cell.grain * 0.72;
782
914
  // slowly drifting noise on top
@@ -795,7 +927,18 @@ export function AsciiOrb({
795
927
  v = Math.max(v, 0.9);
796
928
  }
797
929
 
798
- const gate = cell.gap + Math.sin(t * 0.28 + cell.seed * 6.283) * 0.05;
930
+ const gate =
931
+ cell.gap +
932
+ Math.sin(t * 0.28 + cell.seed * 6.283) * 0.05 +
933
+ f.listen *
934
+ (LISTEN_THIN +
935
+ (hash(
936
+ Math.floor(t * LISTEN_POP_RATE + cell.seed * 3),
937
+ cell.seed * 97,
938
+ ) -
939
+ 0.5) *
940
+ LISTEN_POP *
941
+ voice.micLevel);
799
942
  if (v < gate) v = 0;
800
943
  }
801
944
 
@@ -13,6 +13,7 @@ import {
13
13
  import type { FaceKind } from "@/features/thursday/face.const";
14
14
  import type {
15
15
  CallStatus,
16
+ FaceWord,
16
17
  ThursdayFace,
17
18
  } from "@/features/thursday/thursday.schema";
18
19
  import { useResolvedTheme } from "@/hooks/use-theme";
@@ -21,7 +22,7 @@ import { cn } from "@/lib/utils";
21
22
  /**
22
23
  * The call face. Two renderers with nothing in common inside (an SVG
23
24
  * silhouette and a glyph canvas); the shared contract is CallStatus and `failed`,
24
- * and each adapter maps them. A third face is one FaceKind, one state table, one FACES entry.
25
+ * and each adapter maps them. `word` is the orb's alone. A third face is one FaceKind, one state table, one FACES entry.
25
26
  */
26
27
 
27
28
  export type FaceProps = {
@@ -29,6 +30,8 @@ export type FaceProps = {
29
30
  status: CallStatus;
30
31
  /** A call just failed to open or dropped. The status is idle by then; this says why the face is not at rest. */
31
32
  failed?: boolean;
33
+ /** A word `emote` put on the face. Only the orb draws one; the call offers `emote` only with the orb. */
34
+ word?: FaceWord | null;
32
35
  /** Box size in px, measured (see Face). */
33
36
  size: number;
34
37
  /** Audio bands, read once per animation frame, not through state. */
@@ -116,6 +119,7 @@ const ORB_LIGHT: [number, number, number] = [10, 10, 10];
116
119
  function OrbFace({
117
120
  status,
118
121
  failed,
122
+ word,
119
123
  size,
120
124
  getSpectrum,
121
125
  getMicSpectrum,
@@ -126,6 +130,7 @@ function OrbFace({
126
130
  return (
127
131
  <AsciiOrb
128
132
  mode={failed ? "error" : ORB_MODE[status]}
133
+ word={word}
129
134
  size={size}
130
135
  color={dark ? ORB_DARK : ORB_LIGHT}
131
136
  getSpectrum={getSpectrum}
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import { ShinyText } from "@/components/ui/shiny-text";
5
+ import type { CallMessage } from "@/features/thursday/thursday.schema";
6
+ import { capturesKeys } from "@/hooks/use-hotkey";
7
+ import { cn } from "@/lib/utils";
8
+ import { ThursdayMark } from "./thursday-mark";
9
+
10
+ /**
11
+ * Side captions (Captions › sides): the conversation beside the face, one turn
12
+ * a side at the face's middle line and the rest receding around it.
13
+ */
14
+
15
+ /** One speaker's words in a row, however many display groups Live cut them into. */
16
+ export type Turn = { id: string; role: CallMessage["role"]; text: string };
17
+
18
+ export function turnsOf(messages: CallMessage[]) {
19
+ const turns: Turn[] = [];
20
+ for (const message of messages) {
21
+ const last = turns.at(-1);
22
+ if (last?.role === message.role) last.text = `${last.text} ${message.text}`;
23
+ else turns.push({ ...message });
24
+ }
25
+ return turns;
26
+ }
27
+
28
+ /** Wheel delta per turn: a turn is a paragraph, so a trackpad flick moves one, not three. */
29
+ const WHEEL_STEP = 120;
30
+
31
+ /** How long the wheel keeps what it has gathered before starting over. */
32
+ const WHEEL_SETTLE_MS = 220;
33
+
34
+ /**
35
+ * Which turn the side captions are on: the latest, or as far back as the arrow
36
+ * keys or the wheel took them. A new turn brings the focus back to now, as the
37
+ * centre caption does with new text. The keys are the window's while the
38
+ * captions are up, except in a field, a key capture or a dialog.
39
+ */
40
+ export function useTurnFocus(turns: Turn[], enabled: boolean) {
41
+ const [back, setBack] = useState(0);
42
+ const count = useRef(turns.length);
43
+ const drag = useRef(0);
44
+ const settle = useRef<ReturnType<typeof setTimeout> | null>(null);
45
+ const furthest = Math.max(0, turns.length - 1);
46
+
47
+ useEffect(() => {
48
+ if (turns.length === count.current) return;
49
+ count.current = turns.length;
50
+ setBack(0);
51
+ }, [turns.length]);
52
+
53
+ const step = useCallback(
54
+ (by: number) => setBack((was) => Math.min(furthest, Math.max(0, was + by))),
55
+ [furthest],
56
+ );
57
+
58
+ useEffect(() => {
59
+ if (!enabled) return;
60
+ const onKeyDown = (event: KeyboardEvent) => {
61
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
62
+ if (event.defaultPrevented || event.altKey || event.ctrlKey) return;
63
+ if (event.metaKey || event.shiftKey || capturesKeys(event.target)) return;
64
+ if ((event.target as HTMLElement | null)?.closest?.('[role="dialog"]'))
65
+ return;
66
+ event.preventDefault();
67
+ step(event.key === "ArrowUp" ? 1 : -1);
68
+ };
69
+ window.addEventListener("keydown", onKeyDown);
70
+ return () => window.removeEventListener("keydown", onKeyDown);
71
+ }, [enabled, step]);
72
+
73
+ useEffect(
74
+ () => () => {
75
+ if (settle.current) clearTimeout(settle.current);
76
+ },
77
+ [],
78
+ );
79
+
80
+ const onWheel = (event: React.WheelEvent) => {
81
+ drag.current += event.deltaY;
82
+ const steps = Math.trunc(drag.current / WHEEL_STEP);
83
+ if (steps !== 0) {
84
+ drag.current -= steps * WHEEL_STEP;
85
+ step(-steps);
86
+ }
87
+ if (settle.current) clearTimeout(settle.current);
88
+ settle.current = setTimeout(() => {
89
+ drag.current = 0;
90
+ }, WHEEL_SETTLE_MS);
91
+ };
92
+
93
+ return { focus: Math.max(0, turns.length - 1 - back), back, onWheel };
94
+ }
95
+
96
+ /**
97
+ * How the turns around the focus recede (canvas "Thursday Call Polish" D3, user
98
+ * 09-17): each turn away steps back `DEPTH` px under `PERSPECTIVE`, so it draws
99
+ * smaller, softer and a little higher. These are the drawing, not settings.
100
+ */
101
+ const SIDE_PERSPECTIVE = 1000;
102
+ const SIDE_DEPTH = 700;
103
+ /** Space between turns at the focus's size, and the extra that lifts earlier turns. */
104
+ const SIDE_GAP = 22;
105
+ const SIDE_RISE = 10;
106
+ const SIDE_BLUR = 1.1;
107
+ /** Ink by turns away on the focused side; the other side's turn level with the focus is `SIDE_PAIR`. */
108
+ const SIDE_FADE = [1, 0.46, 0.28, 0.16];
109
+ const SIDE_PAIR = 0.62;
110
+ /** Turns farther than this are not drawn. */
111
+ const SIDE_REACH = 3;
112
+
113
+ /**
114
+ * The conversation beside the face: her turns on the left, yours on the right,
115
+ * no plates. Each side holds its turn level with the focus at the face's middle
116
+ * — the focus itself, or that side's last turn before it — and the rest recede
117
+ * above and below. Out of focus a turn keeps three lines; the turn being said
118
+ * shines its name. Anchored outside the face box so nothing covers the face.
119
+ */
120
+ export function SideCaptions({
121
+ turns,
122
+ focus,
123
+ live,
124
+ }: {
125
+ turns: Turn[];
126
+ focus: number;
127
+ /** The last turn is still being said. */
128
+ live: boolean;
129
+ }) {
130
+ return (
131
+ <>
132
+ <SideColumn turns={turns} focus={focus} live={live} role="assistant" />
133
+ <SideColumn turns={turns} focus={focus} live={live} role="user" />
134
+ </>
135
+ );
136
+ }
137
+
138
+ function SideColumn({
139
+ turns,
140
+ focus,
141
+ live,
142
+ role,
143
+ }: {
144
+ turns: Turn[];
145
+ focus: number;
146
+ live: boolean;
147
+ role: Turn["role"];
148
+ }) {
149
+ const mine = role === "user";
150
+ // Heights as laid out, before any transform: text grows while it is said, and
151
+ // leaving focus clamps a turn to three lines
152
+ const [heights, setHeights] = useState<Record<string, number>>({});
153
+ const watch = useRef<ResizeObserver | null>(null);
154
+ useEffect(() => () => watch.current?.disconnect(), []);
155
+ const measure = useCallback((node: HTMLDivElement | null) => {
156
+ if (!node) return;
157
+ watch.current ??= new ResizeObserver((entries) =>
158
+ setHeights((was) => {
159
+ let next = was;
160
+ for (const entry of entries) {
161
+ const box = entry.target as HTMLElement;
162
+ const id = box.dataset.turn;
163
+ if (!id || was[id] === box.offsetHeight) continue;
164
+ if (next === was) next = { ...was };
165
+ next[id] = box.offsetHeight;
166
+ }
167
+ return next;
168
+ }),
169
+ );
170
+ watch.current.observe(node);
171
+ return () => watch.current?.unobserve(node);
172
+ }, []);
173
+
174
+ const own = turns.flatMap((turn, index) =>
175
+ turn.role === role ? [{ turn, index }] : [],
176
+ );
177
+ if (!own.length) return null;
178
+ const focused = turns[focus]?.role === role;
179
+ const level = focused
180
+ ? focus
181
+ : (own.findLast((one) => one.index <= focus) ?? own[0]).index;
182
+ const at = own.findIndex((one) => one.index === level);
183
+ const scale = (k: number) =>
184
+ SIDE_PERSPECTIVE / (SIDE_PERSPECTIVE + SIDE_DEPTH * Math.abs(k - at));
185
+ const tall = (k: number) => heights[own[k].turn.id] ?? 42;
186
+ // Stacked by their drawn size, then placed back by it: perspective pulls each toward the middle line
187
+ const drawn = own.map(() => 0);
188
+ for (let k = at - 1; k >= 0; k--) {
189
+ drawn[k] =
190
+ drawn[k + 1] -
191
+ ((tall(k) * scale(k)) / 2 +
192
+ SIDE_GAP * scale(k + 1) +
193
+ SIDE_RISE +
194
+ (tall(k + 1) * scale(k + 1)) / 2);
195
+ }
196
+ for (let k = at + 1; k < own.length; k++) {
197
+ drawn[k] =
198
+ drawn[k - 1] +
199
+ ((tall(k - 1) * scale(k - 1)) / 2 +
200
+ SIDE_GAP * scale(k - 1) +
201
+ (tall(k) * scale(k)) / 2);
202
+ }
203
+
204
+ return (
205
+ <div
206
+ className={cn(
207
+ "pointer-events-none absolute top-1/2 h-0 w-[min(25rem,24vw)] [perspective:1000px]",
208
+ // the vanishing point is the face's edge on the middle line: what recedes goes toward her
209
+ mine
210
+ ? "left-full ml-11 [perspective-origin:0_0]"
211
+ : "right-full mr-11 [perspective-origin:100%_0]",
212
+ )}
213
+ >
214
+ {own.map(({ turn, index }, k) => {
215
+ const away = Math.abs(k - at);
216
+ const fade = SIDE_FADE[away] ?? 0;
217
+ const ink =
218
+ index === focus
219
+ ? 1
220
+ : focused
221
+ ? fade
222
+ : index === level
223
+ ? SIDE_PAIR
224
+ : fade * 0.8;
225
+ const name = mine ? "You" : "Thursday";
226
+ return (
227
+ <div
228
+ key={turn.id}
229
+ ref={measure}
230
+ data-turn={turn.id}
231
+ aria-hidden={away > SIDE_REACH}
232
+ style={{
233
+ transform: `translateY(-50%) translateY(${drawn[k] / scale(k)}px) translateZ(${-SIDE_DEPTH * away}px)`,
234
+ opacity: away > SIDE_REACH ? 0 : ink,
235
+ filter: away ? `blur(${away * SIDE_BLUR}px)` : undefined,
236
+ }}
237
+ className={cn(
238
+ "absolute top-0 w-max max-w-full text-base leading-[26px] text-foreground transition-[transform,opacity,filter] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none",
239
+ away > SIDE_REACH ? "invisible" : "pointer-events-auto",
240
+ mine ? "left-0" : "right-0",
241
+ )}
242
+ >
243
+ <div
244
+ className={cn(
245
+ "mb-1.5 flex h-4 items-center gap-1.5 text-xs font-medium",
246
+ !mine && "justify-end",
247
+ )}
248
+ >
249
+ {mine ? (
250
+ <span className="size-2 rounded-full ring-[1.5px] ring-foreground ring-inset" />
251
+ ) : (
252
+ <ThursdayMark size={14} />
253
+ )}
254
+ {live && index === turns.length - 1 ? (
255
+ <ShinyText text={name} />
256
+ ) : (
257
+ <span>{name}</span>
258
+ )}
259
+ </div>
260
+ <p
261
+ className={cn(
262
+ "break-keep text-pretty",
263
+ index !== focus && "line-clamp-3",
264
+ )}
265
+ >
266
+ {turn.text}
267
+ </p>
268
+ </div>
269
+ );
270
+ })}
271
+ </div>
272
+ );
273
+ }