diff --git a/apps/web/src/components/licenseCard/cardTheme.test.ts b/apps/web/src/components/licenseCard/cardTheme.test.ts new file mode 100644 index 0000000..e3681cd --- /dev/null +++ b/apps/web/src/components/licenseCard/cardTheme.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { translations } from "@eventer/shared/i18n"; +import { BG_VARIANTS, CARD_THEMES } from "./cardTheme.js"; + +/** + * 見た目の選択肢の**呼び名が辞書から来ている**ことの見張り (#475)。 + * + * 元は「ロゼット」「インディゴ」がカタログに直に書いてあり、英語で見ている人にも + * 日本語のチップが出ていた。直っても、次に選択肢を足す人が同じ形で + * `label: "ネオン"` と書けば静かに戻る。**型はキーの綴りしか見ない**ので、 + * ここが拾うのは型で拾えない2つ: + * + * 1. カタログに表示用の文字列そのものが戻ってくる(`label` / `name` の復活) + * 2. 引き先のキーが片方の言語にしか無い(英語だけ・日本語だけ足した) + * + * 実際にチップへ訳が出るか(画面まで繋がっているか)は + * `pages/LicenseCardPage.test.tsx` が英語に切り替えて確かめる。 + */ + +/** カタログの各項目が持ってよいフィールド。ここに無いものが増えたら止める */ +const BG_FIELDS = ["key", "labelKey"]; +const THEME_FIELDS = [ + "key", + "nameKey", + "paper", + "accentA", + "accentB", + "accentBLight", + "accentDeep", + "watermark", +]; + +/** 選択肢が引く辞書キー("profile.xxx" の形) */ +const LABEL_KEYS = [ + ...BG_VARIANTS.map((v) => v.labelKey), + ...CARD_THEMES.map((t) => t.nameKey), +]; + +describe("カードの見た目の呼び名 (#475)", () => { + it("カタログは表示用の文字列を持たない", () => { + for (const v of BG_VARIANTS) { + expect(Object.keys(v).sort(), v.key).toEqual([...BG_FIELDS].sort()); + } + for (const t of CARD_THEMES) { + expect(Object.keys(t).sort(), t.key).toEqual([...THEME_FIELDS].sort()); + } + }); + + it("引く先のキーは日本語と英語の両方にある", () => { + // 型(en: Record)は「両方に同じキーがある」ことしか + // 守れない。キーごと辞書から消えた場合はここで落ちる + for (const key of LABEL_KEYS) { + const name = key.slice("profile.".length) as keyof typeof translations.ja.profile; + for (const lang of ["ja", "en"] as const) { + const text = translations[lang].profile[name]; + expect(typeof text, `${lang} ${key}`).toBe("string"); + expect(text, `${lang} ${key}`).not.toBe(""); + } + } + }); + + it("選択肢の数だけ呼び名があり、使い回していない", () => { + expect(LABEL_KEYS.length).toBe(BG_VARIANTS.length + CARD_THEMES.length); + expect(new Set(LABEL_KEYS).size).toBe(LABEL_KEYS.length); + // 同じ訳文を2つの選択肢に当てると、ピッカーで見分けがつかない + for (const lang of ["ja", "en"] as const) { + const texts = LABEL_KEYS.map( + (k) => + translations[lang].profile[ + k.slice("profile.".length) as keyof typeof translations.ja.profile + ], + ); + expect(new Set(texts).size, lang).toBe(texts.length); + } + }); +}); diff --git a/apps/web/src/components/licenseCard/cardTheme.ts b/apps/web/src/components/licenseCard/cardTheme.ts index f0ab5d5..499bc5a 100644 --- a/apps/web/src/components/licenseCard/cardTheme.ts +++ b/apps/web/src/components/licenseCard/cardTheme.ts @@ -6,7 +6,19 @@ * 以前は描画コンポーネントのファイルに同居していたため、色の一覧が欲しいだけの * `cardLook.ts` が React コンポーネントを import していた (#466 で切り出し)。 * - * 値はすべて承認済みモックアップ type-T1.svg 由来。 */ + * 値はすべて承認済みモックアップ type-T1.svg 由来。 + * + * 選択肢の**呼び名はここに書かない** (#475)。表示名は辞書 + * (`packages/shared/src/i18n/messages/profile.ts`) が持ち、ここが持つのは + * その引き先のキーだけ。日本語を直に書くと英語で見ている人に日本語が出る。 */ +import type { TranslationResource } from "@eventer/shared/i18n"; + +/** 見た目の選択肢に付けられる辞書キー。 + * 辞書に無いキーも、カード以外のキーも、ここで型が落とす */ +export type CardLookLabelKey = Extract< + `profile.${Extract}`, + `profile.cardBg${string}` | `profile.cardTheme${string}` +>; /** 見出し系フォント。モックの Avenir Next はmacOS限定のため、 * アプリ同梱の Plus Jakarta Sans(700/600)で置き換える */ @@ -21,11 +33,11 @@ export const INK_FAINT = "#6B7499"; /** 背景パターンの選択肢。rosette がデフォルト(type-T1.svg 本体の背景) */ export const BG_VARIANTS = [ - { key: "rosette", label: "ロゼット" }, - { key: "topo", label: "等高線" }, - { key: "arcs", label: "円弧" }, - { key: "flow", label: "流線" }, -] as const; + { key: "rosette", labelKey: "profile.cardBgRosette" }, + { key: "topo", labelKey: "profile.cardBgTopo" }, + { key: "arcs", labelKey: "profile.cardBgArcs" }, + { key: "flow", labelKey: "profile.cardBgFlow" }, +] as const satisfies readonly { key: string; labelKey: CardLookLabelKey }[]; export type CardBgVariant = (typeof BG_VARIANTS)[number]["key"]; /** カード配色テーマ。 @@ -38,7 +50,6 @@ export type CardBgVariant = (typeof BG_VARIANTS)[number]["key"]; * インク(文字)色とバッジの金色はテーマ間で共通 */ export interface CardTheme { key: string; - name: string; paper: readonly [string, string, string]; accentA: string; accentB: string; @@ -47,11 +58,17 @@ export interface CardTheme { watermark: string; } +/** 配色テーマの選択肢。`CardTheme`(描画に要る色)に、ピッカーが引く + * 辞書キーを足したもの。描く側は色しか見ないので `CardTheme` には入れない */ +export interface CardThemeChoice extends CardTheme { + nameKey: CardLookLabelKey; +} + /** 配色テーマの選択肢。indigo がデフォルト(従来配色そのまま) */ export const CARD_THEMES = [ { key: "indigo", - name: "インディゴ", + nameKey: "profile.cardThemeIndigo", paper: ["#C9D2EC", "#DFE5F6", "#BFC9E8"], accentA: "#4F46E5", accentB: "#0EA5A0", @@ -61,7 +78,7 @@ export const CARD_THEMES = [ }, { key: "teal", - name: "ティール", + nameKey: "profile.cardThemeTeal", paper: ["#C7E4E0", "#E0F3F0", "#BBDCD7"], accentA: "#0D9488", accentB: "#4F46E5", @@ -71,7 +88,7 @@ export const CARD_THEMES = [ }, { key: "rose", - name: "ローズ", + nameKey: "profile.cardThemeRose", paper: ["#EED2DE", "#F8E6EE", "#E5C4D4"], accentA: "#DB2777", accentB: "#7C3AED", @@ -81,7 +98,7 @@ export const CARD_THEMES = [ }, { key: "amber", - name: "アンバー", + nameKey: "profile.cardThemeAmber", paper: ["#EBDFC7", "#F6EEDC", "#E2D4B8"], accentA: "#B45309", accentB: "#4F46E5", @@ -91,7 +108,7 @@ export const CARD_THEMES = [ }, { key: "mono", - name: "モノクロ", + nameKey: "profile.cardThemeMono", paper: ["#D9DDE4", "#EDF0F4", "#CDD2DA"], accentA: "#334155", accentB: "#64748B", @@ -99,7 +116,7 @@ export const CARD_THEMES = [ accentDeep: "#1E293B", watermark: "#3A4356", }, -] as const satisfies readonly CardTheme[]; +] as const satisfies readonly CardThemeChoice[]; export type CardThemeKey = (typeof CARD_THEMES)[number]["key"]; /** patternData.ts に焼き込まれたストローク色をテーマ色へ差し替える。 diff --git a/apps/web/src/pages/LicenseCardPage.test.tsx b/apps/web/src/pages/LicenseCardPage.test.tsx index b2de275..8c5bf26 100644 --- a/apps/web/src/pages/LicenseCardPage.test.tsx +++ b/apps/web/src/pages/LicenseCardPage.test.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { describe, expect, it, vi, beforeEach } from "vitest"; import type { UserProfile } from "@eventer/shared"; +import { i18next } from "../i18n/index.js"; /** * カードのデザイン画面 (#334)。 @@ -127,6 +128,23 @@ describe("カードのデザイン画面 (#334)", () => { ).toBeTruthy(); }); + /** + * 背景と配色の呼び名は辞書から引く (#475)。カタログに日本語を直に書いていた + * ころは、英語で見ている人にも「ロゼット」「インディゴ」が出ていた。 + * カタログ側の見張りは components/licenseCard/cardTheme.test.ts にある。 + */ + it("英語で見ると背景・配色の名前も英語になる", async () => { + await i18next.changeLanguage("en"); + renderCardPage(profile()); + expect(await screen.findByText("Rosette")).toBeTruthy(); + expect(screen.getByText("Contours")).toBeTruthy(); + expect(screen.getByText("Indigo")).toBeTruthy(); + expect(screen.getByText("Monochrome")).toBeTruthy(); + // 直書きに戻ると日本語がそのまま出る + expect(screen.queryByText("ロゼット")).toBeNull(); + expect(screen.queryByText("インディゴ")).toBeNull(); + }); + it("保存済みの見た目で描く(手元の既定で上書きしない)", async () => { // 手元の既定はティール。保存済みはローズなので、保存済みが勝つ localStorage.setItem("eventer:cardBg", "topo"); diff --git a/apps/web/src/pages/LicenseCardPage.tsx b/apps/web/src/pages/LicenseCardPage.tsx index 6ca6f46..bc8c556 100644 --- a/apps/web/src/pages/LicenseCardPage.tsx +++ b/apps/web/src/pages/LicenseCardPage.tsx @@ -13,6 +13,7 @@ import PrintIcon from "@mui/icons-material/Print"; import DownloadIcon from "@mui/icons-material/Download"; import QrCode2Icon from "@mui/icons-material/QrCode2"; import { Link as RouterLink, Navigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useUserProfile } from "../api/userHooks.js"; import { BigQrDialog } from "../components/BigQrDialog.js"; import { LicenseCardSvg } from "../components/licenseCard/LicenseCardSvg.js"; @@ -163,6 +164,7 @@ function downloadBlob(png: Blob, fileName: string): void { // --------------------------------------------------------------------------- export function LicenseCardPage() { + const { t } = useTranslation(); const { id = "" } = useParams(); const { data, isLoading, isError } = useUserProfile(id); // 表示中の見た目。保存済みの値が届くまでは手元の既定で描く @@ -332,7 +334,7 @@ export function LicenseCardPage() { {BG_VARIANTS.map((v) => ( selectVariant(v.key)} @@ -345,13 +347,13 @@ export function LicenseCardPage() { - {CARD_THEMES.map((t) => ( + {CARD_THEMES.map((th) => ( selectTheme(t.key)} + key={th.key} + label={t(th.nameKey)} + color={theme === th.key ? "primary" : "default"} + variant={theme === th.key ? "filled" : "outlined"} + onClick={() => selectTheme(th.key)} /> ))} diff --git a/packages/shared/src/i18n/messages/profile.ts b/packages/shared/src/i18n/messages/profile.ts index c01c08e..ab8212a 100644 --- a/packages/shared/src/i18n/messages/profile.ts +++ b/packages/shared/src/i18n/messages/profile.ts @@ -138,6 +138,20 @@ const ja = { "あなたのプロフィールカード。印刷(91×55mm)や画像の書き出しは「デザインを変える」から", cardOtherHint: "このカードは本人が選んだ見た目で表示しています", + /** カードの見た目を選ぶチップの名前 (#475)。背景パターンと配色テーマ。 + * 色や模様の呼び名なので、直訳ではなくその言語で通じる呼び方にする。 + * キーの綴りは apps/web の cardTheme.ts が型で縛っている(`profile.cardBg*` + * `profile.cardTheme*` 以外を書くと落ちる) */ + cardBgRosette: "ロゼット", + cardBgTopo: "等高線", + cardBgArcs: "円弧", + cardBgFlow: "流線", + cardThemeIndigo: "インディゴ", + cardThemeTeal: "ティール", + cardThemeRose: "ローズ", + cardThemeAmber: "アンバー", + cardThemeMono: "モノクロ", + /** 交流用の大きなQR */ qrLabel: "{{name}} の交流用QRコード", qrError: @@ -252,6 +266,16 @@ const en: Record = { "Your profile card. Printing (91×55mm) and image export are under “Change the design”.", cardOtherHint: "This card is shown with the look its owner chose.", + cardBgRosette: "Rosette", + cardBgTopo: "Contours", + cardBgArcs: "Arcs", + cardBgFlow: "Flow", + cardThemeIndigo: "Indigo", + cardThemeTeal: "Teal", + cardThemeRose: "Rose", + cardThemeAmber: "Amber", + cardThemeMono: "Monochrome", + qrLabel: "{{name}}'s meet-up QR code", qrError: "Couldn't show the QR code. Check your connection, then close this and try again.",