diff --git a/src/components/ui/FinalCtaSection.astro b/src/components/ui/FinalCtaSection.astro
index 1f29ac3..e99c51b 100644
--- a/src/components/ui/FinalCtaSection.astro
+++ b/src/components/ui/FinalCtaSection.astro
@@ -84,7 +84,7 @@ const repositoryUrl = productData.links.repositoryUrl
.final-band-title {
margin: 0;
- color: var(--hearth-ink-strong);
+ color: var(--hearth-text-heading-strong);
font-family: var(--font-display);
font-size: clamp(2rem, 4vw, 2.7rem);
line-height: 0.98;
@@ -109,7 +109,7 @@ const repositoryUrl = productData.links.repositoryUrl
align-items: center;
gap: 0.4rem;
margin-top: 0.2rem;
- color: var(--hearth-ink-soft);
+ color: var(--hearth-text-body-soft);
font-family: var(--font-ui);
font-size: 0.82rem;
font-weight: 600;
@@ -122,7 +122,7 @@ const repositoryUrl = productData.links.repositoryUrl
.final-source-link:hover {
opacity: 1;
- color: var(--hearth-ink-strong);
+ color: var(--hearth-text-heading-strong);
}
@media (min-width: 1200px) {
diff --git a/src/components/ui/HeroSection.astro b/src/components/ui/HeroSection.astro
index 9fd4b95..750ed83 100644
--- a/src/components/ui/HeroSection.astro
+++ b/src/components/ui/HeroSection.astro
@@ -219,6 +219,9 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, '
{installCommand}
{t(lang, 'hero.copy')}
+ {/* Stable empty region so the copy confirmation is announced; swapping the
+ hint text in place is silent for screen readers. */}
+
{t(lang, 'hero.kicker')}
+ {/* The kicker text carries the page's purpose, so it belongs in the h1; + the visible copy is hidden from assistive tech to avoid a duplicate. */} +{t(lang, 'hero.subtitle')}
@@ -350,19 +356,27 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' padding: 0; } + /* Stage and capsule share one grid cell. The capsule still overlays the + specimen, but as a grid item it contributes its height to the row, so a + short viewport grows the section instead of letting an absolutely + positioned panel escape and land on top of the next one. */ .hero-specimen { position: relative; min-width: 0; + display: grid; } /* ---- Specimen stage: the product IS the page ------------------------- */ .specimen-stage { position: relative; - height: clamp(540px, calc(100svh - 160px), 820px); + grid-area: 1 / 1; + /* min-height, not height: the stage stretches to the row so the panes keep + filling the frame when the capsule is the taller of the two. */ + min-height: clamp(540px, calc(100svh - 160px), 820px); overflow: hidden; - border-top: 1px solid color-mix(in srgb, var(--pick-accent) 14%, var(--hearth-card-border)); - border-bottom: 1px solid color-mix(in srgb, var(--pick-accent) 14%, var(--hearth-card-border)); + border-top: 1px solid color-mix(in srgb, var(--pick-accent) 14%, var(--hearth-surface-border-weak)); + border-bottom: 1px solid color-mix(in srgb, var(--pick-accent) 14%, var(--hearth-surface-border-weak)); } .specimen-layer { @@ -457,7 +471,7 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' place-items: center; width: 2.15rem; aspect-ratio: 1; - border: 1px solid color-mix(in srgb, var(--pick-keyword) 55%, var(--hearth-card-border)); + border: 1px solid color-mix(in srgb, var(--pick-keyword) 55%, var(--hearth-surface-border-weak)); border-radius: 50%; color: var(--pick-keyword); background: color-mix(in srgb, var(--hearth-bg) 94%, black 6%); @@ -548,9 +562,11 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' --cap-ink: var(--pick-ink); --cap-accent: var(--pick-keyword); --cap-line: color-mix(in srgb, var(--cap-ink) 22%, transparent); - position: absolute; - top: clamp(1rem, 3vh, 2.2rem); - left: clamp(1rem, 3.4vw, 3rem); + position: relative; + grid-area: 1 / 1; + justify-self: start; + align-self: start; + margin: clamp(1rem, 3vh, 2.2rem) 0 clamp(1rem, 3vh, 2.2rem) clamp(1rem, 3.4vw, 3rem); z-index: 4; display: grid; gap: 0.62rem; @@ -596,7 +612,7 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' line-height: 0.95; } - .hero-title span:last-child { + .hero-title-accent { color: color-mix(in srgb, var(--cap-accent) 60%, var(--cap-ink)); } @@ -750,7 +766,7 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' .hero-select-row:focus-within { border-color: var(--cap-accent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--cap-accent) 70%, transparent); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--cap-accent) 70%, transparent); } .hero-file-select { @@ -807,7 +823,7 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' .hero-forge-card small { color: color-mix(in srgb, var(--cap-ink) 64%, transparent); font-family: var(--font-ui); - font-size: 0.59rem; + font-size: 0.7rem; font-weight: 580; line-height: 1.35; } @@ -846,8 +862,10 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' .hero-action:focus-visible, .hero-forge-card:focus-visible, .hero-support-links a:focus-visible, + /* The command button lives in .specimen-stage, not in .hero-capsule, so the + --cap-* tokens are out of scope here; --pick-accent is set on the stage. */ .specimen-cmd:focus-visible { - outline: 2px solid var(--cap-accent); + outline: 2px solid var(--pick-accent); outline-offset: 2px; } @@ -862,6 +880,8 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' .hero-capsule { order: -1; position: static; + /* Undo the grid-overlay placement: this column is a flex stack. */ + align-self: stretch; width: auto; margin: 0.9rem 1rem; backdrop-filter: none; @@ -869,6 +889,7 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' .specimen-stage { height: auto; + min-height: 0; display: grid; } @@ -929,20 +950,26 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' } } + /* Stacked directly above the command chip on the stage's right edge: the + capsule is left-anchored and can be taller than the stage, so the old + bottom-left slot was covered by it. The right edge is always the compare + layer (it is clipped from --split-x rightward), so it takes compare ink. */ .specimen-ignite { position: absolute; - left: 1.1rem; - bottom: 1rem; + right: 1.1rem; + bottom: 4.2rem; z-index: 2; display: flex; flex-direction: column; + align-items: flex-end; gap: 0.2rem; font-family: var(--font-metric); font-size: 0.72rem; font-weight: 600; letter-spacing: 0.08em; font-variant-numeric: tabular-nums; - color: color-mix(in srgb, var(--pick-ink) 76%, transparent); + text-align: right; + color: color-mix(in srgb, var(--compare-ink) 76%, transparent); pointer-events: none; } @@ -1425,6 +1452,7 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' root.querySelectorAll('[data-copy-command]').forEach((button) => { if (!(button instanceof HTMLElement)) return const hint = button.querySelector('.specimen-cmd-hint') + const status = root.querySelector('[data-copy-status]') const originalHint = hint?.textContent || '' const copiedLabel = button.getAttribute('data-copied-label') || originalHint button.addEventListener('click', async () => { @@ -1432,12 +1460,12 @@ const initialForgeUrl = `${forgeUrl}?color=${ignitionDefaultSeed.replace(/^#/, ' if (!command) return try { await navigator.clipboard.writeText(command) - if (hint) { - hint.textContent = copiedLabel - window.setTimeout(() => { - hint.textContent = originalHint - }, 1600) - } + if (status) status.textContent = copiedLabel + if (hint) hint.textContent = copiedLabel + window.setTimeout(() => { + if (hint) hint.textContent = originalHint + if (status) status.textContent = '' + }, 1600) } catch { /* clipboard unavailable — the command stays selectable text */ } diff --git a/src/components/ui/InstallChannelGrid.astro b/src/components/ui/InstallChannelGrid.astro index 3dcb6d5..1081d3b 100644 --- a/src/components/ui/InstallChannelGrid.astro +++ b/src/components/ui/InstallChannelGrid.astro @@ -162,6 +162,9 @@ const channels = allChannels.filter(({ id, href }) => productData.channels[id] & } .install-channel-grid--hero { + /* The hero grid is the primary install path — it must not inherit the + smallest fallback meta size just because it skips the override. */ + --channel-meta-size: 0.7rem; --channel-card-edge: color-mix(in srgb, var(--cap-ink) 28%, transparent); --channel-card-face: color-mix(in srgb, var(--cap-ink) 4%, transparent); --channel-card-hover: color-mix(in srgb, var(--cap-accent) 10%, transparent); @@ -181,8 +184,8 @@ const channels = allChannels.filter(({ id, href }) => productData.channels[id] & --channel-card-edge: var(--hearth-panel-edge); --channel-card-face: color-mix(in srgb, var(--hearth-panel-face) 92%, var(--hearth-bg)); --channel-card-hover: color-mix(in srgb, var(--hearth-kicker-accent) 9%, var(--hearth-panel-face)); - --channel-card-ink: var(--hearth-ink-strong); - --channel-card-accent: color-mix(in srgb, var(--hearth-kicker-accent) 72%, var(--hearth-ink-strong)); + --channel-card-ink: var(--hearth-text-heading-strong); + --channel-card-accent: color-mix(in srgb, var(--hearth-kicker-accent) 72%, var(--hearth-text-heading-strong)); } @media (max-width: 360px) { diff --git a/src/i18n/en.json b/src/i18n/en.json index 58d9fd5..6779da9 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -3,6 +3,11 @@ "nav.themes": "Themes", "nav.forge": "Forge", "nav.install": "Install", + "nav.langSwitch": "Language", + "nav.skip": "Skip to content", + "lang.notice.body": "This page is available in English.", + "lang.notice.action": "View in English", + "lang.notice.dismiss": "Dismiss", "hero.kicker": "Two materials. One semantic system.", "hero.subtitle": "Warmth or structure. Meaning stays clear. Choose Ember or Moss, compare Dark and Light live, then install it where you work.", "hero.flow.preview": "Preview theme", @@ -11,7 +16,7 @@ "hero.flow.more": "More ways to explore HearthCode", "hero.scope.label": "Sample language", "hero.divider.label": "Drag to compare dark and light", - "hero.copy": "Click to copy", + "hero.copy": "Copy", "hero.copied": "Copied", "hero.ignite.count": "{n} tokens", "hero.ignite.custom": "Color direction", diff --git a/src/i18n/ja.json b/src/i18n/ja.json index a013628..c5e7afb 100644 --- a/src/i18n/ja.json +++ b/src/i18n/ja.json @@ -3,6 +3,11 @@ "nav.themes": "テーマ", "nav.forge": "Forge", "nav.install": "インストール", + "nav.langSwitch": "言語", + "nav.skip": "本文へスキップ", + "lang.notice.body": "このページには日本語版があります。", + "lang.notice.action": "日本語で表示", + "lang.notice.dismiss": "閉じる", "hero.kicker": "二つの質感。一つの意味体系。", "hero.subtitle": "温もりか、構造か。意味は明瞭なまま。Ember または Moss を選び、Dark と Light をライブ比較して、使う場所へインストールできます。", "hero.flow.preview": "テーマを確認", @@ -11,7 +16,7 @@ "hero.flow.more": "HearthCode を試すその他の方法", "hero.scope.label": "サンプル言語", "hero.divider.label": "ドラッグしてダークとライトを比較", - "hero.copy": "クリックしてコピー", + "hero.copy": "コピー", "hero.copied": "コピーしました", "hero.ignite.count": "{n} トークン", "hero.ignite.custom": "カラー方向", diff --git a/src/i18n/zh.json b/src/i18n/zh.json index e33b765..7a129a7 100644 --- a/src/i18n/zh.json +++ b/src/i18n/zh.json @@ -3,6 +3,11 @@ "nav.themes": "主题", "nav.forge": "Forge", "nav.install": "安装", + "nav.langSwitch": "语言", + "nav.skip": "跳到主要内容", + "lang.notice.body": "本页面提供简体中文版本。", + "lang.notice.action": "切换到简体中文", + "lang.notice.dismiss": "关闭", "hero.kicker": "两种质感,一套语义系统。", "hero.subtitle": "温暖或秩序,语义始终清晰。选择 Ember 或 Moss,实时比较 Dark 与 Light,再安装到你的工作平台。", "hero.flow.preview": "预览主题", @@ -11,7 +16,7 @@ "hero.flow.more": "更多体验 HearthCode 的方式", "hero.scope.label": "代码示例语言", "hero.divider.label": "拖动比较深浅两版", - "hero.copy": "点击复制", + "hero.copy": "复制", "hero.copied": "已复制", "hero.ignite.count": "{n} 个 token", "hero.ignite.custom": "色彩方向", diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 45ad19e..0033ac2 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -54,6 +54,25 @@ const langHref = (target: Lang) => const absoluteLangHref = (target: Lang) => new URL(langHref(target), siteBase).href; const canonicalUrl = new URL(url.pathname + url.search, siteBase).href; const defaultUrl = new URL(localizedPath(pageKind, "en"), siteBase).href; + +// Language switcher: each option is named in its own language, so the label is +// readable no matter which locale the page is rendered in. +const langOptions = [ + { code: "en" as Lang, short: "EN", name: "English" }, + { code: "zh" as Lang, short: "中", name: "简体中文" }, + { code: "ja" as Lang, short: "日", name: "日本語" }, +]; + +// Offered rather than forced: a browser that prefers zh/ja gets a dismissible +// notice on the English entry point instead of a redirect, so a shared English +// link still resolves to the English page. +const langNotices = (["zh", "ja"] as const).map((code) => ({ + code, + href: localizedPath(pageKind, code), + body: t(code, "lang.notice.body"), + action: t(code, "lang.notice.action"), + dismiss: t(code, "lang.notice.dismiss"), +})); const ogImage = new URL(productData.site.ogImagePath, siteBase).href; --- @@ -62,6 +81,11 @@ const ogImage = new URL(productData.site.ogImagePath, siteBase).href; + @@ -109,8 +133,10 @@ const ogImage = new URL(productData.site.ogImagePath, siteBase).href;