Skip to content

feat(i18n): add Loco package integration and Storybook live sync#289

Open
HrithikMani wants to merge 13 commits into
mainfrom
feat/loco-i18n-package-integration
Open

feat(i18n): add Loco package integration and Storybook live sync#289
HrithikMani wants to merge 13 commits into
mainfrom
feat/loco-i18n-package-integration

Conversation

@HrithikMani

@HrithikMani HrithikMani commented Jun 29, 2026

Copy link
Copy Markdown

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

  1. Package mode (Loco i18n) now actually works end-to-end:
    • Exported Loco pack is served via Storybook staticDirs at /i18n/i18n-translations.json
    • Preview initializes the Loco runtime with Loco.init({ file }) and applies the selected locale to the whole DOM (stories + docs)
    • Canonical pack format switched from i18next-nested to Loco's native loco export format (the only format the runtime's file mode accepts)
  2. Live mode (Loco Sync Text):
    • Initializes the runtime in API mode, posts discovered text nodes to the Loco pending list, and applies approved translations live
    • Server URL / API key configurable via .env.local (VITE_LOCO_SERVER_URL, VITE_LOCO_API_KEY); pnpm loco:pack:sync reads the same file
  3. Toolbar UX (manager addon):
    • Locale switcher hides when Loco mode is Disabled
    • Locale resets to English whenever the Loco mode changes
    • Preview reloads once when switching between file and API modes (the runtime is a singleton)
  4. i18n utility layer: getLocoDictionary supports both resources and native translations pack shapes; dotted-key lookup and fallback-language translator (+ unit tests, 5/5 passing)
  5. Cleanup: removed the redundant manual I18nTranslated AddContactModal story (package mode covers it); fixed hooks-in-render lint errors in integration stories; regenerated the pack with zh-Hans translations

Companion fix (Loco repo)

The Loco runtime had a deadlock: Loco.apply() in file mode leaked its _applyInFlight guard on early returns, permanently blocking subsequent language switches. Fixed in client/src/index.js and rebuilt public/loco.js / loco.min.js (separate commit in the loco repo).

Workflow

  1. Use Loco Sync Text mode to push new UI phrases to the Loco server
  2. Translate/approve them in Loco
  3. Run pnpm loco:pack:sync to export the updated pack
  4. Loco i18n mode translates everything offline from that file

Validation

  • Browser-tested all three modes: package mode translates full AddContactModal docs page to zh-Hans; rapid locale cycling works (previously deadlocked); Disable restores English and hides the switcher; live mode applies server translations
  • pnpm typecheck, pnpm lint, pnpm format:fix, and i18n unit tests all pass

Copilot AI review requested due to automatic review settings June 29, 2026 15:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread .storybook/preview.tsx
Comment on lines 392 to 401
const preview: Preview = {
initialGlobals: {
brand: 'bluehive',
theme: 'light',
density: 'standard',
locale: 'en',
locoMode: 'package',
locoServer: 'http://localhost:6101',
locoApiKey: '82b6c1a44ec247dcb6c96fe0',
},
Comment thread src/utils/i18n.test.ts
Comment on lines +49 to +52
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');
});
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying ui with  Cloudflare Pages  Cloudflare Pages

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

View logs

- 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
Copilot AI review requested due to automatic review settings July 23, 2026 18:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.title exists in the active fr dictionary so the fallback language is never consulted. Adjust the fixture (or mutate a clone) so the key is missing in fr but present in en, 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');
  });

Comment thread .storybook/preview.tsx Outdated
Comment thread .storybook/preview.tsx
Comment thread scripts/sync-loco-pack.mjs Outdated
console.log(`Usage: node scripts/sync-loco-pack.mjs [options]

Options:
--server=<url> Loco server URL (default: http://10.3.37.116:6199/loco)
Comment thread src/i18n/i18n-translations.json Outdated
Comment on lines +4 to +12
"languages": [
"zh-Hans"
],
"languageNames": {
"zh-Hans": "Chinese (Simplified)"
},
"resources": {
"zh-Hans": {
"translation": {
Copilot AI review requested due to automatic review settings July 23, 2026 18:24

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =&gt; {t('ui.actions.save')}</Text>
          <Text size="sm">ui.actions.cancel =&gt; {t('ui.actions.cancel')}</Text>
          <Text size="sm">ui.status.ready =&gt; {t('ui.status.ready')}</Text>
          <Text size="sm">ui.unknown.key =&gt; {t('ui.unknown.key')}</Text>

Comment thread src/utils/i18n.ts
Comment on lines +62 to +64
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')}
Comment on lines +83 to +87
<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'}`}
Comment on lines +101 to 105
render: (args, context) => {
const t = getTranslator(context);
return <Badge {...args}>{t('ui.badge.single')}</Badge>;
},
};
Comment on lines +93 to 96
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
Copilot AI review requested due to automatic review settings July 23, 2026 18:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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\"}"

Comment thread src/utils/loco-live.ts
Comment on lines +36 to +37
export function collectLocoKeysFromElement(root: HTMLElement): LocoKeyEntry[] {
const collected = new Map<string, LocoKeyEntry>();
Comment thread src/utils/loco-live.ts Outdated
Comment on lines +36 to +46
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);
Comment thread .storybook/preview.tsx
},
},
decorators: [withGitHubSource, withBrand, withCodeLookup],
decorators: [withGitHubSource, withBrand, withLocoLiveSync],
Comment on lines +219 to +222
/**
* Demonstrates i18n integration with Loco package translations.
* Change the Locale toolbar value to see the modal title translate to French or Chinese.
*/
Comment on lines 222 to +231
/** 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 (
Comment on lines +1 to +5
- generic [ref=e3]:
- banner [ref=e7]:
- heading "Storybook" [level=1] [ref=e8]
- generic [ref=e12]:
- generic [ref=e13]:
Comment on lines +1 to +5
- generic [ref=f2e3]:
- banner [ref=f2e6]:
- heading "Storybook" [level=1] [ref=f2e7]
- generic [ref=f2e11]:
- generic [ref=f2e12]:
Comment on lines +1 to +5
- generic [ref=e3]:
- banner [ref=e6]:
- heading "Storybook" [level=1] [ref=e7]
- generic [ref=e11]:
- generic [ref=e13]:
Copilot AI review requested due to automatic review settings July 23, 2026 18:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • serverFromEnv falls 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. Prefer defaultLocoServer (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

  • initialGlobals defaults locoMode to live, which will load a remote script and attempt to post discovered phrases as soon as Storybook loads. Consider defaulting to package and 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.title is 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.subtitle is 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 AddContactModal and Description, 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-Hans translations 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-*.yml snapshots 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 .gitignore if it isn’t meant to be committed.
- generic [ref=e3]:
  - banner [ref=e6]:
    - heading "Storybook" [level=1] [ref=e7]

Comment thread .storybook/preview.tsx Outdated
Comment on lines +456 to +458
{ value: 'en', title: 'English (en)' },
{ value: 'fr', title: 'French (fr)' },
{ value: 'zh-Hans', title: 'Chinese (zh-Hans)' },
Comment thread src/utils/loco-live.ts
Comment on lines +29 to +34
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
Copilot AI review requested due to automatic review settings July 23, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • defaultLocoApiKey is 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 as ui.badge.single in 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.new isn’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).
Copilot AI review requested due to automatic review settings July 23, 2026 22:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 document exists. Since this module is exported from @mieweb/ui/utils, consumers could call it in SSR / non-DOM environments and get a ReferenceError. Consider using root.ownerDocument and 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 =&gt; {t('ui.actions.save')}</Text>
        <Text size="sm">ui.actions.cancel =&gt; {t('ui.actions.cancel')}</Text>
        <Text size="sm">ui.status.ready =&gt; {t('ui.status.ready')}</Text>
        <Text size="sm">ui.unknown.key =&gt; {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 other ui.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>;

Comment thread scripts/sync-loco-pack.mjs Outdated
Comment on lines +40 to +41
const server = (args.server || process.env.LOCO_SERVER_URL || 'https://loco.os.mieweb.org').replace(/\/$/, '');
const apiKey = args.apiKey || process.env.LOCO_API_KEY || '';
Copilot AI review requested due to automatic review settings July 23, 2026 23:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • defaultLocoApiKey falls 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 via VITE_LOCO_SERVER_URL / VITE_LOCO_API_KEY. As-is, pnpm loco:pack:sync will ignore the .env.local variables unless they’re duplicated under the LOCO_ 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

  • resolveLocoTranslation treats an empty-string translation as “missing” because it checks if (nested) rather than nested !== 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 committed i18n-translations.json pack 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 provide defaultValues / 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 a defaultValue keeps 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 contain ui.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>

Copilot AI review requested due to automatic review settings July 24, 2026 19:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:sync won’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 || '';

Comment thread .storybook/preview.tsx
Comment thread .storybook/preview.tsx
Comment on lines +556 to +558
const signature = `${context.id}:${serverUrl}:${keys.map((entry) => entry.key).join('|')}`;
if (postedLiveSyncSignatures.has(signature)) return;
postedLiveSyncSignatures.add(signature);
Copilot AI review requested due to automatic review settings July 24, 2026 20:06

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_KEY into 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,

Comment thread package.json
"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",
Copilot AI review requested due to automatic review settings July 24, 2026 23:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • defaultLocoApiKey falls 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 ambient CodeLookupProvider when present (e.g. src/components/CodeLookup/context.tsx and MedicationReconciliation docs), so removing it changes Storybook behavior and may silently drop coded-search functionality in stories that don’t pass codeLookup explicitly.
  return (
    <div data-loco-scan-root="true">
      <Story />
    </div>
  );

Comment thread .storybook/preview.tsx
Comment on lines +228 to +232
try {
const projectsResponse = await fetch(`${baseUrl}/api/projects`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Comment thread .storybook/manager.ts
Comment on lines +454 to +458
style.textContent = `
[role="toolbar"] button[aria-label^="Locale used by i18n"] {
display: none !important;
}
`;
Copilot AI review requested due to automatic review settings July 25, 2026 00:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-exists is 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.local inside 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

  • resolveLiveApiKey attempts to discover and return an api_key by 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';
Comment on lines +36 to +52
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 ||
'';
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants