diff --git a/.changeset/flag-locale-menu.md b/.changeset/flag-locale-menu.md new file mode 100644 index 0000000..f03a1c3 --- /dev/null +++ b/.changeset/flag-locale-menu.md @@ -0,0 +1,5 @@ +--- +"@devslab/site-kit": minor +--- + +`LocaleMenu` gains `variant="flag"` (flag trigger, flag + native-name rows, works without JavaScript) and the runtime-neutral entry exports `FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`. `SiteHeader` forwards `localeVariant`. diff --git a/docs/backlog.md b/docs/backlog.md index 7600d40..67c98da 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -151,6 +151,9 @@ devslab.kr과 동일한 `localStorage 'theme'` 키 + `data-theme` 속성 패턴 Geist/Geist Mono 셀프호스팅 여부, Pretendard 서브셋, RN 번들 전략. 결정을 `docs/decisions.md`에 기록. +### 12. site-kit 국기 로케일 피커 — `완료` (2026-09-02) +D-017. `LocaleMenu variant="flag"` + 국기 데이터 14개. 첫 소비자 TraceLinq 랜딩. + --- ## P3 — 모바일 이후 diff --git a/docs/decisions.md b/docs/decisions.md index e72332d..52b03e2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -5,6 +5,20 @@ --- +## D-017 — 국기는 site-kit 데이터, 아이콘 세트 밖 (2026-09-02) + +**결정.** 로케일 피커의 국기 14개는 `@devslab/site-kit`가 `src/core/flags.mjs`로 +소유한다(flag-icons 7.5.0에서 벤더링, MIT). `dds-icons`에는 넣지 않는다. +`LocaleMenu`는 `variant="flag"`를 얻고 기본값 `select`는 무변경이다. + +**근거.** D-013의 아이콘 계약(`check-icons.mjs`)은 색 리터럴 금지·`currentColor` +스트로크 필수를 기계적으로 강제한다. 다색 채움인 국기는 그 계약을 만족할 수 없고, +계약을 느슨하게 하면 아이콘 세트가 아이콘 세트이기를 그만둔다. 로케일 목록을 +소유한 패키지가 그 목록의 국기도 소유하는 것이 데이터의 자리다. + +**첫 소비자.** TraceLinq 랜딩(gettracelinq.app). 허브·devslab.kr·AskLinq +마케팅의 같은 14로케일 피커는 이후 갈아탈 수 있다. + ## D-016 — 외부 디자인 시스템 참조 표기 제거 (2026-09-01) **결정.** 공개 문서(README·스펙·백로그)에서 특정 외부 디자인 시스템을 참조 diff --git a/packages/site-kit/README.ko.md b/packages/site-kit/README.ko.md index 300d274..0f0e040 100644 --- a/packages/site-kit/README.ko.md +++ b/packages/site-kit/README.ko.md @@ -16,3 +16,15 @@ DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다. claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다. `buildRobots`의 기존 environment-only 출력은 유지되며, 선택적 `policies`로 검색 인덱싱, 인용 crawler, 모델 학습 crawler를 각각 제어할 수 있다. + +## 로케일 메뉴 variant + +`LocaleMenu`는 기본으로 네이티브 `` by default. `variant="flag"` renders a `
` disclosure whose trigger is the current locale's flag and whose rows are flag + native-name links — it works without JavaScript; Solid adds Escape-to-close and the `onLocaleChange(locale, href)` callback. `SiteHeader` forwards `localeVariant`. Flag data (`FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`) is exported from the runtime-neutral entry; the artwork is vendored from flag-icons (MIT, `flags/LICENSE-flag-icons.txt`). Flags are site-kit data, not `dds-icons` entries, because the icon set's contract requires single-colour `currentColor` strokes. diff --git a/packages/site-kit/flags/LICENSE-flag-icons.txt b/packages/site-kit/flags/LICENSE-flag-icons.txt new file mode 100644 index 0000000..ee959dc --- /dev/null +++ b/packages/site-kit/flags/LICENSE-flag-icons.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2013 Panayiotis Lipiridis + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/site-kit/flags/br.svg b/packages/site-kit/flags/br.svg new file mode 100644 index 0000000..719a763 --- /dev/null +++ b/packages/site-kit/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/site-kit/flags/de.svg b/packages/site-kit/flags/de.svg new file mode 100644 index 0000000..71aa2d2 --- /dev/null +++ b/packages/site-kit/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/site-kit/flags/es.svg b/packages/site-kit/flags/es.svg new file mode 100644 index 0000000..a296ebf --- /dev/null +++ b/packages/site-kit/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/site-kit/flags/fr.svg b/packages/site-kit/flags/fr.svg new file mode 100644 index 0000000..4110e59 --- /dev/null +++ b/packages/site-kit/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/site-kit/flags/hk.svg b/packages/site-kit/flags/hk.svg new file mode 100644 index 0000000..4fd55bc --- /dev/null +++ b/packages/site-kit/flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/site-kit/flags/id.svg b/packages/site-kit/flags/id.svg new file mode 100644 index 0000000..3b7c8fc --- /dev/null +++ b/packages/site-kit/flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/site-kit/flags/in.svg b/packages/site-kit/flags/in.svg new file mode 100644 index 0000000..bc47d74 --- /dev/null +++ b/packages/site-kit/flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/site-kit/flags/jp.svg b/packages/site-kit/flags/jp.svg new file mode 100644 index 0000000..cc1c181 --- /dev/null +++ b/packages/site-kit/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/site-kit/flags/kr.svg b/packages/site-kit/flags/kr.svg new file mode 100644 index 0000000..6947eab --- /dev/null +++ b/packages/site-kit/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/site-kit/flags/sa.svg b/packages/site-kit/flags/sa.svg new file mode 100644 index 0000000..596cf48 --- /dev/null +++ b/packages/site-kit/flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/site-kit/flags/th.svg b/packages/site-kit/flags/th.svg new file mode 100644 index 0000000..1e93a61 --- /dev/null +++ b/packages/site-kit/flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/site-kit/flags/tw.svg b/packages/site-kit/flags/tw.svg new file mode 100644 index 0000000..57fd98b --- /dev/null +++ b/packages/site-kit/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/site-kit/flags/us.svg b/packages/site-kit/flags/us.svg new file mode 100644 index 0000000..9cfd0c9 --- /dev/null +++ b/packages/site-kit/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/site-kit/flags/vn.svg b/packages/site-kit/flags/vn.svg new file mode 100644 index 0000000..7e4bac8 --- /dev/null +++ b/packages/site-kit/flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/site-kit/package.json b/packages/site-kit/package.json index fa4acf6..fb72e24 100644 --- a/packages/site-kit/package.json +++ b/packages/site-kit/package.json @@ -19,6 +19,7 @@ }, "files": [ "dist", + "flags", "src/core", "src/tanstack-start.mjs", "src/tanstack-start.d.mts", @@ -49,7 +50,7 @@ }, "scripts": { "build": "vite build && vite build --config vite.server.config.ts && tsc -p tsconfig.build.json --emitDeclarationOnly", - "check": "tsc -p tsconfig.json --noEmit", + "check": "node scripts/build-flags.mjs --check && tsc -p tsconfig.json --noEmit", "test": "vitest run --config vitest.config.ts && vitest run --config vitest.ssr.config.ts", "test:a11y": "vitest run --config vitest.config.ts src/solid/__tests__/a11y.test.tsx", "test:worker": "wrangler deploy --dry-run --config fixtures/worker/wrangler.jsonc --outdir dist-worker" diff --git a/packages/site-kit/scripts/build-flags.mjs b/packages/site-kit/scripts/build-flags.mjs new file mode 100644 index 0000000..7a5a5e1 --- /dev/null +++ b/packages/site-kit/scripts/build-flags.mjs @@ -0,0 +1,62 @@ +/** + * Flag data for the locale picker — one vendored 4x3 SVG per locale, inlined + * as inner markup so SSR and the Worker can render it without a fetch. + * + * Flags live here, not in dds-icons: that set's check-icons.mjs bans colour + * literals and requires stroke="currentColor", which a flag cannot satisfy. + * + * node scripts/build-flags.mjs write src/core/flags.mjs + .d.mts + * node scripts/build-flags.mjs --check fail if the committed output is stale + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const pkg = join(dirname(fileURLToPath(import.meta.url)), ".."); +const FLAG_COUNTRY = { + ko: "kr", en: "us", ja: "jp", "zh-HK": "hk", "zh-TW": "tw", hi: "in", vi: "vn", + id: "id", th: "th", "pt-BR": "br", fr: "fr", de: "de", es: "es", ar: "sa", +}; +const HEADER = "/* Generated by scripts/build-flags.mjs — do not edit. Source: flags/*.svg (flag-icons, MIT) */"; + +const entries = Object.entries(FLAG_COUNTRY).map(([locale, country]) => { + const text = readFileSync(join(pkg, "flags", `${country}.svg`), "utf8").trim(); + const open = text.match(/^]*>/); + if (!open) throw new Error(`flags/${country}.svg: no root`); + const viewBox = open[0].match(/viewBox="([^"]+)"/)?.[1]; + if (!viewBox) throw new Error(`flags/${country}.svg: no viewBox`); + const body = text.replace(/^]*>/, "").replace(/<\/svg>\s*$/, "").trim(); + if (/ ` ${JSON.stringify(e.locale)}: Object.freeze({ country: ${JSON.stringify(e.country)}, viewBox: ${JSON.stringify(e.viewBox)}, body: ${JSON.stringify(e.body)} }),`).join("\n")} +}); + +export function flagFor(locale) { + const flag = LOCALE_FLAGS[locale]; + if (!flag) throw new RangeError(\`No flag for locale: \${locale}\`); + return flag; +} +`; +const dts = `${HEADER} +import type { SiteLocale } from "./locales.mjs"; +export interface LocaleFlag { readonly country: string; readonly viewBox: string; readonly body: string } +export declare const FLAG_COUNTRY: Readonly>; +export declare const LOCALE_FLAGS: Readonly>; +export declare function flagFor(locale: SiteLocale): LocaleFlag; +`; + +const targets = [["src/core/flags.mjs", js], ["src/core/flags.d.mts", dts]]; +if (process.argv.includes("--check")) { + const stale = targets.filter(([rel, next]) => readFileSync(join(pkg, rel), "utf8") !== next); + if (stale.length) { console.error(`flags out of date: ${stale.map(([r]) => r).join(", ")} — run build-flags`); process.exit(1); } + console.log("site-kit flags: 14 locales, generated output in sync"); +} else { + for (const [rel, next] of targets) writeFileSync(join(pkg, rel), next); + console.log("site-kit flags: wrote src/core/flags.mjs and flags.d.mts (14 locales)"); +} diff --git a/packages/site-kit/src/core/flags.d.mts b/packages/site-kit/src/core/flags.d.mts new file mode 100644 index 0000000..4f29434 --- /dev/null +++ b/packages/site-kit/src/core/flags.d.mts @@ -0,0 +1,6 @@ +/* Generated by scripts/build-flags.mjs — do not edit. Source: flags/*.svg (flag-icons, MIT) */ +import type { SiteLocale } from "./locales.mjs"; +export interface LocaleFlag { readonly country: string; readonly viewBox: string; readonly body: string } +export declare const FLAG_COUNTRY: Readonly>; +export declare const LOCALE_FLAGS: Readonly>; +export declare function flagFor(locale: SiteLocale): LocaleFlag; diff --git a/packages/site-kit/src/core/flags.mjs b/packages/site-kit/src/core/flags.mjs new file mode 100644 index 0000000..266f3aa --- /dev/null +++ b/packages/site-kit/src/core/flags.mjs @@ -0,0 +1,40 @@ +/* Generated by scripts/build-flags.mjs — do not edit. Source: flags/*.svg (flag-icons, MIT) */ +export const FLAG_COUNTRY = Object.freeze({ + "ko": "kr", + "en": "us", + "ja": "jp", + "zh-HK": "hk", + "zh-TW": "tw", + "hi": "in", + "vi": "vn", + "id": "id", + "th": "th", + "pt-BR": "br", + "fr": "fr", + "de": "de", + "es": "es", + "ar": "sa" +}); + +export const LOCALE_FLAGS = Object.freeze({ + "ko": Object.freeze({ country: "kr", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "en": Object.freeze({ country: "us", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n " }), + "ja": Object.freeze({ country: "jp", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n " }), + "zh-HK": Object.freeze({ country: "hk", viewBox: "0 0 640 480", body: "\n \n \n \n \n " }), + "zh-TW": Object.freeze({ country: "tw", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "hi": Object.freeze({ country: "in", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "vi": Object.freeze({ country: "vn", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n " }), + "id": Object.freeze({ country: "id", viewBox: "0 0 640 480", body: "\n " }), + "th": Object.freeze({ country: "th", viewBox: "0 0 640 480", body: "\n \n \n \n " }), + "pt-BR": Object.freeze({ country: "br", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "fr": Object.freeze({ country: "fr", viewBox: "0 0 640 480", body: "\n \n " }), + "de": Object.freeze({ country: "de", viewBox: "0 0 640 480", body: "\n \n " }), + "es": Object.freeze({ country: "es", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "ar": Object.freeze({ country: "sa", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), +}); + +export function flagFor(locale) { + const flag = LOCALE_FLAGS[locale]; + if (!flag) throw new RangeError(`No flag for locale: ${locale}`); + return flag; +} diff --git a/packages/site-kit/src/core/index.d.mts b/packages/site-kit/src/core/index.d.mts index 297b73d..dad4f35 100644 --- a/packages/site-kit/src/core/index.d.mts +++ b/packages/site-kit/src/core/index.d.mts @@ -2,3 +2,4 @@ export * from "./locales.mjs"; export * from "./catalog.mjs"; export * from "./seo.mjs"; export * from "./geo.mjs"; +export * from "./flags.mjs"; diff --git a/packages/site-kit/src/core/index.mjs b/packages/site-kit/src/core/index.mjs index 410d1f3..c3a519d 100644 --- a/packages/site-kit/src/core/index.mjs +++ b/packages/site-kit/src/core/index.mjs @@ -2,3 +2,4 @@ export { LOCALES, canonicalLocale, localeAttributes, resolveLocale } from "./loc export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs"; export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs"; export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs"; +export { FLAG_COUNTRY, LOCALE_FLAGS, flagFor } from "./flags.mjs"; diff --git a/packages/site-kit/src/solid/__tests__/locale-menu.test.tsx b/packages/site-kit/src/solid/__tests__/locale-menu.test.tsx new file mode 100644 index 0000000..87d6bd8 --- /dev/null +++ b/packages/site-kit/src/solid/__tests__/locale-menu.test.tsx @@ -0,0 +1,103 @@ +import { render } from "solid-js/web"; +import { afterEach, expect, it, vi } from "vitest"; + +import { LocaleMenu, SiteHeader } from "../index"; +import { locale, messages } from "./fixtures"; + +let dispose: (() => void) | undefined; +afterEach(() => { dispose?.(); dispose = undefined; document.body.replaceChildren(); }); + +const mount = (node: () => any) => { + const host = document.body.appendChild(document.createElement("div")); + dispose = render(node, host); + return host; +}; + +it("keeps the select variant as the default", () => { + const host = mount(() => ); + expect(host.querySelector("select.dds-select__input")).not.toBeNull(); + expect(host.querySelector(".site-locale-flag")).toBeNull(); +}); + +it("flag variant renders a flag-only trigger named after the current language", () => { + const host = mount(() => ); + const trigger = host.querySelector(".site-locale-flag__trigger")!; + expect(trigger.tagName).toBe("SUMMARY"); + expect(trigger.textContent?.trim()).toBe(""); + expect(trigger.getAttribute("aria-label")).toBe("English"); + expect(trigger.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true"); +}); + +it("flag variant lists fourteen flag + native-name links with the current one marked", () => { + const host = mount(() => ); + const options = [...host.querySelectorAll(".site-locale-flag__option")]; + expect(options).toHaveLength(14); + for (const option of options) { + expect(option.querySelector("svg")).not.toBeNull(); + expect(option.querySelector("span")?.textContent?.length).toBeGreaterThan(0); + expect(option.getAttribute("hreflang")).toBe(option.getAttribute("lang")); + } + const arabic = options.find((o) => o.lang === "ar")!; + expect(arabic.getAttribute("dir")).toBe("rtl"); + expect(arabic.getAttribute("href")).toBe("/ar"); + expect(options.find((o) => o.lang === "en")?.getAttribute("aria-current")).toBe("true"); + expect(arabic.getAttribute("aria-current")).toBeNull(); +}); + +it("flag variant calls onLocaleChange with the locale and href, then closes", () => { + const onLocaleChange = vi.fn(); + const host = mount(() => ); + const details = host.querySelector("details")!; + details.open = true; + const japanese = host.querySelector('.site-locale-flag__option[lang="ja"]')!; + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + japanese.dispatchEvent(event); + expect(onLocaleChange).toHaveBeenCalledWith("ja", "/ja"); + expect(event.defaultPrevented).toBe(true); + expect(details.open).toBe(false); +}); + +it("flag variant leaves navigation to the link when no handler is given", () => { + const host = mount(() => ); + const japanese = host.querySelector('.site-locale-flag__option[lang="ja"]')!; + const event = new MouseEvent("click", { bubbles: true, cancelable: true }); + japanese.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); +}); + +it("Escape closes an open flag menu and returns focus to the trigger", () => { + const host = mount(() => ); + const details = host.querySelector("details")!; + const trigger = host.querySelector("summary")!; + details.open = true; + host.querySelector('.site-locale-flag__option[lang="fr"]')!.focus(); + details.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + expect(details.open).toBe(false); + expect(document.activeElement).toBe(trigger); +}); + +it("SiteHeader passes localeVariant through", () => { + const host = mount(() => ); + expect(host.querySelector(".site-header__controls .site-locale-flag")).not.toBeNull(); + expect(host.querySelector(".site-header__controls select")).toBeNull(); +}); + +it("scopes flag svg ids uniquely when the current locale's flag renders twice (trigger + its own row)", () => { + const koLocale = { ...locale, locale: "ko" as const }; + const host = mount(() => ); + const svgs = [...host.querySelectorAll("svg")]; + const allIds = svgs.flatMap((svg) => [...svg.querySelectorAll("[id]")].map((el) => el.id)); + expect(new Set(allIds).size).toBe(allIds.length); + for (const svg of svgs) { + const svgIds = new Set([...svg.querySelectorAll("[id]")].map((el) => el.id)); + for (const el of svg.querySelectorAll("*")) { + const href = el.getAttribute("href") + ?? el.getAttribute("xlink:href") + ?? el.getAttributeNS("http://www.w3.org/1999/xlink", "href"); + if (!href?.startsWith("#")) continue; + expect(svgIds.has(href.slice(1))).toBe(true); + } + } +}); diff --git a/packages/site-kit/src/solid/__tests__/ssr.test.tsx b/packages/site-kit/src/solid/__tests__/ssr.test.tsx index 4fa1e09..7707cfb 100644 --- a/packages/site-kit/src/solid/__tests__/ssr.test.tsx +++ b/packages/site-kit/src/solid/__tests__/ssr.test.tsx @@ -1,7 +1,7 @@ import { renderToString } from "solid-js/web"; import { expect, it } from "vitest"; -import { MarketingShell } from "../index"; +import { LocaleMenu, MarketingShell } from "../index"; import { locale, messages } from "./fixtures"; const Fixture = () => { expect(html).toContain("Document intelligence"); expect(html).toContain('id="main-content"'); }); + +it("server-renders the flag variant as a working disclosure without JavaScript", () => { + const html = renderToString(() => ); + expect(html).toContain(" void; -} - -export function LocaleMenu(props: LocaleMenuProps) { - return ( - {props.messages.localeLabel} - - - - - ); -} +export type { LocaleMenuProps }; export interface ThemeToggleProps { messages: SiteMessages; @@ -92,6 +65,7 @@ export interface SiteHeaderProps { messages: SiteMessages; theme?: Omit; onLocaleChange?: LocaleMenuProps["onLocaleChange"]; + localeVariant?: LocaleMenuVariant; actions?: JSX.Element; } @@ -112,6 +86,7 @@ export function SiteHeader(props: SiteHeaderProps) { {props.theme && } diff --git a/packages/site-kit/src/solid/index.ts b/packages/site-kit/src/solid/index.ts index fa8bf42..01c3646 100644 --- a/packages/site-kit/src/solid/index.ts +++ b/packages/site-kit/src/solid/index.ts @@ -1,4 +1,5 @@ -export { LocaleMenu, SiteFooter, SiteHeader, ThemeToggle, type LocaleMenuProps, type SiteFooterProps, type SiteHeaderProps, type ThemeToggleProps } from "./chrome"; +export { LocaleMenu, type LocaleMenuProps, type LocaleMenuVariant } from "./locale-menu"; +export { SiteFooter, SiteHeader, ThemeToggle, type SiteFooterProps, type SiteHeaderProps, type ThemeToggleProps } from "./chrome"; export { ErrorLayout, LegalLayout, MarketingShell, NotFoundLayout, StatusBanner, type ErrorLayoutProps, type LegalLayoutProps, type MarketingShellProps, type NotFoundLayoutProps, type StatusBannerProps } from "./layouts"; export { RequestAccessForm, type RequestAccessFormProps, type RequestAccessMessages } from "./request-access"; export { OssProductMark, type OssProductMarkProps } from "./oss-product-mark"; diff --git a/packages/site-kit/src/solid/locale-menu.tsx b/packages/site-kit/src/solid/locale-menu.tsx new file mode 100644 index 0000000..83f22dd --- /dev/null +++ b/packages/site-kit/src/solid/locale-menu.tsx @@ -0,0 +1,111 @@ +import { For, createUniqueId, type JSX } from "solid-js"; + +import { LOCALES, type SiteLocale } from "../core/locales.mjs"; +import { flagFor } from "../core/flags.mjs"; +import type { LocaleState, SiteMessages } from "./types"; + +export type LocaleMenuVariant = "select" | "flag"; + +export interface LocaleMenuProps { + state: LocaleState; + messages: SiteMessages; + variant?: LocaleMenuVariant; + onLocaleChange?: (locale: SiteLocale, href: string) => void; +} + +function scopeFlagIds(body: string, uid: string): string { + const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]!); + if (ids.length === 0) return body; + let scoped = body; + for (const id of ids) { + const suffixed = `${id}-${uid}`; + scoped = scoped + .replaceAll(`id="${id}"`, `id="${suffixed}"`) + .replaceAll(`href="#${id}"`, `href="#${suffixed}"`) + .replaceAll(`url(#${id})`, `url(#${suffixed})`); + } + return scoped; +} + +function Flag(props: { locale: SiteLocale; class?: string }) { + const uid = createUniqueId(); + const flag = () => flagFor(props.locale); + return ( + + ); +} + +function SelectLocaleMenu(props: LocaleMenuProps) { + return ( + + ); +} + +function FlagLocaleMenu(props: LocaleMenuProps) { + let details: HTMLDetailsElement | undefined; + let trigger: HTMLElement | undefined; + const current = () => LOCALES.find((entry) => entry.code === props.state.locale)!; + const close = () => { if (details) details.open = false; }; + const onKeyDown: JSX.EventHandler = (event) => { + if (event.key !== "Escape" || !details?.open) return; + event.preventDefault(); + close(); + trigger?.focus(); + }; + return ( +
+ + + + +
+ ); +} + +export function LocaleMenu(props: LocaleMenuProps) { + return props.variant === "flag" ? : ; +} diff --git a/packages/site-kit/styles.css b/packages/site-kit/styles.css index 8108165..d8ce420 100644 --- a/packages/site-kit/styles.css +++ b/packages/site-kit/styles.css @@ -42,3 +42,17 @@ @media (prefers-reduced-motion: reduce) { .site-header { scroll-behavior: auto; } } @media (forced-colors: active) { .site-header, .site-footer, .site-status { forced-color-adjust: auto; } } + +.site-locale-flag { position: relative; } +.site-locale-flag__trigger { list-style: none; display: inline-grid; place-items: center; min-inline-size: 44px; min-block-size: 44px; border-radius: var(--dds-radius-sm); cursor: pointer; } +.site-locale-flag__trigger::-webkit-details-marker { display: none; } +.site-locale-flag__trigger:hover { background: var(--dds-color-bg-subtle); } +.site-locale-flag__trigger:focus-visible { outline: 2px solid var(--dds-color-border-focus); outline-offset: 2px; } +.site-locale-flag__svg { inline-size: 24px; block-size: 18px; border-radius: 2px; box-shadow: 0 0 0 1px var(--dds-color-border-default); } +/* z-index is local to .site-header's own stacking context (position: sticky, z-index: 100), not the document root. */ +.site-locale-flag__list { position: absolute; inset-block-start: calc(100% + var(--dds-space-4)); inset-inline-end: 0; z-index: 200; margin: 0; padding: var(--dds-space-4); list-style: none; min-inline-size: 14rem; max-block-size: min(60dvh, 28rem); overflow-y: auto; background: var(--dds-color-bg-default); border: 1px solid var(--dds-color-border-default); border-radius: var(--dds-radius-md); box-shadow: var(--dds-elevation-2); } +.site-locale-flag__option { display: flex; align-items: center; gap: var(--dds-space-12); min-block-size: 44px; padding-inline: var(--dds-space-12); border-radius: var(--dds-radius-sm); color: var(--dds-color-text-primary); text-decoration: none; } +.site-locale-flag__option:hover { background: var(--dds-color-bg-subtle); } +.site-locale-flag__option:focus-visible { outline: 2px solid var(--dds-color-border-focus); outline-offset: -2px; } +.site-locale-flag__option[aria-current="true"] { font-weight: 700; } +@media (max-width: 720px) { .site-locale-flag__list { position: static; inline-size: 100%; max-block-size: none; box-shadow: none; } } diff --git a/packages/site-kit/vitest.config.ts b/packages/site-kit/vitest.config.ts index 6c271e2..4b87423 100644 --- a/packages/site-kit/vitest.config.ts +++ b/packages/site-kit/vitest.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ environment: "jsdom", include: [ "src/solid/__tests__/a11y.test.tsx", + "src/solid/__tests__/locale-menu.test.tsx", "src/solid/__tests__/shells.test.tsx", "src/solid/__tests__/oss-product-mark.test.tsx", ], diff --git a/tests/browser/site-kit.spec.ts b/tests/browser/site-kit.spec.ts index 15da37f..07a717e 100644 --- a/tests/browser/site-kit.spec.ts +++ b/tests/browser/site-kit.spec.ts @@ -14,6 +14,16 @@ const fixture = ` +

واجهة عربية طويلة لا ينبغي أن تتجاوز عرض الشاشة

+ +`; + function channel(value: number) { const normalized = value / 255; return normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; @@ -91,3 +101,29 @@ for (const width of [1280, 375]) { } }); } + +for (const width of [1280, 375]) { + test(`flag locale menu opens, is keyboard-operable, and does not overflow in RTL at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 812 }); + await page.setContent(flagFixture); + if (width === 375) { + await page.getByRole("button", { name: "فتح القائمة" }).click(); + } + const trigger = page.locator(".site-locale-flag__trigger"); + await expect(trigger).toHaveAttribute("aria-label", "العربية"); + const box = (await trigger.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + await trigger.click(); + await expect(page.locator("details.site-locale-flag")).toHaveAttribute("open", ""); + const hit = await page.locator('.site-locale-flag__option[lang="ko"]').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2)?.closest(".site-locale-flag__option") === element; + }); + expect(hit, "the row must be the element under its own centre — visible AND usable").toBe(true); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + await page.keyboard.press("Tab"); + await expect(page.locator('.site-locale-flag__option[lang="ko"]')).toBeFocused(); + }); +} diff --git a/tests/site-kit-contracts.test.mjs b/tests/site-kit-contracts.test.mjs index f3c5510..676c15b 100644 --- a/tests/site-kit-contracts.test.mjs +++ b/tests/site-kit-contracts.test.mjs @@ -32,12 +32,30 @@ test("site-kit exposes runtime-neutral, Solid, TanStack, and stylesheet boundari test("Solid adapter exports every shared public-site shell", async () => { const source = await read("packages/site-kit/src/solid/index.ts"); for (const symbol of [ - "SiteHeader", "LocaleMenu", "ThemeToggle", "MarketingShell", "SiteFooter", + "SiteHeader", "LocaleMenu", "LocaleMenuVariant", "ThemeToggle", "MarketingShell", "SiteFooter", "LegalLayout", "StatusBanner", "RequestAccessForm", "NotFoundLayout", "ErrorLayout", "OssProductMark", "OssProductMarkProps", ]) assert.match(source, new RegExp(`\\b${symbol}\\b`), `${symbol} missing`); }); +test("flag locale menu is a native disclosure with tokenised, logical styles", async () => { + const menu = await read("packages/site-kit/src/solid/locale-menu.tsx"); + const styles = await read("packages/site-kit/styles.css"); + assert.match(menu, /
{ const component = await read("packages/site-kit/src/solid/oss-product-mark.tsx"); const styles = await read("packages/site-kit/styles.css"); diff --git a/tests/site-kit-core.test.mjs b/tests/site-kit-core.test.mjs index da5641f..d59685d 100644 --- a/tests/site-kit-core.test.mjs +++ b/tests/site-kit-core.test.mjs @@ -18,6 +18,7 @@ import { buildVerifiedJsonLd, renderLlmsTxt, } from "../packages/site-kit/src/core/geo.mjs"; +import { FLAG_COUNTRY, LOCALE_FLAGS, flagFor } from "../packages/site-kit/src/core/flags.mjs"; const localeCodes = ["ko", "en", "ja", "zh-HK", "zh-TW", "hi", "vi", "id", "th", "pt-BR", "fr", "de", "es", "ar"]; @@ -113,3 +114,17 @@ test("GEO output accepts only sourced, current facts", () => { assert.throws(() => buildVerifiedJsonLd({ type: "toString", id: "x", identity: { name: "x", url: "https://example.com" }, claims: {} }, registry), /unsupported schema type/i); assert.throws(() => buildVerifiedJsonLd({ type: "SoftwareApplication", id: "x", identity: { name: "x", url: "https://example.com" }, claims: { aggregateRating: { factId: "coverage" } } }, registry), /unsupported claim/i); }); + +test("every locale has exactly one flag and the flag data is renderable SVG", () => { + assert.deepEqual(Object.keys(FLAG_COUNTRY).sort(), [...localeCodes].sort()); + assert.deepEqual(Object.keys(LOCALE_FLAGS).sort(), [...localeCodes].sort()); + for (const locale of localeCodes) { + const flag = flagFor(locale); + assert.equal(flag.country, FLAG_COUNTRY[locale]); + assert.match(flag.viewBox, /^0 0 \d+ \d+$/); + assert.ok(flag.body.length > 0, `${locale} body is empty`); + assert.doesNotMatch(flag.body, / flagFor("xx"), RangeError); +});