feat(i18n): add Loco package integration and Storybook live sync#289
feat(i18n): add Loco package integration and Storybook live sync#289HrithikMani wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds Loco i18n integration helpers plus Storybook tooling for switching locales and optionally running a “live sync” mode that discovers text in rendered stories and posts it to a Loco server.
Changes:
- Added i18n utility helpers (
createLocoTranslator, dotted-key resolution, fallback language support) with Vitest coverage. - Added Storybook preview decorators and globals for locale + Loco “package vs live” mode, including runtime script loading and textnode posting.
- Added a CLI script to export a Loco package and write it to a local sample pack used by Storybook integration stories.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/loco-live.ts | Adds DOM scanning + POST helper for live text discovery/sync. |
| src/utils/index.ts | Re-exports new i18n and live-sync utilities. |
| src/utils/i18n.ts | Introduces translation dictionary resolution + dotted-key lookup + translator factory. |
| src/utils/i18n.test.ts | Adds unit tests for i18n helpers. |
| src/i18n/loco-sample-pack.json | Adds a sample Loco export package for Storybook demos. |
| src/components/Text/TextI18nIntegration.stories.tsx | Adds an i18n integration story demonstrating package-driven translation. |
| src/components/CountBadge/CountBadge.stories.tsx | Updates stories to use the translator (notably for “eSign” and item labels). |
| src/components/Badge/Badge.stories.tsx | Updates stories to use the translator for displayed labels. |
| src/components/AppHeader/AppHeaderI18nIntegration.stories.tsx | Adds an AppHeader i18n integration story using the translator. |
| scripts/sync-loco-pack.mjs | Adds a script to fetch/export a Loco package to a local JSON file. |
| package.json | Adds loco:pack:sync script entry. |
| pnpm-lock.yaml | Updates lockfile entries (ychart file reference normalization). |
| .storybook/preview.tsx | Adds Storybook globals + decorator to load/apply Loco runtime and post discovered phrases. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const preview: Preview = { | ||
| initialGlobals: { | ||
| brand: 'bluehive', | ||
| theme: 'light', | ||
| density: 'standard', | ||
| locale: 'en', | ||
| locoMode: 'package', | ||
| locoServer: 'http://localhost:6101', | ||
| locoApiKey: '82b6c1a44ec247dcb6c96fe0', | ||
| }, |
| it('falls back to fallback language when missing', () => { | ||
| expect(resolveLocoTranslation(samplePack, 'fr', 'ui.missing', 'en')).toBeUndefined(); | ||
| expect(resolveLocoTranslation(samplePack, 'fr', 'ui.title', 'en')).toBe('Parametres'); | ||
| }); |
Deploying ui with
|
| Latest commit: |
cc3e9ca
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://b0f09ad3.ui-6d0.pages.dev |
| Branch Preview URL: | https://feat-loco-i18n-package-integ.ui-6d0.pages.dev |
- Configure Storybook to connect to local Loco server (http://10.3.37.116:6199/loco) - Add 'Loco Sync Text' mode to post discovered phrases to Loco pending translations - Add 'Disable' option to completely disable Loco initialization - Default to 'Loco Sync Text' mode for active translation workflow - Support environment variables: VITE_LOCO_SERVER_URL, VITE_LOCO_API_KEY, VITE_DISABLE_LOCO - Rename i18n translation file to i18n-translations.json for clarity - Update sync script to use local Loco server and new file name - Update story imports to use new i18n-translations.json file - Add Chinese (Simplified) translations from Loco
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (3)
.storybook/preview.tsx:218
- Hard-coded Loco server IP and especially the default API key risk leaking credentials and coupling Storybook to an internal environment. Defaults should be non-sensitive and overridable via env/toolbars only.
// Default Loco configuration from environment variables
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'http://10.3.37.116:6199/loco';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '82b6c1a44ec247dcb6c96fe0';
const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true';
scripts/sync-loco-pack.mjs:40
- The script defaults to an internal IP for
--server, which is likely to fail for most developers and can inadvertently send requests to the wrong environment. Use a safer default such as localhost (or require--server/LOCO_SERVER_URL).
const server = (args.server || process.env.LOCO_SERVER_URL || 'http://10.3.37.116:6199/loco').replace(/\/$/, '');
const apiKey = args.apiKey || process.env.LOCO_API_KEY || '';
src/utils/i18n.test.ts:52
- This test doesn’t actually exercise fallback behavior:
ui.titleexists in the activefrdictionary so the fallback language is never consulted. Adjust the fixture (or mutate a clone) so the key is missing infrbut present inen, and assert that the English string is returned.
it('falls back to fallback language when missing', () => {
expect(resolveLocoTranslation(samplePack, 'fr', 'ui.missing', 'en')).toBeUndefined();
expect(resolveLocoTranslation(samplePack, 'fr', 'ui.title', 'en')).toBe('Parametres');
});
| console.log(`Usage: node scripts/sync-loco-pack.mjs [options] | ||
|
|
||
| Options: | ||
| --server=<url> Loco server URL (default: http://10.3.37.116:6199/loco) |
| "languages": [ | ||
| "zh-Hans" | ||
| ], | ||
| "languageNames": { | ||
| "zh-Hans": "Chinese (Simplified)" | ||
| }, | ||
| "resources": { | ||
| "zh-Hans": { | ||
| "translation": { |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 18 changed files in this pull request and generated 6 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (5)
.storybook/preview.tsx:218
- A hard-coded default API key is committed into the repo. This risks credential exposure and causes Storybook to send authenticated requests unintentionally when live sync is enabled.
// Default Loco configuration from environment variables
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true';
.storybook/preview.tsx:322
- The fallback Loco server URL defaults to an internal IP address, which is likely unreachable for most contributors and can cause confusing network behavior. It also diverges from defaultLocoServer defined above.
const serverFromEnv =
(import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() ||
'http://10.3.37.116:6199';
const serverUrl = configuredServer || serverFromEnv;
.storybook/preview.tsx:405
- Storybook defaults to Loco live sync mode on startup. That triggers runtime script loading and network POSTs by default, which is risky/noisy for local development. Defaulting to package mode avoids unexpected external calls.
density: 'standard',
locale: 'en',
locoMode: 'live',
locoServer: defaultLocoServer,
locoApiKey: defaultLocoApiKey,
src/components/Text/TextI18nIntegration.stories.tsx:54
- This story uses a dotted i18n key that does not exist in the bundled sample pack, so it will always render the raw key instead of a translation.
{t('ui.page.subtitle')}
src/components/Text/TextI18nIntegration.stories.tsx:61
- These examples reference dotted keys that are not present in the bundled sample pack, so the rendered output will be the key string rather than a translated value. Use sample-pack phrase keys (and keep one missing key example to demonstrate fallback) so the story actually shows translations in zh-Hans.
<Text size="sm">ui.actions.save => {t('ui.actions.save')}</Text>
<Text size="sm">ui.actions.cancel => {t('ui.actions.cancel')}</Text>
<Text size="sm">ui.status.ready => {t('ui.status.ready')}</Text>
<Text size="sm">ui.unknown.key => {t('ui.unknown.key')}</Text>
| const nested = resolveDottedValue(dictionary, key); | ||
| if (nested) return nested; | ||
|
|
| weight="bold" | ||
| className={`transition-all duration-300 ${isLocaleChanging ? 'opacity-60 translate-y-[1px]' : 'opacity-100 translate-y-0'}`} | ||
| > | ||
| {t('ui.page.title')} |
| <AppHeaderBrand>{t('ui.appHeader.brand')}</AppHeaderBrand> | ||
| <AppHeaderDivider /> | ||
| <AppHeaderTitle | ||
| subtitle={t('ui.appHeader.subtitle')} | ||
| className={`transition-all duration-300 ${isLocaleChanging ? 'opacity-70 translate-y-[1px]' : 'opacity-100 translate-y-0'}`} |
| render: (args, context) => { | ||
| const t = getTranslator(context); | ||
| return <Badge {...args}>{t('ui.badge.single')}</Badge>; | ||
| }, | ||
| }; |
| render: (_, context) => { | ||
| const t = getTranslator(context); | ||
| return <CountBadge label={t('eSign')} count={7} variant="alert" />; | ||
| }, |
| @@ -0,0 +1 @@ | |||
| - generic [active] [ref=e1]: "{\"statusCode\":500,\"error\":\"Internal Server Error\",\"message\":\"reply.sendFile is not a function\"}" No newline at end of file | |||
…h valid JSON syntax
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 20 changed files in this pull request and generated 8 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (11)
.storybook/preview.tsx:217
- A Loco API key is hard-coded as the default fallback. Even for Storybook, committing an API key to the repo is a credential leak risk and encourages accidental use in forks/CI. Default to an empty string (or undefined) and require developers to provide VITE_LOCO_API_KEY explicitly.
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
.storybook/preview.tsx:403
- Storybook defaults locoMode to "live", which will attempt to load a remote script and POST discovered phrases as soon as Storybook starts. For safety and reproducibility, default to "package" and require an explicit opt-in to live sync.
locoMode: 'live',
.storybook/preview.tsx:322
- The fallback server URL is a hard-coded internal IP (http://10.3.37.116:6199). This can leak internal infrastructure details and creates confusing defaults for external contributors. Prefer the existing defaultLocoServer value (or require explicit configuration) instead of an internal IP fallback.
const serverFromEnv =
(import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() ||
'http://10.3.37.116:6199';
const serverUrl = configuredServer || serverFromEnv;
src/components/Text/TextI18nIntegration.stories.tsx:52
- These example translation keys (ui.page., ui.actions., ui.status.*) do not exist in the checked-in sample Loco package (src/i18n/i18n-translations.json), so the story will always render raw keys instead of demonstrating translation. Update the story to use keys that exist in the sample package (or update the sample package to include these keys).
{t('ui.page.title')}
</Text>
<Text
variant="muted"
className={`transition-opacity duration-300 ${isLocaleChanging ? 'opacity-70' : 'opacity-100'}`}
src/components/Badge/Badge.stories.tsx:104
- These Badge stories now render i18n keys like "ui.badge.single" as the visible label. Since the bundled sample Loco package does not include ui.badge.* keys, this regresses Storybook readability (it will show raw keys). Until those keys exist in the sample pack, keep the original human-readable labels in the stories (or provide a dedicated i18n integration story).
export const Default: Story = {
render: (args, context) => {
const t = getTranslator(context);
return <Badge {...args}>{t('ui.badge.single')}</Badge>;
},
src/components/Badge/Badge.stories.tsx:113
- AllVariants currently uses ui.badge.variants.* keys as visible text, but those keys are not present in the sample Loco package, so the story will display raw keys. Prefer the original variant labels until the sample package actually contains translations for these values.
export const AllVariants: Story = {
render: (_, context) => {
const t = getTranslator(context);
return (
<div className="flex flex-wrap gap-2">
<Badge variant="default">{t('ui.badge.variants.default')}</Badge>
<Badge variant="secondary">{t('ui.badge.variants.secondary')}</Badge>
src/components/Badge/Badge.stories.tsx:130
- AllSizes uses ui.badge.sizes.* keys as visible text, but those keys are not present in the sample Loco package, so the story will display raw keys. Prefer the original size labels until translations exist.
export const AllSizes: Story = {
render: (_, context) => {
const t = getTranslator(context);
return (
<div className="flex items-center gap-2">
<Badge size="sm">{t('ui.badge.sizes.small')}</Badge>
<Badge size="md">{t('ui.badge.sizes.medium')}</Badge>
<Badge size="lg">{t('ui.badge.sizes.large')}</Badge>
src/components/Badge/Badge.stories.tsx:146
- WithIcon uses ui.badge.examples.new as visible text, but that key is not present in the sample Loco package, so the story will display a raw key. Prefer the original "New" label until translations exist.
export const WithIcon: Story = {
render: (args, context) => {
const t = getTranslator(context);
const IconComponent = args.iconName ? iconMap[args.iconName] : undefined;
return (
<Badge
{...args}
icon={IconComponent ? <IconComponent size={12} /> : undefined}
>
{t('ui.badge.examples.new')}
</Badge>
src/components/Badge/Badge.stories.tsx:164
- StatusExamples uses ui.badge.examples.* keys as visible text, but those keys are not present in the sample Loco package, so the story will display raw keys. Prefer the original status labels until translations exist.
export const StatusExamples: Story = {
render: (_, context) => {
const t = getTranslator(context);
return (
<div className="flex flex-wrap gap-2">
<Badge variant="success">{t('ui.badge.examples.active')}</Badge>
<Badge variant="warning">{t('ui.badge.examples.pending')}</Badge>
<Badge variant="danger">{t('ui.badge.examples.expired')}</Badge>
<Badge variant="secondary">{t('ui.badge.examples.draft')}</Badge>
</div>
src/components/CountBadge/CountBadge.stories.tsx:97
- Alert was converted to a render + translator, but the sample Loco package does not include an "eSign" entry, so this adds extra complexity without changing output. Prefer keeping this story as simple args-based configuration unless the translation key exists in the sample pack.
/** Alert variant (red). */
export const Alert: Story = {
render: (_, context) => {
const t = getTranslator(context);
return <CountBadge label={t('eSign')} count={7} variant="alert" />;
},
};
.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml:1
- This looks like a generated Playwright MCP snapshot/artifact (and in this case it even captures a 500 error payload). Committing these files will add noise and can leak internal runtime details; consider removing .playwright-mcp snapshots from the repo and adding .playwright-mcp/ to .gitignore instead.
- generic [active] [ref=e1]: "{\"statusCode\":500,\"error\":\"Internal Server Error\",\"message\":\"reply.sendFile is not a function\"}"
| export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] { | ||
| const collected = new Map<string, LocoKeyEntry>(); |
| export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] { | ||
| const collected = new Map<string, LocoKeyEntry>(); | ||
|
|
||
| const addPhrase = (raw: string) => { | ||
| const phrase = normalizeText(raw); | ||
| if (!isUsefulPhrase(phrase)) return; | ||
| if (collected.has(phrase)) return; | ||
| collected.set(phrase, { key: phrase, context: '' }); | ||
| }; | ||
|
|
||
| const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); |
| }, | ||
| }, | ||
| decorators: [withGitHubSource, withBrand, withCodeLookup], | ||
| decorators: [withGitHubSource, withBrand, withLocoLiveSync], |
| /** | ||
| * Demonstrates i18n integration with Loco package translations. | ||
| * Change the Locale toolbar value to see the modal title translate to French or Chinese. | ||
| */ |
| /** Alert variant with many items showing scroll behavior. */ | ||
| export const HoverMenuAlert: Story = { | ||
| render: () => ( | ||
| <CountBadge | ||
| label="eSign" | ||
| count={7} | ||
| variant="alert" | ||
| items={sampleEsigns} | ||
| onView={(item) => console.log('View:', item)} | ||
| onEdit={(item) => console.log('Edit:', item)} | ||
| onDelete={(item) => console.log('Delete:', item)} | ||
| /> | ||
| ), | ||
| render: (_, context) => { | ||
| const t = getTranslator(context); | ||
| const translatedItems = sampleEsigns.map((item) => ({ | ||
| ...item, | ||
| label: t(item.label), | ||
| })); | ||
|
|
||
| return ( |
| - generic [ref=e3]: | ||
| - banner [ref=e7]: | ||
| - heading "Storybook" [level=1] [ref=e8] | ||
| - generic [ref=e12]: | ||
| - generic [ref=e13]: |
| - generic [ref=f2e3]: | ||
| - banner [ref=f2e6]: | ||
| - heading "Storybook" [level=1] [ref=f2e7] | ||
| - generic [ref=f2e11]: | ||
| - generic [ref=f2e12]: |
| - generic [ref=e3]: | ||
| - banner [ref=e6]: | ||
| - heading "Storybook" [level=1] [ref=e7] | ||
| - generic [ref=e11]: | ||
| - generic [ref=e13]: |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 20 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (10)
.storybook/preview.tsx:218
- The Storybook preview hardcodes a fallback Loco API key. Committing an API key (even for dev) risks leaking credentials and also stores it in Storybook globals (often persisted / shareable). Default this to empty/undefined and require the key via VITE_LOCO_API_KEY or explicit toolbar input.
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true';
.storybook/preview.tsx:322
serverFromEnvfalls back to an internal IP address (10.x) when no env var is set. This can cause unexpected network calls for external contributors and will fail outside your network. PreferdefaultLocoServer(or require explicit configuration) instead of an internal default.
const serverFromEnv =
(import.meta.env?.VITE_LOCO_SERVER_URL as string | undefined)?.trim() ||
'http://10.3.37.116:6199';
const serverUrl = configuredServer || serverFromEnv;
.storybook/preview.tsx:405
initialGlobalsdefaultslocoModetolive, which will load a remote script and attempt to post discovered phrases as soon as Storybook loads. Consider defaulting topackageand letting developers opt into live sync explicitly from the toolbar.
locoMode: 'live',
locoServer: defaultLocoServer,
locoApiKey: defaultLocoApiKey,
.storybook/preview.tsx:533
- The CodeLookup Storybook decorator/provider was removed (decorators now only include withGitHubSource/withBrand/withLocoLiveSync). Many clinical components rely on an ambient CodeLookupProvider for their default coded-search demos; without it, those stories likely regress to plain-text behavior.
decorators: [withGitHubSource, withBrand, withLocoLiveSync],
src/components/Badge/Badge.stories.tsx:104
- These stories use dotted i18n keys (e.g.
ui.badge.single) that are not present in the included sample Loco package, so Storybook will render the raw key string. Provide a defaultValue so the story remains readable even when translations are missing.
render: (args, context) => {
const t = getTranslator(context);
return <Badge {...args}>{t('ui.badge.single')}</Badge>;
},
src/components/Text/TextI18nIntegration.stories.tsx:48
ui.page.titleis not present in the sample Loco package, so this will render the raw dotted key. Pass a defaultValue so the demo remains readable while still exercising the translation resolver.
{t('ui.page.title')}
src/components/Text/TextI18nIntegration.stories.tsx:54
ui.page.subtitleis not present in the sample Loco package, so this will render the raw dotted key. Pass a defaultValue so the demo remains readable while still exercising the translation resolver.
{t('ui.page.subtitle')}
src/i18n/i18n-translations.json:16
- AppHeaderI18nIntegration uses keys like
AddContactModalandDescription, but the sample Loco package doesn’t include them, so the story will render raw keys. Add them to the sample package so the integration story actually demonstrates translation.
"Add Contact": "Add Contact",
"Edit Contact": "Edit Contact",
"Cancel": "Cancel",
"Save": "Save",
"Address": "Address",
src/i18n/i18n-translations.json:33
- The sample
zh-Hanstranslations are also missing keys referenced by AppHeaderI18nIntegration (AddContactModal,Description), so those values won’t translate in Chinese. Add matching entries so switching locales produces visible changes.
"Add Contact": "添加联系人",
"Edit Contact": "编辑联系人",
"Cancel": "取消",
"Save": "保存",
"Address": "地址",
.playwright-mcp/page-2026-07-23T18-38-53-595Z.yml:3
- These
.playwright-mcp/page-*.ymlsnapshots look like local/agent artifacts rather than source-controlled assets. They add noise (and can include incidental UI content). Consider removing them from the PR and adding.playwright-mcp/to.gitignoreif it isn’t meant to be committed.
- generic [ref=e3]:
- banner [ref=e6]:
- heading "Storybook" [level=1] [ref=e7]
| { value: 'en', title: 'English (en)' }, | ||
| { value: 'fr', title: 'French (fr)' }, | ||
| { value: 'zh-Hans', title: 'Chinese (zh-Hans)' }, |
| function isUsefulPhrase(value: string): boolean { | ||
| if (!value) return false; | ||
| if (value.length < 2 || value.length > 180) return false; | ||
| if (/^[\d\s.,:%+-/()]+$/.test(value)) return false; | ||
| return true; | ||
| } |
… export format - Serve exported Loco pack via Storybook staticDirs and initialize the Loco runtime in file mode for 'Loco i18n' (package) toolbar mode, so all stories/docs translate at DOM level without per-story wiring - Change canonical pack format from i18next-nested to native 'loco' export format; support both shapes in getLocoDictionary (+ test) - Manager addon: hide locale switcher when Loco is disabled and reset locale to English whenever the Loco mode changes - Default locoMode to 'package'; reload preview once on file<->API mode switches (runtime is a singleton) - loco:pack:sync reads .env.local for server URL/API key - Remove redundant manual I18nTranslated story; fix hooks-in-render lint errors in i18n integration stories; add NodeFilter/Text globals - Regenerate src/i18n/i18n-translations.json with zh-Hans translations - Ignore .playwright-mcp/ test artifacts
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 23 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (8)
.storybook/preview.tsx:263
defaultLocoApiKeyis hard-coded to a real-looking API key. This effectively publishes a credential in the repo and also sets it as a Storybook global by default. Prefer leaving the default empty/undefined and requiring VITE_LOCO_API_KEY (or an explicit toolbar/URL override) when live sync is needed.
// Default Loco configuration from environment variables.
// Falls back to the shared hosted Loco instance when env values are absent.
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true';
src/components/Text/TextI18nIntegration.stories.tsx:46
- This story uses dotted translation keys (e.g.
ui.page.title) that do not exist in the committed sample Loco pack (src/i18n/i18n-translations.json), so the translator will fall back to rendering the key strings instead of demonstrating translations. Use phrase keys that are present in the sample pack (e.g. "Edit Contact", "Description", "Save Contact") or provide appropriate default values.
<Text
as="h3"
size="xl"
weight="bold"
className={`transition-all duration-300 ${isLocaleChanging ? 'translate-y-[1px] opacity-60' : 'translate-y-0 opacity-100'}`}
src/components/Badge/Badge.stories.tsx:106
- The sample translator falls back to returning the key when no translation exists. Since the sample pack currently does not include
ui.badge.single, this renders asui.badge.singlein the UI. Provide a human-readable default so the story remains readable even when the pack is missing a key.
render: (args, context) => {
const t = getTranslator(context);
return <Badge {...args}>{t('ui.badge.single')}</Badge>;
},
src/components/Badge/Badge.stories.tsx:118
- These story labels use keys like
ui.badge.variants.*, but the committed sample pack doesn’t currently contain them, so the UI will show the raw dotted keys. Provide readable default values so the story doesn’t regress when translations are missing.
<div className="flex flex-wrap gap-2">
<Badge variant="default">{t('ui.badge.variants.default')}</Badge>
<Badge variant="secondary">{t('ui.badge.variants.secondary')}</Badge>
<Badge variant="success">{t('ui.badge.variants.success')}</Badge>
<Badge variant="warning">{t('ui.badge.variants.warning')}</Badge>
<Badge variant="danger">{t('ui.badge.variants.danger')}</Badge>
src/components/Badge/Badge.stories.tsx:133
- Same issue as variants: the sample pack doesn’t include these
ui.badge.sizes.*keys, so the story will render the raw dotted key strings. Provide human-readable defaults.
<div className="flex items-center gap-2">
<Badge size="sm">{t('ui.badge.sizes.small')}</Badge>
<Badge size="md">{t('ui.badge.sizes.medium')}</Badge>
<Badge size="lg">{t('ui.badge.sizes.large')}</Badge>
</div>
src/components/Badge/Badge.stories.tsx:148
- If
ui.badge.examples.newisn’t present in the sample pack, the badge will display the dotted key. Providing a default value keeps the story readable while still allowing translations when the key exists.
<Badge
{...args}
icon={IconComponent ? <IconComponent size={12} /> : undefined}
>
{t('ui.badge.examples.new')}
</Badge>
src/components/Badge/Badge.stories.tsx:166
- These example labels currently fall back to rendering dotted keys when translations are missing from the sample pack. Provide readable defaults so the story remains understandable.
<div className="flex flex-wrap gap-2">
<Badge variant="success">{t('ui.badge.examples.active')}</Badge>
<Badge variant="warning">{t('ui.badge.examples.pending')}</Badge>
<Badge variant="danger">{t('ui.badge.examples.expired')}</Badge>
<Badge variant="secondary">{t('ui.badge.examples.draft')}</Badge>
</div>
.playwright-mcp/page-2026-07-17T21-40-43-850Z.yml:1
- This looks like a local Playwright/MCP snapshot artifact (and even contains a transient 500 error payload). These files add noise to the repo and are already covered by the new
.playwright-mcp/gitignore entry; please remove them from the PR.
- generic [active] [ref=e1]: "{\"statusCode\":500,\"error\":\"Internal Server Error\",\"message\":\"reply.sendFile is not a function\"}"
Package mode previously loaded the runtime script from the Loco server (/cdn/loco.js), so translations broke whenever the server was down even though the translation pack is committed to the repo. Vendor the built loco.min.js (includes the _applyInFlight deadlock fix) into src/i18n/ and serve it via staticDirs. Live mode still loads the runtime from the Loco server it syncs with (default: https://loco.os.mieweb.org).
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 24 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (7)
.storybook/preview.tsx:273
- Defaulting VITE_LOCO_API_KEY to a hard-coded value commits an API key into the repo and also exposes it via Storybook globals (initialGlobals). This should be treated as a secret/configuration value and not have an in-repo fallback; default to an empty string and rely on .env.local / toolbar overrides.
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
const isLocoDisabled = (import.meta.env.VITE_DISABLE_LOCO as string | undefined)?.trim() === 'true';
.storybook/preview.tsx:452
- This decorator replaced the previous CodeLookupProvider wrapper entirely. Per src/components/CodeLookup/context.tsx, clinical components will now fall back to plain text instead of coded search when no provider is mounted, which changes Storybook behavior. If the coded-search demo is still desired, consider composing CodeLookupProvider back in alongside the Loco wrapper rather than removing it.
return (
<div data-loco-scan-root="true">
<Story />
</div>
);
src/utils/loco-live.ts:46
- collectLocoKeysFromElement assumes a global
documentexists. Since this module is exported from@mieweb/ui/utils, consumers could call it in SSR / non-DOM environments and get a ReferenceError. Consider usingroot.ownerDocumentand returning an empty list when no document is available.
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
src/components/Text/TextI18nIntegration.stories.tsx:48
- The sample pack committed at src/i18n/i18n-translations.json does not contain the key
ui.page.title(it’s a phrase-key loco export). As written, this heading will render the raw key string and won’t demonstrate translation behavior.
{t('ui.page.title')}
src/components/Text/TextI18nIntegration.stories.tsx:54
- The sample pack committed at src/i18n/i18n-translations.json does not contain the key
ui.page.subtitle, so this will render the raw key string. Consider using a phrase key that exists in the pack (or pass a defaultValue) so the story renders meaningful English and demonstrates zh-Hans translation.
{t('ui.page.subtitle')}
src/components/Text/TextI18nIntegration.stories.tsx:62
- These example translations use dotted keys that are not present in the committed loco sample pack, so the story will just show the raw keys. Consider using phrase keys that exist in the sample pack so switching to zh-Hans visibly changes the output (and still shows English for en/fr).
<Text size="sm">ui.actions.save => {t('ui.actions.save')}</Text>
<Text size="sm">ui.actions.cancel => {t('ui.actions.cancel')}</Text>
<Text size="sm">ui.status.ready => {t('ui.status.ready')}</Text>
<Text size="sm">ui.unknown.key => {t('ui.unknown.key')}</Text>
</div>
src/components/Badge/Badge.stories.tsx:105
- The committed loco sample pack doesn’t contain
ui.badge.single(or the otherui.badge.*keys below), so the story will render the raw dotted key strings. If you want these stories to remain readable without a matching pack, pass a defaultValue so the UI falls back to the intended English label.
return <Badge {...args}>{t('ui.badge.single')}</Badge>;
| const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, ''); | ||
| const apiKey = args.apiKey || process.env.LOCO_API_KEY || ''; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 18 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (8)
.storybook/preview.tsx:272
defaultLocoApiKeyfalls back to a hard-coded API key in the client bundle, which effectively publishes the key to anyone running Storybook. Avoid shipping a real API key as a default; prefer an empty/undefined default and require explicit configuration via env or URL globals.
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
scripts/sync-loco-pack.mjs:41
- This script reads
LOCO_SERVER_URL/LOCO_API_KEY, but the PR description +loco:pack:sync(--env-file-if-exists=.env.local) indicate configuration viaVITE_LOCO_SERVER_URL/VITE_LOCO_API_KEY. As-is,pnpm loco:pack:syncwill ignore the.env.localvariables unless they’re duplicated under theLOCO_names.
const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, '');
const apiKey = args.apiKey || process.env.LOCO_API_KEY || '';
src/utils/i18n.ts:97
resolveLocoTranslationtreats an empty-string translation as “missing” because it checksif (nested)rather thannested !== undefined. If a translation legitimately resolves to"", the function will incorrectly fall back to another language or the key.
const nested = resolveDottedValue(dictionary, key);
if (nested) return nested;
src/components/Text/TextI18nIntegration.stories.tsx:52
- This story looks up keys like
ui.page.title/ui.actions.save, but the committedi18n-translations.jsonpack is in the native Loco “phrase key” format and does not contain those dotted keys (so the UI will render the key strings). Use keys that exist in the pack, or providedefaultValues / a dedicated sample pack for dotted-key demos.
{t('ui.page.title')}
</Text>
<Text
variant="muted"
className={`transition-opacity duration-300 ${isLocaleChanging ? 'opacity-70' : 'opacity-100'}`}
src/components/Badge/Badge.stories.tsx:106
- These stories now call
t('ui.badge...')against the committed Loco pack, but that pack doesn’t include those keys, so Storybook will render the raw key strings (e.g.ui.badge.variants.default). Passing adefaultValuekeeps the stories readable while still allowing real translations when/if the keys exist in a pack.
export const Default: Story = {
render: (args, context) => {
const t = getTranslator(context);
return <Badge {...args}>{t('ui.badge.single')}</Badge>;
},
src/components/Badge/Badge.stories.tsx:133
- Same issue in the size variants: without
defaultValues these render as raw i18n keys when the pack doesn’t containui.badge.sizes.*.
<div className="flex items-center gap-2">
<Badge size="sm">{t('ui.badge.sizes.small')}</Badge>
<Badge size="md">{t('ui.badge.sizes.medium')}</Badge>
<Badge size="lg">{t('ui.badge.sizes.large')}</Badge>
</div>
src/components/Badge/Badge.stories.tsx:147
- Without a
defaultValue, this will render the raw key (ui.badge.examples.new) when the committed pack doesn’t include that key.
{t('ui.badge.examples.new')}
src/components/Badge/Badge.stories.tsx:165
- These status examples also need
defaultValues; otherwise the badges render the i18n key strings if translations aren’t present in the pack.
<Badge variant="success">{t('ui.badge.examples.active')}</Badge>
<Badge variant="warning">{t('ui.badge.examples.pending')}</Badge>
<Badge variant="danger">{t('ui.badge.examples.expired')}</Badge>
<Badge variant="secondary">{t('ui.badge.examples.draft')}</Badge>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 18 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (2)
src/utils/i18n.ts:96
- resolveLocoTranslation() treats dotted-key lookups as truthy checks ("if (nested)"). If a translation legitimately resolves to an empty string, it will incorrectly fall through to fallback/key behavior. This should check for undefined instead of truthiness so empty-string translations are preserved.
const nested = resolveDottedValue(dictionary, key);
if (nested) return nested;
scripts/sync-loco-pack.mjs:41
- scripts/sync-loco-pack.mjs reads LOCO_SERVER_URL / LOCO_API_KEY, but the Storybook integration and PR description use VITE_LOCO_SERVER_URL / VITE_LOCO_API_KEY from .env.local. As-is,
pnpm loco:pack:syncwon’t pick up the same .env.local values unless users duplicate variables under LOCO_ names. Consider supporting the VITE_ names here as fallbacks so the workflow truly shares one config source.
const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, '');
const apiKey = args.apiKey || process.env.LOCO_API_KEY || '';
| const signature = `${context.id}:${serverUrl}:${keys.map((entry) => entry.key).join('|')}`; | ||
| if (postedLiveSyncSignatures.has(signature)) return; | ||
| postedLiveSyncSignatures.add(signature); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 18 changed files in this pull request and generated 1 comment.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (1)
.storybook/preview.tsx:633
- Avoid putting
VITE_LOCO_API_KEYinto Storybook globals (initialGlobals.locoApiKey). Storybook persists globals into the URL; this makes it easy to leak the API key by copying a Storybook link or publishing a static build that was built with the env var set.
Prefer reading the API key from import.meta.env (or a local-only prompt) inside the decorator without storing it in globals, and keep locoApiKey out of the initial globals state.
locoMode: 'package',
locoServer: defaultLocoServer,
locoApiKey: defaultLocoApiKey,
| "test:watch": "vitest", | ||
| "prestorybook": "npm run build:esheet", | ||
| "storybook": "storybook dev -p 6006", | ||
| "loco:pack:sync": "node --env-file-if-exists=.env.local scripts/sync-loco-pack.mjs", |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 18 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (2)
.storybook/preview.tsx:521
defaultLocoApiKeyfalls back to a hard-coded API key. That bakes credentials into the repo/Storybook bundle and makes it easy to accidentally use (or leak) the key in forks, CI logs, etc. Default should be empty/undefined and only sourced from environment variables or explicit URL params.
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
const defaultLocoProject = (import.meta.env.VITE_LOCO_PROJECT_NAME as string | undefined)?.trim() || 'miewebui';
.storybook/preview.tsx:799
- The Storybook decorators no longer mount a
CodeLookupProvider. Several clinical components explicitly document that they default to the ambientCodeLookupProviderwhen present (e.g.src/components/CodeLookup/context.tsxandMedicationReconciliationdocs), so removing it changes Storybook behavior and may silently drop coded-search functionality in stories that don’t passcodeLookupexplicitly.
return (
<div data-loco-scan-root="true">
<Story />
</div>
);
| try { | ||
| const projectsResponse = await fetch(`${baseUrl}/api/projects`, { | ||
| method: 'GET', | ||
| headers: { | ||
| 'Content-Type': 'application/json', |
| style.textContent = ` | ||
| [role="toolbar"] button[aria-label^="Locale used by i18n"] { | ||
| display: none !important; | ||
| } | ||
| `; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 18 changed files in this pull request and generated 2 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (3)
.storybook/preview.tsx:513
- Hard-coding a Loco API key in Storybook preview code will ship that credential in the built Storybook bundle and makes it easy to accidentally reuse in other environments. This should come only from environment variables / local overrides, and default to an empty value when unset.
const defaultLocoServer = (import.meta.env.VITE_LOCO_SERVER_URL as string | undefined)?.trim() || 'https://loco.os.mieweb.org';
const defaultLocoApiKey = (import.meta.env.VITE_LOCO_API_KEY as string | undefined)?.trim() || '84ad26c4d9934e638f206ae8';
const defaultLocoProject = (import.meta.env.VITE_LOCO_PROJECT_NAME as string | undefined)?.trim() || 'miewebui';
package.json:225
node --env-file-if-existsis not supported in Node 20.x (this repo supports Node ^20.19.0 per the engines field), so this script will fail for contributors/CI running Node 20. Prefer loading.env.localinside the script (or use a cross-version approach) and run the script directly.
"loco:pack:sync": "node --env-file-if-exists=.env.local scripts/sync-loco-pack.mjs",
.storybook/preview.tsx:224
resolveLiveApiKeyattempts to discover and return anapi_keyby calling${server}/api/projects(and${server}/api/project) without any credentials. If these endpoints ever return real API keys, this leaks credentials to any browser running Storybook (including a static Storybook build) and undermines the expectation that API keys must be provided via.env.local/query params.
try {
const projectsResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
| @@ -0,0 +1,84 @@ | |||
| import { writeFile } from 'node:fs/promises'; | |||
| async function main() { | ||
| const args = parseArgs(process.argv.slice(2)); | ||
| if (args.help === 'true') { | ||
| printHelp(); | ||
| return; | ||
| } | ||
| const server = ( | ||
| args.server || | ||
| process.env.LOCO_SERVER_URL || | ||
| process.env.VITE_LOCO_SERVER_URL || | ||
| 'https://loco.os.mieweb.org' | ||
| ).replace(/\/$/, ''); | ||
| const apiKey = | ||
| args.apiKey || | ||
| process.env.LOCO_API_KEY || | ||
| process.env.VITE_LOCO_API_KEY || | ||
| ''; |
Summary
Integrates Loco i18n into Storybook with three toolbar modes: Loco i18n (offline package/file mode — default), Loco Sync Text (live API mode that syncs phrases to the Loco server), and Disable. Package mode now translates every story and docs page at the DOM level via the Loco runtime's file mode — no per-story wiring needed.
What Changed
staticDirsat/i18n/i18n-translations.jsonLoco.init({ file })and applies the selected locale to the whole DOM (stories + docs)locoexport format (the only format the runtime's file mode accepts).env.local(VITE_LOCO_SERVER_URL,VITE_LOCO_API_KEY);pnpm loco:pack:syncreads the same filegetLocoDictionarysupports bothresourcesand nativetranslationspack shapes; dotted-key lookup and fallback-language translator (+ unit tests, 5/5 passing)I18nTranslatedAddContactModal story (package mode covers it); fixed hooks-in-render lint errors in integration stories; regenerated the pack with zh-Hans translationsCompanion fix (Loco repo)
The Loco runtime had a deadlock:
Loco.apply()in file mode leaked its_applyInFlightguard on early returns, permanently blocking subsequent language switches. Fixed inclient/src/index.jsand rebuiltpublic/loco.js/loco.min.js(separate commit in the loco repo).Workflow
pnpm loco:pack:syncto export the updated packValidation
pnpm typecheck,pnpm lint,pnpm format:fix, and i18n unit tests all pass