@vestra-player/sdk 1.5.11 → 1.5.13

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.13
4
+
5
+ **`i18n extract` keeps the translator's comments and writes the right number of plural forms.** A fix; `apiVersion` stays `vestra/1`.
6
+
7
+ - Merging into a `locales/<lang>.po` kept only the header's text: comment lines before the header (`# Reviewed by an AI reviewer on 2026-10-25`, `# Translator: …`, `#, fuzzy`) and the translator's own `# ` comments on entries were dropped. Both are now kept as written (`parsePo` returns `headerComments`, entries carry `comments`; `writePo` writes them).
8
+ - A plural entry has exactly `nplurals` `msgstr[n]` lines, from the file's `Plural-Forms` (or the language: one for `ja`, `ko`, `zh-Hans`). Before, every plural entry got an empty `msgstr[1]`, also in one-form languages; a merge now removes such an extra empty line (a filled one stays).
9
+
10
+ ## 1.5.12
11
+
12
+ **Keys of disabled commands pass on; `i18n check` fails on stale catalogs; translator notes in tables; the Button follows its text.** A fix release; `apiVersion` stays `vestra/1`.
13
+
14
+ - **`TestHost.press` matches the player (0.4.3) for disabled commands.** A disabled command no longer swallows its key: the key goes to the next command bound to it (the one it shadowed in a conflict, then the global scope after a panel binding). With only disabled commands left, the first one with a `reason` shows the reason on the OSD and eats the key; without a reason the key reaches the focused control. `press` now resolves with what happened, `"ran"`, `"reason"` (see `host.osd`) or `"passed"`, instead of rejecting for a disabled command; it still rejects when nothing is bound and when the command that runs is busy. A test that expected `press` to reject for a disabled command now gets `"passed"` or `"reason"`.
15
+ - **`vestra-plugin i18n check` exits with 1** when `locales/messages.pot` is out of date, missing while `.po` files exist, or a `.po` lacks some of the template's messages, so CI catches a forgotten `extract`. Before, it printed these and exited 0. The other findings stay warnings, and `vestra-plugin check` still only prints them.
16
+ - **`TRANSLATORS:` notes on `as const` table entries.** A comment before a property or element of an object or array literal under `as const` (at any depth) now reaches the translators like one at a call site; before, texts extracted through a table lost their notes.
17
+ - **`slint/vestra/widgets.slint`** (the copy of the player's library): `Button`'s preferred width follows its label (the minimum width is unchanged), so a longer translated label widens the button instead of overflowing it.
18
+
3
19
  ## 1.5.11
4
20
 
5
21
  **Text values: words mixed with data, translated by the player** (player 0.4.3). Additive; `apiVersion` stays `vestra/1`, and plugins that worked need no change. 1.5.9 and 1.5.10 were not published to npm; this release includes them. Round-2 i18n requests R2-audio-1, R2-video-1, R2-subtitles-1/2, R2-playlist-1/2, R2-store-1, R2-workshop-1, R2-nes-1, capture #1, P-2, the info and hotkeys export texts, timeline and subtitle titles: one mechanism. Guide: [Translate a plugin ▸ Text that mixes words and data](docs/how-to/i18n.md#text-that-mixes-words-and-data).
package/README.md CHANGED
@@ -83,7 +83,7 @@ Complete, tested plugins in [`examples/`](https://github.com/vestrasys/vestra-pl
83
83
  | `vestra-plugin build [dir...] [--version x.y.z]` | Writes `dist/`: types stripped, files copied, manifest versioned. |
84
84
  | `vestra-plugin pack [dir...] [--version x.y.z] [--out dir]` | Builds, then packs `dist/` as `<id>-<version>.vxp`. |
85
85
  | `vestra-plugin i18n extract [dir...] [--locale lang]` | Collects the texts to translate into `locales/`, keeping translations. |
86
- | `vestra-plugin i18n check [dir...]` | Translation coverage and untranslatable text (warnings only). |
86
+ | `vestra-plugin i18n check [dir...]` | Translation coverage and untranslatable text; fails on stale catalogs. |
87
87
 
88
88
  ## Compatibility
89
89
 
@@ -29,7 +29,8 @@
29
29
  * i18n check coverage per language, placeholder and plural problems, a
30
30
  * stale messages.pot, and texts that cannot be translated
31
31
  * (composed in TypeScript, not literals, not English, Slint
32
- * text without @tr); warnings only, an unreadable .po fails
32
+ * text without @tr) as warnings; fails (exit 1) on a stale
33
+ * messages.pot, a .po missing messages, an unreadable file
33
34
  *
34
35
  * `check` prints the same i18n warnings when the plugin has locales/;
35
36
  * `build` compiles locales/<lang>.po to dist/locales/<lang>.json (the .po
@@ -430,7 +431,8 @@ async function i18nCheck(target) {
430
431
  dependencies(target);
431
432
  const { warnings, errors } = await i18nReport(target);
432
433
  for (const line of warnings) console.log(`${target.name}: i18n: ${line}`);
433
- if (errors.length) fail(`${target.name}: ${errors.join("; ")}`);
434
+ // A stale messages.pot, a .po missing messages, an unreadable file.
435
+ if (errors.length) fail(`${target.name}: i18n: ${errors.join("; ")}`);
434
436
  if (!warnings.length) console.log(`${target.name}: i18n: no problems`);
435
437
  }
436
438
 
@@ -100,6 +100,34 @@ function ignored(node, text) {
100
100
  .slice(node.getFullStart(), node.getStart())
101
101
  .includes("i18n-ignore");
102
102
  }
103
+ /**
104
+ * A string that is an entry (a property value or an element) of an object
105
+ * or array literal inside `… as const`, at any depth.
106
+ */
107
+ function inConstTable(node) {
108
+ let current;
109
+ if (ts.isPropertyAssignment(node.parent) && node.parent.initializer === node)
110
+ current = node.parent.parent;
111
+ else if (ts.isArrayLiteralExpression(node.parent))
112
+ current = node.parent;
113
+ else
114
+ return false;
115
+ for (;;) {
116
+ const parent = current.parent;
117
+ if (ts.isAsExpression(parent) &&
118
+ ts.isTypeReferenceNode(parent.type) &&
119
+ ts.isIdentifier(parent.type.typeName) &&
120
+ parent.type.typeName.text === "const")
121
+ return true;
122
+ if (ts.isPropertyAssignment(parent))
123
+ current = parent.parent;
124
+ else if (ts.isArrayLiteralExpression(parent) ||
125
+ ts.isParenthesizedExpression(parent))
126
+ current = parent;
127
+ else
128
+ return false;
129
+ }
130
+ }
103
131
  /** The statement (or class member) a node belongs to. */
104
132
  function statementOf(node) {
105
133
  let current = node;
@@ -111,6 +139,11 @@ function statementOf(node) {
111
139
  }
112
140
  class Extractor {
113
141
  found = [];
142
+ /**
143
+ * `TRANSLATORS:` notes on entries of `as const` tables, by text: a table
144
+ * entry is extracted through its literal type, which has no comments.
145
+ */
146
+ tableNotes = new Map();
114
147
  findings = [];
115
148
  #checker;
116
149
  constructor(checker) {
@@ -302,6 +335,20 @@ class Extractor {
302
335
  const visit = (node) => {
303
336
  if (ts.isCallExpression(node))
304
337
  this.#call(node, file, source, text);
338
+ if ((ts.isStringLiteral(node) ||
339
+ ts.isNoSubstitutionTemplateLiteral(node)) &&
340
+ inConstTable(node)) {
341
+ // The comment sits before the entry: the property, or the element.
342
+ const entry = ts.isPropertyAssignment(node.parent) ? node.parent : node;
343
+ const notes = notesOf(entry, text);
344
+ if (notes.length) {
345
+ const known = this.tableNotes.get(node.text) ?? [];
346
+ for (const note of notes)
347
+ if (!known.includes(note))
348
+ known.push(note);
349
+ this.tableNotes.set(node.text, known);
350
+ }
351
+ }
305
352
  // A text value written as data: `{ $vestraText: "…" }`.
306
353
  if (ts.isPropertyAssignment(node) &&
307
354
  (ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) &&
@@ -589,7 +636,10 @@ export function extract(options) {
589
636
  }
590
637
  if (occurrence.plural !== undefined && message.plural === undefined)
591
638
  message.plural = occurrence.plural;
592
- for (const note of occurrence.notes)
639
+ const tableNotes = occurrence.context === undefined
640
+ ? (extractor.tableNotes.get(occurrence.id) ?? [])
641
+ : [];
642
+ for (const note of [...occurrence.notes, ...tableNotes])
593
643
  if (!message.notes.includes(note))
594
644
  message.notes.push(note);
595
645
  if (!message.references.includes(reference))
@@ -598,10 +648,17 @@ export function extract(options) {
598
648
  const findings = extractor.findings.sort((a, b) => a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1);
599
649
  return { messages: [...messages.values()], findings };
600
650
  }
601
- const entryOf = (message) => {
651
+ /** `msgstr[n]` lines a plural entry has: the header's `nplurals`, else the language's. */
652
+ const formsOf = (language, header) => {
653
+ const declared = /nplurals\s*=\s*(\d+)/.exec(header ?? "")?.[1];
654
+ if (declared !== undefined && Number(declared) >= 1)
655
+ return Number(declared);
656
+ return ONE_FORM.has(language) ? 1 : 2;
657
+ };
658
+ const entryOf = (message, forms = 2) => {
602
659
  const entry = {
603
660
  id: message.id,
604
- strings: message.plural === undefined ? [""] : ["", ""],
661
+ strings: message.plural === undefined ? [""] : Array(forms).fill(""),
605
662
  fuzzy: false,
606
663
  notes: [...message.notes],
607
664
  references: [...message.references],
@@ -619,7 +676,7 @@ export function template(manifest, messages) {
619
676
  const project = [manifest.id, manifest.version]
620
677
  .filter((part) => typeof part === "string")
621
678
  .join(" ");
622
- return writePo(`Project-Id-Version: ${project}\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n`, messages.map(entryOf));
679
+ return writePo(`Project-Id-Version: ${project}\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\n`, messages.map((message) => entryOf(message)));
623
680
  }
624
681
  /** The header of a new `<lang>.po`. */
625
682
  export const newHeader = (language) => `Language: ${language}\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nPlural-Forms: ${pluralForms(language)}\n`;
@@ -637,20 +694,30 @@ export function merge(existing, language, messages) {
637
694
  old.set(keyOf(entry.context, entry.id), entry);
638
695
  const result = [];
639
696
  const present = new Set();
697
+ // A plural entry has exactly nplurals msgstr[n] (one for ja, ko,
698
+ // zh-Hans); extra empty ones are dropped, filled ones kept.
699
+ const forms = formsOf(language, parsed?.header ?? newHeader(language));
640
700
  let added = 0;
641
701
  let translated = 0;
642
702
  for (const message of messages) {
643
703
  const key = keyOf(message.context, message.id);
644
704
  present.add(key);
645
- const entry = entryOf(message);
705
+ const entry = entryOf(message, forms);
646
706
  const before = old.get(key);
707
+ // The translator's own comments stay with their message.
708
+ if (before?.comments?.length)
709
+ entry.comments = [...before.comments];
647
710
  if (before && hasText(before)) {
648
711
  entry.strings = [...before.strings];
649
712
  if (message.plural === undefined)
650
713
  entry.strings = [entry.strings[0]];
651
- else
652
- while (entry.strings.length < 2)
714
+ else {
715
+ while (entry.strings.length < forms)
653
716
  entry.strings.push("");
717
+ while (entry.strings.length > forms &&
718
+ entry.strings[entry.strings.length - 1] === "")
719
+ entry.strings.pop();
720
+ }
654
721
  entry.fuzzy = before.fuzzy;
655
722
  entry.flags = [...before.flags];
656
723
  }
@@ -668,7 +735,7 @@ export function merge(existing, language, messages) {
668
735
  obsolete++;
669
736
  }
670
737
  return {
671
- text: writePo(parsed?.header ?? newHeader(language), result),
738
+ text: writePo(parsed?.header ?? newHeader(language), result, parsed?.headerComments ?? []),
672
739
  total: messages.length,
673
740
  translated,
674
741
  added,
@@ -683,24 +750,26 @@ export function checkCatalogs(directory, messages, findings) {
683
750
  const pot = join(locales, "messages.pot");
684
751
  const keys = (entries) => new Set(entries.map((e) => `${keyOf(e.context, e.id)}\u0000${e.plural ?? ""}`));
685
752
  const wanted = keys(messages);
753
+ const files = existsSync(locales)
754
+ ? readdirSync(locales)
755
+ .filter((name) => name.endsWith(".po"))
756
+ .sort()
757
+ : [];
758
+ // A stale template or a .po without some of its messages fails
759
+ // `i18n check` (an error); `check` prints it like the warnings.
686
760
  if (!existsSync(pot))
687
- warnings.push("locales/messages.pot is missing: run vestra-plugin i18n extract");
761
+ (files.length ? errors : warnings).push("locales/messages.pot is missing: run vestra-plugin i18n extract");
688
762
  else {
689
763
  try {
690
764
  const have = keys(parsePo(readFileSync(pot, "utf8")).entries);
691
765
  const same = have.size === wanted.size && [...wanted].every((key) => have.has(key));
692
766
  if (!same)
693
- warnings.push("locales/messages.pot is out of date: run vestra-plugin i18n extract");
767
+ errors.push("locales/messages.pot is out of date: run vestra-plugin i18n extract");
694
768
  }
695
769
  catch (error) {
696
770
  errors.push(`locales/messages.pot: ${error.message}`);
697
771
  }
698
772
  }
699
- const files = existsSync(locales)
700
- ? readdirSync(locales)
701
- .filter((name) => name.endsWith(".po"))
702
- .sort()
703
- : [];
704
773
  for (const name of files) {
705
774
  const language = name.slice(0, -3);
706
775
  if (language === "en") {
@@ -729,7 +798,7 @@ export function checkCatalogs(directory, messages, findings) {
729
798
  const inFile = keys(live);
730
799
  const missing = [...wanted].filter((key) => !inFile.has(key)).length;
731
800
  if (missing)
732
- warnings.push(`locales/${name}: ${missing} ${missing === 1 ? "message is" : "messages are"} not in this file: run vestra-plugin i18n extract`);
801
+ errors.push(`locales/${name}: ${missing} ${missing === 1 ? "message is" : "messages are"} not in this file: run vestra-plugin i18n extract`);
733
802
  for (const entry of live) {
734
803
  entry.strings.forEach((text, form) => {
735
804
  if (text === "")
package/dist/i18n.d.ts CHANGED
@@ -25,16 +25,23 @@ export interface PoEntry {
25
25
  flags: string[];
26
26
  /** A `#~` entry: no longer in the source. */
27
27
  obsolete: boolean;
28
+ /** `# ` lines: the translator's own comments, kept as written. */
29
+ comments?: string[];
28
30
  }
29
31
  /** The catalog key: `msgid`, or `msgctxt` U+0004 `msgid`. */
30
32
  export declare const keyOf: (context: string | undefined, id: string) => string;
31
33
  /** Parse a `.po` (or `.pot`) file. Throws with the line number on errors. */
32
34
  export declare function parsePo(text: string): {
33
35
  header: string;
36
+ /** The comment lines before the header entry, as written (`# Reviewed…`, `#, fuzzy`). */
37
+ headerComments: string[];
34
38
  entries: PoEntry[];
35
39
  };
36
- /** Write a `.po` file: the header, then the entries in the given order. */
37
- export declare function writePo(header: string, entries: readonly PoEntry[]): string;
40
+ /**
41
+ * Write a `.po` file: the header (after its comment lines, as written),
42
+ * then the entries in the given order.
43
+ */
44
+ export declare function writePo(header: string, entries: readonly PoEntry[], headerComments?: readonly string[]): string;
38
45
  /** A message of a compiled catalog: a translation, or `[one, other]`. */
39
46
  export type CompiledMessage = string | [string, string];
40
47
  /**
package/dist/i18n.js CHANGED
@@ -23,6 +23,9 @@ const translated = (entry) => !entry.fuzzy && !entry.obsolete && (entry.strings[
23
23
  export function parsePo(text) {
24
24
  const entries = [];
25
25
  let header = "";
26
+ let headerComments = [];
27
+ // Every comment line of the entry being read, as written.
28
+ let raw = [];
26
29
  const blank = () => ({
27
30
  id: "",
28
31
  strings: [],
@@ -39,11 +42,14 @@ export function parsePo(text) {
39
42
  let pending = blank();
40
43
  const finish = () => {
41
44
  if (started) {
42
- if (entry.id === "" && entry.context === undefined)
45
+ if (entry.id === "" && entry.context === undefined) {
43
46
  header = entry.strings[0] ?? "";
47
+ headerComments = raw;
48
+ }
44
49
  else
45
50
  entries.push(entry);
46
51
  }
52
+ raw = [];
47
53
  entry = blank();
48
54
  started = false;
49
55
  field = "none";
@@ -66,8 +72,11 @@ export function parsePo(text) {
66
72
  else if (line.startsWith("#")) {
67
73
  if (started)
68
74
  finish();
75
+ raw.push(lines[index].trimEnd());
69
76
  const body = line.slice(2).trim();
70
- if (line.startsWith("#."))
77
+ if (line === "#" || line.startsWith("# "))
78
+ (pending.comments ??= []).push(line.slice(2));
79
+ else if (line.startsWith("#."))
71
80
  pending.notes.push(body);
72
81
  else if (line.startsWith("#:"))
73
82
  pending.references.push(...body.split(/\s+/).filter(Boolean));
@@ -135,7 +144,7 @@ export function parsePo(text) {
135
144
  field = next;
136
145
  }
137
146
  finish();
138
- return { header, entries };
147
+ return { header, headerComments, entries };
139
148
  }
140
149
  function unquote(text, at) {
141
150
  if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"'))
@@ -172,12 +181,19 @@ function field(keyword, text) {
172
181
  return `${keyword} ${poString(text)}`;
173
182
  return [`${keyword} ""`, ...parts.map(poString)].join("\n");
174
183
  }
175
- /** Write a `.po` file: the header, then the entries in the given order. */
176
- export function writePo(header, entries) {
177
- const blocks = [`msgid ""\n${field("msgstr", header)}`];
184
+ /**
185
+ * Write a `.po` file: the header (after its comment lines, as written),
186
+ * then the entries in the given order.
187
+ */
188
+ export function writePo(header, entries, headerComments = []) {
189
+ const blocks = [
190
+ [...headerComments, `msgid ""`, field("msgstr", header)].join("\n"),
191
+ ];
178
192
  for (const entry of entries) {
179
193
  const prefix = entry.obsolete ? "#~ " : "";
180
194
  const lines = [];
195
+ for (const comment of entry.comments ?? [])
196
+ lines.push(comment === "" ? "#" : `# ${comment}`);
181
197
  for (const note of entry.notes)
182
198
  lines.push(`#. ${note}`);
183
199
  if (entry.references.length && !entry.obsolete)
@@ -1242,11 +1242,20 @@ export declare class TestHost implements Transport {
1242
1242
  failStream(producerId: number, message: string): void;
1243
1243
  /**
1244
1244
  * Press a binding like the host's table would: panel-focus bindings first
1245
- * when `panel` is true, then global ones; the earliest registration wins a
1246
- * conflict. Resolves after the command ran; rejects when nothing is bound
1247
- * or the command is disabled or busy. Bindings use the reference syntax
1248
- * (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`); the player's core actions
1249
- * match first, and user overrides in `bindings.overlay` apply.
1245
+ * when `panel` is true, then global ones; within a scope the earliest
1246
+ * registration first (the player's own commands before the plugin's), as
1247
+ * the conflict rule orders them. A disabled command lets the key pass to
1248
+ * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
1249
+ * shadowed, then the global scope. The first enabled one runs; `press`
1250
+ * resolves after it ran (`"ran"`), or rejects when it is busy. When only
1251
+ * disabled commands hold the key, the first one with a `reason` shows the
1252
+ * reason on the OSD and eats the key (`"reason"`, see `host.osd`);
1253
+ * without a reason the key goes on to the focused control (`"passed"`,
1254
+ * nothing runs). Rejects when nothing is bound. Bindings use the
1255
+ * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
1256
+ * overrides in `bindings.overlay` apply.
1257
+ *
1258
+ * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
1250
1259
  *
1251
1260
  * @example
1252
1261
  * ```ts
@@ -1256,7 +1265,7 @@ export declare class TestHost implements Transport {
1256
1265
  */
1257
1266
  press(binding: string, options?: {
1258
1267
  panel?: boolean;
1259
- }): Promise<void>;
1268
+ }): Promise<"ran" | "reason" | "passed">;
1260
1269
  /**
1261
1270
  * Bindings declared by more than one command, each as
1262
1271
  * `[binding, winner, ...ignored]` (command ids; core actions as
@@ -1749,11 +1749,20 @@ export class TestHost {
1749
1749
  }
1750
1750
  /**
1751
1751
  * Press a binding like the host's table would: panel-focus bindings first
1752
- * when `panel` is true, then global ones; the earliest registration wins a
1753
- * conflict. Resolves after the command ran; rejects when nothing is bound
1754
- * or the command is disabled or busy. Bindings use the reference syntax
1755
- * (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`); the player's core actions
1756
- * match first, and user overrides in `bindings.overlay` apply.
1752
+ * when `panel` is true, then global ones; within a scope the earliest
1753
+ * registration first (the player's own commands before the plugin's), as
1754
+ * the conflict rule orders them. A disabled command lets the key pass to
1755
+ * the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it
1756
+ * shadowed, then the global scope. The first enabled one runs; `press`
1757
+ * resolves after it ran (`"ran"`), or rejects when it is busy. When only
1758
+ * disabled commands hold the key, the first one with a `reason` shows the
1759
+ * reason on the OSD and eats the key (`"reason"`, see `host.osd`);
1760
+ * without a reason the key goes on to the focused control (`"passed"`,
1761
+ * nothing runs). Rejects when nothing is bound. Bindings use the
1762
+ * reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user
1763
+ * overrides in `bindings.overlay` apply.
1764
+ *
1765
+ * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
1757
1766
  *
1758
1767
  * @example
1759
1768
  * ```ts
@@ -1763,11 +1772,28 @@ export class TestHost {
1763
1772
  */
1764
1773
  async press(binding, options = {}) {
1765
1774
  const claims = this.#claims();
1766
- const id = (options.panel ? claims.get(`panel ${binding}`)?.[0] : undefined) ??
1767
- claims.get(`global ${binding}`)?.[0];
1768
- if (!id)
1775
+ const ids = [
1776
+ ...(options.panel ? (claims.get(`panel ${binding}`) ?? []) : []),
1777
+ ...(claims.get(`global ${binding}`) ?? []),
1778
+ ];
1779
+ if (!ids.length)
1769
1780
  throw new Error(`test host: nothing is bound to ${binding}`);
1770
- await (this.coreCommands.get(id) ?? this.command(id)).run();
1781
+ const command = (id) => this.coreCommands.get(id) ?? this.command(id);
1782
+ const enabled = ids.find((id) => command(id).state.enabled);
1783
+ if (enabled !== undefined) {
1784
+ await command(enabled).run();
1785
+ return "ran";
1786
+ }
1787
+ for (const id of ids) {
1788
+ const reason = this.commands.has(id)
1789
+ ? command(id).display.reason
1790
+ : command(id).state.reason;
1791
+ if (typeof reason === "string" && reason !== "") {
1792
+ this.osd.push(reason);
1793
+ return "reason";
1794
+ }
1795
+ }
1796
+ return "passed";
1771
1797
  }
1772
1798
  /**
1773
1799
  * Bindings declared by more than one command, each as
package/llms-full.txt CHANGED
@@ -1,4 +1,4 @@
1
- # @vestra-player/sdk 1.5.11: full reference
1
+ # @vestra-player/sdk 1.5.13: full reference
2
2
 
3
3
  > Everything needed to write a Vestra Player plugin (plugin API `vestra/1`): the guides, every public declaration of `@vestra-player/sdk` and `@vestra-player/sdk/testing` with its documentation, and the complete source of the example plugins. Generated from the SDK's sources by scripts/llms.mjs; the short index is llms.txt.
4
4
 
@@ -5230,11 +5230,12 @@ class TestHost implements Transport {
5230
5230
  */
5231
5231
  failStream(producerId: number, message: string): void;
5232
5232
  /**
5233
- * Press a binding like the host's table would: panel-focus bindings first when `panel` is true, then global ones; the earliest registration wins a conflict. Resolves after the command ran; rejects when nothing is bound or the command is disabled or busy. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`); the player's core actions match first, and user overrides in `bindings.overlay` apply.
5233
+ * Press a binding like the host's table would: panel-focus bindings first when `panel` is true, then global ones; within a scope the earliest registration first (the player's own commands before the plugin's), as the conflict rule orders them. A disabled command lets the key pass to the next command bound to it (SDK 1.5.12, as player 0.4.3): the one it shadowed, then the global scope. The first enabled one runs; `press` resolves after it ran (`"ran"`), or rejects when it is busy. When only disabled commands hold the key, the first one with a `reason` shows the reason on the OSD and eats the key (`"reason"`, see `host.osd`); without a reason the key goes on to the focused control (`"passed"`, nothing runs). Rejects when nothing is bound. Bindings use the reference syntax (`"Space"`, `"Ctrl+O"`, `"Mouse:WheelUp"`), and user overrides in `bindings.overlay` apply.
5234
+ * @returns What the key did: `"ran"`, `"reason"` or `"passed"`.
5234
5235
  */
5235
5236
  press(binding: string, options?: {
5236
5237
  panel?: boolean;
5237
- }): Promise<void>;
5238
+ }): Promise<"ran" | "reason" | "passed">;
5238
5239
  /** Bindings declared by more than one command, each as `[binding, winner, ...ignored]` (command ids; core actions as `host/<id>`). Empty when there are none. */
5239
5240
  get conflicts(): string[][];
5240
5241
  /** The binding overlay `plugin.bindings.set` writes (`"<plugin>/<command>"` to inputs; preset it to simulate saved overrides). `press` honours it. */
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vestra Player SDK
2
2
 
3
- > @vestra-player/sdk 1.5.11: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls; it runs in its own Node.js process. llms-full.txt, next to this file, has every declaration, the guides and the examples; L<n> below are its line numbers.
3
+ > @vestra-player/sdk 1.5.13: write plugins for Vestra Player in TypeScript (plugin API `vestra/1`). A plugin is a folder with `manifest.json` and an entry module whose default export `activate(plugin)` the player calls; it runs in its own Node.js process. llms-full.txt, next to this file, has every declaration, the guides and the examples; L<n> below are its line numbers.
4
4
 
5
5
  **How plugins run**
6
6
 
@@ -121,7 +121,7 @@ test("shows the position", async () => {
121
121
  - `vestra-plugin build [dir...] [--version x.y.z]`: Writes `dist/`: types stripped, files copied, manifest versioned.
122
122
  - `vestra-plugin pack [dir...] [--version x.y.z] [--out dir]`: Builds, then packs `dist/` as `<id>-<version>.vxp`.
123
123
  - `vestra-plugin i18n extract [dir...] [--locale lang]`: Collects the texts to translate into `locales/`, keeping translations.
124
- - `vestra-plugin i18n check [dir...]`: Translation coverage and untranslatable text (warnings only).
124
+ - `vestra-plugin i18n check [dir...]`: Translation coverage and untranslatable text; fails on stale catalogs.
125
125
  - `vestra-player --plugin <dir>` runs a plugin from source, no build needed.
126
126
 
127
127
  **Error codes** (`error.code`; branch on it, never on `message`)
@@ -153,7 +153,7 @@ test("shows the position", async () => {
153
153
  - [How-to: Keep a slider responsive while dragging](https://vestrasys.github.io/vestra-player-sdk/documents/Keep_a_slider_responsive_while_dragging.html): L627–801
154
154
  - [How-to: Remember a window's state between runs](https://vestrasys.github.io/vestra-player-sdk/documents/Remember_a_window_s_state_between_runs.html): L802–931
155
155
  - [API: @vestra-player/sdk](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk.html): L932–4427
156
- - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html) (TestHost, startPlugin): L4428–5537
156
+ - [API: @vestra-player/sdk/testing](https://vestrasys.github.io/vestra-player-sdk/modules/_vestra-player_sdk_testing.html) (TestHost, startPlugin): L4428–5538
157
157
 
158
158
  ## API
159
159
 
@@ -193,15 +193,15 @@ Where each module of `plugin` is declared:
193
193
 
194
194
  Complete plugins with tests, in [examples/](https://github.com/vestrasys/vestra-player-sdk/tree/main/examples) and in llms-full.txt:
195
195
 
196
- - hello: The README Quick start: one menu command and an on-screen message. L5954–6043
197
- - blur: A tool window with one slider that blurs the playing video. L6044–6305
198
- - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L6306–6638
199
- - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L6639–6823
200
- - panel-menu: A sidebar panel with its own right-click menu. L6824–7029
201
- - menus: Radio menu items, separator groups and a menu item whose title changes. L7030–7189
202
- - theme-widgets: A tool window drawn with the player's theme and widgets. L7190–7353
203
- - markdown: A window that shows a Markdown document. L7354–7547
204
- - i18n: A plugin in the player's language: English source text, a Chinese translation. L7548–7715
196
+ - hello: The README Quick start: one menu command and an on-screen message. L5955–6044
197
+ - blur: A tool window with one slider that blurs the playing video. L6045–6306
198
+ - signal-generator: Generates its own video and audio: colour bars and a 1 kHz tone. L6307–6639
199
+ - bookmark: A tool window bound to the playback state: remember the position and jump back to it. L6640–6824
200
+ - panel-menu: A sidebar panel with its own right-click menu. L6825–7030
201
+ - menus: Radio menu items, separator groups and a menu item whose title changes. L7031–7190
202
+ - theme-widgets: A tool window drawn with the player's theme and widgets. L7191–7354
203
+ - markdown: A window that shows a Markdown document. L7355–7548
204
+ - i18n: A plugin in the player's language: English source text, a Chinese translation. L7549–7716
205
205
 
206
206
  ## Optional
207
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vestra-player/sdk",
3
- "version": "1.5.11",
3
+ "version": "1.5.13",
4
4
  "description": "Plugin API types, test host and plugin CLI for Vestra Player (apiVersion vestra/1)",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -81,13 +81,17 @@ export component Button {
81
81
  min-height: root.icon-only ? root.icon-height : root.small ? Theme.button-small-height : Theme.button-height;
82
82
  min-width: root.icon-only ? root.icon-width : root.small ? Theme.button-small-min-width : Theme.button-min-width;
83
83
  preferred-height: self.min-height;
84
+ // A longer (translated) label widens the button instead of overflowing it.
85
+ preferred-width: root.icon-only ? self.min-width
86
+ : max(self.min-width, label.preferred-width + 2 * Theme.button-padding);
84
87
  accessible-role: button;
85
88
  accessible-label: root.text;
86
89
  accessible-enabled: root.enabled;
87
90
  accessible-checkable: root.icon-only;
88
91
  accessible-checked: root.checked;
89
92
  accessible-action-default => { if root.enabled { root.clicked(); } }
90
- if !root.icon-only: Rectangle {
93
+ Rectangle {
94
+ visible: !root.icon-only;
91
95
  border-radius: Theme.radius-control;
92
96
  background: !root.enabled ? Theme.button-disabled
93
97
  : touch.pressed ? Theme.button-press
@@ -96,7 +100,7 @@ export component Button {
96
100
  padding-left: Theme.button-padding;
97
101
  padding-right: Theme.button-padding;
98
102
  alignment: center;
99
- Text {
103
+ label := Text {
100
104
  text: root.text;
101
105
  color: root.enabled ? Theme.text-list : Theme.text-dim;
102
106
  font-size: Theme.font-body;