@hypit/hypit 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +1 -1
  2. package/examples/semantic-composition/README.md +2 -1
  3. package/examples/semantic-composition/chat.svml +1 -1
  4. package/examples/semantic-composition/hypit.runtime.json +1 -1
  5. package/package.json +4 -1
  6. package/packages/caption-fine/README.md +7 -0
  7. package/packages/cli/src/view.ts +4 -3
  8. package/packages/film/src/surface.ts +4 -1
  9. package/packages/provider-hypihub/README.md +17 -3
  10. package/packages/provider-hypihub/src/oauth.ts +1 -1
  11. package/packages/provider-hypihub/src/provider.ts +35 -9
  12. package/packages/provider-hypihub/src/upload.ts +37 -4
  13. package/packages/runtime-local/README.md +17 -0
  14. package/packages/script/README.md +15 -3
  15. package/packages/script/src/manifest.ts +2 -1
  16. package/packages/script/src/parser.ts +45 -6
  17. package/packages/studio/LOCALIZATION.md +98 -0
  18. package/packages/studio/README.md +13 -0
  19. package/packages/studio/locales/en.json +197 -0
  20. package/packages/studio/locales/zh-CN.json +191 -0
  21. package/packages/studio/src/localization-node.ts +46 -0
  22. package/packages/studio/src/localization.ts +85 -0
  23. package/packages/studio/src/style.css +15 -3
  24. package/packages/studio/src/ui/artifact-name.ts +8 -6
  25. package/packages/studio/src/ui/artifact-preview.ts +5 -4
  26. package/packages/studio/src/ui/code.ts +17 -16
  27. package/packages/studio/src/ui/comments.ts +26 -25
  28. package/packages/studio/src/ui/dropdown.ts +68 -0
  29. package/packages/studio/src/ui/i18n.ts +109 -0
  30. package/packages/studio/src/ui/icons.ts +1 -0
  31. package/packages/studio/src/ui/library.ts +76 -65
  32. package/packages/studio/src/ui/main.ts +84 -131
  33. package/packages/studio/src/ui/scrub-preview.ts +8 -6
  34. package/packages/studio/src/ui/stage.ts +28 -27
  35. package/packages/studio/src/ui/timeline.ts +17 -14
  36. package/packages/studio/start.ts +39 -22
@@ -142,7 +142,7 @@ button:focus-visible, textarea:focus-visible {
142
142
  white-space: nowrap;
143
143
  }
144
144
  .topbar-right { justify-self: end; display: flex; align-items: center; min-width: 0; }
145
- .view-tabs { display: flex; align-items: center; gap: 2px; margin-left: 16px; padding: 2px; border-radius: 5px; background: var(--subtab-surface); }
145
+ .view-tabs { display: flex; flex: 0 0 auto; white-space: nowrap; align-items: center; gap: 2px; margin-left: 16px; padding: 2px; border-radius: 5px; background: var(--subtab-surface); }
146
146
  .view-tabs button {
147
147
  display: flex; align-items: center; gap: 7px; height: 24px; padding: 0 10px;
148
148
  border: 0; border-radius: 4px; color: var(--text-3); background: transparent;
@@ -150,6 +150,18 @@ button:focus-visible, textarea:focus-visible {
150
150
  .view-tabs button[aria-selected="true"] { color: var(--subtab-active-text); background: var(--subtab-active-surface); }
151
151
  .view-tabs button:focus-visible { outline: 1px solid #8a8f99; outline-offset: -1px; }
152
152
  .view-tabs .icon { width: 14px; height: 14px; }
153
+ .parameter-select.language-select { width: auto; flex: 0 0 auto; margin-left: 10px; }
154
+ .language-select .language-toggle {
155
+ display: grid; place-items: center; grid-template-columns: 1fr;
156
+ width: 28px; height: 28px; padding: 0;
157
+ color: var(--subtab-active-text); background: var(--subtab-surface);
158
+ border-color: var(--line);
159
+ }
160
+ .language-toggle .icon { width: 16px; height: 16px; }
161
+ .language-select .parameter-select-menu { width: max-content; min-width: 150px; max-width: 240px; }
162
+ .language-select .parameter-select-option { font-family: var(--font-ui); text-align: start; }
163
+ .topbar { position: relative; z-index: 40; }
164
+
153
165
  .meta {
154
166
  color: var(--text-3);
155
167
  font: 500 11px/1 var(--font-ui);
@@ -1560,8 +1572,8 @@ button:focus-visible, textarea:focus-visible {
1560
1572
  .failure:empty { display: none; }
1561
1573
 
1562
1574
  @media (max-width: 920px) {
1563
- .meta, .topbar-right { display: none; }
1564
- .topbar { grid-template-columns: 1fr minmax(120px, 1fr); }
1575
+ .meta, .topbar-right .status { display: none; }
1576
+ .topbar { grid-template-columns: auto minmax(0, 1fr) auto; }
1565
1577
  .upper-shell { grid-template-columns: minmax(320px, var(--source-width)) 8px minmax(300px, 1fr); }
1566
1578
  .workspace-handle, .workspace-panel { display: none; }
1567
1579
  }
@@ -1,3 +1,5 @@
1
+ import { uiAttr, uiText, userText } from "./i18n.js";
2
+
1
3
  /** Two-line label with an expanding editor; the caller owns persistence. */
2
4
  export function createArtifactName(options: {
3
5
  readonly name: string;
@@ -15,7 +17,7 @@ export function createArtifactName(options: {
15
17
  text.className = "artifact-name-text";
16
18
  text.textContent = name;
17
19
  label.append(text);
18
- label.title = options.editable ? `${name}\nDouble-click or press F2 to rename` : `${name}\nNaming is available when this Build finishes`;
20
+ uiAttr(label, "title", options.editable ? "library.rename-hint" : "library.name-pending", { name });
19
21
  element.append(label);
20
22
  label.addEventListener("click", (event) => { event.stopPropagation(); options.select(); });
21
23
  let editing = false;
@@ -30,7 +32,7 @@ export function createArtifactName(options: {
30
32
  field.className = "artifact-name-editor";
31
33
  field.value = name;
32
34
  field.rows = 1;
33
- field.setAttribute("aria-label", "Display name");
35
+ uiAttr(field, "aria-label", "library.display-name");
34
36
  const error = document.createElement("span");
35
37
  error.className = "artifact-name-error";
36
38
  error.setAttribute("role", "status");
@@ -53,17 +55,17 @@ export function createArtifactName(options: {
53
55
  if (!editing || saving) return;
54
56
  const value = field.value.trim();
55
57
  if (value === name) { close(); return; }
56
- if (value.length === 0) { error.textContent = "Enter a name."; return; }
58
+ if (value.length === 0) { uiText(error, "library.enter-name"); return; }
57
59
  saving = true;
58
60
  field.readOnly = true;
59
- error.textContent = "Saving…";
61
+ uiText(error, "common.saving");
60
62
  try {
61
63
  name = await options.save(value);
62
64
  text.textContent = name;
63
- label.title = `${name}\nDouble-click or press F2 to rename`;
65
+ uiAttr(label, "title", "library.rename-hint", { name });
64
66
  close();
65
67
  } catch (cause) {
66
- error.textContent = cause instanceof Error ? cause.message : String(cause);
68
+ userText(error, cause instanceof Error ? cause.message : String(cause));
67
69
  } finally {
68
70
  saving = false;
69
71
  field.readOnly = false;
@@ -1,3 +1,4 @@
1
+ import { uiAttr, uiText, userText } from "./i18n.js";
1
2
  import type { StudioArtifactView } from "../shared.js";
2
3
  import { audioPreview } from "./material-preview.js";
3
4
 
@@ -35,7 +36,7 @@ export function createArtifactPreview(changed: () => void) {
35
36
  error.setAttribute("role", "status");
36
37
  const failed = () => {
37
38
  if (generation !== current) return;
38
- error.textContent = "This media could not be opened in the browser.";
39
+ uiText(error, "player.media-failed");
39
40
  error.hidden = false;
40
41
  changed();
41
42
  };
@@ -61,14 +62,14 @@ export function createArtifactPreview(changed: () => void) {
61
62
  if (audio) {
62
63
  const wave = document.createElement("div");
63
64
  wave.className = "stage-audio-wave";
64
- wave.textContent = "Audio";
65
+ uiText(wave, "library.audio");
65
66
  element.append(wave);
66
67
  void audioPreview(url).then((waveform) => {
67
68
  if (generation !== current || waveform === undefined) return;
68
69
  const image = document.createElement("img");
69
70
  image.src = waveform;
70
- image.alt = "Audio waveform";
71
- wave.replaceChildren(image);
71
+ uiAttr(image, "alt", "player.audio-waveform");
72
+ userText(wave, ""); wave.replaceChildren(image);
72
73
  });
73
74
  }
74
75
  }
@@ -1,3 +1,4 @@
1
+ import { t, userText, uiAttribute, uiText, uiAttr } from "./i18n.js";
1
2
  import type { Range, StudioSnapshot, StudioSourceView } from "../shared.js";
2
3
  import { icon, setIcon } from "./icons.js";
3
4
  import { intentTones } from "./markers.js";
@@ -77,10 +78,10 @@ export function createCodePane(): CodePane {
77
78
  <strong class="code-location" data-path></strong>
78
79
  <div class="code-actions">
79
80
  <span class="code-save-state" data-save-state></span>
80
- <button type="button" class="icon-button code-wrap" data-wrap aria-label="Wrap lines" aria-pressed="false" title="Wrap lines">
81
+ <button type="button" class="icon-button code-wrap" data-wrap ${uiAttribute("aria-label", "source.wrap-lines")} aria-pressed="false" ${uiAttribute("title", "source.wrap-lines")}>
81
82
  ${icon("wrap")}
82
83
  </button>
83
- <button type="button" class="icon-button code-mode" data-mode aria-label="Edit source" title="Edit source">
84
+ <button type="button" class="icon-button code-mode" data-mode ${uiAttribute("aria-label", "source.edit-source")} ${uiAttribute("title", "source.edit-source")}>
84
85
  <span data-mode-icon>${icon("edit")}</span>
85
86
  </button>
86
87
  </div>`;
@@ -88,7 +89,7 @@ export function createCodePane(): CodePane {
88
89
  element.className = "code";
89
90
  element.innerHTML = `
90
91
  <div class="code-scroll"><svg class="range-canvas" aria-hidden="true"></svg></div>
91
- <textarea class="code-editor" data-editor spellcheck="false" aria-label="SVML source"></textarea>`;
92
+ <textarea class="code-editor" data-editor spellcheck="false" ${uiAttribute("aria-label", "source.svml-source")}></textarea>`;
92
93
  const path = toolbar.querySelector<HTMLElement>("[data-path]")!;
93
94
  const scroll = element.querySelector<HTMLElement>(".code-scroll")!;
94
95
  const canvas = element.querySelector<SVGSVGElement>(".range-canvas")!;
@@ -116,8 +117,8 @@ export function createCodePane(): CodePane {
116
117
  wrapped = value;
117
118
  element.classList.toggle("is-wrapped", wrapped);
118
119
  wrap.setAttribute("aria-pressed", String(wrapped));
119
- wrap.setAttribute("aria-label", wrapped ? "Keep lines unwrapped" : "Wrap lines");
120
- wrap.title = wrapped ? "Keep lines unwrapped" : "Wrap lines";
120
+ uiAttr(wrap, "aria-label", wrapped ? "source.keep-lines-unwrapped" : "source.wrap-lines");
121
+ uiAttr(wrap, "title", wrapped ? "source.keep-lines-unwrapped" : "source.wrap-lines");
121
122
  window.requestAnimationFrame(draw);
122
123
  };
123
124
 
@@ -125,8 +126,8 @@ export function createCodePane(): CodePane {
125
126
  editing = value;
126
127
  element.classList.toggle("is-editing", editing);
127
128
  setIcon(modeIcon, editing ? "eye" : "edit");
128
- mode.setAttribute("aria-label", editing ? "Read source" : "Edit source");
129
- mode.title = editing ? "Read source" : "Edit source";
129
+ uiAttr(mode, "aria-label", editing ? "source.read-source" : "source.edit-source");
130
+ uiAttr(mode, "title", editing ? "source.read-source" : "source.edit-source");
130
131
  if (editing) {
131
132
  editor.value = sourceText;
132
133
  editingRevision = snapshotRevision;
@@ -143,7 +144,7 @@ export function createCodePane(): CodePane {
143
144
  saveInFlight = true;
144
145
  let accepted = false;
145
146
  let acceptedRevision = savingRevision;
146
- saveState.textContent = "Saving";
147
+ uiText(saveState, "common.saving");
147
148
  saveState.className = "code-save-state saving";
148
149
  try {
149
150
  const response = await fetch("/__studio/source", {
@@ -153,7 +154,7 @@ export function createCodePane(): CodePane {
153
154
  });
154
155
  if (!response.ok) {
155
156
  const reason = await response.text();
156
- throw new Error(reason || `Save failed (${response.status})`);
157
+ throw new Error(reason || t("source.save-failed-status", { status: response.status }));
157
158
  }
158
159
  const result = await response.json() as { readonly revision?: unknown };
159
160
  if (typeof result.revision === "number") acceptedRevision = result.revision;
@@ -163,15 +164,15 @@ export function createCodePane(): CodePane {
163
164
  sourceText = savingText;
164
165
  dirty = false;
165
166
  }
166
- saveState.textContent = "Saved";
167
+ uiText(saveState, "common.saved");
167
168
  saveState.className = "code-save-state saved";
168
169
  window.setTimeout(() => {
169
- if (!dirty) saveState.textContent = "";
170
+ if (!dirty) userText(saveState, "");
170
171
  }, 1200);
171
172
  } catch (error) {
172
- saveState.textContent = error instanceof Error && error.message.includes("changed")
173
- ? "Changed outside Studio"
174
- : "Save failed";
173
+ uiText(saveState, error instanceof Error && error.message.includes("changed")
174
+ ? "source.changed-outside-studio"
175
+ : "common.save-failed");
175
176
  saveState.className = "code-save-state error";
176
177
  } finally {
177
178
  saveInFlight = false;
@@ -189,7 +190,7 @@ export function createCodePane(): CodePane {
189
190
  wrap.addEventListener("click", () => setWrapped(!wrapped));
190
191
  editor.addEventListener("input", () => {
191
192
  dirty = true;
192
- saveState.textContent = "Unsaved";
193
+ uiText(saveState, "source.unsaved");
193
194
  saveState.className = "code-save-state dirty";
194
195
  if (saveTimer !== undefined) window.clearTimeout(saveTimer);
195
196
  saveTimer = window.setTimeout(() => void save(), 480);
@@ -308,7 +309,7 @@ export function createCodePane(): CodePane {
308
309
  imports: [],
309
310
  };
310
311
  if (activeSource !== undefined && activeSource.path !== sourceFile.path && dirty) {
311
- saveState.textContent = "Saving before switch";
312
+ uiText(saveState, "source.saving-before-switch");
312
313
  saveState.className = "code-save-state saving";
313
314
  void save();
314
315
  return false;
@@ -1,3 +1,4 @@
1
+ import { t, uiLabel, uiAttribute, uiText, uiAttr, type Message } from "./i18n.js";
1
2
  import { feedbackClock } from "../feedback.js";
2
3
  import type { FeedbackComment, FeedbackMutation, FeedbackView } from "../feedback.js";
3
4
  import { icon } from "./icons.js";
@@ -11,28 +12,28 @@ export function createComments(store: Store, stage: Stage) {
11
12
  element.className = "comments-panel";
12
13
  element.innerHTML = `
13
14
  <div class="pane-heading comments-heading">
14
- <div class="pane-title">${icon("comments")}<h2>Comments</h2><span data-count></span></div>
15
- <button type="button" class="comments-icon" data-refresh aria-label="Refresh comments" title="Refresh comments">${icon("refresh")}</button>
15
+ <div class="pane-title">${icon("comments")}<h2>${uiLabel("app.comments")}</h2><span data-count></span></div>
16
+ <button type="button" class="comments-icon" data-refresh ${uiAttribute("aria-label", "comments.refresh-comments")} ${uiAttribute("title", "comments.refresh-comments")}>${icon("refresh")}</button>
16
17
  </div>
17
- <div class="comments-filters" role="group" aria-label="Filter comments">
18
- <button type="button" data-filter="all" aria-pressed="true">All</button>
19
- <button type="button" data-filter="open" aria-pressed="false">Open</button>
20
- <button type="button" data-filter="done" aria-pressed="false">Done</button>
18
+ <div class="comments-filters" role="group" ${uiAttribute("aria-label", "comments.filter-comments")}>
19
+ <button type="button" data-filter="all" aria-pressed="true">${uiLabel("comments.all")}</button>
20
+ <button type="button" data-filter="open" aria-pressed="false">${uiLabel("comments.open")}</button>
21
+ <button type="button" data-filter="done" aria-pressed="false">${uiLabel("comments.done")}</button>
21
22
  </div>
22
23
  <div class="comments-error" role="status" aria-live="polite" hidden></div>
23
- <div class="comments-list" aria-label="Comments"></div>
24
+ <div class="comments-list" ${uiAttribute("aria-label", "app.comments")}></div>
24
25
  <form class="comment-composer">
25
26
  <div class="comment-anchor">
26
- <button type="button" class="comment-time" data-current-time title="Use current frame" aria-label="Use current frame">${icon("when")}<span>00:00.00</span></button>
27
+ <button type="button" class="comment-time" data-current-time ${uiAttribute("title", "comments.use-current-frame")} ${uiAttribute("aria-label", "comments.use-current-frame")}>${icon("when")}<span>00:00.00</span></button>
27
28
  </div>
28
29
  <div class="comment-input">
29
- <textarea rows="3" aria-label="Write a comment" placeholder="What would you like to change?" spellcheck="true" enterkeyhint="send"></textarea>
30
+ <textarea rows="3" ${uiAttribute("aria-label", "comments.write-a-comment")} ${uiAttribute("placeholder", "comments.placeholder")} spellcheck="true" enterkeyhint="send"></textarea>
30
31
  <div class="comment-input-actions">
31
32
  <div class="comment-tools">
32
- <button type="button" class="comments-icon" aria-label="Emoji coming later" title="Emoji — coming later" disabled>${icon("smile")}</button>
33
- <button type="button" class="comments-icon" aria-label="Draw coming later" title="Draw — coming later" disabled>${icon("edit")}</button>
33
+ <button type="button" class="comments-icon" ${uiAttribute("aria-label", "comments.emoji-soon")} ${uiAttribute("title", "comments.emoji-soon")} disabled>${icon("smile")}</button>
34
+ <button type="button" class="comments-icon" ${uiAttribute("aria-label", "comments.draw-soon")} ${uiAttribute("title", "comments.draw-soon")} disabled>${icon("edit")}</button>
34
35
  </div>
35
- <button class="comment-submit" type="submit" aria-label="Send comment" title="Send comment (Enter)" disabled>${icon("send")}</button>
36
+ <button class="comment-submit" type="submit" ${uiAttribute("aria-label", "comments.send-comment")} ${uiAttribute("title", "comments.send-shortcut")} disabled>${icon("send")}</button>
36
37
  </div>
37
38
  </div>
38
39
  </form>`;
@@ -68,10 +69,10 @@ export function createComments(store: Store, stage: Stage) {
68
69
  submit.disabled = busy || view === undefined || anchor === undefined || input.value.trim().length === 0;
69
70
  };
70
71
  const capture = (): void => { stage.pause(); anchor = currentAnchor(); renderAnchor(); };
71
- const button = (name: string, label: string, action: () => void): HTMLButtonElement => {
72
+ const button = (name: string, label: Message, action: () => void): HTMLButtonElement => {
72
73
  const node = document.createElement("button");
73
74
  node.type = "button"; node.className = "comments-icon";
74
- node.innerHTML = icon(name); node.title = label; node.setAttribute("aria-label", label);
75
+ node.innerHTML = icon(name); uiAttr(node, "title", label); uiAttr(node, "aria-label", label);
75
76
  node.disabled = busy;
76
77
  node.addEventListener("click", (event) => { event.stopPropagation(); action(); });
77
78
  return node;
@@ -101,8 +102,8 @@ export function createComments(store: Store, stage: Stage) {
101
102
  const empty = document.createElement("div");
102
103
  empty.className = "comments-empty";
103
104
  empty.innerHTML = `${icon(filter === "done" ? "check" : "comments")}<strong></strong><p></p>`;
104
- empty.querySelector("strong")!.textContent = view === undefined ? "Loading comments" : filter === "done" ? "No completed comments" : filter === "open" ? "All caught up" : "A note starts here";
105
- empty.querySelector("p")!.textContent = filter === "all" ? "Pause on a frame and describe what you would like to change." : "Switch filters to see the other comments.";
105
+ uiText(empty.querySelector("strong")!, view === undefined ? "comments.loading-comments" : filter === "done" ? "comments.no-completed-comments" : filter === "open" ? "comments.caught-up" : "comments.empty");
106
+ uiText(empty.querySelector("p")!, filter === "all" ? "comments.empty-hint" : "comments.filter-hint");
106
107
  list.append(empty);
107
108
  }
108
109
  for (const { comment, number } of comments) {
@@ -114,31 +115,31 @@ export function createComments(store: Store, stage: Stage) {
114
115
  const label = document.createElement("span"); label.className = "comment-number"; label.textContent = `#${number}`;
115
116
  const time = document.createElement("span"); time.className = "comment-time"; time.textContent = feedbackClock(comment.at);
116
117
  location.append(time);
117
- location.title = "Go to this frame"; location.addEventListener("click", () => locate(comment));
118
+ uiAttr(location, "title", "comments.go-to-this-frame"); location.addEventListener("click", () => locate(comment));
118
119
  row.addEventListener("click", (event) => {
119
120
  if ((event.target as Element).closest("button, form") !== null || window.getSelection()?.isCollapsed === false) return;
120
121
  locate(comment);
121
122
  });
122
123
  const actions = document.createElement("div"); actions.className = "comment-actions";
123
- const resolve = button("check", comment.resolved ? "Reopen comment" : "Mark complete", () => {
124
+ const resolve = button("check", comment.resolved ? "comments.reopen-comment" : "comments.mark-complete", () => {
124
125
  void mutate({ type: "replace", before: comment, comment: { ...comment, resolved: !comment.resolved } });
125
126
  });
126
127
  resolve.classList.toggle("is-done", comment.resolved === true);
127
128
  resolve.setAttribute("aria-pressed", String(comment.resolved === true));
128
- actions.append(label, resolve, button("edit", "Edit comment", () => {
129
+ actions.append(label, resolve, button("edit", "comments.edit-comment", () => {
129
130
  editing = { id: comment.id, before: comment, text: comment.text }; selected = comment.id; render();
130
131
  list.querySelector<HTMLTextAreaElement>(".comment-edit textarea")?.focus();
131
- }), button("trash", "Delete comment", () => { void mutate({ type: "delete", before: comment }); }));
132
+ }), button("trash", "comments.delete-comment", () => { void mutate({ type: "delete", before: comment }); }));
132
133
  head.append(location, actions); row.append(head);
133
134
  if (editing?.id === comment.id) {
134
135
  const edit = document.createElement("form"); edit.className = "comment-edit";
135
136
  const text = document.createElement("textarea"); text.rows = 3; text.value = editing.text;
136
- text.setAttribute("aria-label", "Edit comment text");
137
+ uiAttr(text, "aria-label", "comments.edit-comment-text");
137
138
  text.addEventListener("input", () => { if (editing !== undefined) editing.text = text.value; });
138
139
  const controls = document.createElement("div"); controls.className = "comment-edit-actions";
139
- const cancel = document.createElement("button"); cancel.type = "button"; cancel.textContent = "Cancel";
140
+ const cancel = document.createElement("button"); cancel.type = "button"; uiText(cancel, "common.cancel");
140
141
  cancel.addEventListener("click", () => { editing = undefined; render(); });
141
- const save = document.createElement("button"); save.type = "submit"; save.textContent = "Save"; save.disabled = busy;
142
+ const save = document.createElement("button"); save.type = "submit"; uiText(save, "common.save"); save.disabled = busy;
142
143
  controls.append(cancel, save); edit.append(text, controls);
143
144
  edit.addEventListener("submit", (event) => {
144
145
  event.preventDefault();
@@ -161,7 +162,7 @@ export function createComments(store: Store, stage: Stage) {
161
162
  try {
162
163
  const response = await fetch("/__studio/feedback");
163
164
  const data = await response.json() as FeedbackView & { error?: string };
164
- if (!response.ok) throw new Error(data.error ?? "Could not read comments.");
165
+ if (!response.ok) throw new Error(data.error ?? t("comments.read-failed"));
165
166
  if (request !== fetchId) return;
166
167
  if (JSON.stringify(view) !== JSON.stringify(data)) { view = data; render(); }
167
168
  showError(); renderAnchor();
@@ -173,7 +174,7 @@ export function createComments(store: Store, stage: Stage) {
173
174
  try {
174
175
  const response = await fetch("/__studio/feedback", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(mutation) });
175
176
  const data = await response.json() as FeedbackView & { error?: string };
176
- if (!response.ok) throw new Error(data.error ?? "Could not save comment.");
177
+ if (!response.ok) throw new Error(data.error ?? t("comments.save-failed"));
177
178
  ++fetchId; view = data; busy = false; saved?.();
178
179
  if (mutation.type === "delete" && selected === mutation.before.id) selected = undefined;
179
180
  if (mutation.type === "delete" && editing?.id === mutation.before.id) editing = undefined;
@@ -0,0 +1,68 @@
1
+ /** Shared listbox interaction for Studio controls. Callers own the options and values. */
2
+ export function bindDropdown(control: HTMLElement, trigger: HTMLButtonElement, menu: HTMLElement, options: readonly HTMLButtonElement[]) {
3
+ const close = (restoreFocus = false): void => {
4
+ control.classList.remove("open", "open-up");
5
+ trigger.setAttribute("aria-expanded", "false");
6
+ menu.hidden = true;
7
+ if (restoreFocus) trigger.focus();
8
+ };
9
+ const open = (focus: "selected" | "first" | "last" = "selected"): void => {
10
+ control.classList.add("open");
11
+ trigger.setAttribute("aria-expanded", "true");
12
+ menu.hidden = false;
13
+ control.classList.remove("open-up");
14
+ const scroll = control.closest<HTMLElement>(".workspace-scroll");
15
+ if (scroll !== null) {
16
+ const menuBox = menu.getBoundingClientRect();
17
+ const scrollBox = scroll.getBoundingClientRect();
18
+ if (menuBox.bottom > scrollBox.bottom && trigger.getBoundingClientRect().top - menuBox.height >= scrollBox.top) {
19
+ control.classList.add("open-up");
20
+ }
21
+ }
22
+ const target = focus === "first" ? options[0]
23
+ : focus === "last" ? options.at(-1)
24
+ : options.find((option) => option.classList.contains("active")) ?? options[0];
25
+ target?.focus();
26
+ };
27
+ const moveOptionFocus = (offset: number): void => {
28
+ const current = options.indexOf(document.activeElement as HTMLButtonElement);
29
+ const next = current < 0 ? 0 : (current + offset + options.length) % options.length;
30
+ options[next]?.focus();
31
+ };
32
+
33
+ trigger.addEventListener("click", () => {
34
+ if (control.classList.contains("open")) close(false);
35
+ else open();
36
+ });
37
+ trigger.addEventListener("keydown", (event) => {
38
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
39
+ event.preventDefault();
40
+ event.stopPropagation();
41
+ open(event.key === "ArrowDown" ? "first" : "last");
42
+ } else if (event.key === "Escape" && control.classList.contains("open")) {
43
+ event.preventDefault();
44
+ event.stopPropagation();
45
+ close(false);
46
+ }
47
+ });
48
+ menu.addEventListener("keydown", (event) => {
49
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
50
+ event.preventDefault();
51
+ event.stopPropagation();
52
+ moveOptionFocus(event.key === "ArrowDown" ? 1 : -1);
53
+ } else if (event.key === "Home" || event.key === "End") {
54
+ event.preventDefault();
55
+ event.stopPropagation();
56
+ options[event.key === "Home" ? 0 : options.length - 1]?.focus();
57
+ } else if (event.key === "Escape") {
58
+ event.preventDefault();
59
+ event.stopPropagation();
60
+ close(true);
61
+ }
62
+ });
63
+ control.addEventListener("focusout", (event) => {
64
+ if (event.relatedTarget instanceof Node && control.contains(event.relatedTarget)) return;
65
+ close(false);
66
+ });
67
+ return { close };
68
+ }
@@ -0,0 +1,109 @@
1
+ import { builtinLanguages, chooseLanguage, formatMessage, readLanguagePack } from "../localization.js";
2
+ import type { LanguagePack, Message, MessageValues } from "../localization.js";
3
+ import { bindDropdown } from "./dropdown.js";
4
+ import { icon } from "./icons.js";
5
+ export type { Message } from "../localization.js";
6
+
7
+ type Attribute = "title" | "aria-label" | "placeholder" | "alt";
8
+ const attributes: readonly Attribute[] = ["title", "aria-label", "placeholder", "alt"];
9
+ const storageKey = "hypit-studio.language";
10
+ let languages = builtinLanguages;
11
+ let language = chooseLanguage(languages, navigator.languages);
12
+
13
+ export async function initializeI18n(): Promise<void> {
14
+ const response = await fetch("/__studio/locales");
15
+ if (!response.ok) throw new Error(`Could not load Studio languages (${response.status}).`);
16
+ languages = (await response.json() as unknown[]).map(readLanguagePack);
17
+ let saved: string | null = null;
18
+ try { saved = localStorage.getItem(storageKey); } catch { /* Browser storage may be disabled. */ }
19
+ language = chooseLanguage(languages, [...(saved ? [saved] : []), ...navigator.languages]);
20
+ document.documentElement.lang = language.locale;
21
+ }
22
+
23
+ export function t(message: Message, values: MessageValues = {}): string {
24
+ return formatMessage(language, message, values);
25
+ }
26
+ function escape(value: string): string {
27
+ return value.replace(/[&<>"']/gu, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!);
28
+ }
29
+ export function uiLabel(message: Message): string {
30
+ return `<span data-ui-text="${message}" dir="${language.direction}">${escape(t(message))}</span>`;
31
+ }
32
+ export function uiAttribute(attribute: Attribute, message: Message): string {
33
+ return `${attribute}="${escape(t(message))}" data-ui-${attribute}="${message}"`;
34
+ }
35
+ export function uiText(node: Element, message: Message, values: MessageValues = {}): void {
36
+ node.setAttribute("data-ui-text", message);
37
+ node.setAttribute("data-ui-values", JSON.stringify(values));
38
+ node.setAttribute("dir", language.direction);
39
+ node.textContent = t(message, values);
40
+ }
41
+ /** Use when a node changes from UI text into a user-provided name or an empty value. */
42
+ export function userText(node: Element, value: string): void {
43
+ node.removeAttribute("data-ui-text");
44
+ node.removeAttribute("data-ui-values");
45
+ node.setAttribute("dir", "auto");
46
+ node.textContent = value;
47
+ }
48
+ export function uiAttr(node: Element, attribute: Attribute, message: Message, values: MessageValues = {}): void {
49
+ node.setAttribute(`data-ui-${attribute}`, message);
50
+ node.setAttribute(`data-ui-${attribute}-values`, JSON.stringify(values));
51
+ node.setAttribute(attribute, t(message, values));
52
+ }
53
+ export function uiDate(node: Element, timestamp: number): void {
54
+ node.setAttribute("data-ui-date", String(timestamp));
55
+ node.textContent = new Intl.DateTimeFormat(language.locale, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(timestamp);
56
+ }
57
+
58
+ function selectLanguage(pack: LanguagePack): void {
59
+ language = pack;
60
+ try { localStorage.setItem(storageKey, pack.locale); } catch { /* Tab-local choice still applies. */ }
61
+ document.documentElement.lang = pack.locale;
62
+ // Update explicitly marked chrome in place, without rebuilding editors or the player.
63
+ for (const node of Array.from(document.querySelectorAll("[data-ui-text]"))) {
64
+ uiText(node, node.getAttribute("data-ui-text") as Message, JSON.parse(node.getAttribute("data-ui-values") ?? "{}") as MessageValues);
65
+ }
66
+ for (const attribute of attributes) {
67
+ for (const node of Array.from(document.querySelectorAll(`[data-ui-${attribute}]`))) {
68
+ uiAttr(node, attribute, node.getAttribute(`data-ui-${attribute}`) as Message, JSON.parse(node.getAttribute(`data-ui-${attribute}-values`) ?? "{}") as MessageValues);
69
+ }
70
+ }
71
+ for (const node of Array.from(document.querySelectorAll("[data-ui-date]"))) uiDate(node, Number(node.getAttribute("data-ui-date")));
72
+ }
73
+
74
+ export function languageMenu(): HTMLElement {
75
+ const control = document.createElement("div");
76
+ control.className = "parameter-select language-select";
77
+ const trigger = document.createElement("button");
78
+ trigger.type = "button"; trigger.className = "parameter-select-trigger language-toggle";
79
+ trigger.dataset.language = "";
80
+ trigger.innerHTML = icon("globe");
81
+ uiAttr(trigger, "title", "app.language"); uiAttr(trigger, "aria-label", "app.language");
82
+ trigger.setAttribute("aria-haspopup", "listbox"); trigger.setAttribute("aria-expanded", "false");
83
+ const menu = document.createElement("div");
84
+ menu.className = "parameter-select-menu"; menu.id = "studio-languages"; menu.hidden = true;
85
+ menu.setAttribute("role", "listbox"); uiAttr(menu, "aria-label", "app.language");
86
+ trigger.setAttribute("aria-controls", menu.id);
87
+ const options = languages.map(pack => {
88
+ const item = document.createElement("button");
89
+ item.type = "button"; item.className = "parameter-select-option";
90
+ item.lang = pack.locale; item.dir = pack.direction;
91
+ item.textContent = pack.name;
92
+ item.dataset.locale = pack.locale;
93
+ item.setAttribute("role", "option");
94
+ item.addEventListener("click", () => {
95
+ selectLanguage(pack); renderSelection(); close(true);
96
+ });
97
+ return item;
98
+ });
99
+ const renderSelection = (): void => {
100
+ for (const item of options) {
101
+ const selected = item.dataset.locale === language.locale;
102
+ item.classList.toggle("active", selected); item.setAttribute("aria-selected", String(selected));
103
+ }
104
+ };
105
+ menu.append(...options); control.append(trigger, menu);
106
+ const { close } = bindDropdown(control, trigger, menu, options);
107
+ renderSelection();
108
+ return control;
109
+ }
@@ -1,5 +1,6 @@
1
1
  const paths: Readonly<Record<string, string>> = {
2
2
  brand: '<g transform="translate(0 1.282) scale(.091603) translate(-50 -180)"><path fill="currentColor" stroke="none" d="M303.78,277.82c-5.98-8.63-15.83-13.77-26.32-13.77h-93.41c-12.28,0-24.92,7.44-29.6,18.8l-28.26,75.82c-4.6,11.25-4.54,24.05,2.22,34.16,6.79,10.15,19.36,16.15,31.57,16.15h85.98c14.12,0,26.9-8.88,31.84-22.1l29.69-79.57c3.67-9.84,2.29-20.86-3.69-29.48v-.02h-.02ZM252.02,377.25c-.94,2.52-3.39,4.22-6.08,4.22h-83.45c-4.34,0-6.68-2.8-7.49-3.99s-2.5-4.43-.84-8.44l23.85-66.24c2.29-6.35,8.08-10.87,14.82-11.22.33-.02.67-.02,1.02-.02h75.41s12.51,1.42,9.29,14.61l-26.53,71.09h0ZM106.85,361.73s-22.36-11.59-17.66-32.66l29.67-82.23c4.78-13.25,17.36-22.09,31.45-22.09h95.02c6.25,0,12.1,3.03,15.71,8.14l13.89,19.68h-112.97c-8.7,0-16.49,5.41-19.52,13.57l-35.59,95.6h0ZM73.3,323.27s-22.37-11.59-17.66-32.66l29.67-82.23c4.79-13.25,17.37-22.09,31.45-22.09h89.91c6.25,0,12.1,3.03,15.71,8.14l13.89,19.68h-107.84c-8.7,0-16.49,5.41-19.52,13.57l-35.59,95.6h-.02Z"/></g>',
3
+ globe: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>',
3
4
  code: '<path d="M9 7 4 12l5 5M15 7l5 5-5 5M13 4l-2 16"/>',
4
5
  edit: '<path d="m4 16-.8 4 4-.8L18.5 8.9 15.1 5.5 4 16Z"/><path d="m13.8 6.8 3.4 3.4"/>',
5
6
  studio: '<rect x="3" y="4" width="18" height="16" rx="1.5"/><path d="M3 14h18M8 4v10M15 14v6"/>',