Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions apps/web/src/components/licenseCard/cardTheme.test.ts
Original file line number Diff line number Diff line change
@@ -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<keyof typeof ja, string>)は「両方に同じキーがある」ことしか
// 守れない。キーごと辞書から消えた場合はここで落ちる
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);
}
});
});
43 changes: 30 additions & 13 deletions apps/web/src/components/licenseCard/cardTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof TranslationResource["profile"], string>}`,
`profile.cardBg${string}` | `profile.cardTheme${string}`
>;

/** 見出し系フォント。モックの Avenir Next はmacOS限定のため、
* アプリ同梱の Plus Jakarta Sans(700/600)で置き換える */
Expand All @@ -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"];

/** カード配色テーマ。
Expand All @@ -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;
Expand All @@ -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",
Expand All @@ -61,7 +78,7 @@ export const CARD_THEMES = [
},
{
key: "teal",
name: "ティール",
nameKey: "profile.cardThemeTeal",
paper: ["#C7E4E0", "#E0F3F0", "#BBDCD7"],
accentA: "#0D9488",
accentB: "#4F46E5",
Expand All @@ -71,7 +88,7 @@ export const CARD_THEMES = [
},
{
key: "rose",
name: "ローズ",
nameKey: "profile.cardThemeRose",
paper: ["#EED2DE", "#F8E6EE", "#E5C4D4"],
accentA: "#DB2777",
accentB: "#7C3AED",
Expand All @@ -81,7 +98,7 @@ export const CARD_THEMES = [
},
{
key: "amber",
name: "アンバー",
nameKey: "profile.cardThemeAmber",
paper: ["#EBDFC7", "#F6EEDC", "#E2D4B8"],
accentA: "#B45309",
accentB: "#4F46E5",
Expand All @@ -91,15 +108,15 @@ export const CARD_THEMES = [
},
{
key: "mono",
name: "モノクロ",
nameKey: "profile.cardThemeMono",
paper: ["#D9DDE4", "#EDF0F4", "#CDD2DA"],
accentA: "#334155",
accentB: "#64748B",
accentBLight: "#94A3B8",
accentDeep: "#1E293B",
watermark: "#3A4356",
},
] as const satisfies readonly CardTheme[];
] as const satisfies readonly CardThemeChoice[];
export type CardThemeKey = (typeof CARD_THEMES)[number]["key"];

/** patternData.ts に焼き込まれたストローク色をテーマ色へ差し替える。
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/pages/LicenseCardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down Expand Up @@ -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");
Expand Down
16 changes: 9 additions & 7 deletions apps/web/src/pages/LicenseCardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
// 表示中の見た目。保存済みの値が届くまでは手元の既定で描く
Expand Down Expand Up @@ -332,7 +334,7 @@ export function LicenseCardPage() {
{BG_VARIANTS.map((v) => (
<Chip
key={v.key}
label={v.label}
label={t(v.labelKey)}
color={variant === v.key ? "primary" : "default"}
variant={variant === v.key ? "filled" : "outlined"}
onClick={() => selectVariant(v.key)}
Expand All @@ -345,13 +347,13 @@ export function LicenseCardPage() {
<Typography variant="caption" color="text.secondary" sx={{ minWidth: 32 }}>
</Typography>
{CARD_THEMES.map((t) => (
{CARD_THEMES.map((th) => (
<Chip
key={t.key}
label={t.name}
color={theme === t.key ? "primary" : "default"}
variant={theme === t.key ? "filled" : "outlined"}
onClick={() => 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)}
/>
))}
</Stack>
Expand Down
24 changes: 24 additions & 0 deletions packages/shared/src/i18n/messages/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -252,6 +266,16 @@ const en: Record<keyof typeof ja, string> = {
"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.",
Expand Down
Loading