@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
@@ -1,3 +1,5 @@
1
+ import { bindDropdown } from "./dropdown.js";
2
+ import { uiLabel, uiAttribute, uiText, uiAttr, userText, languageMenu, initializeI18n, type Message } from "./i18n.js";
1
3
  import type { Clip, StudioFailure, StudioInspectorDomain, StudioSnapshot } from "../shared.js";
2
4
  import type { CanonicalValue, ValueSchema } from "@hypit/protocol";
3
5
  import { parameterAuthorValue, parameterControlForSchema, parameterNumber, parameterOption, parameterRecordSchema, parameterRecordVariants, validateParameterValue } from "../parameter-values.js";
@@ -16,6 +18,8 @@ import { createTimeline } from "./timeline.js";
16
18
  import { createComments } from "./comments.js";
17
19
  import "../style.css";
18
20
 
21
+ await initializeI18n();
22
+
19
23
  const app = document.querySelector<HTMLElement>("#app")!;
20
24
  app.innerHTML = `
21
25
  <header class="topbar">
@@ -40,9 +44,10 @@ app.innerHTML = `
40
44
  <div class="topbar-right">
41
45
  <div class="meta" data-meta></div>
42
46
  <div class="status" data-status></div>
43
- <div class="view-tabs" role="tablist" aria-label="Studio view">
44
- <button type="button" role="tab" data-view="studio" aria-selected="true">${icon("studio")}Studio</button>
45
- <button type="button" role="tab" data-view="comments" aria-selected="false">${icon("comments")}Comments</button>
47
+ <div data-language-menu></div>
48
+ <div class="view-tabs" role="tablist" ${uiAttribute("aria-label", "app.studio-view")}>
49
+ <button type="button" role="tab" data-view="studio" aria-selected="true">${icon("studio")}${uiLabel("app.studio")}</button>
50
+ <button type="button" role="tab" data-view="comments" aria-selected="false">${icon("comments")}${uiLabel("app.comments")}</button>
46
51
  </div>
47
52
  </div>
48
53
  </header>
@@ -52,7 +57,7 @@ app.innerHTML = `
52
57
  <div class="preview-panel" data-stage></div>
53
58
  <aside class="workspace-panel">
54
59
  <div class="pane-heading workspace-heading" data-workspace-heading>
55
- <div class="pane-title">${icon("tune")}<h2>Properties</h2></div>
60
+ <div class="pane-title">${icon("tune")}<h2>${uiLabel("inspector.properties")}</h2></div>
56
61
  </div>
57
62
  <div class="workspace-scroll">
58
63
  <section class="workspace-section">
@@ -65,6 +70,8 @@ app.innerHTML = `
65
70
  </main>
66
71
  <pre class="failure" data-failure></pre>`;
67
72
 
73
+ app.querySelector("[data-language-menu]")!.replaceWith(languageMenu());
74
+
68
75
  const store = createStore();
69
76
  const code = createCodePane();
70
77
  const stage = createStage(store, (id) => library.selectArtifact(id));
@@ -144,7 +151,7 @@ changeView();
144
151
  timeline.element.addEventListener("studio:write", (event) => {
145
152
  const state = (event as CustomEvent<{ readonly state?: string }>).detail.state;
146
153
  status.className = state === "error" ? "status error" : state === "saved" ? "status saved" : "status saving";
147
- status.textContent = state === "error" ? "Save failed" : state === "saved" ? "Saved" : "Saving";
154
+ uiText(status, state === "error" ? "common.save-failed" : state === "saved" ? "common.saved" : "common.saving");
148
155
  });
149
156
 
150
157
  // Program-level facts never change while a Source is being read, so they live in
@@ -154,14 +161,14 @@ function renderMeta(snapshot: StudioSnapshot): void {
154
161
  project.title = snapshot.source.path;
155
162
  // Sequence facts belong to the project/inspector, not the application chrome.
156
163
  // The timeline transport is the single persistent time readout.
157
- meta.textContent = "";
164
+ userText(meta, "");
158
165
  }
159
166
 
160
- function property(label: string, value: string, tone?: string): HTMLElement {
167
+ function property(label: Message, value: string, tone?: string): HTMLElement {
161
168
  const node = document.createElement("div");
162
169
  node.className = `property${tone === undefined ? "" : ` ${tone}`}`;
163
170
  node.innerHTML = "<span></span><strong></strong>";
164
- node.querySelector("span")!.textContent = label;
171
+ uiText(node.querySelector("span")!, label);
165
172
  node.querySelector("strong")!.textContent = value;
166
173
  node.querySelector("strong")!.title = value;
167
174
  return node;
@@ -179,6 +186,12 @@ function group(label: string, items: readonly HTMLElement[], className = ""): HT
179
186
  return node;
180
187
  }
181
188
 
189
+ function uiGroup(label: Message, items: readonly HTMLElement[]): HTMLElement {
190
+ const node = group(label, items);
191
+ uiText(node.querySelector("h3")!, label);
192
+ return node;
193
+ }
194
+
182
195
  function aspectRatio(width: number, height: number): string {
183
196
  let a = width;
184
197
  let b = height;
@@ -186,10 +199,10 @@ function aspectRatio(width: number, height: number): string {
186
199
  return `${width / a}:${height / a}`;
187
200
  }
188
201
 
189
- const domainPresentation: Readonly<Record<StudioInspectorDomain, { readonly label: string; readonly icon: string }>> = {
190
- where: { label: "Where", icon: "where" },
191
- how: { label: "How", icon: "how" },
192
- when: { label: "When", icon: "when" },
202
+ const domainPresentation: Readonly<Record<StudioInspectorDomain, { readonly label: Message; readonly icon: string }>> = {
203
+ where: { label: "inspector.where", icon: "where" },
204
+ how: { label: "inspector.how", icon: "how" },
205
+ when: { label: "inspector.when", icon: "when" },
193
206
  };
194
207
  const domainOrder: readonly StudioInspectorDomain[] = ["where", "when", "how"];
195
208
  const inspectorDomainByEntity = new Map<string, StudioInspectorDomain>();
@@ -197,7 +210,7 @@ const inspectorPageByEntity = new Map<string, string>();
197
210
 
198
211
  function defaultWorkspaceHeading(): void {
199
212
  workspaceHeading.className = "pane-heading workspace-heading";
200
- workspaceHeading.innerHTML = `<div class="pane-title">${icon("tune")}<h2>Properties</h2></div>`;
213
+ workspaceHeading.innerHTML = `<div class="pane-title">${icon("tune")}<h2>${uiLabel("inspector.properties")}</h2></div>`;
201
214
  }
202
215
 
203
216
  function inspectorHeading(
@@ -213,7 +226,7 @@ function inspectorHeading(
213
226
  button.type = "button";
214
227
  button.className = `inspector-domain-tab${domain === active ? " active" : ""}`;
215
228
  button.dataset.domain = domain;
216
- button.innerHTML = `${icon(presentation.icon)}<strong>${presentation.label}</strong>`;
229
+ button.innerHTML = `${icon(presentation.icon)}<strong>${uiLabel(presentation.label)}</strong>`;
217
230
  button.setAttribute("aria-pressed", String(domain === active));
218
231
  button.addEventListener("click", () => {
219
232
  inspectorDomainByEntity.set(entityId, domain);
@@ -244,7 +257,7 @@ function commitControl(entityId: string, parameter: Clip["inspector"][number], r
244
257
  void writeParameter(entityId, parameter, replacement);
245
258
  } catch (error) {
246
259
  restoreParameterControls(entityId);
247
- status.textContent = "Invalid value"; status.className = "status error";
260
+ uiText(status, "common.invalid-value"); status.className = "status error";
248
261
  status.title = error instanceof Error ? error.message : String(error);
249
262
  }
250
263
  }
@@ -329,70 +342,7 @@ function selectControl(
329
342
  });
330
343
  menu.append(...options);
331
344
 
332
- const close = (restoreFocus: boolean): void => {
333
- control.classList.remove("open", "open-up");
334
- trigger.setAttribute("aria-expanded", "false");
335
- menu.hidden = true;
336
- if (restoreFocus) trigger.focus();
337
- };
338
- const open = (focus: "selected" | "first" | "last" = "selected"): void => {
339
- control.classList.add("open");
340
- trigger.setAttribute("aria-expanded", "true");
341
- menu.hidden = false;
342
- control.classList.remove("open-up");
343
- const scroll = control.closest<HTMLElement>(".workspace-scroll");
344
- if (scroll !== null) {
345
- const menuBox = menu.getBoundingClientRect();
346
- const scrollBox = scroll.getBoundingClientRect();
347
- if (menuBox.bottom > scrollBox.bottom && trigger.getBoundingClientRect().top - menuBox.height >= scrollBox.top) {
348
- control.classList.add("open-up");
349
- }
350
- }
351
- const target = focus === "first" ? options[0]
352
- : focus === "last" ? options.at(-1)
353
- : options.find((option) => option.classList.contains("active")) ?? options[0];
354
- target?.focus();
355
- };
356
- const moveOptionFocus = (offset: number): void => {
357
- const current = options.indexOf(document.activeElement as HTMLButtonElement);
358
- const next = current < 0 ? 0 : (current + offset + options.length) % options.length;
359
- options[next]?.focus();
360
- };
361
-
362
- trigger.addEventListener("click", () => {
363
- if (control.classList.contains("open")) close(false);
364
- else open();
365
- });
366
- trigger.addEventListener("keydown", (event) => {
367
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
368
- event.preventDefault();
369
- event.stopPropagation();
370
- open(event.key === "ArrowDown" ? "first" : "last");
371
- } else if (event.key === "Escape" && control.classList.contains("open")) {
372
- event.preventDefault();
373
- event.stopPropagation();
374
- close(false);
375
- }
376
- });
377
- menu.addEventListener("keydown", (event) => {
378
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
379
- event.preventDefault();
380
- event.stopPropagation();
381
- moveOptionFocus(event.key === "ArrowDown" ? 1 : -1);
382
- } else if (event.key === "Home" || event.key === "End") {
383
- event.preventDefault();
384
- event.stopPropagation();
385
- options[event.key === "Home" ? 0 : options.length - 1]?.focus();
386
- } else if (event.key === "Escape") {
387
- event.preventDefault();
388
- event.stopPropagation();
389
- close(true);
390
- }
391
- });
392
- control.addEventListener("focusout", (event) => {
393
- if (event.relatedTarget instanceof Node && control.contains(event.relatedTarget)) return;
394
- close(false);
395
- });
345
+ const { close } = bindDropdown(control, trigger, menu, options);
396
346
  control.append(trigger, menu);
397
347
  return control;
398
348
  }
@@ -413,7 +363,7 @@ function colorValueControl(
413
363
  const picker = document.createElement("input");
414
364
  picker.type = "color";
415
365
  picker.className = "parameter-color-native";
416
- picker.setAttribute("aria-label", `${label} picker`);
366
+ uiAttr(picker, "aria-label", "inspector.color-picker", { label });
417
367
  picker.title = label;
418
368
  const exact = /^#[0-9a-f]{6}(?:[0-9a-f]{2})?$/iu.test(initial);
419
369
  picker.value = exact ? initial.slice(0, 7) : "#000000";
@@ -530,7 +480,7 @@ function structuredControl(entityId: string, parameter: Clip["inspector"][number
530
480
  const save = (): void => {
531
481
  const changed = !sameValue(draft, parameter.value);
532
482
  notice.hidden = !changed || valid();
533
- notice.textContent = "Complete the required fields with valid values to save.";
483
+ uiText(notice, "inspector.invalid-fields");
534
484
  if (changed && valid()) change(draft);
535
485
  };
536
486
 
@@ -563,9 +513,8 @@ function structuredControl(entityId: string, parameter: Clip["inspector"][number
563
513
  const summary = document.createElement("div");
564
514
  summary.className = "parameter-structured-summary";
565
515
  const count = document.createElement("span");
566
- count.textContent = schema.kind === "array" && Array.isArray(draft)
567
- ? `${draft.length} ${draft.length === 1 ? "item" : "items"}`
568
- : "Structured value";
516
+ if (schema.kind === "array" && Array.isArray(draft)) uiText(count, "inspector.item-count", { count: draft.length });
517
+ else uiText(count, "inspector.structured-value");
569
518
  summary.append(count);
570
519
  if (schema.kind === "array") shell.append(summary);
571
520
 
@@ -600,19 +549,19 @@ function structuredControl(entityId: string, parameter: Clip["inspector"][number
600
549
  const up = document.createElement("button");
601
550
  up.type = "button";
602
551
  up.textContent = "↑";
603
- up.title = "Move up";
552
+ uiAttr(up, "title", "inspector.move-up");
604
553
  up.disabled = index === 0;
605
554
  up.addEventListener("click", () => move(-1));
606
555
  const down = document.createElement("button");
607
556
  down.type = "button";
608
557
  down.textContent = "↓";
609
- down.title = "Move down";
558
+ uiAttr(down, "title", "inspector.move-down");
610
559
  down.disabled = index === list.length - 1;
611
560
  down.addEventListener("click", () => move(1));
612
561
  const remove = document.createElement("button");
613
562
  remove.type = "button";
614
563
  remove.innerHTML = icon("minus");
615
- remove.title = "Remove item";
564
+ uiAttr(remove, "title", "inspector.remove-item");
616
565
  remove.disabled = schema.minItems !== undefined && list.length <= schema.minItems;
617
566
  remove.addEventListener("click", () => {
618
567
  const current = Array.isArray(draft) ? draft : [];
@@ -639,7 +588,7 @@ function structuredControl(entityId: string, parameter: Clip["inspector"][number
639
588
  const add = document.createElement("button");
640
589
  add.type = "button";
641
590
  add.className = "parameter-structured-add";
642
- add.innerHTML = `${icon("plus")}<span>Add ${schema.items.kind === "string" && schema.items.format === "color" ? "color" : "item"}</span>`;
591
+ add.innerHTML = `${icon("plus")}${uiLabel(schema.items.kind === "string" && schema.items.format === "color" ? "inspector.add-color" : "inspector.add-item")}`;
643
592
  add.disabled = schema.maxItems !== undefined && list.length >= schema.maxItems;
644
593
  add.addEventListener("click", () => {
645
594
  const current = Array.isArray(draft) ? draft : [];
@@ -708,7 +657,7 @@ function parameterControl(
708
657
  const palette = document.createElement("span"); palette.className = "parameter-swatches";
709
658
  for (const color of parameter.swatches) {
710
659
  const swatch = document.createElement("button"); swatch.type = "button";
711
- swatch.style.backgroundColor = color; swatch.title = color; swatch.setAttribute("aria-label", `Use ${color}`);
660
+ swatch.style.backgroundColor = color; swatch.title = color; uiAttr(swatch, "aria-label", "inspector.use-color", { color });
712
661
  swatch.addEventListener("click", () => commit(color)); palette.append(swatch);
713
662
  }
714
663
  right.append(palette);
@@ -784,13 +733,13 @@ function parameterGroups(entityId: string, fields: readonly Clip["inspector"][nu
784
733
  });
785
734
  }
786
735
 
787
- let parameterWriteState: "" | "Saving" | "Saved" | "Failed" = "";
736
+ let parameterWriteState: "" | "common.saving" | "common.saved" | "common.failed" = "";
788
737
  async function writeParameter(entityId: string, parameter: Clip["inspector"][number], replacement: CanonicalValue): Promise<void> {
789
738
  const state = store.current();
790
739
  if (state === undefined) return;
791
- parameterWriteState = "Saving";
740
+ parameterWriteState = "common.saving";
792
741
  status.title = "";
793
- status.textContent = parameterWriteState;
742
+ uiText(status, parameterWriteState);
794
743
  status.className = "status saving";
795
744
  try {
796
745
  await applyStudioMutation({
@@ -800,15 +749,15 @@ async function writeParameter(entityId: string, parameter: Clip["inspector"][num
800
749
  parameterId: parameter.id,
801
750
  value: replacement,
802
751
  });
803
- parameterWriteState = "Saved";
804
- status.textContent = parameterWriteState;
752
+ parameterWriteState = "common.saved";
753
+ uiText(status, parameterWriteState);
805
754
  status.className = "status saved";
806
755
  } catch (error) {
807
756
  // Validation and stale-revision rejections do not publish a new snapshot.
808
757
  // Restore the accepted value just as a rejected compilation does.
809
758
  restoreParameterControls(entityId);
810
- parameterWriteState = "Failed";
811
- status.textContent = error instanceof Error ? "Save failed" : parameterWriteState;
759
+ parameterWriteState = "common.failed";
760
+ uiText(status, error instanceof Error ? "common.save-failed" : parameterWriteState);
812
761
  status.className = "status error";
813
762
  status.title = error instanceof Error ? error.message : String(error);
814
763
  }
@@ -826,26 +775,26 @@ function renderInspector(snapshot: StudioSnapshot, clipId: string | undefined):
826
775
  defaultWorkspaceHeading();
827
776
  const fps = snapshot.space.frameRate.numerator / snapshot.space.frameRate.denominator;
828
777
  inspector.replaceChildren(
829
- group("Project", [
830
- property("Author", snapshot.source.path, "property-code"),
831
- property("Run", snapshot.run.path, "property-code"),
832
- property("Sources", `${snapshot.source.files.length} referenced files`),
833
- property("Tracks", String(snapshot.tracks.length), "property-number"),
778
+ uiGroup("inspector.project", [
779
+ property("inspector.author", snapshot.source.path, "property-code"),
780
+ property("inspector.run", snapshot.run.path, "property-code"),
781
+ property("inspector.sources", String(snapshot.source.files.length)),
782
+ property("inspector.tracks", String(snapshot.tracks.length), "property-number"),
834
783
  ]),
835
- group("Canvas", [
836
- property("Resolution", `${snapshot.space.canvasWidth} × ${snapshot.space.canvasHeight}`, "property-number"),
837
- property("Aspect ratio", aspectRatio(snapshot.space.canvasWidth, snapshot.space.canvasHeight), "property-number"),
784
+ uiGroup("inspector.canvas", [
785
+ property("inspector.resolution", `${snapshot.space.canvasWidth} × ${snapshot.space.canvasHeight}`, "property-number"),
786
+ property("inspector.aspect-ratio", aspectRatio(snapshot.space.canvasWidth, snapshot.space.canvasHeight), "property-number"),
838
787
  ]),
839
- group("Timeline", [
840
- property("Duration", `${snapshot.space.durationSec.toFixed(2)} s`, "property-number"),
841
- property("Frame rate", `${fps.toFixed(Number.isInteger(fps) ? 0 : 2)} fps`, "property-number"),
842
- property("Frames", String(snapshot.space.frameCount), "property-number"),
788
+ uiGroup("inspector.timeline", [
789
+ property("inspector.duration", `${snapshot.space.durationSec.toFixed(2)} s`, "property-number"),
790
+ property("inspector.frame-rate", `${fps.toFixed(Number.isInteger(fps) ? 0 : 2)} fps`, "property-number"),
791
+ property("inspector.frames", String(snapshot.space.frameCount), "property-number"),
843
792
  ]),
844
- group("Build", [
845
- property("Targets", snapshot.run.targets
793
+ uiGroup("inspector.build", [
794
+ property("inspector.targets", snapshot.run.targets
846
795
  .map((target) => target.split("::output::").at(-1) ?? target)
847
796
  .join(", ") || "—", "property-code"),
848
- property("Candidates", String(snapshot.run.satisfactions.length), "property-number"),
797
+ property("inspector.candidates", String(snapshot.run.satisfactions.length), "property-number"),
849
798
  ]),
850
799
  );
851
800
  return;
@@ -855,7 +804,7 @@ function renderInspector(snapshot: StudioSnapshot, clipId: string | undefined):
855
804
  defaultWorkspaceHeading();
856
805
  const empty = document.createElement("div");
857
806
  empty.className = "inspector-empty";
858
- empty.textContent = "No details for this selection";
807
+ uiText(empty, "inspector.empty");
859
808
  inspector.replaceChildren(empty);
860
809
  return;
861
810
  }
@@ -908,7 +857,7 @@ function renderSemanticInspector(snapshot: StudioSnapshot, segmentId: string): v
908
857
  defaultWorkspaceHeading();
909
858
  const empty = document.createElement("div");
910
859
  empty.className = "inspector-empty";
911
- empty.textContent = "No details for this selection";
860
+ uiText(empty, "inspector.empty");
912
861
  inspector.replaceChildren(empty);
913
862
  }
914
863
 
@@ -920,32 +869,36 @@ function semanticAnchorInspector(snapshot: StudioSnapshot, kind: "selection" | "
920
869
  .map((handle) => ({ clip, handle })));
921
870
  const current = kind === "selection" ? semantic?.selections.find((item) => item.id === id)
922
871
  : semantic?.moments.find((item) => item.id === id);
923
- if (!semantic || !current) return group("Timing", []);
924
- const endpoints = "anchorId" in current ? [["Moment", current.anchorId]]
925
- : [["Start", current.startAnchorId], ["End", current.endAnchorId]];
926
- return group("Semantic anchors", endpoints.map(([label, anchorId]) => {
872
+ if (!semantic || !current) return uiGroup("inspector.timing", []);
873
+ const endpoints: readonly (readonly [Message, string])[] = "anchorId" in current ? [["inspector.moment", current.anchorId]]
874
+ : [["inspector.start", current.startAnchorId], ["inspector.end", current.endAnchorId]];
875
+ return uiGroup("inspector.semantic-anchors", endpoints.map(([label, anchorId]) => {
927
876
  const anchor = semantic.anchors.find((item) => item.id === anchorId)!;
928
- const describe = (item: typeof anchor) => {
877
+ const describe = (node: Element, item: typeof anchor) => {
929
878
  const word = semantic.tokens.find((token) => token.id === item.tokenId)?.text;
930
- return `${item.kind.replaceAll("-", " ")}${word ? ` · ${word}` : ""}${item.segmentId ? ` · ${item.segmentId}` : ""}`;
879
+ uiText(node, `inspector.anchor.${item.kind}`, { detail: `${word ? ` · ${word}` : ""}${item.segmentId ? ` · ${item.segmentId}` : ""}` });
931
880
  };
932
881
  const candidates = semantic.anchors.filter((item) => item.frame === anchor.frame).flatMap((item) => {
933
882
  const target: SemanticTarget = "anchorId" in current ? { kind: "moment", anchorId: item.id }
934
- : { kind: "selection", startAnchorId: label === "Start" ? item.id : current.startAnchorId,
935
- endAnchorId: label === "End" ? item.id : current.endAnchorId };
883
+ : { kind: "selection", startAnchorId: label === "inspector.start" ? item.id : current.startAnchorId,
884
+ endAnchorId: label === "inspector.end" ? item.id : current.endAnchorId };
936
885
  const owner = consumers.find(({ handle }) => semanticGestureSpan(semantic.anchors, handle, target) !== undefined);
937
886
  return owner ? [{ item, target, ...owner }] : [];
938
887
  });
939
- if (candidates.length < 2) return property(label!, describe(anchor));
888
+ if (candidates.length < 2) {
889
+ const row = property(label!, "");
890
+ describe(row.querySelector("strong")!, anchor);
891
+ return row;
892
+ }
940
893
  const row = document.createElement("label");
941
894
  row.className = "property";
942
895
  const name = document.createElement("span");
943
- name.textContent = label!;
896
+ uiText(name, label!);
944
897
  const control = document.createElement("select");
945
898
  control.className = "parameter-value";
946
- control.setAttribute("aria-label", `${label} semantic anchor`);
899
+ uiAttr(control, "aria-label", label === "inspector.start" ? "inspector.start-anchor" : label === "inspector.end" ? "inspector.end-anchor" : "inspector.moment-anchor");
947
900
  for (const { item } of candidates) {
948
- const option = document.createElement("option"); option.value = item.id; option.textContent = describe(item);
901
+ const option = document.createElement("option"); option.value = item.id; describe(option, item);
949
902
  control.append(option);
950
903
  }
951
904
  control.value = anchorId!;
@@ -954,14 +907,14 @@ function semanticAnchorInspector(snapshot: StudioSnapshot, kind: "selection" | "
954
907
  const span = semanticGestureSpan(semantic.anchors, choice.handle, choice.target)!;
955
908
  const temporal = choice.handle.temporal!;
956
909
  control.disabled = true;
957
- status.textContent = "Saving"; status.className = "status saving";
910
+ uiText(status, "common.saving"); status.className = "status saving";
958
911
  void applyStudioMutation({ type: "timeline.adjust", revision: snapshot.revision,
959
912
  entityId: choice.clip.id, gesture: choice.handle.gesture,
960
913
  target: temporal.kind === "instant" ? { kind: "instant", frame: span.startFrame, semantic: choice.target }
961
914
  : { kind: "window", ...span, semantic: choice.target },
962
- }).then(() => { status.textContent = "Saved"; status.className = "status saved"; })
915
+ }).then(() => { uiText(status, "common.saved"); status.className = "status saved"; })
963
916
  .catch((error: unknown) => {
964
- control.value = anchorId!; status.textContent = "Save failed"; status.className = "status error";
917
+ control.value = anchorId!; uiText(status, "common.save-failed"); status.className = "status error";
965
918
  status.title = error instanceof Error ? error.message : String(error);
966
919
  }).finally(() => { control.disabled = false; });
967
920
  });
@@ -1145,10 +1098,10 @@ window.addEventListener("keydown", (event) => {
1145
1098
  });
1146
1099
 
1147
1100
  function applySnapshot(snapshot: StudioSnapshot): void {
1148
- failureView.textContent = "";
1101
+ userText(failureView, "");
1149
1102
  status.className = "status";
1150
1103
  status.title = "";
1151
- status.textContent = "";
1104
+ userText(status, "");
1152
1105
  renderMeta(snapshot);
1153
1106
  library.show(snapshot);
1154
1107
  store.load(snapshot);
@@ -1156,7 +1109,7 @@ function applySnapshot(snapshot: StudioSnapshot): void {
1156
1109
 
1157
1110
  function applyFailure(failure: StudioFailure): void {
1158
1111
  status.className = "status error";
1159
- status.textContent = "Compile failed";
1112
+ uiText(status, "common.compile-failed");
1160
1113
  failureView.textContent = failure.error;
1161
1114
  if (failure.range !== undefined) code.highlight([{ range: failure.range, tone: "element" }], true);
1162
1115
  // A parameter control changes immediately in the browser, but the source
@@ -1,3 +1,4 @@
1
+ import { uiAttr, uiText, t } from "./i18n.js";
1
2
  import type { StudioSnapshot } from "../shared.js";
2
3
  import { feedbackClock } from "../feedback.js";
3
4
 
@@ -11,10 +12,11 @@ export function createScrubPreview(container: HTMLElement) {
11
12
  const element = document.createElement("div");
12
13
  element.className = "scrub-preview";
13
14
  element.hidden = true;
14
- element.innerHTML = '<div class="scrub-preview-picture"><span>Loading frame…</span></div><div class="scrub-preview-time"></div>';
15
+ element.innerHTML = '<div class="scrub-preview-picture"><span></span></div><div class="scrub-preview-time"></div>';
15
16
  element.setAttribute("aria-hidden", "true");
16
17
  const picture = element.querySelector<HTMLElement>(".scrub-preview-picture")!;
17
18
  const status = picture.querySelector<HTMLElement>("span")!;
19
+ uiText(status, "player.frame-loading");
18
20
  const time = element.querySelector<HTMLElement>(".scrub-preview-time")!;
19
21
  container.append(element);
20
22
  let iframe: HTMLIFrameElement | undefined;
@@ -34,7 +36,7 @@ export function createScrubPreview(container: HTMLElement) {
34
36
  const target = current.contentWindow as PreviewWindow | null;
35
37
  const placed = await target?.__hypitSeekFrame?.(frame);
36
38
  if (iframe !== current) return;
37
- if (!placed) throw new Error("Frame unavailable");
39
+ if (!placed) throw new Error(t("player.frame-unavailable"));
38
40
  displayed = frame;
39
41
  if (requested === frame) {
40
42
  current.style.visibility = "visible";
@@ -43,7 +45,7 @@ export function createScrubPreview(container: HTMLElement) {
43
45
  }
44
46
  }
45
47
  } catch {
46
- if (iframe === current) status.textContent = "Frame unavailable";
48
+ if (iframe === current) uiText(status, "player.frame-unavailable");
47
49
  } finally {
48
50
  if (iframe === current) seeking = false;
49
51
  }
@@ -65,7 +67,7 @@ export function createScrubPreview(container: HTMLElement) {
65
67
  picture.style.width = `${canvasWidth * scale}px`;
66
68
  picture.style.height = `${canvasHeight * scale}px`;
67
69
  const created = document.createElement("iframe");
68
- created.title = "Hovered frame";
70
+ uiAttr(created, "title", "player.hovered-frame");
69
71
  created.tabIndex = -1;
70
72
  created.setAttribute("sandbox", "allow-scripts allow-same-origin");
71
73
  created.setAttribute("allow", "autoplay 'none'");
@@ -80,7 +82,7 @@ export function createScrubPreview(container: HTMLElement) {
80
82
  if (iframe !== created) return;
81
83
  ready = true;
82
84
  await seek();
83
- })().catch(() => { if (iframe === created) status.textContent = "Frame unavailable"; });
85
+ })().catch(() => { if (iframe === created) uiText(status, "player.frame-unavailable"); });
84
86
  });
85
87
  created.srcdoc = snapshot.preview.srcdoc;
86
88
  iframe = created;
@@ -94,7 +96,7 @@ export function createScrubPreview(container: HTMLElement) {
94
96
  time.textContent = feedbackClock(frame * snapshot.space.frameRate.denominator / snapshot.space.frameRate.numerator);
95
97
  if (frame !== displayed) {
96
98
  iframe.style.visibility = "hidden";
97
- status.textContent = "Loading frame";
99
+ uiText(status, "player.frame-loading");
98
100
  status.hidden = false;
99
101
  delete element.dataset.frame;
100
102
  } else {