@hegemonart/get-design-done 1.27.7 → 1.28.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +64 -0
- package/agents/design-verifier.md +17 -0
- package/package.json +1 -1
- package/reference/accessibility.md +4 -0
- package/reference/audit-scoring.md +14 -0
- package/reference/color-theory.md +279 -0
- package/reference/composition.md +349 -0
- package/reference/contrast-advanced.md +205 -0
- package/reference/design-system-guidance.md +2 -0
- package/reference/form-patterns.md +2 -0
- package/reference/i18n.md +554 -0
- package/reference/iconography.md +2 -0
- package/reference/motion-interpolate.md +1 -0
- package/reference/palette-catalog.md +2 -0
- package/reference/proportion-systems.md +267 -0
- package/reference/registry.json +35 -0
- package/reference/rtl-cjk-cultural.md +2 -0
- package/reference/style-vocabulary.md +2 -0
- package/reference/typography.md +4 -0
- package/reference/visual-hierarchy-layout.md +4 -0
- package/skills/explore/SKILL.md +31 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: i18n
|
|
3
|
+
type: heuristic
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
phase: 28
|
|
6
|
+
tags: [i18n, intl, icu, unicode, rtl, multi-script, wcag-i18n]
|
|
7
|
+
last_updated: 2026-05-18
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# i18n — Internationalization Engineering Primitives
|
|
11
|
+
|
|
12
|
+
This file closes a gap: the existing references touch internationalization in fragments — [rtl-cjk-cultural.md](./rtl-cjk-cultural.md) covers cultural context (greeting forms, color symbolism, CJK family-name order), [typography.md](./typography.md) covers font-family stacks, [accessibility.md](./accessibility.md) cites WCAG 3.1.1, [form-patterns.md](./form-patterns.md) cites locale-aware autofill — but none stitch the engineering primitives together. This is the file an agent should consult any time it is writing code that will render a localized string, format a number or date, mirror a layout, truncate user text, or audit a UI for i18n readiness.
|
|
13
|
+
|
|
14
|
+
The boundary is strict and additive. `rtl-cjk-cultural.md` owns the *cultural* layer — what gestures and color associations mean in a region, when a name field should accept both family-name-first and given-name-first orders, why a thumbs-up icon is unsafe in parts of the Middle East. This file owns the *code* layer — which CSS property to author, which `Intl.*` API to call, which Unicode normalization form to apply on input, which regex catches a hardcoded English string in a JSX file. Both files are needed; neither replaces the other.
|
|
15
|
+
|
|
16
|
+
## Text Expansion
|
|
17
|
+
|
|
18
|
+
Localized strings expand or contract depending on the target language. A button label that fits in a 96px container in English will overflow at +30% in German, +40% in Russian, and clip cleanly in Japanese. Design must absorb this variation — either by sizing containers for the worst-case expansion, by allowing wrapping, or by truncating with grapheme-aware logic. The factors below are conservative aggregates suitable for layout-budget planning and for the verifier overflow-simulation probe documented further down this file.
|
|
19
|
+
|
|
20
|
+
| Locale family | Expansion | Notes |
|
|
21
|
+
| ------------------- | --------- | ------------------------------ |
|
|
22
|
+
| EN (baseline) | 0% | reference |
|
|
23
|
+
| DE, FR | +30% | medium-long compounds |
|
|
24
|
+
| RU, FI, PL | +40% | morphology + diacritics |
|
|
25
|
+
| NL, SV | +25% | similar to DE/FR magnitude |
|
|
26
|
+
| ES, IT, PT | +25% | romance-language drift |
|
|
27
|
+
| JA, ZH, KO | −50% | ideographic density |
|
|
28
|
+
| AR (RTL) | +25% | RTL + tall script line-height |
|
|
29
|
+
|
|
30
|
+
These factors drive width constraints in component design and the verifier overflow-simulation probe (§Verifier Integration Spec below). A container that holds `EN base × 1.4` for any string is safe against the worst row of this table (RU at +40%) and remains the standard layout-budget rule. Treat the table as the contract between designer and engineer for any new component touching localizable text.
|
|
31
|
+
|
|
32
|
+
## RTL Mirroring
|
|
33
|
+
|
|
34
|
+
Right-to-left scripts — Arabic, Hebrew, Persian, Urdu — do not merely reverse text direction; they reverse the entire spatial logic of the interface. The reading eye enters from the right, "start" becomes the right edge, "end" becomes the left, and every directional affordance must be reconsidered. The engineering rule is to author all spatial CSS in *logical* properties so a single `dir="rtl"` flip on the document root produces a fully mirrored UI with zero per-element overrides.
|
|
35
|
+
|
|
36
|
+
### CSS logical properties
|
|
37
|
+
|
|
38
|
+
Logical properties resolve relative to the writing direction. They replace physical-direction properties one-for-one and are the only spatial CSS that an i18n-ready codebase should author. The mapping below is the working catalog; reach for the logical form by default and only use the physical form when the property is genuinely orientation-locked (e.g., a scrollbar position).
|
|
39
|
+
|
|
40
|
+
| Physical (avoid) | Logical (prefer) | Notes |
|
|
41
|
+
| --------------------------- | ----------------------------- | ---------------------------------- |
|
|
42
|
+
| `margin-left` | `margin-inline-start` | mirrors with `dir` |
|
|
43
|
+
| `margin-right` | `margin-inline-end` | mirrors with `dir` |
|
|
44
|
+
| `padding-left` | `padding-inline-start` | mirrors with `dir` |
|
|
45
|
+
| `padding-right` | `padding-inline-end` | mirrors with `dir` |
|
|
46
|
+
| `border-top-left-radius` | `border-start-start-radius` | block-start × inline-start corner |
|
|
47
|
+
| `border-bottom-right-radius`| `border-end-end-radius` | block-end × inline-end corner |
|
|
48
|
+
| `left: 0` (positioned) | `inset-inline-start: 0` | absolute / fixed offsets |
|
|
49
|
+
| `right: 0` | `inset-inline-end: 0` | absolute / fixed offsets |
|
|
50
|
+
| `text-align: left` | `text-align: start` | aligns with reading direction |
|
|
51
|
+
| `text-align: right` | `text-align: end` | aligns with reading direction |
|
|
52
|
+
|
|
53
|
+
A concrete card component authored entirely in logical props:
|
|
54
|
+
|
|
55
|
+
```css
|
|
56
|
+
.card {
|
|
57
|
+
padding-inline: 1rem;
|
|
58
|
+
padding-block: 0.75rem;
|
|
59
|
+
border-start-start-radius: 8px;
|
|
60
|
+
border-end-end-radius: 8px;
|
|
61
|
+
margin-inline-end: 1.5rem;
|
|
62
|
+
border-inline-start: 4px solid var(--accent);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.card .badge {
|
|
66
|
+
position: absolute;
|
|
67
|
+
inset-block-start: 0.5rem;
|
|
68
|
+
inset-inline-end: 0.5rem;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Under `dir="ltr"` the accent border lands on the left edge and the badge floats top-right. Under `dir="rtl"` the same CSS produces an accent border on the right edge and a badge that floats top-left — no media query, no override, no separate stylesheet.
|
|
73
|
+
|
|
74
|
+
### `dir="rtl"` cascade rules
|
|
75
|
+
|
|
76
|
+
The `dir` attribute on `<html>` propagates directionality through every descendant. Set it once at the document root from the user's locale, never per element to work around a missing logical property. If the product serves both directions from one codebase, control `dir` at runtime based on `Intl.Locale(userLocale).textInfo.direction`:
|
|
77
|
+
|
|
78
|
+
```html
|
|
79
|
+
<!-- Set once at the root, controls the entire subtree -->
|
|
80
|
+
<html lang="ar" dir="rtl">
|
|
81
|
+
<body>
|
|
82
|
+
<nav class="sidebar">...</nav>
|
|
83
|
+
<main>...</main>
|
|
84
|
+
</body>
|
|
85
|
+
</html>
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Per-element `dir` is reserved for genuinely mixed-direction content (a username inside an Arabic sentence; see §Bidi isolation).
|
|
89
|
+
|
|
90
|
+
### Directional-icon flip catalog
|
|
91
|
+
|
|
92
|
+
Not every icon should mirror under RTL. The rule is intent: if the icon means "next in reading order" or "back in reading order", it mirrors; if it means "play this video" or "this is a brand mark" or "this is the digit four", it does not. The catalog below is the working contract — apply `transform: scaleX(-1)` (via a `[dir="rtl"] .icon-directional { ... }` rule) for the flip-yes rows; leave the rest untouched.
|
|
93
|
+
|
|
94
|
+
| Icon | Flip in RTL? | Rationale |
|
|
95
|
+
| ----------------------------- | ------------ | -------------------------------------------------- |
|
|
96
|
+
| Chevron next / back | yes | reading-order directional |
|
|
97
|
+
| Arrow forward / arrow back | yes | reading-order directional |
|
|
98
|
+
| Breadcrumb separator chevrons | yes | reading-order directional |
|
|
99
|
+
| Progress-bar fill direction | yes | progression in reading order |
|
|
100
|
+
| Send / reply arrow | yes | implies outbound direction in reading order |
|
|
101
|
+
| Search (magnifier glass) | no | universal — handle angle is convention not flow |
|
|
102
|
+
| Brand logos, wordmarks | no | brand asset is fixed |
|
|
103
|
+
| Numerals (0–9) | no | digits render LTR even inside Arabic text |
|
|
104
|
+
| Media controls play / pause | no | bound to a physical timeline, not reading order |
|
|
105
|
+
| Close (×), settings gear | no | non-directional symbol |
|
|
106
|
+
| Star, heart, like | no | non-directional symbol |
|
|
107
|
+
| Compass / map north | no | geographic orientation is universal |
|
|
108
|
+
|
|
109
|
+
### Bidi isolation
|
|
110
|
+
|
|
111
|
+
When a string mixes scripts of different directions — an Arabic UI showing an English username, a French sentence quoting a Hebrew title — the Unicode Bidirectional Algorithm needs a hint to keep punctuation and adjacent content from leaking direction across the embed. Three tools handle this:
|
|
112
|
+
|
|
113
|
+
- `<bdi>` — bidi isolate; wraps user-provided runs whose direction is unknown.
|
|
114
|
+
- `dir="auto"` — first-strong heuristic; useful on user-input fields whose locale is unknown until typed.
|
|
115
|
+
- `unicode-bidi: isolate` — CSS equivalent of `<bdi>`; isolates a span without a semantic element.
|
|
116
|
+
|
|
117
|
+
A mixed-direction example: a comment list rendering an Arabic sentence that quotes an English handle:
|
|
118
|
+
|
|
119
|
+
```html
|
|
120
|
+
<p lang="ar" dir="rtl">
|
|
121
|
+
أعجبني المنشور من
|
|
122
|
+
<bdi>@user_42</bdi>
|
|
123
|
+
وأضفت تعليقاً.
|
|
124
|
+
</p>
|
|
125
|
+
|
|
126
|
+
<style>
|
|
127
|
+
/* If a `<bdi>` element isn't semantic for the case, use the CSS form */
|
|
128
|
+
.username {
|
|
129
|
+
unicode-bidi: isolate;
|
|
130
|
+
direction: ltr;
|
|
131
|
+
}
|
|
132
|
+
</style>
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Without `<bdi>` the trailing `42` digit pair can adopt the surrounding RTL direction and render reversed — a class of bug that is almost invisible in QA but jarring to native readers. Wrap any user-controlled identifier in `<bdi>` by default.
|
|
136
|
+
|
|
137
|
+
## Locale Formatting
|
|
138
|
+
|
|
139
|
+
Hand-rolled string concatenation for dates, numbers, lists, and plurals breaks at the locale boundary. The `Intl.*` family is the canonical replacement — every modern engine ships it, every example below is one call away from being correct in 150+ locales. The rule is unambiguous: never build a localized string by interpolation; always pass through an `Intl.*` formatter parameterized on the user's BCP 47 locale tag.
|
|
140
|
+
|
|
141
|
+
### Intl.DateTimeFormat
|
|
142
|
+
|
|
143
|
+
`Intl.DateTimeFormat` replaces `Date.toLocaleString` patterns and hand-formatted `YYYY-MM-DD` templates. It composes a date and time according to locale conventions — day/month ordering, separator characters, 12- vs 24-hour clocks, calendar systems, and time-zone naming.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
const event = new Date('2026-05-18T14:30:00Z');
|
|
147
|
+
|
|
148
|
+
const enUS = new Intl.DateTimeFormat('en-US', {
|
|
149
|
+
dateStyle: 'medium',
|
|
150
|
+
timeStyle: 'short',
|
|
151
|
+
timeZone: 'America/New_York',
|
|
152
|
+
});
|
|
153
|
+
enUS.format(event); // "May 18, 2026, 10:30 AM"
|
|
154
|
+
|
|
155
|
+
const deDE = new Intl.DateTimeFormat('de-DE', {
|
|
156
|
+
dateStyle: 'medium',
|
|
157
|
+
timeStyle: 'short',
|
|
158
|
+
});
|
|
159
|
+
deDE.format(event); // "18.05.2026, 16:30"
|
|
160
|
+
|
|
161
|
+
const arEG = new Intl.DateTimeFormat('ar-EG', {
|
|
162
|
+
dateStyle: 'full',
|
|
163
|
+
});
|
|
164
|
+
arEG.format(event); // "الاثنين، 18 مايو 2026"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Intl.NumberFormat
|
|
168
|
+
|
|
169
|
+
Three modes cover the dominant cases: `currency` for prices, `percent` for ratios, `unit` for physical quantities. The formatter knows the locale-correct decimal separator, grouping character, currency symbol position, and unit spelling.
|
|
170
|
+
|
|
171
|
+
```js
|
|
172
|
+
// currency mode
|
|
173
|
+
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.5);
|
|
174
|
+
// → "$1,234.50"
|
|
175
|
+
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.5);
|
|
176
|
+
// → "1.234,50 €"
|
|
177
|
+
|
|
178
|
+
// percent mode
|
|
179
|
+
new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 }).format(0.087);
|
|
180
|
+
// → "8.7%"
|
|
181
|
+
|
|
182
|
+
// unit mode
|
|
183
|
+
new Intl.NumberFormat('en-US', { style: 'unit', unit: 'kilometer-per-hour' }).format(72);
|
|
184
|
+
// → "72 km/h"
|
|
185
|
+
new Intl.NumberFormat('ja-JP', { style: 'unit', unit: 'kilometer-per-hour' }).format(72);
|
|
186
|
+
// → "時速 72 キロメートル"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Intl.PluralRules
|
|
190
|
+
|
|
191
|
+
Pluralization is not "1 vs many". Arabic has six categories (`zero`, `one`, `two`, `few`, `many`, `other`); Russian has four; Welsh has six. A ternary `count === 1 ? 'item' : 'items'` is wrong in nearly every non-English locale. `Intl.PluralRules` resolves the count to a category which is then looked up in a translation table — usually via an ICU MessageFormat string (next section), but available standalone.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
const ruPlural = new Intl.PluralRules('ru');
|
|
195
|
+
ruPlural.select(1); // → "one"
|
|
196
|
+
ruPlural.select(2); // → "few"
|
|
197
|
+
ruPlural.select(5); // → "many"
|
|
198
|
+
ruPlural.select(21); // → "one" — yes, twenty-one is "one" in Russian
|
|
199
|
+
ruPlural.select(22); // → "few"
|
|
200
|
+
|
|
201
|
+
const enPlural = new Intl.PluralRules('en');
|
|
202
|
+
enPlural.select(1); // → "one"
|
|
203
|
+
enPlural.select(2); // → "other"
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Intl.RelativeTimeFormat
|
|
207
|
+
|
|
208
|
+
`"3 days ago"`, `"in 2 hours"`, `"yesterday"` — relative-time phrases are wildly different across locales and tense systems. `Intl.RelativeTimeFormat` returns the locale-correct phrase given a number and a unit.
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
const enRel = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
|
212
|
+
enRel.format(-1, 'day'); // → "yesterday"
|
|
213
|
+
enRel.format(-3, 'day'); // → "3 days ago"
|
|
214
|
+
enRel.format(2, 'hour'); // → "in 2 hours"
|
|
215
|
+
|
|
216
|
+
const jaRel = new Intl.RelativeTimeFormat('ja', { numeric: 'auto' });
|
|
217
|
+
jaRel.format(-1, 'day'); // → "昨日"
|
|
218
|
+
jaRel.format(-3, 'day'); // → "3 日前"
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Intl.ListFormat
|
|
222
|
+
|
|
223
|
+
Joining a list of items into a sentence — `"A, B, and C"` — has locale-specific rules: serial comma usage, conjunction word, segment separator. `Intl.ListFormat` covers both conjunction (`and`) and disjunction (`or`) modes.
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
const tags = ['React', 'Vue', 'Svelte'];
|
|
227
|
+
|
|
228
|
+
new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }).format(tags);
|
|
229
|
+
// → "React, Vue, and Svelte"
|
|
230
|
+
new Intl.ListFormat('en', { style: 'long', type: 'disjunction' }).format(tags);
|
|
231
|
+
// → "React, Vue, or Svelte"
|
|
232
|
+
new Intl.ListFormat('de', { style: 'long', type: 'conjunction' }).format(tags);
|
|
233
|
+
// → "React, Vue und Svelte"
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Intl.Collator
|
|
237
|
+
|
|
238
|
+
`Array.prototype.sort` on strings uses Unicode code-point order — which produces wrong results outside ASCII (`'Ö'` sorts after `'Z'` instead of with `'O'` in Swedish; `'é'` sorts after `'z'` in French). `Intl.Collator` provides locale-aware sorting and is the only correct choice for any user-facing sorted list.
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
const names = ['Zebra', 'Élan', 'Ödipus', 'Apple'];
|
|
242
|
+
|
|
243
|
+
names.slice().sort(); // raw — wrong
|
|
244
|
+
// → ["Apple", "Zebra", "Élan", "Ödipus"]
|
|
245
|
+
|
|
246
|
+
const svCollator = new Intl.Collator('sv');
|
|
247
|
+
names.slice().sort(svCollator.compare);
|
|
248
|
+
// → ["Apple", "Élan", "Zebra", "Ödipus"] — Swedish: Ö is its own letter, sorts after Z
|
|
249
|
+
|
|
250
|
+
const deCollator = new Intl.Collator('de');
|
|
251
|
+
names.slice().sort(deCollator.compare);
|
|
252
|
+
// → ["Apple", "Élan", "Ödipus", "Zebra"] — German: Ö collates near O
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Intl.Segmenter
|
|
256
|
+
|
|
257
|
+
`Intl.Segmenter` produces grapheme-cluster, word, or sentence segments according to the Unicode segmentation algorithm. The grapheme-cluster mode is the *only* correct way to count or truncate user text. JavaScript `string.length` counts UTF-16 code units, not graphemes — a family-style ZWJ sequence such as `U+1F468 U+200D U+1F469 U+200D U+1F467` (Man + ZWJ + Woman + ZWJ + Girl) reports as length 5 or 7 depending on the engine, and `string.slice(0, 10)` will split an emoji or combining character mid-sequence, producing a string that renders as a broken-glyph diamond.
|
|
258
|
+
|
|
259
|
+
```js
|
|
260
|
+
// HARD RULE: never `string.slice(0, n)` for unknown text
|
|
261
|
+
// const truncated = name.slice(0, 20); // ✗ may split a grapheme
|
|
262
|
+
|
|
263
|
+
const seg = new Intl.Segmenter('en', { granularity: 'grapheme' });
|
|
264
|
+
function truncateGraphemes(input, max) {
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const { segment } of seg.segment(input)) {
|
|
267
|
+
if (out.length >= max) break;
|
|
268
|
+
out.push(segment);
|
|
269
|
+
}
|
|
270
|
+
return out.join('');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
truncateGraphemes('café 🇫🇷👨👩👧 résumé', 6); // → "café 🇫🇷"
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## ICU MessageFormat
|
|
277
|
+
|
|
278
|
+
ICU MessageFormat is the canonical input format for the dominant i18n library matrix — `react-intl`, `formatjs`, `lingui`, `next-intl`, `i18next` all parse it natively. It is a small DSL with three constructs that matter — `plural`, `select`, `selectordinal` — each of which encodes a branch that string concatenation cannot.
|
|
279
|
+
|
|
280
|
+
```txt
|
|
281
|
+
{count, plural,
|
|
282
|
+
=0 {No items}
|
|
283
|
+
one {# item}
|
|
284
|
+
few {# items}
|
|
285
|
+
many {# items}
|
|
286
|
+
other {# items}
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
```txt
|
|
291
|
+
{gender, select,
|
|
292
|
+
female {She invited you}
|
|
293
|
+
male {He invited you}
|
|
294
|
+
other {They invited you}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```txt
|
|
299
|
+
{place, selectordinal,
|
|
300
|
+
one {You finished #st}
|
|
301
|
+
two {You finished #nd}
|
|
302
|
+
few {You finished #rd}
|
|
303
|
+
other {You finished #th}
|
|
304
|
+
}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Why ICU beats string concatenation: pluralization rules vary by locale (Russian has four categories — `one`, `few`, `many`, `other`; Welsh has six — `zero`, `one`, `two`, `few`, `many`, `other`), gendered forms vary (Slavic and Semitic languages branch verb forms on subject gender), and ordinal suffixes differ (English `1st 2nd 3rd 4th`; many languages use a single suffix). Concatenation hardcodes English assumptions; ICU encodes the branch as data, and the runtime picks the correct branch per locale.
|
|
308
|
+
|
|
309
|
+
The library bridge is direct: `react-intl` consumes ICU via `<FormattedMessage>` props, `formatjs` provides the underlying formatter, `lingui` ships its own ICU-compatible parser, `next-intl` accepts ICU strings as namespace values, and `i18next` supports ICU via the `i18next-icu` plugin. Pick the framework's idiom; the input syntax is the same.
|
|
310
|
+
|
|
311
|
+
## Unicode Hygiene
|
|
312
|
+
|
|
313
|
+
Five rules below are non-negotiable for any code path that touches user text — input, storage, comparison, display, or truncation. Skipping them produces bugs that surface in production months after the code ships, when a user with a non-ASCII name discovers their record cannot be found or their display name renders as boxes.
|
|
314
|
+
|
|
315
|
+
### NFC normalization on input
|
|
316
|
+
|
|
317
|
+
The same visible character can be encoded as a single precomposed code point (`é` = `U+00E9`) or as a base character plus a combining mark (`e` + `U+0301`). Two strings that look identical may compare as not-equal, hash to different keys, and fail length comparisons. Normalize all input to NFC at the boundary — the moment text enters the application, before storage, before comparison.
|
|
318
|
+
|
|
319
|
+
```js
|
|
320
|
+
const a = 'é'; // U+00E9
|
|
321
|
+
const b = 'é'; // U+0065 U+0301
|
|
322
|
+
a === b; // false
|
|
323
|
+
a.normalize('NFC') === b.normalize('NFC'); // true
|
|
324
|
+
|
|
325
|
+
// Apply at input boundaries:
|
|
326
|
+
input.addEventListener('blur', (e) => {
|
|
327
|
+
e.target.value = e.target.value.normalize('NFC');
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Grapheme-aware truncation
|
|
332
|
+
|
|
333
|
+
Restating from §Intl.Segmenter as a hard rule: **never `string.slice(0, n)` for unknown text**. Code-point and code-unit slicing splits emojis, combining marks, and ZWJ sequences. Use `Intl.Segmenter` with `granularity: 'grapheme'` for any truncation operation on user-controlled input. See the `truncateGraphemes` helper in §Intl.Segmenter.
|
|
334
|
+
|
|
335
|
+
### BCP 47 language tag structure
|
|
336
|
+
|
|
337
|
+
Locale identifiers in code are BCP 47 tags. The structure is `language[-Script][-Region][-Variant]` with case rules: language lowercase, script title-case, region uppercase. `pt-BR` is correct; `pt_br`, `PT-br`, and `pt_BR` are all wrong and will produce silent failures in `Intl.*` (or worse, partial matches that miss region-specific formatting). Use hyphens, never underscores. Normalize on input via `new Intl.Locale(tag).toString()`.
|
|
338
|
+
|
|
339
|
+
```js
|
|
340
|
+
new Intl.Locale('pt-br').toString(); // → "pt-BR" (canonicalized)
|
|
341
|
+
new Intl.Locale('zh-hant-tw').toString(); // → "zh-Hant-TW"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
### RTL detection from Intl.Locale
|
|
345
|
+
|
|
346
|
+
Never maintain a hardcoded `RTL_LOCALES` regex. The platform exposes the directional metadata directly:
|
|
347
|
+
|
|
348
|
+
```js
|
|
349
|
+
new Intl.Locale('ar').textInfo.direction; // → "rtl"
|
|
350
|
+
new Intl.Locale('he').textInfo.direction; // → "rtl"
|
|
351
|
+
new Intl.Locale('fa').textInfo.direction; // → "rtl"
|
|
352
|
+
new Intl.Locale('en').textInfo.direction; // → "ltr"
|
|
353
|
+
|
|
354
|
+
// Apply at the document root:
|
|
355
|
+
document.documentElement.dir = new Intl.Locale(userLocale).textInfo.direction;
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Browser-support drift
|
|
359
|
+
|
|
360
|
+
`Intl.Segmenter` shipped late on Safari (March 2023, version 16.4). Projects targeting older Safari, embedded WebViews on older iOS, or pre-2023 Node versions need a polyfill — `intl-segmenter-polyfill` or the underlying ICU library exposed via WebAssembly. Check current support at [caniuse.com/?search=Intl.Segmenter](https://caniuse.com/?search=Intl.Segmenter) before relying on grapheme-aware truncation in a hot path; for the rest of `Intl.*` the support has been universal since 2020.
|
|
361
|
+
|
|
362
|
+
## Multi-Script Font Stacks
|
|
363
|
+
|
|
364
|
+
A multi-script product (any product shipping outside a single Latin-script locale) cannot rely on one font family. Latin-only fonts are missing CJK ideographs, Arabic shapes, Devanagari conjuncts, and Cyrillic accents. The browser falls back to a system font when a glyph is missing, and the result is a visible style mismatch — a paragraph that switches typeface mid-sentence. The fix is an explicit per-script fallback stack, chosen so each script has a high-quality font available before the system fallback fires.
|
|
365
|
+
|
|
366
|
+
### CJK fallbacks
|
|
367
|
+
|
|
368
|
+
The Noto Sans CJK family is the open-source default; on macOS/iOS the platform fonts (Hiragino Sans, Apple SD Gothic Neo) render better and load instantly; on Windows the bundled font (Microsoft YaHei for Simplified Chinese) is the right local fallback. The stack composes from most-specific (region-locked) to most-generic.
|
|
369
|
+
|
|
370
|
+
```css
|
|
371
|
+
:root {
|
|
372
|
+
--font-cjk-ja: "Hiragino Sans", "Noto Sans CJK JP", "Yu Gothic", sans-serif;
|
|
373
|
+
--font-cjk-sc: "Microsoft YaHei", "Noto Sans CJK SC", "PingFang SC", sans-serif;
|
|
374
|
+
--font-cjk-tc: "PingFang TC", "Noto Sans CJK TC", "Microsoft JhengHei", sans-serif;
|
|
375
|
+
--font-cjk-ko: "Apple SD Gothic Neo", "Noto Sans CJK KR", "Malgun Gothic", sans-serif;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
:lang(ja) { font-family: var(--font-cjk-ja); }
|
|
379
|
+
:lang(zh-Hans) { font-family: var(--font-cjk-sc); }
|
|
380
|
+
:lang(zh-Hant) { font-family: var(--font-cjk-tc); }
|
|
381
|
+
:lang(ko) { font-family: var(--font-cjk-ko); }
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### Arabic fallbacks
|
|
385
|
+
|
|
386
|
+
Arabic text demands cursive shaping (initial / medial / final / isolated glyph forms) and tall line metrics. Noto Naskh Arabic is the standard naskh choice; Cairo is a contemporary sans alternative with strong UI rendering.
|
|
387
|
+
|
|
388
|
+
```css
|
|
389
|
+
:lang(ar) {
|
|
390
|
+
font-family: "Noto Naskh Arabic", "Cairo", "Geeza Pro", "Segoe UI", sans-serif;
|
|
391
|
+
line-height: 1.7; /* Arabic needs more vertical room than Latin */
|
|
392
|
+
}
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### Devanagari fallbacks
|
|
396
|
+
|
|
397
|
+
Devanagari (Hindi, Marathi, Sanskrit) requires conjunct shaping and proper combining-mark positioning. Noto Sans Devanagari is the open-source default.
|
|
398
|
+
|
|
399
|
+
```css
|
|
400
|
+
:lang(hi),
|
|
401
|
+
:lang(mr) {
|
|
402
|
+
font-family: "Noto Sans Devanagari", "Mangal", "Kohinoor Devanagari", sans-serif;
|
|
403
|
+
}
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
### `font-display: swap` and FOUC across scripts
|
|
407
|
+
|
|
408
|
+
`font-display: swap` shows the fallback immediately and swaps to the web font once loaded — the trade is a Flash of Unstyled Text (FOUT) instead of a Flash of Invisible Text (FOIT). Across scripts the FOUT problem is more severe: the system fallback for a CJK glyph may have radically different proportions from the loaded web font, so the swap moment causes a noticeable layout shift. Mitigations: preload the most-likely script subset for the user's locale, set `size-adjust` on the `@font-face` to match the fallback metrics, and accept FOIT (`font-display: block`) on the smallest critical-text subset where shift is unacceptable. See [variable-fonts-loading.md](./variable-fonts-loading.md) §font-display Values for the complete tradeoff.
|
|
409
|
+
|
|
410
|
+
### Subset strategy via `unicode-range`
|
|
411
|
+
|
|
412
|
+
Shipping the full Noto Sans CJK is a 5–15MB asset. Browsers will only fetch a subset if the `@font-face` declares one via `unicode-range`, so split into per-script files and let the browser fetch only the ranges the page uses.
|
|
413
|
+
|
|
414
|
+
```css
|
|
415
|
+
/* Latin subset — fetched for any page */
|
|
416
|
+
@font-face {
|
|
417
|
+
font-family: "Inter";
|
|
418
|
+
src: url("/fonts/inter-latin.woff2") format("woff2");
|
|
419
|
+
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
420
|
+
font-display: swap;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/* CJK Unified Ideographs subset — only fetched when page renders these code points */
|
|
424
|
+
@font-face {
|
|
425
|
+
font-family: "Noto Sans JP";
|
|
426
|
+
src: url("/fonts/noto-sans-jp-cjk-unified.woff2") format("woff2");
|
|
427
|
+
unicode-range: U+4E00-9FFF;
|
|
428
|
+
font-display: swap;
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
### Variable-font interaction
|
|
433
|
+
|
|
434
|
+
For a single-script product, one variable font with `wght`, `ital`, and `opsz` axes is the byte-budget win — one file, all weights, all styles. For a multi-script product the calculus reverses: shipping `script-count × axis-count` axes in a single variable file balloons the asset, and very few variable fonts cover CJK or Arabic at all (the glyph count multiplies the axis storage). For multi-script products, prefer multiple weighted-static fonts per script, each subset via `unicode-range`, over one mega-variable file. The full tradeoff is in [variable-fonts-loading.md](./variable-fonts-loading.md) §Variable Font Axes; this is the cross-link that downstream agents should follow when the project's `package.json` shows both CJK and Latin scripts in active use.
|
|
435
|
+
|
|
436
|
+
## WCAG i18n
|
|
437
|
+
|
|
438
|
+
Two WCAG success criteria are the i18n contract a designer-engineer agent must enforce. Both are Level A (the lowest threshold) and both are commonly missed by codebases that otherwise pass contrast and target-size audits.
|
|
439
|
+
|
|
440
|
+
### 3.1.1 — `lang` attribute on root
|
|
441
|
+
|
|
442
|
+
Every page must declare its primary language on `<html>`. Screen readers select the correct phoneme inventory and prosody rules from this attribute; without it the reader falls back to its default voice (often US English), producing accented mispronunciation of every word.
|
|
443
|
+
|
|
444
|
+
```html
|
|
445
|
+
<!doctype html>
|
|
446
|
+
<html lang="ja">
|
|
447
|
+
<head>...</head>
|
|
448
|
+
<body>...</body>
|
|
449
|
+
</html>
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### 3.1.2 — Language-of-parts
|
|
453
|
+
|
|
454
|
+
When the page's primary language is set on `<html>` but a span of text is in a different language — a French quote inside an English article, an Arabic title rendered inside an English page — the foreign span needs its own `lang` attribute. The screen reader switches voice for that span and switches back at its close.
|
|
455
|
+
|
|
456
|
+
```html
|
|
457
|
+
<p>
|
|
458
|
+
The original report, titled
|
|
459
|
+
<span lang="fr">Le développement durable</span>,
|
|
460
|
+
was published in 1987.
|
|
461
|
+
</p>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
The auditor catches a missed 3.1.2 by scanning for untranslated quotes, proper nouns in a non-root script, and any code-switching pattern in body copy. Common failure: a marketing page in English embeds a customer-quote in Japanese with no `lang="ja"` wrapper, producing US-English phonemes applied to Japanese romaji — an outcome unintelligible to a screen-reader user. The fix is one attribute per foreign span.
|
|
465
|
+
|
|
466
|
+
## Verifier Integration Spec
|
|
467
|
+
|
|
468
|
+
This section specifies two probes for [agents/design-verifier.md](../agents/design-verifier.md). 28-06 implements; this file owns the spec. Both probes attach to a new `### i18n probes` subsection at the end of Phase 1 — Re-Audit + Category Scoring (after the existing `### Category Scores` subsection), and both classify findings under the orthogonal lens-tag `i18n_readiness` (per D-03 / D-07 — no new pillar, no audit-format change).
|
|
469
|
+
|
|
470
|
+
### Probe 1: Hardcoded-string scan
|
|
471
|
+
|
|
472
|
+
The probe scans component source files for hardcoded user-facing strings that bypass the i18n framework. The regex catalog matches the four library patterns from D-10 — these are the *expected* call shapes; anything that *resembles* a user-facing string but does *not* match one of these patterns is a candidate finding.
|
|
473
|
+
|
|
474
|
+
```txt
|
|
475
|
+
react-intl: <FormattedMessage\s+id="[^"]+"
|
|
476
|
+
next-intl: \bt\(\s*['"][a-zA-Z][\w.]*['"]
|
|
477
|
+
i18next: \bt\(\s*['"][a-zA-Z][\w.]*['"]\s*,\s*\{
|
|
478
|
+
vue-i18n: \$t\(\s*['"][a-zA-Z][\w.]*['"]
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
Allow-list seed (per D-10) prevents day-1 false-positive flood by exempting strings that never reach the UI:
|
|
482
|
+
|
|
483
|
+
```txt
|
|
484
|
+
console\.(log|error|warn|info|debug) — dev logging
|
|
485
|
+
^\s*/\*.*\*/\s*$ — dev-only block comments
|
|
486
|
+
data-testid="[^"]+" — test selectors
|
|
487
|
+
className="[^"]+" — CSS class names
|
|
488
|
+
import\s+.*\s+from\s+['"][^'"]+['"] — import paths
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
Finding classification: tagged `i18n_readiness`. Default severity `MINOR`; raised to `MAJOR` if the file count of unique violating files exceeds 10 (the codebase has a systemic problem, not a one-off oversight). Output line in the verifier report: `i18n_readiness: <N> hardcoded strings in <M> files — see ./i18n.md §ICU MessageFormat`.
|
|
492
|
+
|
|
493
|
+
Reflector hook (D-10): the probe records false-positive count over the first N audit runs. If the false-positive rate exceeds the threshold defined in the Phase 11 self-improvement loop, the reflector proposes a tightened regex or an extended allow-list — the same pattern Phase 11 uses for self-improvement of other heuristics.
|
|
494
|
+
|
|
495
|
+
### Probe 2: +40% Text-overflow simulation
|
|
496
|
+
|
|
497
|
+
The probe simulates the worst-case row of the §Text Expansion table — RU/FI/PL at +40% — by inflating every text node in the rendered DOM and measuring whether containers overflow. The pseudo-code spec below is the integration contract; 28-06 may implement via either DOM measurement or Preview-MCP screenshot diff, whichever is available in the run context.
|
|
498
|
+
|
|
499
|
+
```txt
|
|
500
|
+
FOR every text node T in rendered DOM:
|
|
501
|
+
original := T.textContent
|
|
502
|
+
expanded := original repeated/padded until length(expanded) = length(original) × 1.4
|
|
503
|
+
T.textContent := expanded
|
|
504
|
+
measure: T.parentElement.scrollWidth > T.parentElement.clientWidth?
|
|
505
|
+
YES → finding(selector(T.parentElement), original, "overflows at +40%")
|
|
506
|
+
T.textContent := original // restore
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Implementation note for 28-06: prefer Preview MCP screenshot-diff if the run has a Preview client attached (catches overflow + clipping + ellipsis + wrapping changes in one pass); fall back to in-process DOM measurement (`scrollWidth > clientWidth`) when running headless or without Preview. Either way, classify findings under `i18n_readiness`, severity `MINOR` per finding (raised to `MAJOR` if more than 10 components overflow).
|
|
510
|
+
|
|
511
|
+
Output line in the verifier report: `i18n_readiness: <N> components overflow at +40% text expansion — widen containers or allow text wrap`.
|
|
512
|
+
|
|
513
|
+
## Explore Integration Spec
|
|
514
|
+
|
|
515
|
+
This section specifies one probe for [skills/explore/SKILL.md](../skills/explore/SKILL.md). 28-06 implements; this file owns the spec. The probe attaches as a sub-step "**i18n readiness probe**" inside Step 2 — Inventory scan (before the close of Step 2), and produces one informational line in the standard explore report.
|
|
516
|
+
|
|
517
|
+
### Probe: i18n-readiness (3-state, informational)
|
|
518
|
+
|
|
519
|
+
3-state classification logic (per D-04 / D-11):
|
|
520
|
+
|
|
521
|
+
```txt
|
|
522
|
+
1. Read package.json (dependencies + devDependencies).
|
|
523
|
+
|
|
524
|
+
2. Check against library matrix:
|
|
525
|
+
react-intl, next-intl, i18next, vue-i18n, formatjs, lingui
|
|
526
|
+
≥1 library found in deps or devDeps → state = "framework-managed"
|
|
527
|
+
→ STOP, emit line, exit probe.
|
|
528
|
+
|
|
529
|
+
3. Else (no library):
|
|
530
|
+
grep -RE "Intl\.(DateTimeFormat|NumberFormat|PluralRules|RelativeTimeFormat|ListFormat|Collator|Segmenter)" src/
|
|
531
|
+
≥1 match → state = "partial"
|
|
532
|
+
→ emit line, exit probe.
|
|
533
|
+
|
|
534
|
+
4. Else: state = "none"
|
|
535
|
+
→ emit line, exit probe.
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Output line in explore report (single informational line, per D-04):
|
|
539
|
+
|
|
540
|
+
```txt
|
|
541
|
+
Localization readiness: framework-managed | partial | none
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
The probe is **informational only**. No gate, no blocking, no required-action — explore's job is to describe the project's current state, not prescribe change. A consumer downstream (a planning agent, a roadmap reviewer, the user) can act on the signal if a gap is meaningful for the project, but the probe itself never forces a step. This matches the orthogonal-lens discipline of D-07 — surface signal, do not bolt on a new pillar.
|
|
545
|
+
|
|
546
|
+
## Cross-References
|
|
547
|
+
|
|
548
|
+
- [typography.md](./typography.md) §Variable Fonts — variable-font axes the multi-script subset strategy interacts with.
|
|
549
|
+
- [rtl-cjk-cultural.md](./rtl-cjk-cultural.md) — cultural context (greeting forms, color symbolism, CJK family-name order); this file deepens the engineering side without overlap.
|
|
550
|
+
- [accessibility.md](./accessibility.md) §WCAG 2.1 / 2.2 — the §WCAG i18n section above is the language-specific cross-reference to the broader WCAG corpus.
|
|
551
|
+
- [form-patterns.md](./form-patterns.md) — locale-aware input formatting, `autocomplete` taxonomy, BCP 47 tag use in form locale negotiation.
|
|
552
|
+
- [variable-fonts-loading.md](./variable-fonts-loading.md) — multi-script subset strategy interaction; when shipping multiple weighted-static fonts beats one mega-variable font.
|
|
553
|
+
|
|
554
|
+
Reciprocal inbound cross-links into this file land in Phase 28-06 (additive-only, D-06).
|
package/reference/iconography.md
CHANGED
|
@@ -20,6 +20,8 @@ Icons are not scalable art objects that merely get bigger — they are optical i
|
|
|
20
20
|
|
|
21
21
|
**Pixel alignment rules:** Icons on an even grid (16px, 24px, 32px) align their strokes to whole pixels. Icons on an odd grid (20px, 28px) should have strokes centered on 0.5px boundaries to avoid sub-pixel blur on non-retina displays. For SVG exports, always set `shape-rendering: crispEdges` on icon wrappers, and center the viewBox exactly on the pixel grid (e.g., `viewBox="0 0 24 24"`, not `"0.5 0.5 23 23"`). A stroke centered on the path boundary will anti-alias; a stroke offset by 0.5px will render sharply.
|
|
22
22
|
|
|
23
|
+
**See:** [`./composition.md`](./composition.md) §Optical vs. Mathematical Centering for why icon glyphs (chevrons, play triangles, asymmetric arrows) need a small (−1 to −2px) nudge from mathematical center — the visual-weight asymmetry of the glyph shifts perceived center away from the geometric centroid.
|
|
24
|
+
|
|
23
25
|
---
|
|
24
26
|
|
|
25
27
|
## 2. Weight & Stroke Consistency
|
|
@@ -280,3 +280,4 @@ This is equivalent to three separate interpolations chained by range.
|
|
|
280
280
|
|
|
281
281
|
- Easing functions for the `easing` / `ease` parameter: [motion-easings.md](./motion-easings.md)
|
|
282
282
|
- Spring-based animation (an alternative driver to progress/scroll): [motion-spring.md](./motion-spring.md)
|
|
283
|
+
- Color-specific interpolation paths (sRGB muddy-mid transition vs OKLCH perceptual path): **See** [`./color-theory.md`](./color-theory.md) §Color Interpolation in Animation for the color-channel application of the same interpolation discipline.
|
|
@@ -20,6 +20,8 @@ This catalog gives design agents and the brief-stage palette picker a pre-verifi
|
|
|
20
20
|
|
|
21
21
|
**Step 4 — Adjust for brand uniqueness.** All values here are mid-point baselines. Shift the primary hue ±15°, adjust lightness ±10%, or introduce a proprietary tint to differentiate the brand. Do not use these hex values verbatim in production without at least one brand-distinguishing adjustment.
|
|
22
22
|
|
|
23
|
+
**See:** [`./color-theory.md`](./color-theory.md) §Color Harmonies for the OKLCH model that grounds these hue-shift instructions (perceptual lightness preserved across hues; sRGB ±15° distorts perceived brightness asymmetrically across yellow/blue).
|
|
24
|
+
|
|
23
25
|
**Step 5 — Verify pairing.** After choosing the palette, consult `reference/typography.md` for matching typeface pairings that reinforce the vertical's tone.
|
|
24
26
|
|
|
25
27
|
## WCAG Compliance Notes
|