stitch2 0.9.1 → 0.11.0

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/README.md CHANGED
@@ -37,6 +37,8 @@ file that already exists. From there the agent spends its effort on the design.
37
37
  and status filters, your DESIGN.md drawn as tokens and rules, the component catalog, and an inspector to
38
38
  approve, archive or leave a note on a version. Live reload; copyable references to paste to an agent.
39
39
  With a light and a dark theme (DESIGN.md's `light-*` colours), a switch (`T`) shows every screen in either one.
40
+ With languages configured, a Language switch (`L`) shows every screen in one of them, and the Languages page
41
+ shows a screen side by side in each.
40
42
  Pick mode (`P`) highlights any element of a screen and copies a reference with its source line, such as
41
43
  `home mobile v1 (design/screens/home/mobile-v1.html:36) <gg-button> “Start Push”`; Shift-click collects
42
44
  several to copy at once or add to the version's note.
@@ -44,7 +46,8 @@ file that already exists. From there the agent spends its effort on the design.
44
46
  other text, labels cut short with an ellipsis, content under fixed bars, fixed bars that overlap each other, tap
45
47
  targets (WCAG 2.2), text contrast in every theme, text that is not one of DESIGN.md's type levels, capitals without tracking,
46
48
  large numbers in monospace, components built by hand, components that differ between screens, and colours
47
- written as values instead of DESIGN.md tokens.
49
+ written as values instead of DESIGN.md tokens. Each screen is also run in long and right-to-left
50
+ pseudo-languages and your locales (see below).
48
51
  - **Consistency report** (`stitch2 consistency`): every shared component (tab bar, header, buttons, chips, …)
49
52
  cropped from every screen, next to the catalog's version, with what differs (sizes, fonts, icon set, icons).
50
53
  - **Type audit** (`stitch2 type`): every text style a screen uses, and the level it matches.
@@ -73,7 +76,7 @@ file that already exists. From there the agent spends its effort on the design.
73
76
  - **Skills** for agents (`skills/`): `stitch2` (the design workflow, principles, typography, components,
74
77
  versions), `stitch2-design-md` (building the design system), `stitch2-variants` (fresh-eyes versions and
75
78
  testing skill changes), `stitch2-consistency` (verifying and fixing
76
- consistency), `stitch2-app-sync` (keeping the app and the designs in step: design first, then the app) and,
79
+ consistency), `stitch2-i18n` (screens in every language), `stitch2-app-sync` (keeping the app and the designs in step: design first, then the app) and,
77
80
  optional, `stitch2-import-from-google-stitch` (drafting screens in Google Stitch through its MCP server).
78
81
 
79
82
  ## Setup
@@ -183,6 +186,49 @@ for (const locale of ['de', 'ru', 'ar']) {
183
186
  Warnings worth reading for translations: `truncated` (a label cut short with an ellipsis), `text-crowded` (two
184
187
  controls' labels nearly touching) and `clipped`.
185
188
 
189
+ ## Screens in every language
190
+
191
+ A screen is written once, in the source language, with each product string marked by its message key from your
192
+ app's own translation files. stitch2 swaps the text in when the screen is opened in another language, so one file
193
+ shows every locale and the screens use the same strings as the product:
194
+
195
+ ```html
196
+ <span data-t="nav.home">Home</span>
197
+ <ex-header title="Settings" data-t-title="settings.title"></ex-header>
198
+ <p data-t="workout.sets" data-t-args="count=16">16 sets</p> <!-- plural form picked by count -->
199
+ <p translate="no">Dumbbell Bench Press</p> <!-- sample content stays as written -->
200
+ <ex-row meta="5 exercises · Linear" data-t-meta="{plan.exercises|count=5} · {plan.linear}"></ex-row> <!-- a template -->
201
+ <ex-row meta="Sun, 4 Oct · 7,920 kg"
202
+ data-t-meta="{@date|value=2026-10-04;weekday=short;day=numeric;month=short} · {@number|value=7920} {unit.kg}"></ex-row>
203
+ ```
204
+
205
+ ```json
206
+ "i18n": {
207
+ "source": "en",
208
+ "locales": ["en", "de", "ru", "ar"],
209
+ "messages": { "en": "src/i18n/en.ts", "*": "src/i18n/locales/{locale}.ts" },
210
+ "check": ["de", "ar"]
211
+ }
212
+ ```
213
+
214
+ Messages are JSON or a JS/TS module (its default export, else its first exported object); nested objects become
215
+ dotted keys, and plurals are objects keyed by CLDR category (`one`, `few`, `other`…). A value with braces is a
216
+ template: each `{key|name=value;…}` becomes its message and the rest is kept, and `{@date|…}` and `{@number|…}`
217
+ format with `Intl` in the screen's language. Two pseudo-languages need
218
+ no translations: **pseudo** makes every text about 40% longer, accented and bracketed, so cut text shows, and
219
+ **pseudo-rtl** lays the page out right to left.
220
+
221
+ - The canvas's Language switch (`L`) shows every screen in one language; the **Languages** page shows each
222
+ screen side by side in the check languages, or in all of them, with the number of missing translations.
223
+ - `stitch2 check` runs every screen in pseudo, pseudo-rtl and the locales in `check`; an issue that only shows
224
+ in one language carries it ("pseudo: Text overflows…"). A key missing from the source messages is an error.
225
+ - `stitch2 i18n` lists, per screen, keys the source or a locale lacks, translations whose `{placeholders}`
226
+ differ, and text with no key; `--suggest` names existing messages with the same text.
227
+ - Components that draw their own labels read them with `translate(key, fallback)` from `stitch2/elements`.
228
+
229
+ The `stitch2-i18n` skill teaches agents to write keyed screens and to fix text that does not fit: room in the
230
+ layout first, then a shorter natural word, never a smaller type size.
231
+
186
232
  ## Commands
187
233
 
188
234
  | Command | What it does |
@@ -191,7 +237,8 @@ controls' labels nearly touching) and `clipped`.
191
237
  | `stitch2 init --name N --primary #hex [options]` | Writes a complete DESIGN.md from a few choices |
192
238
  | `stitch2 extract <url or file…>` | Writes a DESIGN.md measured from an existing site or screens |
193
239
  | `stitch2 canvas` | The canvas at http://localhost:4400 (`PORT` and `HOST` to change) |
194
- | `stitch2 check [filter] [--shots] [--strict]` | All checks, in every theme; `--shots` saves renders to `<design>/renders/` (`--light.png` for the other theme) |
240
+ | `stitch2 check [filter] [--shots] [--strict] [--locales l,…]` | All checks, in every theme and check language; `--shots` saves renders to `<design>/renders/` (`--light.png`, `--pseudo.png`…) |
241
+ | `stitch2 i18n [filter] [--locale l] [--suggest] [--json]` | Message keys the source or a locale lacks, and screen text with no key |
195
242
  | `stitch2 consistency [kind…]` | The consistency report, `<design>/consistency/index.html` |
196
243
  | `stitch2 type [filter] [--json]` | The type audit |
197
244
  | `stitch2 screens [name] [--status s] [--json]` | Versions, statuses and notes |
@@ -67,6 +67,30 @@
67
67
  "items": { "type": "string" },
68
68
  "description": "The themes every screen must work in, by their data-theme value. Defaults to DESIGN.md's: dark (the plain colours), plus light when it has light-* colours. The canvas switches between them and check runs in each."
69
69
  },
70
+ "i18n": {
71
+ "type": "object",
72
+ "additionalProperties": false,
73
+ "description": "Languages. Screens mark product text with message keys (data-t, data-t-<attr>, data-t-args); the canvas switches language and check runs each screen in the pseudo-languages and the locales in check.",
74
+ "properties": {
75
+ "source": { "type": "string", "default": "en", "description": "The language screens are written in." },
76
+ "locales": { "type": "array", "items": { "type": "string" }, "description": "Locales with translations, besides the source language." },
77
+ "messages": {
78
+ "description": "Message files, relative to this file: JSON or a JS/TS module (default export, else its first exported object). A pattern with {locale}, or a map per locale with * for the rest.",
79
+ "oneOf": [
80
+ { "type": "string" },
81
+ { "type": "object", "additionalProperties": { "type": "string" } }
82
+ ]
83
+ },
84
+ "check": {
85
+ "description": "Locales stitch2 check runs besides pseudo and pseudo-rtl, or \"all\".",
86
+ "oneOf": [
87
+ { "type": "array", "items": { "type": "string" } },
88
+ { "const": "all" }
89
+ ]
90
+ },
91
+ "rtl": { "type": "array", "items": { "type": "string" }, "description": "Right-to-left locales, if not ar, he, fa, ur, ps and yi." }
92
+ }
93
+ },
70
94
  "skills": {
71
95
  "type": "array",
72
96
  "items": {
package/dist/canvas.html CHANGED
@@ -27,6 +27,7 @@
27
27
  .seg button { background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; flex: 1; padding: 4px 6px; white-space: nowrap; }
28
28
  .seg button.on { background: var(--panel2); color: var(--text); }
29
29
  .seg[hidden] { display: none; }
30
+ #langFilter { background: var(--bg); border: 1px solid var(--edge); border-radius: 8px; color: var(--text); padding: 5px 8px; width: 100%; }
30
31
  #tree { flex: 1; overflow: auto; padding: 6px 8px 16px; }
31
32
  #tree h4 { color: var(--faint); font-size: 11px; letter-spacing: .06em; margin: 14px 6px 6px; text-transform: uppercase; }
32
33
  .item { align-items: center; background: none; border: 0; border-radius: 6px; color: var(--muted); cursor: pointer; display: flex; gap: 8px; padding: 6px 8px; text-align: left; width: 100%; }
@@ -107,6 +108,7 @@
107
108
  <div class="seg" id="statusFilter"><button type="button" data-v="active" class="on" title="Everything except archived versions">Active</button><button type="button" data-v="approved" title="The reference versions agents build on">Approved</button><button type="button" data-v="review" title="Finished by an agent, waiting for your decision">Needs review</button><button type="button" data-v="all" title="Including archived versions">All</button></div>
108
109
  <div class="seg" id="deviceFilter"><button type="button" data-v="all" class="on">All devices</button><button type="button" data-v="mobile">Mobile</button><button type="button" data-v="desktop">Desktop</button></div>
109
110
  <div class="seg" id="themeFilter" hidden title="Show every screen in this theme (T)"></div>
111
+ <select id="langFilter" title="Show every screen in this language (L)" aria-label="Language"></select>
110
112
  </header>
111
113
  <nav id="tree"></nav>
112
114
  <footer>
@@ -140,8 +142,29 @@ let designDir = 'design'
140
142
  let THEMES = ['dark']
141
143
  let theme = localStorage.getItem('stitch2-theme') ?? 'dark'
142
144
  const themed = () => THEMES.length > 1
143
- const themeNote = () => (themed() && theme !== THEMES[0] ? ` in the ${theme} theme` : '')
144
- const forTheme = (issues) => (issues ?? []).filter((i) => !i.theme || !themed() || i.theme === theme)
145
+ // Languages: screens reload in the one picked (?lang=…); the source language shows them as written.
146
+ let LANGS = ['en']
147
+ let SOURCE = 'en'
148
+ let lang = localStorage.getItem('stitch2-lang') ?? 'en'
149
+ const langQuery = () => (lang === SOURCE ? '' : `&lang=${encodeURIComponent(lang)}`)
150
+ const themeNote = () =>
151
+ [themed() && theme !== THEMES[0] ? `in the ${theme} theme` : '', lang !== SOURCE ? `in ${lang}` : '']
152
+ .filter(Boolean)
153
+ .map((s) => ' ' + s)
154
+ .join(',')
155
+ const forTheme = (issues) =>
156
+ (issues ?? []).filter((i) => (!i.theme || !themed() || i.theme === theme) && (!i.locale || i.locale === lang))
157
+ const langName = (l) =>
158
+ l === 'pseudo' ? 'Pseudo: long text' : l === 'pseudo-rtl' ? 'Pseudo: right to left' : `${new Intl.DisplayNames([l], { type: 'language' }).of(l) ?? l} (${l})${l === SOURCE ? ', source' : ''}`
159
+ function setLang(l) {
160
+ lang = l
161
+ localStorage.setItem('stitch2-lang', l)
162
+ document.getElementById('langFilter').value = l
163
+ for (const f of frames.values()) f.iframe.src = `/${f.screen.path}?pick${langQuery()}`
164
+ for (const f of frames.values()) badge(f)
165
+ if (selected) inspector(selected)
166
+ summary()
167
+ }
145
168
  function applyTheme(f) {
146
169
  if (!themed()) return
147
170
  try { f.iframe.contentDocument.documentElement.dataset.theme = theme } catch {}
@@ -283,6 +306,7 @@ function tree() {
283
306
  item('DESIGN.md', 'tokens · rules', docOpen('/design'), () => openDoc('/design'))
284
307
  item('Consistency report', 'components × screens', docOpen('/consistency/index.html'), () => openDoc('/consistency/index.html'))
285
308
  item('App comparison', 'approved × live app', docOpen('/compare/index.html'), () => openDoc('/compare/index.html'))
309
+ item('Languages', 'screens × languages', docOpen('/languages'), () => openDoc('/languages'))
286
310
  const all = rows()
287
311
  const comp = all.find((r) => r.key === '~components')
288
312
  if (comp?.visible.length) {
@@ -379,7 +403,7 @@ function frameFor(s) {
379
403
  const iframe = el.querySelector('iframe')
380
404
  iframe.style.width = `${s.width}px`
381
405
  iframe.style.height = `${s.width > 700 ? 800 : 844}px`
382
- iframe.src = `/${s.path}?pick`
406
+ iframe.src = `/${s.path}?pick${langQuery()}`
383
407
  const f = { el, iframe, screen: s, height: s.width > 700 ? 800 : 844 }
384
408
  iframe.addEventListener('load', () => { applyTheme(f); measure(f); setTimeout(() => measure(f), 800); setTimeout(() => measure(f), 2500) })
385
409
  el.querySelector('.shield').addEventListener('click', () => select(f, false))
@@ -410,7 +434,7 @@ function inspector(f) {
410
434
  const s = f.screen
411
435
  const issues = forTheme(checks.screens?.[s.path])
412
436
  box.innerHTML = `<h3><span class="dot ${s.status}"></span><span class="h"></span><button type="button" class="copy" title="Copy a reference to paste to an agent">Copy</button><button type="button" class="close" title="Close (Esc)">×</button></h3>
413
- <div class="facts"><span class="f"></span> · <a target="_blank"></a></div>
437
+ <div class="facts"><span class="f"></span> · <a target="_blank"></a> · <a href="#" class="langs" title="This screen in every language, side by side">languages</a></div>
414
438
  ${s.kind === 'screen' ? `<div class="status">${['approved', 'review', 'draft', 'archived'].map((st) => `<button type="button" data-st="${st}" title="${STATUS_HINT[st]}" class="${s.status === st ? 'on' : ''}">${st === 'approved' ? 'Approve' : st === 'review' ? 'Needs review' : st === 'draft' ? 'Draft' : 'Archive'}</button>`).join('')}</div>
415
439
  <textarea placeholder="Note for the agent: what to keep, what to change"></textarea><button type="button" class="save">Save note</button>` : ''}
416
440
  <h4>Checks</h4><ul></ul>`
@@ -445,6 +469,7 @@ function inspector(f) {
445
469
  const a = box.querySelector('.facts a')
446
470
  a.href = `/${s.path}`
447
471
  a.textContent = s.path
472
+ box.querySelector('.facts .langs').onclick = (e) => { e.preventDefault(); openDoc(`/languages?screen=${encodeURIComponent(s.path)}`) }
448
473
  box.querySelector('h3 .close').onclick = () => select(null)
449
474
  box.querySelector('h3 .copy').onclick = () => copy(ref(s))
450
475
  for (const b of box.querySelectorAll('.status button')) b.onclick = () => setMeta(s.path, { status: b.dataset.st })
@@ -508,14 +533,15 @@ function elementAt(e) {
508
533
  return el ? { f, el } : null
509
534
  }
510
535
  function describe(el) {
536
+ const key = el.closest('[data-s2-key]')?.getAttribute('data-s2-key')
511
537
  const text = (el.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('alt') || el.value || '').trim().replace(/\s+/g, ' ')
512
- return { tag: el.getAttribute('data-s2-tag') ?? el.localName, line: el.getAttribute('data-s2-line'), text: text.length > 48 ? `${text.slice(0, 47)}…` : text }
538
+ return { tag: el.getAttribute('data-s2-tag') ?? el.localName, line: el.getAttribute('data-s2-line'), key, text: text.length > 48 ? `${text.slice(0, 47)}…` : text }
513
539
  }
514
540
  function elementRef(f, el) {
515
541
  const d = describe(el)
516
542
  const s = f.screen
517
543
  const who = s.kind === 'components' ? 'component catalog' : `${s.screen} ${s.device} ${s.version}`
518
- return `${who}${themeNote()} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}`
544
+ return `${who}${themeNote()} (${designDir}/${s.path}:${d.line}) <${d.tag}>${d.text ? ` “${d.text}”` : ''}${d.key ? ` [${d.key}]` : ''}`
519
545
  }
520
546
  function drawBox(f, el, cls, label) {
521
547
  const r = el.getBoundingClientRect()
@@ -682,6 +708,7 @@ addEventListener('keydown', (e) => {
682
708
  if (e.key === 'Escape') { closeDoc(); select(null) }
683
709
  if (e.key === 'f' || e.key === 'F') fit()
684
710
  if ((e.key === 't' || e.key === 'T') && themed()) setTheme(THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length])
711
+ if (e.key === 'l' || e.key === 'L') setLang(LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length])
685
712
  if (e.key === '0') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, 1)
686
713
  if (e.key === '+' || e.key === '=') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z * 1.2)
687
714
  if (e.key === '-') zoomAt((innerWidth - SIDE) / 2, innerHeight / 2, view.z / 1.2)
@@ -712,6 +739,13 @@ fetch('/api/config').then((r) => r.json()).then((c) => {
712
739
  seg.innerHTML = THEMES.map((t) => `<button type="button" data-v="${t}">${t[0].toUpperCase()}${t.slice(1)}</button>`).join('')
713
740
  for (const b of seg.querySelectorAll('button')) b.onclick = () => setTheme(b.dataset.v)
714
741
  setTheme(theme)
742
+ SOURCE = c.sourceLocale ?? 'en'
743
+ LANGS = c.locales?.length ? c.locales : [SOURCE]
744
+ if (!LANGS.includes(lang)) lang = SOURCE
745
+ const sel = document.getElementById('langFilter')
746
+ sel.innerHTML = LANGS.map((l) => `<option value="${l}">${langName(l)}</option>`).join('')
747
+ sel.value = lang
748
+ sel.onchange = () => setLang(sel.value)
715
749
  document.title = c.name
716
750
  load(true)
717
751
  })
package/dist/check.js CHANGED
@@ -5,7 +5,10 @@
5
5
  * the canvas shows as badges.
6
6
  * Every screen is checked in its own theme, then again in each other theme DESIGN.md defines (designThemes):
7
7
  * colours change there, so contrast is checked again, tagged with the theme.
8
- * Usage: stitch2 check [path filter…] [--shots] [--strict]
8
+ * Then again in each check language (i18n.ts: the pseudo-languages and the config's): layout problems that
9
+ * show only there (a label running into its neighbour, text cut off, a control drawn backwards right to left),
10
+ * and keys a locale has no translation for, tagged with the locale.
11
+ * Usage: stitch2 check [path filter…] [--shots] [--strict] [--locales ru,ar | all | none]
9
12
  * --shots also save page-tall renders to design/renders/ (one per theme)
10
13
  * --strict exit 1 when any screen has errors (for CI and agent loops)
11
14
  */
@@ -16,6 +19,7 @@ import { colourIssues } from './colours.js';
16
19
  import { config } from './config.js';
17
20
  import { collectComponents, collectOptions, consistencyIssues, reuseIssues, } from './consistency.js';
18
21
  import { designThemes, typeLevels } from './design-md.js';
22
+ import { checkLocales } from './i18n.js';
19
23
  import { listScreens, ROOT } from './screens.js';
20
24
  import { startServer } from './serve.js';
21
25
  import { collectText, typeIssues } from './typecheck.js';
@@ -23,7 +27,9 @@ import { audit } from './audit.js';
23
27
  const args = process.argv.slice(2);
24
28
  const shots = args.includes('--shots');
25
29
  const strict = args.includes('--strict');
26
- const filters = args.filter((a) => !a.startsWith('--'));
30
+ const localesFlag = args.indexOf('--locales');
31
+ const locales = checkLocales(localesFlag >= 0 ? args[localesFlag + 1] : undefined);
32
+ const filters = args.filter((a, i) => !a.startsWith('--') && (localesFlag < 0 || i !== localesFlag + 1));
27
33
  const screens = listScreens().filter((s) => !filters.length || filters.some((f) => s.path.includes(f)));
28
34
  const levels = typeLevels();
29
35
  const themes = designThemes();
@@ -61,15 +67,58 @@ for (const s of screens) {
61
67
  }
62
68
  if (themes.length > 1)
63
69
  await setTheme(base);
70
+ const typed = typeIssues(levels, await page.evaluate(collectText));
71
+ if (shots)
72
+ await shoot(s);
73
+ // The other languages, in the screen's own theme. Reported once per element and kind, in the first locale.
74
+ const unknownSeen = new Set();
75
+ for (const locale of locales) {
76
+ await page
77
+ .goto(`http://127.0.0.1:${server.port}/${s.path}?lang=${encodeURIComponent(locale)}`, { waitUntil: 'networkidle', timeout: 30_000 })
78
+ .catch(() => { });
79
+ await page.waitForTimeout(600);
80
+ for (const i of await page.evaluate(audit, { comfortable: config.comfortableTarget })) {
81
+ const key = `${i.type} ${i.selector}`;
82
+ if (i.type === 'contrast' || seen.has(key))
83
+ continue;
84
+ seen.add(key);
85
+ themed.push({ ...i, locale, message: `${locale}: ${i.message}` });
86
+ }
87
+ const lost = await page.evaluate(() => window.__stitch2?.missing ?? []);
88
+ for (const m of lost) {
89
+ const selector = `[data-s2-key="${m.all}"]`;
90
+ const unknown = m.unknown.split(' ').filter(Boolean);
91
+ const fresh = unknown.filter((k) => !unknownSeen.has(k));
92
+ fresh.forEach((k) => unknownSeen.add(k));
93
+ if (fresh.length)
94
+ themed.push({
95
+ severity: 'error',
96
+ type: 'i18n-unknown-key',
97
+ selector,
98
+ text: m.text,
99
+ message: `No message for ${fresh.join(' ')}: add it to the source language's messages`,
100
+ });
101
+ const lacks = m.keys.split(' ').filter((k) => !unknown.includes(k));
102
+ if (lacks.length)
103
+ themed.push({
104
+ severity: 'warning',
105
+ type: 'i18n-missing',
106
+ locale,
107
+ selector,
108
+ text: m.text,
109
+ message: `${locale}: no translation for ${lacks.join(' ')}`,
110
+ });
111
+ }
112
+ if (shots)
113
+ await shoot(s, undefined, locale);
114
+ }
64
115
  results[s.path] = [
65
116
  ...layout,
66
117
  ...themed,
67
- ...typeIssues(levels, await page.evaluate(collectText)),
118
+ ...typed,
68
119
  ...reuseIssues(s.path, found),
69
120
  ...colourIssues(s.path),
70
121
  ];
71
- if (shots)
72
- await shoot(s);
73
122
  }
74
123
  await browser.close();
75
124
  server.close();
@@ -79,12 +128,13 @@ async function setTheme(theme) {
79
128
  }, theme);
80
129
  await page.waitForTimeout(150);
81
130
  }
82
- /** A page-tall render; renders in another theme than the screen's own get a --<theme> suffix. */
83
- async function shoot(s, theme) {
131
+ /** A page-tall render; renders in another theme or language than the screen's own get a --<theme> suffix. */
132
+ async function shoot(s, theme, locale) {
84
133
  const height = await page.evaluate(() => document.documentElement.scrollHeight);
85
134
  await page.setViewportSize({ width: s.width, height: Math.max(844, Math.min(height, 6000)) });
86
135
  mkdirSync(join(ROOT, 'renders'), { recursive: true });
87
- const name = s.path.replaceAll('/', '__').replace(/\.html$/, theme ? `--${theme}.png` : '.png');
136
+ const suffix = theme ?? locale;
137
+ const name = s.path.replaceAll('/', '__').replace(/\.html$/, suffix ? `--${suffix}.png` : '.png');
88
138
  await page.screenshot({ path: join(ROOT, 'renders', name) });
89
139
  await page.setViewportSize({ width: s.width, height: 844 });
90
140
  }
package/dist/cli.js CHANGED
@@ -38,6 +38,10 @@ const COMMANDS = {
38
38
  compare: ['compare.ts', '[screen…] [--strict] The live app against the approved screens ("app" in the config)'],
39
39
  elements: ['build-elements.ts', '<entry> [--out file] Bundle the project\'s components (defineElements) for the screens'],
40
40
  lint: ['', "Google's DESIGN.md linter on the project's DESIGN.md"],
41
+ i18n: [
42
+ 'i18n-report.ts',
43
+ '[filter] [--locale l] [--suggest] [--json] Keys screens use that a locale lacks, and screen text with no key',
44
+ ],
41
45
  screens: ['list.ts', '[name] [--status s] [--json] Screen versions, statuses and notes'],
42
46
  localize: ['assets.ts', '[filter] Copy remote images a screen uses into its assets/ folder'],
43
47
  sandbox: [
package/dist/config.d.ts CHANGED
@@ -38,6 +38,8 @@ export interface LabConfig {
38
38
  prepare?: string;
39
39
  }>;
40
40
  };
41
+ /** Languages: message files the screens' data-t keys come from (see i18n.ts). */
42
+ i18n?: I18nConfig;
41
43
  /** Token files stitch2 tokens also writes for the app (see exports.ts). */
42
44
  exports?: {
43
45
  format: 'css' | 'tailwind4' | 'dtcg';
@@ -49,6 +51,18 @@ export interface LabConfig {
49
51
  };
50
52
  }[];
51
53
  }
54
+ export interface I18nConfig {
55
+ /** Locales with translations, besides the source language. */
56
+ locales?: string[];
57
+ /** The language screens are written in (default en). */
58
+ source?: string;
59
+ /** Where messages live, relative to the config: "src/i18n/{locale}.json", or per locale with "*" as the rest. */
60
+ messages?: string | Record<string, string>;
61
+ /** Locales stitch2 check runs besides the pseudo-languages ("all" for every one). */
62
+ check?: string[] | 'all';
63
+ /** Right-to-left locales, if not the usual (ar, he, fa, ur, ps, yi). */
64
+ rtl?: string[];
65
+ }
52
66
  export declare const CONFIG_NAME = "stitch2.config.json";
53
67
  export declare const CONFIG_FILE: string | undefined;
54
68
  /** The folder paths in the config are relative to (the project root). */
@@ -29,4 +29,13 @@ export interface ElementsOptions<C> {
29
29
  /** Runs after every element has rendered, e.g. to draw icon placeholders (lucide.createIcons). */
30
30
  after?: () => void;
31
31
  }
32
+ /**
33
+ * A message in the locale the screen is shown in (the canvas's Language switch), for labels a component draws
34
+ * itself. Without one (the source language, or outside stitch2) it returns the fallback.
35
+ *
36
+ * render: (C, props) => renderToStaticMarkup(createElement(C, { labels: { home: translate('nav.home', 'Home') }, ...props }))
37
+ */
38
+ export declare function translate(key: string, fallback: string, args?: Record<string, string>): string;
39
+ /** The locale the screen is shown in ("en", "ru", "ar-XB" for the right-to-left pseudo-language), or undefined. */
40
+ export declare const screenLocale: () => string | undefined;
32
41
  export declare function defineElements<C>(components: Record<string, C>, options: ElementsOptions<C>): void;
package/dist/elements.js CHANGED
@@ -18,6 +18,18 @@
18
18
  * values stay strings. The element's content becomes children, rendered where the component puts them.
19
19
  * Bundle the entry with `stitch2 elements <entry>› and load the bundle in the screens and the template.
20
20
  */
21
+ /**
22
+ * A message in the locale the screen is shown in (the canvas's Language switch), for labels a component draws
23
+ * itself. Without one (the source language, or outside stitch2) it returns the fallback.
24
+ *
25
+ * render: (C, props) => renderToStaticMarkup(createElement(C, { labels: { home: translate('nav.home', 'Home') }, ...props }))
26
+ */
27
+ export function translate(key, fallback, args) {
28
+ const s = globalThis.__stitch2;
29
+ return s?.t(key, args) ?? fallback;
30
+ }
31
+ /** The locale the screen is shown in ("en", "ru", "ar-XB" for the right-to-left pseudo-language), or undefined. */
32
+ export const screenLocale = () => globalThis.__stitch2?.lang;
21
33
  const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
22
34
  export function defineElements(components, options) {
23
35
  const { prefix, render } = options;
@@ -30,7 +42,8 @@ export function defineElements(components, options) {
30
42
  connectedCallback() {
31
43
  const props = {};
32
44
  for (const a of Array.from(this.attributes)) {
33
- if (a.name.startsWith('data-s2-'))
45
+ // stitch2's own markers and the message keys (i18n.ts translated them already) are not props.
46
+ if (a.name.startsWith('data-s2-') || a.name === 'data-t' || a.name.startsWith('data-t-'))
34
47
  continue;
35
48
  props[camel(a.name)] = a.value === '' ? true : a.value;
36
49
  }
@@ -0,0 +1,17 @@
1
+ interface Use {
2
+ key: string;
3
+ line: number;
4
+ plural: boolean;
5
+ }
6
+ interface Loose {
7
+ text: string;
8
+ line: number;
9
+ attr?: string;
10
+ suggest?: string[];
11
+ }
12
+ /** The keys a screen uses and its text with no key, from its markup (scripts, styles and SVG left out). */
13
+ export declare function scan(html: string): {
14
+ uses: Use[];
15
+ loose: Loose[];
16
+ };
17
+ export {};
@@ -0,0 +1,138 @@
1
+ /**
2
+ * How far the screens are translated, for agents writing them in several languages:
3
+ *
4
+ * stitch2 i18n [filter] [--locale l] [--suggest] [--json]
5
+ *
6
+ * Errors: keys screens use (data-t, data-t-<attr>) that the source messages lack, which show the screen's own
7
+ * text in every language. Warnings: keys a locale lacks (they fall back to the source language), translations
8
+ * whose {placeholders} differ from the source's, and product text in screens with no key. Text that is sample
9
+ * content (names, numbers, user input) opts out with translate="no" or data-t-skip. --suggest names source
10
+ * messages with the same text as an unkeyed one. Archived screens are skipped. Exits 1 on errors.
11
+ */
12
+ import { readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { ROOT } from './config.js';
15
+ import { loadMessages, realLocales, sourceLocale } from './i18n.js';
16
+ import { listScreens } from './screens.js';
17
+ const args = process.argv.slice(2);
18
+ const opt = (n) => (args.includes(n) ? args[args.indexOf(n) + 1] : undefined);
19
+ const filter = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--locale');
20
+ const locales = opt('--locale')?.split(',') ?? realLocales();
21
+ const suggest = args.includes('--suggest');
22
+ /** Attributes that hold text people read. On components (a dash in the tag) any attribute that reads as words. */
23
+ const TEXT_ATTRS = new Set(['title', 'label', 'placeholder', 'aria-label', 'alt']);
24
+ const NOT_TEXT = /^(class|id|style|href|src|srcset|type|name|value|for|role|slot|lang|dir|icon|variant|tone|size|width|height|viewbox|d|fill|stroke|xmlns|rel|target|tabindex|loading|translate)$/;
25
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr']);
26
+ const words = (s) => /\p{L}{2,}/u.test(s);
27
+ const readsAsText = (s) => words(s) && (/\s/.test(s.trim()) || /\p{Lu}/u.test(s) || /[^\x00-\x7f]/.test(s));
28
+ function attrsOf(s) {
29
+ const out = {};
30
+ for (const m of s.matchAll(/([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g))
31
+ out[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
32
+ return out;
33
+ }
34
+ /** The keys a screen uses and its text with no key, from its markup (scripts, styles and SVG left out). */
35
+ export function scan(html) {
36
+ const uses = [];
37
+ const loose = [];
38
+ const body = html.replace(/<(script|style|svg|template)\b[\s\S]*?<\/\1>|<!--[\s\S]*?-->/gi, (m) => m.replace(/[^\n]/g, ' '));
39
+ const start = body.search(/<body\b/i);
40
+ const lineAt = (i) => body.slice(0, i).split('\n').length;
41
+ const stack = [];
42
+ const re = /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>|([^<]+)/g;
43
+ re.lastIndex = Math.max(0, start);
44
+ for (let m = re.exec(body); m; m = re.exec(body)) {
45
+ const line = lineAt(m.index);
46
+ const top = stack[stack.length - 1];
47
+ if (m[5] !== undefined) {
48
+ const text = m[5].replace(/\s+/g, ' ').trim();
49
+ if (words(text) && !top?.keyed && !top?.skip)
50
+ loose.push({ text: text.replaceAll('&amp;', '&'), line });
51
+ continue;
52
+ }
53
+ const tag = m[2].toLowerCase();
54
+ if (m[1]) {
55
+ const at = stack.map((s) => s.tag).lastIndexOf(tag);
56
+ if (at >= 0)
57
+ stack.length = at;
58
+ continue;
59
+ }
60
+ const a = attrsOf(m[3]);
61
+ const plural = 'data-t-args' in a && /(^|,)\s*count\s*=/.test(a['data-t-args']);
62
+ const skip = !!top?.skip || 'data-t-skip' in a || a.translate === 'no';
63
+ for (const [name, value] of Object.entries(a)) {
64
+ const keyed = name === 'data-t' || (name.startsWith('data-t-') && name !== 'data-t-args' && name !== 'data-t-skip');
65
+ if (keyed && value.includes('{'))
66
+ for (const m of value.matchAll(/\{([^{}|@][^{}|]*)(?:\|([^}]*))?\}/g))
67
+ uses.push({ key: m[1], line, plural: plural || /(^|;)\s*count\s*=/.test(m[2] ?? '') });
68
+ else if (name === 'data-t')
69
+ uses.push({ key: value, line, plural });
70
+ else if (keyed)
71
+ for (const k of value.split(','))
72
+ uses.push({ key: k.trim(), line, plural });
73
+ else if (!skip && !name.startsWith('data-') && !NOT_TEXT.test(name) && !('data-t-' + name in a)) {
74
+ const textual = TEXT_ATTRS.has(name) ? words(value) : tag.includes('-') && readsAsText(value);
75
+ if (textual)
76
+ loose.push({ text: value, line, attr: name });
77
+ }
78
+ }
79
+ if (!m[4] && !VOID.has(tag))
80
+ stack.push({ tag, keyed: !!top?.keyed || 'data-t' in a, skip });
81
+ }
82
+ return { uses, loose };
83
+ }
84
+ const has = (msgs, u) => u.key in msgs || (u.plural && u.key + '.other' in msgs);
85
+ const holes = (s) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort().join(',');
86
+ const source = await loadMessages(sourceLocale());
87
+ const byLocale = Object.fromEntries(await Promise.all(locales.map(async (l) => [l, await loadMessages(l)])));
88
+ const byText = new Map();
89
+ for (const [k, v] of Object.entries(source)) {
90
+ const n = v.trim().toLowerCase();
91
+ byText.set(n, [...(byText.get(n) ?? []), k]);
92
+ }
93
+ const screens = listScreens().filter((s) => s.kind === 'screen' && s.status !== 'archived' && (!filter || s.path.includes(filter) || s.screen === filter));
94
+ const report = screens.map((s) => {
95
+ const { uses, loose } = scan(readFileSync(join(ROOT, s.path), 'utf8'));
96
+ const unknown = uses.filter((u) => !has(source, u));
97
+ const missing = {};
98
+ const placeholders = [];
99
+ for (const l of locales) {
100
+ const msgs = byLocale[l];
101
+ const lacks = [...new Set(uses.filter((u) => has(source, u) && !has(msgs, u)).map((u) => u.key))];
102
+ if (lacks.length)
103
+ missing[l] = lacks;
104
+ for (const u of uses)
105
+ if (u.key in msgs && u.key in source && holes(msgs[u.key]) !== holes(source[u.key]))
106
+ placeholders.push({ locale: l, key: u.key });
107
+ }
108
+ if (suggest)
109
+ for (const x of loose)
110
+ x.suggest = byText.get(x.text.toLowerCase());
111
+ return { path: s.path, keys: new Set(uses.map((u) => u.key)).size, unknown, missing, placeholders, loose };
112
+ });
113
+ if (args.includes('--json'))
114
+ console.log(JSON.stringify({ source: sourceLocale(), locales, screens: report }, null, 2));
115
+ else {
116
+ if (!Object.keys(source).length)
117
+ console.log('No source messages: set i18n.messages in stitch2.config.json (see the stitch2-i18n skill).\n');
118
+ for (const r of report) {
119
+ const lines = [];
120
+ for (const u of r.unknown)
121
+ lines.push(` error line ${u.line}: ${u.key} is not in the ${sourceLocale()} messages`);
122
+ for (const [l, keys] of Object.entries(r.missing))
123
+ lines.push(` warn ${l} lacks ${keys.length}: ${keys.join(', ')}`);
124
+ for (const p of r.placeholders)
125
+ lines.push(` warn ${p.locale} ${p.key}: placeholders differ from ${sourceLocale()}`);
126
+ for (const x of r.loose)
127
+ lines.push(` text line ${x.line}${x.attr ? ` [${x.attr}]` : ''}: "${x.text.slice(0, 60)}" has no key${x.suggest ? ` (${x.suggest.join(' or ')}?)` : ''}`);
128
+ console.log(`${r.path} ${r.keys} keys${lines.length ? '' : ', all translated'}`);
129
+ if (lines.length)
130
+ console.log(lines.join('\n'));
131
+ }
132
+ const errors = report.reduce((n, r) => n + r.unknown.length, 0);
133
+ const loose = report.reduce((n, r) => n + r.loose.length, 0);
134
+ const lacking = locales.filter((l) => report.some((r) => r.missing[l]));
135
+ console.log(`\n${screens.length} screens, ${errors} unknown keys, ${loose} texts with no key${lacking.length ? `, missing translations in ${lacking.join(', ')}` : ''}.`);
136
+ }
137
+ if (report.some((r) => r.unknown.length))
138
+ process.exitCode = 1;
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type I18nConfig } from './config.ts';
2
+ export type { I18nConfig };
3
+ export declare const PSEUDO: readonly ['pseudo', 'pseudo-rtl'];
4
+ export declare const i18nConfig: () => I18nConfig;
5
+ export declare const sourceLocale: () => string;
6
+ export declare const realLocales: () => string[];
7
+ export declare const isRtl: (locale: string) => boolean;
8
+ /** Every language the canvas offers: the source, the pseudo-languages, and the project's own. */
9
+ export declare const allLocales: () => string[];
10
+ /** The locales check runs: with an i18n block, the pseudo-languages plus the configured ones; else only --locales. */
11
+ export declare function checkLocales(override?: string): string[];
12
+ /**
13
+ * A locale's messages: JSON, or a JS/TS module (its default export, else its first exported object; Node strips
14
+ * the types). Re-read when the file changes. Empty when the file is missing.
15
+ */
16
+ export declare function loadMessages(locale: string): Promise<Record<string, string>>;
17
+ /** What a page needs to show a locale: its messages over the source language's, and its direction. */
18
+ export declare function pagePayload(locale: string): Promise<{
19
+ locale: string;
20
+ lang: string;
21
+ dir: string;
22
+ pseudo: boolean;
23
+ messages: {
24
+ [x: string]: string;
25
+ };
26
+ missing: string[];
27
+ }>;
28
+ /** The script for a screen opened in a locale; serve adds it after pick mode has numbered the lines. */
29
+ export declare function localeScript(locale: string): Promise<string>;
package/dist/i18n.js ADDED
@@ -0,0 +1,275 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * Languages for screens. A screen is written in the source language and marks the text that belongs to the product
11
+ * with message keys from the project's own translation files:
12
+ *
13
+ * <span data-t="nav.library">Exercises</span> the element's text
14
+ * <gg-header title="Settings" data-t-title="settings.title"> an attribute (also comma lists: data-t-options)
15
+ * <p data-t="count.sets" data-t-args="count=16">16 sets</p> {count} filled in, plural form picked by count
16
+ * <x-row meta="5 exercises · Linear" data-t-meta="{plan.count|count=5} · {plan.linear}">
17
+ * a template: messages in braces, the rest kept
18
+ * <x-row meta="Sun, 4 Oct" data-t-meta="{@date|value=2026-10-04;weekday=short;day=numeric;month=short}">
19
+ * dates and numbers ({@number|value=7920}) by Intl
20
+ *
21
+ * Opened with ?lang=<locale> (the canvas's Language switch, and check), the page's text is replaced from that
22
+ * locale's messages before its components render, and the page gets lang and dir. Two pseudo-languages need no
23
+ * translations: "pseudo" makes every text about 40% longer with accented letters, "pseudo-rtl" lays the page out
24
+ * right to left. Project components can read the same messages with translate() from stitch2/elements.
25
+ */
26
+ import { existsSync, readFileSync, statSync } from 'node:fs';
27
+ import { extname, resolve } from 'node:path';
28
+ import { pathToFileURL } from 'node:url';
29
+ import { BASE, config } from './config.js';
30
+ export const PSEUDO = ['pseudo', 'pseudo-rtl'];
31
+ const RTL = ['ar', 'he', 'fa', 'ur', 'ps', 'yi'];
32
+ export const i18nConfig = () => config.i18n ?? {};
33
+ export const sourceLocale = () => i18nConfig().source ?? 'en';
34
+ export const realLocales = () => i18nConfig().locales?.filter((l) => l !== sourceLocale()) ?? [];
35
+ export const isRtl = (locale) => locale === 'pseudo-rtl' || (i18nConfig().rtl ?? RTL).includes(locale.split('-')[0].toLowerCase());
36
+ /** Every language the canvas offers: the source, the pseudo-languages, and the project's own. */
37
+ export const allLocales = () => [sourceLocale(), ...PSEUDO, ...realLocales()];
38
+ /** The locales check runs: with an i18n block, the pseudo-languages plus the configured ones; else only --locales. */
39
+ export function checkLocales(override) {
40
+ if (override === 'none')
41
+ return [];
42
+ const list = override ? (override === 'all' ? [...PSEUDO, ...realLocales()] : override.split(',')) : undefined;
43
+ if (list)
44
+ return list.filter((l) => l !== sourceLocale());
45
+ if (!config.i18n)
46
+ return [];
47
+ const c = i18nConfig().check;
48
+ return [...PSEUDO, ...(c === 'all' ? realLocales() : (c ?? []))];
49
+ }
50
+ function messagesFile(locale) {
51
+ const m = i18nConfig().messages;
52
+ if (!m)
53
+ return undefined;
54
+ const pattern = typeof m === 'string' ? m : (m[locale] ?? m['*']);
55
+ return pattern ? resolve(BASE, pattern.replaceAll('{locale}', locale)) : undefined;
56
+ }
57
+ /** Nested objects become dotted keys; only strings are kept. */
58
+ function flatten(o, prefix = '', out = {}) {
59
+ if (o && typeof o === 'object')
60
+ for (const [k, v] of Object.entries(o)) {
61
+ const key = prefix ? prefix + '.' + k : k;
62
+ if (typeof v === 'string')
63
+ out[key] = v;
64
+ else
65
+ flatten(v, key, out);
66
+ }
67
+ return out;
68
+ }
69
+ const cache = new Map();
70
+ /**
71
+ * A locale's messages: JSON, or a JS/TS module (its default export, else its first exported object; Node strips
72
+ * the types). Re-read when the file changes. Empty when the file is missing.
73
+ */
74
+ export async function loadMessages(locale) {
75
+ const file = messagesFile(locale);
76
+ if (!file || !existsSync(file))
77
+ return {};
78
+ const at = statSync(file).mtimeMs;
79
+ const hit = cache.get(file);
80
+ if (hit && hit.at === at)
81
+ return hit.messages;
82
+ let data;
83
+ if (extname(file) === '.json')
84
+ data = JSON.parse(readFileSync(file, 'utf8'));
85
+ else {
86
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href + '?t=' + at)));
87
+ data = mod.default ?? Object.values(mod).find((v) => v && typeof v === 'object');
88
+ }
89
+ const messages = flatten(data);
90
+ cache.set(file, { at, messages });
91
+ return messages;
92
+ }
93
+ /** What a page needs to show a locale: its messages over the source language's, and its direction. */
94
+ export async function pagePayload(locale) {
95
+ const pseudo = PSEUDO.includes(locale);
96
+ const source = await loadMessages(sourceLocale());
97
+ const own = pseudo || locale === sourceLocale() ? {} : await loadMessages(locale);
98
+ return {
99
+ locale,
100
+ lang: locale === 'pseudo' ? sourceLocale() + '-XA' : locale === 'pseudo-rtl' ? 'ar-XB' : locale,
101
+ dir: isRtl(locale) ? 'rtl' : 'ltr',
102
+ pseudo: locale === 'pseudo',
103
+ // Keys the locale lacks fall back to the source language, and are reported.
104
+ messages: { ...source, ...own },
105
+ missing: pseudo || locale === sourceLocale() ? [] : Object.keys(source).filter((k) => !(k in own)),
106
+ };
107
+ }
108
+ /** Runs in the page. Plain JS on purpose: it is sent as source into the screen's head. */
109
+ function runtime(p) {
110
+ const missing = new Set(p.missing);
111
+ const plural = new Intl.PluralRules(p.lang);
112
+ // {@date|value=…;weekday=short…} and {@number|value=…;maximumFractionDigits=1…}: Intl's options, in the locale.
113
+ const intl = (key, args) => {
114
+ const opts = {};
115
+ for (const [k, v] of Object.entries(args))
116
+ if (k !== 'value')
117
+ opts[k] = v === 'true' ? true : v === 'false' ? false : /^\d+$/.test(v) ? Number(v) : v;
118
+ try {
119
+ return key === '@date'
120
+ ? // As written, whatever the viewer's time zone: the value is read and shown in UTC.
121
+ new Intl.DateTimeFormat(p.lang, { timeZone: 'UTC', ...opts }).format(new Date(/T.*(Z|[+-]\d\d:?\d\d)$/.test(args.value ?? '') || !/T/.test(args.value ?? '') ? (args.value ?? '') : args.value + 'Z'))
122
+ : new Intl.NumberFormat(p.lang, opts).format(Number(args.value));
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ };
128
+ const t = (key, args) => {
129
+ if (key === '@date' || key === '@number')
130
+ return intl(key, args ?? {});
131
+ let msg;
132
+ if (args && args.count !== undefined) {
133
+ const n = Number(args.count);
134
+ msg = p.messages[key + '.' + plural.select(n)] ?? p.messages[key + '.other'];
135
+ }
136
+ msg = msg ?? p.messages[key];
137
+ if (msg === undefined)
138
+ return undefined;
139
+ return msg.replace(/\{(\w+)\}/g, (m, k) => (args && args[k] !== undefined ? args[k] : m));
140
+ };
141
+ const known = (key) => key.startsWith('@') || key in p.messages || key + '.other' in p.messages;
142
+ const lacks = (key) => missing.has(key) || missing.has(key + '.other') || !known(key);
143
+ const parseArgs = (s, sep = ',') => {
144
+ const out = {};
145
+ for (const part of (s ?? '').split(sep)) {
146
+ const i = part.indexOf('=');
147
+ if (i > 0)
148
+ out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
149
+ }
150
+ return out;
151
+ };
152
+ const lostList = [];
153
+ window.__stitch2 = { locale: p.locale, lang: p.lang, dir: p.dir, t, missing: lostList };
154
+ document.documentElement.lang = p.lang;
155
+ document.documentElement.dir = p.dir;
156
+ let done = false;
157
+ const translate = () => {
158
+ if (done)
159
+ return;
160
+ done = true;
161
+ for (const el of Array.from(document.querySelectorAll('*'))) {
162
+ const keys = [];
163
+ for (const a of Array.from(el.attributes)) {
164
+ if (a.name !== 'data-t' && !a.name.startsWith('data-t-'))
165
+ continue;
166
+ if (a.name === 'data-t-args' || a.name === 'data-t-skip')
167
+ continue;
168
+ const args = parseArgs(el.getAttribute('data-t-args'));
169
+ let v;
170
+ if (a.value.includes('{')) {
171
+ // A template: each {key} or {key|name=value;name=value} becomes its message, the rest stays as written.
172
+ let whole = true;
173
+ v = a.value.replace(/\{([^{}|]+)(?:\|([^}]*))?\}/g, (_m, key, inline) => {
174
+ keys.push(key);
175
+ const r = t(key, { ...args, ...parseArgs(inline, ';') });
176
+ if (r === undefined)
177
+ whole = false;
178
+ return r ?? '';
179
+ });
180
+ if (!whole)
181
+ v = undefined;
182
+ }
183
+ else if (a.name === 'data-t') {
184
+ v = t(a.value, args);
185
+ keys.push(a.value);
186
+ }
187
+ else {
188
+ // A list attribute (options="A,B"): one key per item.
189
+ const parts = a.value.split(',').map((k) => k.trim());
190
+ const vals = parts.map((k) => t(k, args));
191
+ if (vals.every((x) => x !== undefined))
192
+ v = vals.join(',');
193
+ keys.push(...parts);
194
+ }
195
+ if (v === undefined)
196
+ continue;
197
+ if (a.name === 'data-t')
198
+ el.textContent = v;
199
+ else
200
+ el.setAttribute(a.name.slice(7), v);
201
+ }
202
+ if (!keys.length)
203
+ continue;
204
+ el.setAttribute('data-s2-key', keys.join(' '));
205
+ const lost = keys.filter(lacks);
206
+ if (lost.length) {
207
+ el.setAttribute('data-s2-i18n-missing', lost.join(' '));
208
+ lostList.push({
209
+ keys: lost.join(' '),
210
+ all: keys.join(' '),
211
+ text: (el.textContent ?? '').trim().slice(0, 60),
212
+ // Not even in the source language: the screen shows its own text in every language.
213
+ unknown: lost.filter((k) => !known(k)).join(' '),
214
+ });
215
+ }
216
+ }
217
+ };
218
+ // Pseudo-language: every visible text, accented and about 40% longer, bracketed so a cut end shows.
219
+ const MAP = {
220
+ a: 'á', b: 'ƀ', c: 'ç', d: 'ð', e: 'é', f: 'ƒ', g: 'ĝ', h: 'ĥ', i: 'í', j: 'ĵ', k: 'ķ', l: 'ļ', m: 'ɱ', n: 'ñ',
221
+ o: 'ó', p: 'þ', r: 'ŕ', s: 'š', t: 'ţ', u: 'ú', w: 'ŵ', y: 'ý', z: 'ž',
222
+ A: 'Á', B: 'Ɓ', C: 'Ç', D: 'Ð', E: 'É', G: 'Ĝ', H: 'Ĥ', I: 'Í', J: 'Ĵ', K: 'Ķ', L: 'Ļ', N: 'Ñ', O: 'Ó',
223
+ R: 'Ŕ', S: 'Š', T: 'Ţ', U: 'Ú', W: 'Ŵ', Y: 'Ý', Z: 'Ž',
224
+ };
225
+ const seen = new WeakSet();
226
+ const stretch = (s) => {
227
+ const core = s.trim();
228
+ if (!/\p{L}/u.test(core))
229
+ return s;
230
+ const extra = Math.max(1, Math.round(core.length * 0.3));
231
+ const lead = s.slice(0, s.indexOf(core));
232
+ return lead + '[' + core.replace(/[A-Za-z]/g, (c) => MAP[c] ?? c) + ' ' + '·'.repeat(extra) + ']' + s.slice(lead.length + core.length);
233
+ };
234
+ const SKIP = 'script,style,code,pre,textarea,[data-t-skip],svg';
235
+ const pseudoAll = (root) => {
236
+ const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
237
+ for (let n = w.nextNode(); n; n = w.nextNode()) {
238
+ if (seen.has(n) || n.parentElement?.closest(SKIP))
239
+ continue;
240
+ seen.add(n);
241
+ n.textContent = stretch(n.textContent ?? '');
242
+ }
243
+ if (root instanceof Element || root instanceof Document)
244
+ for (const el of Array.from(root.querySelectorAll('[placeholder]'))) {
245
+ if (el.hasAttribute('data-s2-pseudo'))
246
+ continue;
247
+ el.setAttribute('data-s2-pseudo', '');
248
+ el.setAttribute('placeholder', stretch(el.getAttribute('placeholder') ?? ''));
249
+ }
250
+ };
251
+ // Translate once the body is parsed and before components render: the first customElements.define (the
252
+ // components' deferred bundle) or DOMContentLoaded, whichever comes first.
253
+ const define = customElements.define.bind(customElements);
254
+ customElements.define = (name, cls, opts) => {
255
+ translate();
256
+ return define(name, cls, opts);
257
+ };
258
+ document.addEventListener('DOMContentLoaded', () => {
259
+ translate();
260
+ if (!p.pseudo)
261
+ return;
262
+ pseudoAll(document.body);
263
+ new MutationObserver((list) => {
264
+ for (const m of list)
265
+ for (const n of Array.from(m.addedNodes))
266
+ pseudoAll(n);
267
+ }).observe(document.body, { childList: true, subtree: true });
268
+ });
269
+ }
270
+ /** The script for a screen opened in a locale; serve adds it after pick mode has numbered the lines. */
271
+ export async function localeScript(locale) {
272
+ const payload = await pagePayload(locale);
273
+ const json = JSON.stringify(payload).replaceAll('<', '\\u003c');
274
+ return '<script>(' + runtime.toString() + ')(' + json + ')</script>';
275
+ }
@@ -0,0 +1 @@
1
+ export declare function languagesPage(only?: string): string;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The Languages page (/languages on the canvas server): each screen in a row, once per language, so a label that
3
+ * only breaks in German or a layout that flips wrong in Arabic shows next to the others. ?screen=<path> shows one
4
+ * screen; otherwise every active screen. Starts with the check languages; "All" adds every locale.
5
+ */
6
+ import { allLocales, checkLocales, sourceLocale } from './i18n.js';
7
+ import { listScreens } from './screens.js';
8
+ export function languagesPage(only) {
9
+ const screens = listScreens()
10
+ .filter((s) => (only ? s.path === only : s.kind === 'screen' && s.status !== 'archived'))
11
+ .map((s) => ({ path: s.path, label: s.kind === 'components' ? 'Component catalog' : s.screen + ' ' + s.device + ' ' + s.version, width: s.width }));
12
+ const data = { screens, source: sourceLocale(), check: [sourceLocale(), ...checkLocales()], all: allLocales() };
13
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Languages</title><style>
14
+ body { background: #0f0f11; color: #e4e4e7; font: 13px/1.4 system-ui, sans-serif; margin: 0; }
15
+ header { align-items: center; background: #0f0f11; border-bottom: 1px solid #26262b; display: flex; gap: 12px; padding: 12px 140px 12px 20px; position: sticky; top: 0; z-index: 1; }
16
+ header h1 { font-size: 15px; margin: 0 8px 0 0; }
17
+ header p { color: #71717a; margin: 0 0 0 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
18
+ .seg { background: #18181b; border: 1px solid #26262b; border-radius: 8px; display: flex; padding: 2px; }
19
+ .seg button { background: none; border: 0; border-radius: 6px; color: #a1a1aa; cursor: pointer; font: inherit; padding: 4px 10px; }
20
+ .seg button.on { background: #27272a; color: #fafafa; }
21
+ section { padding: 16px 20px 8px; }
22
+ section h2 { color: #a1a1aa; font-size: 13px; font-weight: 600; margin: 0 0 10px; }
23
+ section h2 code { color: #71717a; font-weight: 400; margin-left: 8px; }
24
+ .row { align-items: flex-start; display: flex; gap: 24px; overflow-x: auto; padding-bottom: 16px; }
25
+ .frame { flex: none; }
26
+ .frame .label { align-items: center; color: #a1a1aa; display: flex; flex-wrap: wrap; gap: 4px 6px; margin-bottom: 6px; min-width: 0; white-space: nowrap; }
27
+ .frame .label .n { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
28
+ .frame .label > :not(.n) { flex: none; }
29
+ .frame .label b { color: #fafafa; }
30
+ .frame .miss { background: #422006; border-radius: 99px; color: #fbbf24; font-size: 11px; padding: 1px 7px; }
31
+ .frame .label button { background: #18181b; border: 1px solid #26262b; border-radius: 6px; color: #a1a1aa; cursor: pointer; font: inherit; font-size: 11px; margin-left: auto; padding: 1px 7px; }
32
+ .frame .box { border-radius: 14px; overflow: hidden; }
33
+ .frame iframe { background: #fff; border: 0; display: block; }
34
+ </style></head><body>
35
+ <header><h1>Languages</h1>
36
+ <div class="seg" id="set"><button type="button" data-v="check" title="The source, the pseudo-languages and the ones check runs">Check languages</button><button type="button" data-v="all">All</button></div>
37
+ <div class="seg" id="zoom"><button type="button" data-v="0.4">40%</button><button type="button" data-v="0.6">60%</button><button type="button" data-v="1">100%</button></div>
38
+ <p>Pseudo: every text about 40% longer, and right to left. Copy a frame to reference it.</p></header>
39
+ <main></main>
40
+ <script>
41
+ const D = ${JSON.stringify(data).replaceAll('<', '\\u003c')}
42
+ const theme = localStorage.getItem('stitch2-theme')
43
+ let set = localStorage.getItem('stitch2-lang-set') ?? 'check'
44
+ let zoom = Number(localStorage.getItem('stitch2-lang-zoom') ?? 0.4)
45
+ const name = (l) => l === 'pseudo' ? 'Pseudo: long text' : l === 'pseudo-rtl' ? 'Pseudo: right to left' : (new Intl.DisplayNames([l], { type: 'language' }).of(l) ?? l) + (l === D.source ? ', source' : '')
46
+ async function copy(text) {
47
+ try { await navigator.clipboard.writeText(text) } catch { const t = document.createElement('textarea'); t.value = text; document.body.append(t); t.select(); document.execCommand('copy'); t.remove() }
48
+ }
49
+ function render() {
50
+ for (const b of document.querySelectorAll('#set button')) b.classList.toggle('on', b.dataset.v === set)
51
+ for (const b of document.querySelectorAll('#zoom button')) b.classList.toggle('on', Number(b.dataset.v) === zoom)
52
+ const langs = set === 'all' ? D.all : D.check
53
+ const main = document.querySelector('main')
54
+ main.innerHTML = ''
55
+ if (!D.screens.length) main.innerHTML = '<section><h2>No screens.</h2></section>'
56
+ for (const s of D.screens) {
57
+ const sec = document.createElement('section')
58
+ sec.innerHTML = '<h2></h2><div class="row"></div>'
59
+ sec.querySelector('h2').textContent = s.label
60
+ const code = document.createElement('code'); code.textContent = s.path; sec.querySelector('h2').append(code)
61
+ for (const l of langs) {
62
+ const f = document.createElement('div')
63
+ f.className = 'frame'
64
+ f.style.width = s.width * zoom + 'px'
65
+ f.innerHTML = '<div class="label"><b></b><span class="n"></span><span class="miss" hidden></span><button type="button">Copy</button></div><div class="box"><iframe loading="lazy" scrolling="no"></iframe></div>'
66
+ f.querySelector('b').textContent = l
67
+ f.querySelector('.n').textContent = name(l)
68
+ f.querySelector('button').onclick = () => copy(s.label + (l === D.source ? '' : ' in ' + l) + ' (' + s.path + ')')
69
+ const box = f.querySelector('.box')
70
+ const frame = f.querySelector('iframe')
71
+ frame.style.width = s.width + 'px'
72
+ frame.style.height = '844px'
73
+ frame.style.zoom = zoom
74
+ frame.src = '/' + s.path + (l === D.source ? '' : '?lang=' + encodeURIComponent(l))
75
+ const measure = () => {
76
+ try {
77
+ const doc = frame.contentDocument
78
+ frame.style.height = Math.max(doc.documentElement.scrollHeight, 400) + 'px'
79
+ const miss = frame.contentWindow.__stitch2?.missing ?? []
80
+ const m = f.querySelector('.miss')
81
+ m.hidden = !miss.length
82
+ m.textContent = miss.length + ' missing'
83
+ f.querySelector('.n').hidden = !!miss.length && zoom < 0.6
84
+ m.title = miss.map((x) => x.keys).join(', ')
85
+ } catch {}
86
+ }
87
+ frame.addEventListener('load', () => {
88
+ if (theme) try { frame.contentDocument.documentElement.dataset.theme = theme } catch {}
89
+ measure(); setTimeout(measure, 800); setTimeout(measure, 2500)
90
+ })
91
+ sec.querySelector('.row').append(f)
92
+ }
93
+ main.append(sec)
94
+ }
95
+ }
96
+ for (const b of document.querySelectorAll('#set button')) b.onclick = () => { set = b.dataset.v; localStorage.setItem('stitch2-lang-set', set); render() }
97
+ for (const b of document.querySelectorAll('#zoom button')) b.onclick = () => { zoom = Number(b.dataset.v); localStorage.setItem('stitch2-lang-zoom', String(zoom)); render() }
98
+ render()
99
+ </script></body></html>`;
100
+ }
package/dist/pick.js CHANGED
@@ -9,7 +9,7 @@
9
9
  /** Tags that are never picked by themselves: their nearest picked ancestor stands for them. */
10
10
  const SKIP = new Set(['body', 'br', 'i', 'script', 'style', 'template', 'path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect', 'g', 'defs', 'stop', 'use', 'text', 'tspan']);
11
11
  /** Runs in the screen before its own scripts: components that replace themselves pass their line on. */
12
- const SHIM = `<script>(()=>{const d=customElements.define.bind(customElements);customElements.define=(name,cls,opts)=>{const p=cls.prototype,cb=p.connectedCallback;if(cb)p.connectedCallback=function(...a){const line=this.getAttribute('data-s2-line'),parent=this.parentNode,before=this.previousSibling,after=this.nextSibling;const r=cb.apply(this,a);if(line){if(this.isConnected)this.setAttribute('data-s2-tag',name);else if(parent)for(let n=before?before.nextSibling:parent.firstChild;n&&n!==after;n=n.nextSibling)if(n.nodeType===1&&!n.hasAttribute('data-s2-line')){n.setAttribute('data-s2-line',line);n.setAttribute('data-s2-tag',name)}}return r};return d(name,cls,opts)}})()</script>`;
12
+ const SHIM = `<script>(()=>{const d=customElements.define.bind(customElements);customElements.define=(name,cls,opts)=>{const p=cls.prototype,cb=p.connectedCallback;if(cb)p.connectedCallback=function(...a){const line=this.getAttribute('data-s2-line'),parent=this.parentNode,before=this.previousSibling,after=this.nextSibling;const r=cb.apply(this,a);if(line){const key=this.getAttribute('data-s2-key');if(this.isConnected)this.setAttribute('data-s2-tag',name);else if(parent)for(let n=before?before.nextSibling:parent.firstChild;n&&n!==after;n=n.nextSibling)if(n.nodeType===1&&!n.hasAttribute('data-s2-line')){n.setAttribute('data-s2-line',line);n.setAttribute('data-s2-tag',name);if(key)n.setAttribute('data-s2-key',key)}}return r};return d(name,cls,opts)}})()</script>`;
13
13
  /** The page with source lines on its body's elements and the shim at the start of its head. */
14
14
  export function annotate(html) {
15
15
  const bodyAt = html.search(/<body[\s>]/i);
package/dist/serve.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The stitch2 server: the canvas at /, DESIGN.md drawn at /design, the screen list at /api/screens
2
+ * The stitch2 server: the canvas at /, DESIGN.md drawn at /design, screens side by side per language at /languages, the screen list at /api/screens
3
3
  * (POST /api/screens/meta sets a version's status or note), check results at /api/checks, live-reload events
4
4
  * at /events, and every file under design/ by its path. Local only (127.0.0.1).
5
5
  * Usage: stitch2 canvas (PORT=4400 by default)
@@ -10,7 +10,9 @@ import { dirname, extname, join, normalize } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { CONFIG_FILE, config } from './config.js';
12
12
  import { designThemes } from './design-md.js';
13
+ import { allLocales, localeScript, sourceLocale } from './i18n.js';
13
14
  import { designPage } from './design-page.js';
15
+ import { languagesPage } from './languages-page.js';
14
16
  import { annotate } from './pick.js';
15
17
  /**
16
18
  * Inlines <link rel="stylesheet" type="text/tailwindcss" href="…">: the Tailwind v4 browser build reads @theme and
@@ -71,6 +73,10 @@ export function startServer(port) {
71
73
  res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
72
74
  return res.end(designPage());
73
75
  }
76
+ if (url.pathname === '/languages') {
77
+ res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
78
+ return res.end(languagesPage(url.searchParams.get('screen') ?? undefined));
79
+ }
74
80
  if (url.pathname === '/compare/index.html' && !existsSync(join(ROOT, 'compare/index.html'))) {
75
81
  res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
76
82
  return res.end('<body style="background:#0f0f11;color:#a1a1aa;font:15px system-ui;padding:32px">No comparison yet: run <code>stitch2 compare</code> with the app running.</body>');
@@ -115,7 +121,7 @@ export function startServer(port) {
115
121
  return;
116
122
  }
117
123
  if (url.pathname === '/api/config')
118
- return json({ ...config, themes: designThemes() });
124
+ return json({ ...config, themes: designThemes(), locales: allLocales(), sourceLocale: sourceLocale() });
119
125
  if (url.pathname === '/api/screens')
120
126
  return json(listScreens());
121
127
  if (url.pathname === '/api/checks') {
@@ -145,7 +151,13 @@ export function startServer(port) {
145
151
  // The canvas loads screens with ?pick: their elements carry source lines for picking.
146
152
  if (extname(file) === '.html') {
147
153
  const html = inlineTailwind(readFileSync(file, 'utf8'), file);
148
- res.end(url.searchParams.has('pick') ? annotate(html) : html);
154
+ const page = url.searchParams.has('pick') ? annotate(html) : html;
155
+ const lang = url.searchParams.get('lang');
156
+ if (!lang || lang === sourceLocale())
157
+ return res.end(page);
158
+ // After the lines are numbered: the locale's script may span several lines.
159
+ void localeScript(lang).then((script) => res.end(page.replace(/<head(\s[^>]*)?>/i, (h) => h + script)), (e) => res.end(page.replace(/<body([^>]*)>/i, (b) => b + '<pre style="color:#e5484d">stitch2: ' + String(e) + '</pre>')));
160
+ return;
149
161
  }
150
162
  else
151
163
  res.end(readFileSync(file));
@@ -11,6 +11,8 @@ export interface Issue {
11
11
  text: string;
12
12
  /** Set when the issue depends on the theme (contrast): the data-theme it was found in. */
13
13
  theme?: string;
14
+ /** Set when the issue shows only in another language: the locale it was found in. */
15
+ locale?: string;
14
16
  }
15
17
  export interface TextItem {
16
18
  selector: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",
@@ -42,7 +42,8 @@ References, read before the first screen:
42
42
  4. **Build** the next version, `<screens>/<screen>/<device>-v<N>.html`, from the project's template (status
43
43
  `draft`; never edit an approved version). Assemble it from the components; plain markup only where none
44
44
  applies, with token classes, `.type-<level>` classes for text, the spacing scale, and the states DESIGN.md
45
- defines. Never add classes to a component to change how it looks, and never write a colour value (hex,
45
+ defines. When the config has `i18n`, mark every product string with its message key (`data-t`, see
46
+ **stitch2-i18n**). Never add classes to a component to change how it looks, and never write a colour value (hex,
46
47
  `rgb()`, `hsl()`): only DESIGN.md's colours, as token classes or `var(--<prefix>-<name>)`.
47
48
  5. **Check** `stitch2 check <path> --shots --strict`. Fix every error (overflow, wrapped labels, content under
48
49
  fixed bars, fixed bars overlapping each other, tap targets, contrast, cramped text) and every warning about type (`type-*`), hand-built
@@ -51,7 +52,9 @@ References, read before the first screen:
51
52
  short with an ellipsis (`truncated`) is a warning to fix with room, not a smaller size. When DESIGN.md has a
52
53
  light and a dark theme, every screen is checked in both: an issue marked with a theme
53
54
  ("light theme: Low contrast…") is fixed in DESIGN.md's colours for that theme when it shows on many
54
- screens, or in the screen when it is one element. Look at both renders (`--light.png`).
55
+ screens, or in the screen when it is one element. Look at both renders (`--light.png`). With `i18n`, check
56
+ also runs the screen in the pseudo-languages and the config's locales; fix what they flag as the
57
+ **stitch2-i18n** skill says.
55
58
  6. **Look and critique.** View the render in `<design>/renders/` (crop the first screenful). Score it with the
56
59
  rubric in principles.md, fix what fails, repeat 5–6 at most three times.
57
60
  7. **Hand over.** Set the version's status to `review`, point the user to it on the canvas, and report what
@@ -65,7 +68,8 @@ References, read before the first screen:
65
68
  |---|---|
66
69
  | `setup [--name N --primary #hex] [options]` | Sets a project up in one step; keeps every file that exists |
67
70
  | `canvas` | The canvas at http://localhost:4400: screens and versions, DESIGN.md, the consistency report, approval and notes |
68
- | `check [filter] [--shots] [--strict]` | Layout, type, reuse and consistency checks; `--shots` saves renders |
71
+ | `check [filter] [--shots] [--strict] [--locales l,…]` | Layout, type, reuse and consistency checks, in every theme and check language; `--shots` saves renders |
72
+ | `i18n [filter] [--locale l] [--suggest]` | Message keys screens use that the source or a locale lacks, and text with no key (stitch2-i18n) |
69
73
  | `consistency [kind…]` | Every shared component cropped from every screen, next to the catalog's version |
70
74
  | `type [filter] [--json]` | Every text style a screen uses and the type level it matches |
71
75
  | `screens [name] [--status s] [--json]` | Every version with its status and the user's note |
@@ -0,0 +1,105 @@
1
+ ---
2
+ name: stitch2-i18n
3
+ description: Design screens that work in every language the product ships — text marked with the project's message keys, checked in pseudo-languages, right to left and the real locales, side by side on the canvas. Use when a project has i18n in stitch2.config.json, when writing or changing screen text, when the user mentions languages, translations, RTL or a label that breaks in one language, or before handing over screens of a multilingual product.
4
+ ---
5
+
6
+ # Screens in every language with stitch2
7
+
8
+ A screen is written once, in the source language, and every product string in it carries a **message key** from
9
+ the project's own translation files. stitch2 replaces the text from those files when the screen is opened in
10
+ another language, so one HTML file shows every locale, and the screen and the app share the same strings.
11
+ The checks then run each screen in the languages that break layouts: **pseudo** (every text about 40% longer,
12
+ accented, in brackets, so a cut end shows), **pseudo-rtl** (right to left) and the locales in the config.
13
+
14
+ ## Setup
15
+
16
+ `stitch2.config.json` names the messages; paths are relative to it:
17
+
18
+ ```json
19
+ "i18n": {
20
+ "source": "en",
21
+ "locales": ["en", "de", "ru", "ar"],
22
+ "messages": { "en": "src/i18n/en.ts", "*": "src/i18n/locales/{locale}.ts" },
23
+ "check": ["de", "ar"]
24
+ }
25
+ ```
26
+
27
+ `messages` is one pattern (`"src/i18n/{locale}.json"`) or a map with `*` for the rest. JSON, or a JS/TS module:
28
+ its default export, else its first exported object. Nested objects become dotted keys (`nav.home`). Plurals
29
+ are objects keyed by CLDR category: `{ "one": "{count} set", "other": "{count} sets" }`. `check` lists the
30
+ locales `stitch2 check` runs besides the two pseudo-languages; pick the longest (German, Finnish, Russian) and
31
+ one right-to-left language, or `"all"`.
32
+
33
+ Use the app's own files. Never copy messages into the design folder: two copies drift.
34
+
35
+ ## Marking text
36
+
37
+ ```html
38
+ <span data-t="nav.home">Home</span> <!-- the element's text -->
39
+ <s2-header title="Settings" data-t-title="settings.title"> <!-- an attribute, before the component renders -->
40
+ <input placeholder="Search" data-t-placeholder="exercises.search" />
41
+ <p data-t="workout.sets" data-t-args="count=16">16 sets</p> <!-- {count} filled in, plural form by count -->
42
+ <p translate="no">Dumbbell Bench Press</p> <!-- sample content: names, user input, numbers -->
43
+ <s2-row meta="5 exercises · Linear" data-t-meta="{plan.exercises|count=5} · {plan.linear}">
44
+ ```
45
+
46
+ A value with braces is a **template**: each `{key}` or `{key|name=value;name=value}` becomes its message and
47
+ the rest stays as written. Use it for lines the app builds from several messages and data (a meta line, a
48
+ list of `label=url` links: `data-t-links="{site.features}=#features,{site.docs}=/docs"`); sample data inside
49
+ it stays literal. Without braces, `data-t-<attr>` on a comma list takes one key per item.
50
+
51
+ Dates and numbers are written by the locale too: `{@date|value=2026-10-04;weekday=short;day=numeric;month=short}`
52
+ ("Sun, 4 Oct", "вс, 4 окт.") and `{@number|value=7920}` ("7,920", "7 920"); the options are `Intl.DateTimeFormat`'s and
53
+ `Intl.NumberFormat`'s, and dates are read and shown in UTC. Pair a number with the unit's message:
54
+ `{@number|value=82.5} {unit.kg}`.
55
+
56
+ - Keep the source text in the markup: the screen reads correctly without stitch2, and a missing key shows it.
57
+ - Key every string the product owns: labels, buttons, headings, empty states, hints, units, tab names, the
58
+ placeholder and aria-label too. Sample data (an exercise name, a user's note, a number) gets `translate="no"`.
59
+ - `data-t` replaces the element's whole text, so put it on the innermost element holding just that string;
60
+ an icon next to the label stays outside it.
61
+ - Components that draw their own labels (a tab bar's tab names) take them from `translate(key, fallback)` in
62
+ `stitch2/elements` (or `window.__stitch2.t(key)` in plain JS), so the labels follow the language.
63
+ - Reuse the app's keys. Search the source messages for the text before inventing one (`stitch2 i18n --suggest`
64
+ lists matches). A new string gets a key in the app's naming scheme, added to the **source** messages first and
65
+ then to every locale; when you cannot write a good translation, add it to the source only and say so: the
66
+ locale falls back to the source and the report lists it.
67
+
68
+ ## Check
69
+
70
+ 1. `stitch2 i18n [filter] --suggest`: per screen, keys the source lacks (errors: they show the screen's own text
71
+ everywhere), keys each locale lacks, translations whose `{placeholders}` differ from the source, and text
72
+ with no key. Fix until it reports no unknown keys and no unkeyed product text.
73
+ 2. `stitch2 check <path> --shots`: runs the screen in the source, then in each check language. Issues that only
74
+ appear in a language carry it ("pseudo: Text overflows…", "ar: …"); renders are saved as
75
+ `--pseudo.png`, `--ar.png`. `--locales de,ar`, `all` or `none` overrides the config for one run.
76
+ 3. Look at the screen side by side: the canvas's **Languages** page (sidebar, or "languages" in a screen's
77
+ inspector) shows it once per language at the same size; the Language switch (`L`) shows the whole canvas in
78
+ one language. Read the pseudo frames for cut or overlapping text and the right-to-left ones for anything
79
+ that did not flip.
80
+
81
+ ## Fixing text that does not fit
82
+
83
+ In this order, and never by shrinking the type below its level:
84
+
85
+ 1. **Make room in the layout.** Let the text wrap where a second line is fine (headings, hints, cards); let a
86
+ row stack its label above its value; give a button the full width; move a secondary action out of a crowded
87
+ bar. Fixed widths and `whitespace-nowrap` on text are the usual cause.
88
+ 2. **Use a shorter natural word** in that locale, the one a native speaker would use (a translator's choice,
89
+ not an abbreviation the product does not use elsewhere). Change it in the locale's messages, which the app
90
+ shares.
91
+ 3. **Change the component** when one component fails in many languages (a tab label, a chip): fix it once in
92
+ the component and the catalog, then re-check every screen.
93
+
94
+ Ellipsis is acceptable only for user content (a long exercise name), never for a label the product owns.
95
+
96
+ ## Right to left
97
+
98
+ Use logical properties so layouts flip by themselves: `ms-*`/`me-*`, `ps-*`/`pe-*`, `start-*`/`end-*`,
99
+ `text-start`, `rounded-s-*`. Icons that point (back, next, chevrons, progress) get `rtl:-scale-x-100`;
100
+ icons that show objects (a dumbbell, a clock) and numbers, charts and media controls do not flip.
101
+
102
+ ## Hand over
103
+
104
+ Tell the user which languages you checked, what you changed to make each fit (layout, word, component), and
105
+ which locales still fall back to the source for new keys, by key.