@hypit/hypit 0.1.5 → 0.1.6

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 (31) hide show
  1. package/examples/semantic-composition/README.md +2 -1
  2. package/examples/semantic-composition/chat.svml +1 -1
  3. package/examples/semantic-composition/hypit.runtime.json +1 -1
  4. package/package.json +4 -1
  5. package/packages/caption-fine/README.md +7 -0
  6. package/packages/cli/src/view.ts +4 -3
  7. package/packages/film/src/surface.ts +4 -1
  8. package/packages/runtime-local/README.md +17 -0
  9. package/packages/script/README.md +15 -3
  10. package/packages/script/src/manifest.ts +2 -1
  11. package/packages/script/src/parser.ts +45 -6
  12. package/packages/studio/LOCALIZATION.md +98 -0
  13. package/packages/studio/README.md +13 -0
  14. package/packages/studio/locales/en.json +197 -0
  15. package/packages/studio/locales/zh-CN.json +191 -0
  16. package/packages/studio/src/localization-node.ts +46 -0
  17. package/packages/studio/src/localization.ts +85 -0
  18. package/packages/studio/src/style.css +15 -3
  19. package/packages/studio/src/ui/artifact-name.ts +8 -6
  20. package/packages/studio/src/ui/artifact-preview.ts +5 -4
  21. package/packages/studio/src/ui/code.ts +17 -16
  22. package/packages/studio/src/ui/comments.ts +26 -25
  23. package/packages/studio/src/ui/dropdown.ts +68 -0
  24. package/packages/studio/src/ui/i18n.ts +109 -0
  25. package/packages/studio/src/ui/icons.ts +1 -0
  26. package/packages/studio/src/ui/library.ts +76 -65
  27. package/packages/studio/src/ui/main.ts +84 -131
  28. package/packages/studio/src/ui/scrub-preview.ts +8 -6
  29. package/packages/studio/src/ui/stage.ts +28 -27
  30. package/packages/studio/src/ui/timeline.ts +17 -14
  31. package/packages/studio/start.ts +39 -22
@@ -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"/>',