@vestra-player/sdk 1.5.11 → 1.5.12

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.12
4
+
5
+ **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`.
6
+
7
+ - **`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"`.
8
+ - **`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.
9
+ - **`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.
10
+ - **`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.
11
+
3
12
  ## 1.5.11
4
13
 
5
14
  **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))
@@ -683,24 +733,26 @@ export function checkCatalogs(directory, messages, findings) {
683
733
  const pot = join(locales, "messages.pot");
684
734
  const keys = (entries) => new Set(entries.map((e) => `${keyOf(e.context, e.id)}\u0000${e.plural ?? ""}`));
685
735
  const wanted = keys(messages);
736
+ const files = existsSync(locales)
737
+ ? readdirSync(locales)
738
+ .filter((name) => name.endsWith(".po"))
739
+ .sort()
740
+ : [];
741
+ // A stale template or a .po without some of its messages fails
742
+ // `i18n check` (an error); `check` prints it like the warnings.
686
743
  if (!existsSync(pot))
687
- warnings.push("locales/messages.pot is missing: run vestra-plugin i18n extract");
744
+ (files.length ? errors : warnings).push("locales/messages.pot is missing: run vestra-plugin i18n extract");
688
745
  else {
689
746
  try {
690
747
  const have = keys(parsePo(readFileSync(pot, "utf8")).entries);
691
748
  const same = have.size === wanted.size && [...wanted].every((key) => have.has(key));
692
749
  if (!same)
693
- warnings.push("locales/messages.pot is out of date: run vestra-plugin i18n extract");
750
+ errors.push("locales/messages.pot is out of date: run vestra-plugin i18n extract");
694
751
  }
695
752
  catch (error) {
696
753
  errors.push(`locales/messages.pot: ${error.message}`);
697
754
  }
698
755
  }
699
- const files = existsSync(locales)
700
- ? readdirSync(locales)
701
- .filter((name) => name.endsWith(".po"))
702
- .sort()
703
- : [];
704
756
  for (const name of files) {
705
757
  const language = name.slice(0, -3);
706
758
  if (language === "en") {
@@ -729,7 +781,7 @@ export function checkCatalogs(directory, messages, findings) {
729
781
  const inFile = keys(live);
730
782
  const missing = [...wanted].filter((key) => !inFile.has(key)).length;
731
783
  if (missing)
732
- warnings.push(`locales/${name}: ${missing} ${missing === 1 ? "message is" : "messages are"} not in this file: run vestra-plugin i18n extract`);
784
+ errors.push(`locales/${name}: ${missing} ${missing === 1 ? "message is" : "messages are"} not in this file: run vestra-plugin i18n extract`);
733
785
  for (const entry of live) {
734
786
  entry.strings.forEach((text, form) => {
735
787
  if (text === "")
@@ -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.12: 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.12: 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.12",
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;